| | |
| | | "database/sql" |
| | | "encoding/json" |
| | | "sort" |
| | | "strings" |
| | | ) |
| | | // FTSResult 全文搜索结果 |
| | | type FTSResult struct { |
| | |
| | | Title string |
| | | Section string |
| | | Rank float64 |
| | | Aliases []string // 别名(JSON 解析) |
| | | Tags []string // 标签(JSON 解析) |
| | | Status string // 状态(草稿/待确认/跟进中 等参与降权) |
| | | } |
| | | |
| | | // CreateFTS 创建 FTS5 虚拟表(external-content 模式,由触发器增量维护) |
| | |
| | | |
| | | for _, kw := range keywords { |
| | | rows, err := s.db.Query(` |
| | | SELECT n.id, n.path, n.title, n.section, fts.rank |
| | | 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 ? |
| | |
| | | |
| | | for rows.Next() { |
| | | var r FTSResult |
| | | if err := rows.Scan(&r.ID, &r.Path, &r.Title, &r.Section, &r.Rank); err != nil { |
| | | 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 |
| | |
| | | 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) |
| | | hitCount := make(map[int64]int) // 每个节点被多少个关键词命中(截断排序用) |
| | | firstIdx := make(map[int64]int) // 首命中位置(截断平手时的次序依据) |
| | | var order []int64 |
| | | addCandidate := func(r *FTSResult) { |
| | | if _, ok := seen[r.ID]; !ok { |
| | | seen[r.ID] = r |
| | | firstIdx[r.ID] = len(order) |
| | | order = append(order, r.ID) |
| | | } |
| | | hitCount[r.ID]++ |
| | | } |
| | | |
| | | // 通道 1: FTS(ASCII 词) |
| | | var ascii []string |
| | | for _, kw := range keywords { |
| | | if !containsCJK(kw) { |
| | | ascii = append(ascii, kw) |
| | | } |
| | | } |
| | | for _, kw := range ascii { |
| | | // FTS5 查询语法里 "-" 是 MINUS 运算符、"(" 等是语法符, |
| | | // 不引号包裹会报 "no such column" 被静默跳过(如 tag "fill-curve" 搜不到)。 |
| | | // 用双引号包裹成字符串字面量,内部双引号转义。 |
| | | quoted := `"` + strings.ReplaceAll(kw, `"`, `""`) + `"` |
| | | rows, err := s.db.Query(` |
| | | SELECT n.id, n.path, n.title, n.section, fts.rank, n.aliases, n.tags, n.status |
| | | FROM nodes_fts fts JOIN nodes n ON n.id = fts.rowid |
| | | WHERE nodes_fts MATCH ? ORDER BY fts.rank LIMIT ?`, quoted, limit) |
| | | if err != nil { |
| | | continue // 语法异常词跳过 |
| | | } |
| | | for rows.Next() { |
| | | var r FTSResult |
| | | var aliasesJSON, tagsJSON string |
| | | if err := rows.Scan(&r.ID, &r.Path, &r.Title, &r.Section, &r.Rank, &aliasesJSON, &tagsJSON, &r.Status); err != nil { |
| | | continue |
| | | } |
| | | if aliasesJSON != "" { |
| | | _ = json.Unmarshal([]byte(aliasesJSON), &r.Aliases) |
| | | } |
| | | if tagsJSON != "" { |
| | | _ = json.Unmarshal([]byte(tagsJSON), &r.Tags) |
| | | } |
| | | addCandidate(&r) |
| | | } |
| | | 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, tags, 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, tagsJSON string |
| | | if err := rows.Scan(&r.ID, &r.Path, &r.Title, &r.Section, &aliasesJSON, &tagsJSON, &r.Status); err != nil { |
| | | continue |
| | | } |
| | | if aliasesJSON != "" { |
| | | _ = json.Unmarshal([]byte(aliasesJSON), &r.Aliases) |
| | | } |
| | | if tagsJSON != "" { |
| | | _ = json.Unmarshal([]byte(tagsJSON), &r.Tags) |
| | | } |
| | | r.Rank = 0 // LIKE 无 rank |
| | | addCandidate(&r) |
| | | } |
| | | rows.Close() |
| | | } |
| | | |
| | | // 截断排序:命中关键词多的节点优先保留,平手按首命中先后。 |
| | | // 旧实现按首命中顺序直接截断,高频泛词(如 bigram "电子" 命中 486 行) |
| | | // 会把排在后面但与多词强相关的文档挤出候选集。 |
| | | ranked := make([]int64, len(order)) |
| | | copy(ranked, order) |
| | | sort.SliceStable(ranked, func(i, j int) bool { |
| | | a, b := ranked[i], ranked[j] |
| | | if hitCount[a] != hitCount[b] { |
| | | return hitCount[a] > hitCount[b] |
| | | } |
| | | return firstIdx[a] < firstIdx[b] |
| | | }) |
| | | |
| | | results := make([]FTSResult, 0, len(order)) |
| | | for _, id := range ranked { |
| | | 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 |