// 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 }