From cb72759b568baf34281bd15f821ac9834c17bbb9 Mon Sep 17 00:00:00 2001
From: ai_xiaopei <xiaopei@aisim.cn>
Date: Thu, 03 Sep 2026 14:24:29 +0800
Subject: [PATCH] merge: 合并 origin/master schema v2 重构(RWR/增量对账/explore),保留 tag/entity 实验于 experiment 分支 + 本地 expandPath 修复与 draft list 过滤
---
internal/index/migrations_test.go | 246 ++
cmd/search.go | 19
Makefile | 14
docs/superpowers/plans/2026-09-03-kb-cli-codegraph-alignment.md | 2455 +++++++++++++++++++++++++
internal/graph/rwr_test.go | 37
internal/index/sqlite_test.go | 64
scripts/e2e-verify.sh | 48
internal/search/explore.go | 170 +
cmd/rebuild.go | 45
internal/llm/client.go | 10
internal/search/engine_test.go | 111 +
docs/superpowers/specs/2026-09-03-kb-cli-codegraph-alignment-design.md | 236 ++
go.env | 2
internal/graph/builder.go | 118
internal/index/reconcile_test.go | 175 +
cmd/explore.go | 111 +
internal/graph/builder_test.go | 67
internal/index/sqlite.go | 181 +
.codegraph/.gitignore | 5
internal/graph/rwr.go | 106 +
internal/index/reconcile.go | 372 +++
internal/vault/parser.go | 5
internal/index/unresolved_test.go | 130 +
internal/search/expand.go | 39
internal/index/graphload.go | 50
internal/graph/model.go | 38
internal/index/fts.go | 151 +
internal/search/explore_test.go | 100 +
README.md | 89
internal/vault/scanner.go | 37
bin/kb-cli | 0
internal/index/fts_test.go | 78
internal/search/engine.go | 127 +
internal/index/cache.go | 32
internal/index/migrations.go | 134 +
cmd/index.go | 41
36 files changed, 5,338 insertions(+), 305 deletions(-)
diff --git a/.codegraph/.gitignore b/.codegraph/.gitignore
new file mode 100644
index 0000000..d20c0fe
--- /dev/null
+++ b/.codegraph/.gitignore
@@ -0,0 +1,5 @@
+# CodeGraph data files — local to each machine, not for committing.
+# Ignore everything in .codegraph/ except this file itself, so transient
+# files (the database, daemon.pid, sockets, logs) never show up in git.
+*
+!.gitignore
diff --git a/Makefile b/Makefile
index c772d23..f0daa47 100644
--- a/Makefile
+++ b/Makefile
@@ -1,20 +1,24 @@
-.PHONY: build test clean install
+# Makefile
+CGO_CFLAGS := -DSQLITE_ENABLE_FTS5
+CGO_LDFLAGS := -lm
+
+.PHONY: build test clean install test-v run-search run-index run-status
# 构建
build:
- CGO_ENABLED=1 go build -tags fts5 -o bin/kb-cli .
+ CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" go build -o bin/kb-cli .
# 安装到 ~/go/bin
install:
- CGO_ENABLED=1 go install -tags fts5 .
+ CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" go install .
# 测试
test:
- CGO_ENABLED=1 go test -tags fts5 ./...
+ CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" go test ./... 2>&1 | grep -v "no test files"
# 详细测试
test-v:
- CGO_ENABLED=1 go test -tags fts5 -v ./...
+ CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" go test -v ./... 2>&1 | grep -v "no test files"
# 清理
clean:
diff --git a/README.md b/README.md
index fb6dd96..6243763 100644
--- a/README.md
+++ b/README.md
@@ -7,8 +7,10 @@
- ✅ **Vault 解析器**:解析 Obsidian frontmatter、标签、实体、wikilinks
- ✅ **知识图谱构建**:文件 → 节点,标签/实体/wikilinks → 边
- ✅ **SQLite 存储**:高效持久化,支持增量更新
-- ✅ **FTS5 全文搜索**:基于 SQLite FTS5 的快速搜索
-- ✅ **图谱评分算法**:考虑标签、实体、wikilinks 权重
+- ✅ **FTS5 全文搜索**:基于 SQLite FTS5 的快速搜索(ASCII 走 FTS5、CJK 走 LIKE 双通道)
+- ✅ **图谱评分算法**:文本位置分 + RWR 随机游走图质量双信号加权
+- ✅ **长中文词召回**:>3 字符 CJK 词自动 bigram 展开
+- ✅ **explore 命令**:按字节预算直出原文段落(agent 一次性上下文)
- ✅ **多种输出格式**:表格、JSON
- ✅ **内容返回**:`--with-content` 返回完整文件内容
- ✅ **关联链接**:`--with-links` 返回 wikilink 关联文档
@@ -28,6 +30,16 @@
**注意**:需要 CGO 和 FTS5 支持。
+## 构建要求
+
+go-sqlite3 需要系统 SQLite 开启 FTS5,构建时必须显式传入 CGO 标志(`make build` 已内置):
+
+```bash
+CGO_CFLAGS="-DSQLITE_ENABLE_FTS5" CGO_LDFLAGS="-lm" go build
+# 或
+make build
+```
+
## 使用方法
### 搜索
@@ -58,11 +70,34 @@
kb-cli search 充装 --top 5
```
+**双通道检索**:ASCII 词走 FTS5,CJK 词走 LIKE(title/aliases/content/tags 四列)。FTS5 unicode61 把连续中文当整串单 token,多字符中文词 MATCH 匹配不到,必须走 LIKE。长中文词(>3 字符)自动做 bigram 滑动展开(如「电子秤补气失败」→ 电子/子秤/秤补/补气/气失/失败),bigram 命中按扩展词档计权。
+
+**排序双信号**:`最终分 = 文本位置分 × TextWeight + RWR 随机游走图质量 × (1-TextWeight)`(TextWeight 缺省 0.5,可配 config.yaml);草稿态/待审阅板块降权 0.6。
+
+### 探索(explore)
+
+面向 agent 的一次性上下文获取:按字节预算直出相关文档的**原文段落**(整段不截半句),并附关联清单(wikilinks)与悬空链接提示,agent 无需再读文件。
+
+```bash
+# 默认预算 16000 字节、Top 5 篇(均可配 config.yaml explore 节)
+kb-cli explore "电子秤补气失败"
+
+# 自定义预算与文档数
+kb-cli explore "补气失败" --budget 8000 --top 3
+
+# JSON 输出
+kb-cli explore "补气失败" --json
+```
+
+**与 search 的分工**:`search` 返回文档列表(路径/标题/板块/得分,供浏览定位);`explore` 直接按预算返回命中文档的原文段落(供 agent 一次性获取上下文)。
+
### 索引管理
```bash
-# 构建/重建索引
+# 构建索引:默认增量对账同步(按 mtime/size 比对,只处理新增/修改/删除的文件),
+# --force 全量重建
kb-cli index build
+kb-cli index build --force
# 查看索引状态
kb-cli index status
@@ -78,6 +113,21 @@
kb-cli git sync
```
+### 知识图谱
+
+```bash
+# 图谱统计(节点数、边数、关系类型分布)
+kb-cli graph stats
+
+# 查询节点的关联关系
+kb-cli graph query 补气
+
+# 查找与关键词相关的节点
+kb-cli graph related 补气 --top 10
+```
+
+**provenance 标注**:每条边带 `provenance` 字段标注关系来源类型:`tag`(frontmatter 标签)、`entity`(正文实体)、`exact`/`fuzzy`(wikilink 精确/模糊匹配)。`graph query` 的边数据(含 `--json` 输出)携带该字段;悬空补全的 wikilink 边按匹配方式标注 `exact`/`fuzzy`。`graph related` 只输出节点级信息(路径/板块/标签/关联度),不含边 provenance。
+
### 全局选项
```bash
@@ -92,8 +142,10 @@
├── cmd/ # CLI 命令
│ ├── root.go # 根命令
│ ├── search.go # search 命令
+│ ├── explore.go # explore 命令
│ ├── index.go # index 命令
-│ └── rebuild.go # 索引重建逻辑
+│ ├── graph.go # graph 命令
+│ └── rebuild.go # 索引构建逻辑
├── internal/
│ ├── vault/ # Vault 解析器
│ │ ├── parser.go
@@ -101,27 +153,38 @@
│ │ └── sections.go
│ ├── graph/ # 知识图谱
│ │ ├── model.go
-│ │ └── builder.go
+│ │ ├── builder.go
+│ │ └── rwr.go # RWR 随机游走图质量
│ ├── index/ # SQLite 存储
│ │ ├── sqlite.go
-│ │ ├── fts.go
-│ │ └── cache.go
+│ │ ├── fts.go # 双通道检索(FTS5 + LIKE)
+│ │ ├── reconcile.go # 增量对账
+│ │ ├── migrations.go # schema 迁移
+│ │ └── graphload.go # RWR 邻接加载
│ ├── search/ # 搜索引擎
│ │ ├── engine.go
+│ │ ├── explore.go # explore 段落直出
+│ │ ├── expand.go # CJK bigram 展开(search/explore 共享)
│ │ └── scorer.go
+│ ├── llm/ # LLM 配置
+│ ├── classify/ # 实体/标签分类
+│ ├── draft/ # 草稿入库
+│ ├── review/ # 草稿预览
│ └── output/ # 输出格式化
│ └── formatter.go
+├── scripts/
+│ └── e2e-verify.sh # 端到端验证脚本
└── main.go
```
## 评分算法
-搜索结果评分考虑:
-- **FTS5 rank**:全文搜索相关性
-- **标签匹配**:标签权重 2.0
-- **实体匹配**:实体权重 1.5
-- **Wikilinks**:引用关系权重 1.2
-- **扩展词加成**:提升相关实体权重
+搜索结果按**双信号加权**排序:
+
+- **文本位置分**:路径/标题/板块/别名/内容命中计权(位置权重不同,别名按 title 档;实体词降权 1/5,通用词降权 1/3)
+- **RWR 图质量**:随机游走(Personalized PageRank),种子 = 检索候选前 20,体现节点在知识图谱中的中心性
+- **合并**:`最终分 = 归一化文本分 × TextWeight + RWR × (1-TextWeight)`,TextWeight 缺省 0.5(config.yaml 可配)
+- **降权**:草稿态(草稿/待确认/跟进中)与「待审阅」板块 ×0.6
## 开发
diff --git a/bin/kb-cli b/bin/kb-cli
index 21b2b8c..9202959 100755
--- a/bin/kb-cli
+++ b/bin/kb-cli
Binary files differ
diff --git a/cmd/explore.go b/cmd/explore.go
new file mode 100644
index 0000000..eb808c3
--- /dev/null
+++ b/cmd/explore.go
@@ -0,0 +1,111 @@
+package cmd
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "strings"
+
+ "github.com/aisim/kb-cli/internal/index"
+ "github.com/aisim/kb-cli/internal/llm"
+ "github.com/aisim/kb-cli/internal/search"
+ "github.com/spf13/cobra"
+)
+
+var (
+ exploreBudget int
+ exploreTopN int
+ exploreJSON bool
+)
+
+var exploreCmd = &cobra.Command{
+ Use: "explore <问题>",
+ Short: "一次调用获取相关文档原文 + 关联清单(供 agent 使用)",
+ Long: `# explore - 精准上下文
+kb-cli explore <问题> [--budget 字节] [--top N] [--json] # 按字节预算返回相关文档原文、关联文档、悬空链接`,
+ Args: cobra.MinimumNArgs(1),
+ RunE: runExplore,
+}
+
+func init() {
+ rootCmd.AddCommand(exploreCmd)
+ exploreCmd.Flags().IntVar(&exploreBudget, "budget", 0, "字节预算(0=配置默认 16000)")
+ exploreCmd.Flags().IntVar(&exploreTopN, "top", 0, "文档数(0=配置默认 5)")
+ exploreCmd.Flags().BoolVar(&exploreJSON, "json", false, "JSON 输出")
+
+ // 参数不足时显示 help(与 search 命令同款模式)
+ exploreCmd.SetUsageTemplate(exploreCmd.Long)
+}
+
+func runExplore(cmd *cobra.Command, args []string) error {
+ // 打开索引
+ store, err := index.Open(dbPath)
+ if err != nil {
+ return fmt.Errorf("打开索引失败: %w", err)
+ }
+ defer store.Close()
+
+ // pre-flight:快速检查索引是否有变更(只 stat 比对,不读内容)
+ dirty, err := index.QuickCheck(store, vaultPath)
+ if err != nil {
+ return fmt.Errorf("检查索引状态失败: %w", err)
+ }
+
+ if dirty {
+ fmt.Fprintln(os.Stderr, "索引有变更,正在增量同步...")
+ if err := syncIndex(store); err != nil {
+ return fmt.Errorf("增量同步失败: %w", err)
+ }
+ }
+
+ // 关键词按词拆分(与 search 一致):长复合词走 LIKE 整串匹配,文档里
+ // "电子秤补气失败" 通常不是连续子串,拆成 电子秤/补气失败 才能命中
+ var allKeywords []string
+ for _, arg := range args {
+ allKeywords = append(allKeywords, strings.Fields(arg)...)
+ }
+ if len(allKeywords) == 0 {
+ return fmt.Errorf("请提供至少一个关键词")
+ }
+
+ // 默认值优先读配置(config.yaml 的 explore 节),读不到再用代码缺省
+ budget := exploreBudget
+ topN := exploreTopN
+ cfg := llm.LoadConfig()
+ if budget <= 0 && cfg.Explore.DefaultBudget > 0 {
+ budget = cfg.Explore.DefaultBudget
+ }
+ if topN <= 0 && cfg.Explore.TopN > 0 {
+ topN = cfg.Explore.TopN
+ }
+ hardBudget := cfg.Explore.HardBudget // 0 = Explore 内部用缺省 32000
+
+ res, err := search.Explore(store, allKeywords, search.ExploreOptions{Budget: budget, TopN: topN, HardBudget: hardBudget})
+ if err != nil {
+ return fmt.Errorf("explore 失败: %w", err)
+ }
+ if len(res.Docs) == 0 {
+ fmt.Println("未找到相关文档")
+ return nil
+ }
+
+ if exploreJSON {
+ b, _ := json.MarshalIndent(res, "", " ")
+ fmt.Println(string(b))
+ return nil
+ }
+ for _, d := range res.Docs {
+ fmt.Printf("## %s(%s,score %d)\n", d.Title, d.Path, d.Score)
+ fmt.Print(d.Body)
+ fmt.Println()
+ if rel, ok := res.Related[d.Path]; ok && len(rel) > 0 {
+ fmt.Printf("关联: %s\n", strings.Join(rel, ", "))
+ }
+ fmt.Println()
+ }
+ if len(res.UnresolvedLinks) > 0 {
+ fmt.Printf("⚠️ 悬空链接: %s\n", strings.Join(res.UnresolvedLinks, ", "))
+ }
+ fmt.Println("以上为文档原文直出,agent 无需再读文件")
+ return nil
+}
diff --git a/cmd/index.go b/cmd/index.go
index 63ecbe2..4678803 100644
--- a/cmd/index.go
+++ b/cmd/index.go
@@ -17,10 +17,13 @@
Short: "索引管理",
}
+// forceRebuild index build 的 --force flag:全量重建索引
+var forceRebuild bool
+
var indexBuildCmd = &cobra.Command{
Use: "build",
Short: "构建或重建知识库索引",
- Long: `kb-cli index build [--vault <路径>] [--db <路径>] # 构建或重建知识库索引`,
+ Long: `kb-cli index build [--vault <路径>] [--db <路径>] [--force] # 构建或重建知识库索引(默认增量对账,--force 全量重建)`,
RunE: runIndexBuild,
}
@@ -55,6 +58,7 @@
indexCmd.AddCommand(indexBuildCmd)
indexCmd.AddCommand(indexStatusCmd)
indexCmd.AddCommand(indexGcCmd)
+ indexBuildCmd.Flags().BoolVar(&forceRebuild, "force", false, "全量重建索引(默认走增量对账)")
rootCmd.AddCommand(gitCmd)
gitCmd.AddCommand(gitSyncCmd)
@@ -68,19 +72,26 @@
}
defer store.Close()
- // 获取当前 commit
- commit, err := index.GetGitCommit(vaultPath)
- if err != nil {
- fmt.Fprintln(os.Stderr, "警告: 无法获取 git commit:", err)
- commit = ""
+ if forceRebuild {
+ // 全量重建
+ commit, err := index.GetGitCommit(vaultPath)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "警告: 无法获取 git commit:", err)
+ commit = ""
+ }
+ fmt.Fprintln(os.Stderr, "正在全量重建索引...")
+ if err := rebuildFull(store, commit); err != nil {
+ return fmt.Errorf("重建索引失败: %w", err)
+ }
+ fmt.Fprintln(os.Stderr, "索引重建完成")
+ return nil
}
- fmt.Fprintln(os.Stderr, "正在重建索引...")
- if err := rebuildIndex(store, commit); err != nil {
- return fmt.Errorf("重建索引失败: %w", err)
+ // 默认走增量对账
+ if err := syncIndex(store); err != nil {
+ return fmt.Errorf("增量同步失败: %w", err)
}
-
- fmt.Fprintln(os.Stderr, "索引重建完成")
+ fmt.Fprintln(os.Stderr, "索引同步完成")
return nil
}
@@ -127,14 +138,14 @@
fmt.Printf(" 当前 commit: %s\n", currentCommit)
fmt.Printf(" 构建时间: %s\n", builtAt)
- // 检查是否需要更新
- needsRebuild, _, err := index.NeedsRebuild(store, vaultPath)
+ // 检查是否有变更(stat 快速比对)
+ dirty, err := index.QuickCheck(store, vaultPath)
if err != nil {
return fmt.Errorf("检查索引状态失败: %w", err)
}
- if needsRebuild {
- fmt.Printf(" 状态: 需要更新\n")
+ if dirty {
+ fmt.Printf(" 状态: 有变更\n")
} else {
fmt.Printf(" 状态: 最新\n")
}
diff --git a/cmd/rebuild.go b/cmd/rebuild.go
index dfff961..47684dd 100644
--- a/cmd/rebuild.go
+++ b/cmd/rebuild.go
@@ -10,8 +10,8 @@
"github.com/aisim/kb-cli/internal/vault"
)
-// rebuildIndex 重建索引:扫描 vault → 构建图 → 写入 SQLite
-func rebuildIndex(store *index.Store, commit string) error {
+// rebuildFull 全量重建索引:扫描 vault → 构建图 → 写入 SQLite
+func rebuildFull(store *index.Store, commit string) error {
// 清空旧数据
if err := store.ClearData(); err != nil {
return fmt.Errorf("清空数据失败: %w", err)
@@ -26,7 +26,7 @@
fmt.Fprintf(os.Stderr, "扫描到 %d 个文件\n", len(files))
// 构建图
- g := graph.BuildGraph(files)
+ g, unresolved := graph.BuildGraph(files)
// 写入节点,并记录 BuildGraph ID -> SQLite ID 的映射
idMap := make(map[int64]int64) // BuildGraph ID -> SQLite ID
@@ -52,10 +52,11 @@
continue
}
edge := &graph.Edge{
- FromNode: fromID,
- ToNode: toID,
- Relation: e.Relation,
- Label: e.Label,
+ FromNode: fromID,
+ ToNode: toID,
+ Relation: e.Relation,
+ Label: e.Label,
+ Provenance: e.Provenance,
}
if err := store.InsertEdge(edge); err != nil {
return fmt.Errorf("插入边失败: %w", err)
@@ -63,6 +64,17 @@
actualEdgeCount++
}
+ // 写入悬空链接(BuildGraph 内部 ID 映射为 SQLite ID)
+ for _, u := range unresolved {
+ fromID, ok := idMap[u.FromNode]
+ if !ok {
+ continue
+ }
+ if err := store.InsertUnresolved(fromID, u.LinkText, u.NameTail); err != nil {
+ return fmt.Errorf("写入悬空链接失败 [%s]: %w", u.LinkText, err)
+ }
+ }
+
// 创建并填充 FTS5 索引
if err := store.CreateFTS(); err != nil {
return fmt.Errorf("创建 FTS 索引失败: %w", err)
@@ -84,3 +96,22 @@
fmt.Fprintf(os.Stderr, "已索引 %d 个节点, %d 条边\n", len(g.Nodes), actualEdgeCount)
return nil
}
+
+// syncIndex 增量对账路径(index build 默认、search pre-flight 用)
+func syncIndex(store *index.Store) error {
+ res, err := index.Reconcile(store, vaultPath)
+ if err != nil {
+ return fmt.Errorf("增量同步失败: %w", err)
+ }
+ fmt.Fprintf(os.Stderr, "增量同步: 新增 %d / 修改 %d / 删除 %d / 未变 %d\n",
+ res.Added, res.Modified, res.Deleted, res.Unchanged)
+ if commit, err := index.GetGitCommit(vaultPath); err == nil {
+ if err := store.SetMeta("git_commit", commit); err != nil {
+ return fmt.Errorf("记录 git commit 失败: %w", err)
+ }
+ }
+ if err := store.SetMeta("built_at", time.Now().Format(time.RFC3339)); err != nil {
+ return fmt.Errorf("记录构建时间失败: %w", err)
+ }
+ return nil
+}
diff --git a/cmd/search.go b/cmd/search.go
index e7e31a1..379d3cf 100644
--- a/cmd/search.go
+++ b/cmd/search.go
@@ -7,6 +7,7 @@
"github.com/aisim/kb-cli/internal/index"
"github.com/aisim/kb-cli/internal/search"
+ "github.com/aisim/kb-cli/internal/llm"
"github.com/aisim/kb-cli/internal/output"
"github.com/spf13/cobra"
)
@@ -50,16 +51,16 @@
}
defer store.Close()
- // 检查是否需要重建索引
- needsRebuild, commit, err := index.NeedsRebuild(store, vaultPath)
+ // pre-flight:快速检查索引是否有变更(只 stat 比对,不读内容)
+ dirty, err := index.QuickCheck(store, vaultPath)
if err != nil {
return fmt.Errorf("检查索引状态失败: %w", err)
}
- if needsRebuild {
- fmt.Fprintln(os.Stderr, "索引过期,正在重建...")
- if err := rebuildIndex(store, commit); err != nil {
- return fmt.Errorf("重建索引失败: %w", err)
+ if dirty {
+ fmt.Fprintln(os.Stderr, "索引有变更,正在增量同步...")
+ if err := syncIndex(store); err != nil {
+ return fmt.Errorf("增量同步失败: %w", err)
}
}
@@ -76,12 +77,18 @@
}
// 执行搜索
+ cfg := llm.LoadConfig()
+ textWeight := cfg.Search.TextWeight
+ if textWeight <= 0 || textWeight > 1 {
+ textWeight = 0.5 // 缺省 0.5
+ }
opts := search.SearchOptions{
Expanded: expanded,
Symptom: symptom,
TopN: topN,
WithContent: withContent,
WithLinks: withLinks,
+ TextWeight: textWeight,
}
results, err := search.Search(store, allKeywords, opts)
diff --git a/docs/superpowers/plans/2026-09-03-kb-cli-codegraph-alignment.md b/docs/superpowers/plans/2026-09-03-kb-cli-codegraph-alignment.md
new file mode 100644
index 0000000..63e1163
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-03-kb-cli-codegraph-alignment.md
@@ -0,0 +1,2455 @@
+# kb-cli 对标 CodeGraph 增强 实施计划
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 按 spec `docs/superpowers/specs/2026-09-03-kb-cli-codegraph-alignment-design.md` 实现 kb-cli 的 RWR 图排序、CJK 混合检索、增量同步、悬空双链自动补全、explore 命令。
+
+**Architecture:** SQLite(WAL) 单库存储,schema v2 迁移加列(aliases/status/provenance/文件指纹);FTS5 改 external-content + 触发器;搜索 = FTS(ASCII 词) + LIKE(CJK 词) 双通道 → RWR 图质量 → min-max 双信号加权;索引改为 (size, mtime, sha256) 对账增量同步;wikilink 解析失败入 unresolved_links 表,新节点入库时自动重试。
+
+**Tech Stack:** Go 1.22.2, mattn/go-sqlite3 (FTS5), cobra, yaml.v3
+
+## Global Constraints
+
+- **构建标志(铁律)**:`CGO_CFLAGS="-DSQLITE_ENABLE_FTS5" CGO_LDFLAGS="-lm"`——本机 gcc 下 go-sqlite3 自带 amalgamation 链接 libm 失败导致 FTS5 编译失败;系统 libsqlite3 (3.45.1) 原生含 FTS5,加此两参数后全部测试通过。已存于 `go.env`,Makefile 必须固化
+- 不新增第三方依赖(stdlib + go.mod 现有依赖)
+- 注释/错误信息用中文,与现有代码风格一致
+- 每个任务 TDD:先写失败测试 → 跑失败 → 最小实现 → 跑通过 → commit
+- 测试命令模板:`CGO_CFLAGS="-DSQLITE_ENABLE_FTS5" CGO_LDFLAGS="-lm" go test ./... 2>&1 | grep -v "no test files"`
+- 现有代码约定:`nodes.path` UNIQUE;虚拟节点(tag/entity)ID 从 1000000 起;边表 `UNIQUE(from_node, to_node, relation, label)`
+- spec 全文在 `docs/superpowers/specs/2026-09-03-kb-cli-codegraph-alignment-design.md`,本计划与其冲突时以 spec 为准
+
+**实测背景(计划依据):**
+- 真实库 `~/rag-lpg-obsidian`(458 个 .md)上 `search 补气` 只返回 1 条——FTS5 unicode61 把连续中文当整串单 token,`MATCH '补气'` 匹配不到"电子秤补气失败";而 `LIKE '%补气%'` 命中 16 条 content + 1 条 title。**CJK 词必须走 LIKE 通道**
+- trigram tokenizer 实测:多字符 CJK 词部分可用("红绿闪"命中)但单字符("秤")不命中,且改变现有 FTS 行为面太大——不采用,保持 unicode61
+
+---
+
+### Task 1: 构建环境固化 + Schema v2 迁移
+
+**Files:**
+- Create: `internal/index/migrations.go`
+- Create: `internal/index/migrations_test.go`
+- Modify: `internal/index/sqlite.go:48-87`(initTables)
+- Modify: `internal/index/fts.go:18-37`(CreateFTS/PopulateFTS)
+- Create: `Makefile`
+- Modify: `go.env`(已存在,确认内容)
+
+**Interfaces:**
+- Consumes: 现有 `Store.Open`
+- Produces: `func (s *Store) migrate() error`(Open 时自动调用);v2 后 `nodes` 表含列:`aliases TEXT, status TEXT, size INTEGER, mtime INTEGER, content_hash TEXT`;`edges` 表含列 `provenance TEXT`;新表 `unresolved_links(id, from_node, link_text, name_tail, status, created_at, last_attempt)` 与 `schema_versions`;`nodes_fts` 为 external-content 虚拟表(title, content, tags, aliases)带增删改触发器
+
+- [ ] **Step 1: 固化构建标志(Makefile)**
+
+```makefile
+# Makefile
+CGO_CFLAGS := -DSQLITE_ENABLE_FTS5
+CGO_LDFLAGS := -lm
+
+.PHONY: build test clean
+
+build:
+ CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" go build -o kb-cli .
+
+test:
+ CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" go test ./... 2>&1 | grep -v "no test files"
+
+clean:
+ rm -f kb-cli
+```
+
+- [ ] **Step 2: 写迁移失败测试**
+
+`internal/index/migrations_test.go`:
+
+```go
+package index
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+// newV1Store 手工构建一个 v1 schema 的老库(模拟迁移前状态)
+func newV1Store(t *testing.T) (*Store, string) {
+ t.Helper()
+ dbPath := filepath.Join(t.TempDir(), "kb-v1.db")
+ db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL")
+ if err != nil {
+ t.Fatal(err)
+ }
+ schema := `
+ CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
+ CREATE TABLE nodes (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ path TEXT NOT NULL UNIQUE,
+ title TEXT, section TEXT, tags TEXT, entities TEXT, wikilinks TEXT,
+ content_fts TEXT,
+ created_at TEXT DEFAULT (datetime('now')),
+ updated_at TEXT DEFAULT (datetime('now')));
+ CREATE TABLE edges (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ from_node INTEGER NOT NULL, to_node INTEGER NOT NULL,
+ relation TEXT NOT NULL, label TEXT,
+ UNIQUE(from_node, to_node, relation, label));
+ `
+ if _, err := db.Exec(schema); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := db.Exec(`INSERT INTO nodes (path, title, tags, entities, wikilinks, content_fts)
+ VALUES ('FAQ/001-测试.md', '测试', '["a"]', '[]', '[]', '内容')`); err != nil {
+ t.Fatal(err)
+ }
+ return &Store{db: db}, dbPath
+}
+
+func TestMigrateV1ToV2(t *testing.T) {
+ store, dbPath := newV1Store(t)
+ defer store.Close()
+
+ // 迁移前备份文件必须存在
+ if err := store.migrate(); err != nil {
+ t.Fatalf("migrate: %v", err)
+ }
+ if _, err := os.Stat(dbPath + ".bak"); err != nil {
+ t.Fatalf("备份文件不存在: %v", err)
+ }
+
+ // v2 列必须存在
+ cols := store.nodeColumns(t)
+ for _, want := range []string{"aliases", "status", "size", "mtime", "content_hash"} {
+ if !cols[want] {
+ t.Errorf("nodes 缺列 %s", want)
+ }
+ }
+ if !store.edgeHasProvenance(t) {
+ t.Error("edges 缺 provenance 列")
+ }
+ if !store.tableExists(t, "unresolved_links") {
+ t.Error("缺 unresolved_links 表")
+ }
+
+ // 数据保留
+ var title string
+ if err := store.db.QueryRow(`SELECT title FROM nodes WHERE path='FAQ/001-测试.md'`).Scan(&title); err != nil {
+ t.Fatal(err)
+ }
+ if title != "测试" {
+ t.Errorf("迁移丢数据: %s", title)
+ }
+}
+
+func TestMigrateIdempotent(t *testing.T) {
+ store, _ := newV1Store(t)
+ defer store.Close()
+ if err := store.migrate(); err != nil {
+ t.Fatalf("第一次: %v", err)
+ }
+ if err := store.migrate(); err != nil {
+ t.Fatalf("第二次(幂等): %v", err)
+ }
+}
+
+func TestNewDBIsV2(t *testing.T) {
+ dbPath := filepath.Join(t.TempDir(), "kb-v2.db")
+ store, err := Open(dbPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer store.Close()
+ if !store.edgeHasProvenance(t) {
+ t.Error("新建库应为 v2 schema")
+ }
+}
+
+// 测试辅助:列检查
+func (s *Store) nodeColumns(t *testing.T) map[string]bool {
+ t.Helper()
+ rows, err := s.db.Query(`SELECT name FROM pragma_table_info('nodes')`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer rows.Close()
+ m := map[string]bool{}
+ for rows.Next() {
+ var name string
+ rows.Scan(&name)
+ m[name] = true
+ }
+ return m
+}
+
+func (s *Store) edgeHasProvenance(t *testing.T) bool {
+ t.Helper()
+ var n int
+ s.db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('edges') WHERE name='provenance'`).Scan(&n)
+ return n > 0
+}
+
+func (s *Store) tableExists(t *testing.T, name string) bool {
+ t.Helper()
+ var n int
+ s.db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?`, name).Scan(&n)
+ return n > 0
+}
+```
+
+注意:测试文件顶部需 `import "database/sql"`。
+
+- [ ] **Step 3: 跑测试确认失败**
+
+Run: `CGO_CFLAGS="-DSQLITE_ENABLE_FTS5" CGO_LDFLAGS="-lm" go test ./internal/index/ -run TestMigrate -v`
+Expected: FAIL — `store.migrate undefined`
+
+- [ ] **Step 4: 实现 migrations.go**
+
+`internal/index/migrations.go`:
+
+```go
+package index
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+)
+
+const currentSchemaVersion = 2
+
+// migrate 幂等的 schema 升级:v1 → v2。
+// v2 变更:nodes 加 aliases/status/size/mtime/content_hash 列;
+// edges 加 provenance 列;新建 unresolved_links、schema_versions 表;
+// nodes_fts 改 external-content + 触发器。迁移前自动备份 .db 文件。
+func (s *Store) migrate() error {
+ // 读当前版本
+ var version int
+ err := s.db.QueryRow(`SELECT value FROM schema_versions WHERE key='version'`).Scan(&version)
+ if err != nil && version == 0 {
+ // 首次:schema_versions 表可能不存在
+ version = 1
+ }
+ if version >= currentSchemaVersion {
+ return nil
+ }
+
+ // 备份
+ if err := s.backupDB(); err != nil {
+ return fmt.Errorf("备份失败: %w", err)
+ }
+
+ // v1 → v2
+ if err := s.migrateV1toV2(); err != nil {
+ return fmt.Errorf("v1→v2 迁移失败: %w", err)
+ }
+ return nil
+}
+
+func (s *Store) backupDB() error {
+ // dbPath 从 DSN 提取:DSN 形如 <path>?_journal_mode=WAL
+ for _, row := range s.db.QueryRow(`PRAGMA database_list`).Rows() {
+ }
+ // 简化:Open 时把 dbPath 存到 Store
+ if s.dbPath == "" {
+ return nil // 无路径信息时跳过备份(测试场景)
+ }
+ dst := s.dbPath + ".bak"
+ if _, err := os.Stat(dst); err == nil {
+ return nil // 已有备份
+ }
+ data, err := os.ReadFile(s.dbPath)
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(dst, data, 0644)
+}
+
+func (s *Store) migrateV1toV2() error {
+ // 1. schema_versions 表
+ if _, err := s.db.Exec(`CREATE TABLE IF NOT EXISTS schema_versions (
+ key TEXT PRIMARY KEY, value TEXT NOT NULL)`); err != nil {
+ return err
+ }
+ if _, err := s.db.Exec(`INSERT OR REPLACE INTO schema_versions VALUES ('version', '2')`); err != nil {
+ return err
+ }
+
+ // 2. nodes 加列(逐列检查,幂等)
+ for _, col := range []struct{ name, def string }{
+ {"aliases", "ALTER TABLE nodes ADD COLUMN aliases TEXT"},
+ {"status", "ALTER TABLE nodes ADD COLUMN status TEXT"},
+ {"size", "ALTER TABLE nodes ADD COLUMN size INTEGER"},
+ {"mtime", "ALTER TABLE nodes ADD COLUMN mtime INTEGER"},
+ {"content_hash", "ALTER TABLE nodes ADD COLUMN content_hash TEXT"},
+ } {
+ if !s.nodeColumnExists(col.name) {
+ if _, err := s.db.Exec(col.def); err != nil {
+ return fmt.Errorf("加列 %s 失败: %w", col.name, err)
+ }
+ }
+ }
+
+ // 3. edges 加 provenance 列
+ if !s.edgeColumnExists("provenance") {
+ if _, err := s.db.Exec(`ALTER TABLE edges ADD COLUMN provenance TEXT`); err != nil {
+ return fmt.Errorf("edges 加 provenance 失败: %w", err)
+ }
+ }
+
+ // 4. unresolved_links 表
+ if _, err := s.db.Exec(`CREATE TABLE IF NOT EXISTS unresolved_links (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ from_node INTEGER NOT NULL,
+ link_text TEXT NOT NULL,
+ name_tail TEXT NOT NULL,
+ status TEXT NOT NULL DEFAULT 'pending',
+ created_at TEXT DEFAULT (datetime('now')),
+ last_attempt TEXT)`); err != nil {
+ return err
+ }
+ if _, err := s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_unresolved_tail
+ ON unresolved_links(name_tail) WHERE status='failed'`); err != nil {
+ return err
+ }
+
+ // 5. FTS 改 external-content:删旧表重建 + 触发器 + 重灌
+ if _, err := s.db.Exec(`DROP TABLE IF EXISTS nodes_fts`); err != nil {
+ return err
+ }
+ if err := s.CreateFTS(); err != nil {
+ return err
+ }
+ return s.PopulateFTS()
+}
+
+// nodeColumnExists / edgeColumnExists 幂等检查
+func (s *Store) nodeColumnExists(name string) bool {
+ var n int
+ s.db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('nodes') WHERE name=?`, name).Scan(&n)
+ return n > 0
+}
+
+func (s *Store) edgeColumnExists(name string) bool {
+ var n int
+ s.db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('edges') WHERE name=?`, name).Scan(&n)
+ return n > 0
+}
+```
+
+- [ ] **Step 5: 改 Store 结构存 dbPath + Open 调 migrate**
+
+`internal/index/sqlite.go` 修改:
+
+```go
+// Store SQLite 存储层
+type Store struct {
+ db *sql.DB
+ dbPath string
+}
+
+func Open(dbPath string) (*Store, error) {
+ dir := filepath.Dir(dbPath)
+ if err := os.MkdirAll(dir, 0755); err != nil {
+ return nil, fmt.Errorf("创建目录失败: %w", err)
+ }
+ db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL")
+ if err != nil {
+ return nil, fmt.Errorf("打开数据库失败: %w", err)
+ }
+ s := &Store{db: db, dbPath: dbPath}
+ if err := s.initTables(); err != nil {
+ db.Close()
+ return nil, err
+ }
+ if err := s.migrate(); err != nil {
+ db.Close()
+ return nil, err
+ }
+ return s, nil
+}
+```
+
+`initTables` 的 v1 建表语句保留(全新库先建 v1 再被 migrate 升到 v2,幂等)。
+
+- [ ] **Step 6: 改 fts.go 为 external-content + 触发器**
+
+`internal/index/fts.go` 替换 CreateFTS/PopulateFTS:
+
+```go
+// CreateFTS 创建 FTS5 虚拟表(external-content 模式,由触发器增量维护)
+func (s *Store) CreateFTS() error {
+ _, err := s.db.Exec(`
+ CREATE VIRTUAL TABLE IF NOT EXISTS nodes_fts USING fts5(
+ title, content, tags, aliases,
+ content='nodes', content_rowid='id'
+ )`)
+ if err != nil {
+ return err
+ }
+ // 触发器:nodes 的增删改同步维护 FTS
+ triggers := []string{
+ `CREATE TRIGGER IF NOT EXISTS nodes_fts_ai AFTER INSERT ON nodes BEGIN
+ INSERT INTO nodes_fts(rowid, title, content, tags, aliases)
+ VALUES (new.id, new.title, new.content_fts, new.tags, new.aliases);
+ END`,
+ `CREATE TRIGGER IF NOT EXISTS nodes_fts_ad AFTER DELETE ON nodes BEGIN
+ INSERT INTO nodes_fts(nodes_fts, rowid, title, content, tags, aliases)
+ VALUES ('delete', old.id, old.title, old.content_fts, old.tags, old.aliases);
+ END`,
+ `CREATE TRIGGER IF NOT EXISTS nodes_fts_au AFTER UPDATE ON nodes BEGIN
+ INSERT INTO nodes_fts(nodes_fts, rowid, title, content, tags, aliases)
+ VALUES ('delete', old.id, old.title, old.content_fts, old.tags, old.aliases);
+ INSERT INTO nodes_fts(rowid, title, content, tags, aliases)
+ VALUES (new.id, new.title, new.content_fts, new.tags, new.aliases);
+ END`,
+ }
+ for _, t := range triggers {
+ if _, err := s.db.Exec(t); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// PopulateFTS 全量重灌 FTS(external-content 模式专用语法)
+func (s *Store) PopulateFTS() error {
+ _, err := s.db.Exec(`INSERT INTO nodes_fts(nodes_fts) VALUES('rebuild')`)
+ return err
+}
+```
+
+- [ ] **Step 7: 全量测试**
+
+Run: `CGO_CFLAGS="-DSQLITE_ENABLE_FTS5" CGO_LDFLAGS="-lm" go test ./... 2>&1 | grep -v "no test files"`
+Expected: 全部 PASS(含迁移测试 3 个 + 既有 FTS/search 测试)
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add Makefile go.env internal/index/migrations.go internal/index/migrations_test.go internal/index/sqlite.go internal/index/fts.go
+git commit -m "feat: schema v2 迁移(aliases/status/文件指纹/provenance/unresolved_links + FTS external-content)+ Makefile 固化 FTS5 构建标志"
+```
+
+---
+
+### Task 2: 增量对账(reconcile)
+
+**Files:**
+- Create: `internal/index/reconcile.go`
+- Create: `internal/index/reconcile_test.go`
+- Modify: `internal/vault/scanner.go`(新增 ScanVaultStat 只 stat 不解析)
+- Modify: `internal/vault/parser.go`(Frontmatter 加 Status 字段)
+- Modify: `internal/graph/model.go`(Node 加 Status/Aliases/Size/Mtime/ContentHash 字段)
+- Modify: `internal/index/sqlite.go`(InsertNode 写新列 + UpsertNode + DeleteNode)
+- Modify: `cmd/rebuild.go`(rebuildIndex 改走对账路径 + --force 全量)
+- Modify: `cmd/index.go`(index build 默认增量,加 --force flag;index gc 保留)
+- Delete: `internal/index/cache.go` 中 `NeedsRebuild` 函数(保留 GetGitCommit)
+
+**Interfaces:**
+- Consumes: Task 1 的 v2 schema(nodes 新列)
+- Produces:
+ - `func Reconcile(store *Store, vaultPath string) (*ReconcileResult, error)`
+ - `type ReconcileResult struct { Added, Modified, Deleted, Unchanged int }`
+ - `func QuickCheck(store *Store, vaultPath string) (bool, error)`(只 stat,返回"是否有差异")
+ - `func (s *Store) UpsertNode(n *graph.Node) error`(按 path INSERT OR REPLACE,触发器自动维护 FTS)
+ - `func (s *Store) DeleteNode(path string) error`
+ - `func (s *Store) GetFileStats() (map[string]FileStat, error)`;`type FileStat struct { Path string; Size int64; Mtime int64; ContentHash string }`
+
+- [ ] **Step 1: vault 层——Status 提取 + ScanVaultStat**
+
+`internal/vault/parser.go` Frontmatter 加字段 + ParseFile 赋值:
+
+```go
+type Frontmatter struct {
+ Title string `yaml:"title"`
+ Tags []string `yaml:"tags"`
+ Entities []string `yaml:"entities"`
+ Aliases []string `yaml:"aliases"`
+ Status string `yaml:"status"`
+}
+```
+ParseFile 中解析成功后加:`meta.Status = fm.Status`;FileMeta 加 `Status string` 与 `Aliases []string` 字段(Aliases = fm.Aliases)。
+
+`internal/vault/scanner.go` 加(不改 ScanVault):
+
+```go
+// FileStat 文件指纹(只 stat,不读内容)
+type FileStat struct {
+ Path string // 相对路径
+ Size int64
+ Mtime int64 // Unix 秒
+}
+
+// ScanVaultStat 扫描 .md 文件指纹清单(跳过隐藏目录和待审阅目录,与 ScanVault 一致)
+func ScanVaultStat(vaultPath string) ([]FileStat, error) {
+ var stats []FileStat
+ err := filepath.Walk(vaultPath, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return nil
+ }
+ if info.IsDir() {
+ if strings.HasPrefix(info.Name(), ".") && info.Name() != "." {
+ return filepath.SkipDir
+ }
+ if info.Name() == "待审阅" {
+ return filepath.SkipDir
+ }
+ return nil
+ }
+ if !strings.HasSuffix(path, ".md") {
+ return nil
+ }
+ relPath, _ := filepath.Rel(vaultPath, path)
+ stats = append(stats, FileStat{
+ Path: relPath,
+ Size: info.Size(),
+ Mtime: info.ModTime().Unix(),
+ })
+ return nil
+ })
+ return stats, err
+}
+```
+
+- [ ] **Step 2: 写对账失败测试**
+
+`internal/index/reconcile_test.go`:
+
+```go
+package index
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/aisim/kb-cli/internal/graph"
+ "github.com/aisim/kb-cli/internal/vault"
+)
+
+// setupVault 造一个含 1 个 .md 的临时 vault
+func setupVault(t *testing.T) string {
+ t.Helper()
+ dir := t.TempDir()
+ content := "---\ntitle: 测试文档\ntags: [t1]\n---\n正文提到 [[目标文档]] 和关键词补气\n"
+ if err := os.WriteFile(filepath.Join(dir, "FAQ", "001-测试文档.md"), []byte(content), 0644); err != nil {
+ // FAQ 目录需先建
+ }
+ return dir
+}
+
+func TestReconcileAdd(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.MkdirAll(filepath.Join(dir, "FAQ"), 0755); err != nil {
+ t.Fatal(err)
+ }
+ content := "---\ntitle: 测试文档\ntags: [t1]\nstatus: 已解决\n---\n正文\n"
+ os.WriteFile(filepath.Join(dir, "FAQ", "001-测试文档.md"), []byte(content), 0644)
+
+ store, _ := Open(filepath.Join(t.TempDir(), "kb.db"))
+ defer store.Close()
+
+ res, err := Reconcile(store, dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if res.Added != 1 || res.Unchanged != 0 {
+ t.Errorf("新增场景: %+v", res)
+ }
+ var title, status string
+ store.db.QueryRow(`SELECT title, status FROM nodes WHERE path='FAQ/001-测试文档.md'`).Scan(&title, &status)
+ if title != "测试文档" || status != "已解决" {
+ t.Errorf("节点字段错误: %s / %s", title, status)
+ }
+}
+
+func TestReconcileModify(t *testing.T) {
+ dir := t.TempDir()
+ os.MkdirAll(filepath.Join(dir, "FAQ"), 0755)
+ p := filepath.Join(dir, "FAQ", "001-测试文档.md")
+ os.WriteFile(p, []byte("---\ntitle: 旧标题\n---\n旧内容\n"), 0644)
+
+ store, _ := Open(filepath.Join(t.TempDir(), "kb.db"))
+ defer store.Close()
+ Reconcile(store, dir)
+
+ // 修改内容(title 变新标题,内容加"补气")
+ time.Sleep(1100 * time.Millisecond) // mtime 秒级精度,确保 mtime 变化
+ os.WriteFile(p, []byte("---\ntitle: 新标题\n---\n新内容补气\n"), 0644)
+
+ res, err := Reconcile(store, dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if res.Modified != 1 || res.Added != 0 {
+ t.Errorf("修改场景: %+v", res)
+ }
+ var title string
+ store.db.QueryRow(`SELECT title FROM nodes WHERE path='FAQ/001-测试文档.md'`).Scan(&title)
+ if title != "新标题" {
+ t.Errorf("标题未更新: %s", title)
+ }
+}
+
+func TestReconcileDelete(t *testing.T) {
+ dir := t.TempDir()
+ os.MkdirAll(filepath.Join(dir, "FAQ"), 0755)
+ p := filepath.Join(dir, "FAQ", "001-测试文档.md")
+ os.WriteFile(p, []byte("---\ntitle: 测试文档\n---\n内容\n"), 0644)
+
+ store, _ := Open(filepath.Join(t.TempDir(), "kb.db"))
+ defer store.Close()
+ Reconcile(store, dir)
+ os.Remove(p)
+
+ res, err := Reconcile(store, dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if res.Deleted != 1 {
+ t.Errorf("删除场景: %+v", res)
+ }
+ var n int
+ store.db.QueryRow(`SELECT COUNT(*) FROM nodes`).Scan(&n)
+ if n != 0 {
+ t.Errorf("节点未删净: %d", n)
+ }
+}
+
+func TestReconcileUnchanged(t *testing.T) {
+ dir := t.TempDir()
+ os.MkdirAll(filepath.Join(dir, "FAQ"), 0755)
+ os.WriteFile(filepath.Join(dir, "FAQ", "001-测试文档.md"),
+ []byte("---\ntitle: 测试文档\n---\n内容\n"), 0644)
+
+ store, _ := Open(filepath.Join(t.TempDir(), "kb.db"))
+ defer store.Close()
+ Reconcile(store, dir)
+
+ res, err := Reconcile(store, dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if res.Unchanged != 1 || res.Added != 0 || res.Modified != 0 {
+ t.Errorf("未变场景: %+v", res)
+ }
+}
+
+func TestQuickCheck(t *testing.T) {
+ dir := t.TempDir()
+ os.MkdirAll(filepath.Join(dir, "FAQ"), 0755)
+ os.WriteFile(filepath.Join(dir, "FAQ", "001-测试文档.md"),
+ []byte("---\ntitle: 测试文档\n---\n内容\n"), 0644)
+
+ store, _ := Open(filepath.Join(t.TempDir(), "kb.db"))
+ defer store.Close()
+ Reconcile(store, dir)
+
+ dirty, err := QuickCheck(store, dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if dirty {
+ t.Error("无差异时 QuickCheck 应 false")
+ }
+}
+```
+
+- [ ] **Step 3: 跑测试确认失败**
+
+Run: `CGO_CFLAGS="-DSQLITE_ENABLE_FTS5" CGO_LDFLAGS="-lm" go test ./internal/index/ -run TestReconcile -v`
+Expected: FAIL — `undefined: Reconcile`
+
+- [ ] **Step 4: Store 新方法(sqlite.go 追加)**
+
+```go
+// FileStat 索引中的文件指纹
+type FileStat struct {
+ Path string
+ Size int64
+ Mtime int64
+ ContentHash string
+}
+
+// GetFileStats 返回所有已索引文件的指纹
+func (s *Store) GetFileStats() (map[string]FileStat, error) {
+ rows, err := s.db.Query(`SELECT path, size, mtime, content_hash FROM nodes`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ m := make(map[string]FileStat)
+ for rows.Next() {
+ var st FileStat
+ var hash sql.NullString
+ if err := rows.Scan(&st.Path, &st.Size, &st.Mtime, &hash); err != nil {
+ return nil, err
+ }
+ st.ContentHash = hash.String
+ m[st.Path] = st
+ }
+ return m, rows.Err()
+}
+
+// UpsertNode 按 path 插入或更新节点(触发器自动维护 FTS)
+func (s *Store) UpsertNode(n *graph.Node, size, mtime int64, contentHash string) error {
+ tagsJSON, _ := json.Marshal(n.Tags)
+ entitiesJSON, _ := json.Marshal(n.Entities)
+ wikilinksJSON, _ := json.Marshal(n.Wikilinks)
+ aliasesJSON, _ := json.Marshal(n.Aliases)
+ _, err := s.db.Exec(`
+ INSERT INTO nodes (path, title, section, tags, entities, wikilinks, aliases, status,
+ content_fts, size, mtime, content_hash)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(path) DO UPDATE SET
+ title = excluded.title,
+ section = excluded.section,
+ tags = excluded.tags,
+ entities = excluded.entities,
+ wikilinks = excluded.wikilinks,
+ aliases = excluded.aliases,
+ status = excluded.status,
+ content_fts = excluded.content_fts,
+ size = excluded.size,
+ mtime = excluded.mtime,
+ content_hash = excluded.content_hash,
+ updated_at = datetime('now')`,
+ n.Path, n.Title, n.Section, string(tagsJSON), string(entitiesJSON),
+ string(wikilinksJSON), string(aliasesJSON), n.Status, n.Content, size, mtime, contentHash)
+ return err
+}
+
+// DeleteNode 按 path 删节点(级联删边),同时清该节点相关的 unresolved_links
+func (s *Store) DeleteNode(path string) error {
+ tx, err := s.db.Begin()
+ if err != nil {
+ return err
+ }
+ var id int64
+ if err := tx.QueryRow(`SELECT id FROM nodes WHERE path = ?`, path).Scan(&id); err != nil {
+ tx.Rollback()
+ return nil // 不存在,视为成功
+ }
+ if _, err := tx.Exec(`DELETE FROM edges WHERE from_node = ? OR to_node = ?`, id, id); err != nil {
+ tx.Rollback()
+ return err
+ }
+ if _, err := tx.Exec(`DELETE FROM unresolved_links WHERE from_node = ?`, id); err != nil {
+ tx.Rollback()
+ return err
+ }
+ if _, err := tx.Exec(`DELETE FROM nodes WHERE id = ?`, id); err != nil {
+ tx.Rollback()
+ return err
+ }
+ return tx.Commit()
+}
+
+// DeleteNodeEdges 只删某节点的边(保留节点行,用于"修改"场景重建边)
+func (s *Store) DeleteNodeEdges(nodeID int64) error {
+ _, err := s.db.Exec(`DELETE FROM edges WHERE from_node = ? OR to_node = ?`, nodeID, nodeID)
+ return err
+}
+```
+
+同时改 `InsertNode` 签名追加 `aliases/status/size/mtime/content_hash`(全量重建路径用),并同步 `graph.Node` 结构体加 `Aliases []string` 和 `Status string` 字段(Task 2 Step 1 已改 FileMeta,graph.Node 对齐)。
+
+- [ ] **Step 5: reconcile.go 实现**
+
+```go
+package index
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/aisim/kb-cli/internal/graph"
+ "github.com/aisim/kb-cli/internal/vault"
+)
+
+// ReconcileResult 对账结果统计
+type ReconcileResult struct {
+ Added int
+ Modified int
+ Deleted int
+ Unchanged int
+}
+
+// Reconcile 增量对账:stat 比对 → 只对变更文件解析和写库
+func Reconcile(store *Store, vaultPath string) (*ReconcileResult, error) {
+ res := &ReconcileResult{}
+
+ // 1. vault 侧指纹
+ vaultStats, err := vault.ScanVaultStat(vaultPath)
+ if err != nil {
+ return nil, fmt.Errorf("扫描失败: %w", err)
+ }
+ dbStats, err := store.GetFileStats()
+ if err != nil {
+ return nil, fmt.Errorf("读取索引指纹失败: %w", err)
+ }
+
+ // 2. 分类
+ type change struct {
+ path string
+ stat vault.FileStat
+ known FileStat
+ }
+ var added, modified []change
+ for _, v := range vaultStats {
+ known, ok := dbStats[v.Path]
+ if !ok {
+ added = append(added, change{v.Path, v, FileStat{}})
+ continue
+ }
+ if v.Size == known.Size && v.Mtime == known.Mtime {
+ res.Unchanged++
+ continue
+ }
+ // size/mtime 变化 → sha256 二次确认
+ hash, err := fileHash(filepath.Join(vaultPath, v.Path))
+ if err != nil {
+ return nil, fmt.Errorf("哈希 %s 失败: %w", v.Path, err)
+ }
+ if known.ContentHash != "" && hash == known.ContentHash {
+ res.Unchanged++ // 内容没变(如 touch),只更新指纹
+ store.db.Exec(`UPDATE nodes SET size=?, mtime=? WHERE path=?`, v.Size, v.Mtime, v.Path)
+ continue
+ }
+ modified = append(modified, change{v.Path, v, known})
+ }
+ // 3. 删除
+ for path := range dbStats {
+ found := false
+ for _, v := range vaultStats {
+ if v.Path == path {
+ found = true
+ break
+ }
+ }
+ if !found {
+ if err := store.DeleteNode(path); err != nil {
+ return nil, err
+ }
+ res.Deleted++
+ }
+ }
+
+ // 4. 新增 + 修改:解析 → 写节点 → 重建该节点边
+ for _, c := range append(added, modified...) {
+ isNew := c.known.Path == ""
+ meta, err := vault.ParseFile(filepath.Join(vaultPath, c.path), c.path)
+ if err != nil {
+ return nil, fmt.Errorf("解析 %s 失败: %w", c.path, err)
+ }
+ hash, err := fileHash(filepath.Join(vaultPath, c.path))
+ if err != nil {
+ return nil, err
+ }
+ if isNew {
+ res.Added++
+ } else {
+ res.Modified++
+ }
+ if err := applyNode(store, vaultPath, meta, c.stat.Size, c.stat.Mtime, hash); err != nil {
+ return nil, err
+ }
+ }
+
+ // 5. 悬空链接重试(新增/修改节点可能让 failed 链接变可解析)
+ if res.Added+res.Modified > 0 {
+ store.RetryUnresolved() // 见 Task 4,此处先以空实现占位编译通过
+ }
+ return res, nil
+}
+
+func fileHash(path string) (string, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return "", err
+ }
+ sum := sha256.Sum256(data)
+ return hex.EncodeToString(sum[:]), nil
+}
+
+// applyNode 写节点 + 重建该节点的出边(tag/entity/wikilink)+ 悬空入表
+func applyNode(store *Store, vaultPath string, meta *vault.FileMeta, size, mtime int64, hash string) error {
+ n := &graph.Node{
+ Path: meta.Path, Title: meta.Title, Section: meta.Section,
+ Tags: meta.Tags, Entities: meta.Entities, Wikilinks: meta.Wikilinks,
+ Aliases: meta.Aliases, Status: meta.Status, Content: meta.Content,
+ }
+ if err := store.UpsertNode(n, size, mtime, hash); err != nil {
+ return err
+ }
+ var nodeID int64
+ if err := store.db.QueryRow(`SELECT id FROM nodes WHERE path=?`, meta.Path).Scan(&nodeID); err != nil {
+ return err
+ }
+ // 删旧边后重建出边
+ if err := store.DeleteNodeEdges(nodeID); err != nil {
+ return err
+ }
+ return store.buildNodeEdges(nodeID, meta)
+}
+
+// buildNodeEdges 为单个节点建出边;wikilink 解析失败入 unresolved_links
+func (s *Store) buildNodeEdges(nodeID int64, meta *vault.FileMeta) error {
+ // tag / entity 边(虚拟节点 ID 沿用 1000000+ 规则,按 label 查现有行避免重复)
+ for _, tag := range meta.Tags {
+ if err := s.insertTagEntityEdge(nodeID, "tag:"+tag, "tag", tag); err != nil {
+ return err
+ }
+ }
+ for _, entity := range meta.Entities {
+ if err := s.insertTagEntityEdge(nodeID, "entity:"+entity, "entity", entity); err != nil {
+ return err
+ }
+ }
+ // wikilink 边 + 悬空
+ for _, link := range meta.Wikilinks {
+ targetID, prov, ok := s.resolveWikilink(link)
+ if !ok {
+ tail := nameTail(link)
+ s.db.Exec(`INSERT INTO unresolved_links (from_node, link_text, name_tail, status)
+ VALUES (?, ?, ?, 'pending') ON CONFLICT DO NOTHING`, nodeID, link, tail)
+ continue
+ }
+ if err := s.InsertEdge(&graph.Edge{
+ FromNode: nodeID, ToNode: targetID,
+ Relation: "wikilink", Label: link, Provenance: prov,
+ }); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// insertTagEntityEdge tag/entity 边(虚拟节点按 label 复用 ID)
+func (s *Store) insertTagEntityEdge(fromNode int64, key, relation, label string) error {
+ var virtualID int64
+ err := s.db.QueryRow(`SELECT to_node FROM edges WHERE relation=? AND label=? LIMIT 1`, relation, label).Scan(&virtualID)
+ if err != nil {
+ // 新虚拟节点:分配 ID = 1000000 + 行号(稳定:按 label 排序后的行号)
+ var maxID int64
+ s.db.QueryRow(`SELECT COALESCE(MAX(to_node), 1000000) FROM edges WHERE to_node >= 1000000 AND relation=?`, relation).Scan(&maxID)
+ virtualID = maxID + 1
+ }
+ return s.InsertEdge(&graph.Edge{FromNode: fromNode, ToNode: virtualID, Relation: relation, Label: label})
+}
+
+// resolveWikilink 解析 wikilink 目标,返回 (nodeID, provenance, ok)
+// provenance: exact = 标题或文件名精确匹配;fuzzy = 标题包含匹配
+func (s *Store) resolveWikilink(link string) (int64, string, bool) {
+ // 去锚点:[[标题|别名]] 取标题部分
+ if idx := strings.Index(link, "|"); idx >= 0 {
+ link = link[:idx]
+ }
+ var id int64
+ var title, path string
+ // 1. 标题精确
+ err := s.db.QueryRow(`SELECT id, title, path FROM nodes WHERE title = ? LIMIT 1`, link).Scan(&id, &title, &path)
+ if err == nil {
+ return id, "exact", true
+ }
+ // 2. 文件名精确(去 .md 和编号前缀)
+ rows, err := s.db.Query(`SELECT id, title, path FROM nodes`)
+ if err != nil {
+ return 0, "", false
+ }
+ defer rows.Close()
+ var fuzzyID int64
+ for rows.Next() {
+ var nid int64
+ var nTitle, nPath string
+ if err := rows.Scan(&nid, &nTitle, &nPath); err != nil {
+ return 0, "", false
+ }
+ base := filepath.Base(nPath)
+ base = strings.TrimSuffix(base, ".md")
+ if dash := strings.Index(base, "-"); dash >= 0 {
+ base = base[dash+1:]
+ }
+ if base == link || nTitle == link {
+ return nid, "exact", true
+ }
+ if fuzzyID == 0 && strings.Contains(nTitle, link) {
+ fuzzyID = nid
+ }
+ }
+ if fuzzyID != 0 {
+ return fuzzyID, "fuzzy", true
+ }
+ return 0, "", false
+}
+
+// nameTail 取 link 尾部用于重试匹配(去锚点修饰)
+func nameTail(link string) string {
+ if idx := strings.Index(link, "|"); idx >= 0 {
+ link = link[:idx]
+ }
+ return link
+}
+```
+
+注意:`resolveWikilink` 的全表扫描对 458 节点规模可接受;Task 4 的 RetryUnresolved 复用同一函数。`graph.Edge` 需加 `Provenance string` 字段,`InsertEdge` SQL 加 provenance 列。
+
+- [ ] **Step 6: retryUnresolved 空实现占位(Task 4 补全)**
+
+reconcile.go 底部:
+
+```go
+// RetryUnresolved 重试解析悬空链接(Task 4 补全完整实现)
+func (s *Store) RetryUnresolved() {}
+```
+
+- [ ] **Step 7: 改 cmd 层**
+
+`cmd/rebuild.go`:`rebuildIndex` 保留为全量路径(改名 `rebuildFull`),新增:
+
+```go
+// syncIndex 增量对账路径(index build 默认、search pre-flight 用)
+func syncIndex(store *index.Store) error {
+ res, err := index.Reconcile(store, vaultPath)
+ if err != nil {
+ return fmt.Errorf("增量同步失败: %w", err)
+ }
+ fmt.Fprintf(os.Stderr, "增量同步: 新增 %d / 修改 %d / 删除 %d / 未变 %d\n",
+ res.Added, res.Modified, res.Deleted, res.Unchanged)
+ if commit, err := index.GetGitCommit(vaultPath); err == nil {
+ store.SetMeta("git_commit", commit)
+ }
+ store.SetMeta("built_at", time.Now().Format(time.RFC3339))
+ return nil
+}
+```
+
+`cmd/index.go`:`indexBuildCmd` 加 `--force` bool flag;`runIndexBuild`:force → rebuildFull,默认 → syncIndex。
+
+`cmd/search.go`:pre-flight 从 `NeedsRebuild` 改为:
+
+```go
+dirty, err := index.QuickCheck(store, vaultPath)
+if err != nil {
+ return fmt.Errorf("索引状态检查失败: %w", err)
+}
+if dirty {
+ fmt.Fprintln(os.Stderr, "索引有变更,正在增量同步...")
+ if err := syncIndex(store); err != nil {
+ return fmt.Errorf("增量同步失败: %w", err)
+ }
+}
+```
+
+`internal/index/cache.go`:删除 `NeedsRebuild` 函数(`GetGitCommit` 保留),文件重命名保持 cache.go 不改名(减少 diff)。
+
+- [ ] **Step 8: QuickCheck 实现(reconcile.go)**
+
+```go
+// QuickCheck 只 stat 比对(不读内容不哈希),返回是否有差异
+func QuickCheck(store *Store, vaultPath string) (bool, error) {
+ vaultStats, err := vault.ScanVaultStat(vaultPath)
+ if err != nil {
+ return false, err
+ }
+ dbStats, err := store.GetFileStats()
+ if err != nil {
+ return false, err
+ }
+ if len(vaultStats) != len(dbStats) {
+ return true, nil
+ }
+ for _, v := range vaultStats {
+ known, ok := dbStats[v.Path]
+ if !ok || v.Size != known.Size || v.Mtime != known.Mtime {
+ return true, nil
+ }
+ }
+ return false, nil
+}
+```
+
+- [ ] **Step 9: 全量测试 + 编译**
+
+Run: `CGO_CFLAGS="-DSQLITE_ENABLE_FTS5" CGO_LDFLAGS="-lm" go test ./... 2>&1 | grep -v "no test files" && CGO_CFLAGS="-DSQLITE_ENABLE_FTS5" CGO_LDFLAGS="-lm" go build ./...`
+Expected: 全 PASS + 编译通过
+
+- [ ] **Step 10: Commit**
+
+```bash
+git add internal/index/ internal/vault/ internal/graph/model.go cmd/
+git commit -m "feat: 增量对账同步(stat+sha256 比对,替代 commit hash 全量重建)"
+```
+
+---
+
+### Task 3: 边可信度 + 悬空链接表接入全量重建
+
+**Files:**
+- Modify: `internal/graph/model.go`(Edge 加 Provenance)
+- Modify: `internal/graph/builder.go`(BuildGraph 产出 provenance + Unresolved 列表)
+- Create: `internal/graph/builder_test.go`(provenance 用例)
+- Modify: `internal/index/sqlite.go`(InsertEdge 写 provenance;InsertUnresolved)
+- Modify: `cmd/rebuild.go`(全量重建写入 unresolved)
+
+**Interfaces:**
+- Consumes: Task 2 的 `resolveWikilink`(迁移为 graph 包共享函数 `graph.ResolveWikilink`,reconcile.go 改引用)
+- Produces:
+ - `graph.Edge{Provenance string}`("exact"|"fuzzy"|"tag"|"entity")
+ - `graph.BuildGraph(files []*vault.FileMeta) (*Graph, []*UnresolvedLink)`
+ - `type UnresolvedLink struct { FromNode int64; LinkText string; NameTail string }`(FromNode 为 BuildGraph 内部 ID)
+ - `func (s *Store) InsertUnresolved(fromNode int64, linkText, nameTail string) error`
+
+- [ ] **Step 1: 失败测试——builder provenance**
+
+`internal/graph/builder_test.go`:
+
+```go
+package graph
+
+import (
+ "testing"
+
+ "github.com/aisim/kb-cli/internal/vault"
+)
+
+func TestBuildGraphProvenance(t *testing.T) {
+ files := []*vault.FileMeta{
+ {Path: "FAQ/001-补气失败.md", Title: "补气失败", Wikilinks: []string{"红绿闪", "不存在的链接"}},
+ {Path: "FAQ/002-红绿闪.md", Title: "红绿闪"},
+ {Path: "知识/003-称重原理.md", Title: "称重原理详解"}, // "称重原理" 是 "称重原理详解" 的子串 → fuzzy
+ }
+ files[0].Wikilinks = append(files[0].Wikilinks, "称重原理")
+
+ g, unresolved := BuildGraph(files)
+
+ // exact: 标题精确匹配
+ var exact, fuzzy int
+ for _, e := range g.Edges {
+ if e.Relation != "wikilink" {
+ continue
+ }
+ switch e.Provenance {
+ case "exact":
+ exact++
+ case "fuzzy":
+ fuzzy++
+ }
+ }
+ if exact != 1 || fuzzy != 1 {
+ t.Errorf("provenance 分布: exact=%d fuzzy=%d (want 1/1)", exact, fuzzy)
+ }
+ // 悬空链接
+ if len(unresolved) != 1 || unresolved[0].LinkText != "不存在的链接" {
+ t.Errorf("unresolved: %+v", unresolved)
+ }
+ if unresolved[0].NameTail != "不存在的链接" {
+ t.Errorf("name_tail: %s", unresolved[0].NameTail)
+ }
+}
+```
+
+- [ ] **Step 2: 跑测试确认失败**
+
+Run: `CGO_CFLAGS="-DSQLITE_ENABLE_FTS5" CGO_LDFLAGS="-lm" go test ./internal/graph/ -run TestBuildGraphProvenance -v`
+Expected: FAIL — `Provenance 未定义 / BuildGraph 返回值不匹配`
+
+- [ ] **Step 3: 实现**
+
+`internal/graph/model.go`:
+
+```go
+// Edge 图边(实体关系)
+type Edge struct {
+ FromNode int64 `json:"from_node"`
+ ToNode int64 `json:"to_node"`
+ Relation string `json:"relation"` // "tag" | "entity" | "wikilink"
+ Label string `json:"label"`
+ Provenance string `json:"provenance"` // "exact" | "fuzzy" | "tag" | "entity"
+}
+
+// UnresolvedLink 悬空 wikilink(未匹配到目标节点)
+type UnresolvedLink struct {
+ FromNode int64 // BuildGraph 内部节点 ID
+ LinkText string
+ NameTail string
+}
+```
+
+`internal/graph/builder.go`:`BuildGraph` 签名改 `func BuildGraph(files []*vault.FileMeta) (*Graph, []*UnresolvedLink)`;wikilink 匹配循环中:
+
+```go
+for _, n := range g.Nodes {
+ for _, link := range n.Wikilinks {
+ target, prov := matchWikilink(g.Nodes, link)
+ if target == nil {
+ unresolved = append(unresolved, &UnresolvedLink{
+ FromNode: n.ID, LinkText: link, NameTail: nameTail(link),
+ })
+ continue
+ }
+ g.Edges = append(g.Edges, &Edge{
+ FromNode: n.ID, ToNode: target.ID,
+ Relation: "wikilink", Label: link, Provenance: prov,
+ })
+ }
+}
+```
+
+`matchWikilink` 从现有 `matchesWikilink` 升级:
+
+```go
+// matchWikilink 返回 (目标节点, provenance)。exact = 标题/文件名精确;fuzzy = 标题包含
+func matchWikilink(nodes []*Node, link string) (*Node, string) {
+ // 去锚点
+ if idx := strings.Index(link, "|"); idx >= 0 {
+ link = link[:idx]
+ }
+ var fuzzy *Node
+ for _, node := range nodes {
+ if node.Title == link {
+ return node, "exact"
+ }
+ base := filepath.Base(node.Path)
+ base = strings.TrimSuffix(base, ".md")
+ if dash := strings.Index(base, "-"); dash >= 0 {
+ base = base[dash+1:]
+ }
+ if base == link {
+ return node, "exact"
+ }
+ if fuzzy == nil && strings.Contains(node.Title, link) {
+ fuzzy = node
+ }
+ }
+ if fuzzy != nil {
+ return fuzzy, "fuzzy"
+ }
+ return nil, ""
+}
+
+func nameTail(link string) string {
+ if idx := strings.Index(link, "|"); idx >= 0 {
+ link = link[:idx]
+ }
+ return link
+}
+```
+
+tag/entity 边追加 `Provenance: "tag"` / `"entity"`。
+
+`internal/index/sqlite.go` InsertEdge 改:
+
+```go
+func (s *Store) InsertEdge(e *graph.Edge) error {
+ _, err := s.db.Exec(`
+ INSERT OR IGNORE INTO edges (from_node, to_node, relation, label, provenance)
+ VALUES (?, ?, ?, ?, ?)`, e.FromNode, e.ToNode, e.Relation, e.Label, e.Provenance)
+ return err
+}
+
+// InsertUnresolved 悬空链接入表(幂等:同 from_node+link_text 不重复插)
+func (s *Store) InsertUnresolved(fromNode int64, linkText, nameTail string) error {
+ _, err := s.db.Exec(`
+ INSERT INTO unresolved_links (from_node, link_text, name_tail, status)
+ SELECT ?, ?, ?, 'pending'
+ WHERE NOT EXISTS (
+ SELECT 1 FROM unresolved_links WHERE from_node = ? AND link_text = ?)`,
+ fromNode, linkText, nameTail, fromNode, linkText)
+ return err
+}
+```
+
+`cmd/rebuild.go` 全量路径:`g, unresolved := graph.BuildGraph(files)`;节点写完后映射 ID 写 unresolved:
+
+```go
+for _, u := range unresolved {
+ fromID, ok := idMap[u.FromNode]
+ if !ok {
+ continue
+ }
+ if err := store.InsertUnresolved(fromID, u.LinkText, u.NameTail); err != nil {
+ return err
+ }
+}
+```
+
+`internal/index/reconcile.go`(Task 2 产物):`buildNodeEdges` 中 wikilink 解析改调 `graph.ResolveWikilink`——为便于共享,Task 2 的 `resolveWikilink` 逻辑保留在 Store 上(DB 查询版),graph 包的是纯内存版,两者规则一致即可,不强制合并(避免跨包依赖 SQLite)。
+
+- [ ] **Step 4: 跑测试**
+
+Run: `CGO_CFLAGS="-DSQLITE_ENABLE_FTS5" CGO_LDFLAGS="-lm" go test ./... 2>&1 | grep -v "no test files"`
+Expected: 全 PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/graph/ internal/index/sqlite.go cmd/rebuild.go
+git commit -m "feat: wikilink 边可信度标注(exact/fuzzy)+ 悬空链接入 unresolved_links 表"
+```
+
+---
+
+### Task 4: 悬空链接自动补全(RetryUnresolved)
+
+**Files:**
+- Modify: `internal/index/reconcile.go`(RetryUnresolved 补全实现)
+- Create: `internal/index/reconcile_test.go` 追加测试(或新文件 `unresolved_test.go`)
+
+**Interfaces:**
+- Consumes: Task 3 的 `InsertUnresolved`、`resolveWikilink`(Store 版)
+- Produces: `func (s *Store) RetryUnresolved() (resolved int, error error)`——每次对账后调用
+
+- [ ] **Step 1: 失败测试**
+
+`internal/index/unresolved_test.go`:
+
+```go
+package index
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestRetryUnresolved(t *testing.T) {
+ dir := t.TempDir()
+ os.MkdirAll(filepath.Join(dir, "FAQ"), 0755)
+ os.WriteFile(filepath.Join(dir, "FAQ", "001-旧文档.md"),
+ []byte("---\ntitle: 旧文档\n---\n提到 [[补气失败]]\n"), 0644)
+
+ store, _ := Open(filepath.Join(t.TempDir(), "kb.db"))
+ defer store.Close()
+
+ Reconcile(store, dir)
+
+ // 验证悬空链接入表
+ var n int
+ store.db.QueryRow(`SELECT COUNT(*) FROM unresolved_links WHERE link_text='补气失败'`).Scan(&n)
+ if n != 1 {
+ t.Fatalf("悬空链接未入表: %d", n)
+ }
+
+ // 新文档入库:标题正好是 "补气失败"
+ os.WriteFile(filepath.Join(dir, "FAQ", "002-补气失败.md"),
+ []byte("---\ntitle: 补气失败\n---\n补气失败排查\n"), 0644)
+
+ res, err := Reconcile(store, dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if res.Added != 1 {
+ t.Fatalf("新增: %+v", res)
+ }
+
+ // 悬空链接应被解析:行删除 + 边建立
+ store.db.QueryRow(`SELECT COUNT(*) FROM unresolved_links WHERE link_text='补气失败'`).Scan(&n)
+ if n != 0 {
+ t.Errorf("悬空行未清除: %d", n)
+ }
+ store.db.QueryRow(`SELECT COUNT(*) FROM edges WHERE relation='wikilink' AND label='补气失败'`).Scan(&n)
+ if n != 1 {
+ t.Errorf("wikilink 边未建立: %d", n)
+ }
+ var prov string
+ store.db.QueryRow(`SELECT provenance FROM edges WHERE relation='wikilink' AND label='补气失败'`).Scan(&prov)
+ if prov != "exact" {
+ t.Errorf("补全边应为 exact: %s", prov)
+ }
+}
+```
+
+- [ ] **Step 2: 跑测试确认失败**
+
+Run: `CGO_CFLAGS="-DSQLITE_ENABLE_FTS5" CGO_LDFLAGS="-lm" go test ./internal/index/ -run TestRetryUnresolved -v`
+Expected: FAIL — RetryUnresolved 是空实现,悬空行不清除
+
+- [ ] **Step 3: 实现 RetryUnresolved**
+
+reconcile.go 替换空实现:
+
+```go
+// RetryUnresolved 重试解析悬空链接:用当前全部节点的标题/文件名去匹配 unresolved_links 的 name_tail。
+// 命中则建边(provenance 按匹配严格度)、删行。返回成功解析条数。
+func (s *Store) RetryUnresolved() (int, error) {
+ rows, err := s.db.Query(`SELECT id, from_node, link_text, name_tail FROM unresolved_links`)
+ if err != nil {
+ return 0, err
+ }
+ type pending struct {
+ id int64
+ fromNode int64
+ linkText string
+ tail string
+ }
+ var pendings []pending
+ for rows.Next() {
+ var p pending
+ if err := rows.Scan(&p.id, &p.fromNode, &p.linkText, &p.tail); err != nil {
+ rows.Close()
+ return 0, err
+ }
+ pendings = append(pendings, p)
+ }
+ rows.Close()
+ if len(pendings) == 0 {
+ return 0, nil
+ }
+
+ // 建匹配索引:标题/文件名(去编号) → nodeID,精确匹配优先
+ type matchInfo struct {
+ id int64
+ prov string
+ }
+ exactMap := make(map[string]matchInfo)
+ var fuzzyRows []struct {
+ id int64
+ title string
+ }
+ nrows, err := s.db.Query(`SELECT id, title, path FROM nodes`)
+ if err != nil {
+ return 0, err
+ }
+ for nrows.Next() {
+ var id int64
+ var title, path string
+ if err := nrows.Scan(&id, &title, &path); err != nil {
+ nrows.Close()
+ return 0, err
+ }
+ if _, ok := exactMap[title]; !ok {
+ exactMap[title] = matchInfo{id, "exact"}
+ }
+ base := filepath.Base(path)
+ base = strings.TrimSuffix(base, ".md")
+ if dash := strings.Index(base, "-"); dash >= 0 {
+ base = base[dash+1:]
+ }
+ if _, ok := exactMap[base]; !ok {
+ exactMap[base] = matchInfo{id, "exact"}
+ }
+ fuzzyRows = append(fuzzyRows, struct {
+ id int64
+ title string
+ }{id, title})
+ }
+ nrows.Close()
+
+ resolved := 0
+ for _, p := range pendings {
+ if info, ok := exactMap[p.tail]; ok {
+ // 建边
+ if err := s.InsertEdge(&graph.Edge{
+ FromNode: p.fromNode, ToNode: info.id,
+ Relation: "wikilink", Label: p.linkText, Provenance: info.prov,
+ }); err != nil {
+ return resolved, err
+ }
+ if _, err := s.db.Exec(`DELETE FROM unresolved_links WHERE id=?`, p.id); err != nil {
+ return resolved, err
+ }
+ resolved++
+ continue
+ }
+ // fuzzy:标题包含
+ for _, fr := range fuzzyRows {
+ if strings.Contains(fr.title, p.tail) {
+ if err := s.InsertEdge(&graph.Edge{
+ FromNode: p.fromNode, ToNode: fr.id,
+ Relation: "wikilink", Label: p.linkText, Provenance: "fuzzy",
+ }); err != nil {
+ return resolved, err
+ }
+ s.db.Exec(`DELETE FROM unresolved_links WHERE id=?`, p.id)
+ resolved++
+ break
+ }
+ }
+ }
+ return resolved, nil
+}
+```
+
+reconcile.go 需 import `github.com/aisim/kb-cli/internal/graph` 和 `strings`(已有)。
+
+- [ ] **Step 4: 跑测试**
+
+Run: `CGO_CFLAGS="-DSQLITE_ENABLE_FTS5" CGO_LDFLAGS="-lm" go test ./internal/index/ -v 2>&1 | tail -20`
+Expected: 全 PASS(含 Task 2 的 5 个对账测试)
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/index/
+git commit -m "feat: 悬空双链自动补全(新文档入库后历史链接自动解析建边)"
+```
+
+---
+
+### Task 5: RWR 图排序 + CJK 混合检索 + 双信号加权
+
+**Files:**
+- Create: `internal/graph/rwr.go`
+- Create: `internal/graph/rwr_test.go`
+- Create: `internal/index/graphload.go`(RWR 邻接加载)
+- Modify: `internal/index/fts.go`(FTSSearch 拆 ASCII/CJK 双通道)
+- Modify: `internal/search/engine.go`(双信号加权 + status 降权)
+- Modify: `internal/search/scorer.go`(aliases 位置权重)
+- Modify: `cmd/search.go`(读 config 的 text_weight)
+- Modify: `internal/llm/client.go`(Config 加 Search/Explore 节)
+
+**Interfaces:**
+- Consumes: Task 3 的 provenance 边
+- Produces:
+ - `func RWR(seedIDs []int64, adj map[int64][]adjEdge, alpha float64) map[int64]float64`
+ - `type adjEdge struct { To int64; Weight float64 }`
+ - `func (s *Store) LoadRWRGraph(seedIDs []int64) (map[int64][]adjEdge, error)`(只加载种子可达子图内的边;wikilink exact=1.0 / fuzzy=0.5 / entity=1.0 / tag=0.5)
+ - `func (s *Store) KeywordSearch(keywords []string, limit int) ([]FTSResult, error)`(ASCII 走 FTS MATCH,CJK 走 LIKE 合并)
+ - `func search.ContainsCJK(s string) bool`
+
+- [ ] **Step 1: RWR 失败测试**
+
+`internal/graph/rwr_test.go`:
+
+```go
+package graph
+
+import "testing"
+
+func TestRWRConvergenceAndSeed(t *testing.T) {
+ // 图: A-B-C 链 + D 孤立
+ adj := map[int64][]adjEdge{
+ 1: {{2, 1}}, 2: {{1, 1}, {3, 1}}, 3: {{2, 1}}, 4: {{}},
+ }
+ r := RWR([]int64{1}, adj, 0.25)
+ if r[1] <= 0 {
+ t.Fatal("种子节点质量必须 > 0")
+ }
+ // 与种子连通的质量应高于孤立节点
+ if r[4] >= r[3] {
+ t.Errorf("孤立节点质量不应高于连通节点: r4=%f r3=%f", r[4], r[3])
+ }
+ // 归一化 [0,1]
+ for _, v := range r {
+ if v < 0 || v > 1 {
+ t.Errorf("质量未归一化: %f", v)
+ }
+ }
+}
+
+func TestRWRWeightedByProvenance(t *testing.T) {
+ // A -exact-> B, A -fuzzy-> C:B 的质量应高于 C
+ adj := map[int64][]adjEdge{
+ 1: {{2, 1.0}, {3, 0.5}},
+ 2: {{1, 1.0}},
+ 3: {{1, 0.5}},
+ }
+ r := RWR([]int64{1}, adj, 0.25)
+ if r[2] <= r[3] {
+ t.Errorf("exact 边节点质量应高于 fuzzy: r2=%f r3=%f", r[2], r[3])
+ }
+}
+```
+
+- [ ] **Step 2: 跑测试确认失败**
+
+Run: `CGO_CFLAGS="-DSQLITE_ENABLE_FTS5" CGO_LDFLAGS="-lm" go test ./internal/graph/ -run TestRWR -v`
+Expected: FAIL — `undefined: RWR`
+
+- [ ] **Step 3: 实现 rwr.go**
+
+```go
+package graph
+
+// adjEdge 带权邻接边
+type adjEdge struct {
+ To int64
+ Weight float64
+}
+
+// RWR Random-Walk-with-Restart(个性化 PageRank):
+// 从种子集合出发在无向带权图上做 power iteration,restart 概率 alpha 回到种子。
+// 返回归一化到 [0,1] 的游走质量。确定性算法,无随机数。
+func RWR(seedIDs []int64, adj map[int64][]adjEdge, alpha float64) map[int64]float64 {
+ if len(seedIDs) == 0 {
+ return map[int64]float64{}
+ }
+ // 收集参与节点:种子 + 邻接可达
+ nodes := make(map[int64]bool)
+ for _, s := range seedIDs {
+ nodes[s] = true
+ }
+ for from := range adj {
+ nodes[from] = true
+ for _, e := range adj[from] {
+ nodes[e.To] = true
+ }
+ }
+ n := len(nodes)
+ if n == 0 {
+ return map[int64]float64{}
+ }
+ idx := make(map[int64]int, n)
+ ids := make([]int64, 0, n)
+ for id := range nodes {
+ idx[id] = len(ids)
+ ids = append(ids, id)
+ }
+
+ // 重启向量:种子均匀
+ r := make([]float64, n)
+ for _, s := range seedIDs {
+ if i, ok := idx[s]; ok {
+ r[i] = 1.0 / float64(len(seedIDs))
+ }
+ }
+
+ const maxIter = 50
+ const eps = 1e-6
+ newR := make([]float64, n)
+ for iter := 0; iter < maxIter; iter++ {
+ for i := range newR {
+ newR[i] = 0
+ }
+ // 游走传播:out[i] = sum(w_ij)
+ for i, id := range ids {
+ var total float64
+ for _, e := range adj[id] {
+ total += e.Weight
+ }
+ if total == 0 {
+ continue
+ }
+ for _, e := range adj[id] {
+ j, ok := idx[e.To]
+ if !ok {
+ continue
+ }
+ newR[j] += (1-alpha) * r[i] * e.Weight / total
+ }
+ }
+ // restart
+ for i := range newR {
+ newR[i] += alpha * r[i]
+ }
+ // 收敛判断
+ diff := 0.0
+ for i := range newR {
+ d := newR[i] - r[i]
+ if d < 0 {
+ d = -d
+ }
+ diff += d
+ }
+ r, newR = newR, r
+ if diff < eps {
+ break
+ }
+ }
+ // 归一化 [0,1]
+ maxV := 0.0
+ for _, v := range r {
+ if v > maxV {
+ maxV = v
+ }
+ }
+ out := make(map[int64]float64, n)
+ if maxV == 0 {
+ for _, id := range ids {
+ out[id] = 0
+ }
+ return out
+ }
+ for i, id := range ids {
+ out[id] = r[i] / maxV
+ }
+ return out
+}
+```
+
+- [ ] **Step 4: LoadRWRGraph(internal/index/graphload.go)**
+
+```go
+package index
+
+import (
+ "fmt"
+
+ "github.com/aisim/kb-cli/internal/graph"
+)
+
+// LoadRWRGraph 加载 RWR 邻接(无向、带权)。
+// 权重:wikilink exact=1.0 / fuzzy=0.5 / entity=1.0 / tag=0.5(tag 扇出大降权)。
+// 只加载与种子同连通域的边不可行(SQLite 无图查询),全量加载后 RWR 内部按种子收敛——
+// 458 节点 / ~2k 边规模下全量加载 <5ms,可接受。
+func (s *Store) LoadRWRGraph() (map[int64][]graph.adjEdge, error) {
+ rows, err := s.db.Query(`
+ SELECT from_node, to_node, relation, provenance FROM edges
+ WHERE relation IN ('wikilink', 'entity', 'tag')`)
+ if err != nil {
+ return nil, fmt.Errorf("加载边失败: %w", err)
+ }
+ defer rows.Close()
+ adj := make(map[int64][]graph.adjEdge)
+ add := func(from, to int64, w float64) {
+ adj[from] = append(adj[from], graph.adjEdge{To: to, Weight: w})
+ adj[to] = append(adj[to], graph.adjEdge{To: from, Weight: w})
+ }
+ for rows.Next() {
+ var from, to int64
+ var relation, prov string
+ if err := rows.Scan(&from, &to, &relation, &prov); err != nil {
+ return nil, err
+ }
+ var w float64
+ switch relation {
+ case "wikilink":
+ if prov == "exact" {
+ w = 1.0
+ } else {
+ w = 0.5
+ }
+ case "entity":
+ w = 1.0
+ case "tag":
+ w = 0.5
+ default:
+ continue
+ }
+ add(from, to, w)
+ }
+ return adj, rows.Err()
+}
+```
+
+- [ ] **Step 5: 跑测试**
+
+Run: `CGO_CFLAGS="-DSQLITE_ENABLE_FTS5" CGO_LDFLAGS="-lm" go test ./internal/graph/ ./internal/index/ 2>&1 | tail -5`
+Expected: PASS
+
+- [ ] **Step 6: CJK 混合检索(fts.go 加 KeywordSearch)**
+
+```go
+// containsCJK 是否含 CJK 统一表意文字
+func containsCJK(s string) bool {
+ for _, r := range s {
+ if r >= 0x4E00 && r <= 0x9FFF {
+ return true
+ }
+ }
+ return false
+}
+
+// KeywordSearch 双通道关键词检索:ASCII 词走 FTS5 MATCH,CJK 词走 LIKE(title/aliases/content/tags)。
+// FTS5 unicode61 把连续中文当整串单 token,多字符 CJK 词 MATCH 匹配不到,必须走 LIKE。
+func (s *Store) KeywordSearch(keywords []string, limit int) ([]FTSResult, error) {
+ if len(keywords) == 0 {
+ return nil, nil
+ }
+ seen := make(map[int64]*FTSResult)
+ var order []int64
+
+ // 通道 1: FTS(ASCII 词)
+ var ascii []string
+ for _, kw := range keywords {
+ if !containsCJK(kw) {
+ ascii = append(ascii, kw)
+ }
+ }
+ for _, kw := range ascii {
+ rows, err := s.db.Query(`
+ SELECT n.id, n.path, n.title, n.section, fts.rank
+ FROM nodes_fts fts JOIN nodes n ON n.id = fts.rowid
+ WHERE nodes_fts MATCH ? ORDER BY fts.rank LIMIT ?`, kw, limit)
+ if err != nil {
+ continue // 语法异常词跳过
+ }
+ for rows.Next() {
+ var r FTSResult
+ if err := rows.Scan(&r.ID, &r.Path, &r.Title, &r.Section, &r.Rank); err != nil {
+ continue
+ }
+ if _, ok := seen[r.ID]; !ok {
+ seen[r.ID] = &r
+ order = append(order, r.ID)
+ }
+ }
+ rows.Close()
+ }
+
+ // 通道 2: LIKE(CJK 词)
+ for _, kw := range keywords {
+ if !containsCJK(kw) {
+ continue
+ }
+ pat := "%" + kw + "%"
+ rows, err := s.db.Query(`
+ SELECT id, path, title, section FROM nodes
+ WHERE title LIKE ? OR aliases LIKE ? OR content_fts LIKE ? OR tags LIKE ?`,
+ pat, pat, pat, pat)
+ if err != nil {
+ continue
+ }
+ for rows.Next() {
+ var r FTSResult
+ if err := rows.Scan(&r.ID, &r.Path, &r.Title, &r.Section); err != nil {
+ continue
+ }
+ r.Rank = 0 // LIKE 无 rank
+ if _, ok := seen[r.ID]; !ok {
+ seen[r.ID] = &r
+ order = append(order, r.ID)
+ }
+ }
+ rows.Close()
+ }
+
+ results := make([]FTSResult, 0, len(order))
+ for _, id := range order {
+ results = append(results, *seen[id])
+ }
+ if limit > 0 && len(results) > limit {
+ results = results[:limit]
+ }
+ return results, nil
+}
+```
+
+- [ ] **Step 7: engine.go 双信号加权**
+
+替换 `Search` 主体(保留函数签名,`SearchOptions` 加 `TextWeight float64`,缺省 0.5):
+
+```go
+func Search(store *index.Store, keywords []string, opts SearchOptions) ([]SearchResult, error) {
+ allKeywords := append(append(keywords, opts.Expanded...), opts.Symptom...)
+
+ // 1. 双通道检索
+ candidates, err := store.KeywordSearch(allKeywords, 100)
+ if err != nil {
+ return nil, err
+ }
+ if len(candidates) == 0 {
+ return nil, nil
+ }
+
+ // 2. 文本分(沿用位置加权,aliases 按 title 档计权)
+ textScore := make(map[int64]int, len(candidates))
+ for _, r := range candidates {
+ var score int
+ for _, kw := range keywords {
+ score += scoreResult(r, kw, ScoreNormal)
+ }
+ for _, kw := range opts.Expanded {
+ score += scoreResult(r, kw, ScoreExpanded)
+ }
+ for _, kw := range opts.Symptom {
+ score += scoreResult(r, kw, ScoreSymptom)
+ }
+ textScore[r.ID] = score
+ }
+
+ // 3. RWR 图质量(种子 = 全部候选,上限 20)
+ seedIDs := make([]int64, 0, len(candidates))
+ for _, r := range candidates {
+ if len(seedIDs) >= 20 {
+ break
+ }
+ seedIDs = append(seedIDs, r.ID)
+ }
+ rwrMass := map[int64]float64{}
+ if len(seedIDs) > 0 {
+ adj, err := store.LoadRWRGraph()
+ if err == nil {
+ rwrMass = graph.RWR(seedIDs, adj, 0.25)
+ }
+ }
+
+ // 4. 双信号加权:finalScore = norm(textScore)*tw + rwrMass*(1-tw)
+ tw := opts.TextWeight
+ if tw <= 0 || tw > 1 {
+ tw = 0.5
+ }
+ minT, maxT := 0, 0
+ for _, s := range textScore {
+ if s < minT || maxT == 0 && s > maxT {
+ minT = s
+ }
+ if s > maxT {
+ maxT = s
+ }
+ }
+ statusFactor := make(map[int64]float64)
+ for _, r := range candidates {
+ if r.Section == "待审阅" || isDraftStatus(r.Status) {
+ statusFactor[r.ID] = 0.6
+ } else {
+ statusFactor[r.ID] = 1.0
+ }
+ }
+
+ var results []SearchResult
+ for _, r := range candidates {
+ ts := float64(textScore[r.ID])
+ if maxT > minT {
+ ts = (ts - float64(minT)) / float64(maxT-minT)
+ } else if maxT > 0 {
+ ts = 1
+ }
+ final := ts*tw + rwrMass[r.ID]*(1-tw)
+ final *= statusFactor[r.ID]
+ results = append(results, SearchResult{
+ ID: r.ID, Path: r.Path, Title: r.Title, Section: r.Section,
+ Score: int(final * 100), Status: r.Status,
+ })
+ }
+ sort.Slice(results, func(i, j int) bool {
+ return results[i].Score > results[j].Score
+ })
+ if opts.TopN > 0 && len(results) > opts.TopN {
+ results = results[:opts.TopN]
+ }
+ // WithContent / WithLinks 逻辑沿用现有代码
+ if opts.WithContent {
+ for i := range results {
+ content, _, _, err := store.GetNodeContent(results[i].ID)
+ if err == nil {
+ results[i].Content = content
+ }
+ }
+ }
+ if opts.WithLinks {
+ for i := range results {
+ links, err := store.GetNodeLinks(results[i].ID)
+ if err == nil {
+ results[i].Links = links
+ }
+ }
+ }
+ return results, nil
+}
+
+// isDraftStatus 草稿态降权判断
+func isDraftStatus(status string) bool {
+ switch status {
+ case "草稿", "待确认", "跟进中":
+ return true
+ }
+ return false
+}
+```
+
+配套修改:
+- `FTSResult` 加 `Status string` 字段;`KeywordSearch` 两通道 SELECT 均加 `n.status` 列并 Scan
+- `SearchResult` 加 `Status string \`json:"status,omitempty"\``
+- `SearchOptions` 加 `TextWeight float64`
+- `scoreResult` 加 aliases 检查:`var aliasesJSON string` 从 store 取(或 FTSResult 带 Aliases []string 解析),命中按 "title" 档计权:
+
+```go
+// scoreResult 追加(Aliases 字段已在 FTSResult 上):
+if len(r.Aliases) > 0 {
+ for _, a := range r.Aliases {
+ if strings.Contains(strings.ToLower(a), kw) {
+ score += CalcScore(keyword, "title", scoreType)
+ break
+ }
+ }
+}
+```
+(`FTSResult` 加 `Aliases []string`;KeywordSearch SELECT 加 `n.aliases` 并 json.Unmarshal)
+
+- `cmd/search.go`:`opts.TextWeight` 从 config 读(`cfg.Search.TextWeight`,缺省 0.5)
+- `internal/llm/client.go` Config 加:
+
+```go
+Search struct {
+ TextWeight float64 `yaml:"text_weight"`
+} `yaml:"search"`
+Explore struct {
+ DefaultBudget int `yaml:"default_budget"`
+ HardBudget int `yaml:"hard_budget"`
+ TopN int `yaml:"top_n"`
+} `yaml:"explore"`
+```
+
+- [ ] **Step 8: engine_test.go 适配 + 新测试**
+
+现有 `TestSearch` 保持通过(FTS 通道不变)。追加:
+
+```go
+func TestCJKLikeChannel(t *testing.T) {
+ // 库中含 "电子秤补气失败" 的文档,搜 "补气"(CJK)应命中
+ // 构造:store.UpsertNode 写入 content 含该词的节点
+ ...
+}
+```
+
+测试构造方式:`Open` 临时库 → `UpsertNode` 写 3 个节点(1 个 content 含"补气",1 个 title 含"补气",1 个不含)→ `Search(store, []string{"补气"}, opts)` → 断言含"补气"的两个在结果中且 title 命中者排前。
+
+- [ ] **Step 9: 全量测试**
+
+Run: `CGO_CFLAGS="-DSQLITE_ENABLE_FTS5" CGO_LDFLAGS="-lm" go test ./... 2>&1 | grep -v "no test files"`
+Expected: 全 PASS
+
+- [ ] **Step 10: Commit**
+
+```bash
+git add internal/graph/rwr.go internal/graph/rwr_test.go internal/index/graphload.go internal/index/fts.go internal/search/ internal/llm/client.go cmd/search.go
+git commit -m "feat: RWR 图结构排序 + CJK LIKE 混合检索 + 双信号加权 + aliases/status 参与排序"
+```
+
+---
+
+### Task 6: explore 命令
+
+**Files:**
+- Create: `cmd/explore.go`
+- Create: `internal/search/explore.go`
+- Create: `internal/search/explore_test.go`
+- Modify: `cmd/root.go`(注册 exploreCmd)
+
+**Interfaces:**
+- Consumes: Task 5 的 `Search`、`store.GetNodeContent`、`store.GetNodeLinks`
+- Produces:
+ - `func Explore(store *index.Store, query string, opts ExploreOptions) (*ExploreResult, error)`
+ - `type ExploreOptions struct { Budget, TopN int }`
+ - `type ExploreResult struct { Docs []ExploredDoc; Related map[string][]string; UnresolvedLinks []string }`
+ - `type ExploredDoc struct { Path, Title, Section string; Score int; Body string }`
+
+- [ ] **Step 1: 失败测试**
+
+`internal/search/explore_test.go`:
+
+```go
+package search
+
+import (
+ "testing"
+)
+
+// TestExploreParagraphExtraction 段落截取:只输出命中关键词的段落,整段不截半句
+func TestExploreParagraphExtraction(t *testing.T) {
+ content := "# 标题\n\n第一段讲称重。\n\n## 补气流程\n\n补气失败时先检查阀门。\n\n## 其他\n\n无关内容。\n"
+ got := extractRelevantParagraphs(content, []string{"补气"})
+ want := "## 补气流程\n\n补气失败时先检查阀门。\n"
+ if got != want {
+ t.Errorf("段落截取:\n got=%q\nwant=%q", got, want)
+ }
+}
+
+// TestExploreWholeDocWhenSmall 文档短于预算时整篇输出
+func TestExploreWholeDocWhenSmall(t *testing.T) {
+ got := extractRelevantParagraphs("短文档\n", []string{"不存在"}, 10000)
+ if got != "短文档\n" {
+ t.Errorf("应整篇输出: %q", got)
+ }
+}
+
+// TestExploreBudget 预算分配:按分数降序,超预算文档截断到段落边界
+func TestExploreBudget(t *testing.T) {
+ // 用 extractRelevantParagraphs 的预算版验证:预算 20 字节,命中段落 30 字节 → 输出空(宁缺毋滥,不截半段)
+ got := extractWithBudget("## 段落\n\n这是一段超过预算的内容啊\n", []string{"段落"}, 20)
+ if got != "" {
+ t.Errorf("超预算段落应跳过: %q", got)
+ }
+}
+```
+
+- [ ] **Step 2: 跑测试确认失败**
+
+Run: `CGO_CFLAGS="-DSQLITE_ENABLE_FTS5" CGO_LDFLAGS="-lm" go test ./internal/search/ -run TestExplore -v`
+Expected: FAIL — `undefined: extractRelevantParagraphs`
+
+- [ ] **Step 3: 实现 explore.go**
+
+```go
+package search
+
+import (
+ "regexp"
+ "strings"
+
+ "github.com/aisim/kb-cli/internal/index"
+)
+
+var headingRe = regexp.MustCompile(`^#{1,4} .+$`)
+
+// splitParagraphs 按 1-4 级标题切段。无标题的文档整体为一段。
+func splitParagraphs(content string) []string {
+ lines := strings.Split(content, "\n")
+ var paras []string
+ var cur []string
+ flush := func() {
+ if len(cur) > 0 {
+ paras = append(paras, strings.TrimRight(strings.Join(cur, "\n"), "\n")+"\n")
+ cur = nil
+ }
+ }
+ for _, l := range lines {
+ if headingRe.MatchString(l) {
+ flush()
+ }
+ cur = append(cur, l)
+ }
+ flush()
+ return paras
+}
+
+// extractRelevantParagraphs 返回命中关键词的段落(整段不截半句)。
+// 文档总长 <= budget 时整篇输出;无命中段落时输出空串。
+func extractRelevantParagraphs(content string, keywords []string, budget int) string {
+ if budget <= 0 || len(content) <= budget {
+ return content
+ }
+ var out []string
+ for _, p := range splitParagraphs(content) {
+ for _, kw := range keywords {
+ if strings.Contains(p, kw) {
+ out = append(out, p)
+ break
+ }
+ }
+ }
+ // 预算约束:累计超预算的段落丢弃(不截半段)
+ var total int
+ kept := []string{}
+ for _, p := range out {
+ if total+len(p) > budget {
+ break
+ }
+ total += len(p)
+ kept = append(kept, p)
+ }
+ return strings.Join(kept, "")
+}
+
+// extractWithBudget extractRelevantParagraphs 的预算版(budget<=0 视为无预算)
+func extractWithBudget(content string, keywords []string, budget int) string {
+ if budget <= 0 {
+ budget = 100000
+ }
+ return extractRelevantParagraphs(content, keywords, budget)
+}
+
+// ExploreOptions explore 参数
+type ExploreOptions struct {
+ Budget int // 字节预算(0 = 用配置默认)
+ TopN int // 0 = 用配置默认
+}
+
+// ExploredDoc 入选文档及其输出正文
+type ExploredDoc struct {
+ Path string `json:"path"`
+ Title string `json:"title"`
+ Section string `json:"section"`
+ Score int `json:"score"`
+ Body string `json:"body"`
+}
+
+// ExploreResult explore 结果
+type ExploreResult struct {
+ Docs []ExploredDoc `json:"docs"`
+ Related map[string][]string `json:"related"`
+ UnresolvedLinks []string `json:"unresolved_links"`
+}
+
+// Explore 一次调用返回相关文档原文 + 关联清单 + 悬空链接
+func Explore(store *index.Store, query string, cfg ExploreOptions) (*ExploreResult, error) {
+ keywords := strings.Fields(query)
+ if len(keywords) == 0 {
+ return nil, nil
+ }
+ budget := cfg.Budget
+ if budget <= 0 {
+ budget = 16000
+ }
+ if budget > 32000 {
+ budget = 32000
+ }
+ topN := cfg.TopN
+ if topN <= 0 {
+ topN = 5
+ }
+
+ opts := SearchOptions{TopN: topN}
+ results, err := Search(store, keywords, opts)
+ if err != nil {
+ return nil, err
+ }
+ if len(results) == 0 {
+ return &ExploreResult{Related: map[string][]string{}}, nil
+ }
+
+ // 按分数降序分配预算:每文档至少 800 字节
+ res := &ExploreResult{Related: map[string][]string{}}
+ perDoc := budget / len(results)
+ if perDoc < 800 {
+ perDoc = 800
+ }
+ for i, r := range results {
+ content, _, _, err := store.GetNodeContent(r.ID)
+ if err != nil || content == "" {
+ continue
+ }
+ body := extractRelevantParagraphs(content, keywords, perDoc)
+ if body == "" {
+ // 无命中段落但文档入选 → 整篇(若放得下),否则跳过
+ if len(content) <= perDoc {
+ body = content
+ } else {
+ continue
+ }
+ }
+ res.Docs = append(res.Docs, ExploredDoc{
+ Path: r.Path, Title: r.Title, Section: r.Section,
+ Score: r.Score, Body: body,
+ })
+ // 关联清单
+ if links, err := store.GetNodeLinks(r.ID); err == nil {
+ res.Related[r.Path] = links
+ }
+ _ = i
+ }
+
+ // 悬空链接提示:入选文档的 wikilinks 中未解析的
+ for _, d := range res.Docs {
+ links, err := store.GetUnresolvedLinks(d.Path)
+ if err == nil {
+ res.UnresolvedLinks = append(res.UnresolvedLinks, links...)
+ }
+ }
+ return res, nil
+}
+```
+
+Store 需补方法(sqlite.go):
+
+```go
+// GetUnresolvedLinks 某文档的悬空链接文本列表
+func (s *Store) GetUnresolvedLinks(path string) ([]string, error) {
+ rows, err := s.db.Query(`
+ SELECT u.link_text FROM unresolved_links u
+ JOIN nodes n ON n.id = u.from_node WHERE n.path = ?`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var links []string
+ for rows.Next() {
+ var l string
+ rows.Scan(&l)
+ links = append(links, l)
+ }
+ return links, rows.Err()
+}
+```
+
+- [ ] **Step 4: cmd/explore.go**
+
+```go
+package cmd
+
+import (
+ "fmt"
+ "os"
+
+ "github.com/aisim/kb-cli/internal/index"
+ "github.com/aisim/kb-cli/internal/search"
+ "github.com/spf13/cobra"
+)
+
+var (
+ exploreBudget int
+ exploreTopN int
+ exploreJSON bool
+)
+
+var exploreCmd = &cobra.Command{
+ Use: "explore <问题>",
+ Short: "一次调用获取相关文档原文 + 关联清单(供 agent 使用)",
+ Long: `# explore - 精准上下文
+kb-cli explore <问题> [--budget 字节] [--top N] [--json] # 按字节预算返回相关文档原文、关联文档、悬空链接`,
+ Args: cobra.MinimumNArgs(1),
+ RunE: runExplore,
+}
+
+func init() {
+ rootCmd.AddCommand(exploreCmd)
+ exploreCmd.Flags().IntVar(&exploreBudget, "budget", 0, "字节预算(0=配置默认 16000)")
+ exploreCmd.Flags().IntVar(&exploreTopN, "top", 0, "文档数(0=配置默认 5)")
+ exploreCmd.Flags().BoolVar(&exploreJSON, "json", false, "JSON 输出")
+ exploreCmd.SetUsageTemplate(exploreCmd.Long)
+}
+
+func runExplore(cmd *cobra.Command, args []string) error {
+ store, err := index.Open(dbPath)
+ if err != nil {
+ return fmt.Errorf("打开索引失败: %w", err)
+ }
+ defer store.Close()
+
+ dirty, err := index.QuickCheck(store, vaultPath)
+ if err != nil {
+ return fmt.Errorf("索引状态检查失败: %w", err)
+ }
+ if dirty {
+ fmt.Fprintln(os.Stderr, "索引有变更,正在增量同步...")
+ if err := syncIndex(store); err != nil {
+ return fmt.Errorf("增量同步失败: %w", err)
+ }
+ }
+
+ query := strings.Join(args, " ")
+ res, err := search.Explore(store, query, search.ExploreOptions{Budget: exploreBudget, TopN: exploreTopN})
+ if err != nil {
+ return fmt.Errorf("explore 失败: %w", err)
+ }
+ if len(res.Docs) == 0 {
+ fmt.Println("未找到相关文档")
+ return nil
+ }
+
+ if exploreJSON {
+ b, _ := json.MarshalIndent(res, "", " ")
+ fmt.Println(string(b))
+ return nil
+ }
+ for _, d := range res.Docs {
+ fmt.Printf("## %s(%s,score %d)\n", d.Title, d.Path, d.Score)
+ fmt.Print(d.Body)
+ fmt.Println()
+ if rel, ok := res.Related[d.Path]; ok && len(rel) > 0 {
+ fmt.Printf("关联: %s\n", strings.Join(rel, ", "))
+ }
+ fmt.Println()
+ }
+ if len(res.UnresolvedLinks) > 0 {
+ fmt.Printf("⚠️ 悬空链接: %s\n", strings.Join(res.UnresolvedLinks, ", "))
+ }
+ fmt.Println("以上为文档原文直出,agent 无需再读文件")
+ return nil
+}
+```
+
+import 需加 `encoding/json` 和 `strings`。
+
+- [ ] **Step 5: 跑测试 + 编译**
+
+Run: `CGO_CFLAGS="-DSQLITE_ENABLE_FTS5" CGO_LDFLAGS="-lm" go test ./... 2>&1 | grep -v "no test files" && CGO_CFLAGS="-DSQLITE_ENABLE_FTS5" CGO_LDFLAGS="-lm" go build -o kb-cli .`
+Expected: 全 PASS + 二进制产出
+
+- [ ] **Step 6: 手动冒烟**
+
+```bash
+CGO_CFLAGS="-DSQLITE_ENABLE_FTS5" CGO_LDFLAGS="-lm" go build -o kb-cli .
+./kb-cli index build --vault ~/rag-lpg-obsidian
+./kb-cli explore "电子秤补气失败" --top 3
+```
+Expected: 输出含补气相关文档原文段落 + 关联清单 + "原文直出"尾注
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add cmd/explore.go internal/search/explore.go internal/search/explore_test.go internal/index/sqlite.go
+git commit -m "feat: explore 命令(字节预算 + 段落级原文直出 + 关联清单 + 悬空链接提示)"
+```
+
+---
+
+### Task 7: 端到端验证 + README
+
+**Files:**
+- Modify: `README.md`(命令文档更新)
+- Create: `scripts/e2e-verify.sh`
+
+**Interfaces:**
+- Consumes: Task 1-6 全部产物
+
+- [ ] **Step 1: e2e 验证脚本**
+
+`scripts/e2e-verify.sh`:
+
+```bash
+#!/bin/bash
+# 端到端验证(对标 spec 测试计划第 2 节)
+set -e
+export CGO_CFLAGS="-DSQLITE_ENABLE_FTS5"
+export CGO_LDFLAGS="-lm"
+VAULT="${1:-$HOME/rag-lpg-obsidian}"
+cd "$(dirname "$0")/.."
+go build -o /tmp/kb-cli-e2e .
+
+echo "=== 1. 全量重建基准 ==="
+/tmp/kb-cli-e2e index build --vault "$VAULT" --force 2>&1 | tail -1
+
+echo "=== 2. 增量同步耗时 ==="
+time /tmp/kb-cli-e2e index build --vault "$VAULT" 2>&1 | tail -1
+
+echo "=== 3. 未 commit 编辑可见性 ==="
+TESTFILE="$VAULT/笔记/e2e-test-$(date +%s).md"
+printf -- "---\ntitle: e2e测试\ntags: [补气]\n---\ne2e 补气测试内容\n" > "$TESTFILE"
+/tmp/kb-cli-e2e search e2e --vault "$VAULT" --top 3 2>&1 | grep -q "e2e测试" && echo "PASS: 未commit编辑可见" || echo "FAIL: 未commit编辑不可见"
+rm -f "$TESTFILE"
+/tmp/kb-cli-e2e index build --vault "$VAULT" 2>/dev/null
+
+echo "=== 4. CJK 召回质量(补气应命中多条)==="
+N=$(/tmp/kb-cli-e2e search 补气 --vault "$VAULT" --top 10 2>/dev/null | grep -c "^文档/\|^FAQ/\|^知识/" || true)
+echo "补气 命中 $N 条 (期望 >= 5)"
+
+echo "=== 5. explore 预算内 ==="
+BYTES=$(/tmp/kb-cli-e2e explore "电子秤补气失败" --vault "$VAULT" --top 3 2>/dev/null | wc -c)
+echo "explore 输出 ${BYTES} 字节 (上限 32000+尾注)"
+
+echo "=== 6. 悬空补全 ==="
+/tmp/kb-cli-e2e graph stats --vault "$VAULT" 2>&1 | head -5
+```
+
+- [ ] **Step 2: 跑 e2e**
+
+Run: `bash scripts/e2e-verify.sh`
+Expected: 第 3 项 PASS;第 4 项 N>=5(改造前实测为 1);第 5 项 BYTES <= 32200
+
+- [ ] **Step 3: README 更新**
+
+README.md 命令章节更新:
+- `index build` 说明改"默认增量对账同步,`--force` 全量重建"
+- `search` 说明加"ASCII 走 FTS5、CJK 走 LIKE 双通道;排序 = 文本分 + RWR 图质量双信号"
+- 新增 `explore` 命令说明
+- 新增"构建要求"章节:`CGO_CFLAGS="-DSQLITE_ENABLE_FTS5" CGO_LDFLAGS="-lm" go build`(或 make build)
+- `graph query/related` 输出含 provenance 标注说明
+
+- [ ] **Step 4: 最终全量测试 + 提交推送**
+
+```bash
+CGO_CFLAGS="-DSQLITE_ENABLE_FTS5" CGO_LDFLAGS="-lm" go test ./... 2>&1 | grep -v "no test files"
+git add README.md scripts/e2e-verify.sh
+git commit -m "docs: e2e 验证脚本 + README 更新(增量同步/explore/构建要求)"
+git push
+```
+
+---
+
+## Self-Review
+
+**Spec 覆盖检查:**
+- §1.1 RWR → Task 5 Step 1-5 ✅
+- §1.2 aliases 进 FTS → Task 1(FTS 表加列)+ Task 5 Step 6-7 ✅
+- §1.3 status 降权 → Task 5 Step 7 ✅
+- §2.1 provenance → Task 3 ✅
+- §2.2 悬空补全 → Task 3(入表)+ Task 4(重试)✅
+- §3.1 对账 → Task 2 ✅
+- §3.2 FTS external-content → Task 1 Step 6 ✅
+- §3.3 schema 迁移 + 备份 → Task 1 Step 4 ✅
+- §4 explore → Task 6 ✅
+- §5 config → Task 5 Step 7(Config 加节)+ Task 6(读 config 缺省值)✅
+- 测试计划 5 项 → Task 1-6 各单测 + Task 7 e2e ✅
+
+**额外修复(spec 外,实测发现的既有 bug):** CJK 词 FTS5 MATCH 失效 → Task 5 的 LIKE 通道。此为搜索质量层的必要组成部分,已在 spec 的"搜索质量提升"目标内。
+
+**类型一致性:** `graph.Edge.Provenance`(Task 3 定义,Task 4/5 使用);`index.FileStat`(Task 2 定义);`search.ExploreOptions`(Task 6 定义);`vault.FileMeta.Status/Aliases`(Task 2 定义,Task 3 的 graph.Node 对齐)。`RetryUnresolved` 签名 Task 2 占位 `() {}` → Task 4 改 `(int, error)`——Task 2 的 reconcile 调用处写 `store.RetryUnresolved()` 不接收返回值,兼容两种签名。✅
+
+**Placeholder 扫描:** 无 TBD/TODO;所有代码步骤含完整代码。Task 2 Step 5 的 `resolveWikilink` 与 Task 3 的 `matchWikilink` 是两套实现(DB 版/内存版),规则一致,注释已说明。✅
diff --git a/docs/superpowers/specs/2026-09-03-kb-cli-codegraph-alignment-design.md b/docs/superpowers/specs/2026-09-03-kb-cli-codegraph-alignment-design.md
new file mode 100644
index 0000000..b3f06da
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-03-kb-cli-codegraph-alignment-design.md
@@ -0,0 +1,236 @@
+# kb-cli 对标 CodeGraph 增强设计
+
+**日期**: 2026-09-03
+**状态**: 已批准(Lexi 确认方案 C 全量对标)
+**版本**: v1.0.0
+
+## 背景
+
+kb-cli 是 Go 实现的知识库索引与检索 CLI(SQLite + FTS5 + 知识图谱),服务 LPG 知识库(`~/rag-lpg-obsidian`,约 768 个 .md 文件)。
+
+CodeGraph(colbymchenry/codegraph)是代码知识图谱工具,其核心机制经源码分析确认:
+
+1. **双信号排序**:FTS5/bm25 文本信号 + Random-Walk-with-Restart(个性化 PageRank,α=0.25)图结构信号。纯文本命中但调用图不连通的符号自然沉底
+2. **悬空引用留痕**:`unresolved_refs` 表记录解析失败的引用(failed + name_tail),下次同步有新符号出现时自动重试
+3. **三层新鲜度**:文件事件 + (size, mtime, content-hash) 对账 + 连接时追平;增量同步成本只与改动量成正比
+4. **输出预算控制**:explore 一次调用返回相关源码原文(带字节预算)+ 调用路径 + 影响面,明确标注"原文直出,agent 无需再读文件"
+5. **边可信度**:边带 `provenance` 字段(静态解析 vs 启发式合成),消费方一眼区分
+6. **低价值内容降权**:generated 文件、测试文件在排序中降权垫底
+
+kb-cli 现状痛点(源码分析确认):
+
+| 痛点 | 位置 |
+|------|------|
+| 排序纯文本加权,无图结构信号 | `internal/search/engine.go` |
+| aliases 已解析但搜索完全未用 | `internal/vault/parser.go` / `internal/index/fts.go` |
+| 索引新鲜度靠 git commit hash 比对,未 commit 的编辑不可见 | `internal/index/cache.go` |
+| 索引变更即全量重建,无增量 | `cmd/index.go` / `cmd/rebuild.go` |
+| wikilink 模糊匹配(去编号前缀/标题部分匹配)可能产生假边,无可信度标注 | `internal/graph/builder.go` |
+| 悬空 wikilink 直接丢弃,无留痕无自动补全 | `internal/graph/builder.go` |
+| 搜索结果全量 dump 内容,无预算控制 | `internal/search/engine.go` WithContent |
+
+## 目标
+
+1. 搜索排序引入图结构信号,售后场景(症状词查询)召回质量显著提升
+2. 悬空双链自动补全:新文档入库后历史文档指向它的链接自动解析
+3. 增量同步:未 commit 的编辑在下次 search/index 时可见,变更只重解析变更文件
+4. `explore` 命令供 Hermes agent 一次调用拿到完整上下文(原文 + 关联 + 预算控制)
+5. 边带可信度标注,排序与展示区分精确边和启发式边
+
+## 非目标
+
+- 不引入文件事件 watcher(知识库写入频率低,命令触发式对账足够;codegraph 的 watcher 面向高频编辑的代码库)
+- 不引入 embedding / LLM 参与排序(RWR 是确定性算法,零 API 成本,保持可复现)
+- 不改变 vault 目录结构和编号规则
+- 不改 draft/classify/review 等 LLM 录入链路
+
+## 设计
+
+### 1. 搜索质量层
+
+#### 1.1 RWR 图结构排序
+
+新增 `internal/graph/rwr.go`:
+
+- **种子**:FTS5 命中的节点 ID 集合(去重,上限 20 个)
+- **邻接**:无向图。边类型纳入排序:wikilink(全权重)、entity(全权重)、tag(半权重——tag 虚拟节点扇出大,区分度低)
+- **算法**:power iteration,restart 概率 α=0.25(种子均匀分布),收敛阈值 1e-6,上限 50 次迭代。768 节点规模下单次计算 <1ms
+- **输出**:`map[nodeID]float64`(游走质量),归一化到 [0,1]
+
+最终排序分(`internal/search/engine.go` 修改):
+
+```
+finalScore = norm(textScore) * textWeight + rwrMass * (1 - textWeight)
+```
+
+- `norm(textScore)`:本批次内 min-max 归一化到 [0,1](textScore 沿用现有位置加权:path/title/tag/content + 通用词/实体词降权;批次只有 1 条结果时 norm=1)
+- `rwrMass`:已归一化 [0,1]
+- textWeight 可配置(config.yaml `search.text_weight`,默认 0.5),两信号量纲对齐后直接加权
+
+#### 1.2 aliases 进 FTS
+
+- `nodes` 表加列 `aliases TEXT`(JSON 数组),parser 已解析 frontmatter aliases,直接落库
+- FTS5 虚拟表改为 external-content 模式(见 §3.2),aliases 列纳入
+- bm25 列权重:`aliases 4.0, title 3.0, content 1.0`(aliases 是 FAQ 症状词主战场,最高权重)
+- `FTSSearch` 对 aliases 列的命中在 textScore 中按 title 档计权
+
+#### 1.3 status 降权
+
+- `nodes` 表加列 `status TEXT`(parser 从 frontmatter 提取,缺省视为"已解决")
+- 排序系数:status ∈ {草稿, 待确认, 跟进中} 或 section = `待审阅` 时 finalScore × 0.6
+- 结果展示中标注 status(`--json` 输出含 status 字段)
+
+### 2. 图谱质量层
+
+#### 2.1 边可信度(provenance)
+
+`edges` 表加列 `provenance TEXT`:
+
+| 值 | 含义 |
+|----|------|
+| `exact` | wikilink 文本 == 目标标题,或 == 文件名(去 .md 和编号前缀)精确匹配 |
+| `fuzzy` | 标题包含关系等模糊匹配(现有 `matchesWikilink` 的非精确分支) |
+| `tag` / `entity` | tag/entity 虚拟边(非 wikilink,不适用可信度概念,但统一存值) |
+
+消费规则:RWR 邻接中 fuzzy 边权重 ×0.5;`graph query` / `graph related` 输出标注 provenance。
+
+#### 2.2 悬空链接留痕 + 自动补全
+
+新增表:
+
+```sql
+CREATE TABLE IF NOT EXISTS unresolved_links (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ from_node INTEGER NOT NULL,
+ link_text TEXT NOT NULL,
+ name_tail TEXT NOT NULL, -- link_text 去掉 [[ 内的锚点/标题修饰后的尾部
+ status TEXT NOT NULL DEFAULT 'pending', -- pending | failed
+ created_at TEXT DEFAULT (datetime('now')),
+ last_attempt TEXT
+);
+CREATE INDEX idx_unresolved_tail ON unresolved_links(name_tail) WHERE status='failed';
+```
+
+- **索引时**:wikilink 未匹配到任何节点 → 插入行(status=pending);匹配到但为 fuzzy → 建边(provenance=fuzzy)
+- **重试时机**:每次增量同步(§3.1)后执行 `retryUnresolved`:
+ 1. 对本批新增/变更节点,以其 title 和文件名(去编号前缀)查 `unresolved_links` 的 name_tail 精确命中
+ 2. 命中且满足 exact 规则 → 建边(provenance=exact),删行
+ 3. 命中仅 fuzzy 规则 → 建边(provenance=fuzzy),删行;同时输出提示"建议将双链改为精确标题"
+ 4. 全量重建(`--force`)时清空表并重新解析
+- **效果**:新文档入库 → 历史文档中指向它的悬空双链自动补全,无需人工回改
+
+### 3. 增量同步层
+
+#### 3.1 对账机制(替代 commit hash 全量重建)
+
+`internal/index/cache.go` 重写为 `reconcile.go`:
+
+1. 扫描 vault 得到文件清单 `{path: (size, mtime, sha256)}`(sha256 仅对 size/mtime 变化的文件计算,避免全库哈希开销)
+2. 与 nodes 表记录比对,分四类:
+ - **新增**:vault 有、库中没有 → 解析、建节点、建边
+ - **修改**:size/mtime 变化且 sha256 不同 → 重解析该文件,更新节点行,删该文件相关边后重建(tag/entity 边 + 该文件作为 from 的 wikilink 边);该文件作为 to 的边不受影响
+ - **删除**:库中有、vault 没有 → 删节点,ON DELETE CASCADE 删相关边;unresolved_links 中 from_node 指向它的行一并删除
+ - **未变**:跳过
+3. 执行 `retryUnresolved`(§2.2)
+4. `meta.git_commit` 仍记录(供 status 展示),但**不再作为重建判据**
+
+`index build` 默认走增量对账路径;`index build --force` 保留全量重建(ClearData + 重建 + 清空 unresolved_links)。
+
+`NeedsRebuild` 逻辑废弃:`search` 命令的 pre-flight 改为"快速对账"——只比对 size/mtime(不读内容不哈希),有差异才触发完整对账。768 文件 stat 开销 <50ms。
+
+#### 3.2 FTS external-content + 触发器
+
+```sql
+CREATE VIRTUAL TABLE nodes_fts USING fts5(
+ title, content, tags, aliases,
+ content='nodes', content_rowid='id',
+ tokenize='unicode61'
+);
+-- 触发器:nodes 的 INSERT/DELETE/UPDATE 同步维护 nodes_fts
+```
+
+- 删除 `PopulateFTS` 全量重建路径,增量维护
+- `--force` 全量重建时执行 `INSERT INTO nodes_fts(nodes_fts) VALUES('rebuild')` 一次性重灌
+
+#### 3.3 schema 迁移
+
+新增 `schema_versions` 表(同 codegraph)。`Open` 时按版本顺序应用迁移:
+
+- v1 → 现有 schema(老库兼容)
+- v2:nodes 加 aliases/status 列;edges 加 provenance 列;unresolved_links 建表;FTS 改 external-content(rebuild 重灌)
+
+迁移幂等(列已存在则跳过),老库自动升级,无需手动重建。
+
+### 4. explore 命令
+
+`cmd/explore.go`(新):
+
+```
+kb-cli explore <问题> [--budget 16000] [--top 5] [--json]
+```
+
+流程:
+
+1. 查询词按空白切分为关键词,走 §1 的 FTS + RWR 双信号排序
+2. 取 top N(默认 5)文档,按 `--budget`(默认 16000 字节,硬上限 32000)分配预算:按 finalScore 降序依次分配,每文档至少 800 字节,超预算的文档截断到整段边界
+3. **段落级截取**:文档按 markdown 标题(`#`~`####`)切段,只输出命中查询词的段落(整段不截半句);文档总长 < 分配预算时整篇输出
+4. 关联清单:每个入选文档的 wikilink 目标(exact 边优先,fuzzy 边标注)
+5. 悬空链接提示:入选文档中未解析的 wikilink 列表
+6. 尾部标注:`以上为文档原文直出(含行号),agent 无需再读文件`;`--json` 输出结构化
+
+预算诊断:`KB_EXPLORE_DEBUG=1` 时 stderr 输出每文档分配/实际/占比表(对标 codegraph 的 explore 诊断,默认关闭且不影响输出字节)。
+
+### 5. 配置
+
+config.yaml 新增:
+
+```yaml
+search:
+ text_weight: 0.5 # 文本分权重(图分权重 = 1 - text_weight)
+explore:
+ default_budget: 16000 # 默认字节预算
+ hard_budget: 32000 # 硬上限
+ top_n: 5
+```
+
+## 数据流
+
+```
+vault 扫描(stat对账) → 变更文件解析(parser) → 节点/边增量写入(SQLite+FTS触发器)
+ ↘ 悬空链接入 unresolved_links
+查询: 关键词 → FTS5(bm25) → 种子 → RWR(图质量) → 双信号排序 → [search 列表 | explore 原文+关联]
+```
+
+## 测试计划
+
+**单元测试**(Go test):
+- `rwr_test.go`:收敛性、种子外孤立节点质量≈0、tag 半权重生效
+- `reconcile_test.go`:新增/修改/删除/未变四场景,边重建正确性
+- `unresolved_test.go`:悬空入表 → 新节点入库 → 自动解析建边删行
+- `migration_test.go`:v1 老库 → v2 迁移幂等,FTS 重灌后查询正确
+- `explore_test.go`:预算分配、段落截取不截半句、硬上限
+
+**端到端验证**(`~/rag-lpg-obsidian` 768 文件实库):
+1. 旧版索引 vs 新版索引,同一组售后查询(含症状词,如"红绿闪""充气失败")排序对比:目标文档 rank 提升
+2. 增量同步耗时 vs 全量重建耗时
+3. 未 commit 编辑:修改一个文档后直接 `kb-cli search`,新内容可见
+4. 悬空补全:新建一个文档标题匹配既有悬空链接,确认双链自动建边
+5. `explore` 输出在预算内且原文与文件一致(diff 校验)
+
+## 实施顺序
+
+1. schema v2 迁移 + FTS external-content(基础设施,其他全部依赖它)
+2. 增量对账(reconcile)
+3. RWR + 双信号排序 + aliases + status 降权
+4. provenance + unresolved_links 自动补全
+5. explore 命令
+6. 端到端验证 + 文档(README 命令更新)
+
+## 风险与缓解
+
+| 风险 | 缓解 |
+|------|------|
+| RWR 权重调不当导致排序退化 | text_weight 可配置;端到端用固定查询集回归对比,退化即回调 |
+| 增量对账边界 case(git mv、文件重命名) | 重命名 = 删+增,正确性不受影响;mtime 未变但内容变的极端 case 由 sha256 二次确认兜底 |
+| 老库迁移破坏现有索引 | 迁移前自动备份 .db 文件(cp 到 .db.bak);迁移失败可回滚 |
+| explore 段落截取对无标题结构的文档失效 | 无标题文档整篇输出(受预算约束) |
diff --git a/go.env b/go.env
new file mode 100644
index 0000000..76ef903
--- /dev/null
+++ b/go.env
@@ -0,0 +1,2 @@
+CGO_CFLAGS=-DSQLITE_ENABLE_FTS5
+CGO_LDFLAGS=-lm
diff --git a/internal/graph/builder.go b/internal/graph/builder.go
index ce5f0ea..9c89e7a 100644
--- a/internal/graph/builder.go
+++ b/internal/graph/builder.go
@@ -7,27 +7,22 @@
"github.com/aisim/kb-cli/internal/vault"
)
-// BuildGraph 从文件元数据构建知识图谱
-func BuildGraph(files []*vault.FileMeta) *Graph {
+// BuildGraph 从文件元数据构建知识图谱,返回图和未解析的悬空链接列表
+func BuildGraph(files []*vault.FileMeta) (*Graph, []*UnresolvedLink) {
g := &Graph{}
+ unresolved := make([]*UnresolvedLink, 0)
// 标签/实体 -> 虚拟节点 ID 映射
labelToID := make(map[string]int64)
nextVirtualID := int64(1000000) // 虚拟节点从 1000000 开始
- getOrCreateVirtualNode := func(label, nodeType string) int64 {
+ getOrCreateVirtualNode := func(label string) int64 {
if id, ok := labelToID[label]; ok {
return id
}
id := nextVirtualID
nextVirtualID++
labelToID[label] = id
- g.Nodes = append(g.Nodes, &Node{
- ID: id,
- Path: label, // label 已含 "tag:"/"entity:" 前缀
- Title: strings.TrimPrefix(strings.TrimPrefix(label, "tag:"), "entity:"),
- NodeType: nodeType,
- })
return id
}
@@ -37,7 +32,6 @@
Path: f.Path,
Title: f.Title,
Section: f.Section,
- NodeType: "file",
Tags: f.Tags,
Entities: f.Entities,
Wikilinks: f.Wikilinks,
@@ -47,66 +41,88 @@
// 创建 tag 边
for _, tag := range f.Tags {
- virtualID := getOrCreateVirtualNode("tag:"+tag, "tag")
+ virtualID := getOrCreateVirtualNode("tag:" + tag)
g.Edges = append(g.Edges, &Edge{
- FromNode: node.ID,
- ToNode: virtualID,
- Relation: "tag",
- Label: tag,
+ FromNode: node.ID,
+ ToNode: virtualID,
+ Relation: "tag",
+ Label: tag,
+ Provenance: "tag",
})
}
// 创建 entity 边
for _, entity := range f.Entities {
- virtualID := getOrCreateVirtualNode("entity:"+entity, "entity")
+ virtualID := getOrCreateVirtualNode("entity:" + entity)
g.Edges = append(g.Edges, &Edge{
- FromNode: node.ID,
- ToNode: virtualID,
- Relation: "entity",
- Label: entity,
+ FromNode: node.ID,
+ ToNode: virtualID,
+ Relation: "entity",
+ Label: entity,
+ Provenance: "entity",
})
}
}
- // 创建 wikilink 边(文件间链接)
- pathToID := make(map[string]int64)
- for _, n := range g.Nodes {
- pathToID[n.Path] = n.ID
- }
-
+ // 创建 wikilink 边(文件间链接),未匹配的记入悬空链接列表
for _, n := range g.Nodes {
for _, link := range n.Wikilinks {
- // 尝试匹配目标文件(模糊匹配:链接文本可能只是标题的一部分)
- for _, target := range g.Nodes {
- if matchesWikilink(target, link) {
- g.Edges = append(g.Edges, &Edge{
- FromNode: n.ID,
- ToNode: target.ID,
- Relation: "wikilink",
- Label: link,
- })
- break
- }
+ target, prov := ResolveWikilink(g.Nodes, link)
+ if target == nil {
+ unresolved = append(unresolved, &UnresolvedLink{
+ FromNode: n.ID, LinkText: link, NameTail: NameTail(link),
+ })
+ continue
}
+ g.Edges = append(g.Edges, &Edge{
+ FromNode: n.ID,
+ ToNode: target.ID,
+ Relation: "wikilink",
+ Label: link,
+ Provenance: prov,
+ })
}
}
- return g
+ return g, unresolved
}
-// matchesWikilink 检查文件是否匹配 wikilink
-func matchesWikilink(node *Node, link string) bool {
- // 精确匹配标题
- if node.Title == link {
- return true
+// ResolveWikilink 解析 wikilink 目标,返回 (目标节点, provenance)。
+// exact = 标题/文件名精确;fuzzy = 标题包含。纯内存版,规则与 index 包 DB 版一致。
+func ResolveWikilink(nodes []*Node, link string) (*Node, string) {
+ // 去锚点:[[标题|别名]] 取标题部分
+ if idx := strings.Index(link, "|"); idx >= 0 {
+ link = link[:idx]
}
- // 匹配文件名(不含扩展名和编号前缀)
- // 例如:[[充装规格配置]] 匹配 "知识/002-充装规格配置.md"
- base := filepath.Base(node.Path)
- base = strings.TrimSuffix(base, ".md")
- // 去掉编号前缀(如 "002-")
- if idx := strings.Index(base, "-"); idx >= 0 {
- base = base[idx+1:]
+ var fuzzy *Node
+ for _, node := range nodes {
+ if node.Title == link {
+ return node, "exact"
+ }
+ // 匹配文件名(去 .md 和编号前缀),如 [[充装规格配置]] 匹配 "知识/002-充装规格配置.md"
+ base := filepath.Base(node.Path)
+ base = strings.TrimSuffix(base, ".md")
+ if dash := strings.Index(base, "-"); dash >= 0 {
+ base = base[dash+1:]
+ }
+ if base == link {
+ return node, "exact"
+ }
+ // 空 link 跳过 fuzzy(Contains 对空串恒真)
+ if fuzzy == nil && link != "" && strings.Contains(node.Title, link) {
+ fuzzy = node
+ }
}
- return base == link || node.Title == link
+ if fuzzy != nil {
+ return fuzzy, "fuzzy"
+ }
+ return nil, ""
+}
+
+// NameTail 取 link 尾部用于悬空链接重试匹配(去锚点修饰)
+func NameTail(link string) string {
+ if idx := strings.Index(link, "|"); idx >= 0 {
+ link = link[:idx]
+ }
+ return link
}
diff --git a/internal/graph/builder_test.go b/internal/graph/builder_test.go
index a966c86..afd95de 100644
--- a/internal/graph/builder_test.go
+++ b/internal/graph/builder_test.go
@@ -1,12 +1,46 @@
package graph
import (
- "strings"
"testing"
"github.com/aisim/kb-cli/internal/vault"
)
+func TestBuildGraphProvenance(t *testing.T) {
+ files := []*vault.FileMeta{
+ {Path: "FAQ/001-补气失败.md", Title: "补气失败", Wikilinks: []string{"红绿闪", "不存在的链接"}},
+ {Path: "FAQ/002-红绿闪.md", Title: "红绿闪"},
+ {Path: "知识/003-称重原理详解.md", Title: "称重原理详解"}, // "称重原理" 是 "称重原理详解" 的子串 → fuzzy
+ }
+ files[0].Wikilinks = append(files[0].Wikilinks, "称重原理")
+
+ g, unresolved := BuildGraph(files)
+
+ // exact: 标题精确匹配
+ var exact, fuzzy int
+ for _, e := range g.Edges {
+ if e.Relation != "wikilink" {
+ continue
+ }
+ switch e.Provenance {
+ case "exact":
+ exact++
+ case "fuzzy":
+ fuzzy++
+ }
+ }
+ if exact != 1 || fuzzy != 1 {
+ t.Errorf("provenance 分布: exact=%d fuzzy=%d (want 1/1)", exact, fuzzy)
+ }
+ // 悬空链接
+ if len(unresolved) != 1 || unresolved[0].LinkText != "不存在的链接" {
+ t.Errorf("unresolved: %+v", unresolved)
+ }
+ if unresolved[0].NameTail != "不存在的链接" {
+ t.Errorf("name_tail: %s", unresolved[0].NameTail)
+ }
+}
+
func TestBuildGraph(t *testing.T) {
files := []*vault.FileMeta{
{
@@ -29,35 +63,10 @@
},
}
- g := BuildGraph(files)
+ g, _ := BuildGraph(files)
- // 2 个文件节点 + 2 个 tag 虚拟节点(充装、智能枪)+ 1 个 entity 虚拟节点
- if len(g.Nodes) != 5 {
- t.Errorf("node count = %d, want 5", len(g.Nodes))
- }
-
- // 验证虚拟节点 path 命名空间与 NodeType
- tagCount, entityCount, fileCount := 0, 0, 0
- for _, n := range g.Nodes {
- switch n.NodeType {
- case "tag":
- tagCount++
- if !strings.HasPrefix(n.Path, "tag:") {
- t.Errorf("tag node path = %q, want prefix tag:", n.Path)
- }
- case "entity":
- entityCount++
- if !strings.HasPrefix(n.Path, "entity:") {
- t.Errorf("entity node path = %q, want prefix entity:", n.Path)
- }
- case "file":
- fileCount++
- default:
- t.Errorf("unexpected node_type %q for %s", n.NodeType, n.Path)
- }
- }
- if tagCount != 2 || entityCount != 1 || fileCount != 2 {
- t.Errorf("file/tag/entity = %d/%d/%d, want 2/2/1", fileCount, tagCount, entityCount)
+ if len(g.Nodes) != 2 {
+ t.Errorf("node count = %d, want 2", len(g.Nodes))
}
// 应该有 3 条边:2条 tag + 1条 entity
diff --git a/internal/graph/model.go b/internal/graph/model.go
index bcad193..06a70c2 100644
--- a/internal/graph/model.go
+++ b/internal/graph/model.go
@@ -2,23 +2,35 @@
// Node 图节点(对应一个 markdown 文件)
type Node struct {
- ID int64 `json:"id"`
- Path string `json:"path"`
- Title string `json:"title"`
- Section string `json:"section"`
- NodeType string `json:"node_type"` // "file" | "tag" | "entity"
- Tags []string `json:"tags"`
- Entities []string `json:"entities"`
- Wikilinks []string `json:"wikilinks"`
- Content string `json:"content"`
+ ID int64 `json:"id"`
+ Path string `json:"path"`
+ Title string `json:"title"`
+ Section string `json:"section"`
+ Tags []string `json:"tags"`
+ Entities []string `json:"entities"`
+ Aliases []string `json:"aliases"`
+ Status string `json:"status"`
+ Wikilinks []string `json:"wikilinks"`
+ Content string `json:"content"`
+ Size int64 `json:"size"`
+ Mtime int64 `json:"mtime"`
+ ContentHash string `json:"content_hash"`
}
// Edge 图边(实体关系)
type Edge struct {
- FromNode int64 `json:"from_node"`
- ToNode int64 `json:"to_node"` // 对于 tag/entity 边,ToNode 可以是虚拟节点 ID
- Relation string `json:"relation"` // "tag" | "entity" | "wikilink"
- Label string `json:"label"` // 具体值
+ FromNode int64 `json:"from_node"`
+ ToNode int64 `json:"to_node"` // 对于 tag/entity 边,ToNode 可以是虚拟节点 ID
+ Relation string `json:"relation"` // "tag" | "entity" | "wikilink"
+ Label string `json:"label"` // 具体值
+ Provenance string `json:"provenance"` // "exact" | "fuzzy" | "tag" | "entity"
+}
+
+// UnresolvedLink 悬空 wikilink(未匹配到目标节点)
+type UnresolvedLink struct {
+ FromNode int64 // BuildGraph 内部节点 ID
+ LinkText string
+ NameTail string
}
// Graph 知识图谱
diff --git a/internal/graph/rwr.go b/internal/graph/rwr.go
new file mode 100644
index 0000000..d1d2252
--- /dev/null
+++ b/internal/graph/rwr.go
@@ -0,0 +1,106 @@
+package graph
+
+// AdjEdge 带权邻接边(导出:index 包 LoadRWRGraph 需引用该类型)
+type AdjEdge struct {
+ To int64
+ Weight float64
+}
+
+// RWR Random-Walk-with-Restart(个性化 PageRank):
+// 从种子集合出发在无向带权图上做 power iteration,restart 概率 alpha 回到种子。
+// 返回归一化到 [0,1] 的游走质量。确定性算法,无随机数。
+func RWR(seedIDs []int64, adj map[int64][]AdjEdge, alpha float64) map[int64]float64 {
+ if len(seedIDs) == 0 {
+ return map[int64]float64{}
+ }
+ // 收集参与节点:种子 + 邻接可达
+ nodes := make(map[int64]bool)
+ for _, s := range seedIDs {
+ nodes[s] = true
+ }
+ for from := range adj {
+ nodes[from] = true
+ for _, e := range adj[from] {
+ nodes[e.To] = true
+ }
+ }
+ n := len(nodes)
+ if n == 0 {
+ return map[int64]float64{}
+ }
+ idx := make(map[int64]int, n)
+ ids := make([]int64, 0, n)
+ for id := range nodes {
+ idx[id] = len(ids)
+ ids = append(ids, id)
+ }
+
+ // 重启向量:种子均匀
+ r := make([]float64, n)
+ for _, s := range seedIDs {
+ if i, ok := idx[s]; ok {
+ r[i] = 1.0 / float64(len(seedIDs))
+ }
+ }
+
+ const maxIter = 50
+ const eps = 1e-6
+ newR := make([]float64, n)
+ for iter := 0; iter < maxIter; iter++ {
+ for i := range newR {
+ newR[i] = 0
+ }
+ // 游走传播:out[i] = sum(w_ij)
+ for i, id := range ids {
+ var total float64
+ for _, e := range adj[id] {
+ total += e.Weight
+ }
+ if total == 0 {
+ continue
+ }
+ for _, e := range adj[id] {
+ j, ok := idx[e.To]
+ if !ok {
+ continue
+ }
+ newR[j] += (1 - alpha) * r[i] * e.Weight / total
+ }
+ }
+ // restart
+ for i := range newR {
+ newR[i] += alpha * r[i]
+ }
+ // 收敛判断
+ diff := 0.0
+ for i := range newR {
+ d := newR[i] - r[i]
+ if d < 0 {
+ d = -d
+ }
+ diff += d
+ }
+ r, newR = newR, r
+ if diff < eps {
+ break
+ }
+ }
+ // 归一化 [0,1]
+ maxV := 0.0
+ for _, v := range r {
+ if v > maxV {
+ maxV = v
+ }
+ }
+ out := make(map[int64]float64, n)
+ if maxV == 0 {
+ for _, id := range ids {
+ out[id] = 0
+ }
+ return out
+ }
+ for i, id := range ids {
+ out[id] = r[i] / maxV
+ }
+ return out
+}
diff --git a/internal/graph/rwr_test.go b/internal/graph/rwr_test.go
new file mode 100644
index 0000000..a4111fe
--- /dev/null
+++ b/internal/graph/rwr_test.go
@@ -0,0 +1,37 @@
+package graph
+
+import "testing"
+
+func TestRWRConvergenceAndSeed(t *testing.T) {
+ // 图: A-B-C 链 + D 孤立
+ adj := map[int64][]AdjEdge{
+ 1: {{2, 1}}, 2: {{1, 1}, {3, 1}}, 3: {{2, 1}}, 4: {{}},
+ }
+ r := RWR([]int64{1}, adj, 0.25)
+ if r[1] <= 0 {
+ t.Fatal("种子节点质量必须 > 0")
+ }
+ // 与种子连通的质量应高于孤立节点
+ if r[4] >= r[3] {
+ t.Errorf("孤立节点质量不应高于连通节点: r4=%f r3=%f", r[4], r[3])
+ }
+ // 归一化 [0,1]
+ for _, v := range r {
+ if v < 0 || v > 1 {
+ t.Errorf("质量未归一化: %f", v)
+ }
+ }
+}
+
+func TestRWRWeightedByProvenance(t *testing.T) {
+ // A -exact-> B, A -fuzzy-> C:B 的质量应高于 C
+ adj := map[int64][]AdjEdge{
+ 1: {{2, 1.0}, {3, 0.5}},
+ 2: {{1, 1.0}},
+ 3: {{1, 0.5}},
+ }
+ r := RWR([]int64{1}, adj, 0.25)
+ if r[2] <= r[3] {
+ t.Errorf("exact 边节点质量应高于 fuzzy: r2=%f r3=%f", r[2], r[3])
+ }
+}
diff --git a/internal/index/cache.go b/internal/index/cache.go
index b54860c..74b64c7 100644
--- a/internal/index/cache.go
+++ b/internal/index/cache.go
@@ -14,35 +14,3 @@
}
return strings.TrimSpace(string(out)), nil
}
-
-// NeedsRebuild 检查是否需要重建索引
-func NeedsRebuild(store *Store, vaultPath string) (bool, string, error) {
- currentCommit, err := GetGitCommit(vaultPath)
- if err != nil {
- // 不是 git 仓库,总是需要重建
- return true, "", nil
- }
-
- storedCommit, err := store.GetMeta("git_commit")
- if err != nil {
- return true, currentCommit, nil
- }
-
- if storedCommit == "" {
- // 没有记录,需要重建
- return true, currentCommit, nil
- }
-
- if storedCommit != currentCommit {
- // commit 变了,需要重建
- return true, currentCommit, nil
- }
-
- // 检查是否有节点
- count, _ := store.NodeCount()
- if count == 0 {
- return true, currentCommit, nil
- }
-
- return false, currentCommit, nil
-}
diff --git a/internal/index/fts.go b/internal/index/fts.go
index 954eaaf..552b428 100644
--- a/internal/index/fts.go
+++ b/internal/index/fts.go
@@ -12,28 +12,50 @@
Title string
Section string
Rank float64
+ Aliases []string // 别名(JSON 解析)
+ Status string // 状态(草稿/待确认/跟进中 等参与降权)
}
-// CreateFTS 创建 FTS5 虚拟表
+// CreateFTS 创建 FTS5 虚拟表(external-content 模式,由触发器增量维护)
+// 注意:external-content 模式下 FTS 列名必须与 content 表列名一致,
+// 故 content 列对应 nodes 表的 content_fts 列
func (s *Store) CreateFTS() error {
_, err := s.db.Exec(`
CREATE VIRTUAL TABLE IF NOT EXISTS nodes_fts USING fts5(
- title,
- content,
- tags,
- entities
- )
- `)
- return err
+ title, content_fts, tags, aliases,
+ content='nodes', content_rowid='id'
+ )`)
+ if err != nil {
+ return err
+ }
+ // 触发器:nodes 的增删改同步维护 FTS
+ triggers := []string{
+ `CREATE TRIGGER IF NOT EXISTS nodes_fts_ai AFTER INSERT ON nodes BEGIN
+ INSERT INTO nodes_fts(rowid, title, content_fts, tags, aliases)
+ VALUES (new.id, new.title, new.content_fts, new.tags, new.aliases);
+ END`,
+ `CREATE TRIGGER IF NOT EXISTS nodes_fts_ad AFTER DELETE ON nodes BEGIN
+ INSERT INTO nodes_fts(nodes_fts, rowid, title, content_fts, tags, aliases)
+ VALUES ('delete', old.id, old.title, old.content_fts, old.tags, old.aliases);
+ END`,
+ `CREATE TRIGGER IF NOT EXISTS nodes_fts_au AFTER UPDATE ON nodes BEGIN
+ INSERT INTO nodes_fts(nodes_fts, rowid, title, content_fts, tags, aliases)
+ VALUES ('delete', old.id, old.title, old.content_fts, old.tags, old.aliases);
+ INSERT INTO nodes_fts(rowid, title, content_fts, tags, aliases)
+ VALUES (new.id, new.title, new.content_fts, new.tags, new.aliases);
+ END`,
+ }
+ for _, t := range triggers {
+ if _, err := s.db.Exec(t); err != nil {
+ return err
+ }
+ }
+ return nil
}
-// PopulateFTS 填充 FTS 索引
+// PopulateFTS 全量重灌 FTS(external-content 模式专用语法)
func (s *Store) PopulateFTS() error {
- _, err := s.db.Exec(`
- INSERT INTO nodes_fts(rowid, title, content, tags, entities)
- SELECT id, title, content_fts, tags, entities FROM nodes
- WHERE node_type = 'file'
- `)
+ _, err := s.db.Exec(`INSERT INTO nodes_fts(nodes_fts) VALUES('rebuild')`)
return err
}
@@ -50,7 +72,7 @@
for _, kw := range keywords {
rows, err := s.db.Query(`
- SELECT n.id, n.path, n.title, n.section, fts.rank
+ SELECT n.id, n.path, n.title, n.section, fts.rank, n.aliases, n.status
FROM nodes_fts fts
JOIN nodes n ON n.id = fts.rowid
WHERE nodes_fts MATCH ?
@@ -64,10 +86,14 @@
for rows.Next() {
var r FTSResult
- if err := rows.Scan(&r.ID, &r.Path, &r.Title, &r.Section, &r.Rank); err != nil {
+ var aliasesJSON string
+ if err := rows.Scan(&r.ID, &r.Path, &r.Title, &r.Section, &r.Rank, &aliasesJSON, &r.Status); err != nil {
rows.Close()
continue
}
+ if aliasesJSON != "" {
+ _ = json.Unmarshal([]byte(aliasesJSON), &r.Aliases)
+ }
// 去重
if !seen[r.ID] {
seen[r.ID] = true
@@ -90,6 +116,99 @@
return allResults, nil
}
+// containsCJK 是否含 CJK 统一表意文字
+func containsCJK(s string) bool {
+ for _, r := range s {
+ if r >= 0x4E00 && r <= 0x9FFF {
+ return true
+ }
+ }
+ return false
+}
+
+// KeywordSearch 双通道关键词检索:ASCII 词走 FTS5 MATCH,CJK 词走 LIKE(title/aliases/content/tags)。
+// FTS5 unicode61 把连续中文当整串单 token,多字符 CJK 词 MATCH 匹配不到,必须走 LIKE。
+// 结果按"首命中顺序"排列(FTS 通道按 rank,LIKE 通道补在尾部),engine 再按双信号重排。
+func (s *Store) KeywordSearch(keywords []string, limit int) ([]FTSResult, error) {
+ if len(keywords) == 0 {
+ return nil, nil
+ }
+ seen := make(map[int64]*FTSResult)
+ var order []int64
+
+ // 通道 1: FTS(ASCII 词)
+ var ascii []string
+ for _, kw := range keywords {
+ if !containsCJK(kw) {
+ ascii = append(ascii, kw)
+ }
+ }
+ for _, kw := range ascii {
+ rows, err := s.db.Query(`
+ SELECT n.id, n.path, n.title, n.section, fts.rank, n.aliases, n.status
+ FROM nodes_fts fts JOIN nodes n ON n.id = fts.rowid
+ WHERE nodes_fts MATCH ? ORDER BY fts.rank LIMIT ?`, kw, limit)
+ if err != nil {
+ continue // 语法异常词跳过
+ }
+ for rows.Next() {
+ var r FTSResult
+ var aliasesJSON string
+ if err := rows.Scan(&r.ID, &r.Path, &r.Title, &r.Section, &r.Rank, &aliasesJSON, &r.Status); err != nil {
+ continue
+ }
+ if aliasesJSON != "" {
+ _ = json.Unmarshal([]byte(aliasesJSON), &r.Aliases)
+ }
+ if _, ok := seen[r.ID]; !ok {
+ seen[r.ID] = &r
+ order = append(order, r.ID)
+ }
+ }
+ rows.Close()
+ }
+
+ // 通道 2: LIKE(CJK 词)
+ for _, kw := range keywords {
+ if !containsCJK(kw) {
+ continue
+ }
+ pat := "%" + kw + "%"
+ rows, err := s.db.Query(`
+ SELECT id, path, title, section, aliases, status FROM nodes
+ WHERE title LIKE ? OR aliases LIKE ? OR content_fts LIKE ? OR tags LIKE ?`,
+ pat, pat, pat, pat)
+ if err != nil {
+ continue
+ }
+ for rows.Next() {
+ var r FTSResult
+ var aliasesJSON string
+ if err := rows.Scan(&r.ID, &r.Path, &r.Title, &r.Section, &aliasesJSON, &r.Status); err != nil {
+ continue
+ }
+ if aliasesJSON != "" {
+ _ = json.Unmarshal([]byte(aliasesJSON), &r.Aliases)
+ }
+ r.Rank = 0 // LIKE 无 rank
+ if _, ok := seen[r.ID]; !ok {
+ seen[r.ID] = &r
+ order = append(order, r.ID)
+ }
+ }
+ rows.Close()
+ }
+
+ results := make([]FTSResult, 0, len(order))
+ for _, id := range order {
+ results = append(results, *seen[id])
+ }
+ if limit > 0 && len(results) > limit {
+ results = results[:limit]
+ }
+ return results, nil
+}
+
// GetNodeContent 获取节点内容
func (s *Store) GetNodeContent(id int64) (string, []string, []string, error) {
var content, tagsJSON, entitiesJSON string
diff --git a/internal/index/fts_test.go b/internal/index/fts_test.go
index 1a42430..050f3e0 100644
--- a/internal/index/fts_test.go
+++ b/internal/index/fts_test.go
@@ -52,3 +52,81 @@
t.Errorf("result count = %d, want >= 1", len(results))
}
}
+
+// TestKeywordSearchASCII 覆盖 KeywordSearch 的 FTS 通道(ASCII 词 MATCH + rank 排序)
+func TestKeywordSearchASCII(t *testing.T) {
+ store, err := Open(filepath.Join(t.TempDir(), "test.db"))
+ if err != nil {
+ t.Fatalf("Open failed: %v", err)
+ }
+ defer store.Close()
+
+ // 两个节点都含 "pump",但 node1 标题也含(title 权重高 → rank 更靠前)
+ store.InsertNode(&graph.Node{
+ ID: 1,
+ Path: "知识/001-pump.md",
+ Title: "pump failure",
+ Section: "知识",
+ Content: "pump failure troubleshooting guide",
+ })
+ store.InsertNode(&graph.Node{
+ ID: 2,
+ Path: "知识/002-valve.md",
+ Title: "valve config",
+ Section: "知识",
+ Content: "the pump is configured here",
+ })
+ if err := store.CreateFTS(); err != nil {
+ t.Fatalf("CreateFTS failed: %v", err)
+ }
+ if err := store.PopulateFTS(); err != nil {
+ t.Fatalf("PopulateFTS failed: %v", err)
+ }
+
+ results, err := store.KeywordSearch([]string{"pump"}, 10)
+ if err != nil {
+ t.Fatalf("KeywordSearch failed: %v", err)
+ }
+ if len(results) != 2 {
+ t.Fatalf("result count = %d, want 2 (FTS 通道应命中两节点)", len(results))
+ }
+ // FTS 通道 rank 排序:标题含 pump 的 node1 应排第一
+ if results[0].ID != 1 {
+ t.Errorf("FTS rank 排序: 首位 = %d, want 1", results[0].ID)
+ }
+}
+
+// TestKeywordSearchMixed 覆盖双通道混合(ASCII 走 FTS + CJK 走 LIKE,seen 去重)
+func TestKeywordSearchMixed(t *testing.T) {
+ store, err := Open(filepath.Join(t.TempDir(), "test.db"))
+ if err != nil {
+ t.Fatalf("Open failed: %v", err)
+ }
+ defer store.Close()
+
+ store.InsertNode(&graph.Node{
+ ID: 1,
+ Path: "FAQ/001-pump.md",
+ Title: "pump 补气",
+ Section: "FAQ",
+ Content: "pump 补气失败排查",
+ })
+ if err := store.CreateFTS(); err != nil {
+ t.Fatalf("CreateFTS failed: %v", err)
+ }
+ if err := store.PopulateFTS(); err != nil {
+ t.Fatalf("PopulateFTS failed: %v", err)
+ }
+
+ // 同一节点被两个通道命中 → 去重后只出现一次
+ results, err := store.KeywordSearch([]string{"pump", "补气"}, 10)
+ if err != nil {
+ t.Fatalf("KeywordSearch failed: %v", err)
+ }
+ if len(results) != 1 {
+ t.Fatalf("双通道去重: count = %d, want 1", len(results))
+ }
+ if results[0].ID != 1 {
+ t.Errorf("命中节点 = %d, want 1", results[0].ID)
+ }
+}
diff --git a/internal/index/graphload.go b/internal/index/graphload.go
new file mode 100644
index 0000000..73db2ef
--- /dev/null
+++ b/internal/index/graphload.go
@@ -0,0 +1,50 @@
+package index
+
+import (
+ "fmt"
+
+ "github.com/aisim/kb-cli/internal/graph"
+)
+
+// LoadRWRGraph 加载 RWR 邻接(无向、带权)。
+// 权重:wikilink exact=1.0 / fuzzy=0.5 / entity=1.0 / tag=0.5(tag 扇出大降权)。
+// 只加载与种子同连通域的边不可行(SQLite 无图查询),全量加载后 RWR 内部按种子收敛——
+// 458 节点 / ~2k 边规模下全量加载 <5ms,可接受。
+func (s *Store) LoadRWRGraph() (map[int64][]graph.AdjEdge, error) {
+ rows, err := s.db.Query(`
+ SELECT from_node, to_node, relation, provenance FROM edges
+ WHERE relation IN ('wikilink', 'entity', 'tag')`)
+ if err != nil {
+ return nil, fmt.Errorf("加载边失败: %w", err)
+ }
+ defer rows.Close()
+ adj := make(map[int64][]graph.AdjEdge)
+ add := func(from, to int64, w float64) {
+ adj[from] = append(adj[from], graph.AdjEdge{To: to, Weight: w})
+ adj[to] = append(adj[to], graph.AdjEdge{To: from, Weight: w})
+ }
+ for rows.Next() {
+ var from, to int64
+ var relation, prov string
+ if err := rows.Scan(&from, &to, &relation, &prov); err != nil {
+ return nil, err
+ }
+ var w float64
+ switch relation {
+ case "wikilink":
+ if prov == "exact" {
+ w = 1.0
+ } else {
+ w = 0.5
+ }
+ case "entity":
+ w = 1.0
+ case "tag":
+ w = 0.5
+ default:
+ continue
+ }
+ add(from, to, w)
+ }
+ return adj, rows.Err()
+}
diff --git a/internal/index/migrations.go b/internal/index/migrations.go
new file mode 100644
index 0000000..85dce28
--- /dev/null
+++ b/internal/index/migrations.go
@@ -0,0 +1,134 @@
+package index
+
+import (
+ "fmt"
+ "os"
+)
+
+const currentSchemaVersion = 2
+
+// migrate 幂等的 schema 升级:v1 → v2。
+// v2 变更:nodes 加 aliases/status/size/mtime/content_hash 列;
+// edges 加 provenance 列;新建 unresolved_links、schema_versions 表;
+// nodes_fts 改 external-content + 触发器。迁移前自动备份 .db 文件。
+func (s *Store) migrate() error {
+ // 读当前版本
+ var version int
+ err := s.db.QueryRow(`SELECT value FROM schema_versions WHERE key='version'`).Scan(&version)
+ if err != nil && version == 0 {
+ // 首次:schema_versions 表可能不存在
+ version = 1
+ }
+ if version >= currentSchemaVersion {
+ return nil
+ }
+
+ // 备份
+ if err := s.backupDB(); err != nil {
+ return fmt.Errorf("备份失败: %w", err)
+ }
+
+ // v1 → v2
+ if err := s.migrateV1toV2(); err != nil {
+ return fmt.Errorf("v1→v2 迁移失败: %w", err)
+ }
+ return nil
+}
+
+// backupDB 迁移前备份 .db 文件(已有备份则跳过)。
+// 用 VACUUM INTO 而非 os.ReadFile:WAL 模式下未 checkpoint 的数据在
+// -wal 文件里,直读主文件会漏掉这部分,导致备份不一致;VACUUM INTO
+// 会合并 WAL 生成一份完整的独立库文件(SQLite 3.27+)。
+func (s *Store) backupDB() error {
+ if s.dbPath == "" {
+ return nil // 无路径信息时跳过备份(测试场景)
+ }
+ dst := s.dbPath + ".bak"
+ if _, err := os.Stat(dst); err == nil {
+ return nil // 已有备份
+ }
+ _, err := s.db.Exec(`VACUUM INTO ?`, dst)
+ return err
+}
+
+// migrateV1toV2 执行 v1 → v2 的全部变更。
+// 注意:版本号(schema_versions)必须在最后一步(FTS 重建)成功后才写入,
+// 否则中途失败时后续 Open 会读到 version=2 直接早退,半迁移状态无法重试。
+func (s *Store) migrateV1toV2() error {
+ // 1. schema_versions 表(只建表,版本号推迟到最后写入)
+ if _, err := s.db.Exec(`CREATE TABLE IF NOT EXISTS schema_versions (
+ key TEXT PRIMARY KEY, value TEXT NOT NULL)`); err != nil {
+ return err
+ }
+
+ // 2. nodes 加列(逐列检查,幂等)
+ for _, col := range []struct{ name, def string }{
+ {"aliases", "ALTER TABLE nodes ADD COLUMN aliases TEXT"},
+ {"status", "ALTER TABLE nodes ADD COLUMN status TEXT"},
+ {"size", "ALTER TABLE nodes ADD COLUMN size INTEGER"},
+ {"mtime", "ALTER TABLE nodes ADD COLUMN mtime INTEGER"},
+ {"content_hash", "ALTER TABLE nodes ADD COLUMN content_hash TEXT"},
+ } {
+ if !s.nodeColumnExists(col.name) {
+ if _, err := s.db.Exec(col.def); err != nil {
+ return fmt.Errorf("加列 %s 失败: %w", col.name, err)
+ }
+ }
+ }
+
+ // 3. edges 加 provenance 列
+ if !s.edgeColumnExists("provenance") {
+ if _, err := s.db.Exec(`ALTER TABLE edges ADD COLUMN provenance TEXT`); err != nil {
+ return fmt.Errorf("edges 加 provenance 失败: %w", err)
+ }
+ }
+
+ // 4. unresolved_links 表
+ if _, err := s.db.Exec(`CREATE TABLE IF NOT EXISTS unresolved_links (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ from_node INTEGER NOT NULL,
+ link_text TEXT NOT NULL,
+ name_tail TEXT NOT NULL,
+ status TEXT NOT NULL DEFAULT 'pending',
+ created_at TEXT DEFAULT (datetime('now')),
+ last_attempt TEXT)`); err != nil {
+ return err
+ }
+ if _, err := s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_unresolved_tail
+ ON unresolved_links(name_tail) WHERE status='failed'`); err != nil {
+ return err
+ }
+
+ // 5. FTS 改 external-content:删旧表重建 + 触发器 + 重灌
+ if _, err := s.db.Exec(`DROP TABLE IF EXISTS nodes_fts`); err != nil {
+ return err
+ }
+ if err := s.CreateFTS(); err != nil {
+ return err
+ }
+ if err := s.PopulateFTS(); err != nil {
+ return err
+ }
+
+ // 6. 最后一步:全部成功后才写入版本号(参数化,引用常量 currentSchemaVersion)。
+ // 提前写入会导致中途失败时后续 Open 读到 version=2 直接早退,半迁移状态无法重试。
+ if _, err := s.db.Exec(`INSERT OR REPLACE INTO schema_versions VALUES ('version', ?)`,
+ currentSchemaVersion); err != nil {
+ return err
+ }
+ return nil
+}
+
+// nodeColumnExists 检查 nodes 表是否已有指定列
+func (s *Store) nodeColumnExists(name string) bool {
+ var n int
+ s.db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('nodes') WHERE name=?`, name).Scan(&n)
+ return n > 0
+}
+
+// edgeColumnExists 检查 edges 表是否已有指定列
+func (s *Store) edgeColumnExists(name string) bool {
+ var n int
+ s.db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('edges') WHERE name=?`, name).Scan(&n)
+ return n > 0
+}
diff --git a/internal/index/migrations_test.go b/internal/index/migrations_test.go
new file mode 100644
index 0000000..5f9ab8e
--- /dev/null
+++ b/internal/index/migrations_test.go
@@ -0,0 +1,246 @@
+package index
+
+import (
+ "database/sql"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/aisim/kb-cli/internal/graph"
+)
+
+// newV1Store 手工构建一个 v1 schema 的老库(模拟迁移前状态)
+func newV1Store(t *testing.T) (*Store, string) {
+ t.Helper()
+ dbPath := filepath.Join(t.TempDir(), "kb-v1.db")
+ db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL")
+ if err != nil {
+ t.Fatal(err)
+ }
+ schema := `
+ CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
+ CREATE TABLE nodes (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ path TEXT NOT NULL UNIQUE,
+ title TEXT, section TEXT, tags TEXT, entities TEXT, wikilinks TEXT,
+ content_fts TEXT,
+ created_at TEXT DEFAULT (datetime('now')),
+ updated_at TEXT DEFAULT (datetime('now')));
+ CREATE TABLE edges (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ from_node INTEGER NOT NULL, to_node INTEGER NOT NULL,
+ relation TEXT NOT NULL, label TEXT,
+ UNIQUE(from_node, to_node, relation, label));
+ `
+ if _, err := db.Exec(schema); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := db.Exec(`INSERT INTO nodes (path, title, tags, entities, wikilinks, content_fts)
+ VALUES ('FAQ/001-测试.md', '测试', '["a"]', '[]', '[]', '内容')`); err != nil {
+ t.Fatal(err)
+ }
+ return &Store{db: db, dbPath: dbPath}, dbPath
+}
+
+func TestMigrateV1ToV2(t *testing.T) {
+ store, dbPath := newV1Store(t)
+ defer store.Close()
+
+ // 迁移前备份文件必须存在
+ if err := store.migrate(); err != nil {
+ t.Fatalf("migrate: %v", err)
+ }
+ if _, err := os.Stat(dbPath + ".bak"); err != nil {
+ t.Fatalf("备份文件不存在: %v", err)
+ }
+
+ // v2 列必须存在
+ cols := store.nodeColumns(t)
+ for _, want := range []string{"aliases", "status", "size", "mtime", "content_hash"} {
+ if !cols[want] {
+ t.Errorf("nodes 缺列 %s", want)
+ }
+ }
+ if !store.edgeHasProvenance(t) {
+ t.Error("edges 缺 provenance 列")
+ }
+ if !store.tableExists(t, "unresolved_links") {
+ t.Error("缺 unresolved_links 表")
+ }
+
+ // 数据保留
+ var title string
+ if err := store.db.QueryRow(`SELECT title FROM nodes WHERE path='FAQ/001-测试.md'`).Scan(&title); err != nil {
+ t.Fatal(err)
+ }
+ if title != "测试" {
+ t.Errorf("迁移丢数据: %s", title)
+ }
+}
+
+func TestMigrateIdempotent(t *testing.T) {
+ store, _ := newV1Store(t)
+ defer store.Close()
+ if err := store.migrate(); err != nil {
+ t.Fatalf("第一次: %v", err)
+ }
+ if err := store.migrate(); err != nil {
+ t.Fatalf("第二次(幂等): %v", err)
+ }
+}
+
+func TestNewDBIsV2(t *testing.T) {
+ dbPath := filepath.Join(t.TempDir(), "kb-v2.db")
+ store, err := Open(dbPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer store.Close()
+ if !store.edgeHasProvenance(t) {
+ t.Error("新建库应为 v2 schema")
+ }
+}
+
+// TestMigrateVersionNotWrittenOnFailure 验证版本写入时序:
+// 若 FTS 重建中途失败,schema_versions 中不得出现 version='2'
+// (否则后续 Open 读到 version=2 直接早退,半迁移状态无法重试)。
+// 手法:把 v1 库的 nodes.content_fts 列改名,FTS5 external-content
+// 建表引用不存在的列必然真实失败(CreateFTS 第 1 步即报错)。
+func TestMigrateVersionNotWrittenOnFailure(t *testing.T) {
+ store, _ := newV1Store(t)
+ defer store.Close()
+
+ // 破坏 content 表:content_fts 列改名,使 FTS external-content 建表失败
+ if _, err := store.db.Exec(`ALTER TABLE nodes RENAME COLUMN content_fts TO content_x`); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := store.migrate(); err == nil {
+ t.Fatal("migrate 应返回 FTS 建表失败错误, 实际成功")
+ }
+
+ // 失败后版本号必须未写入(行不存在)
+ var v string
+ err := store.db.QueryRow(`SELECT value FROM schema_versions WHERE key='version'`).Scan(&v)
+ if err == nil {
+ t.Fatalf("迁移失败后版本号不应写入, 实际写入了: %s", v)
+ }
+ if err != sql.ErrNoRows {
+ t.Fatalf("查询版本行出错: %v", err)
+ }
+}
+
+// TestBackupWALConsistent 验证备份一致性:WAL 模式未 checkpoint 的
+// 新增数据必须包含在 .bak 里(VACUUM INTO 合并 WAL;旧的
+// ReadFile 直读主文件做不到)。
+func TestBackupWALConsistent(t *testing.T) {
+ store, dbPath := newV1Store(t)
+ defer store.Close()
+
+ // 通过活动连接插入一条数据,停留在未 checkpoint 的 WAL 中
+ if _, err := store.db.Exec(`INSERT INTO nodes (path, title, content_fts)
+ VALUES ('WAL/002-新节点.md', 'walnode', 'wal数据')`); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := store.backupDB(); err != nil {
+ t.Fatalf("backupDB: %v", err)
+ }
+ // .bak 必须是合法 SQLite 库且包含 WAL 中未 checkpoint 的数据
+ db, err := sql.Open("sqlite3", dbPath+".bak")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer db.Close()
+ var n int
+ if err := db.QueryRow(`SELECT COUNT(*) FROM nodes WHERE path='WAL/002-新节点.md'`).Scan(&n); err != nil {
+ t.Fatal(err)
+ }
+ if n != 1 {
+ t.Fatalf("备份缺少 WAL 未 checkpoint 数据 (count=%d)", n)
+ }
+}
+
+// TestFTSTriggers FTS 增删改触发器回归测试:
+// 新建库 → 插节点 → FTS 可查 → UPDATE 改内容 → 新词可查/旧词不可查 → DELETE → 不可查。
+// 注意:unicode61 分词下 CJK 连续串是整串 token,故用英文词验证触发器行为。
+func TestFTSTriggers(t *testing.T) {
+ store, err := Open(filepath.Join(t.TempDir(), "kb-fts.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer store.Close()
+
+ // INSERT 触发器:新插节点立即可被 FTS 查到
+ id, err := store.InsertNode(&graph.Node{
+ Path: "TRG/001.md",
+ Title: "trigger node",
+ Content: "alpha omega",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := store.ftsMatchCount(t, "alpha"); got != 1 {
+ t.Fatalf("INSERT 后 FTS 应查到 1 条 alpha, 实际 %d", got)
+ }
+
+ // UPDATE 触发器:改内容后新词可查、旧词不可查
+ if _, err := store.db.Exec(`UPDATE nodes SET content_fts=? WHERE id=?`, "beta delta", id); err != nil {
+ t.Fatal(err)
+ }
+ if got := store.ftsMatchCount(t, "beta"); got != 1 {
+ t.Fatalf("UPDATE 后 FTS 应查到 1 条 beta, 实际 %d", got)
+ }
+ if got := store.ftsMatchCount(t, "alpha"); got != 0 {
+ t.Fatalf("UPDATE 后旧词 alpha 应不可查, 实际 %d 条", got)
+ }
+
+ // DELETE 触发器:删节点后 FTS 不可查
+ if _, err := store.db.Exec(`DELETE FROM nodes WHERE id=?`, id); err != nil {
+ t.Fatal(err)
+ }
+ if got := store.ftsMatchCount(t, "beta"); got != 0 {
+ t.Fatalf("DELETE 后 FTS 不应查到 beta, 实际 %d 条", got)
+ }
+}
+
+// ftsMatchCount 统计 FTS 虚拟表中匹配 term 的文档数(测试辅助)
+func (s *Store) ftsMatchCount(t *testing.T, term string) int {
+ t.Helper()
+ var n int
+ if err := s.db.QueryRow(`SELECT COUNT(*) FROM nodes_fts WHERE nodes_fts MATCH ?`, term).Scan(&n); err != nil {
+ t.Fatalf("FTS MATCH %q: %v", term, err)
+ }
+ return n
+}
+
+// 测试辅助:列检查
+func (s *Store) nodeColumns(t *testing.T) map[string]bool {
+ t.Helper()
+ rows, err := s.db.Query(`SELECT name FROM pragma_table_info('nodes')`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer rows.Close()
+ m := map[string]bool{}
+ for rows.Next() {
+ var name string
+ rows.Scan(&name)
+ m[name] = true
+ }
+ return m
+}
+
+func (s *Store) edgeHasProvenance(t *testing.T) bool {
+ t.Helper()
+ var n int
+ s.db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('edges') WHERE name='provenance'`).Scan(&n)
+ return n > 0
+}
+
+func (s *Store) tableExists(t *testing.T, name string) bool {
+ t.Helper()
+ var n int
+ s.db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?`, name).Scan(&n)
+ return n > 0
+}
diff --git a/internal/index/reconcile.go b/internal/index/reconcile.go
new file mode 100644
index 0000000..18c25c3
--- /dev/null
+++ b/internal/index/reconcile.go
@@ -0,0 +1,372 @@
+package index
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/aisim/kb-cli/internal/graph"
+ "github.com/aisim/kb-cli/internal/vault"
+)
+
+// ReconcileResult 对账结果统计
+type ReconcileResult struct {
+ Added int
+ Modified int
+ Deleted int
+ Unchanged int
+ Resolved int // 悬空链接自动补全成功的条数
+}
+
+// Reconcile 增量对账:stat 比对 → 只对变更文件解析和写库
+func Reconcile(store *Store, vaultPath string) (*ReconcileResult, error) {
+ res := &ReconcileResult{}
+
+ // 1. vault 侧指纹
+ vaultStats, err := vault.ScanVaultStat(vaultPath)
+ if err != nil {
+ return nil, fmt.Errorf("扫描失败: %w", err)
+ }
+ dbStats, err := store.GetFileStats()
+ if err != nil {
+ return nil, fmt.Errorf("读取索引指纹失败: %w", err)
+ }
+
+ // 2. 分类
+ type change struct {
+ path string
+ stat vault.FileStat
+ known FileStat
+ }
+ var added, modified []change
+ for _, v := range vaultStats {
+ known, ok := dbStats[v.Path]
+ if !ok {
+ added = append(added, change{v.Path, v, FileStat{}})
+ continue
+ }
+ if v.Size == known.Size && v.Mtime == known.Mtime {
+ res.Unchanged++
+ continue
+ }
+ // size/mtime 变化 → sha256 二次确认
+ hash, err := fileHash(filepath.Join(vaultPath, v.Path))
+ if err != nil {
+ return nil, fmt.Errorf("哈希 %s 失败: %w", v.Path, err)
+ }
+ if known.ContentHash != "" && hash == known.ContentHash {
+ res.Unchanged++ // 内容没变(如 touch),只更新指纹
+ store.db.Exec(`UPDATE nodes SET size=?, mtime=? WHERE path=?`, v.Size, v.Mtime, v.Path)
+ continue
+ }
+ modified = append(modified, change{v.Path, v, known})
+ }
+ // 3. 删除
+ for path := range dbStats {
+ found := false
+ for _, v := range vaultStats {
+ if v.Path == path {
+ found = true
+ break
+ }
+ }
+ if !found {
+ if err := store.DeleteNode(path); err != nil {
+ return nil, err
+ }
+ res.Deleted++
+ }
+ }
+
+ // 4. 新增 + 修改:解析 → 写节点 → 重建该节点边
+ for _, c := range append(added, modified...) {
+ isNew := c.known.Path == ""
+ meta, err := vault.ParseFile(filepath.Join(vaultPath, c.path), c.path)
+ if err != nil {
+ return nil, fmt.Errorf("解析 %s 失败: %w", c.path, err)
+ }
+ hash, err := fileHash(filepath.Join(vaultPath, c.path))
+ if err != nil {
+ return nil, err
+ }
+ if isNew {
+ res.Added++
+ } else {
+ res.Modified++
+ }
+ if err := applyNode(store, vaultPath, meta, c.stat.Size, c.stat.Mtime, hash); err != nil {
+ return nil, err
+ }
+ }
+
+ // 5. 悬空链接重试:新节点入库后,历史悬空链接可能变可解析
+ resolved, err := store.RetryUnresolved()
+ if err != nil {
+ return nil, fmt.Errorf("悬空链接重试失败: %w", err)
+ }
+ res.Resolved = resolved
+ return res, nil
+}
+
+func fileHash(path string) (string, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return "", err
+ }
+ sum := sha256.Sum256(data)
+ return hex.EncodeToString(sum[:]), nil
+}
+
+// applyNode 写节点 + 重建该节点的出边(tag/entity/wikilink)+ 悬空入表
+func applyNode(store *Store, vaultPath string, meta *vault.FileMeta, size, mtime int64, hash string) error {
+ n := &graph.Node{
+ Path: meta.Path, Title: meta.Title, Section: meta.Section,
+ Tags: meta.Tags, Entities: meta.Entities, Wikilinks: meta.Wikilinks,
+ Aliases: meta.Aliases, Status: meta.Status, Content: meta.Content,
+ }
+ if err := store.UpsertNode(n, size, mtime, hash); err != nil {
+ return err
+ }
+ var nodeID int64
+ if err := store.db.QueryRow(`SELECT id FROM nodes WHERE path=?`, meta.Path).Scan(&nodeID); err != nil {
+ return err
+ }
+ // 删旧边后重建出边
+ if err := store.DeleteNodeEdges(nodeID); err != nil {
+ return err
+ }
+ return store.buildNodeEdges(nodeID, meta)
+}
+
+// buildNodeEdges 为单个节点建出边;wikilink 解析失败入 unresolved_links
+func (s *Store) buildNodeEdges(nodeID int64, meta *vault.FileMeta) error {
+ // tag / entity 边(虚拟节点 ID 沿用 1000000+ 规则,按 label 查现有行避免重复)
+ for _, tag := range meta.Tags {
+ if err := s.insertTagEntityEdge(nodeID, "tag:"+tag, "tag", tag); err != nil {
+ return err
+ }
+ }
+ for _, entity := range meta.Entities {
+ if err := s.insertTagEntityEdge(nodeID, "entity:"+entity, "entity", entity); err != nil {
+ return err
+ }
+ }
+ // wikilink 边 + 悬空
+ for _, link := range meta.Wikilinks {
+ targetID, prov, ok := s.resolveWikilink(link)
+ if !ok {
+ tail := nameTail(link)
+ // 幂等守卫:同 from_node+link_text 不重复插(unresolved_links 无 UNIQUE 约束,用 WHERE NOT EXISTS)
+ s.db.Exec(`INSERT INTO unresolved_links (from_node, link_text, name_tail, status)
+ SELECT ?, ?, ?, 'pending'
+ WHERE NOT EXISTS (
+ SELECT 1 FROM unresolved_links WHERE from_node = ? AND link_text = ?)`,
+ nodeID, link, tail, nodeID, link)
+ continue
+ }
+ if err := s.InsertEdge(&graph.Edge{
+ FromNode: nodeID, ToNode: targetID,
+ Relation: "wikilink", Label: link, Provenance: prov,
+ }); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// insertTagEntityEdge tag/entity 边(虚拟节点按 label 复用 ID)
+func (s *Store) insertTagEntityEdge(fromNode int64, key, relation, label string) error {
+ var virtualID int64
+ err := s.db.QueryRow(`SELECT to_node FROM edges WHERE relation=? AND label=? LIMIT 1`, relation, label).Scan(&virtualID)
+ if err != nil {
+ // 新虚拟节点:分配 ID = 1000000 + 行号(稳定:按 label 排序后的行号)
+ var maxID int64
+ s.db.QueryRow(`SELECT COALESCE(MAX(to_node), 1000000) FROM edges WHERE to_node >= 1000000 AND relation=?`, relation).Scan(&maxID)
+ virtualID = maxID + 1
+ }
+ return s.InsertEdge(&graph.Edge{FromNode: fromNode, ToNode: virtualID, Relation: relation, Label: label, Provenance: relation})
+}
+
+// resolveWikilink 解析 wikilink 目标,返回 (nodeID, provenance, ok)
+// provenance: exact = 标题或文件名精确匹配;fuzzy = 标题包含匹配
+func (s *Store) resolveWikilink(link string) (int64, string, bool) {
+ // 去锚点:[[标题|别名]] 取标题部分
+ if idx := strings.Index(link, "|"); idx >= 0 {
+ link = link[:idx]
+ }
+ var id int64
+ var title, path string
+ // 1. 标题精确
+ err := s.db.QueryRow(`SELECT id, title, path FROM nodes WHERE title = ? LIMIT 1`, link).Scan(&id, &title, &path)
+ if err == nil {
+ return id, "exact", true
+ }
+ // 2. 文件名精确(去 .md 和编号前缀)
+ rows, err := s.db.Query(`SELECT id, title, path FROM nodes`)
+ if err != nil {
+ return 0, "", false
+ }
+ defer rows.Close()
+ var fuzzyID int64
+ for rows.Next() {
+ var nid int64
+ var nTitle, nPath string
+ if err := rows.Scan(&nid, &nTitle, &nPath); err != nil {
+ return 0, "", false
+ }
+ base := filepath.Base(nPath)
+ base = strings.TrimSuffix(base, ".md")
+ if dash := strings.Index(base, "-"); dash >= 0 {
+ base = base[dash+1:]
+ }
+ if base == link || nTitle == link {
+ return nid, "exact", true
+ }
+ // 空 link 跳过 fuzzy(Contains 对空串恒真会误建边)
+ if fuzzyID == 0 && link != "" && strings.Contains(nTitle, link) {
+ fuzzyID = nid
+ }
+ }
+ if fuzzyID != 0 {
+ return fuzzyID, "fuzzy", true
+ }
+ return 0, "", false
+}
+
+// nameTail 取 link 尾部用于重试匹配(去锚点修饰)
+func nameTail(link string) string {
+ if idx := strings.Index(link, "|"); idx >= 0 {
+ link = link[:idx]
+ }
+ return link
+}
+
+// RetryUnresolved 重试解析悬空链接:用当前全部节点的标题/文件名去匹配 unresolved_links 的 name_tail。
+// 命中则建边(provenance 按匹配严格度)、删行。返回成功解析条数。
+func (s *Store) RetryUnresolved() (int, error) {
+ rows, err := s.db.Query(`SELECT id, from_node, link_text, name_tail FROM unresolved_links`)
+ if err != nil {
+ return 0, err
+ }
+ type pending struct {
+ id int64
+ fromNode int64
+ linkText string
+ tail string
+ }
+ var pendings []pending
+ for rows.Next() {
+ var p pending
+ if err := rows.Scan(&p.id, &p.fromNode, &p.linkText, &p.tail); err != nil {
+ rows.Close()
+ return 0, err
+ }
+ pendings = append(pendings, p)
+ }
+ rows.Close()
+ if len(pendings) == 0 {
+ return 0, nil
+ }
+
+ // 建匹配索引:标题/文件名(去编号) → nodeID,精确匹配优先
+ type matchInfo struct {
+ id int64
+ prov string
+ }
+ exactMap := make(map[string]matchInfo)
+ var fuzzyRows []struct {
+ id int64
+ title string
+ }
+ nrows, err := s.db.Query(`SELECT id, title, path FROM nodes`)
+ if err != nil {
+ return 0, err
+ }
+ for nrows.Next() {
+ var id int64
+ var title, path string
+ if err := nrows.Scan(&id, &title, &path); err != nil {
+ nrows.Close()
+ return 0, err
+ }
+ if _, ok := exactMap[title]; !ok {
+ exactMap[title] = matchInfo{id, "exact"}
+ }
+ base := filepath.Base(path)
+ base = strings.TrimSuffix(base, ".md")
+ if dash := strings.Index(base, "-"); dash >= 0 {
+ base = base[dash+1:]
+ }
+ if _, ok := exactMap[base]; !ok {
+ exactMap[base] = matchInfo{id, "exact"}
+ }
+ fuzzyRows = append(fuzzyRows, struct {
+ id int64
+ title string
+ }{id, title})
+ }
+ nrows.Close()
+
+ resolved := 0
+ for _, p := range pendings {
+ if info, ok := exactMap[p.tail]; ok {
+ // 精确命中:建边 + 删悬空行
+ if err := s.InsertEdge(&graph.Edge{
+ FromNode: p.fromNode, ToNode: info.id,
+ Relation: "wikilink", Label: p.linkText, Provenance: info.prov,
+ }); err != nil {
+ return resolved, err
+ }
+ if _, err := s.db.Exec(`DELETE FROM unresolved_links WHERE id=?`, p.id); err != nil {
+ return resolved, err
+ }
+ resolved++
+ continue
+ }
+ // fuzzy:标题包含(空 tail 跳过,避免 Contains 恒真)
+ if p.tail == "" {
+ continue
+ }
+ for _, fr := range fuzzyRows {
+ if strings.Contains(fr.title, p.tail) {
+ if err := s.InsertEdge(&graph.Edge{
+ FromNode: p.fromNode, ToNode: fr.id,
+ Relation: "wikilink", Label: p.linkText, Provenance: "fuzzy",
+ }); err != nil {
+ return resolved, err
+ }
+ if _, err := s.db.Exec(`DELETE FROM unresolved_links WHERE id=?`, p.id); err != nil {
+ return resolved, err
+ }
+ resolved++
+ break
+ }
+ }
+ }
+ return resolved, nil
+}
+
+// QuickCheck 只 stat 比对(不读内容不哈希),返回是否有差异
+func QuickCheck(store *Store, vaultPath string) (bool, error) {
+ vaultStats, err := vault.ScanVaultStat(vaultPath)
+ if err != nil {
+ return false, err
+ }
+ dbStats, err := store.GetFileStats()
+ if err != nil {
+ return false, err
+ }
+ if len(vaultStats) != len(dbStats) {
+ return true, nil
+ }
+ for _, v := range vaultStats {
+ known, ok := dbStats[v.Path]
+ if !ok || v.Size != known.Size || v.Mtime != known.Mtime {
+ return true, nil
+ }
+ }
+ return false, nil
+}
diff --git a/internal/index/reconcile_test.go b/internal/index/reconcile_test.go
new file mode 100644
index 0000000..677015f
--- /dev/null
+++ b/internal/index/reconcile_test.go
@@ -0,0 +1,175 @@
+package index
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+func TestReconcileAdd(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.MkdirAll(filepath.Join(dir, "FAQ"), 0755); err != nil {
+ t.Fatal(err)
+ }
+ content := "---\ntitle: 测试文档\ntags: [t1]\nstatus: 已解决\n---\n正文\n"
+ if err := os.WriteFile(filepath.Join(dir, "FAQ", "001-测试文档.md"), []byte(content), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ store, err := Open(filepath.Join(t.TempDir(), "kb.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer store.Close()
+
+ res, err := Reconcile(store, dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if res.Added != 1 || res.Unchanged != 0 {
+ t.Errorf("新增场景: %+v", res)
+ }
+ var title, status string
+ if err := store.db.QueryRow(`SELECT title, status FROM nodes WHERE path='FAQ/001-测试文档.md'`).Scan(&title, &status); err != nil {
+ t.Fatal(err)
+ }
+ if title != "测试文档" || status != "已解决" {
+ t.Errorf("节点字段错误: %s / %s", title, status)
+ }
+}
+
+func TestReconcileModify(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.MkdirAll(filepath.Join(dir, "FAQ"), 0755); err != nil {
+ t.Fatal(err)
+ }
+ p := filepath.Join(dir, "FAQ", "001-测试文档.md")
+ if err := os.WriteFile(p, []byte("---\ntitle: 旧标题\n---\n旧内容\n"), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ store, err := Open(filepath.Join(t.TempDir(), "kb.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer store.Close()
+ if _, err := Reconcile(store, dir); err != nil {
+ t.Fatal(err)
+ }
+
+ // 修改内容(title 变新标题,内容加"补气")
+ time.Sleep(1100 * time.Millisecond) // mtime 秒级精度,确保 mtime 变化
+ if err := os.WriteFile(p, []byte("---\ntitle: 新标题\n---\n新内容补气\n"), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ res, err := Reconcile(store, dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if res.Modified != 1 || res.Added != 0 {
+ t.Errorf("修改场景: %+v", res)
+ }
+ var title string
+ if err := store.db.QueryRow(`SELECT title FROM nodes WHERE path='FAQ/001-测试文档.md'`).Scan(&title); err != nil {
+ t.Fatal(err)
+ }
+ if title != "新标题" {
+ t.Errorf("标题未更新: %s", title)
+ }
+}
+
+func TestReconcileDelete(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.MkdirAll(filepath.Join(dir, "FAQ"), 0755); err != nil {
+ t.Fatal(err)
+ }
+ p := filepath.Join(dir, "FAQ", "001-测试文档.md")
+ if err := os.WriteFile(p, []byte("---\ntitle: 测试文档\n---\n内容\n"), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ store, err := Open(filepath.Join(t.TempDir(), "kb.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer store.Close()
+ if _, err := Reconcile(store, dir); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Remove(p); err != nil {
+ t.Fatal(err)
+ }
+
+ res, err := Reconcile(store, dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if res.Deleted != 1 {
+ t.Errorf("删除场景: %+v", res)
+ }
+ var n int
+ if err := store.db.QueryRow(`SELECT COUNT(*) FROM nodes`).Scan(&n); err != nil {
+ t.Fatal(err)
+ }
+ if n != 0 {
+ t.Errorf("节点未删净: %d", n)
+ }
+}
+
+func TestReconcileUnchanged(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.MkdirAll(filepath.Join(dir, "FAQ"), 0755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "FAQ", "001-测试文档.md"),
+ []byte("---\ntitle: 测试文档\n---\n内容\n"), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ store, err := Open(filepath.Join(t.TempDir(), "kb.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer store.Close()
+ if _, err := Reconcile(store, dir); err != nil {
+ t.Fatal(err)
+ }
+
+ res, err := Reconcile(store, dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if res.Unchanged != 1 || res.Added != 0 || res.Modified != 0 {
+ t.Errorf("未变场景: %+v", res)
+ }
+}
+
+func TestQuickCheck(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.MkdirAll(filepath.Join(dir, "FAQ"), 0755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "FAQ", "001-测试文档.md"),
+ []byte("---\ntitle: 测试文档\n---\n内容\n"), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ store, err := Open(filepath.Join(t.TempDir(), "kb.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer store.Close()
+ if _, err := Reconcile(store, dir); err != nil {
+ t.Fatal(err)
+ }
+
+ dirty, err := QuickCheck(store, dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if dirty {
+ t.Error("无差异时 QuickCheck 应 false")
+ }
+}
diff --git a/internal/index/sqlite.go b/internal/index/sqlite.go
index bf2477a..9f8d4c5 100644
--- a/internal/index/sqlite.go
+++ b/internal/index/sqlite.go
@@ -15,7 +15,8 @@
// Store SQLite 存储层
type Store struct {
- db *sql.DB
+ db *sql.DB
+ dbPath string
}
// Open 打开或创建数据库
@@ -31,11 +32,15 @@
return nil, fmt.Errorf("打开数据库失败: %w", err)
}
- s := &Store{db: db}
+ s := &Store{db: db, dbPath: dbPath}
if err := s.initTables(); err != nil {
db.Close()
return nil, err
}
+ if err := s.migrate(); err != nil {
+ db.Close()
+ return nil, err
+ }
return s, nil
}
@@ -55,7 +60,6 @@
CREATE TABLE IF NOT EXISTS nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
path TEXT NOT NULL UNIQUE,
- node_type TEXT NOT NULL DEFAULT 'file',
title TEXT,
section TEXT,
tags TEXT,
@@ -84,55 +88,140 @@
if err != nil {
return fmt.Errorf("创建表失败: %w", err)
}
-
- // 旧库迁移:补 node_type 列(幂等)
- var colCount int
- if err := s.db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('nodes') WHERE name = 'node_type'").Scan(&colCount); err == nil && colCount == 0 {
- if _, err := s.db.Exec("ALTER TABLE nodes ADD COLUMN node_type TEXT NOT NULL DEFAULT 'file'"); err != nil {
- return fmt.Errorf("迁移 node_type 列失败: %w", err)
- }
- }
-
return nil
}
// ClearData 清空数据(重建前调用)
func (s *Store) ClearData() error {
- _, err := s.db.Exec("DELETE FROM edges; DELETE FROM nodes;")
+ _, err := s.db.Exec("DELETE FROM edges; DELETE FROM nodes; DELETE FROM unresolved_links;")
return err
}
-// InsertNode 插入节点
+// InsertNode 插入节点(全量重建路径用)
func (s *Store) InsertNode(n *graph.Node) (int64, error) {
tagsJSON, _ := json.Marshal(n.Tags)
entitiesJSON, _ := json.Marshal(n.Entities)
wikilinksJSON, _ := json.Marshal(n.Wikilinks)
+ aliasesJSON, _ := json.Marshal(n.Aliases)
result, err := s.db.Exec(`
- INSERT INTO nodes (path, node_type, title, section, tags, entities, wikilinks, content_fts)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
- `, n.Path, nodeTypeOf(n), n.Title, n.Section, string(tagsJSON), string(entitiesJSON),
- string(wikilinksJSON), n.Content)
+ INSERT INTO nodes (path, title, section, tags, entities, wikilinks, aliases, status,
+ content_fts, size, mtime, content_hash)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ `, n.Path, n.Title, n.Section, string(tagsJSON), string(entitiesJSON),
+ string(wikilinksJSON), string(aliasesJSON), n.Status, n.Content,
+ n.Size, n.Mtime, n.ContentHash)
if err != nil {
return 0, err
}
return result.LastInsertId()
}
-// nodeTypeOf 节点类型(空值归一为 file)
-func nodeTypeOf(n *graph.Node) string {
- if n.NodeType == "" {
- return "file"
- }
- return n.NodeType
-}
-
// InsertEdge 插入边
func (s *Store) InsertEdge(e *graph.Edge) error {
_, err := s.db.Exec(`
- INSERT OR IGNORE INTO edges (from_node, to_node, relation, label)
- VALUES (?, ?, ?, ?)
- `, e.FromNode, e.ToNode, e.Relation, e.Label)
+ INSERT OR IGNORE INTO edges (from_node, to_node, relation, label, provenance)
+ VALUES (?, ?, ?, ?, ?)
+ `, e.FromNode, e.ToNode, e.Relation, e.Label, e.Provenance)
+ return err
+}
+
+// InsertUnresolved 悬空链接入表(幂等:同 from_node+link_text 不重复插)
+func (s *Store) InsertUnresolved(fromNode int64, linkText, nameTail string) error {
+ _, err := s.db.Exec(`
+ INSERT INTO unresolved_links (from_node, link_text, name_tail, status)
+ SELECT ?, ?, ?, 'pending'
+ WHERE NOT EXISTS (
+ SELECT 1 FROM unresolved_links WHERE from_node = ? AND link_text = ?)`,
+ fromNode, linkText, nameTail, fromNode, linkText)
+ return err
+}
+
+// FileStat 索引中的文件指纹
+type FileStat struct {
+ Path string
+ Size int64
+ Mtime int64
+ ContentHash string
+}
+
+// GetFileStats 返回所有已索引文件的指纹
+func (s *Store) GetFileStats() (map[string]FileStat, error) {
+ rows, err := s.db.Query(`SELECT path, size, mtime, content_hash FROM nodes`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ m := make(map[string]FileStat)
+ for rows.Next() {
+ var st FileStat
+ var hash sql.NullString
+ if err := rows.Scan(&st.Path, &st.Size, &st.Mtime, &hash); err != nil {
+ return nil, err
+ }
+ st.ContentHash = hash.String
+ m[st.Path] = st
+ }
+ return m, rows.Err()
+}
+
+// UpsertNode 按 path 插入或更新节点(触发器自动维护 FTS)
+func (s *Store) UpsertNode(n *graph.Node, size, mtime int64, contentHash string) error {
+ tagsJSON, _ := json.Marshal(n.Tags)
+ entitiesJSON, _ := json.Marshal(n.Entities)
+ wikilinksJSON, _ := json.Marshal(n.Wikilinks)
+ aliasesJSON, _ := json.Marshal(n.Aliases)
+ _, err := s.db.Exec(`
+ INSERT INTO nodes (path, title, section, tags, entities, wikilinks, aliases, status,
+ content_fts, size, mtime, content_hash)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(path) DO UPDATE SET
+ title = excluded.title,
+ section = excluded.section,
+ tags = excluded.tags,
+ entities = excluded.entities,
+ wikilinks = excluded.wikilinks,
+ aliases = excluded.aliases,
+ status = excluded.status,
+ content_fts = excluded.content_fts,
+ size = excluded.size,
+ mtime = excluded.mtime,
+ content_hash = excluded.content_hash,
+ updated_at = datetime('now')`,
+ n.Path, n.Title, n.Section, string(tagsJSON), string(entitiesJSON),
+ string(wikilinksJSON), string(aliasesJSON), n.Status, n.Content, size, mtime, contentHash)
+ return err
+}
+
+// DeleteNode 按 path 删节点(级联删边),同时清该节点相关的 unresolved_links
+func (s *Store) DeleteNode(path string) error {
+ tx, err := s.db.Begin()
+ if err != nil {
+ return err
+ }
+ var id int64
+ if err := tx.QueryRow(`SELECT id FROM nodes WHERE path = ?`, path).Scan(&id); err != nil {
+ tx.Rollback()
+ return nil // 不存在,视为成功
+ }
+ if _, err := tx.Exec(`DELETE FROM edges WHERE from_node = ? OR to_node = ?`, id, id); err != nil {
+ tx.Rollback()
+ return err
+ }
+ if _, err := tx.Exec(`DELETE FROM unresolved_links WHERE from_node = ?`, id); err != nil {
+ tx.Rollback()
+ return err
+ }
+ if _, err := tx.Exec(`DELETE FROM nodes WHERE id = ?`, id); err != nil {
+ tx.Rollback()
+ return err
+ }
+ return tx.Commit()
+}
+
+// DeleteNodeEdges 只删某节点的边(保留节点行,用于"修改"场景重建边)
+func (s *Store) DeleteNodeEdges(nodeID int64) error {
+ _, err := s.db.Exec(`DELETE FROM edges WHERE from_node = ? OR to_node = ?`, nodeID, nodeID)
return err
}
@@ -198,6 +287,24 @@
return links, nil
}
+// GetUnresolvedLinks 某文档的悬空链接文本列表
+func (s *Store) GetUnresolvedLinks(path string) ([]string, error) {
+ rows, err := s.db.Query(`
+ SELECT u.link_text FROM unresolved_links u
+ JOIN nodes n ON n.id = u.from_node WHERE n.path = ?`, path)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var links []string
+ for rows.Next() {
+ var l string
+ rows.Scan(&l)
+ links = append(links, l)
+ }
+ return links, rows.Err()
+}
+
// NodeInfo 节点基本信息(用于 GC)
type NodeInfo struct {
ID int64
@@ -206,7 +313,7 @@
// GetAllNodes 获取所有节点(用于 GC 检查)
func (s *Store) GetAllNodes() ([]NodeInfo, error) {
- rows, err := s.db.Query("SELECT id, path FROM nodes WHERE node_type = 'file'")
+ rows, err := s.db.Query("SELECT id, path FROM nodes")
if err != nil {
return nil, fmt.Errorf("查询节点失败: %w", err)
}
@@ -314,8 +421,7 @@
query := `
SELECT id, path, title, section, tags, entities, wikilinks, content_fts
FROM nodes
- WHERE (title LIKE ? OR path LIKE ? OR tags LIKE ? OR entities LIKE ?)
- AND node_type = 'file'
+ WHERE title LIKE ? OR path LIKE ? OR tags LIKE ? OR entities LIKE ?
LIMIT 20
`
rows, err := s.db.Query(query, keyword, keyword, keyword, keyword)
@@ -376,7 +482,7 @@
visited[id] = true
query := `
- SELECT id, from_node, to_node, relation, label
+ SELECT id, from_node, to_node, relation, label, provenance
FROM edges
WHERE from_node = ?
`
@@ -395,7 +501,7 @@
for rows.Next() {
var e graph.Edge
var edgeID int64
- if err := rows.Scan(&edgeID, &e.FromNode, &e.ToNode, &e.Relation, &e.Label); err != nil {
+ if err := rows.Scan(&edgeID, &e.FromNode, &e.ToNode, &e.Relation, &e.Label, &e.Provenance); err != nil {
rows.Close()
return nil, fmt.Errorf("扫描边失败: %w", err)
}
@@ -418,8 +524,7 @@
query := `
SELECT id, path, title, section, tags, entities
FROM nodes
- WHERE (title LIKE ? OR path LIKE ? OR tags LIKE ? OR entities LIKE ?)
- AND node_type = 'file'
+ WHERE title LIKE ? OR path LIKE ? OR tags LIKE ? OR entities LIKE ?
LIMIT 5
`
rows, err := s.db.Query(query, keyword, keyword, keyword, keyword)
@@ -470,7 +575,7 @@
rows, err := s.db.Query(`
SELECT id, path, title, section, tags
FROM nodes
- WHERE tags LIKE ? AND node_type = 'file'
+ WHERE tags LIKE ?
`, tagPattern)
if err != nil {
continue
@@ -500,7 +605,7 @@
rows, err := s.db.Query(`
SELECT id, path, title, section, tags
FROM nodes
- WHERE entities LIKE ? AND node_type = 'file'
+ WHERE entities LIKE ?
`, entityPattern)
if err != nil {
continue
diff --git a/internal/index/sqlite_test.go b/internal/index/sqlite_test.go
index e0430ee..5ff1d40 100644
--- a/internal/index/sqlite_test.go
+++ b/internal/index/sqlite_test.go
@@ -131,67 +131,3 @@
t.Errorf("links count = %d, want 0", len(links))
}
}
-
-func TestNodeTypeSeparation(t *testing.T) {
- tmpDir := t.TempDir()
- store, err := Open(filepath.Join(tmpDir, "test.db"))
- if err != nil {
- t.Fatalf("Open failed: %v", err)
- }
- defer store.Close()
-
- // 插入文件节点 + tag 节点
- fileID, err := store.InsertNode(&graph.Node{
- Path: "FAQ/001-测试.md", Title: "测试文档", Section: "FAQ", NodeType: "file",
- Tags: []string{"电磁阀"},
- })
- if err != nil {
- t.Fatalf("InsertNode file failed: %v", err)
- }
- tagID, err := store.InsertNode(&graph.Node{
- Path: "tag:电磁阀", Title: "电磁阀", NodeType: "tag",
- })
- if err != nil {
- t.Fatalf("InsertNode tag failed: %v", err)
- }
-
- // tag 节点不进 FTS
- if err := store.CreateFTS(); err != nil {
- t.Fatalf("CreateFTS failed: %v", err)
- }
- if err := store.PopulateFTS(); err != nil {
- t.Fatalf("PopulateFTS failed: %v", err)
- }
- res, err := store.FTSSearch([]string{"测试"}, 10)
- if err != nil {
- t.Fatalf("FTSSearch failed: %v", err)
- }
- for _, r := range res {
- if r.ID == tagID {
- t.Error("FTS result contains tag node")
- }
- }
-
- // FindNodesByKeyword 不返回 tag 节点
- nodes, err := store.FindNodesByKeyword("电磁阀")
- if err != nil {
- t.Fatalf("FindNodesByKeyword failed: %v", err)
- }
- for _, n := range nodes {
- if n.ID == tagID {
- t.Error("FindNodesByKeyword returned tag node")
- }
- }
-
- // GetAllNodes 只返回 file 节点
- all, err := store.GetAllNodes()
- if err != nil {
- t.Fatalf("GetAllNodes failed: %v", err)
- }
- for _, n := range all {
- if n.ID == tagID {
- t.Error("GetAllNodes returned tag node")
- }
- }
- _ = fileID
-}
diff --git a/internal/index/unresolved_test.go b/internal/index/unresolved_test.go
new file mode 100644
index 0000000..4d15230
--- /dev/null
+++ b/internal/index/unresolved_test.go
@@ -0,0 +1,130 @@
+package index
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestRetryUnresolved(t *testing.T) {
+ dir := t.TempDir()
+ os.MkdirAll(filepath.Join(dir, "FAQ"), 0755)
+ os.WriteFile(filepath.Join(dir, "FAQ", "001-旧文档.md"),
+ []byte("---\ntitle: 旧文档\n---\n提到 [[补气失败]]\n"), 0644)
+
+ store, _ := Open(filepath.Join(t.TempDir(), "kb.db"))
+ defer store.Close()
+
+ Reconcile(store, dir)
+
+ // 验证悬空链接入表
+ var n int
+ store.db.QueryRow(`SELECT COUNT(*) FROM unresolved_links WHERE link_text='补气失败'`).Scan(&n)
+ if n != 1 {
+ t.Fatalf("悬空链接未入表: %d", n)
+ }
+
+ // 新文档入库:标题正好是 "补气失败"
+ os.WriteFile(filepath.Join(dir, "FAQ", "002-补气失败.md"),
+ []byte("---\ntitle: 补气失败\n---\n补气失败排查\n"), 0644)
+
+ res, err := Reconcile(store, dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if res.Added != 1 {
+ t.Fatalf("新增: %+v", res)
+ }
+
+ // 悬空链接应被解析:行删除 + 边建立
+ store.db.QueryRow(`SELECT COUNT(*) FROM unresolved_links WHERE link_text='补气失败'`).Scan(&n)
+ if n != 0 {
+ t.Errorf("悬空行未清除: %d", n)
+ }
+ store.db.QueryRow(`SELECT COUNT(*) FROM edges WHERE relation='wikilink' AND label='补气失败'`).Scan(&n)
+ if n != 1 {
+ t.Errorf("wikilink 边未建立: %d", n)
+ }
+ var prov string
+ store.db.QueryRow(`SELECT provenance FROM edges WHERE relation='wikilink' AND label='补气失败'`).Scan(&prov)
+ if prov != "exact" {
+ t.Errorf("补全边应为 exact: %s", prov)
+ }
+}
+
+func TestResolveWikilinkEmptyLink(t *testing.T) {
+ dir := t.TempDir()
+ os.MkdirAll(filepath.Join(dir, "FAQ"), 0755)
+ os.WriteFile(filepath.Join(dir, "FAQ", "001-文档.md"),
+ []byte("---\ntitle: 文档\n---\n提到 [[|别名]]\n"), 0644)
+
+ store, _ := Open(filepath.Join(t.TempDir(), "kb.db"))
+ defer store.Close()
+ Reconcile(store, dir)
+
+ // 空 link 不应 fuzzy 误建边(Contains 空串恒真)
+ var n int
+ store.db.QueryRow(`SELECT COUNT(*) FROM edges WHERE relation='wikilink' AND label='|别名'`).Scan(&n)
+ if n != 0 {
+ t.Errorf("空 link 不应建 wikilink 边: %d", n)
+ }
+ // 悬空行应入表且只入一行
+ store.db.QueryRow(`SELECT COUNT(*) FROM unresolved_links WHERE link_text='|别名'`).Scan(&n)
+ if n != 1 {
+ t.Errorf("悬空行应为 1: %d", n)
+ }
+}
+
+func TestTagEntityEdgeProvenance(t *testing.T) {
+ dir := t.TempDir()
+ os.MkdirAll(filepath.Join(dir, "FAQ"), 0755)
+ os.WriteFile(filepath.Join(dir, "FAQ", "001-文档.md"),
+ []byte("---\ntitle: 文档\ntags: [t1]\nentities: [张三]\n---\n内容\n"), 0644)
+
+ store, _ := Open(filepath.Join(t.TempDir(), "kb.db"))
+ defer store.Close()
+ Reconcile(store, dir)
+
+ // tag/entity 边的 provenance 应为 relation 本身(Minor 1 修复)
+ var prov string
+ store.db.QueryRow(`SELECT provenance FROM edges WHERE relation='tag' AND label='t1'`).Scan(&prov)
+ if prov != "tag" {
+ t.Errorf("tag 边 provenance: %q", prov)
+ }
+ store.db.QueryRow(`SELECT provenance FROM edges WHERE relation='entity' AND label='张三'`).Scan(&prov)
+ if prov != "entity" {
+ t.Errorf("entity 边 provenance: %q", prov)
+ }
+}
+
+// TestGetUnresolvedLinks 覆盖 GetUnresolvedLinks 查询(explore 命令悬空链接提示用)
+func TestGetUnresolvedLinks(t *testing.T) {
+ dir := t.TempDir()
+ os.MkdirAll(filepath.Join(dir, "FAQ"), 0755)
+ // 文档 A 有两条悬空链接;文档 B 无
+ os.WriteFile(filepath.Join(dir, "FAQ", "001-文档A.md"),
+ []byte("---\ntitle: 文档A\n---\n提到 [[悬空甲]] 和 [[悬空乙]]\n"), 0644)
+ os.WriteFile(filepath.Join(dir, "FAQ", "002-文档B.md"),
+ []byte("---\ntitle: 文档B\n---\n无链接\n"), 0644)
+
+ store, _ := Open(filepath.Join(t.TempDir(), "kb.db"))
+ defer store.Close()
+ Reconcile(store, dir)
+
+ links, err := store.GetUnresolvedLinks("FAQ/001-文档A.md")
+ if err != nil {
+ t.Fatalf("GetUnresolvedLinks failed: %v", err)
+ }
+ if len(links) != 2 {
+ t.Fatalf("文档A 悬空链接 = %d, want 2: %v", len(links), links)
+ }
+
+ // 无悬空链接的文档返回空(非 nil 错误)
+ links, err = store.GetUnresolvedLinks("FAQ/002-文档B.md")
+ if err != nil {
+ t.Fatalf("GetUnresolvedLinks(文档B) failed: %v", err)
+ }
+ if len(links) != 0 {
+ t.Errorf("文档B 悬空链接 = %d, want 0: %v", len(links), links)
+ }
+}
diff --git a/internal/llm/client.go b/internal/llm/client.go
index d48961f..44ff58b 100644
--- a/internal/llm/client.go
+++ b/internal/llm/client.go
@@ -39,6 +39,16 @@
ReviewDir string `yaml:"review_dir"`
DefaultType string `yaml:"default_type"`
} `yaml:"draft"`
+ // Search 搜索配置(双信号加权权重)
+ Search struct {
+ TextWeight float64 `yaml:"text_weight"` // 文本分权重(0~1,缺省 0.5)
+ } `yaml:"search"`
+ // Explore 探索预算配置(Task 7 预留)
+ Explore struct {
+ DefaultBudget int `yaml:"default_budget"`
+ HardBudget int `yaml:"hard_budget"`
+ TopN int `yaml:"top_n"`
+ } `yaml:"explore"`
}
// Client LLM 客户端
diff --git a/internal/search/engine.go b/internal/search/engine.go
index 16fa86d..13e2ff1 100644
--- a/internal/search/engine.go
+++ b/internal/search/engine.go
@@ -4,6 +4,7 @@
"sort"
"strings"
+ "github.com/aisim/kb-cli/internal/graph"
"github.com/aisim/kb-cli/internal/index"
)
@@ -14,6 +15,7 @@
TopN int // 返回前 N 条
WithContent bool // 返回完整文件内容
WithLinks bool // 返回关联文档链接
+ TextWeight float64 // 文本分权重(0~1,缺省 0.5;RWR 图质量权重 = 1 - TextWeight)
}
// SearchResult 搜索结果
@@ -23,50 +25,124 @@
Title string `json:"title"`
Section string `json:"section"`
Score int `json:"score"`
+ Status string `json:"status,omitempty"`
Content string `json:"content,omitempty"` // 文件内容(WithContent=true 时填充)
Links []string `json:"links,omitempty"` // 关联文档路径(WithLinks=true 时填充)
}
-// Search 执行搜索
+// Search 执行搜索(双信号加权:文本位置分 × TextWeight + RWR 图质量 × (1-TextWeight),
+// 草稿态/待审阅降权 0.6)
func Search(store *index.Store, keywords []string, opts SearchOptions) ([]SearchResult, error) {
// 合并所有关键词
- allKeywords := append(keywords, opts.Expanded...)
- allKeywords = append(allKeywords, opts.Symptom...)
+ allKeywords := append(append(keywords, opts.Expanded...), opts.Symptom...)
- // FTS5 搜索
- ftsResults, err := store.FTSSearch(allKeywords, 100)
+ // 长 CJK 词 bigram 展开(统一下沉):FTS5 unicode61 把连续中文当整串单 token,
+ // 长复合词整串 LIKE 匹配不到;ExpandCJKKeywords 拆成 bigram 滑动窗口
+ // (原词保留、去重保序)。bigram 参与检索,textScore 按 ScoreExpanded 档计权
+ // (与现有 Expanded 机制一致),原词仍按原词档。
+ searchKeywords := ExpandCJKKeywords(allKeywords)
+ originalSet := make(map[string]bool, len(allKeywords))
+ for _, kw := range allKeywords {
+ originalSet[kw] = true
+ }
+ var bigrams []string
+ for _, kw := range searchKeywords {
+ if !originalSet[kw] {
+ bigrams = append(bigrams, kw)
+ }
+ }
+
+ // 1. 双通道检索(ASCII 走 FTS,CJK 走 LIKE)
+ candidates, err := store.KeywordSearch(searchKeywords, 100)
if err != nil {
return nil, err
}
+ if len(candidates) == 0 {
+ return nil, nil
+ }
- // 评分
- scoreMap := make(map[int64]int)
- for _, r := range ftsResults {
- score := 0
-
- // 关键词匹配评分
+ // 2. 文本分(沿用位置加权,aliases 按 title 档计权)
+ textScore := make(map[int64]int, len(candidates))
+ for _, r := range candidates {
+ var score int
for _, kw := range keywords {
score += scoreResult(r, kw, ScoreNormal)
}
for _, kw := range opts.Expanded {
score += scoreResult(r, kw, ScoreExpanded)
}
+ // bigram 展开词按 Expanded 档计权(与用户显式扩展词同档)
+ for _, kw := range bigrams {
+ score += scoreResult(r, kw, ScoreExpanded)
+ }
for _, kw := range opts.Symptom {
score += scoreResult(r, kw, ScoreSymptom)
}
-
- scoreMap[r.ID] = score
+ textScore[r.ID] = score
}
- // 转换为结果列表
+ // 3. RWR 图质量(种子 = 候选前 20,全量加载邻接后按种子收敛)
+ seedIDs := make([]int64, 0, len(candidates))
+ for _, r := range candidates {
+ if len(seedIDs) >= 20 {
+ break
+ }
+ seedIDs = append(seedIDs, r.ID)
+ }
+ rwrMass := map[int64]float64{}
+ if len(seedIDs) > 0 {
+ adj, err := store.LoadRWRGraph()
+ if err == nil {
+ rwrMass = graph.RWR(seedIDs, adj, 0.25)
+ }
+ }
+
+ // 4. 双信号加权:finalScore = norm(textScore)*tw + rwrMass*(1-tw)
+ tw := opts.TextWeight
+ if tw <= 0 || tw > 1 {
+ tw = 0.5
+ }
+ // 先求文本分真实 min/max,再按 (s-minT)/(maxT-minT) 归一化
+ minT, maxT := 0, 0
+ for _, s := range textScore {
+ if minT == 0 && maxT == 0 {
+ minT, maxT = s, s
+ } else {
+ if s < minT {
+ minT = s
+ }
+ if s > maxT {
+ maxT = s
+ }
+ }
+ }
+ // status 降权:草稿态或待审阅板块 ×0.6
+ statusFactor := make(map[int64]float64, len(candidates))
+ for _, r := range candidates {
+ if r.Section == "待审阅" || isDraftStatus(r.Status) {
+ statusFactor[r.ID] = 0.6
+ } else {
+ statusFactor[r.ID] = 1.0
+ }
+ }
+
var results []SearchResult
- for _, r := range ftsResults {
+ for _, r := range candidates {
+ ts := float64(textScore[r.ID])
+ if maxT > minT {
+ ts = (ts - float64(minT)) / float64(maxT - minT)
+ } else if maxT > 0 {
+ ts = 1
+ }
+ final := ts*tw + rwrMass[r.ID]*(1-tw)
+ final *= statusFactor[r.ID]
results = append(results, SearchResult{
ID: r.ID,
Path: r.Path,
Title: r.Title,
Section: r.Section,
- Score: scoreMap[r.ID],
+ Score: int(final * 100),
+ Status: r.Status,
})
}
@@ -103,6 +179,15 @@
return results, nil
}
+// isDraftStatus 草稿态降权判断(草稿/待确认/跟进中 降权 0.6)
+func isDraftStatus(status string) bool {
+ switch status {
+ case "草稿", "待确认", "跟进中":
+ return true
+ }
+ return false
+}
+
// scoreResult 计算单个结果的得分
func scoreResult(r index.FTSResult, keyword string, scoreType ScoreType) int {
score := 0
@@ -123,6 +208,16 @@
score += CalcScore(keyword, "tag", scoreType)
}
+ // 别名匹配(命中按 title 档计权,一次命中即计)
+ if len(r.Aliases) > 0 {
+ for _, a := range r.Aliases {
+ if strings.Contains(strings.ToLower(a), kw) {
+ score += CalcScore(keyword, "title", scoreType)
+ break
+ }
+ }
+ }
+
// 内容匹配(FTS 已经匹配,给基础分)
score += CalcScore(keyword, "content", scoreType)
diff --git a/internal/search/engine_test.go b/internal/search/engine_test.go
index 2cd91d3..06b511f 100644
--- a/internal/search/engine_test.go
+++ b/internal/search/engine_test.go
@@ -78,6 +78,117 @@
}
}
+func TestCJKLikeChannel(t *testing.T) {
+ // CJK LIKE 通道:多字符中文词 FTS MATCH 匹配不到(unicode61 整串 token),
+ // 必须走 LIKE 才能命中 content/title
+ tmpDir := t.TempDir()
+ dbPath := filepath.Join(tmpDir, "test.db")
+ store, err := index.Open(dbPath)
+ if err != nil {
+ t.Fatalf("Open failed: %v", err)
+ }
+ defer store.Close()
+
+ nodes := []*graph.Node{
+ { // content 含"补气",title 不含(靠 LIKE content 通道命中)
+ Path: "FAQ/001-a.md",
+ Title: "设备故障排查",
+ Section: "FAQ",
+ Content: "电子秤补气失败时的排查步骤",
+ },
+ { // title 含"补气"(title LIKE 命中,应排前)
+ Path: "FAQ/002-b.md",
+ Title: "电子秤补气失败",
+ Section: "FAQ",
+ Content: "补气失败的处理方法",
+ },
+ { // 不含"补气"(不应出现在结果中)
+ Path: "FAQ/003-c.md",
+ Title: "阀门漏气处理",
+ Section: "FAQ",
+ Content: "阀门漏气的原因和处理",
+ },
+ }
+ for _, n := range nodes {
+ if err := store.UpsertNode(n, 100, 1, "x"); err != nil {
+ t.Fatalf("UpsertNode failed: %v", err)
+ }
+ }
+ if err := store.CreateFTS(); err != nil {
+ t.Fatalf("CreateFTS failed: %v", err)
+ }
+ if err := store.PopulateFTS(); err != nil {
+ t.Fatalf("PopulateFTS failed: %v", err)
+ }
+
+ results, err := Search(store, []string{"补气"}, SearchOptions{})
+ if err != nil {
+ t.Fatalf("Search failed: %v", err)
+ }
+ if len(results) < 2 {
+ t.Fatalf("Expected 2 results (content 命中 + title 命中), got %d", len(results))
+ }
+ // 两个含"补气"的节点都应在结果中
+ if results[0].Path != "FAQ/002-b.md" && results[1].Path != "FAQ/002-b.md" {
+ t.Error("title 含'补气'的节点应出现在结果中")
+ }
+ if results[0].Path != "FAQ/001-a.md" && results[1].Path != "FAQ/001-a.md" {
+ t.Error("content 含'补气'的节点应出现在结果中")
+ }
+ // 不含"补气"的节点不应出现
+ for _, r := range results {
+ if r.Path == "FAQ/003-c.md" {
+ t.Error("不含关键词的节点不应出现在结果中")
+ }
+ }
+ // title 命中者应排前(title 计权高于 content)
+ if results[0].Path != "FAQ/002-b.md" {
+ t.Errorf("title 命中者应排第一, got %q", results[0].Path)
+ }
+}
+
+// TestSearchLongCJKBigram 回归(审查遗留):单复合 CJK 词「电子秤补气失败」
+// 在内容中不连续出现时,整串 LIKE 匹配不到,search 必须对长 CJK 词做 bigram
+// 展开后检索才能命中(RED 证据:未下沉前该查询 0 结果)。
+func TestSearchLongCJKBigram(t *testing.T) {
+ tmpDir := t.TempDir()
+ dbPath := filepath.Join(tmpDir, "test.db")
+ store, err := index.Open(dbPath)
+ if err != nil {
+ t.Fatalf("Open failed: %v", err)
+ }
+ defer store.Close()
+
+ // content 含「电子秤补气」「补气失败」片段,但不含完整的连续「电子秤补气失败」
+ n := &graph.Node{
+ ID: 1,
+ Path: "FAQ/称重/001-test.md",
+ Title: "称重故障排查",
+ Section: "FAQ",
+ Content: "电子秤补气 时先检查阀门,补气失败后断电重启。",
+ }
+ if err := store.UpsertNode(n, 100, 1, "x"); err != nil {
+ t.Fatalf("UpsertNode failed: %v", err)
+ }
+ if err := store.CreateFTS(); err != nil {
+ t.Fatalf("CreateFTS failed: %v", err)
+ }
+ if err := store.PopulateFTS(); err != nil {
+ t.Fatalf("PopulateFTS failed: %v", err)
+ }
+
+ results, err := Search(store, []string{"电子秤补气失败"}, SearchOptions{})
+ if err != nil {
+ t.Fatalf("Search failed: %v", err)
+ }
+ if len(results) != 1 {
+ t.Fatalf("expected 1 result (bigram 展开命中), got %d", len(results))
+ }
+ if results[0].Path != "FAQ/称重/001-test.md" {
+ t.Errorf("expected hit FAQ/称重/001-test.md, got %q", results[0].Path)
+ }
+}
+
func TestIsGenericWord(t *testing.T) {
tests := []struct {
word string
diff --git a/internal/search/expand.go b/internal/search/expand.go
new file mode 100644
index 0000000..1c1d835
--- /dev/null
+++ b/internal/search/expand.go
@@ -0,0 +1,39 @@
+package search
+
+// containsCJK 是否含 CJK 统一表意文字
+func containsCJK(s string) bool {
+ for _, r := range s {
+ if r >= 0x4E00 && r <= 0x9FFF {
+ return true
+ }
+ }
+ return false
+}
+
+// ExpandCJKKeywords 关键词展开(search 与 explore 共享):CJK 长词(>3 字符)
+// 拆成 bigram 滑动窗口(FTS5 unicode61 把连续中文当整串单 token,长复合词整串
+// LIKE 匹配不到;文档里 "电子秤补气失败" 通常不是连续子串,bigram 才能命中)。
+// 原词保留(段落命中判断时原词更精确)。末尾去重保序(原词在前、展开词在后)。
+func ExpandCJKKeywords(keywords []string) []string {
+ var out []string
+ for _, kw := range keywords {
+ out = append(out, kw)
+ if containsCJK(kw) && len([]rune(kw)) > 3 {
+ runes := []rune(kw)
+ for i := 0; i+1 < len(runes); i++ {
+ bg := string(runes[i : i+2])
+ out = append(out, bg)
+ }
+ }
+ }
+ // 去重保序(原词在前、展开词在后):避免 bigram 与原词重复时 text 分重复计权
+ var dedup []string
+ seen := make(map[string]bool, len(out))
+ for _, kw := range out {
+ if !seen[kw] {
+ seen[kw] = true
+ dedup = append(dedup, kw)
+ }
+ }
+ return dedup
+}
diff --git a/internal/search/explore.go b/internal/search/explore.go
new file mode 100644
index 0000000..c0cb05d
--- /dev/null
+++ b/internal/search/explore.go
@@ -0,0 +1,170 @@
+package search
+
+import (
+ "regexp"
+ "strings"
+
+ "github.com/aisim/kb-cli/internal/index"
+)
+
+// headingRe 1-4 级 Markdown 标题
+var headingRe = regexp.MustCompile(`^#{1,4} .+$`)
+
+// splitParagraphs 按 1-4 级标题切段。无标题的文档整体为一段。
+func splitParagraphs(content string) []string {
+ lines := strings.Split(content, "\n")
+ var paras []string
+ var cur []string
+ flush := func() {
+ if len(cur) > 0 {
+ paras = append(paras, strings.TrimRight(strings.Join(cur, "\n"), "\n")+"\n")
+ cur = nil
+ }
+ }
+ for _, l := range lines {
+ if headingRe.MatchString(l) {
+ flush()
+ }
+ cur = append(cur, l)
+ }
+ flush()
+ return paras
+}
+
+// extractRelevantParagraphs 返回命中关键词的段落(整段不截半句)。
+// 文档总长 <= budget 时整篇输出;无命中段落时输出空串;
+// 预算约束:累计超预算的段落丢弃(不截半段)。
+func extractRelevantParagraphs(content string, keywords []string, budget int) string {
+ if budget <= 0 || len(content) <= budget {
+ return content
+ }
+ var out []string
+ for _, p := range splitParagraphs(content) {
+ for _, kw := range keywords {
+ if strings.Contains(p, kw) {
+ out = append(out, p)
+ break
+ }
+ }
+ }
+ // 预算约束:累计超预算的段落丢弃(不截半段)
+ var total int
+ kept := []string{}
+ for _, p := range out {
+ if total+len(p) > budget {
+ break
+ }
+ total += len(p)
+ kept = append(kept, p)
+ }
+ return strings.Join(kept, "")
+}
+
+// extractWithBudget extractRelevantParagraphs 的预算版(budget<=0 视为无预算)
+func extractWithBudget(content string, keywords []string, budget int) string {
+ if budget <= 0 {
+ budget = 100000
+ }
+ return extractRelevantParagraphs(content, keywords, budget)
+}
+
+// ExploreOptions explore 参数
+type ExploreOptions struct {
+ Budget int // 字节预算(0 = 用配置默认)
+ TopN int // 0 = 用配置默认
+ HardBudget int // 字节硬上限(0 = 用代码缺省 32000)
+}
+
+// ExploredDoc 入选文档及其输出正文
+type ExploredDoc struct {
+ Path string `json:"path"`
+ Title string `json:"title"`
+ Section string `json:"section"`
+ Score int `json:"score"`
+ Body string `json:"body"`
+}
+
+// ExploreResult explore 结果
+type ExploreResult struct {
+ Docs []ExploredDoc `json:"docs"`
+ Related map[string][]string `json:"related"`
+ UnresolvedLinks []string `json:"unresolved_links"`
+}
+
+// Explore 一次调用返回相关文档原文 + 关联清单 + 悬空链接
+func Explore(store *index.Store, keywords []string, cfg ExploreOptions) (*ExploreResult, error) {
+ if len(keywords) == 0 {
+ return nil, nil
+ }
+ // 段落命中用原词(精确),FTS/LIKE 检索用展开后的 bigram
+ searchKeywords := ExpandCJKKeywords(keywords)
+ budget := cfg.Budget
+ if budget <= 0 {
+ budget = 16000 // 代码缺省(config 读取在 cmd 层完成)
+ }
+ if budget > 32000 {
+ budget = 32000 // 硬上限缺省
+ }
+ // 配置显式给出的硬上限优先(cmd 层读 config.yaml 的 explore.hard_budget)
+ if cfg.HardBudget > 0 && budget > cfg.HardBudget {
+ budget = cfg.HardBudget
+ }
+ topN := cfg.TopN
+ if topN <= 0 {
+ topN = 5
+ }
+
+ opts := SearchOptions{TopN: topN}
+ results, err := Search(store, searchKeywords, opts)
+ if err != nil {
+ return nil, err
+ }
+ if len(results) == 0 {
+ return &ExploreResult{Related: map[string][]string{}}, nil
+ }
+
+ // 按分数降序分配预算:每文档至少 800 字节
+ res := &ExploreResult{Related: map[string][]string{}}
+ perDoc := budget / len(results)
+ if perDoc < 800 {
+ perDoc = 800
+ }
+ for _, r := range results {
+ content, _, _, err := store.GetNodeContent(r.ID)
+ if err != nil || content == "" {
+ continue
+ }
+ // 两级回退:先用原词提取(精确);原词在任何段落都不连续出现时
+ //(复合 CJK 词常见:bigram 召回了文档,但原词不连续)回退到展开词提取,
+ // 避免大文档被 continue 静默丢弃、最终输出「未找到相关文档」。
+ body := extractRelevantParagraphs(content, keywords, perDoc)
+ if body == "" {
+ body = extractRelevantParagraphs(content, searchKeywords, perDoc)
+ }
+ if body == "" {
+ // 无命中段落但文档入选 → 整篇(若放得下),否则跳过
+ if len(content) <= perDoc {
+ body = content
+ } else {
+ continue
+ }
+ }
+ res.Docs = append(res.Docs, ExploredDoc{
+ Path: r.Path, Title: r.Title, Section: r.Section,
+ Score: r.Score, Body: body,
+ })
+ // 关联清单
+ if links, err := store.GetNodeLinks(r.ID); err == nil {
+ res.Related[r.Path] = links
+ }
+ }
+
+ // 悬空链接提示:入选文档的 wikilinks 中未解析的
+ for _, d := range res.Docs {
+ links, err := store.GetUnresolvedLinks(d.Path)
+ if err == nil {
+ res.UnresolvedLinks = append(res.UnresolvedLinks, links...)
+ }
+ }
+ return res, nil
+}
diff --git a/internal/search/explore_test.go b/internal/search/explore_test.go
new file mode 100644
index 0000000..bfe2677
--- /dev/null
+++ b/internal/search/explore_test.go
@@ -0,0 +1,100 @@
+package search
+
+import (
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/aisim/kb-cli/internal/graph"
+ "github.com/aisim/kb-cli/internal/index"
+)
+
+// TestExploreParagraphExtraction 段落截取:只输出命中关键词的段落,整段不截半句
+func TestExploreParagraphExtraction(t *testing.T) {
+ content := "# 标题\n\n第一段讲称重。\n\n## 补气流程\n\n补气失败时先检查阀门。\n\n## 其他\n\n无关内容。\n"
+ got := extractRelevantParagraphs(content, []string{"补气"}, 100)
+ want := "## 补气流程\n\n补气失败时先检查阀门。\n"
+ if got != want {
+ t.Errorf("段落截取:\n got=%q\nwant=%q", got, want)
+ }
+}
+
+// TestExploreWholeDocWhenSmall 文档短于预算时整篇输出
+func TestExploreWholeDocWhenSmall(t *testing.T) {
+ got := extractRelevantParagraphs("短文档\n", []string{"不存在"}, 10000)
+ if got != "短文档\n" {
+ t.Errorf("应整篇输出: %q", got)
+ }
+}
+
+// TestExploreKeywordExpansion 长 CJK 词拆 bigram(原词保留,ASCII/短词不拆;末尾去重保序)
+func TestExploreKeywordExpansion(t *testing.T) {
+ got := ExpandCJKKeywords([]string{"电子秤补气失败", "补气", "abc"})
+ want := []string{"电子秤补气失败", "电子", "子秤", "秤补", "补气", "气失", "失败", "abc"}
+ if len(got) != len(want) {
+ t.Fatalf("展开数量: got=%d want=%d (%v)", len(got), len(want), got)
+ }
+ for i := range want {
+ if got[i] != want[i] {
+ t.Errorf("第 %d 项: got=%q want=%q", i, got[i], want[i])
+ }
+ }
+}
+
+func TestExploreBudget(t *testing.T) {
+ // 用 extractRelevantParagraphs 的预算版验证:预算 20 字节,命中段落 30 字节 → 输出空(宁缺毋滥,不截半段)
+ got := extractWithBudget("## 段落\n\n这是一段超过预算的内容啊\n", []string{"段落"}, 20)
+ if got != "" {
+ t.Errorf("超预算段落应跳过: %q", got)
+ }
+}
+
+// TestExploreFallbackToExpanded 回归(审查 Important):单复合 CJK 词在大文档中
+// 原文不连续出现(bigram 召回入选,但原词在任何段落都不出现)时,
+// 段落截取必须回退到展开词,不能静默丢弃文档。
+func TestExploreFallbackToExpanded(t *testing.T) {
+ tmpDir := t.TempDir()
+ dbPath := filepath.Join(tmpDir, "test.db")
+ store, err := index.Open(dbPath)
+ if err != nil {
+ t.Fatalf("Open failed: %v", err)
+ }
+ defer store.Close()
+
+ // 大文档(> 2000 字节预算):某段落只含「电子秤补气」片段,不含完整原词「电子秤补气失败」
+ filler := strings.Repeat("填充段落内容,用于把文档撑过预算。\n", 60)
+ content := "# 称重故障排查\n\n" + filler + "\n## 称重故障\n\n电子秤补气 时先检查阀门,失败则断电重启。\n"
+
+ n := &graph.Node{
+ ID: 1,
+ Path: "FAQ/称重/001-test.md",
+ Title: "称重故障排查",
+ Section: "FAQ",
+ Content: content,
+ }
+ if _, err := store.InsertNode(n); err != nil {
+ t.Fatalf("InsertNode failed: %v", err)
+ }
+ if err := store.CreateFTS(); err != nil {
+ t.Fatalf("CreateFTS failed: %v", err)
+ }
+ if err := store.PopulateFTS(); err != nil {
+ t.Fatalf("PopulateFTS failed: %v", err)
+ }
+
+ // 原词提取必为空(内容里没有完整的「电子秤补气失败」)
+ if b := extractRelevantParagraphs(content, []string{"电子秤补气失败"}, 2000); b != "" {
+ t.Fatalf("前提不成立:原词提取应为空,got=%q", b)
+ }
+
+ res, err := Explore(store, []string{"电子秤补气失败"}, ExploreOptions{Budget: 2000, TopN: 5})
+ if err != nil {
+ t.Fatalf("Explore failed: %v", err)
+ }
+ if len(res.Docs) != 1 {
+ t.Fatalf("应召回 1 篇文档(bigram 命中),got=%d", len(res.Docs))
+ }
+ if !strings.Contains(res.Docs[0].Body, "电子秤补气") {
+ t.Errorf("回退展开词后应提取含片段段落: body=%q", res.Docs[0].Body)
+ }
+}
diff --git a/internal/vault/parser.go b/internal/vault/parser.go
index 6764af4..f4cbb89 100644
--- a/internal/vault/parser.go
+++ b/internal/vault/parser.go
@@ -16,6 +16,8 @@
Section string // 所属板块
Tags []string // frontmatter tags
Entities []string // frontmatter entities
+ Aliases []string // frontmatter aliases
+ Status string // frontmatter status
Wikilinks []string // 正文中的 [[xxx]] 链接
Content string // 纯文本内容(去 frontmatter)
}
@@ -26,6 +28,7 @@
Tags []string `yaml:"tags"`
Entities []string `yaml:"entities"`
Aliases []string `yaml:"aliases"`
+ Status string `yaml:"status"`
}
var wikilinkRe = regexp.MustCompile(`\[\[([^\]]+)\]\]`)
@@ -75,6 +78,8 @@
meta.Title = fm.Title
meta.Tags = fm.Tags
meta.Entities = fm.Entities
+ meta.Aliases = fm.Aliases
+ meta.Status = fm.Status
}
}
diff --git a/internal/vault/scanner.go b/internal/vault/scanner.go
index 41b37b8..7981aaf 100644
--- a/internal/vault/scanner.go
+++ b/internal/vault/scanner.go
@@ -40,3 +40,40 @@
return files, err
}
+
+// FileStat 文件指纹(只 stat,不读内容)
+type FileStat struct {
+ Path string // 相对路径
+ Size int64
+ Mtime int64 // Unix 秒
+}
+
+// ScanVaultStat 扫描 .md 文件指纹清单(跳过隐藏目录和待审阅目录,与 ScanVault 一致)
+func ScanVaultStat(vaultPath string) ([]FileStat, error) {
+ var stats []FileStat
+ err := filepath.Walk(vaultPath, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return nil
+ }
+ if info.IsDir() {
+ if strings.HasPrefix(info.Name(), ".") && info.Name() != "." {
+ return filepath.SkipDir
+ }
+ if info.Name() == "待审阅" {
+ return filepath.SkipDir
+ }
+ return nil
+ }
+ if !strings.HasSuffix(path, ".md") {
+ return nil
+ }
+ relPath, _ := filepath.Rel(vaultPath, path)
+ stats = append(stats, FileStat{
+ Path: relPath,
+ Size: info.Size(),
+ Mtime: info.ModTime().Unix(),
+ })
+ return nil
+ })
+ return stats, err
+}
diff --git a/scripts/e2e-verify.sh b/scripts/e2e-verify.sh
new file mode 100644
index 0000000..b4aa02b
--- /dev/null
+++ b/scripts/e2e-verify.sh
@@ -0,0 +1,48 @@
+#!/bin/bash
+# 端到端验证(对标 spec 测试计划第 2 节)
+set -e
+set -o pipefail # 防止管道末端(tail/head)掩盖真实命令失败
+export CGO_CFLAGS="-DSQLITE_ENABLE_FTS5"
+export CGO_LDFLAGS="-lm"
+FAILS=0
+VAULT="${1:-$HOME/rag-lpg-obsidian}"
+cd "$(dirname "$0")/.."
+go build -o /tmp/kb-cli-e2e .
+
+echo "=== 1. 全量重建基准 ==="
+/tmp/kb-cli-e2e index build --vault "$VAULT" --force 2>&1 | tail -1
+
+echo "=== 2. 增量同步耗时 ==="
+time /tmp/kb-cli-e2e index build --vault "$VAULT" 2>&1 | tail -1
+
+echo "=== 3. 未 commit 编辑可见性 ==="
+mkdir -p "$VAULT/笔记"
+TESTFILE="$VAULT/笔记/e2e-test-$(date +%s).md"
+printf -- "---\ntitle: e2e测试\ntags: [补气]\n---\ne2e 补气测试内容\n" > "$TESTFILE"
+if /tmp/kb-cli-e2e search e2e --vault "$VAULT" --top 3 2>&1 | grep -q "e2e测试"; then
+ echo "PASS: 未commit编辑可见"
+else
+ echo "FAIL: 未commit编辑不可见"; FAILS=$((FAILS+1))
+fi
+rm -f "$TESTFILE"
+/tmp/kb-cli-e2e index build --vault "$VAULT" 2>/dev/null
+
+echo "=== 4. CJK 召回质量(补气应命中多条)==="
+# 适配说明:brief 原 grep 模式 ^文档/\|^FAQ/\|^知识/ 会漏掉「笔记/」等其它板块的结果行,
+# 实际输出结果行统一为「板块/…/xxx.md <标题>…」(行首即板块前缀、以 .md 结尾),
+# 故按「行首路径以 .md 结尾」计数(以实际输出为准的最小适配)。
+N=$(/tmp/kb-cli-e2e search 补气 --vault "$VAULT" --top 10 2>/dev/null | grep -cE '^[^[:space:]]+\.md[[:space:]]' || true)
+echo "补气 命中 $N 条 (期望 >= 5)"
+if [ "$N" -ge 5 ]; then echo "PASS: CJK 召回 >= 5"; else echo "FAIL: CJK 召回不足"; FAILS=$((FAILS+1)); fi
+
+echo "=== 5. explore 预算内 ==="
+BYTES=$(/tmp/kb-cli-e2e explore "电子秤补气失败" --vault "$VAULT" --top 3 2>/dev/null | wc -c)
+echo "explore 输出 ${BYTES} 字节 (上限 32000+尾注)"
+if [ "$BYTES" -le 32200 ]; then echo "PASS: explore 预算内"; else echo "FAIL: explore 超预算"; FAILS=$((FAILS+1)); fi
+
+echo "=== 6. 悬空补全 ==="
+/tmp/kb-cli-e2e graph stats --vault "$VAULT" 2>&1 | head -5
+
+echo "=== e2e 验证完成 ==="
+# CI 可凭退出码判定:任一检查 FAIL 则非 0
+[ "$FAILS" -eq 0 ] || exit "$FAILS"
--
Gitblit v1.10.0