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 可开什么药。 // // 刷新策略:进程内缓存 + TTL(10 分钟)。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 }