Task 4: add CLI flags for with-content and with-links
5 files modified
7 files added
| New file |
| | |
| | | .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 |
| New file |
| | |
| | | # 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 |
| New file |
| | |
| | | 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 |
| | | } |
| New file |
| | |
| | | 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 |
| | | } |
| New file |
| | |
| | | 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 |
| | | } |
| | |
| | | package graph |
| | | |
| | | import ( |
| | | "path/filepath" |
| | | "strings" |
| | | |
| | | "github.com/aisim/kb-cli/internal/vault" |
| | | ) |
| | | |
| | |
| | | } |
| | | // 匹配文件名(不含扩展名和编号前缀) |
| | | // 例如:[[充装规格配置]] 匹配 "知识/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 |
| | | } |
| | |
| | | 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 |
| | | } |
| | |
| | | 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)) |
| | | } |
| | | } |
| | |
| | | |
| | | // 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 执行搜索 |
| | |
| | | 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 |
| | | } |
| | | |
| New file |
| | |
| | | #!/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 "=== 集成测试完成 ===" |