Files
2026-04-01 15:29:35 +08:00

164 lines
5.4 KiB
Go

// Package service 提供知识库的搜索逻辑。
// 搜索是本项目最核心的功能,此文件实现了"关键词模糊匹配 + 降级兜底"策略。
package service
import (
"errors"
"log"
"AI-Expert-Sidebar/internal/database"
"AI-Expert-Sidebar/internal/models"
)
// maxResults 限制每次搜索最多返回的条目数。
// 侧边栏 UI 空间有限,超过 5 条会让用户滚动,体验下降;
// 同时限制条数也控制了传给 AI 的 Context 长度,避免超出 Token 限制。
const maxResults = 5
// ErrDBUnavailable 是数据库未就绪时返回的哨兵错误。
// 前端可以检查此错误来决定是否显示"数据库初始化中"提示,
// 而不是和其他内部错误混为一谈。
var ErrDBUnavailable = errors.New("database unavailable")
// 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 表示匹配精度:2=关键词命中, 1=问题文本命中, 0=降级兜底。
// 前端用此值决定显示 "⚡精准匹配" 还是 "🔥热门推荐" 徽章。
Score int `json:"score"` // 2=keyword, 1=question, 0=fallback
// IsFallback 为 true 时,表示搜索无命中,结果是随机推荐的热门问答。
// 前端据此显示提示横幅,避免用户误以为这是精准答案。
IsFallback bool `json:"is_fallback"`
}
// 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 {
return nil, ErrDBUnavailable
}
if len(query) < 1 {
return nil, nil
}
// 用 channel + goroutine 封装查询,使 handler 层可以安全地配合 context 取消
type res struct {
rows []SearchResult
err error
}
ch := make(chan res, 1)
go func() {
// 检查库是否为空;空库时直接返回,避免无意义的 LIKE 查询
var total int64
db.Model(&models.Entry{}).Count(&total)
if total == 0 {
ch <- res{[]SearchResult{}, nil}
return
}
like := "%" + query + "%"
var rows []models.Entry
err := db.Where("keyword LIKE ? OR question LIKE ?", like, like).
Order("updated_at DESC").Limit(maxResults).Find(&rows).Error
if err != nil {
ch <- res{nil, err}
return
}
// 阶段 2:降级兜底
isFallback := len(rows) == 0
if isFallback {
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 // 关键词精准命中,优先级最高
} else if containsIgnoreCase(r.Question, query) {
score = 1 // 问题文本命中,次优
}
}
out = append(out, SearchResult{
ID: r.ID, Question: r.Question, Answer: r.Answer,
Category: r.Category, Score: score, IsFallback: isFallback,
})
}
ch <- res{out, nil}
}()
r := <-ch
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
}
sl, subl := []rune(s), []rune(sub)
if len(sl) < len(subl) {
return false
}
for i := 0; i <= len(sl)-len(subl); i++ {
match := true
for j, c := range subl {
if toLower(sl[i+j]) != toLower(c) {
match = false
break
}
}
if match {
return true
}
}
return false
}
// toLower 将 ASCII 大写字母转为小写,其余字符不变。
func toLower(r rune) rune {
if r >= 'A' && r <= 'Z' {
return r + 32
}
return r
}