Files
xk-ai-agent/internal/kb/alias.go
2026-08-14 21:50:48 +08:00

160 lines
5.4 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package kb
import (
"encoding/json"
"log"
"sort"
"strings"
"sync"
"time"
"tcm-agent/internal/dao"
)
// ========================================================================
// AliasIndex —— 药材"别名 → 正名"归一索引P1 检索质量)
// ========================================================================
// 数据来源爬取的药材文档xk_kb_doc.source_type='crawl'
// - title = 正名(如"山药"
// - meta_json.aliases = 别名列表(如["淮山药","土薯","怀山药"]
//
// 用途enhance 链路检索前,把查询词中出现的别名**追加**正名到查询尾部
// 不做原地替换——ngram OR 语义下追加更安全:原词与正名都参与匹配,
// 召回只增不减;替换有把长句里的短词误换、破坏语境的风险)。
//
// 数据定位(与业务确认过的铁律):爬取数据只做辅助参照(别名归一/安全校验),
// 不作为药品来源——本索引只影响"检索召回",不影响 AI 可开什么药。
//
// 刷新策略:进程内缓存 + TTL10 分钟。899 味药 + 别名约几千条,
// 全量加载 <10ms无需增量抓取任务更新别名后最迟 10 分钟生效。
// ========================================================================
// aliasTTL 别名索引缓存有效期
const aliasTTL = 10 * time.Minute
// maxAliasAppends 单次查询最多追加的正名个数
//
// 长病历上下文可能命中十几个别名,全部追加会稀释原始查询词权重,
// 取命中顺序前 8 个足够覆盖一份处方的核心药材
const maxAliasAppends = 8
// AliasIndex 别名归一索引(并发安全)
type AliasIndex struct {
mu sync.RWMutex
aliasMap map[string]string // 别名 → 正名
aliases []string // 别名列表(按长度降序,先匹配长别名避免子串误命中)
loadedAt time.Time
}
// globalAliasIndex 进程级单例enhance 是高频路径,共享一份索引
var globalAliasIndex = &AliasIndex{}
// GetAliasIndex 取全局别名索引(自动按 TTL 惰性加载/刷新)
func GetAliasIndex() *AliasIndex {
globalAliasIndex.ensureFresh()
return globalAliasIndex
}
// ensureFresh TTL 过期则重新加载(双重检查锁,避免并发重复加载)
func (a *AliasIndex) ensureFresh() {
a.mu.RLock()
fresh := time.Since(a.loadedAt) < aliasTTL && a.aliasMap != nil
a.mu.RUnlock()
if fresh {
return
}
a.mu.Lock()
defer a.mu.Unlock()
if time.Since(a.loadedAt) < aliasTTL && a.aliasMap != nil {
return
}
a.loadLocked()
}
// loadLocked 从爬取文档构建"别名→正名"映射(调用方必须已持写锁)
//
// 冲突处理同一别名指向多个正名时保留先加载的zhongyoo 数据中极少见,
// 且检索场景下任选其一都能召回相近语料,不值得为此做复杂消歧)
func (a *AliasIndex) loadLocked() {
rows, err := dao.KBListCrawlDocAliases()
if err != nil {
// 加载失败保留旧索引可用DB 抖动时检索退化为无别名扩展,不阻断)
log.Printf("[AliasIndex] ⚠️ 加载别名映射失败(沿用旧索引): %v", err)
if a.aliasMap == nil {
a.aliasMap = map[string]string{}
}
a.loadedAt = time.Now()
return
}
aliasMap := make(map[string]string, len(rows)*4)
for _, row := range rows {
canonical := strings.TrimSpace(row.Title)
if canonical == "" || row.MetaJSON == nil {
continue
}
var meta struct {
Aliases []string `json:"aliases"`
}
if err := json.Unmarshal([]byte(*row.MetaJSON), &meta); err != nil {
continue
}
for _, alias := range meta.Aliases {
alias = strings.TrimSpace(alias)
// 过滤:空串、与正名相同、单字(单字别名误命中率太高,如"术"
if alias == "" || alias == canonical || len([]rune(alias)) < 2 {
continue
}
if _, exists := aliasMap[alias]; !exists {
aliasMap[alias] = canonical
}
}
}
aliases := make([]string, 0, len(aliasMap))
for alias := range aliasMap {
aliases = append(aliases, alias)
}
// 长别名优先匹配:如"淮山药"先于"淮山",避免同前缀重复追加
sort.Slice(aliases, func(i, j int) bool { return len(aliases[i]) > len(aliases[j]) })
a.aliasMap = aliasMap
a.aliases = aliases
a.loadedAt = time.Now()
log.Printf("[AliasIndex] 别名索引加载完成:%d 个别名 ← %d 篇爬取文档", len(aliasMap), len(rows))
}
// ExpandQuery 别名归一扩展:查询中出现的别名,把对应正名追加到查询尾部
//
// 返回:扩展后的查询 + 实际追加的正名列表(供 kb_retrieval 步骤 detail 记录,
// 面板上能看到"这次检索做了哪些别名扩展",排障直观)
func (a *AliasIndex) ExpandQuery(query string) (string, []string) {
if query == "" {
return query, nil
}
a.mu.RLock()
defer a.mu.RUnlock()
if len(a.aliases) == 0 {
return query, nil
}
appended := make([]string, 0, 4)
seen := map[string]bool{}
for _, alias := range a.aliases {
if len(appended) >= maxAliasAppends {
break
}
if !strings.Contains(query, alias) {
continue
}
canonical := a.aliasMap[alias]
// 正名已在查询里(或已追加过)就不重复追加
if canonical == "" || seen[canonical] || strings.Contains(query, canonical) {
continue
}
seen[canonical] = true
appended = append(appended, canonical)
}
if len(appended) == 0 {
return query, nil
}
// 空格分隔追加ngram FULLTEXT 的自然语言/BOOLEAN 模式都按词处理
return query + " " + strings.Join(appended, " "), appended
}