Going to Production: Incremental Sync and Deduplication, Permission-Based Filtering, Cache Layering, Tracing, and the Cost-Latency Ledger
One stretch remains before handing the system to real users: documents change, people have different permissions, repeated questions shouldn't be recomputed every time, and when something breaks you need to find which step was slow and expensive. Today finish all four in one pass.
今日目标
- 能设计一套基于内容指纹的增量同步方案,做到文档改动后只重算受影响的块
- 能把访问控制做进检索本身,并说明为什么在生成阶段才过滤是错的
- 能给整条链路加上追踪与成本计量,定位出延迟和花费各自集中在哪一步
前十二天你把这套系统做得越来越准,但它一直活在一个假设里:语料是静止的、所有人看到的是同一份、每个问题都从头算一遍。今天把这三个假设全部拆掉。读完并做完实验之后,回到页面顶部把三条目标逐一勾掉。
小白版讲解
馆藏是会变的,而重新编一次目录太贵
图书馆的卡片目录不是编完就完事的。每周都有新书上架、有书被借丢下架、有书出了修订版换掉旧版。如果每次变动都把全馆重编一次目录,图书管理员这辈子就只干这一件事了。真实的做法是盘点:拿一份现在的馆藏清单,跟目录柜里的卡片逐条对账,只动对不上的那几张。
索引也一样。你的语料来自网盘、工单系统、内部维基,它们每天都在变。"每晚定时全量重建"这个方案在三十篇文档时毫无问题,在三万篇时会变成一笔每天都要付的巨款——向量化是按 token 收费的,全量重建就是每天把整个知识库重新买一遍。更糟的是重建期间索引处于半新半旧的状态,用户这时候提问,拿到的结果是不可复现的。
对账要处理三种变更,一种都不能少:来源里有、索引里没有的是新增;两边都有但内容不一样的是修改;索引里有、来源里已经没有的是删除。写出来只有十几行,关键是这三条分支必须都在:
// 三向对账:拿来源的全集和索引的全集比,只动对不上的那部分
export function planSync(indexed, incoming) {
const plan = { added: [], modified: [], unchanged: [], deleted: [] }
for (const doc of incoming) {
const known = indexed.get(doc.id)
if (!known) plan.added.push(doc.id)
else if (known.contentHash !== contentHash(doc.raw)) plan.modified.push(doc.id)
else plan.unchanged.push(doc.id)
}
// 删除必须反过来遍历索引:来源里根本不会出现"这篇没了"这条记录
const incomingIds = new Set(incoming.map((d) => d.id))
for (const docId of indexed.keys()) {
if (!incomingIds.has(docId)) plan.deleted.push(docId)
}
return plan
}# 三向对账:拿来源的全集和索引的全集比,只动对不上的那部分
def plan_sync(indexed: dict[str, DocRow], incoming: list[Doc]) -> SyncPlan:
plan = SyncPlan(added=[], modified=[], unchanged=[], deleted=[])
for doc in incoming:
known = indexed.get(doc.id)
if known is None:
plan.added.append(doc.id)
elif known.content_hash != content_hash(doc.raw):
plan.modified.append(doc.id)
else:
plan.unchanged.append(doc.id)
# 删除必须反过来遍历索引:来源里根本不会出现"这篇没了"这条记录
incoming_ids = {d.id for d in incoming}
plan.deleted = [doc_id for doc_id in indexed if doc_id not in incoming_ids]
return plan在今天的实验里,这次对账认出了新增一篇、修改一篇、删除一篇,最后只向量化了 6 块;而全量重建要向量化 151 块。同一份语料,账单差了二十多倍。
内容指纹:一个数字回答"这篇要不要重算"
对账的每一行都要回答"内容变了没有"。逐字比对原文太慢也太占地方,通用做法是给每篇文档算一个内容指纹(content hash):把整篇原文喂给 sha256,取前 16 位。内容改一个字,指纹就完全不同;一个字没改,指纹就一模一样。
这里有个陷阱,第三天已经替你踩过了:算指纹之前必须先做换行归一化。同一份文件,同事从 Windows 上传一次、你从 Mac 上传一次,字节流是不同的(一个用 \r\n,一个用 \n),但内容完全相同。不归一化的话,每次跨系统重传都会被判成"变了",白白触发一次重算。这个 bug 最难受的地方在于它不报错,只是让你的账单看起来像在做全量重建:
import { createHash } from 'node:crypto'
// 归一化要在算指纹之前做,不是在比较之后补救
export function contentHash(raw) {
const normalized = raw.replace(/\r\n/g, '\n').trim()
return createHash('sha256').update(normalized, 'utf8').digest('hex').slice(0, 16)
}import hashlib
# 归一化要在算指纹之前做,不是在比较之后补救
def content_hash(raw: str) -> str:
normalized = raw.replace("\r\n", "\n").strip()
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:16]指纹还顺手解决了去重。同一份《新版发布说明》被产品经理发在维基、又被客服另存进工单附件、再被谁转成 PDF 放进网盘,你的知识库里就有了三份内容相同的文档。它们会一起挤进检索结果,把三个宝贵的名额占掉两个。做法是:变更检测的指纹算在整份文件上(改了标题或部门标签也要重算),去重的指纹算在正文上(剥掉文件名、路径这类跟来源绑定的元信息,才认得出"同一份内容的另一个副本")。同一个函数、两种输入范围,这个区分是刻意的——用整份文件去重,重复件永远认不出来;用正文做变更检测,改了部门标签却不会触发重算。
权限要压进检索,而不是等生成时再筛
想象一个查资料的场景:你走进档案室,管理员先把全部档案摊在桌上让你挑,你挑完九份,他再从里面抽走三份说"这些你不能看"。问题是——你已经看见它们的标题了。
在生成阶段才过滤权限就是这个动作。哪怕最终答案里没有那三篇的内容,越权的文档也已经进过检索、参与过排序、被程序读进过内存、大概率还写进了日志。更现实的后果是结果被稀释:你取前 8 条,其中 3 条是这个用户不能看的,筛掉之后他只剩 5 条,而排在第 9、10 位的合法结果本来该补上来的——用户会觉得系统"查不到东西",而你在日志里看到的是一次正常的检索。
正确的做法只有一句话:把权限条件和排序、LIMIT 写进同一条查询。数据库先按谓词裁掉行,再排序取前 k,越权的行一次都没被比较过:
SELECT c.chunk_id, c.text, d.department,
e.embedding <=> $1::vector AS distance
FROM chunk_embeddings e
JOIN chunks c ON c.chunk_id = e.chunk_id
JOIN documents d ON d.doc_id = c.doc_id
WHERE d.department = ANY($2) -- 权限谓词和排序在同一条语句里
ORDER BY e.embedding <=> $1::vector, c.chunk_id
LIMIT 8这叫行级过滤:一份索引,加一个谓词。它的代价是为了凑出这 26 行合法数据,整张表都得先过一遍谓词。另一种做法是索引隔离:按部门(或按租户)各建各的索引,检索时直接打到对应那一份。今天的实验把两者并排跑了一遍:前 8 名完全一致,但行级过滤"碰过 149 行",索引隔离只"碰过 26 行"。
怎么选?看隔离边界的数量和稳定性。部门只有四个、边界几乎不变,隔离是划算的;如果是几万个用户各自的私有文档,建几万份索引会把运维压垮,那就只能行级过滤。还有一条容易忽略的理由:共用一份近似最近邻索引时,数据量大的那个租户会实实在在拖慢别人的检索质量——这一点第五天已经讲过。
缓存分三层,各自的失效条件完全不同
一样的问题不该每次都重算。但"缓存"在 RAG 里不是一件事,是三件事,而它们的寿命差着数量级:
| 层 | 缓存什么 | key 里必须有什么 | 什么时候失效 |
|---|---|---|---|
| L1 答案 | 问题到最终答案 | 问题、权限范围、索引版本、模型与提示词版本 | 上面任何一样变了 |
| L2 检索 | 检索式到命中块列表 | 问题、权限范围、topK、索引版本、向量后端 | 索引变了、参数变了 |
| L3 向量 | 文本到向量 | 文本、向量后端 | 只有换模型才失效 |
看出规律了吗?"什么时候必须失效"这个问题,等价于"key 里有没有把那样东西算进去"。 key 少放一样,那样东西变了缓存就不会失效。
今天实验里最值得看的就是这个错误用例。第一版的答案缓存 key 只有问题本身——几乎所有人第一次都会这么写,因为"同一个问题当然是同一个答案"听起来天经地义。然后增量同步把 doc-006 的单文件上限从 200 MB 改成了 500 MB,索引已经更新,再问同一个问题,系统笑呵呵地从缓存里掏出那句 200 MB 还给你。它没有报错,日志上是一次漂亮的缓存命中。更糟的是第二个现象:换一个 hr 部门的用户来问,同一个 key 直接命中了 product 用户的答案——一次没有任何痕迹的越权。
修法是把索引版本号和权限范围补进 key:
// 同步只要真的改动了索引就把版本号 +1,旧 key 再也算不出来,自然没人读得到
export function answerCacheKey({ question, departments, indexVersion, model, promptVersion }) {
return digest([
'answer',
question,
departments ? [...departments].sort().join(',') : '*', // 排序,保证范围相同就 key 相同
indexVersion,
model,
promptVersion,
])
}# 同步只要真的改动了索引就把版本号 +1,旧 key 再也算不出来,自然没人读得到
def answer_cache_key(question, departments, index_version, model, prompt_version) -> str:
scope = ",".join(sorted(departments)) if departments else "*" # 排序,保证范围相同就 key 相同
return digest(["answer", question, scope, index_version, model, prompt_version])用版本号做失效,比"精确删除受影响的缓存条目"可靠得多——后者要求你能列出"这次改动影响了哪些问题",而那是列不出来的。
追踪:把一次问答拆开,看清每一段花了多久
系统慢的时候,"慢"这个词毫无价值。你需要知道的是慢在哪一段。所以给一次问答的每一步开一个跨度(span):记下名字、耗时、进出的 token 数,跑完打成一张表。
不用急着接 Langfuse 或 OpenTelemetry 这类平台。它们解决的是"上千条链路怎么汇总、怎么检索",而在你想清楚"一条链路该记哪些字段"之前接进去,只会得到一堆没有语义的时间戳。今天的实验里,这个记录器一共 80 行,输出长这样(在离线模式下跑出来的,生成那一段没有走网络):
—— 一次问答的追踪记录 ——
向量化 0.09 ms ( 0.9%) 13 in / 0 out $0.00000026 ( 0.0%)
检索 1.20 ms ( 12.5%) 0 in / 0 out $0.00000000 ( 0.0%)
重排 4.59 ms ( 47.8%) 690 in / 0 out $0.00006900 ( 3.1%)
生成 3.73 ms ( 38.8%) 532 in / 38 out $0.00216600 ( 96.9%)
合计 9.61 ms $0.00223526这张表的关键在于时间和钱各占一栏。上面这一次,最慢的是重排、最贵的是生成,两者不是同一段——如果你只盯着延迟去优化,会一头扎进重排里,把最贵的那一项完好无损地留在线上。先分开看,再决定优化谁。
要提醒一句:离线模式下的延迟分布不代表真实系统。生成那一段在这里只花几毫秒,是因为它根本没有发请求;接上真模型之后它通常占整条链路九成以上的时间。这份记录里真正可信的是每一段的 token 数——那是真数出来的。
成本账:四项各占多少,先优化哪一项
有了 token 数,折算成钱只差一步。四项的计费方式完全不同,这才是账要单独算的原因:
// 单价的单位是「美元 / 每百万 token」,所以除以 1e6,不是 1e3
export function estimateCost(stage, tokensIn, tokensOut) {
const price = PRICES[stage]
return (tokensIn * price.in + tokensOut * price.out) / 1_000_000
}# 单价的单位是「美元 / 每百万 token」,所以除以 1e6,不是 1e3
def estimate_cost(stage: str, tokens_in: int, tokens_out: int) -> float:
price = PRICES[stage]
return (tokens_in * price["in"] + tokens_out * price["out"]) / 1_000_000向量化只在摄取时花钱,查询侧一次只有十几个 token,可以忽略;但它是一次性大额支出,全量重建一次就是把整个知识库重买一遍——这正是前面增量同步要省的那笔钱。检索不按 token 计费,它花的是机器时间,算在服务器账上。重排按送进去的候选文本计费,所以重排只能作用于融合后的前 20 条,对全库重排的成本是灾难性的。生成同时按输入和输出计费,而输入里装着你塞进去的全部上下文——它几乎必然是四项里最贵的。
所以优化顺序是清楚的:先砍生成侧的输入 token(少塞几块、块切小一点、把重排做准好让 topK 能降下来),再考虑换更便宜的模型,最后才轮到重排和检索。
换 embedding 模型:双写、灰度、随时回滚
最后一件上生产前必须想清楚的事:有一天你会想换 embedding 模型。新模型更准、更便宜,或者旧的那个要下线了。
麻烦在于不同模型的向量之间没有可比性。它们的维度可能不同,即使维度相同,坐标系也完全不是一回事——用新模型编码问题、拿去和旧模型编码的文档比距离,算出来的相似度是纯噪声。所以"换模型"实质上等于"把整个知识库重新向量化一遍",也就是一次全量重建。选型本身第二天已经讲过,今天只讲怎么把这次重建做得不停机。
标准做法是双写加切换,四步:
先给向量表加一列(比如 embedding_v2),允许为空。然后起一个后台任务慢慢回填新向量,写的全是新列,旧列一个字节都不动——线上此刻仍然走旧列,用户毫无感知。回填完之后进入灰度:把一小部分流量的查询切到新列,同时用第八天那套标准答案集在两列上各跑一遍,比召回率和忠实度。数字站得住,再把全部流量切过去,旧列观察一两周后才删。
这套流程的价值全在回滚成本上:切换只是改一个配置项(走哪一列),出问题时切回去是一秒钟的事,而不是"重新跑一遍八小时的重建任务"。今天实验的第十项就是这条链路的最小版本:回填 149 条 v2 向量的全程,v1 一直可查;切到 v2 之后名次照样能排出来。
顺带说一句:这套双写切换的骨架不只用于换模型。改切块策略、改上下文块头的写法、给块补元数据——凡是会让"索引里的东西"整体改变的事,都是同一条流程。把它做成可复用的,比每次临时想办法要省心得多。
源码导读
动手实验
今天的代码量不大,但每一个练习点都对应一类线上事故,做完之后建议把自测输出抄进自己的笔记——面试里被问到"你怎么保证删掉的文档一定不出现"时,能报出具体数字的回答和背概念的回答完全不是一个量级。不装 Docker、不配 key 也能跑完整条链路:没有数据库连接串时索引自动退回内存实现,语义与 SQL 版一致,两边跑出来的十项结果逐项相同。
- 补完内容指纹的归一化,看对账那一项里 doc-005 从"被误判为修改"变成"仍判为未变"。
- 补完删除检测,看 doc-030 从索引里消失,并确认块数与向量数重新相等。
- 把权限过滤从排序之后挪到排序之前,看比较的行数从 149 降到 26。
- 补全答案缓存的 key,让陈旧答案和跨权限串答案两个现象同时消失。
- 补完成本折算,拿到一张四段都非零的追踪记录,找出最慢的一段和最贵的一段各是谁;再起容器数据库跑一遍,确认十项结果与内存版一致。
面试题
今天 4 道题在下方题库区,侧重增量更新的正确性、权限过滤的位置、缓存失效与成本归因。展开后先看"分析过程"再看要点——照着推导练,比背要点管用。标注"国内高频 / 海外高频"方便按目标市场取舍。
检查清单与明日预告
- 能设计一套基于内容指纹的增量同步方案,做到文档改动后只重算受影响的块
- 能把访问控制做进检索本身,并说明为什么在生成阶段才过滤是错的
- 能给整条链路加上追踪与成本计量,定位出延迟和花费各自集中在哪一步
- 能说清三层缓存各自的 key 里必须放什么,以及为什么向量缓存不能绑索引版本
- 能讲出换 embedding 模型的双写切换四步,以及这套流程的价值为什么在回滚成本上
- 实验的 5 条验收标准全部通过
- 4 道面试题不看要点也能答出至少 3 道
明天(D14)是收尾:把十四天的东西装成一个多租户、带引用、有评估面板的企业知识库问答,再把整门课压缩成一张决策地图。今天必须排在明天前面——多租户的地基就是今天这套权限下推,而"这个方案值不值得上"的判断,靠的是今天这份成本与延迟账。没有它们,那个综合项目只是个更大的玩具。
Interview questions
After a document changes, how do you recompute only the affected chunks? And how do you guarantee a deleted document really disappears from the index?文档更新之后,你怎么做到只重算受影响的块?被删掉的文档又怎么保证一定从索引里消失?
Common in ChinaCommon overseasIntermediate#incremental-sync#content-hash#index-maintenanceHow to reason about it · think before answering
- There are two halves here and the second one separates candidates. Almost everyone can say 'hash it and compare'; the score comes from bringing up deletion yourself, because it is the one asymmetric case in the whole mechanism.
- Give the skeleton first: a three-way reconciliation between the full set from the source and the full set in the index. In source but not indexed is an add; in both but with different content hashes is a modify; indexed but absent from the source is a delete. A modify must replace the document wholesale, deleting old chunks before writing new ones, otherwise a shortened document leaves a tail behind in the index.
- Then the fingerprint itself, which is where points are won: sha256 truncated, but normalize line endings and trim before hashing. The same file uploaded from Windows and from macOS differs byte-wise but not in content; skip normalization and every re-upload counts as a change, which is a full rebuild in disguise. It never raises an error, it only shows up on the bill.
- The key insight in the second half: a deletion is not an event, it is an absence. Change feeds tell you what changed; nobody ever sends 'I no longer exist'. So deletion detection has to run in the opposite direction — walk the index and find ids the source no longer has. A synchronizer that only listens to change events will wait forever.
- At the storage layer, cascade the foreign keys across documents, chunks and embeddings so deleting a document is a single statement and the database does the rest. Hand-written three-step deletes eventually miss one, and the one they miss is a ghost in the index. Close with a verifiable invariant: chunk count must equal embedding count, and a mismatch means orphans.
- Expected follow-up: what if the source system itself is unreliable and a pull comes back incomplete? Make pull completeness a precondition for deletion: on a partial pull, apply adds and modifies only, or one failed fetch wipes half your index. Also soft-delete with a retention window so a mistake is recoverable.
分析过程 · 先想清楚再作答
- 这题有两半,区分度全在后半。前半几乎人人答得出「算个哈希比一比」,能不能拿到分取决于你有没有主动讲删除——那是同一套机制里唯一不对称的一种变更。
- 先给增量的骨架:拿来源的全集和索引的全集做三向对账。来源有、索引没有是新增;两边都有但内容指纹不同是修改;索引有、来源没有是删除。修改的处理是整篇替换,先删旧块再写新块,不能只追加——不然改短了的文档会在索引里留下一截尾巴。
- 接着讲指纹本身,这是给分点:sha256 取前若干位,但**算之前必须先做换行归一化再去首尾空白**。同一份文件从 Windows 传一次、从 Mac 传一次,字节不同内容相同,不归一化就每次都判成变了,等于天天在做全量重建。这个 bug 不报错,只体现在账单上。
- 然后是删除这一半的关键判断:**删除不是一个事件,是一个缺席**。文件变动类的通知只告诉你哪些东西变了,永远不会有人发一条「我不存在了」。所以删除检测必须反着来——遍历索引,找出来源里已经没有的 id。只监听变更事件的同步器永远等不到这条消息。
- 落到存储上:文档、块、向量三张表用外键级联删除,删文档只写一条语句,剩下的交给数据库。手写三条删除的版本迟早会漏掉一条,而漏掉的那条就是索引里的幽灵。收尾时报一个可验证的指标:块数与向量数必须相等,不等就说明有孤儿。
- 可预期的追问:来源系统本身就不可靠、拉不全怎么办?那就把「本次拉取是否完整」当成删除检测的前置条件——拉取不完整时只做新增和修改,不做删除,否则一次拉取失败会把半个索引清空。另外给删除加软删标记和保留期,误删还能回滚。
Key points
- Three-way reconciliation covering adds, modifies and deletes; a modify replaces the whole document, old chunks first.
- Normalize line endings and trim before hashing, or cross-platform re-uploads look like edits and you are doing a full rebuild every night.
- Deletion is an absence, not an event: walk the index for ids the source no longer has instead of waiting on a change feed.
- Cascade deletes from documents to chunks to embeddings so one statement suffices; assert chunk count equals embedding count to catch orphans.
- On an incomplete pull, apply adds and modifies only, and soft-delete with a retention window so mistakes are reversible.
答题要点
- 三向对账:新增、修改、删除,缺一不可;修改是整篇替换,先删旧块再写新块。
- 内容指纹算之前必须先做换行归一化再 trim,否则跨系统重传会被误判为修改,等于天天全量重建。
- 删除是缺席不是事件,必须反过来遍历索引找出来源里已消失的 id,不能只监听变更通知。
- 文档、块、向量用外键级联删除,删文档只写一条语句;用「块数等于向量数」当可验证的收尾指标。
- 来源拉取不完整时只做新增与修改、跳过删除,并给删除加软删与保留期以便回滚。
Why can't access control be applied at the generation step? What exactly leaks if you put it there?为什么权限过滤不能放在生成阶段做?放在那里会泄露什么?
Common in ChinaCommon overseasDeep dive#access-control#filter-pushdown#multi-tenancyHow to reason about it · think before answering
- This checks whether you think about RAG as a system. 'Because it's insecure' scores nothing; the interviewer wants what specifically leaks, and what else goes wrong besides the leak.
- Anchor the position with an image: the archivist spreads every file on the table, you pick nine, and only then does he pull three back saying you may not read those. You have already seen the titles. Filtering at generation time is that gesture.
- Then split the consequences, and note the second one is what shows engineering experience. First, exposure: the unauthorized documents were retrieved, ranked, read into process memory, and almost certainly written to retrieval logs and traces, even if none of their text reaches the answer. Second, dilution: you take the top 8, three are off-limits, the user gets five, and the legitimate results ranked ninth and tenth never get promoted. The user experiences 'it can't find anything' while your logs show a perfectly normal retrieval.
- State the fix: put the permission predicate in the same query as the ordering and the LIMIT, so the database prunes rows before ranking and unauthorized vectors are never compared. Cover both shapes: row-level filtering is one index plus a predicate; index isolation is a separate index per boundary.
- Give the selection criterion: the number and stability of the isolation boundaries. A handful of departments that rarely change makes isolation worthwhile; tens of thousands of per-user private document sets leave you with row-level filtering, because that many indexes is unmanageable. Add the shared-index side effect: a large tenant degrades everyone else's retrieval quality because candidate slots are shared.
- Expected follow-up: what about caching? It is the same bug's second crime scene. The answer cache key must include the permission scope, or one user's answer will be served to another, and that leak leaves no trace in the retrieval log at all.
分析过程 · 先想清楚再作答
- 这题在考你有没有真的把 RAG 当系统看。答成「因为不安全」拿不到分,面试官要的是「具体泄露了什么」和「除了泄露还有什么后果」两件事。
- 先用一个画面把位置说清楚:档案管理员先把全部档案摊在桌上让你挑,你挑完他再抽走三份说这些不能看——你已经看见标题了。在生成阶段过滤就是这个动作。
- 然后拆后果,两条,第二条更能显出做过工程:一是**泄露面**,越权文档已经进过检索、参与过排序、被进程读进过内存、大概率写进了检索日志和链路追踪,哪怕最终答案里没有它的内容;二是**结果被稀释**,取前 8 条里有 3 条不该看,筛掉只剩 5 条,而本该补位的第 9、10 名合法结果永远没机会上来——用户体感是「查不到」,你的日志里却是一次正常检索。
- 给正确做法:把权限谓词和排序、LIMIT 写进同一条查询,数据库先裁行再排序取前 k,越权的行一次都没被比较过。两种落法要都讲:行级过滤是一份索引加一个谓词,索引隔离是按边界各建各的索引。
- 选型判据要给出来:看隔离边界的数量和稳定性。部门这种个位数且几乎不变的边界,隔离划算;几万个用户各自的私有文档就只能行级过滤,否则运维扛不住。补一句共用索引的副作用——数据量大的租户会拖慢别人的检索质量,因为候选名额是共享的。
- 可预期的追问:缓存怎么办?这是同一个问题的第二现场——答案缓存的 key 里必须带上权限范围,否则一个用户的答案会被另一个用户命中,而且这条泄露路径连检索日志都不会留下痕迹。
Key points
- Filtering at generation time means unauthorized documents were already retrieved, ranked, held in memory and written to logs and traces; the exposure is far wider than 'did the text reach the answer'.
- The second consequence is dilution: filtered-out slots are not backfilled, so users see 'nothing found' while the log shows a normal retrieval.
- The fix is to put the permission predicate in the same statement as ordering and LIMIT so the database prunes before ranking.
- Choose between row-level filtering and index isolation by the count and stability of the boundaries; a shared index lets a large tenant crowd out a small one's candidate slots.
- Caching is the same bug's second crime scene: the answer cache key must carry the permission scope or answers leak across users without a trace.
答题要点
- 在生成阶段过滤时,越权文档已经被检索、排序、读进内存并写进日志与追踪,泄露面比「答案里有没有」大得多。
- 第二个后果是结果被稀释:筛掉之后名额空着不补,用户体感是查不到,日志里却是一次正常检索。
- 正确做法是把权限谓词和排序、LIMIT 写进同一条查询,让数据库先裁行再排序取前 k。
- 行级过滤与索引隔离的选型判据是隔离边界的数量与稳定性;共用索引时大租户会挤占小租户的候选名额。
- 缓存是同一个漏洞的第二现场:答案缓存的 key 必须包含权限范围,否则会跨用户串答案且不留痕迹。
What can be cached in a RAG system, and what are the invalidation conditions for each?RAG 系统里有哪些东西可以缓存?各自的失效条件是什么?
Common in ChinaCommon overseasIntermediate#caching#invalidation#cost-optimizationHow to reason about it · think before answering
- This looks like a giveaway and is actually a filter. 'Cache the question and answer' earns a third of the credit; the interviewer is waiting for the layering and the per-layer invalidation rules.
- Lead with a transferable rule: 'when must this be invalidated' is the same question as 'is that thing part of the key'. Leave something out of the key and changes to it will never invalidate the entry. With that rule the three layers derive themselves.
- Then go layer by layer. The answer layer maps a question to a final answer; its key needs the question, the permission scope, the index version, and the model plus prompt version. The retrieval layer maps a query to a hit list; its key needs the question, scope, topK, index version and embedding backend, but not the generation model. The embedding layer maps text to a vector; its key is just the text and the backend.
- Emphasize the counterintuitive part of the embedding layer: it is content-addressed, so the index version must not be in its key. Put it there and a single sync invalidates tens of thousands of vectors, which is exactly the full rebuild you added caching to avoid. This is the one layer that can live a long time, even on disk.
- Offer a concrete invalidation mechanism: version numbers rather than targeted deletion. Bump an index version whenever a sync actually changes something and old keys simply stop being computed. Targeted deletion would require enumerating which questions a change affected, and that list cannot be produced.
- Expected follow-up: can you give a real 'should have expired but didn't' case? Yes: an answer cache keyed only on the question. A document's limit changes from 200 MB to 500 MB, the index is updated, and the same question still returns 200 MB. Nothing errors; the log shows a clean cache hit. The same key also serves one department's answer to a user from another.
分析过程 · 先想清楚再作答
- 这题看起来是送分题,实际是筛人题。答成「把问答结果缓存起来」只拿到三分之一,面试官等着听的是「分几层」和「各自什么时候失效」。
- 先给一条能迁移到别的题上的判断依据:**「什么时候必须失效」这个问题,等价于「key 里有没有把那样东西算进去」。** key 少放一样,那样东西变了缓存就不会失效。有了这条,三层的答案自己就长出来了。
- 然后逐层给:答案层缓存问题到最终答案,key 要有问题、权限范围、索引版本、模型与提示词版本;检索层缓存检索式到命中块列表,key 要有问题、权限范围、topK、索引版本、向量后端,但不需要模型;向量层缓存文本到向量,key 只有文本和向量后端。
- 重点讲向量层的反直觉之处:它是**内容寻址**的,文本没变、模型没变,向量就不会变,所以**不能把索引版本放进它的 key**。放进去的话一次同步就作废几万条向量,正好绕回全量重建——你加缓存想省的那笔钱又花回去了。这一层可以放很久甚至持久化。
- 给一个具体的失效手法:用**索引版本号**而不是精确删除。同步只要真的改动了索引就把版本号加一,旧 key 再也算不出来,自然没人读得到。精确删除要求你能列出「这次改动影响了哪些问题」,而那是列不出来的。
- 可预期的追问:能举一个「该失效却没失效」的真实例子吗?答:答案缓存的 key 只放了问题本身,文档里的上限从 200 MB 改成 500 MB、索引已经更新,再问同一个问题仍然返回 200 MB。它不报错,日志上是一次漂亮的缓存命中;同一个 key 还会让另一个部门的用户直接命中别人的答案。
Key points
- Three layers — answer, retrieval, embedding — with lifetimes orders of magnitude apart; treating them as one thing is the mistake.
- The rule is that 'when must it expire' equals 'is it in the key'; anything left out of the key can never invalidate the entry.
- The answer key carries question, permission scope, index version, model and prompt version; the retrieval key drops the model and adds topK and the embedding backend.
- The embedding layer is content-addressed and keyed only on text plus backend; adding an index version turns every sync back into a full rebuild.
- Version-based invalidation beats targeted deletion because you cannot enumerate which questions a given change affected.
答题要点
- 分三层:答案、检索、向量,三者的寿命差着数量级,不能当成一件事。
- 判断依据是「什么时候必须失效」等价于「key 里有没有算进那样东西」,key 少一样就永远失效不了。
- 答案层 key 要有问题、权限范围、索引版本、模型与提示词版本;检索层去掉模型、加上 topK 与向量后端。
- 向量层是内容寻址的,key 只有文本与后端;把索引版本放进去会让每次同步都退化成全量重建。
- 用索引版本号做失效比精确删除可靠,因为「这次改动影响了哪些问题」根本列不出来。
You need to switch embedding models. How do you migrate a live system without downtime and without losing recall?要换一个 embedding 模型,线上系统怎么迁移才能不停机也不掉召回?
Common in ChinaCommon overseasDeep dive#embedding-migration#zero-downtime#rolloutHow to reason about it · think before answering
- The crux is why you cannot swap in place. Jumping straight to the steps without establishing that reads like reciting a runbook.
- Set up the premise: vectors from different models are not comparable. Dimensions may differ, and even at equal dimensions the coordinate spaces are unrelated, so encoding the query with the new model and comparing against documents encoded with the old one yields noise. Switching models therefore means re-embedding the entire corpus.
- Then the four steps: add a nullable second vector column; backfill it with a background job while the old column is untouched and still serves live traffic; canary a slice of traffic onto the new column while running the golden set against both columns to compare recall and faithfulness; cut over fully once the numbers hold, and drop the old column only after a week or two of observation.
- Name the payoff explicitly, because this is where the points are: the value of the whole procedure is the rollback cost. Cutover is a config change naming which column to read, so reverting takes a second rather than re-running an eight-hour rebuild. A migration plan with no rollback path is not a plan.
- Add two engineering details: build the approximate-nearest-neighbour index on the new column after the backfill, not during it, since concurrent building is slow and prone to locking; and make the backfill resumable and rate-limited, or it will exhaust the embedding API quota and drag live queries down with it.
- Expected follow-up: how do you prove the new model is actually better? Not from an offline metric alone — run an A/B on the same golden set with identical retrieval parameters and report four numbers: recall, faithfulness, latency and cost. A conclusion resting on the first number only does not hold. Note also that switching models is the one moment when the embedding cache genuinely must be invalidated.
分析过程 · 先想清楚再作答
- 这题的题眼是「为什么不能就地换」。没有先说清这一点就直接讲步骤,会显得是在背流程。
- 先给前提:不同模型的向量之间**没有可比性**。维度可能不同,即使维度相同坐标系也完全不是一回事,用新模型编码问题去和旧模型编码的文档比距离,算出来的相似度是纯噪声。所以「换模型」实质上等于「把整个知识库重新向量化一遍」。
- 然后给四步:加一列新向量、允许为空;后台任务慢慢回填新列,旧列一个字节不动,线上仍走旧列;小流量灰度到新列,同时用标准答案集在两列上各跑一遍比召回率与忠实度;数字站得住再全量切换,旧列观察一两周后才删。
- 把这套流程的价值点破,这是给分点:**它的价值全在回滚成本上**。切换只是改一个配置项「走哪一列」,出问题时切回去是一秒钟的事,而不是重跑一遍八小时的重建任务。凡是拿不出回滚路径的迁移方案都不算方案。
- 补两个工程细节:新列的近似最近邻索引要在回填完之后再建,边写边建又慢又容易锁表;回填要能断点续传并限速,否则会把 embedding 接口的配额打满,把线上查询一起拖垮。
- 可预期的追问:怎么证明新模型确实更好?答:不能只看离线指标涨没涨,要在同一份标准答案集、同一套检索参数下跑 A/B,报召回率、忠实度、延迟、花费四笔账;只报第一笔的结论不成立。另外注意换模型会让缓存里的向量全部作废,那是这次迁移唯一该作废向量缓存的时刻。
Key points
- Vectors from different models are not comparable, so a model switch is equivalent to re-embedding the entire corpus.
- Four steps: add a nullable second vector column, backfill in the background, canary with the golden set scored on both columns, then cut over once the numbers hold.
- The whole value lies in rollback cost: cutover is a config change, so reverting takes a second instead of another full rebuild.
- Build the ANN index on the new column after the backfill; make the backfill resumable and rate-limited so it does not exhaust the embedding quota and stall live queries.
- Validate with an A/B on one golden set reporting recall, faithfulness, latency and cost; a model switch is also the only time the embedding cache truly must be invalidated.
答题要点
- 不同模型的向量之间没有可比性,所以换模型等价于把整个知识库重新向量化一遍。
- 四步:加一列可空的新向量、后台回填、小流量灰度并用标准答案集在两列上对比、数字站得住再全量切换。
- 这套流程的价值全在回滚成本上:切换是改一个配置项,回滚是一秒钟的事而不是重跑一次重建。
- 新列的近似最近邻索引在回填完成后再建;回填要可断点续传并限速,别把接口配额打满拖垮线上查询。
- 验证要在同一份标准答案集上跑 A/B,同时报召回率、忠实度、延迟与花费四笔账;换模型也是唯一该作废向量缓存的时刻。
Comments
Sign in to join the discussion
No comments yet — be the first.