From a7fbbfd6f974631eadebc6effca8ce7982592786 Mon Sep 17 00:00:00 2001
From: ax_rd <ax_rd@aisim.cn>
Date: Thu, 03 Sep 2026 12:51:40 +0800
Subject: [PATCH] fix: explore 段落命中两级回退(原词→展开词)+ expandKeywords 去重 + HardBudget 接入
---
internal/index/sqlite.go | 507 +++++++++++++++++++++++++++++++++++++++++++++++++++++++-
1 files changed, 497 insertions(+), 10 deletions(-)
diff --git a/internal/index/sqlite.go b/internal/index/sqlite.go
index 6b7839a..77cbb56 100644
--- a/internal/index/sqlite.go
+++ b/internal/index/sqlite.go
@@ -6,6 +6,8 @@
"fmt"
"os"
"path/filepath"
+ "sort"
+ "strings"
_ "github.com/mattn/go-sqlite3"
"github.com/aisim/kb-cli/internal/graph"
@@ -13,7 +15,8 @@
// Store SQLite 存储层
type Store struct {
- db *sql.DB
+ db *sql.DB
+ dbPath string
}
// Open 打开或创建数据库
@@ -29,11 +32,15 @@
return nil, fmt.Errorf("打开数据库失败: %w", err)
}
- s := &Store{db: db}
+ s := &Store{db: db, dbPath: dbPath}
if err := s.initTables(); err != nil {
db.Close()
return nil, err
}
+ if err := s.migrate(); err != nil {
+ db.Close()
+ return nil, err
+ }
return s, nil
}
@@ -86,21 +93,24 @@
// ClearData 清空数据(重建前调用)
func (s *Store) ClearData() error {
- _, err := s.db.Exec("DELETE FROM edges; DELETE FROM nodes;")
+ _, err := s.db.Exec("DELETE FROM edges; DELETE FROM nodes; DELETE FROM unresolved_links;")
return err
}
-// InsertNode 插入节点
+// InsertNode 插入节点(全量重建路径用)
func (s *Store) InsertNode(n *graph.Node) (int64, error) {
tagsJSON, _ := json.Marshal(n.Tags)
entitiesJSON, _ := json.Marshal(n.Entities)
wikilinksJSON, _ := json.Marshal(n.Wikilinks)
+ aliasesJSON, _ := json.Marshal(n.Aliases)
result, err := s.db.Exec(`
- INSERT INTO nodes (path, title, section, tags, entities, wikilinks, content_fts)
- VALUES (?, ?, ?, ?, ?, ?, ?)
+ INSERT INTO nodes (path, title, section, tags, entities, wikilinks, aliases, status,
+ content_fts, size, mtime, content_hash)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, n.Path, n.Title, n.Section, string(tagsJSON), string(entitiesJSON),
- string(wikilinksJSON), n.Content)
+ string(wikilinksJSON), string(aliasesJSON), n.Status, n.Content,
+ n.Size, n.Mtime, n.ContentHash)
if err != nil {
return 0, err
}
@@ -110,9 +120,108 @@
// InsertEdge 插入边
func (s *Store) InsertEdge(e *graph.Edge) error {
_, err := s.db.Exec(`
- INSERT OR IGNORE INTO edges (from_node, to_node, relation, label)
- VALUES (?, ?, ?, ?)
- `, e.FromNode, e.ToNode, e.Relation, e.Label)
+ INSERT OR IGNORE INTO edges (from_node, to_node, relation, label, provenance)
+ VALUES (?, ?, ?, ?, ?)
+ `, e.FromNode, e.ToNode, e.Relation, e.Label, e.Provenance)
+ return err
+}
+
+// InsertUnresolved 悬空链接入表(幂等:同 from_node+link_text 不重复插)
+func (s *Store) InsertUnresolved(fromNode int64, linkText, nameTail string) error {
+ _, err := s.db.Exec(`
+ INSERT INTO unresolved_links (from_node, link_text, name_tail, status)
+ SELECT ?, ?, ?, 'pending'
+ WHERE NOT EXISTS (
+ SELECT 1 FROM unresolved_links WHERE from_node = ? AND link_text = ?)`,
+ fromNode, linkText, nameTail, fromNode, linkText)
+ return err
+}
+
+// FileStat 索引中的文件指纹
+type FileStat struct {
+ Path string
+ Size int64
+ Mtime int64
+ ContentHash string
+}
+
+// GetFileStats 返回所有已索引文件的指纹
+func (s *Store) GetFileStats() (map[string]FileStat, error) {
+ rows, err := s.db.Query(`SELECT path, size, mtime, content_hash FROM nodes`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ m := make(map[string]FileStat)
+ for rows.Next() {
+ var st FileStat
+ var hash sql.NullString
+ if err := rows.Scan(&st.Path, &st.Size, &st.Mtime, &hash); err != nil {
+ return nil, err
+ }
+ st.ContentHash = hash.String
+ m[st.Path] = st
+ }
+ return m, rows.Err()
+}
+
+// UpsertNode 按 path 插入或更新节点(触发器自动维护 FTS)
+func (s *Store) UpsertNode(n *graph.Node, size, mtime int64, contentHash string) error {
+ tagsJSON, _ := json.Marshal(n.Tags)
+ entitiesJSON, _ := json.Marshal(n.Entities)
+ wikilinksJSON, _ := json.Marshal(n.Wikilinks)
+ aliasesJSON, _ := json.Marshal(n.Aliases)
+ _, err := s.db.Exec(`
+ INSERT INTO nodes (path, title, section, tags, entities, wikilinks, aliases, status,
+ content_fts, size, mtime, content_hash)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(path) DO UPDATE SET
+ title = excluded.title,
+ section = excluded.section,
+ tags = excluded.tags,
+ entities = excluded.entities,
+ wikilinks = excluded.wikilinks,
+ aliases = excluded.aliases,
+ status = excluded.status,
+ content_fts = excluded.content_fts,
+ size = excluded.size,
+ mtime = excluded.mtime,
+ content_hash = excluded.content_hash,
+ updated_at = datetime('now')`,
+ n.Path, n.Title, n.Section, string(tagsJSON), string(entitiesJSON),
+ string(wikilinksJSON), string(aliasesJSON), n.Status, n.Content, size, mtime, contentHash)
+ return err
+}
+
+// DeleteNode 按 path 删节点(级联删边),同时清该节点相关的 unresolved_links
+func (s *Store) DeleteNode(path string) error {
+ tx, err := s.db.Begin()
+ if err != nil {
+ return err
+ }
+ var id int64
+ if err := tx.QueryRow(`SELECT id FROM nodes WHERE path = ?`, path).Scan(&id); err != nil {
+ tx.Rollback()
+ return nil // 不存在,视为成功
+ }
+ if _, err := tx.Exec(`DELETE FROM edges WHERE from_node = ? OR to_node = ?`, id, id); err != nil {
+ tx.Rollback()
+ return err
+ }
+ if _, err := tx.Exec(`DELETE FROM unresolved_links WHERE from_node = ?`, id); err != nil {
+ tx.Rollback()
+ return err
+ }
+ if _, err := tx.Exec(`DELETE FROM nodes WHERE id = ?`, id); err != nil {
+ tx.Rollback()
+ return err
+ }
+ return tx.Commit()
+}
+
+// DeleteNodeEdges 只删某节点的边(保留节点行,用于"修改"场景重建边)
+func (s *Store) DeleteNodeEdges(nodeID int64) error {
+ _, err := s.db.Exec(`DELETE FROM edges WHERE from_node = ? OR to_node = ?`, nodeID, nodeID)
return err
}
@@ -177,3 +286,381 @@
return links, nil
}
+
+// GetUnresolvedLinks 某文档的悬空链接文本列表
+func (s *Store) GetUnresolvedLinks(path string) ([]string, error) {
+ rows, err := s.db.Query(`
+ SELECT u.link_text FROM unresolved_links u
+ JOIN nodes n ON n.id = u.from_node WHERE n.path = ?`, path)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var links []string
+ for rows.Next() {
+ var l string
+ rows.Scan(&l)
+ links = append(links, l)
+ }
+ return links, rows.Err()
+}
+
+// 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
+}
+
+// GetRelationStats 获取关系类型分布统计
+func (s *Store) GetRelationStats() (map[string]int, error) {
+ rows, err := s.db.Query(`
+ SELECT relation, COUNT(*) as count
+ FROM edges
+ GROUP BY relation
+ ORDER BY count DESC
+ `)
+ if err != nil {
+ return nil, fmt.Errorf("查询关系统计失败: %w", err)
+ }
+ defer rows.Close()
+
+ stats := make(map[string]int)
+ for rows.Next() {
+ var relation string
+ var count int
+ if err := rows.Scan(&relation, &count); err != nil {
+ return nil, fmt.Errorf("扫描关系统计失败: %w", err)
+ }
+ stats[relation] = count
+ }
+
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("遍历关系统计失败: %w", err)
+ }
+
+ return stats, nil
+}
+
+// FindNodesByKeyword 根据关键词查找节点(模糊匹配标题、路径、标签、实体)
+func (s *Store) FindNodesByKeyword(keyword string) ([]*graph.Node, error) {
+ keyword = "%" + keyword + "%"
+ query := `
+ SELECT id, path, title, section, tags, entities, wikilinks, content_fts
+ FROM nodes
+ WHERE title LIKE ? OR path LIKE ? OR tags LIKE ? OR entities LIKE ?
+ LIMIT 20
+ `
+ rows, err := s.db.Query(query, keyword, keyword, keyword, keyword)
+ if err != nil {
+ return nil, fmt.Errorf("查询节点失败: %w", err)
+ }
+ defer rows.Close()
+
+ var nodes []*graph.Node
+ for rows.Next() {
+ var n graph.Node
+ var tagsJSON, entitiesJSON, wikilinksJSON string
+ var content sql.NullString
+
+ err := rows.Scan(&n.ID, &n.Path, &n.Title, &n.Section,
+ &tagsJSON, &entitiesJSON, &wikilinksJSON, &content)
+ if err != nil {
+ return nil, fmt.Errorf("扫描节点失败: %w", err)
+ }
+
+ json.Unmarshal([]byte(tagsJSON), &n.Tags)
+ json.Unmarshal([]byte(entitiesJSON), &n.Entities)
+ json.Unmarshal([]byte(wikilinksJSON), &n.Wikilinks)
+ if content.Valid {
+ n.Content = content.String
+ }
+
+ nodes = append(nodes, &n)
+ }
+
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("遍历节点失败: %w", err)
+ }
+
+ return nodes, nil
+}
+
+// GetNodeEdges 获取节点的关联边(支持关系类型过滤和深度查询)
+func (s *Store) GetNodeEdges(nodeID int64, relation string, depth int) ([]*graph.Edge, error) {
+ if depth < 1 {
+ depth = 1
+ }
+ if depth > 3 {
+ depth = 3
+ }
+
+ var edges []*graph.Edge
+ visited := make(map[int64]bool)
+ currentLevel := []int64{nodeID}
+
+ for d := 0; d < depth; d++ {
+ var nextLevel []int64
+
+ for _, id := range currentLevel {
+ if visited[id] && d > 0 {
+ continue
+ }
+ visited[id] = true
+
+ query := `
+ SELECT id, from_node, to_node, relation, label
+ FROM edges
+ WHERE from_node = ?
+ `
+ args := []interface{}{id}
+
+ if relation != "" {
+ query += " AND relation = ?"
+ args = append(args, relation)
+ }
+
+ rows, err := s.db.Query(query, args...)
+ if err != nil {
+ return nil, fmt.Errorf("查询边失败: %w", err)
+ }
+
+ for rows.Next() {
+ var e graph.Edge
+ var edgeID int64
+ if err := rows.Scan(&edgeID, &e.FromNode, &e.ToNode, &e.Relation, &e.Label); err != nil {
+ rows.Close()
+ return nil, fmt.Errorf("扫描边失败: %w", err)
+ }
+ edges = append(edges, &e)
+ nextLevel = append(nextLevel, e.ToNode)
+ }
+ rows.Close()
+ }
+
+ currentLevel = nextLevel
+ }
+
+ return edges, nil
+}
+
+// FindRelatedNodes 查找与关键词相关的所有节点(通过共同标签、实体、wikilink)
+func (s *Store) FindRelatedNodes(keyword string, topN int) ([]RelatedNode, error) {
+ // 先找到匹配关键词的节点
+ keyword = "%" + keyword + "%"
+ query := `
+ SELECT id, path, title, section, tags, entities
+ FROM nodes
+ WHERE title LIKE ? OR path LIKE ? OR tags LIKE ? OR entities LIKE ?
+ LIMIT 5
+ `
+ rows, err := s.db.Query(query, keyword, keyword, keyword, keyword)
+ if err != nil {
+ return nil, fmt.Errorf("查询节点失败: %w", err)
+ }
+ defer rows.Close()
+
+ var seedNodes []*graph.Node
+ for rows.Next() {
+ var n graph.Node
+ var tagsJSON, entitiesJSON string
+ err := rows.Scan(&n.ID, &n.Path, &n.Title, &n.Section, &tagsJSON, &entitiesJSON)
+ if err != nil {
+ return nil, fmt.Errorf("扫描节点失败: %w", err)
+ }
+ json.Unmarshal([]byte(tagsJSON), &n.Tags)
+ json.Unmarshal([]byte(entitiesJSON), &n.Entities)
+ seedNodes = append(seedNodes, &n)
+ }
+
+ if len(seedNodes) == 0 {
+ return []RelatedNode{}, nil
+ }
+
+ // 收集种子节点的标签和实体
+ tagSet := make(map[string]bool)
+ entitySet := make(map[string]bool)
+ for _, n := range seedNodes {
+ for _, tag := range n.Tags {
+ tagSet[tag] = true
+ }
+ for _, entity := range n.Entities {
+ entitySet[entity] = true
+ }
+ }
+
+ // 查找共享标签或实体的节点
+ relevanceMap := make(map[int64]int)
+ pathMap := make(map[int64]string)
+ titleMap := make(map[int64]string)
+ sectionMap := make(map[int64]string)
+ tagsMap := make(map[int64][]string)
+
+ // 通过标签查找
+ for tag := range tagSet {
+ tagPattern := "%" + tag + "%"
+ rows, err := s.db.Query(`
+ SELECT id, path, title, section, tags
+ FROM nodes
+ WHERE tags LIKE ?
+ `, tagPattern)
+ if err != nil {
+ continue
+ }
+
+ for rows.Next() {
+ var id int64
+ var path, title, section, tagsJSON string
+ if err := rows.Scan(&id, &path, &title, §ion, &tagsJSON); err != nil {
+ rows.Close()
+ continue
+ }
+ relevanceMap[id]++
+ pathMap[id] = path
+ titleMap[id] = title
+ sectionMap[id] = section
+ var tags []string
+ json.Unmarshal([]byte(tagsJSON), &tags)
+ tagsMap[id] = tags
+ }
+ rows.Close()
+ }
+
+ // 通过实体查找
+ for entity := range entitySet {
+ entityPattern := "%" + entity + "%"
+ rows, err := s.db.Query(`
+ SELECT id, path, title, section, tags
+ FROM nodes
+ WHERE entities LIKE ?
+ `, entityPattern)
+ if err != nil {
+ continue
+ }
+
+ for rows.Next() {
+ var id int64
+ var path, title, section, tagsJSON string
+ if err := rows.Scan(&id, &path, &title, §ion, &tagsJSON); err != nil {
+ rows.Close()
+ continue
+ }
+ relevanceMap[id]++
+ pathMap[id] = path
+ titleMap[id] = title
+ sectionMap[id] = section
+ var tags []string
+ json.Unmarshal([]byte(tagsJSON), &tags)
+ tagsMap[id] = tags
+ }
+ rows.Close()
+ }
+
+ // 转换为结果列表
+ var results []RelatedNode
+ for id, relevance := range relevanceMap {
+ results = append(results, RelatedNode{
+ ID: id,
+ Path: pathMap[id],
+ Title: titleMap[id],
+ Section: sectionMap[id],
+ Tags: tagsMap[id],
+ Relevance: relevance,
+ })
+ }
+
+ // 按相关度排序
+ sort.Slice(results, func(i, j int) bool {
+ return results[i].Relevance > results[j].Relevance
+ })
+
+ // 限制返回数量
+ if topN > 0 && len(results) > topN {
+ results = results[:topN]
+ }
+
+ return results, nil
+}
+
+// RelatedNode 相关节点
+type RelatedNode struct {
+ ID int64 `json:"id"`
+ Path string `json:"path"`
+ Title string `json:"title"`
+ Section string `json:"section"`
+ Tags []string `json:"tags"`
+ Relevance int `json:"relevance"`
+}
--
Gitblit v1.10.0