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