1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
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
}