fix(search): 搜索准确性四连修——FTS5 MATCH 引号包裹(连字符不再被解析成 MINUS);KeywordSearch 截断按命中关键词数排序(高频 bigram 不再挤出强相关文档);原词奖励档(完整短语压过泛化 bigram);tags 字段真实参与评分(此前误用 Section)
4 files modified
104 ■■■■ changed files
bin/kb-cli patch | view | raw | blame | history
internal/index/fts.go 59 ●●●● patch | view | raw | blame | history
internal/search/engine.go 38 ●●●●● patch | view | raw | blame | history
internal/search/scorer.go 7 ●●●●● patch | view | raw | blame | history
bin/kb-cli
Binary files differ
internal/index/fts.go
@@ -4,6 +4,7 @@
    "database/sql"
    "encoding/json"
    "sort"
    "strings"
)
// FTSResult 全文搜索结果
type FTSResult struct {
@@ -13,6 +14,7 @@
    Section string
    Rank    float64
    Aliases []string // 别名(JSON 解析)
    Tags    []string // 标签(JSON 解析)
    Status  string   // 状态(草稿/待确认/跟进中 等参与降权)
}
@@ -134,7 +136,17 @@
        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
@@ -144,26 +156,30 @@
        }
    }
    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()
    }
@@ -175,7 +191,7 @@
        }
        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 {
@@ -183,24 +199,37 @@
        }
        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 {
internal/search/engine.go
@@ -81,6 +81,33 @@
        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 {
@@ -203,9 +230,14 @@
        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 档计权,一次命中即计)
internal/search/scorer.go
@@ -47,6 +47,7 @@
    ScoreNormal  ScoreType = iota // 普通词
    ScoreExpanded                 // 扩展词
    ScoreSymptom                  // 症状词
    ScoreOriginal                 // 用户原词(完整短语,强信号,高于 bigram 扩展档)
)
// ScoreTable 评分表
@@ -57,6 +58,12 @@
        "tag":     2,
        "content": 1,
    },
    ScoreOriginal: {
        "path":    60,
        "title":   60,
        "tag":     60,
        "content": 60,
    },
    ScoreExpanded: {
        "path":    40,
        "title":   30,