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 }