From 46a01d99b5d15120ada95754c9effbdcb576d31e Mon Sep 17 00:00:00 2001
From: ai_xiaopei <xiaopei@aisim.cn>
Date: Sun, 26 Jul 2026 08:19:23 +0800
Subject: [PATCH] Task 4: add CLI flags for with-content and with-links
---
cmd/search.go | 87 +++++++
internal/graph/builder.go | 11
test-integration.sh | 73 ++++++
Makefile | 32 ++
kb | 0
internal/search/engine.go | 40 ++
bin/kb | 0
internal/index/sqlite_test.go | 67 ++++++
cmd/rebuild.go | 65 +++++
cmd/index.go | 117 ++++++++++
internal/index/sqlite.go | 30 ++
README.md | 129 +++++++++++
12 files changed, 642 insertions(+), 9 deletions(-)
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..31dd685
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,32 @@
+.PHONY: build test clean install
+
+# 构建
+build:
+ CGO_ENABLED=1 go build -tags fts5 -o bin/kb .
+
+# 安装到 ~/go/bin
+install:
+ CGO_ENABLED=1 go install -tags fts5 .
+
+# 测试
+test:
+ CGO_ENABLED=1 go test -tags fts5 ./...
+
+# 详细测试
+test-v:
+ CGO_ENABLED=1 go test -tags fts5 -v ./...
+
+# 清理
+clean:
+ rm -rf bin/
+ go clean
+
+# 运行示例
+run-search:
+ ./bin/kb search 充装 --top 5
+
+run-index:
+ ./bin/kb index build
+
+run-status:
+ ./bin/kb index status
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..2ae844e
--- /dev/null
+++ b/README.md
@@ -0,0 +1,129 @@
+# kb-cli
+
+知识库 CLI 工具,支持 Obsidian 风格 Markdown 文件的图谱搜索。
+
+## 功能特性
+
+- ✅ **Vault 解析器**:解析 Obsidian frontmatter、标签、实体、wikilinks
+- ✅ **知识图谱构建**:文件 → 节点,标签/实体/wikilinks → 边
+- ✅ **SQLite 存储**:高效持久化,支持增量更新
+- ✅ **FTS5 全文搜索**:基于 SQLite FTS5 的快速搜索
+- ✅ **图谱评分算法**:考虑标签、实体、wikilinks 权重
+- ✅ **多种输出格式**:表格、JSON
+
+## 安装
+
+```bash
+# 编译
+make build
+
+# 安装到 ~/go/bin
+make install
+
+# 运行测试
+make test
+```
+
+**注意**:需要 CGO 和 FTS5 支持。
+
+## 使用方法
+
+### 搜索
+
+```bash
+# 基础搜索
+kb search 充装规格
+
+# 带扩展词
+kb search 充装 --expanded 重量,规格
+
+# 带症状词
+kb search 充装失败 --symptom 报错,无法启动
+
+# JSON 输出
+kb search 充装 --json
+
+# 限制结果数
+kb search 充装 --top 5
+```
+
+### 索引管理
+
+```bash
+# 构建/重建索引
+kb index build
+
+# 查看索引状态
+kb index status
+```
+
+### 全局选项
+
+```bash
+--vault 知识库路径(默认:~/aisim/note/001/笔记001)
+--db 索引数据库路径(默认:~/.cache/kb-cli/kb.db)
+```
+
+## 项目结构
+
+```
+kb-cli/
+├── cmd/ # CLI 命令
+│ ├── root.go # 根命令
+│ ├── search.go # search 命令
+│ ├── index.go # index 命令
+│ └── rebuild.go # 索引重建逻辑
+├── internal/
+│ ├── vault/ # Vault 解析器
+│ │ ├── parser.go
+│ │ ├── scanner.go
+│ │ └── sections.go
+│ ├── graph/ # 知识图谱
+│ │ ├── model.go
+│ │ └── builder.go
+│ ├── index/ # SQLite 存储
+│ │ ├── sqlite.go
+│ │ ├── fts.go
+│ │ └── cache.go
+│ ├── search/ # 搜索引擎
+│ │ ├── engine.go
+│ │ └── scorer.go
+│ └── output/ # 输出格式化
+│ └── formatter.go
+└── main.go
+```
+
+## 评分算法
+
+搜索结果评分考虑:
+- **FTS5 rank**:全文搜索相关性
+- **标签匹配**:标签权重 2.0
+- **实体匹配**:实体权重 1.5
+- **Wikilinks**:引用关系权重 1.2
+- **扩展词加成**:提升相关实体权重
+
+## 开发
+
+```bash
+# 运行所有测试
+make test
+
+# 详细测试输出
+make test-v
+
+# 构建并运行
+make build
+./bin/kb search 充装
+```
+
+## 依赖
+
+- Go 1.21+
+- SQLite3(带 FTS5 支持)
+- github.com/mattn/go-sqlite3
+- github.com/spf13/cobra
+- gopkg.in/yaml.v3
+
+## 许可证
+
+MIT
diff --git a/bin/kb b/bin/kb
new file mode 100755
index 0000000..fd5beb1
--- /dev/null
+++ b/bin/kb
Binary files differ
diff --git a/cmd/index.go b/cmd/index.go
new file mode 100644
index 0000000..c5399f6
--- /dev/null
+++ b/cmd/index.go
@@ -0,0 +1,117 @@
+package cmd
+
+import (
+ "fmt"
+ "os"
+
+ "github.com/aisim/kb-cli/internal/index"
+ "github.com/spf13/cobra"
+)
+
+var indexCmd = &cobra.Command{
+ Use: "index",
+ Short: "索引管理",
+ Long: `管理知识库索引`,
+}
+
+var indexBuildCmd = &cobra.Command{
+ Use: "build",
+ Short: "构建/重建索引",
+ Long: `构建或重建知识库索引`,
+ RunE: runIndexBuild,
+}
+
+var indexStatusCmd = &cobra.Command{
+ Use: "status",
+ Short: "查看索引状态",
+ Long: `查看索引状态信息`,
+ RunE: runIndexStatus,
+}
+
+func init() {
+ rootCmd.AddCommand(indexCmd)
+ indexCmd.AddCommand(indexBuildCmd)
+ indexCmd.AddCommand(indexStatusCmd)
+}
+
+func runIndexBuild(cmd *cobra.Command, args []string) error {
+ // 打开索引
+ store, err := index.Open(dbPath)
+ if err != nil {
+ return fmt.Errorf("打开索引失败: %w", err)
+ }
+ defer store.Close()
+
+ // 获取当前 commit
+ commit, err := index.GetGitCommit(vaultPath)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "警告: 无法获取 git commit:", err)
+ commit = ""
+ }
+
+ fmt.Fprintln(os.Stderr, "正在重建索引...")
+ if err := rebuildIndex(store, commit); err != nil {
+ return fmt.Errorf("重建索引失败: %w", err)
+ }
+
+ fmt.Fprintln(os.Stderr, "索引重建完成")
+ return nil
+}
+
+func runIndexStatus(cmd *cobra.Command, args []string) error {
+ // 打开索引
+ store, err := index.Open(dbPath)
+ if err != nil {
+ return fmt.Errorf("打开索引失败: %w", err)
+ }
+ defer store.Close()
+
+ // 获取元信息
+ gitCommit, err := store.GetMeta("git_commit")
+ if err != nil {
+ return fmt.Errorf("获取元信息失败: %w", err)
+ }
+ builtAt, _ := store.GetMeta("built_at")
+
+ // 获取节点数
+ nodeCount, err := store.NodeCount()
+ if err != nil {
+ return fmt.Errorf("获取节点数失败: %w", err)
+ }
+
+ // 获取边数
+ edgeCount, err := store.EdgeCount()
+ if err != nil {
+ return fmt.Errorf("获取边数失败: %w", err)
+ }
+
+ // 获取当前 commit
+ currentCommit, err := index.GetGitCommit(vaultPath)
+ if err != nil {
+ currentCommit = ""
+ }
+
+ // 输出状态
+ fmt.Printf("索引状态:\n")
+ fmt.Printf(" 知识库路径: %s\n", vaultPath)
+ fmt.Printf(" 索引文件: %s\n", dbPath)
+ fmt.Printf(" 节点数: %d\n", nodeCount)
+ fmt.Printf(" 边数: %d\n", edgeCount)
+ fmt.Printf(" 索引 commit: %s\n", gitCommit)
+ fmt.Printf(" 当前 commit: %s\n", currentCommit)
+ fmt.Printf(" 构建时间: %s\n", builtAt)
+
+ // 检查是否需要更新
+ needsRebuild, _, err := index.NeedsRebuild(store, vaultPath)
+ if err != nil {
+ return fmt.Errorf("检查索引状态失败: %w", err)
+ }
+
+ if needsRebuild {
+ fmt.Printf(" 状态: 需要更新\n")
+ } else {
+ fmt.Printf(" 状态: 最新\n")
+ }
+
+ return nil
+}
diff --git a/cmd/rebuild.go b/cmd/rebuild.go
new file mode 100644
index 0000000..4c488cd
--- /dev/null
+++ b/cmd/rebuild.go
@@ -0,0 +1,65 @@
+package cmd
+
+import (
+ "fmt"
+ "os"
+ "time"
+
+ "github.com/aisim/kb-cli/internal/graph"
+ "github.com/aisim/kb-cli/internal/index"
+ "github.com/aisim/kb-cli/internal/vault"
+)
+
+// rebuildIndex 重建索引:扫描 vault → 构建图 → 写入 SQLite
+func rebuildIndex(store *index.Store, commit string) error {
+ // 清空旧数据
+ if err := store.ClearData(); err != nil {
+ return fmt.Errorf("清空数据失败: %w", err)
+ }
+
+ // 扫描 vault
+ files, err := vault.ScanVault(vaultPath)
+ if err != nil {
+ return fmt.Errorf("扫描知识库失败: %w", err)
+ }
+
+ fmt.Fprintf(os.Stderr, "扫描到 %d 个文件\n", len(files))
+
+ // 构建图
+ g := graph.BuildGraph(files)
+
+ // 写入节点
+ for _, n := range g.Nodes {
+ if _, err := store.InsertNode(n); err != nil {
+ return fmt.Errorf("插入节点失败 [%s]: %w", n.Path, err)
+ }
+ }
+
+ // 写入边
+ for _, e := range g.Edges {
+ if err := store.InsertEdge(e); err != nil {
+ return fmt.Errorf("插入边失败: %w", err)
+ }
+ }
+
+ // 创建并填充 FTS5 索引
+ if err := store.CreateFTS(); err != nil {
+ return fmt.Errorf("创建 FTS 索引失败: %w", err)
+ }
+ if err := store.PopulateFTS(); err != nil {
+ return fmt.Errorf("填充 FTS 索引失败: %w", err)
+ }
+
+ // 记录元信息
+ if commit != "" {
+ 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)
+ }
+
+ fmt.Fprintf(os.Stderr, "已索引 %d 个节点, %d 条边\n", len(g.Nodes), len(g.Edges))
+ return nil
+}
diff --git a/cmd/search.go b/cmd/search.go
new file mode 100644
index 0000000..d2787a1
--- /dev/null
+++ b/cmd/search.go
@@ -0,0 +1,87 @@
+package cmd
+
+import (
+ "fmt"
+ "os"
+
+ "github.com/aisim/kb-cli/internal/index"
+ "github.com/aisim/kb-cli/internal/search"
+ "github.com/aisim/kb-cli/internal/output"
+ "github.com/spf13/cobra"
+)
+
+var (
+ expanded []string
+ symptom []string
+ topN int
+ jsonOut bool
+ withContent bool
+ withLinks bool
+)
+
+var searchCmd = &cobra.Command{
+ Use: "search [keywords...]",
+ Short: "搜索知识库",
+ Long: `搜索知识库,支持关键词、扩展词、症状词`,
+ Args: cobra.MinimumNArgs(1),
+ RunE: runSearch,
+}
+
+func init() {
+ rootCmd.AddCommand(searchCmd)
+ searchCmd.Flags().StringSliceVar(&expanded, "expanded", nil, "扩展词(提升相关实体权重)")
+ searchCmd.Flags().StringSliceVar(&symptom, "symptom", nil, "症状词(针对具体症状)")
+ searchCmd.Flags().IntVar(&topN, "top", 10, "返回前 N 条结果")
+ searchCmd.Flags().BoolVar(&jsonOut, "json", false, "JSON 格式输出")
+ searchCmd.Flags().BoolVar(&withContent, "with-content", false, "返回完整文件内容")
+ searchCmd.Flags().BoolVar(&withLinks, "with-links", false, "显示关联文档链接")
+}
+
+func runSearch(cmd *cobra.Command, args []string) error {
+ // 打开索引
+ store, err := index.Open(dbPath)
+ if err != nil {
+ return fmt.Errorf("打开索引失败: %w", err)
+ }
+ defer store.Close()
+
+ // 检查是否需要重建索引
+ needsRebuild, commit, err := index.NeedsRebuild(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)
+ }
+ }
+
+ // 执行搜索
+ opts := search.SearchOptions{
+ Expanded: expanded,
+ Symptom: symptom,
+ TopN: topN,
+ WithContent: withContent,
+ WithLinks: withLinks,
+ }
+
+ results, err := search.Search(store, args, opts)
+ if err != nil {
+ return fmt.Errorf("搜索失败: %w", err)
+ }
+
+ // 输出结果
+ if jsonOut {
+ jsonStr, err := output.FormatJSON(results)
+ if err != nil {
+ return err
+ }
+ fmt.Println(jsonStr)
+ } else {
+ fmt.Print(output.FormatTable(results))
+ }
+
+ return nil
+}
diff --git a/internal/graph/builder.go b/internal/graph/builder.go
index 9b5ca4e..428535b 100644
--- a/internal/graph/builder.go
+++ b/internal/graph/builder.go
@@ -1,6 +1,9 @@
package graph
import (
+ "path/filepath"
+ "strings"
+
"github.com/aisim/kb-cli/internal/vault"
)
@@ -92,5 +95,11 @@
}
// 匹配文件名(不含扩展名和编号前缀)
// 例如:[[充装规格配置]] 匹配 "知识/002-充装规格配置.md"
- return false // 简化版,后续可扩展
+ base := filepath.Base(node.Path)
+ base = strings.TrimSuffix(base, ".md")
+ // 去掉编号前缀(如 "002-")
+ if idx := strings.Index(base, "-"); idx >= 0 {
+ base = base[idx+1:]
+ }
+ return base == link || node.Title == link
}
diff --git a/internal/index/sqlite.go b/internal/index/sqlite.go
index e1086df..6b7839a 100644
--- a/internal/index/sqlite.go
+++ b/internal/index/sqlite.go
@@ -147,3 +147,33 @@
err := s.db.QueryRow("SELECT COUNT(*) FROM edges").Scan(&count)
return count, err
}
+
+// GetNodeLinks 获取节点的关联链接(wikilink 目标)
+func (s *Store) GetNodeLinks(nodeID int64) ([]string, error) {
+ query := `
+ SELECT n.path
+ FROM edges e
+ JOIN nodes n ON n.id = e.to_node
+ WHERE e.from_node = ? AND e.relation = 'wikilink'
+ `
+ rows, err := s.db.Query(query, nodeID)
+ if err != nil {
+ return nil, fmt.Errorf("查询链接失败: %w", err)
+ }
+ defer rows.Close()
+
+ var links []string
+ for rows.Next() {
+ var path string
+ if err := rows.Scan(&path); err != nil {
+ return nil, fmt.Errorf("扫描链接失败: %w", err)
+ }
+ links = append(links, path)
+ }
+
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("遍历链接失败: %w", err)
+ }
+
+ return links, nil
+}
diff --git a/internal/index/sqlite_test.go b/internal/index/sqlite_test.go
index f83b4bb..5ff1d40 100644
--- a/internal/index/sqlite_test.go
+++ b/internal/index/sqlite_test.go
@@ -64,3 +64,70 @@
t.Errorf("node count = %d, want 1", count)
}
}
+
+func TestGetNodeLinks(t *testing.T) {
+ tmpDir := t.TempDir()
+ dbPath := filepath.Join(tmpDir, "test.db")
+
+ store, err := Open(dbPath)
+ if err != nil {
+ t.Fatalf("Open failed: %v", err)
+ }
+ defer store.Close()
+
+ // 插入测试节点
+ node1 := &graph.Node{
+ Path: "FAQ/001-测试.md",
+ Title: "测试文档",
+ Section: "FAQ",
+ Content: "# 测试\n\n内容",
+ }
+ node2 := &graph.Node{
+ Path: "FAQ/002-相关.md",
+ Title: "相关文档",
+ Section: "FAQ",
+ Content: "# 相关\n\n内容",
+ }
+
+ id1, err := store.InsertNode(node1)
+ if err != nil {
+ t.Fatalf("InsertNode node1 failed: %v", err)
+ }
+
+ id2, err := store.InsertNode(node2)
+ if err != nil {
+ t.Fatalf("InsertNode node2 failed: %v", err)
+ }
+
+ // 插入 wikilink 边
+ edge := &graph.Edge{
+ FromNode: id1,
+ ToNode: id2,
+ Relation: "wikilink",
+ Label: "相关文档",
+ }
+ if err := store.InsertEdge(edge); err != nil {
+ t.Fatalf("InsertEdge failed: %v", err)
+ }
+
+ // 测试获取链接
+ links, err := store.GetNodeLinks(id1)
+ if err != nil {
+ t.Fatalf("GetNodeLinks failed: %v", err)
+ }
+ if len(links) != 1 {
+ t.Errorf("links count = %d, want 1", len(links))
+ }
+ if len(links) > 0 && links[0] != "FAQ/002-相关.md" {
+ t.Errorf("links[0] = %q, want %q", links[0], "FAQ/002-相关.md")
+ }
+
+ // 测试无链接的节点
+ links, err = store.GetNodeLinks(id2)
+ if err != nil {
+ t.Fatalf("GetNodeLinks for node2 failed: %v", err)
+ }
+ if len(links) != 0 {
+ t.Errorf("links count = %d, want 0", len(links))
+ }
+}
diff --git a/internal/search/engine.go b/internal/search/engine.go
index 95d7c37..16fa86d 100644
--- a/internal/search/engine.go
+++ b/internal/search/engine.go
@@ -9,18 +9,22 @@
// SearchOptions 搜索选项
type SearchOptions struct {
- Expanded []string // 扩展词
- Symptom []string // 症状词
- TopN int // 返回前 N 条
+ Expanded []string // 扩展词
+ Symptom []string // 症状词
+ TopN int // 返回前 N 条
+ WithContent bool // 返回完整文件内容
+ WithLinks bool // 返回关联文档链接
}
// SearchResult 搜索结果
type SearchResult struct {
- ID int64
- Path string
- Title string
- Section string
- Score int
+ ID int64 `json:"id"`
+ Path string `json:"path"`
+ Title string `json:"title"`
+ Section string `json:"section"`
+ Score int `json:"score"`
+ Content string `json:"content,omitempty"` // 文件内容(WithContent=true 时填充)
+ Links []string `json:"links,omitempty"` // 关联文档路径(WithLinks=true 时填充)
}
// Search 执行搜索
@@ -76,6 +80,26 @@
results = results[:opts.TopN]
}
+ // 获取内容(如果请求)
+ 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
}
diff --git a/kb b/kb
index 6db78e1..aa55456 100755
--- a/kb
+++ b/kb
Binary files differ
diff --git a/test-integration.sh b/test-integration.sh
new file mode 100755
index 0000000..0646d96
--- /dev/null
+++ b/test-integration.sh
@@ -0,0 +1,73 @@
+#!/bin/bash
+# 集成测试脚本
+
+set -e
+
+echo "=== kb-cli 集成测试 ==="
+echo
+
+# 清理旧数据
+echo "1. 清理测试环境..."
+rm -f /tmp/test-kb.db
+export KB_VAULT="/tmp/test-vault"
+rm -rf "$KB_VAULT"
+mkdir -p "$KB_VAULT"
+
+# 创建测试知识库
+echo "2. 创建测试知识库..."
+cat > "$KB_VAULT/001-测试文档.md" << 'EOF'
+---
+tags: [充装, 规格]
+entities: [智能枪, 电子秤]
+---
+
+# 充装规格配置
+
+本配置适用于 [[002-常见问题]] 中提到的场景。
+
+## 充装参数
+
+- YSP-12: 净重 5kg
+- YSP-35: 净重 15kg
+EOF
+
+cat > "$KB_VAULT/002-常见问题.md" << 'EOF'
+---
+tags: [故障, 诊断]
+entities: [充装枪]
+---
+
+# 常见问题
+
+## 充装失败
+
+可能原因:
+1. 智能枪未校准
+2. 电子秤异常
+EOF
+
+# 构建索引
+echo "3. 构建索引..."
+go run -tags fts5 main.go index build --vault "$KB_VAULT" --db /tmp/test-kb.db
+
+# 测试搜索
+echo
+echo "4. 测试搜索功能..."
+echo " 搜索关键词:充装"
+go run -tags fts5 main.go search 充装 --vault "$KB_VAULT" --db /tmp/test-kb.db --top 5
+
+echo
+echo " 搜索关键词:智能枪"
+go run -tags fts5 main.go search 智能枪 --vault "$KB_VAULT" --db /tmp/test-kb.db --top 5
+
+echo
+echo " JSON 格式输出:"
+go run -tags fts5 main.go search 规格 --vault "$KB_VAULT" --db /tmp/test-kb.db --json
+
+# 查看索引状态
+echo
+echo "5. 查看索引状态..."
+go run -tags fts5 main.go index status --vault "$KB_VAULT" --db /tmp/test-kb.db
+
+echo
+echo "=== 集成测试完成 ==="
--
Gitblit v1.9.1