项目记录

缓存、知识库版本与数据隔离

设计两级缓存、版本状态机与租户隔离,避免重复计算、旧数据污染和跨域检索。

发布于
本文目录 · 8 节

一次问答要烧掉多少钱?把链路拆开看,成本高得不像话:

Embedding 要算、Milvus 要查两路、CrossEncoder 要重排、LLM 要生成, 外加每次都可能重新编码同一段文本。而企业内部的提问,重复率其实非常高—— 「年假怎么申请」这种问题,一个季度可能被问几百遍。

先给结论

这一层要解决四个问题,每个都对应一个具体故障:

text
重复计算太多   -> 两级缓存(L1 进程 + L2 Redis)
更新后串数据   -> namespace epoch 失效 + key 绑定版本
新旧制度打架   -> 知识库版本状态机 + 引用式增量
谁都不能看谁的 -> 数据域四要素隔离
出了问题查不到 -> LangSmith Trace + 运行状态接口

一、两级缓存:L1 只放元数据,重数据放 Redis

很多人第一反应是把检索结果缓存在进程内存里。这套项目的选择正好相反:

text
L1(进程内存) 只存 namespace epoch 这类低频元数据,TTL 3 秒
L2(Redis)    存检索结果和 embedding 向量

理由很实在:检索结果体积大,放进程内存会随着并发和版本数膨胀,最终 OOM; 而 Redis 有独立的内存管理和淘汰策略。L1 只承担「高频读取、极小体积」的职责。

TTL 也是分层的:

text
CACHE_FAQ_TTL_SECONDS       = 1800   # FAQ 结果变慢,30 分钟
CACHE_DOC_TTL_SECONDS       = 900    # 文档检索 15 分钟
CACHE_EMBEDDING_TTL_SECONDS = 3600   # embedding 最贵,缓存最久

二、失效:推进 epoch,而不是删 key

知识库更新之后,旧缓存必须失效。常规做法是扫描删除全部相关 key—— 在 Redis 里这是一次高成本、可能阻塞的操作,而且很容易删漏。

这里的做法是 namespace epoch:缓存 key 里带一个 epoch 值,发布新版本时只把 epoch 推进一位, 旧 key 自然不再被访问,随 TTL 过期。

text
旧 key:  kf:single:<epoch=7>:retrieval:...
新版本:  epoch -> 8
结果:    新请求全部落到新 key 空间,旧 key 靠 TTL 自然回收

还有一个细节:缓存 key 绑定的是知识库版本、模型版本、数据域和配置版本, 而不是只绑定 query。任何一项变了,缓存自然不命中——这挡住了 「换了 embedding 模型却在读旧向量缓存」这种极难排查的事故。

三、知识库版本:STAGED → ACTIVE → ARCHIVED

内部制度更新不能直接覆盖线上库。版本状态机保证更新是「先构建、再验收、最后切换」:

text
STAGED    新版本已构建完成,尚未对外
ACTIVE    当前对外服务的版本(同一时刻只有一个)
ARCHIVED  历史版本,保留用于对比和回溯

版本号本身带时间戳和配置 hash,所以「哪次构建、用了什么配置」是可追溯的。 解析优先级是三层:

text
请求参数 > 环境变量 > MySQL active 指针

引用式增量版本还会分配单调递增的 version_seq,配合 Milvus 的有效期视图做过滤—— 新版本只写新增内容,历史 chunk 通过版本范围复用,不必每次全量重建向量。

四、隔离:四个字段决定谁能看什么

text
tenant_id     租户
dataset_id    数据集
visibility    可见范围
allowed_roles 允许的角色

这四要素同时作用于两处:检索前的过滤下推,和缓存 key 的组成部分。 只在检索时过滤是危险的——缓存如果按 query 命中,A 租户可能读到 B 租户的结果。

五、观测:知道它为什么慢

LangSmith 是可选的(LANGSMITH_TRACING),打开后每次问答会生成一条 Trace, 把意图、检索、重排、生成各段的耗时和输入输出串起来。 排查「为什么这个问题答错了」时,Trace 比日志有效得多,因为你能看到当时的检索结果, 而不是靠日志里的摘要猜。

另外有运行状态接口:/api/admin/cache/status 看缓存命中与 namespace 状态, /api/kb_versions 看版本列表。这两个接口的价值在于——线上出问题时不靠猜。

动手验证

powershell
# 看版本状态机:创建、对比、激活
python scripts/kb/manage_kb_versions.py
python scripts/kb/compare_kb_versions.py

# 缓存链路验收:命中率、失效是否符合预期
python scripts/quality/cache_acceptance_smoke.py

一个直观实验:连续问同一个问题两次,对比 /api/admin/cache/status 的命中计数; 然后重建一次知识库版本,再问一次,观察命中率回落——说明 epoch 失效生效了。

取舍与边界

  • epoch 是懒失效:不会立刻释放内存,旧 key 要等 TTL 才回收。 频繁发布版本时,要盯着 Redis 内存水位。
  • 缓存会掩盖问题:改了检索参数但缓存还热着,你会以为改动没生效。 调参时记得手动清一次缓存。
  • 版本切换不是瞬时一致:引用式版本靠视图过滤, 切换期间新旧版本可能同时在内存中,需要接受一个很短的过渡窗口。

下一篇是收尾:怎么用工程手段证明「这次改动没有把系统改坏」。