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/migrations.go |  134 ++++++++++++++++++++++++++++++++++++++++++++
 1 files changed, 134 insertions(+), 0 deletions(-)

diff --git a/internal/index/migrations.go b/internal/index/migrations.go
new file mode 100644
index 0000000..85dce28
--- /dev/null
+++ b/internal/index/migrations.go
@@ -0,0 +1,134 @@
+package index
+
+import (
+	"fmt"
+	"os"
+)
+
+const currentSchemaVersion = 2
+
+// migrate 幂等的 schema 升级:v1 → v2。
+// v2 变更:nodes 加 aliases/status/size/mtime/content_hash 列;
+// edges 加 provenance 列;新建 unresolved_links、schema_versions 表;
+// nodes_fts 改 external-content + 触发器。迁移前自动备份 .db 文件。
+func (s *Store) migrate() error {
+	// 读当前版本
+	var version int
+	err := s.db.QueryRow(`SELECT value FROM schema_versions WHERE key='version'`).Scan(&version)
+	if err != nil && version == 0 {
+		// 首次:schema_versions 表可能不存在
+		version = 1
+	}
+	if version >= currentSchemaVersion {
+		return nil
+	}
+
+	// 备份
+	if err := s.backupDB(); err != nil {
+		return fmt.Errorf("备份失败: %w", err)
+	}
+
+	// v1 → v2
+	if err := s.migrateV1toV2(); err != nil {
+		return fmt.Errorf("v1→v2 迁移失败: %w", err)
+	}
+	return nil
+}
+
+// backupDB 迁移前备份 .db 文件(已有备份则跳过)。
+// 用 VACUUM INTO 而非 os.ReadFile:WAL 模式下未 checkpoint 的数据在
+// -wal 文件里,直读主文件会漏掉这部分,导致备份不一致;VACUUM INTO
+// 会合并 WAL 生成一份完整的独立库文件(SQLite 3.27+)。
+func (s *Store) backupDB() error {
+	if s.dbPath == "" {
+		return nil // 无路径信息时跳过备份(测试场景)
+	}
+	dst := s.dbPath + ".bak"
+	if _, err := os.Stat(dst); err == nil {
+		return nil // 已有备份
+	}
+	_, err := s.db.Exec(`VACUUM INTO ?`, dst)
+	return err
+}
+
+// migrateV1toV2 执行 v1 → v2 的全部变更。
+// 注意:版本号(schema_versions)必须在最后一步(FTS 重建)成功后才写入,
+// 否则中途失败时后续 Open 会读到 version=2 直接早退,半迁移状态无法重试。
+func (s *Store) migrateV1toV2() error {
+	// 1. schema_versions 表(只建表,版本号推迟到最后写入)
+	if _, err := s.db.Exec(`CREATE TABLE IF NOT EXISTS schema_versions (
+		key TEXT PRIMARY KEY, value TEXT NOT NULL)`); err != nil {
+		return err
+	}
+
+	// 2. nodes 加列(逐列检查,幂等)
+	for _, col := range []struct{ name, def string }{
+		{"aliases", "ALTER TABLE nodes ADD COLUMN aliases TEXT"},
+		{"status", "ALTER TABLE nodes ADD COLUMN status TEXT"},
+		{"size", "ALTER TABLE nodes ADD COLUMN size INTEGER"},
+		{"mtime", "ALTER TABLE nodes ADD COLUMN mtime INTEGER"},
+		{"content_hash", "ALTER TABLE nodes ADD COLUMN content_hash TEXT"},
+	} {
+		if !s.nodeColumnExists(col.name) {
+			if _, err := s.db.Exec(col.def); err != nil {
+				return fmt.Errorf("加列 %s 失败: %w", col.name, err)
+			}
+		}
+	}
+
+	// 3. edges 加 provenance 列
+	if !s.edgeColumnExists("provenance") {
+		if _, err := s.db.Exec(`ALTER TABLE edges ADD COLUMN provenance TEXT`); err != nil {
+			return fmt.Errorf("edges 加 provenance 失败: %w", err)
+		}
+	}
+
+	// 4. unresolved_links 表
+	if _, err := s.db.Exec(`CREATE TABLE IF NOT EXISTS unresolved_links (
+		id INTEGER PRIMARY KEY AUTOINCREMENT,
+		from_node INTEGER NOT NULL,
+		link_text TEXT NOT NULL,
+		name_tail TEXT NOT NULL,
+		status TEXT NOT NULL DEFAULT 'pending',
+		created_at TEXT DEFAULT (datetime('now')),
+		last_attempt TEXT)`); err != nil {
+		return err
+	}
+	if _, err := s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_unresolved_tail
+		ON unresolved_links(name_tail) WHERE status='failed'`); err != nil {
+		return err
+	}
+
+	// 5. FTS 改 external-content:删旧表重建 + 触发器 + 重灌
+	if _, err := s.db.Exec(`DROP TABLE IF EXISTS nodes_fts`); err != nil {
+		return err
+	}
+	if err := s.CreateFTS(); err != nil {
+		return err
+	}
+	if err := s.PopulateFTS(); err != nil {
+		return err
+	}
+
+	// 6. 最后一步:全部成功后才写入版本号(参数化,引用常量 currentSchemaVersion)。
+	// 提前写入会导致中途失败时后续 Open 读到 version=2 直接早退,半迁移状态无法重试。
+	if _, err := s.db.Exec(`INSERT OR REPLACE INTO schema_versions VALUES ('version', ?)`,
+		currentSchemaVersion); err != nil {
+		return err
+	}
+	return nil
+}
+
+// nodeColumnExists 检查 nodes 表是否已有指定列
+func (s *Store) nodeColumnExists(name string) bool {
+	var n int
+	s.db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('nodes') WHERE name=?`, name).Scan(&n)
+	return n > 0
+}
+
+// edgeColumnExists 检查 edges 表是否已有指定列
+func (s *Store) edgeColumnExists(name string) bool {
+	var n int
+	s.db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('edges') WHERE name=?`, name).Scan(&n)
+	return n > 0
+}

--
Gitblit v1.10.0