164 lines
5.4 KiB
Go
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
|
|
}
|