From 86684d665e2f2f9bb865004e8995a5c110ddcbc2 Mon Sep 17 00:00:00 2001
From: ax_rd <ax_rd@aisim.cn>
Date: Thu, 03 Sep 2026 11:56:04 +0800
Subject: [PATCH] feat: RWR 图结构排序 + CJK LIKE 混合检索 + 双信号加权 + aliases/status 参与排序

---
 internal/graph/rwr.go          |  106 +++++++++++
 internal/search/engine_test.go |   69 +++++++
 cmd/search.go                  |    7 
 internal/index/graphload.go    |   50 +++++
 internal/graph/rwr_test.go     |   37 ++++
 internal/index/fts.go          |  103 +++++++++++
 internal/search/engine.go      |  107 ++++++++++-
 internal/llm/client.go         |   10 +
 8 files changed, 471 insertions(+), 18 deletions(-)

diff --git a/cmd/search.go b/cmd/search.go
index 7083750..379d3cf 100644
--- a/cmd/search.go
+++ b/cmd/search.go
@@ -7,6 +7,7 @@
 
 	"github.com/aisim/kb-cli/internal/index"
 	"github.com/aisim/kb-cli/internal/search"
+	"github.com/aisim/kb-cli/internal/llm"
 	"github.com/aisim/kb-cli/internal/output"
 	"github.com/spf13/cobra"
 )
@@ -76,12 +77,18 @@
 	}
 
 	// 执行搜索
+	cfg := llm.LoadConfig()
+	textWeight := cfg.Search.TextWeight
+	if textWeight <= 0 || textWeight > 1 {
+		textWeight = 0.5 // 缺省 0.5
+	}
 	opts := search.SearchOptions{
 		Expanded:    expanded,
 		Symptom:     symptom,
 		TopN:        topN,
 		WithContent: withContent,
 		WithLinks:   withLinks,
+		TextWeight:  textWeight,
 	}
 
 	results, err := search.Search(store, allKeywords, opts)
diff --git a/internal/graph/rwr.go b/internal/graph/rwr.go
new file mode 100644
index 0000000..d1d2252
--- /dev/null
+++ b/internal/graph/rwr.go
@@ -0,0 +1,106 @@
+package graph
+
+// AdjEdge 带权邻接边(导出:index 包 LoadRWRGraph 需引用该类型)
+type AdjEdge struct {
+	To     int64
+	Weight float64
+}
+
+// RWR Random-Walk-with-Restart(个性化 PageRank):
+// 从种子集合出发在无向带权图上做 power iteration,restart 概率 alpha 回到种子。
+// 返回归一化到 [0,1] 的游走质量。确定性算法,无随机数。
+func RWR(seedIDs []int64, adj map[int64][]AdjEdge, alpha float64) map[int64]float64 {
+	if len(seedIDs) == 0 {
+		return map[int64]float64{}
+	}
+	// 收集参与节点:种子 + 邻接可达
+	nodes := make(map[int64]bool)
+	for _, s := range seedIDs {
+		nodes[s] = true
+	}
+	for from := range adj {
+		nodes[from] = true
+		for _, e := range adj[from] {
+			nodes[e.To] = true
+		}
+	}
+	n := len(nodes)
+	if n == 0 {
+		return map[int64]float64{}
+	}
+	idx := make(map[int64]int, n)
+	ids := make([]int64, 0, n)
+	for id := range nodes {
+		idx[id] = len(ids)
+		ids = append(ids, id)
+	}
+
+	// 重启向量:种子均匀
+	r := make([]float64, n)
+	for _, s := range seedIDs {
+		if i, ok := idx[s]; ok {
+			r[i] = 1.0 / float64(len(seedIDs))
+		}
+	}
+
+	const maxIter = 50
+	const eps = 1e-6
+	newR := make([]float64, n)
+	for iter := 0; iter < maxIter; iter++ {
+		for i := range newR {
+			newR[i] = 0
+		}
+		// 游走传播:out[i] = sum(w_ij)
+		for i, id := range ids {
+			var total float64
+			for _, e := range adj[id] {
+				total += e.Weight
+			}
+			if total == 0 {
+				continue
+			}
+			for _, e := range adj[id] {
+				j, ok := idx[e.To]
+				if !ok {
+					continue
+				}
+				newR[j] += (1 - alpha) * r[i] * e.Weight / total
+			}
+		}
+		// restart
+		for i := range newR {
+			newR[i] += alpha * r[i]
+		}
+		// 收敛判断
+		diff := 0.0
+		for i := range newR {
+			d := newR[i] - r[i]
+			if d < 0 {
+				d = -d
+			}
+			diff += d
+		}
+		r, newR = newR, r
+		if diff < eps {
+			break
+		}
+	}
+	// 归一化 [0,1]
+	maxV := 0.0
+	for _, v := range r {
+		if v > maxV {
+			maxV = v
+		}
+	}
+	out := make(map[int64]float64, n)
+	if maxV == 0 {
+		for _, id := range ids {
+			out[id] = 0
+		}
+		return out
+	}
+	for i, id := range ids {
+		out[id] = r[i] / maxV
+	}
+	return out
+}
diff --git a/internal/graph/rwr_test.go b/internal/graph/rwr_test.go
new file mode 100644
index 0000000..a4111fe
--- /dev/null
+++ b/internal/graph/rwr_test.go
@@ -0,0 +1,37 @@
+package graph
+
+import "testing"
+
+func TestRWRConvergenceAndSeed(t *testing.T) {
+	// 图: A-B-C 链 + D 孤立
+	adj := map[int64][]AdjEdge{
+		1: {{2, 1}}, 2: {{1, 1}, {3, 1}}, 3: {{2, 1}}, 4: {{}},
+	}
+	r := RWR([]int64{1}, adj, 0.25)
+	if r[1] <= 0 {
+		t.Fatal("种子节点质量必须 > 0")
+	}
+	// 与种子连通的质量应高于孤立节点
+	if r[4] >= r[3] {
+		t.Errorf("孤立节点质量不应高于连通节点: r4=%f r3=%f", r[4], r[3])
+	}
+	// 归一化 [0,1]
+	for _, v := range r {
+		if v < 0 || v > 1 {
+			t.Errorf("质量未归一化: %f", v)
+		}
+	}
+}
+
+func TestRWRWeightedByProvenance(t *testing.T) {
+	// A -exact-> B, A -fuzzy-> C:B 的质量应高于 C
+	adj := map[int64][]AdjEdge{
+		1: {{2, 1.0}, {3, 0.5}},
+		2: {{1, 1.0}},
+		3: {{1, 0.5}},
+	}
+	r := RWR([]int64{1}, adj, 0.25)
+	if r[2] <= r[3] {
+		t.Errorf("exact 边节点质量应高于 fuzzy: r2=%f r3=%f", r[2], r[3])
+	}
+}
diff --git a/internal/index/fts.go b/internal/index/fts.go
index 4323985..552b428 100644
--- a/internal/index/fts.go
+++ b/internal/index/fts.go
@@ -12,6 +12,8 @@
 	Title   string
 	Section string
 	Rank    float64
+	Aliases []string // 别名(JSON 解析)
+	Status  string   // 状态(草稿/待确认/跟进中 等参与降权)
 }
 
 // CreateFTS 创建 FTS5 虚拟表(external-content 模式,由触发器增量维护)
@@ -70,7 +72,7 @@
 
 	for _, kw := range keywords {
 		rows, err := s.db.Query(`
-			SELECT n.id, n.path, n.title, n.section, fts.rank
+			SELECT n.id, n.path, n.title, n.section, fts.rank, n.aliases, n.status
 			FROM nodes_fts fts
 			JOIN nodes n ON n.id = fts.rowid
 			WHERE nodes_fts MATCH ?
@@ -84,10 +86,14 @@
 
 		for rows.Next() {
 			var r FTSResult
-			if err := rows.Scan(&r.ID, &r.Path, &r.Title, &r.Section, &r.Rank); err != nil {
+			var aliasesJSON string
+			if err := rows.Scan(&r.ID, &r.Path, &r.Title, &r.Section, &r.Rank, &aliasesJSON, &r.Status); err != nil {
 				rows.Close()
 				continue
 			}
+			if aliasesJSON != "" {
+				_ = json.Unmarshal([]byte(aliasesJSON), &r.Aliases)
+			}
 			// 去重
 			if !seen[r.ID] {
 				seen[r.ID] = true
@@ -110,6 +116,99 @@
 	return allResults, nil
 }
 
+// containsCJK 是否含 CJK 统一表意文字
+func containsCJK(s string) bool {
+	for _, r := range s {
+		if r >= 0x4E00 && r <= 0x9FFF {
+			return true
+		}
+	}
+	return false
+}
+
+// KeywordSearch 双通道关键词检索:ASCII 词走 FTS5 MATCH,CJK 词走 LIKE(title/aliases/content/tags)。
+// FTS5 unicode61 把连续中文当整串单 token,多字符 CJK 词 MATCH 匹配不到,必须走 LIKE。
+// 结果按"首命中顺序"排列(FTS 通道按 rank,LIKE 通道补在尾部),engine 再按双信号重排。
+func (s *Store) KeywordSearch(keywords []string, limit int) ([]FTSResult, error) {
+	if len(keywords) == 0 {
+		return nil, nil
+	}
+	seen := make(map[int64]*FTSResult)
+	var order []int64
+
+	// 通道 1: FTS(ASCII 词)
+	var ascii []string
+	for _, kw := range keywords {
+		if !containsCJK(kw) {
+			ascii = append(ascii, kw)
+		}
+	}
+	for _, kw := range ascii {
+		rows, err := s.db.Query(`
+			SELECT n.id, n.path, n.title, n.section, fts.rank, n.aliases, n.status
+			FROM nodes_fts fts JOIN nodes n ON n.id = fts.rowid
+			WHERE nodes_fts MATCH ? ORDER BY fts.rank LIMIT ?`, kw, limit)
+		if err != nil {
+			continue // 语法异常词跳过
+		}
+		for rows.Next() {
+			var r FTSResult
+			var aliasesJSON string
+			if err := rows.Scan(&r.ID, &r.Path, &r.Title, &r.Section, &r.Rank, &aliasesJSON, &r.Status); err != nil {
+				continue
+			}
+			if aliasesJSON != "" {
+				_ = json.Unmarshal([]byte(aliasesJSON), &r.Aliases)
+			}
+			if _, ok := seen[r.ID]; !ok {
+				seen[r.ID] = &r
+				order = append(order, r.ID)
+			}
+		}
+		rows.Close()
+	}
+
+	// 通道 2: LIKE(CJK 词)
+	for _, kw := range keywords {
+		if !containsCJK(kw) {
+			continue
+		}
+		pat := "%" + kw + "%"
+		rows, err := s.db.Query(`
+			SELECT id, path, title, section, aliases, status FROM nodes
+			WHERE title LIKE ? OR aliases LIKE ? OR content_fts LIKE ? OR tags LIKE ?`,
+			pat, pat, pat, pat)
+		if err != nil {
+			continue
+		}
+		for rows.Next() {
+			var r FTSResult
+			var aliasesJSON string
+			if err := rows.Scan(&r.ID, &r.Path, &r.Title, &r.Section, &aliasesJSON, &r.Status); err != nil {
+				continue
+			}
+			if aliasesJSON != "" {
+				_ = json.Unmarshal([]byte(aliasesJSON), &r.Aliases)
+			}
+			r.Rank = 0 // LIKE 无 rank
+			if _, ok := seen[r.ID]; !ok {
+				seen[r.ID] = &r
+				order = append(order, r.ID)
+			}
+		}
+		rows.Close()
+	}
+
+	results := make([]FTSResult, 0, len(order))
+	for _, id := range order {
+		results = append(results, *seen[id])
+	}
+	if limit > 0 && len(results) > limit {
+		results = results[:limit]
+	}
+	return results, nil
+}
+
 // GetNodeContent 获取节点内容
 func (s *Store) GetNodeContent(id int64) (string, []string, []string, error) {
 	var content, tagsJSON, entitiesJSON string
diff --git a/internal/index/graphload.go b/internal/index/graphload.go
new file mode 100644
index 0000000..73db2ef
--- /dev/null
+++ b/internal/index/graphload.go
@@ -0,0 +1,50 @@
+package index
+
+import (
+	"fmt"
+
+	"github.com/aisim/kb-cli/internal/graph"
+)
+
+// LoadRWRGraph 加载 RWR 邻接(无向、带权)。
+// 权重:wikilink exact=1.0 / fuzzy=0.5 / entity=1.0 / tag=0.5(tag 扇出大降权)。
+// 只加载与种子同连通域的边不可行(SQLite 无图查询),全量加载后 RWR 内部按种子收敛——
+// 458 节点 / ~2k 边规模下全量加载 <5ms,可接受。
+func (s *Store) LoadRWRGraph() (map[int64][]graph.AdjEdge, error) {
+	rows, err := s.db.Query(`
+		SELECT from_node, to_node, relation, provenance FROM edges
+		WHERE relation IN ('wikilink', 'entity', 'tag')`)
+	if err != nil {
+		return nil, fmt.Errorf("加载边失败: %w", err)
+	}
+	defer rows.Close()
+	adj := make(map[int64][]graph.AdjEdge)
+	add := func(from, to int64, w float64) {
+		adj[from] = append(adj[from], graph.AdjEdge{To: to, Weight: w})
+		adj[to] = append(adj[to], graph.AdjEdge{To: from, Weight: w})
+	}
+	for rows.Next() {
+		var from, to int64
+		var relation, prov string
+		if err := rows.Scan(&from, &to, &relation, &prov); err != nil {
+			return nil, err
+		}
+		var w float64
+		switch relation {
+		case "wikilink":
+			if prov == "exact" {
+				w = 1.0
+			} else {
+				w = 0.5
+			}
+		case "entity":
+			w = 1.0
+		case "tag":
+			w = 0.5
+		default:
+			continue
+		}
+		add(from, to, w)
+	}
+	return adj, rows.Err()
+}
diff --git a/internal/llm/client.go b/internal/llm/client.go
index 883cc63..794db83 100644
--- a/internal/llm/client.go
+++ b/internal/llm/client.go
@@ -39,6 +39,16 @@
 		ReviewDir   string `yaml:"review_dir"`
 		DefaultType string `yaml:"default_type"`
 	} `yaml:"draft"`
+	// Search 搜索配置(双信号加权权重)
+	Search struct {
+		TextWeight float64 `yaml:"text_weight"` // 文本分权重(0~1,缺省 0.5)
+	} `yaml:"search"`
+	// Explore 探索预算配置(Task 7 预留)
+	Explore struct {
+		DefaultBudget int `yaml:"default_budget"`
+		HardBudget    int `yaml:"hard_budget"`
+		TopN          int `yaml:"top_n"`
+	} `yaml:"explore"`
 }
 
 // Client LLM 客户端
diff --git a/internal/search/engine.go b/internal/search/engine.go
index 16fa86d..a1dd52a 100644
--- a/internal/search/engine.go
+++ b/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,28 +25,30 @@
 	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)
 		}
@@ -54,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,
 		})
 	}
 
@@ -103,6 +159,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 +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)
 
diff --git a/internal/search/engine_test.go b/internal/search/engine_test.go
index 2cd91d3..6aeeb1e 100644
--- a/internal/search/engine_test.go
+++ b/internal/search/engine_test.go
@@ -78,6 +78,75 @@
 	}
 }
 
+func TestCJKLikeChannel(t *testing.T) {
+	// CJK LIKE 通道:多字符中文词 FTS MATCH 匹配不到(unicode61 整串 token),
+	// 必须走 LIKE 才能命中 content/title
+	tmpDir := t.TempDir()
+	dbPath := filepath.Join(tmpDir, "test.db")
+	store, err := index.Open(dbPath)
+	if err != nil {
+		t.Fatalf("Open failed: %v", err)
+	}
+	defer store.Close()
+
+	nodes := []*graph.Node{
+		{ // content 含"补气",title 不含(靠 LIKE content 通道命中)
+			Path:    "FAQ/001-a.md",
+			Title:   "设备故障排查",
+			Section: "FAQ",
+			Content: "电子秤补气失败时的排查步骤",
+		},
+		{ // title 含"补气"(title LIKE 命中,应排前)
+			Path:    "FAQ/002-b.md",
+			Title:   "电子秤补气失败",
+			Section: "FAQ",
+			Content: "补气失败的处理方法",
+		},
+		{ // 不含"补气"(不应出现在结果中)
+			Path:    "FAQ/003-c.md",
+			Title:   "阀门漏气处理",
+			Section: "FAQ",
+			Content: "阀门漏气的原因和处理",
+		},
+	}
+	for _, n := range nodes {
+		if err := store.UpsertNode(n, 100, 1, "x"); err != nil {
+			t.Fatalf("UpsertNode failed: %v", err)
+		}
+	}
+	if err := store.CreateFTS(); err != nil {
+		t.Fatalf("CreateFTS failed: %v", err)
+	}
+	if err := store.PopulateFTS(); err != nil {
+		t.Fatalf("PopulateFTS failed: %v", err)
+	}
+
+	results, err := Search(store, []string{"补气"}, SearchOptions{})
+	if err != nil {
+		t.Fatalf("Search failed: %v", err)
+	}
+	if len(results) < 2 {
+		t.Fatalf("Expected 2 results (content 命中 + title 命中), got %d", len(results))
+	}
+	// 两个含"补气"的节点都应在结果中
+	if results[0].Path != "FAQ/002-b.md" && results[1].Path != "FAQ/002-b.md" {
+		t.Error("title 含'补气'的节点应出现在结果中")
+	}
+	if results[0].Path != "FAQ/001-a.md" && results[1].Path != "FAQ/001-a.md" {
+		t.Error("content 含'补气'的节点应出现在结果中")
+	}
+	// 不含"补气"的节点不应出现
+	for _, r := range results {
+		if r.Path == "FAQ/003-c.md" {
+			t.Error("不含关键词的节点不应出现在结果中")
+		}
+	}
+	// title 命中者应排前(title 计权高于 content)
+	if results[0].Path != "FAQ/002-b.md" {
+		t.Errorf("title 命中者应排第一, got %q", results[0].Path)
+	}
+}
+
 func TestIsGenericWord(t *testing.T) {
 	tests := []struct {
 		word     string

--
Gitblit v1.10.0