feat: 添加注释
This commit is contained in:
+60
-11
@@ -1,3 +1,5 @@
|
||||
// Package service 提供知识库的搜索逻辑。
|
||||
// 搜索是本项目最核心的功能,此文件实现了"关键词模糊匹配 + 降级兜底"策略。
|
||||
package service
|
||||
|
||||
import (
|
||||
@@ -8,21 +10,58 @@ import (
|
||||
"AI-Expert-Sidebar/internal/models"
|
||||
)
|
||||
|
||||
// maxResults 限制每次搜索最多返回的条目数。
|
||||
// 侧边栏 UI 空间有限,超过 5 条会让用户滚动,体验下降;
|
||||
// 同时限制条数也控制了传给 AI 的 Context 长度,避免超出 Token 限制。
|
||||
const maxResults = 5
|
||||
|
||||
// ErrDBUnavailable 是数据库未就绪时返回的哨兵错误。
|
||||
// 前端可以检查此错误来决定是否显示"数据库初始化中"提示,
|
||||
// 而不是和其他内部错误混为一谈。
|
||||
var ErrDBUnavailable = errors.New("database unavailable")
|
||||
|
||||
// SearchResult is the DTO returned to the frontend.
|
||||
// SearchResult 是从 service 层返回给 handler/前端的搜索结果 DTO。
|
||||
// 使用 DTO(数据传输对象)而非直接返回 models.Entry,原因:
|
||||
// 1. 可以附加计算字段(Score、IsFallback)而不污染数据库模型;
|
||||
// 2. 解耦:前端字段名(snake_case JSON)与数据库字段名可以独立演化;
|
||||
// 3. 敏感字段(如 UpdatedAt)不会意外暴露给前端。
|
||||
type SearchResult struct {
|
||||
ID uint `json:"id"`
|
||||
Question string `json:"question"`
|
||||
Answer string `json:"answer"`
|
||||
Category string `json:"category"`
|
||||
Score int `json:"score"` // 2=keyword, 1=question, 0=fallback
|
||||
IsFallback bool `json:"is_fallback"`
|
||||
ID uint `json:"id"`
|
||||
Question string `json:"question"`
|
||||
Answer string `json:"answer"`
|
||||
Category string `json:"category"`
|
||||
// Score 表示匹配精度:2=关键词命中, 1=问题文本命中, 0=降级兜底。
|
||||
// 前端用此值决定显示 "⚡精准匹配" 还是 "🔥热门推荐" 徽章。
|
||||
Score int `json:"score"` // 2=keyword, 1=question, 0=fallback
|
||||
// IsFallback 为 true 时,表示搜索无命中,结果是随机推荐的热门问答。
|
||||
// 前端据此显示提示横幅,避免用户误以为这是精准答案。
|
||||
IsFallback bool `json:"is_fallback"`
|
||||
}
|
||||
|
||||
// SearchKnowledge performs fuzzy search in the active knowledge library.
|
||||
// SearchKnowledge 在当前活跃知识库中执行模糊关键词搜索,并实现降级兜底。
|
||||
//
|
||||
// # 搜索策略(两阶段)
|
||||
//
|
||||
// 阶段 1 — 精准模糊匹配:
|
||||
//
|
||||
// 在 keyword 和 question 列上执行 LIKE '%query%' 查询。
|
||||
// SQLite 的 LIKE 运算符对 ASCII 字符不区分大小写,
|
||||
// 中文字符则依赖 GBK/UTF-8 字节比较(本项目全 UTF-8,无问题)。
|
||||
// 结果按 updated_at DESC 排序,使最近更新的内容优先展示。
|
||||
//
|
||||
// 阶段 2 — 降级兜底(Fallback):
|
||||
//
|
||||
// 若阶段 1 无任何结果,改为返回最近更新的前 3 条记录,
|
||||
// 并标记 IsFallback=true。
|
||||
// 这样用户永远不会看到"空结果",体验更好;
|
||||
// 前端会显示"未找到精确匹配"提示,不造成误导。
|
||||
//
|
||||
// # 为什么不用 FTS5(全文搜索)
|
||||
//
|
||||
// SQLite FTS5 提供更强大的全文检索,但需要额外建表/触发器,
|
||||
// 对中文效果也不理想(需要分词插件)。
|
||||
// 简单 LIKE 对本项目知识库规模(通常 < 1000 条)已完全足够,
|
||||
// 引入 FTS5 会大幅增加代码复杂度,得不偿失。
|
||||
func SearchKnowledge(query string) ([]SearchResult, error) {
|
||||
db := database.Get()
|
||||
if db == nil {
|
||||
@@ -32,6 +71,7 @@ func SearchKnowledge(query string) ([]SearchResult, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// 用 channel + goroutine 封装查询,使 handler 层可以安全地配合 context 取消
|
||||
type res struct {
|
||||
rows []SearchResult
|
||||
err error
|
||||
@@ -39,6 +79,7 @@ func SearchKnowledge(query string) ([]SearchResult, error) {
|
||||
ch := make(chan res, 1)
|
||||
|
||||
go func() {
|
||||
// 检查库是否为空;空库时直接返回,避免无意义的 LIKE 查询
|
||||
var total int64
|
||||
db.Model(&models.Entry{}).Count(&total)
|
||||
if total == 0 {
|
||||
@@ -55,20 +96,22 @@ func SearchKnowledge(query string) ([]SearchResult, error) {
|
||||
return
|
||||
}
|
||||
|
||||
// 阶段 2:降级兜底
|
||||
isFallback := len(rows) == 0
|
||||
if isFallback {
|
||||
log.Printf("[Search] No match for %q, returning fallback", query)
|
||||
log.Printf("[Search] 关键词 %q 无匹配,启用降级兜底", query)
|
||||
db.Order("updated_at DESC").Limit(3).Find(&rows)
|
||||
}
|
||||
|
||||
// 将数据库模型转换为前端 DTO,计算匹配分
|
||||
out := make([]SearchResult, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
score := 0
|
||||
if !isFallback {
|
||||
if containsIgnoreCase(r.Keyword, query) {
|
||||
score = 2
|
||||
score = 2 // 关键词精准命中,优先级最高
|
||||
} else if containsIgnoreCase(r.Question, query) {
|
||||
score = 1
|
||||
score = 1 // 问题文本命中,次优
|
||||
}
|
||||
}
|
||||
out = append(out, SearchResult{
|
||||
@@ -83,6 +126,11 @@ func SearchKnowledge(query string) ([]SearchResult, error) {
|
||||
return r.rows, r.err
|
||||
}
|
||||
|
||||
// containsIgnoreCase 检查字符串 s 是否包含子串 sub(忽略 ASCII 大小写)。
|
||||
//
|
||||
// 使用手写 rune 循环而非 strings.ToLower + strings.Contains 的原因:
|
||||
// 避免为每次比较创建两个临时字符串,对频繁调用的搜索路径更节省内存。
|
||||
// 注意:仅 A-Z 做大小写转换,中文字符原样比较(中文无大小写概念)。
|
||||
func containsIgnoreCase(s, sub string) bool {
|
||||
if len(sub) == 0 {
|
||||
return true
|
||||
@@ -106,6 +154,7 @@ func containsIgnoreCase(s, sub string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// toLower 将 ASCII 大写字母转为小写,其余字符不变。
|
||||
func toLower(r rune) rune {
|
||||
if r >= 'A' && r <= 'Z' {
|
||||
return r + 32
|
||||
|
||||
Reference in New Issue
Block a user