From b554c303193aa5d22af5f8a4b91430077113e6db Mon Sep 17 00:00:00 2001
From: ax_rd <ax_rd@aisim.cn>
Date: Thu, 03 Sep 2026 13:21:36 +0800
Subject: [PATCH] fix: search 长 CJK 词 bigram 展开统一下沉(ExpandCJKKeywords 共享 + textScore 按 Expanded 档计权 + graph query 边 provenance 填充)
---
internal/search/engine.go | 127 +++++++++++++++++++++++++++++++++++++-----
1 files changed, 111 insertions(+), 16 deletions(-)
diff --git a/internal/search/engine.go b/internal/search/engine.go
index 16fa86d..13e2ff1 100644
--- a/internal/search/engine.go
+++ b/internal/search/engine.go
@@ -4,6 +4,7 @@
"sort"
"strings"
+ "github.com/aisim/kb-cli/internal/graph"
"github.com/aisim/kb-cli/internal/index"
)
@@ -14,6 +15,7 @@
TopN int // 返回前 N 条
WithContent bool // 返回完整文件内容
WithLinks bool // 返回关联文档链接
+ TextWeight float64 // 文本分权重(0~1,缺省 0.5;RWR 图质量权重 = 1 - TextWeight)
}
// SearchResult 搜索结果
@@ -23,50 +25,124 @@
Title string `json:"title"`
Section string `json:"section"`
Score int `json:"score"`
+ Status string `json:"status,omitempty"`
Content string `json:"content,omitempty"` // 文件内容(WithContent=true 时填充)
Links []string `json:"links,omitempty"` // 关联文档路径(WithLinks=true 时填充)
}
-// Search 执行搜索
+// Search 执行搜索(双信号加权:文本位置分 × TextWeight + RWR 图质量 × (1-TextWeight),
+// 草稿态/待审阅降权 0.6)
func Search(store *index.Store, keywords []string, opts SearchOptions) ([]SearchResult, error) {
// 合并所有关键词
- allKeywords := append(keywords, opts.Expanded...)
- allKeywords = append(allKeywords, opts.Symptom...)
+ allKeywords := append(append(keywords, opts.Expanded...), opts.Symptom...)
- // FTS5 搜索
- ftsResults, err := store.FTSSearch(allKeywords, 100)
+ // 长 CJK 词 bigram 展开(统一下沉):FTS5 unicode61 把连续中文当整串单 token,
+ // 长复合词整串 LIKE 匹配不到;ExpandCJKKeywords 拆成 bigram 滑动窗口
+ // (原词保留、去重保序)。bigram 参与检索,textScore 按 ScoreExpanded 档计权
+ // (与现有 Expanded 机制一致),原词仍按原词档。
+ searchKeywords := ExpandCJKKeywords(allKeywords)
+ originalSet := make(map[string]bool, len(allKeywords))
+ for _, kw := range allKeywords {
+ originalSet[kw] = true
+ }
+ var bigrams []string
+ for _, kw := range searchKeywords {
+ if !originalSet[kw] {
+ bigrams = append(bigrams, kw)
+ }
+ }
+
+ // 1. 双通道检索(ASCII 走 FTS,CJK 走 LIKE)
+ candidates, err := store.KeywordSearch(searchKeywords, 100)
if err != nil {
return nil, err
}
+ if len(candidates) == 0 {
+ return nil, nil
+ }
- // 评分
- scoreMap := make(map[int64]int)
- for _, r := range ftsResults {
- score := 0
-
- // 关键词匹配评分
+ // 2. 文本分(沿用位置加权,aliases 按 title 档计权)
+ textScore := make(map[int64]int, len(candidates))
+ for _, r := range candidates {
+ var score int
for _, kw := range keywords {
score += scoreResult(r, kw, ScoreNormal)
}
for _, kw := range opts.Expanded {
score += scoreResult(r, kw, ScoreExpanded)
}
+ // bigram 展开词按 Expanded 档计权(与用户显式扩展词同档)
+ for _, kw := range bigrams {
+ score += scoreResult(r, kw, ScoreExpanded)
+ }
for _, kw := range opts.Symptom {
score += scoreResult(r, kw, ScoreSymptom)
}
-
- scoreMap[r.ID] = score
+ textScore[r.ID] = score
}
- // 转换为结果列表
+ // 3. RWR 图质量(种子 = 候选前 20,全量加载邻接后按种子收敛)
+ seedIDs := make([]int64, 0, len(candidates))
+ for _, r := range candidates {
+ if len(seedIDs) >= 20 {
+ break
+ }
+ seedIDs = append(seedIDs, r.ID)
+ }
+ rwrMass := map[int64]float64{}
+ if len(seedIDs) > 0 {
+ adj, err := store.LoadRWRGraph()
+ if err == nil {
+ rwrMass = graph.RWR(seedIDs, adj, 0.25)
+ }
+ }
+
+ // 4. 双信号加权:finalScore = norm(textScore)*tw + rwrMass*(1-tw)
+ tw := opts.TextWeight
+ if tw <= 0 || tw > 1 {
+ tw = 0.5
+ }
+ // 先求文本分真实 min/max,再按 (s-minT)/(maxT-minT) 归一化
+ minT, maxT := 0, 0
+ for _, s := range textScore {
+ if minT == 0 && maxT == 0 {
+ minT, maxT = s, s
+ } else {
+ if s < minT {
+ minT = s
+ }
+ if s > maxT {
+ maxT = s
+ }
+ }
+ }
+ // status 降权:草稿态或待审阅板块 ×0.6
+ statusFactor := make(map[int64]float64, len(candidates))
+ for _, r := range candidates {
+ if r.Section == "待审阅" || isDraftStatus(r.Status) {
+ statusFactor[r.ID] = 0.6
+ } else {
+ statusFactor[r.ID] = 1.0
+ }
+ }
+
var results []SearchResult
- for _, r := range ftsResults {
+ for _, r := range candidates {
+ ts := float64(textScore[r.ID])
+ if maxT > minT {
+ ts = (ts - float64(minT)) / float64(maxT - minT)
+ } else if maxT > 0 {
+ ts = 1
+ }
+ final := ts*tw + rwrMass[r.ID]*(1-tw)
+ final *= statusFactor[r.ID]
results = append(results, SearchResult{
ID: r.ID,
Path: r.Path,
Title: r.Title,
Section: r.Section,
- Score: scoreMap[r.ID],
+ Score: int(final * 100),
+ Status: r.Status,
})
}
@@ -103,6 +179,15 @@
return results, nil
}
+// isDraftStatus 草稿态降权判断(草稿/待确认/跟进中 降权 0.6)
+func isDraftStatus(status string) bool {
+ switch status {
+ case "草稿", "待确认", "跟进中":
+ return true
+ }
+ return false
+}
+
// scoreResult 计算单个结果的得分
func scoreResult(r index.FTSResult, keyword string, scoreType ScoreType) int {
score := 0
@@ -123,6 +208,16 @@
score += CalcScore(keyword, "tag", scoreType)
}
+ // 别名匹配(命中按 title 档计权,一次命中即计)
+ if len(r.Aliases) > 0 {
+ for _, a := range r.Aliases {
+ if strings.Contains(strings.ToLower(a), kw) {
+ score += CalcScore(keyword, "title", scoreType)
+ break
+ }
+ }
+ }
+
// 内容匹配(FTS 已经匹配,给基础分)
score += CalcScore(keyword, "content", scoreType)
--
Gitblit v1.10.0