From 5a2be87f658d01bc5b3b7ecdba92ac69bf09dcf7 Mon Sep 17 00:00:00 2001
From: ai_xiaopei <xiaopei@aisim.cn>
Date: Thu, 03 Sep 2026 15:15:37 +0800
Subject: [PATCH] fix(search): 搜索准确性四连修——FTS5 MATCH 引号包裹(连字符不再被解析成 MINUS);KeywordSearch 截断按命中关键词数排序(高频 bigram 不再挤出强相关文档);原词奖励档(完整短语压过泛化 bigram);tags 字段真实参与评分(此前误用 Section)
---
internal/index/sqlite.go | 187 +++++++++++++++++++++++++++++++++++++---------
1 files changed, 149 insertions(+), 38 deletions(-)
diff --git a/internal/index/sqlite.go b/internal/index/sqlite.go
index bf2477a..043d483 100644
--- a/internal/index/sqlite.go
+++ b/internal/index/sqlite.go
@@ -15,7 +15,8 @@
// Store SQLite 存储层
type Store struct {
- db *sql.DB
+ db *sql.DB
+ dbPath string
}
// Open 打开或创建数据库
@@ -31,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
}
@@ -55,7 +60,6 @@
CREATE TABLE IF NOT EXISTS nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
path TEXT NOT NULL UNIQUE,
- node_type TEXT NOT NULL DEFAULT 'file',
title TEXT,
section TEXT,
tags TEXT,
@@ -84,55 +88,146 @@
if err != nil {
return fmt.Errorf("创建表失败: %w", err)
}
-
- // 旧库迁移:补 node_type 列(幂等)
- var colCount int
- if err := s.db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('nodes') WHERE name = 'node_type'").Scan(&colCount); err == nil && colCount == 0 {
- if _, err := s.db.Exec("ALTER TABLE nodes ADD COLUMN node_type TEXT NOT NULL DEFAULT 'file'"); err != nil {
- return fmt.Errorf("迁移 node_type 列失败: %w", err)
- }
- }
-
return nil
}
// 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, node_type, title, section, tags, entities, wikilinks, content_fts)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
- `, n.Path, nodeTypeOf(n), n.Title, n.Section, string(tagsJSON), string(entitiesJSON),
- string(wikilinksJSON), n.Content)
+ 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), string(aliasesJSON), n.Status, n.Content,
+ n.Size, n.Mtime, n.ContentHash)
if err != nil {
return 0, err
}
return result.LastInsertId()
}
-// nodeTypeOf 节点类型(空值归一为 file)
-func nodeTypeOf(n *graph.Node) string {
- if n.NodeType == "" {
- return "file"
- }
- return n.NodeType
-}
-
// 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
+ var size, mtime sql.NullInt64
+ // 旧库迁移到 v2 后,老行的 size/mtime/content_hash 为 NULL(ALTER ADD COLUMN 不带值)。
+ // 用 Null 类型容忍 NULL:指纹缺失的行在对账时 size/mtime 必然不等,会走
+ // sha256 二次确认并重新写入指纹,实现自愈,不会误判为"未变更"。
+ if err := rows.Scan(&st.Path, &size, &mtime, &hash); err != nil {
+ return nil, err
+ }
+ st.Size = size.Int64
+ st.Mtime = mtime.Int64
+ 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
}
@@ -198,6 +293,24 @@
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
@@ -206,7 +319,7 @@
// GetAllNodes 获取所有节点(用于 GC 检查)
func (s *Store) GetAllNodes() ([]NodeInfo, error) {
- rows, err := s.db.Query("SELECT id, path FROM nodes WHERE node_type = 'file'")
+ rows, err := s.db.Query("SELECT id, path FROM nodes")
if err != nil {
return nil, fmt.Errorf("查询节点失败: %w", err)
}
@@ -314,8 +427,7 @@
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 ?)
- AND node_type = 'file'
+ WHERE title LIKE ? OR path LIKE ? OR tags LIKE ? OR entities LIKE ?
LIMIT 20
`
rows, err := s.db.Query(query, keyword, keyword, keyword, keyword)
@@ -376,7 +488,7 @@
visited[id] = true
query := `
- SELECT id, from_node, to_node, relation, label
+ SELECT id, from_node, to_node, relation, label, provenance
FROM edges
WHERE from_node = ?
`
@@ -395,7 +507,7 @@
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 {
+ if err := rows.Scan(&edgeID, &e.FromNode, &e.ToNode, &e.Relation, &e.Label, &e.Provenance); err != nil {
rows.Close()
return nil, fmt.Errorf("扫描边失败: %w", err)
}
@@ -418,8 +530,7 @@
query := `
SELECT id, path, title, section, tags, entities
FROM nodes
- WHERE (title LIKE ? OR path LIKE ? OR tags LIKE ? OR entities LIKE ?)
- AND node_type = 'file'
+ WHERE title LIKE ? OR path LIKE ? OR tags LIKE ? OR entities LIKE ?
LIMIT 5
`
rows, err := s.db.Query(query, keyword, keyword, keyword, keyword)
@@ -470,7 +581,7 @@
rows, err := s.db.Query(`
SELECT id, path, title, section, tags
FROM nodes
- WHERE tags LIKE ? AND node_type = 'file'
+ WHERE tags LIKE ?
`, tagPattern)
if err != nil {
continue
@@ -500,7 +611,7 @@
rows, err := s.db.Query(`
SELECT id, path, title, section, tags
FROM nodes
- WHERE entities LIKE ? AND node_type = 'file'
+ WHERE entities LIKE ?
`, entityPattern)
if err != nil {
continue
--
Gitblit v1.10.0