From a7fbbfd6f974631eadebc6effca8ce7982592786 Mon Sep 17 00:00:00 2001
From: ax_rd <ax_rd@aisim.cn>
Date: Thu, 03 Sep 2026 12:51:40 +0800
Subject: [PATCH] fix: explore 段落命中两级回退(原词→展开词)+ expandKeywords 去重 + HardBudget 接入

---
 internal/search/engine.go |  147 +++++++++++++++++++++++++++++++++++++++++--------
 1 files changed, 123 insertions(+), 24 deletions(-)

diff --git a/internal/search/engine.go b/internal/search/engine.go
index 95d7c37..a1dd52a 100644
--- a/internal/search/engine.go
+++ b/internal/search/engine.go
@@ -4,43 +4,51 @@
 	"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)
+	// 1. 双通道检索(ASCII 走 FTS,CJK 走 LIKE)
+	candidates, err := store.KeywordSearch(allKeywords, 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)
 		}
@@ -50,19 +58,71 @@
 		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 +136,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 +188,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)
 

--
Gitblit v1.10.0