项目记录
缓存、知识库版本与数据隔离
设计两级缓存、版本状态机与租户隔离,避免重复计算、旧数据污染和跨域检索。
本文目录 · 8 节
一次问答要烧掉多少钱?把链路拆开看,成本高得不像话:
Embedding 要算、Milvus 要查两路、CrossEncoder 要重排、LLM 要生成, 外加每次都可能重新编码同一段文本。而企业内部的提问,重复率其实非常高—— 「年假怎么申请」这种问题,一个季度可能被问几百遍。
先给结论
这一层要解决四个问题,每个都对应一个具体故障:
重复计算太多 -> 两级缓存(L1 进程 + L2 Redis)
更新后串数据 -> namespace epoch 失效 + key 绑定版本
新旧制度打架 -> 知识库版本状态机 + 引用式增量
谁都不能看谁的 -> 数据域四要素隔离
出了问题查不到 -> LangSmith Trace + 运行状态接口
一、两级缓存:L1 只放元数据,重数据放 Redis
很多人第一反应是把检索结果缓存在进程内存里。这套项目的选择正好相反:
L1(进程内存) 只存 namespace epoch 这类低频元数据,TTL 3 秒
L2(Redis) 存检索结果和 embedding 向量
理由很实在:检索结果体积大,放进程内存会随着并发和版本数膨胀,最终 OOM; 而 Redis 有独立的内存管理和淘汰策略。L1 只承担「高频读取、极小体积」的职责。
TTL 也是分层的:
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 过期。
旧 key: kf:single:<epoch=7>:retrieval:...
新版本: epoch -> 8
结果: 新请求全部落到新 key 空间,旧 key 靠 TTL 自然回收
还有一个细节:缓存 key 绑定的是知识库版本、模型版本、数据域和配置版本, 而不是只绑定 query。任何一项变了,缓存自然不命中——这挡住了 「换了 embedding 模型却在读旧向量缓存」这种极难排查的事故。
三、知识库版本:STAGED → ACTIVE → ARCHIVED
内部制度更新不能直接覆盖线上库。版本状态机保证更新是「先构建、再验收、最后切换」:
STAGED 新版本已构建完成,尚未对外
ACTIVE 当前对外服务的版本(同一时刻只有一个)
ARCHIVED 历史版本,保留用于对比和回溯
版本号本身带时间戳和配置 hash,所以「哪次构建、用了什么配置」是可追溯的。 解析优先级是三层:
请求参数 > 环境变量 > MySQL active 指针
引用式增量版本还会分配单调递增的 version_seq,配合 Milvus 的有效期视图做过滤——
新版本只写新增内容,历史 chunk 通过版本范围复用,不必每次全量重建向量。
四、隔离:四个字段决定谁能看什么
tenant_id 租户
dataset_id 数据集
visibility 可见范围
allowed_roles 允许的角色
这四要素同时作用于两处:检索前的过滤下推,和缓存 key 的组成部分。 只在检索时过滤是危险的——缓存如果按 query 命中,A 租户可能读到 B 租户的结果。
五、观测:知道它为什么慢
LangSmith 是可选的(LANGSMITH_TRACING),打开后每次问答会生成一条 Trace,
把意图、检索、重排、生成各段的耗时和输入输出串起来。
排查「为什么这个问题答错了」时,Trace 比日志有效得多,因为你能看到当时的检索结果,
而不是靠日志里的摘要猜。
另外有运行状态接口:/api/admin/cache/status 看缓存命中与 namespace 状态,
/api/kb_versions 看版本列表。这两个接口的价值在于——线上出问题时不靠猜。
动手验证
# 看版本状态机:创建、对比、激活
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 内存水位。
- 缓存会掩盖问题:改了检索参数但缓存还热着,你会以为改动没生效。 调参时记得手动清一次缓存。
- 版本切换不是瞬时一致:引用式版本靠视图过滤, 切换期间新旧版本可能同时在内存中,需要接受一个很短的过渡窗口。
下一篇是收尾:怎么用工程手段证明「这次改动没有把系统改坏」。