ax_rd
5 hours ago 60eb89c12ee0661785395bff90940204b16dcb3a
internal/search/engine.go
@@ -4,65 +4,145 @@
   "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,
      })
   }
@@ -76,9 +156,38 @@
      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
@@ -99,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)