From 60eb89c12ee0661785395bff90940204b16dcb3a Mon Sep 17 00:00:00 2001
From: ax_rd <ax_rd@aisim.cn>
Date: Thu, 03 Sep 2026 13:21:39 +0800
Subject: [PATCH] docs: e2e 验证脚本 + README 更新(增量同步/explore/构建要求)
---
internal/index/fts.go | 213 +++++++++++++++++++++++++++++++++++++++++++---------
1 files changed, 175 insertions(+), 38 deletions(-)
diff --git a/internal/index/fts.go b/internal/index/fts.go
index 5e67985..552b428 100644
--- a/internal/index/fts.go
+++ b/internal/index/fts.go
@@ -3,9 +3,8 @@
import (
"database/sql"
"encoding/json"
- "strings"
+ "sort"
)
-
// FTSResult 全文搜索结果
type FTSResult struct {
ID int64
@@ -13,29 +12,50 @@
Title string
Section string
Rank float64
+ Aliases []string // 别名(JSON 解析)
+ Status string // 状态(草稿/待确认/跟进中 等参与降权)
}
-// CreateFTS 创建 FTS5 虚拟表
+// CreateFTS 创建 FTS5 虚拟表(external-content 模式,由触发器增量维护)
+// 注意:external-content 模式下 FTS 列名必须与 content 表列名一致,
+// 故 content 列对应 nodes 表的 content_fts 列
func (s *Store) CreateFTS() error {
_, err := s.db.Exec(`
CREATE VIRTUAL TABLE IF NOT EXISTS nodes_fts USING fts5(
- title,
- content,
- tags,
- entities,
- content='nodes',
- content_rowid='id'
- )
- `)
- return err
+ title, content_fts, tags, aliases,
+ content='nodes', content_rowid='id'
+ )`)
+ if err != nil {
+ return err
+ }
+ // 触发器:nodes 的增删改同步维护 FTS
+ triggers := []string{
+ `CREATE TRIGGER IF NOT EXISTS nodes_fts_ai AFTER INSERT ON nodes BEGIN
+ INSERT INTO nodes_fts(rowid, title, content_fts, tags, aliases)
+ VALUES (new.id, new.title, new.content_fts, new.tags, new.aliases);
+ END`,
+ `CREATE TRIGGER IF NOT EXISTS nodes_fts_ad AFTER DELETE ON nodes BEGIN
+ INSERT INTO nodes_fts(nodes_fts, rowid, title, content_fts, tags, aliases)
+ VALUES ('delete', old.id, old.title, old.content_fts, old.tags, old.aliases);
+ END`,
+ `CREATE TRIGGER IF NOT EXISTS nodes_fts_au AFTER UPDATE ON nodes BEGIN
+ INSERT INTO nodes_fts(nodes_fts, rowid, title, content_fts, tags, aliases)
+ VALUES ('delete', old.id, old.title, old.content_fts, old.tags, old.aliases);
+ INSERT INTO nodes_fts(rowid, title, content_fts, tags, aliases)
+ VALUES (new.id, new.title, new.content_fts, new.tags, new.aliases);
+ END`,
+ }
+ for _, t := range triggers {
+ if _, err := s.db.Exec(t); err != nil {
+ return err
+ }
+ }
+ return nil
}
-// PopulateFTS 填充 FTS 索引
+// PopulateFTS 全量重灌 FTS(external-content 模式专用语法)
func (s *Store) PopulateFTS() error {
- _, err := s.db.Exec(`
- INSERT INTO nodes_fts(rowid, title, content, tags, entities)
- SELECT id, title, content_fts, tags, entities FROM nodes
- `)
+ _, err := s.db.Exec(`INSERT INTO nodes_fts(nodes_fts) VALUES('rebuild')`)
return err
}
@@ -45,29 +65,146 @@
return nil, nil
}
- // 构建 FTS5 查询
- query := strings.Join(keywords, " OR ")
+ // 对每个关键词单独搜索,然后合并结果(去重)
+ // 这样即使某个关键词匹配不到,其他关键词也能找到结果
+ seen := make(map[int64]bool)
+ var allResults []FTSResult
- rows, err := s.db.Query(`
- SELECT n.id, n.path, n.title, n.section, fts.rank
- FROM nodes_fts fts
- JOIN nodes n ON n.id = fts.rowid
- WHERE nodes_fts MATCH ?
- ORDER BY fts.rank
- LIMIT ?
- `, query, limit)
- if err != nil {
- return nil, err
- }
- defer rows.Close()
-
- var results []FTSResult
- for rows.Next() {
- var r FTSResult
- if err := rows.Scan(&r.ID, &r.Path, &r.Title, &r.Section, &r.Rank); err != nil {
- return nil, err
+ for _, kw := range keywords {
+ rows, err := s.db.Query(`
+ SELECT n.id, n.path, n.title, n.section, fts.rank, n.aliases, n.status
+ FROM nodes_fts fts
+ JOIN nodes n ON n.id = fts.rowid
+ WHERE nodes_fts MATCH ?
+ ORDER BY fts.rank
+ LIMIT ?
+ `, kw, limit)
+ if err != nil {
+ // 单个关键词搜索失败,跳过继续
+ continue
}
- results = append(results, r)
+
+ for rows.Next() {
+ var r FTSResult
+ var aliasesJSON string
+ if err := rows.Scan(&r.ID, &r.Path, &r.Title, &r.Section, &r.Rank, &aliasesJSON, &r.Status); err != nil {
+ rows.Close()
+ continue
+ }
+ if aliasesJSON != "" {
+ _ = json.Unmarshal([]byte(aliasesJSON), &r.Aliases)
+ }
+ // 去重
+ if !seen[r.ID] {
+ seen[r.ID] = true
+ allResults = append(allResults, r)
+ }
+ }
+ rows.Close()
+ }
+
+ // 按 rank 排序(FTS5 的 rank 越小越好)
+ sort.Slice(allResults, func(i, j int) bool {
+ return allResults[i].Rank < allResults[j].Rank
+ })
+
+ // 限制返回数量
+ if limit > 0 && len(allResults) > limit {
+ allResults = allResults[:limit]
+ }
+
+ return allResults, nil
+}
+
+// containsCJK 是否含 CJK 统一表意文字
+func containsCJK(s string) bool {
+ for _, r := range s {
+ if r >= 0x4E00 && r <= 0x9FFF {
+ return true
+ }
+ }
+ return false
+}
+
+// KeywordSearch 双通道关键词检索:ASCII 词走 FTS5 MATCH,CJK 词走 LIKE(title/aliases/content/tags)。
+// FTS5 unicode61 把连续中文当整串单 token,多字符 CJK 词 MATCH 匹配不到,必须走 LIKE。
+// 结果按"首命中顺序"排列(FTS 通道按 rank,LIKE 通道补在尾部),engine 再按双信号重排。
+func (s *Store) KeywordSearch(keywords []string, limit int) ([]FTSResult, error) {
+ if len(keywords) == 0 {
+ return nil, nil
+ }
+ seen := make(map[int64]*FTSResult)
+ var order []int64
+
+ // 通道 1: FTS(ASCII 词)
+ var ascii []string
+ for _, kw := range keywords {
+ if !containsCJK(kw) {
+ ascii = append(ascii, kw)
+ }
+ }
+ for _, kw := range ascii {
+ rows, err := s.db.Query(`
+ SELECT n.id, n.path, n.title, n.section, fts.rank, n.aliases, n.status
+ FROM nodes_fts fts JOIN nodes n ON n.id = fts.rowid
+ WHERE nodes_fts MATCH ? ORDER BY fts.rank LIMIT ?`, kw, limit)
+ if err != nil {
+ continue // 语法异常词跳过
+ }
+ for rows.Next() {
+ var r FTSResult
+ var aliasesJSON string
+ if err := rows.Scan(&r.ID, &r.Path, &r.Title, &r.Section, &r.Rank, &aliasesJSON, &r.Status); err != nil {
+ continue
+ }
+ if aliasesJSON != "" {
+ _ = json.Unmarshal([]byte(aliasesJSON), &r.Aliases)
+ }
+ if _, ok := seen[r.ID]; !ok {
+ seen[r.ID] = &r
+ order = append(order, r.ID)
+ }
+ }
+ rows.Close()
+ }
+
+ // 通道 2: LIKE(CJK 词)
+ for _, kw := range keywords {
+ if !containsCJK(kw) {
+ continue
+ }
+ pat := "%" + kw + "%"
+ rows, err := s.db.Query(`
+ SELECT id, path, title, section, aliases, status FROM nodes
+ WHERE title LIKE ? OR aliases LIKE ? OR content_fts LIKE ? OR tags LIKE ?`,
+ pat, pat, pat, pat)
+ if err != nil {
+ continue
+ }
+ for rows.Next() {
+ var r FTSResult
+ var aliasesJSON string
+ if err := rows.Scan(&r.ID, &r.Path, &r.Title, &r.Section, &aliasesJSON, &r.Status); err != nil {
+ continue
+ }
+ if aliasesJSON != "" {
+ _ = json.Unmarshal([]byte(aliasesJSON), &r.Aliases)
+ }
+ r.Rank = 0 // LIKE 无 rank
+ if _, ok := seen[r.ID]; !ok {
+ seen[r.ID] = &r
+ order = append(order, r.ID)
+ }
+ }
+ rows.Close()
+ }
+
+ results := make([]FTSResult, 0, len(order))
+ for _, id := range order {
+ results = append(results, *seen[id])
+ }
+ if limit > 0 && len(results) > limit {
+ results = results[:limit]
}
return results, nil
}
--
Gitblit v1.10.0