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