From 8de9d03cad67d6944d40567bff90350d132d60b0 Mon Sep 17 00:00:00 2001
From: ai_xiaopei <xiaopei@aisim.cn>
Date: Sun, 26 Jul 2026 09:54:11 +0800
Subject: [PATCH] feat: add index gc command for cleaning orphan nodes
---
cmd/search.go | 7
cmd/root.go | 58 ++++++++++
bin/kb | 0
cmd/rebuild.go | 27 ++++
cmd/index.go | 68 ++++++++++++-
internal/index/sqlite.go | 81 ++++++++++++++++
6 files changed, 225 insertions(+), 16 deletions(-)
diff --git a/bin/kb b/bin/kb
index a3ab3c4..9b73bcf 100755
--- a/bin/kb
+++ b/bin/kb
Binary files differ
diff --git a/cmd/index.go b/cmd/index.go
index c5399f6..a5f7c77 100644
--- a/cmd/index.go
+++ b/cmd/index.go
@@ -3,6 +3,8 @@
import (
"fmt"
"os"
+ "path/filepath"
+ "strings"
"github.com/aisim/kb-cli/internal/index"
"github.com/spf13/cobra"
@@ -11,27 +13,31 @@
var indexCmd = &cobra.Command{
Use: "index",
Short: "索引管理",
- Long: `管理知识库索引`,
}
var indexBuildCmd = &cobra.Command{
Use: "build",
- Short: "构建/重建索引",
- Long: `构建或重建知识库索引`,
+ Short: "构建或重建知识库索引",
RunE: runIndexBuild,
}
var indexStatusCmd = &cobra.Command{
Use: "status",
- Short: "查看索引状态",
- Long: `查看索引状态信息`,
+ Short: "查看索引状态信息",
RunE: runIndexStatus,
+}
+
+var indexGcCmd = &cobra.Command{
+ Use: "gc",
+ Short: "清理孤立节点和边",
+ RunE: runIndexGc,
}
func init() {
rootCmd.AddCommand(indexCmd)
indexCmd.AddCommand(indexBuildCmd)
indexCmd.AddCommand(indexStatusCmd)
+ indexCmd.AddCommand(indexGcCmd)
}
func runIndexBuild(cmd *cobra.Command, args []string) error {
@@ -115,3 +121,55 @@
return nil
}
+
+func runIndexGc(cmd *cobra.Command, args []string) error {
+ // 展开 ~ 为实际路径
+ expandedVaultPath := vaultPath
+ if strings.HasPrefix(vaultPath, "~/") {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return fmt.Errorf("获取用户目录失败: %w", err)
+ }
+ expandedVaultPath = filepath.Join(home, vaultPath[2:])
+ }
+
+ // 打开索引
+ store, err := index.Open(dbPath)
+ if err != nil {
+ return fmt.Errorf("打开索引失败: %w", err)
+ }
+ defer store.Close()
+
+ fmt.Fprintln(os.Stderr, "正在清理孤立节点和边...")
+
+ // 获取所有节点路径
+ nodes, err := store.GetAllNodes()
+ if err != nil {
+ return fmt.Errorf("获取节点失败: %w", err)
+ }
+
+ // 检查文件是否存在
+ var orphanPaths []string
+ for _, node := range nodes {
+ fullPath := filepath.Join(expandedVaultPath, node.Path)
+ if _, err := os.Stat(fullPath); os.IsNotExist(err) {
+ orphanPaths = append(orphanPaths, node.Path)
+ }
+ }
+
+ if len(orphanPaths) == 0 {
+ fmt.Fprintln(os.Stderr, "没有发现孤立节点")
+ return nil
+ }
+
+ fmt.Fprintf(os.Stderr, "发现 %d 个孤立节点,正在清理...\n", len(orphanPaths))
+
+ // 删除孤立节点
+ deletedCount, err := store.DeleteNodesByPaths(orphanPaths)
+ if err != nil {
+ return fmt.Errorf("删除节点失败: %w", err)
+ }
+
+ fmt.Fprintf(os.Stderr, "已清理 %d 个孤立节点\n", deletedCount)
+ return nil
+}
diff --git a/cmd/rebuild.go b/cmd/rebuild.go
index 4c488cd..841c876 100644
--- a/cmd/rebuild.go
+++ b/cmd/rebuild.go
@@ -28,16 +28,35 @@
// 构建图
g := graph.BuildGraph(files)
- // 写入节点
+ // 写入节点,并记录 BuildGraph ID -> SQLite ID 的映射
+ idMap := make(map[int64]int64) // BuildGraph ID -> SQLite ID
for _, n := range g.Nodes {
- if _, err := store.InsertNode(n); err != nil {
+ sqliteID, err := store.InsertNode(n)
+ if err != nil {
return fmt.Errorf("插入节点失败 [%s]: %w", n.Path, err)
}
+ idMap[n.ID] = sqliteID
}
- // 写入边
+ // 写入边,将 BuildGraph ID 转换为 SQLite ID
for _, e := range g.Edges {
- if err := store.InsertEdge(e); err != nil {
+ fromID, ok := idMap[e.FromNode]
+ if !ok {
+ // 可能是虚拟节点(tag/entity),跳过
+ continue
+ }
+ toID, ok := idMap[e.ToNode]
+ if !ok {
+ // 目标节点不存在,跳过
+ continue
+ }
+ edge := &graph.Edge{
+ FromNode: fromID,
+ ToNode: toID,
+ Relation: e.Relation,
+ Label: e.Label,
+ }
+ if err := store.InsertEdge(edge); err != nil {
return fmt.Errorf("插入边失败: %w", err)
}
}
diff --git a/cmd/root.go b/cmd/root.go
index 0726524..fca90d1 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -3,6 +3,7 @@
import (
"fmt"
"os"
+ "strings"
"github.com/spf13/cobra"
)
@@ -12,16 +13,57 @@
dbPath string
)
+// buildLong 为父命令动态生成 Long,格式:# cmd - Short\n子命令详细用法
+func buildLong(cmd *cobra.Command) string {
+ var sb strings.Builder
+ sb.WriteString(fmt.Sprintf("# %s - %s\n", cmd.Name(), cmd.Short))
+ for _, sub := range cmd.Commands() {
+ if sub.Hidden {
+ continue
+ }
+ if sub.Long != "" {
+ sb.WriteString(sub.Long)
+ // 确保 Long 末尾有换行
+ if !strings.HasSuffix(sub.Long, "\n") {
+ sb.WriteString("\n")
+ }
+ } else {
+ sb.WriteString(fmt.Sprintf("%s # %s\n", sub.CommandPath(), sub.Short))
+ }
+ }
+ return sb.String()
+}
+
var rootCmd = &cobra.Command{
Use: "kb",
- Short: "知识库 CLI 工具",
- Long: "kb-cli: 知识库搜索与管理工具,支持知识图谱搜索",
+ Short: "知识库搜索与管理工具",
+ Long: `kb - 知识库搜索与管理工具
+
+Global: [--vault=<路径>] [--db=<路径>]`,
}
func Execute() {
+ // 为每个父命令动态生成 Long(包含子命令详细用法)
+ for _, cmd := range rootCmd.Commands() {
+ if cmd.HasAvailableSubCommands() && !cmd.Hidden {
+ cmd.Long = buildLong(cmd)
+ }
+ }
+
+ // root Long = 头部 + 所有一级子命令的 Long
+ var sb strings.Builder
+ sb.WriteString(rootCmd.Long)
+ for _, cmd := range rootCmd.Commands() {
+ if cmd.Hidden {
+ continue
+ }
+ sb.WriteString("\n")
+ sb.WriteString(cmd.Long)
+ }
+ rootCmd.Long = sb.String()
+
if err := rootCmd.Execute(); err != nil {
- fmt.Fprintln(os.Stderr, err)
- os.Exit(1)
+ cobra.CheckErr(err)
}
}
@@ -35,4 +77,12 @@
rootCmd.PersistentFlags().StringVar(&vaultPath, "vault", defaultVault, "知识库根目录")
rootCmd.PersistentFlags().StringVar(&dbPath, "db", defaultDB, "索引数据库路径")
+
+ // 自定义 help 模板:只输出 Long
+ helpTemplate := `{{.Long}}`
+ rootCmd.SetUsageTemplate(helpTemplate)
+ rootCmd.SetHelpTemplate(helpTemplate)
+
+ rootCmd.SilenceUsage = true
+ rootCmd.SilenceErrors = true
}
diff --git a/cmd/search.go b/cmd/search.go
index d2787a1..29bc07d 100644
--- a/cmd/search.go
+++ b/cmd/search.go
@@ -22,9 +22,10 @@
var searchCmd = &cobra.Command{
Use: "search [keywords...]",
Short: "搜索知识库",
- Long: `搜索知识库,支持关键词、扩展词、症状词`,
- Args: cobra.MinimumNArgs(1),
- RunE: runSearch,
+ Long: `# search - 搜索
+kb search <关键词> [--top N] [--expanded <词>] [--symptom <词>] [--with-content] [--with-links] [--json] # 搜索知识库,支持关键词、扩展词、症状词`,
+ Args: cobra.MinimumNArgs(1),
+ RunE: runSearch,
}
func init() {
diff --git a/internal/index/sqlite.go b/internal/index/sqlite.go
index 6b7839a..db194fe 100644
--- a/internal/index/sqlite.go
+++ b/internal/index/sqlite.go
@@ -6,6 +6,7 @@
"fmt"
"os"
"path/filepath"
+ "strings"
_ "github.com/mattn/go-sqlite3"
"github.com/aisim/kb-cli/internal/graph"
@@ -177,3 +178,83 @@
return links, nil
}
+
+// NodeInfo 节点基本信息(用于 GC)
+type NodeInfo struct {
+ ID int64
+ Path string
+}
+
+// GetAllNodes 获取所有节点(用于 GC 检查)
+func (s *Store) GetAllNodes() ([]NodeInfo, error) {
+ rows, err := s.db.Query("SELECT id, path FROM nodes")
+ if err != nil {
+ return nil, fmt.Errorf("查询节点失败: %w", err)
+ }
+ defer rows.Close()
+
+ var nodes []NodeInfo
+ for rows.Next() {
+ var n NodeInfo
+ if err := rows.Scan(&n.ID, &n.Path); err != nil {
+ return nil, fmt.Errorf("扫描节点失败: %w", err)
+ }
+ nodes = append(nodes, n)
+ }
+
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("遍历节点失败: %w", err)
+ }
+
+ return nodes, nil
+}
+
+// DeleteNodesByPaths 删除指定路径的节点及其关联边
+func (s *Store) DeleteNodesByPaths(paths []string) (int, error) {
+ if len(paths) == 0 {
+ return 0, nil
+ }
+
+ // 构建 IN 子句
+ placeholders := make([]string, len(paths))
+ args := make([]interface{}, len(paths))
+ for i, path := range paths {
+ placeholders[i] = "?"
+ args[i] = path
+ }
+
+ tx, err := s.db.Begin()
+ if err != nil {
+ return 0, fmt.Errorf("开始事务失败: %w", err)
+ }
+
+ // 先删除关联边
+ query := fmt.Sprintf(`
+ DELETE FROM edges
+ WHERE from_node IN (SELECT id FROM nodes WHERE path IN (%s))
+ OR to_node IN (SELECT id FROM nodes WHERE path IN (%s))
+ `, strings.Join(placeholders, ","), strings.Join(placeholders, ","))
+
+ // 参数需要重复两次
+ allArgs := append(args, args...)
+ _, err = tx.Exec(query, allArgs...)
+ if err != nil {
+ tx.Rollback()
+ return 0, fmt.Errorf("删除边失败: %w", err)
+ }
+
+ // 删除节点
+ query = fmt.Sprintf("DELETE FROM nodes WHERE path IN (%s)", strings.Join(placeholders, ","))
+ result, err := tx.Exec(query, args...)
+ if err != nil {
+ tx.Rollback()
+ return 0, fmt.Errorf("删除节点失败: %w", err)
+ }
+
+ if err := tx.Commit(); err != nil {
+ return 0, fmt.Errorf("提交事务失败: %w", err)
+ }
+
+ affected, _ := result.RowsAffected()
+ return int(affected), nil
+}
--
Gitblit v1.9.1