| | |
| | | "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 条 |
| | | 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 |
| | | Path string |
| | | Title string |
| | | Section string |
| | | Score int |
| | | 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 执行搜索 |
| | | // 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, |
| | | }) |
| | | } |
| | | |
| | |
| | | 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 |
| | |
| | | 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) |
| | | |