ai_xiaopei
9 days ago 8de9d03cad67d6944d40567bff90350d132d60b0
feat: add index gc command for cleaning orphan nodes

- Add GetAllNodes() and DeleteNodesByPaths() to sqlite.go
- Implement runIndexGc() with path expansion for ~
- Update help format to match axin-cli style
- Fix duplicate init() functions in root.go
6 files modified
241 ■■■■■ changed files
bin/kb patch | view | raw | blame | history
cmd/index.go 68 ●●●●● patch | view | raw | blame | history
cmd/rebuild.go 27 ●●●● patch | view | raw | blame | history
cmd/root.go 58 ●●●●● patch | view | raw | blame | history
cmd/search.go 7 ●●●●● patch | view | raw | blame | history
internal/index/sqlite.go 81 ●●●●● patch | view | raw | blame | history
bin/kb
Binary files differ
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
}
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)
        }
    }
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
}
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() {
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
}