fix(search): 搜索准确性四连修——FTS5 MATCH 引号包裹(连字符不再被解析成 MINUS);KeywordSearch 截断按命中关键词数排序(高频 bigram 不再挤出强相关文档);原词奖励档(完整短语压过泛化 bigram);tags 字段真实参与评分(此前误用 Section)
| | |
| | | "database/sql" |
| | | "encoding/json" |
| | | "sort" |
| | | "strings" |
| | | ) |
| | | // FTSResult 全文搜索结果 |
| | | type FTSResult struct { |
| | |
| | | Section string |
| | | Rank float64 |
| | | Aliases []string // 别名(JSON 解析) |
| | | Tags []string // 标签(JSON 解析) |
| | | Status string // 状态(草稿/待确认/跟进中 等参与降权) |
| | | } |
| | | |
| | |
| | | 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 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.status |
| | | 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 ?`, kw, limit) |
| | | WHERE nodes_fts MATCH ? ORDER BY fts.rank LIMIT ?`, quoted, 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 { |
| | | 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 _, ok := seen[r.ID]; !ok { |
| | | seen[r.ID] = &r |
| | | order = append(order, r.ID) |
| | | if tagsJSON != "" { |
| | | _ = json.Unmarshal([]byte(tagsJSON), &r.Tags) |
| | | } |
| | | addCandidate(&r) |
| | | } |
| | | rows.Close() |
| | | } |
| | |
| | | } |
| | | pat := "%" + kw + "%" |
| | | rows, err := s.db.Query(` |
| | | SELECT id, path, title, section, aliases, status FROM nodes |
| | | 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 { |
| | |
| | | } |
| | | 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 { |
| | | 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) |
| | | } |
| | | r.Rank = 0 // LIKE 无 rank |
| | | if _, ok := seen[r.ID]; !ok { |
| | | seen[r.ID] = &r |
| | | order = append(order, r.ID) |
| | | 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 order { |
| | | for _, id := range ranked { |
| | | results = append(results, *seen[id]) |
| | | } |
| | | if limit > 0 && len(results) > limit { |
| | |
| | | textScore[r.ID] = score |
| | | } |
| | | |
| | | // 原词奖励:候选由 bigram 召回时可能并不含用户原词("无法"×10 压过 |
| | | // 含"无法开机"整词的文档)。原词真实出现在正文/标题/标签中时给强信号加分, |
| | | // 使"标题=原词"或"tags 含原词"的文档稳定压过只含泛化 bigram 的文档。 |
| | | for _, r := range candidates { |
| | | content, _, _, err := store.GetNodeContent(r.ID) |
| | | if err != nil { |
| | | continue |
| | | } |
| | | lc := strings.ToLower(content) |
| | | titleL := strings.ToLower(r.Title) |
| | | for _, kw := range keywords { |
| | | kwL := strings.ToLower(kw) |
| | | hit := strings.Contains(lc, kwL) || strings.Contains(titleL, kwL) |
| | | if !hit { |
| | | for _, t := range r.Tags { |
| | | if strings.Contains(strings.ToLower(t), kwL) { |
| | | hit = true |
| | | break |
| | | } |
| | | } |
| | | } |
| | | if hit { |
| | | textScore[r.ID] += CalcScore(kw, "content", ScoreOriginal) |
| | | } |
| | | } |
| | | } |
| | | |
| | | // 3. RWR 图质量(种子 = 候选前 20,全量加载邻接后按种子收敛) |
| | | seedIDs := make([]int64, 0, len(candidates)) |
| | | for _, r := range candidates { |
| | |
| | | score += CalcScore(keyword, "title", scoreType) |
| | | } |
| | | |
| | | // 板块匹配 |
| | | if strings.Contains(strings.ToLower(r.Section), kw) { |
| | | score += CalcScore(keyword, "tag", scoreType) |
| | | // 标签匹配(tags 字段真实参与评分;此前误用 Section,而 Section 恒为"其他") |
| | | if len(r.Tags) > 0 { |
| | | for _, t := range r.Tags { |
| | | if strings.Contains(strings.ToLower(t), kw) { |
| | | score += CalcScore(keyword, "tag", scoreType) |
| | | break |
| | | } |
| | | } |
| | | } |
| | | |
| | | // 别名匹配(命中按 title 档计权,一次命中即计) |
| | |
| | | ScoreNormal ScoreType = iota // 普通词 |
| | | ScoreExpanded // 扩展词 |
| | | ScoreSymptom // 症状词 |
| | | ScoreOriginal // 用户原词(完整短语,强信号,高于 bigram 扩展档) |
| | | ) |
| | | |
| | | // ScoreTable 评分表 |
| | |
| | | "tag": 2, |
| | | "content": 1, |
| | | }, |
| | | ScoreOriginal: { |
| | | "path": 60, |
| | | "title": 60, |
| | | "tag": 60, |
| | | "content": 60, |
| | | }, |
| | | ScoreExpanded: { |
| | | "path": 40, |
| | | "title": 30, |