package search
|
|
import (
|
"sort"
|
"strings"
|
|
"github.com/aisim/kb-cli/internal/graph"
|
"github.com/aisim/kb-cli/internal/index"
|
)
|
|
// SearchOptions 搜索选项
|
type SearchOptions struct {
|
Expanded []string // 扩展词
|
Symptom []string // 症状词
|
TopN int // 返回前 N 条
|
WithContent bool // 返回完整文件内容
|
WithLinks bool // 返回关联文档链接
|
TextWeight float64 // 文本分权重(0~1,缺省 0.5;RWR 图质量权重 = 1 - TextWeight)
|
}
|
|
// SearchResult 搜索结果
|
type SearchResult struct {
|
ID int64 `json:"id"`
|
Path string `json:"path"`
|
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 执行搜索(双信号加权:文本位置分 × TextWeight + RWR 图质量 × (1-TextWeight),
|
// 草稿态/待审阅降权 0.6)
|
func Search(store *index.Store, keywords []string, opts SearchOptions) ([]SearchResult, error) {
|
// 合并所有关键词
|
allKeywords := append(append(keywords, opts.Expanded...), opts.Symptom...)
|
|
// 长 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
|
}
|
|
// 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)
|
}
|
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 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: int(final * 100),
|
Status: r.Status,
|
})
|
}
|
|
// 按分数排序
|
sort.Slice(results, func(i, j int) bool {
|
return results[i].Score > results[j].Score
|
})
|
|
// 限制返回数量
|
if opts.TopN > 0 && len(results) > opts.TopN {
|
results = results[:opts.TopN]
|
}
|
|
// 获取内容(如果请求)
|
if opts.WithContent {
|
for i := range results {
|
content, _, _, err := store.GetNodeContent(results[i].ID)
|
if err == nil {
|
results[i].Content = content
|
}
|
}
|
}
|
|
// 获取关联链接(如果请求)
|
if opts.WithLinks {
|
for i := range results {
|
links, err := store.GetNodeLinks(results[i].ID)
|
if err == nil {
|
results[i].Links = links
|
}
|
}
|
}
|
|
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
|
kw := strings.ToLower(keyword)
|
|
// 路径匹配
|
if strings.Contains(strings.ToLower(r.Path), kw) {
|
score += CalcScore(keyword, "path", scoreType)
|
}
|
|
// 标题匹配
|
if strings.Contains(strings.ToLower(r.Title), kw) {
|
score += CalcScore(keyword, "title", scoreType)
|
}
|
|
// 板块匹配
|
if strings.Contains(strings.ToLower(r.Section), kw) {
|
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)
|
|
return score
|
}
|