[{"data":1,"prerenderedAt":288},["ShallowReactive",2],{"note:\u002Fprojects\u002Frag-knowledge-base\u002F07-缓存版本与隔离":3},{"id":4,"title":5,"body":6,"date":267,"description":268,"draft":269,"extension":270,"featured":269,"kind":271,"lastmod":267,"meta":272,"navigation":205,"path":276,"planned":277,"section":278,"seo":279,"source":280,"stem":281,"tags":282,"toc":205,"weight":286,"__hash__":287},"notes\u002Fprojects\u002Frag-knowledge-base\u002F07-缓存版本与隔离.md","缓存、知识库版本与数据隔离",{"type":7,"value":8,"toc":257},"minimark",[9,13,16,20,23,34,38,41,47,50,53,59,63,66,74,80,87,91,94,100,103,109,120,124,130,141,145,156,170,173,219,225,228,250,253],[10,11,12],"p",{},"一次问答要烧掉多少钱？把链路拆开看，成本高得不像话：",[10,14,15],{},"Embedding 要算、Milvus 要查两路、CrossEncoder 要重排、LLM 要生成，\n外加每次都可能重新编码同一段文本。而企业内部的提问，重复率其实非常高——\n「年假怎么申请」这种问题，一个季度可能被问几百遍。",[17,18,19],"h2",{"id":19},"先给结论",[10,21,22],{},"这一层要解决四个问题，每个都对应一个具体故障：",[24,25,31],"pre",{"className":26,"code":28,"language":29,"meta":30},[27],"language-text","重复计算太多   -> 两级缓存（L1 进程 + L2 Redis）\n更新后串数据   -> namespace epoch 失效 + key 绑定版本\n新旧制度打架   -> 知识库版本状态机 + 引用式增量\n谁都不能看谁的 -> 数据域四要素隔离\n出了问题查不到 -> LangSmith Trace + 运行状态接口\n","text","",[32,33,28],"code",{"__ignoreMap":30},[17,35,37],{"id":36},"一两级缓存l1-只放元数据重数据放-redis","一、两级缓存：L1 只放元数据，重数据放 Redis",[10,39,40],{},"很多人第一反应是把检索结果缓存在进程内存里。这套项目的选择正好相反：",[24,42,45],{"className":43,"code":44,"language":29,"meta":30},[27],"L1（进程内存） 只存 namespace epoch 这类低频元数据，TTL 3 秒\nL2（Redis）    存检索结果和 embedding 向量\n",[32,46,44],{"__ignoreMap":30},[10,48,49],{},"理由很实在：检索结果体积大，放进程内存会随着并发和版本数膨胀，最终 OOM；\n而 Redis 有独立的内存管理和淘汰策略。L1 只承担「高频读取、极小体积」的职责。",[10,51,52],{},"TTL 也是分层的：",[24,54,57],{"className":55,"code":56,"language":29,"meta":30},[27],"CACHE_FAQ_TTL_SECONDS       = 1800   # FAQ 结果变慢，30 分钟\nCACHE_DOC_TTL_SECONDS       = 900    # 文档检索 15 分钟\nCACHE_EMBEDDING_TTL_SECONDS = 3600   # embedding 最贵，缓存最久\n",[32,58,56],{"__ignoreMap":30},[17,60,62],{"id":61},"二失效推进-epoch而不是删-key","二、失效：推进 epoch，而不是删 key",[10,64,65],{},"知识库更新之后，旧缓存必须失效。常规做法是扫描删除全部相关 key——\n在 Redis 里这是一次高成本、可能阻塞的操作，而且很容易删漏。",[10,67,68,69,73],{},"这里的做法是 ",[70,71,72],"strong",{},"namespace epoch","：缓存 key 里带一个 epoch 值，发布新版本时只把 epoch 推进一位，\n旧 key 自然不再被访问，随 TTL 过期。",[24,75,78],{"className":76,"code":77,"language":29,"meta":30},[27],"旧 key:  kf:single:\u003Cepoch=7>:retrieval:...\n新版本:  epoch -> 8\n结果:    新请求全部落到新 key 空间，旧 key 靠 TTL 自然回收\n",[32,79,77],{"__ignoreMap":30},[10,81,82,83,86],{},"还有一个细节：",[70,84,85],{},"缓存 key 绑定的是知识库版本、模型版本、数据域和配置版本","，\n而不是只绑定 query。任何一项变了，缓存自然不命中——这挡住了\n「换了 embedding 模型却在读旧向量缓存」这种极难排查的事故。",[17,88,90],{"id":89},"三知识库版本staged-active-archived","三、知识库版本：STAGED → ACTIVE → ARCHIVED",[10,92,93],{},"内部制度更新不能直接覆盖线上库。版本状态机保证更新是「先构建、再验收、最后切换」：",[24,95,98],{"className":96,"code":97,"language":29,"meta":30},[27],"STAGED    新版本已构建完成，尚未对外\nACTIVE    当前对外服务的版本（同一时刻只有一个）\nARCHIVED  历史版本，保留用于对比和回溯\n",[32,99,97],{"__ignoreMap":30},[10,101,102],{},"版本号本身带时间戳和配置 hash，所以「哪次构建、用了什么配置」是可追溯的。\n解析优先级是三层：",[24,104,107],{"className":105,"code":106,"language":29,"meta":30},[27],"请求参数 > 环境变量 > MySQL active 指针\n",[32,108,106],{"__ignoreMap":30},[10,110,111,112,115,116,119],{},"引用式增量版本还会分配单调递增的 ",[32,113,114],{},"version_seq","，配合 Milvus 的有效期视图做过滤——\n",[70,117,118],{},"新版本只写新增内容，历史 chunk 通过版本范围复用","，不必每次全量重建向量。",[17,121,123],{"id":122},"四隔离四个字段决定谁能看什么","四、隔离：四个字段决定谁能看什么",[24,125,128],{"className":126,"code":127,"language":29,"meta":30},[27],"tenant_id     租户\ndataset_id    数据集\nvisibility    可见范围\nallowed_roles 允许的角色\n",[32,129,127],{"__ignoreMap":30},[10,131,132,133,136,137,140],{},"这四要素同时作用于两处：",[70,134,135],{},"检索前的过滤下推","，和",[70,138,139],{},"缓存 key 的组成部分","。\n只在检索时过滤是危险的——缓存如果按 query 命中，A 租户可能读到 B 租户的结果。",[17,142,144],{"id":143},"五观测知道它为什么慢","五、观测：知道它为什么慢",[10,146,147,148,151,152,155],{},"LangSmith 是可选的（",[32,149,150],{},"LANGSMITH_TRACING","），打开后每次问答会生成一条 Trace，\n把意图、检索、重排、生成各段的耗时和输入输出串起来。\n排查「为什么这个问题答错了」时，Trace 比日志有效得多，因为你能看到",[70,153,154],{},"当时","的检索结果，\n而不是靠日志里的摘要猜。",[10,157,158,159,162,163,166,167],{},"另外有运行状态接口：",[32,160,161],{},"\u002Fapi\u002Fadmin\u002Fcache\u002Fstatus"," 看缓存命中与 namespace 状态，\n",[32,164,165],{},"\u002Fapi\u002Fkb_versions"," 看版本列表。这两个接口的价值在于——",[70,168,169],{},"线上出问题时不靠猜。",[17,171,172],{"id":172},"动手验证",[24,174,178],{"className":175,"code":176,"language":177,"meta":30,"style":30},"language-powershell shiki shiki-themes github-light github-dark","# 看版本状态机：创建、对比、激活\npython scripts\u002Fkb\u002Fmanage_kb_versions.py\npython scripts\u002Fkb\u002Fcompare_kb_versions.py\n\n# 缓存链路验收：命中率、失效是否符合预期\npython scripts\u002Fquality\u002Fcache_acceptance_smoke.py\n","powershell",[32,179,180,188,194,200,207,213],{"__ignoreMap":30},[181,182,185],"span",{"class":183,"line":184},"line",1,[181,186,187],{},"# 看版本状态机：创建、对比、激活\n",[181,189,191],{"class":183,"line":190},2,[181,192,193],{},"python scripts\u002Fkb\u002Fmanage_kb_versions.py\n",[181,195,197],{"class":183,"line":196},3,[181,198,199],{},"python scripts\u002Fkb\u002Fcompare_kb_versions.py\n",[181,201,203],{"class":183,"line":202},4,[181,204,206],{"emptyLinePlaceholder":205},true,"\n",[181,208,210],{"class":183,"line":209},5,[181,211,212],{},"# 缓存链路验收：命中率、失效是否符合预期\n",[181,214,216],{"class":183,"line":215},6,[181,217,218],{},"python scripts\u002Fquality\u002Fcache_acceptance_smoke.py\n",[10,220,221,222,224],{},"一个直观实验：连续问同一个问题两次，对比 ",[32,223,161],{}," 的命中计数；\n然后重建一次知识库版本，再问一次，观察命中率回落——说明 epoch 失效生效了。",[17,226,227],{"id":227},"取舍与边界",[229,230,231,238,244],"ul",{},[232,233,234,237],"li",{},[70,235,236],{},"epoch 是懒失效","：不会立刻释放内存，旧 key 要等 TTL 才回收。\n频繁发布版本时，要盯着 Redis 内存水位。",[232,239,240,243],{},[70,241,242],{},"缓存会掩盖问题","：改了检索参数但缓存还热着，你会以为改动没生效。\n调参时记得手动清一次缓存。",[232,245,246,249],{},[70,247,248],{},"版本切换不是瞬时一致","：引用式版本靠视图过滤，\n切换期间新旧版本可能同时在内存中，需要接受一个很短的过渡窗口。",[10,251,252],{},"下一篇是收尾：怎么用工程手段证明「这次改动没有把系统改坏」。",[254,255,256],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":30,"searchDepth":190,"depth":190,"links":258},[259,260,261,262,263,264,265,266],{"id":19,"depth":190,"text":19},{"id":36,"depth":190,"text":37},{"id":61,"depth":190,"text":62},{"id":89,"depth":190,"text":90},{"id":122,"depth":190,"text":123},{"id":143,"depth":190,"text":144},{"id":172,"depth":190,"text":172},{"id":227,"depth":190,"text":227},"2026-07-10","设计两级缓存、版本状态机与租户隔离，避免重复计算、旧数据污染和跨域检索。",false,"md","project-chapter",{"migration":273},{"generator":274,"sourceSha256":275},"nuxt-site\u002Fscripts\u002Fmigrate-content.mjs","5b27901a9ff782d6e25253b0d7c6722b5596cc1d620109846e52614c1f5bf166","\u002Fprojects\u002Frag-knowledge-base\u002F07-缓存版本与隔离",null,"projects",{"title":5,"description":268},"content\u002Fprojects\u002Frag-knowledge-base\u002F07-缓存版本与隔离.md","projects\u002Frag-knowledge-base\u002F07-缓存版本与隔离",[283,284,285],"RAG","Redis","MySQL",37,"Dvzy8VnSm4HgMyamImirdi6tyspPhiYB-yqa9lfXAgc",1791279145434]