From beb1530ffd6ef2f505acc2bc8809599fbae30c0a Mon Sep 17 00:00:00 2001
From: ai_xiaopei <xiaopei@aisim.cn>
Date: Sat, 25 Jul 2026 23:47:41 +0800
Subject: [PATCH] feat: 实现搜索引擎与评分算法
---
internal/search/engine_test.go | 98 +++++++++++++++++++
internal/search/engine.go | 106 +++++++++++++++++++++
internal/search/scorer.go | 66 +++++++++++++
3 files changed, 270 insertions(+), 0 deletions(-)
diff --git a/internal/search/engine.go b/internal/search/engine.go
new file mode 100644
index 0000000..95d7c37
--- /dev/null
+++ b/internal/search/engine.go
@@ -0,0 +1,106 @@
+package search
+
+import (
+ "sort"
+ "strings"
+
+ "github.com/aisim/kb-cli/internal/index"
+)
+
+// SearchOptions 搜索选项
+type SearchOptions struct {
+ Expanded []string // 扩展词
+ Symptom []string // 症状词
+ TopN int // 返回前 N 条
+}
+
+// SearchResult 搜索结果
+type SearchResult struct {
+ ID int64
+ Path string
+ Title string
+ Section string
+ Score int
+}
+
+// Search 执行搜索
+func Search(store *index.Store, keywords []string, opts SearchOptions) ([]SearchResult, error) {
+ // 合并所有关键词
+ allKeywords := append(keywords, opts.Expanded...)
+ allKeywords = append(allKeywords, opts.Symptom...)
+
+ // FTS5 搜索
+ ftsResults, err := store.FTSSearch(allKeywords, 100)
+ if err != nil {
+ return nil, err
+ }
+
+ // 评分
+ scoreMap := make(map[int64]int)
+ for _, r := range ftsResults {
+ score := 0
+
+ // 关键词匹配评分
+ for _, kw := range keywords {
+ score += scoreResult(r, kw, ScoreNormal)
+ }
+ for _, kw := range opts.Expanded {
+ score += scoreResult(r, kw, ScoreExpanded)
+ }
+ for _, kw := range opts.Symptom {
+ score += scoreResult(r, kw, ScoreSymptom)
+ }
+
+ scoreMap[r.ID] = score
+ }
+
+ // 转换为结果列表
+ var results []SearchResult
+ for _, r := range ftsResults {
+ results = append(results, SearchResult{
+ ID: r.ID,
+ Path: r.Path,
+ Title: r.Title,
+ Section: r.Section,
+ Score: scoreMap[r.ID],
+ })
+ }
+
+ // 按分数排序
+ 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]
+ }
+
+ return results, nil
+}
+
+// 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)
+ }
+
+ // 内容匹配(FTS 已经匹配,给基础分)
+ score += CalcScore(keyword, "content", scoreType)
+
+ return score
+}
diff --git a/internal/search/engine_test.go b/internal/search/engine_test.go
new file mode 100644
index 0000000..2cd91d3
--- /dev/null
+++ b/internal/search/engine_test.go
@@ -0,0 +1,98 @@
+package search
+
+import (
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/aisim/kb-cli/internal/graph"
+ "github.com/aisim/kb-cli/internal/index"
+)
+
+func TestSearch(t *testing.T) {
+ // 创建临时数据库
+ 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{
+ {
+ ID: 1,
+ Path: "FAQ/充装类/001-test.md",
+ Title: "充装问题排查",
+ Section: "FAQ",
+ Tags: []string{"充装"},
+ Content: "关于智能枪充装问题的排查方法",
+ },
+ {
+ ID: 2,
+ Path: "知识/002-config.md",
+ Title: "充装规格配置",
+ Section: "知识",
+ Tags: []string{"配置"},
+ Content: "充装规格配置说明",
+ },
+ }
+
+ for _, n := range nodes {
+ _, err := store.InsertNode(n)
+ if err != nil {
+ t.Fatalf("InsertNode failed: %v", err)
+ }
+ }
+
+ // 创建 FTS 索引
+ 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) == 0 {
+ t.Error("Expected results, got none")
+ }
+
+ // 验证结果包含关键词
+ found := false
+ for _, r := range results {
+ if strings.Contains(r.Title, "充装") || strings.Contains(r.Path, "充装") {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Error("Expected results to contain '充装'")
+ }
+}
+
+func TestIsGenericWord(t *testing.T) {
+ tests := []struct {
+ word string
+ expected bool
+ }{
+ {"问题", true},
+ {"故障", true},
+ {"充装", false},
+ {"智能枪", false},
+ }
+
+ for _, tt := range tests {
+ result := IsGenericWord(tt.word)
+ if result != tt.expected {
+ t.Errorf("IsGenericWord(%q) = %v, want %v", tt.word, result, tt.expected)
+ }
+ }
+}
diff --git a/internal/search/scorer.go b/internal/search/scorer.go
new file mode 100644
index 0000000..ae676b4
--- /dev/null
+++ b/internal/search/scorer.go
@@ -0,0 +1,66 @@
+package search
+
+import (
+ "strings"
+)
+
+// GenericWords 通用词列表(降低权重避免匹配所有文件)
+var GenericWords = []string{
+ "问题", "故障", "报错", "异常", "无法", "不能",
+ "怎么", "如何", "为什么", "是什么", "哪里", "什么",
+ "可以", "需要", "应该", "是否", "有没有",
+}
+
+// IsGenericWord 判断是否为通用词
+func IsGenericWord(word string) bool {
+ word = strings.ToLower(word)
+ for _, g := range GenericWords {
+ if word == g {
+ return true
+ }
+ }
+ return false
+}
+
+// ScoreType 评分类型
+type ScoreType int
+
+const (
+ ScoreNormal ScoreType = iota // 普通词
+ ScoreExpanded // 扩展词
+ ScoreSymptom // 症状词
+)
+
+// ScoreTable 评分表
+var ScoreTable = map[ScoreType]map[string]int{
+ ScoreNormal: {
+ "path": 2,
+ "title": 3,
+ "tag": 2,
+ "content": 1,
+ },
+ ScoreExpanded: {
+ "path": 40,
+ "title": 30,
+ "tag": 20,
+ "content": 5,
+ },
+ ScoreSymptom: {
+ "path": 60,
+ "title": 50,
+ "tag": 35,
+ "content": 15,
+ },
+}
+
+// CalcScore 计算单个关键词在某个位置的得分
+func CalcScore(keyword string, position string, scoreType ScoreType) int {
+ base := ScoreTable[scoreType][position]
+ if base == 0 {
+ return 0
+ }
+ if IsGenericWord(keyword) {
+ return base / 10 // 通用词降权
+ }
+ return base
+}
--
Gitblit v1.9.1