package index import ( "database/sql" "encoding/json" "sort" ) // FTSResult 全文搜索结果 type FTSResult struct { ID int64 Path string Title string Section string Rank float64 Aliases []string // 别名(JSON 解析) Status string // 状态(草稿/待确认/跟进中 等参与降权) } // 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_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(external-content 模式专用语法) func (s *Store) PopulateFTS() error { _, err := s.db.Exec(`INSERT INTO nodes_fts(nodes_fts) VALUES('rebuild')`) return err } // FTSSearch 全文搜索 func (s *Store) FTSSearch(keywords []string, limit int) ([]FTSResult, error) { if len(keywords) == 0 { return nil, nil } // 对每个关键词单独搜索,然后合并结果(去重) // 这样即使某个关键词匹配不到,其他关键词也能找到结果 seen := make(map[int64]bool) var allResults []FTSResult 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 } 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 } // GetNodeContent 获取节点内容 func (s *Store) GetNodeContent(id int64) (string, []string, []string, error) { var content, tagsJSON, entitiesJSON string err := s.db.QueryRow("SELECT content_fts, tags, entities FROM nodes WHERE id = ?", id). Scan(&content, &tagsJSON, &entitiesJSON) if err == sql.ErrNoRows { return "", nil, nil, nil } if err != nil { return "", nil, nil, err } // 解析 JSON 数组 var tags, entities []string if tagsJSON != "" { if err := json.Unmarshal([]byte(tagsJSON), &tags); err != nil { tags = nil } } if entitiesJSON != "" { if err := json.Unmarshal([]byte(entitiesJSON), &entities); err != nil { entities = nil } } return content, tags, entities, nil }