From 86684d665e2f2f9bb865004e8995a5c110ddcbc2 Mon Sep 17 00:00:00 2001
From: ax_rd <ax_rd@aisim.cn>
Date: Thu, 03 Sep 2026 11:56:04 +0800
Subject: [PATCH] feat: RWR 图结构排序 + CJK LIKE 混合检索 + 双信号加权 + aliases/status 参与排序

---
 internal/index/sqlite.go |  489 +++++++++++++++++++++++++++++++++++++++++++++++++++++-
 1 files changed, 479 insertions(+), 10 deletions(-)

diff --git a/internal/index/sqlite.go b/internal/index/sqlite.go
index 6b7839a..ea306fb 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,363 @@
 
 	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
+}
+
+// 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, &section, &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, &section, &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