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)