先看两个教学场景。
第一个场景,管理员把同一份制度文档连续上传了两次。系统也老老实实解析两次、切分两次、计算两次 Embedding,最后往向量库写进两套几乎完全相同的 Chunk。
用户提问时,前几条候选看起来都相关,实际上只是同一段内容重复出现。真正能补充答案的其他证据,反而被挤到后面。
第二个场景,管理员只改了文档中的一个段落。系统为了避免重复,看到文件名相同就直接跳过,结果新版内容根本没有入库。
这两个问题方向相反。
第一个是重复写,第二个是漏更新。只说一句“加个去重”解决不了它们。
所以这道面试题真正问的是:
同一份文档可能重复上传,也可能只修改一部分。Embedding 和批量写入还可能中途失败,怎样让任务重复执行后结果仍然正确?
幂等到底是什么意思?
幂等可以先用一句白话理解:同一个任务执行一次和重复执行多次,最终知识库状态应该一致。
这里的“一致”不是日志完全相同,也不是每次都不做任何计算,而是不会因为重试多出一套内容,不会跳过真正新增的内容,也不会让旧版本和新版本在检索时无规则混用。
重复上传与内容更新看起来都叫“再次上传”,系统应该给出不同判断。
原文完全没有变化时,第二次执行不应产生新 Chunk,也不应重复调用 Embedding。
只有一个段落发生变化时,未变化块可以复用,变化块需要重新向量化,同时还要处理旧块何时退出当前检索范围。
任务执行到一半超时时,再次执行要补齐失败部分,而不是重放整批并制造重复。

重复上传希望最终不增加内容,文档更新希望只替换发生变化的部分。两者都依赖稳定身份,但版本处理不同。
所以,幂等通常不是一个布尔开关,而是一组围绕身份、版本和状态的约束。
稳定 Chunk ID 应该怎样生成?
为了说明稳定 ID 的生成思路,这里用一个教学示例:先把来源与内容组合成身份输入,再生成文档块的稳定 ID。入库前检查 ID 是否存在,存在就跳过 Embedding,不存在才继续处理。实际系统还要根据权限、版本与存储约束确定具体生成规则。
这条思路最关键的不是选哪种哈希算法,而是先定义“同一个 Chunk”到底由什么决定。
只用文件名不够。
文件名不变,正文可能已经修改;文件改名,正文也可能完全相同。多个知识库还可能各自出现同名文件,如果 ID 没有包含知识库范围,就可能发生不该有的碰撞。
只用内容也不一定够。
两份不同制度可能恰好引用同一段通用说明。它们虽然文字相同,来源、权限和版本却不同。用户查看引用时,也必须回到正确文档。
因此,一个可讨论的稳定 ID 通常要组合几类信息:知识库或租户范围、稳定文档标识、内容位置或结构标识、规范化内容。
规范化要保持克制。
去掉无意义空白、统一换行,能避免排版差异制造新 ID。但如果把标点、数字或大小写全部抹掉,原本不同的条款也可能被误判为相同。
文档标识也不应直接等同于文件名。
更稳妥的是让文档在首次进入系统时获得稳定身份,后续改名仍指向同一文档。至于一份同内容文件上传到另一个知识库,是复用向量还是保存独立记录,要根据权限与引用需求决定,不能只看哈希相同就跨范围合并。
一条增量入库链路应该怎么走?
可以把流程拆成七步。
第一步,确认知识库范围与文档身份。
第二步,解析文档并形成结构化内容。
第三步,按确定的切分规则生成 Chunk。
第四步,为每个 Chunk 计算稳定 ID,并批量查询哪些 ID 已经存在。
第五步,只把新增或变化的 Chunk 送去计算 Embedding。
第六步,批量写入文本、向量、来源、版本和状态等字段,并逐条检查结果。
第七步,记录新增、跳过、失败以及待清理的旧 ID,完成文档版本校验。
这七步最好落成显式状态,而不是依靠一条任务日志猜进度。文档版本可以依次处于“已接收、解析完成、待向量化、部分写入、校验通过、当前有效、已停用”。Chunk 也记录自己是否已生成向量、是否成功写入。重试读取状态后只推进尚未完成的步骤,并且每次状态转换都检查前置条件。
下面是一段教学伪代码,只说明“按状态补齐”和“校验后发布”的顺序:
version = load_or_create_version(document_id, source_digest)
chunks = parse_if_needed(version)
embed_missing(chunks)
upsert_missing(chunks)
verify_complete(version)
publish_if_newer(document_id, version)
load_or_create_version 必须在并发下返回同一个版本身份,upsert_missing 必须使用稳定 Chunk ID,publish_if_newer 则要防止旧任务晚到后覆盖新版本。任何一步失败都保留已完成结果,但不会让未校验版本进入普通检索。

稳定 ID 把查重放在 Embedding 之前。新增内容进入向量化和批量写入,失败与旧版本分别进入恢复和清理流程。
把查重放在 Embedding 之后,虽然也能避免向量库重复,却已经浪费了最昂贵的计算之一。
只在内存里去重也不够。任务重启或换一台工作节点后,内存状态会丢失。是否存在应该以持久化存储中的稳定 ID 与版本状态为准。
还要注意,查重与写入之间存在时间窗口。
如果两个任务同时处理同一内容,它们可能都查到“不存在”,随后同时写入。稳定 ID 应当由存储层唯一约束兜底。即使两个工作进程都走到写入,最终也只能留下同一个身份的记录。
幂等不能只靠“先查一下”,还要让最终写入本身可重复。
文件只改了一段,旧 Chunk 怎么处理?
内容哈希能识别新块,却不会自动删除旧块。
假设原文中的办理时间发生修改。新段落生成新 ID 并成功写入,旧段落仍然留在索引里。用户搜索时,两版内容可能一起被召回。
所以,重复上传与版本更新必须分开。
重复上传的目标是保持现状。
文档更新的目标是让一组新 Chunk 成为当前版本,同时让旧版本退出正常检索。
一种更安全的流程是先生成新版本全部 Chunk,在隔离状态完成数量、字段和关键证据校验。确认新版本完整后,再切换文档的当前版本标记,最后异步清理旧版本。
为什么不一开始就删除旧内容?
因为解析、Embedding 或批量写入都可能在中途失败。如果先删旧版,新版又只写入一部分,知识库就会出现缺口。
为什么不让新旧版本长期共存?
因为相关不等于可用。旧条款和新条款往往都能匹配同一个问题,检索器很难仅凭语义判断哪个是当前有效版本。
版本字段应该参与检索范围,而不是只作为展示信息。正常搜索只选择当前有效版本,审核或历史查询再显式进入旧版本。
这里还有一个容易被忽略的边界:切分规则变化。
即使原文件没改,只要切分算法升级,Chunk 边界就可能全部变化。系统需要把切分版本纳入处理记录,并明确这次是内容更新还是索引重建,避免把两类变化混在一起统计。
Bulk 一部分成功,一部分失败怎么办?
批量写入不是一个只有“成功”和“失败”的整体结果。
一批记录里,可能大部分已经成功,只有少数因为字段类型、内容长度或临时连接问题失败。如果只看到请求返回异常就整批重试,成功项也会再次进入写入路径。
如果 ID 和写入操作是幂等的,整批重试未必产生重复,但会增加无效请求,也会掩盖真正不可恢复的错误。
更稳妥的是逐条检查返回结果,保存成功 ID、失败 ID、错误类型和尝试次数。
连接超时、临时不可用等错误,可以在限制次数内重试。
字段映射错误、缺少必要来源、向量维度不匹配等数据问题,重复发送同一内容不会自己变好。它们应当保留现场,修复数据或配置后再补写。
超过重试边界的记录进入待处理队列,不能让任务永远循环。

临时错误可以有限重试,数据错误需要修复,已成功项保持原状态。相同稳定 ID 让补跑只补缺口。
Embedding 也可能部分失败。
系统应记录 Chunk 当前处于待向量化、向量完成、待写入、已写入还是失败状态。补跑时从未完成步骤继续,而不是重新解析和计算所有内容。
如果 Embedding 模型升级,旧向量不能直接与新模型输出混用。缓存键和记录里要带模型版本,这属于模型迁移,不应被普通重复上传逻辑静默跳过。
失败恢复还要区分“请求是否超时”和“服务端是否写入”。客户端看到超时,不代表后端一定没有完成。如果重试时换了随机 ID,就可能留下两份记录;继续使用相同稳定 ID 做 upsert,才能让未知结果收敛到同一状态。对不支持幂等写入的外部服务,可以为每个批次保存请求身份和已确认的结果,再逐条对账,不能只根据一次 HTTP 状态推断整批结果。
任务统计也应来自持久化状态,而不是进程内计数器。工作进程崩溃后,内存里的“成功 90 条”没有恢复价值;稳定 ID、步骤状态和错误类型才能告诉新进程还差哪些内容。恢复完成后重新核对目标 ID 集合与当前有效集合,避免某条记录既被计为成功,又没有真正进入可检索版本。
并发任务怎样避免互相覆盖?
实际系统不一定每次只有一个上传任务。
用户可能连续点击两次,同一文件也可能被定时同步与手工上传同时触发。两个任务如果各自完成查重,再并发计算和写入,就会形成竞争。
稳定 ID 是第一层保护。
即使两个任务都认为某个 Chunk 不存在,写入时仍使用同一个 ID,存储层的唯一约束会让它们指向同一记录,而不是各自生成一份随机身份。
任务状态是第二层保护。
每个文档版本应知道自己正在解析、等待向量化、部分写入、校验完成、已成为当前版本,还是已经失败。后来的任务看到前一个版本仍在处理中时,需要按产品语义决定复用、排队或取消,而不是直接把状态覆盖成成功。
当前版本切换是第三层保护。
新版本的 Chunk 可以逐步写入隔离区域,但只有整版校验通过,才更新“当前版本”指针。这个切换动作应当明确,不能让用户查询在一个请求里同时读到半套新版和半套旧版。
清理动作要放在切换以后。
旧内容退出当前检索,不等于立刻物理删除。可以先标记为非当前版本,确认新版本稳定后再清理。这样失败回退时仍有可恢复对象,也避免删除任务与查询任务互相干扰。
还要防止旧任务晚到。
假设版本 B 已经成为当前版本,较早启动的版本 A 任务此时才完成。系统不能因为 A 最后写完,就把当前版本重新切回 A。切换时需要比较文档版本或任务代次,只允许符合预期的新版本生效。
因此,幂等不只发生在 Chunk 写入。文档状态迁移、版本切换和清理任务也要可重复执行,并且在重试后保持同一结果。
锁与幂等也不是同一件事。文档级锁可以减少两个任务同时工作,但锁可能超时、进程可能崩溃,消息也可能被重复投递。幂等保证即使竞争仍然发生,重复执行也不会破坏最终状态;锁只是降低重复计算。面试里若只回答“给上传接口加分布式锁”,还没有解释锁失效后的正确性。
清理任务同样需要稳定范围。它只能删除某个文档版本中已经被新版本替代、且不再被当前指针引用的 Chunk。若按“文件名相同”批量删除,改名、回滚或并发发布都可能误伤。先切换可见版本,再按明确版本身份清理,失败后重复运行仍应得到相同结果。
四种更新故障,先查身份还是先查写入?
四种故障对应四类不同证据:重复先查稳定身份,改名先查文档边界,局部更新先查版本集合,补跑失败先查逐条任务状态。
同一文件连续上传两次
第二次应识别出相同稳定 ID。检查新增数是否保持不变,跳过列表是否对应已有 Chunk,Embedding 是否没有再次处理这些内容。
如果记录数没增加但 Embedding 仍全部重算,说明只做到了存储去重,没有做到计算去重。
文件改名后再次上传
先明确产品语义。
如果改名仍表示同一文档,文档身份不应跟着文件名变化。如果它被视为一份新来源,也要确保引用、权限和版本都独立。
测试的重点不是强求唯一答案,而是验证身份规则与预期一致。
只修改一个段落
检查未变化块是否保持相同 ID,变化块是否生成新 ID,新版本完整后旧块是否退出当前检索。
再用能命中新旧内容的问题回归,确认正常搜索不会同时返回冲突版本。
批量写入中注入一条非法记录
检查成功项是否保留,失败项是否记录具体原因,修复后重跑是否只补写失败内容。
还要确认重试次数受控,字段错误不会被当作临时故障反复发送。
怎么证明幂等真的成立?
不能只看数据库总数。
总数不变,可能是任务把旧内容覆盖错了;总数增加,也可能是文档确实新增了内容。
每轮测试至少保留文档版本、Chunk ID 集合、新增 ID、跳过 ID、失败 ID和待删除 ID。
再把测试拆成四组:完全重复、文件改名、局部修改、部分失败。
完全重复验证重复执行不会产生新内容。
文件改名验证文档身份策略。
局部修改验证未变化内容复用与新旧版本切换。
部分失败验证成功项不回滚、失败项能补写、不可恢复错误不会无限重试。
最后还要做检索回归。
数据层看起来正确,不代表检索范围已经正确使用版本字段。给新旧段落各准备问题,检查当前查询只返回有效版本,引用仍能回到正确文件和位置。
可以再加入顺序扰动测试:让旧任务故意比新任务晚完成,让同一消息重复投递,让批量写入在不同位置超时。每次执行后都验证三条不变量:同一稳定身份最多一条当前记录;普通检索只读取一个已校验版本;当前版本的目标 Chunk 集合与实际可检索集合一致。只要其中一条被破坏,就说明系统仍依赖理想执行顺序。
这些测试要用稳定 ID 集合做断言,而不是只比较记录总数。总数相同仍可能少了一条新记录、多留了一条旧记录;集合差异能直接指出缺失、重复和错误生效的身份。把每轮输入版本、预期集合、实际集合与任务状态保存下来,失败后才能原样重放。
还应检查任务统计是否能互相核对。新增、跳过、失败、停用和当前有效 Chunk 的集合要能够解释最终索引状态。若汇总数字对不上具体 ID,报表再漂亮也无法帮助恢复。每次补跑后重新计算集合差异,确认没有既标记失败又已经生效的矛盾记录。

同时检查身份、版本、失败恢复和检索结果,才能证明系统既不重复,也没有漏写或混用旧内容。
幂等追问怎么接:别把“查重”当成答案
回答每个追问时,都要区分内容是否重复、版本是否切换成功,以及任务重跑后最终集合是否一致。
稳定 ID 为什么不能只用文件名?
因为文件名不是内容,也不是稳定身份。它可以在内容不变时被修改,也可以在内容变化时保持不变。ID 至少要考虑知识库范围、文档身份和规范化内容。
内容哈希相同,为什么不全局只存一份?
因为相同文字可能属于不同知识库、权限、版本和引用位置。向量是否复用可以单独设计,但检索记录不能因为文字相同就失去来源边界。
新块成功写入后,旧块为什么不会自动消失?
稳定 ID 只能说明新旧内容不同。删除或停用旧内容属于版本管理,需要在新版本完整校验后切换当前版本,再清理旧记录。
查重以后再写入,为什么仍可能重复?
两个任务可能同时查到不存在。最终存储还要对稳定 ID 做唯一约束,让重复写入在存储层保持同一结果。
批量写入超时,能不能整批重试?
可以重试可恢复错误,但应先读取逐条结果并继续使用相同 ID。更好的做法是只补失败项,同时区分临时错误和数据错误,避免无效循环。
Embedding 模型更新后,旧缓存还能用吗?
不能默认复用。缓存和向量记录应包含模型版本。模型迁移需要重新计算并切换索引,它不是普通重复上传。
从稳定 ID 讲到版本切换
现场时间有限时,按“稳定身份、版本集合、失败补偿”三个层次展开:
我会把重复上传、文档更新和失败重试分成三层。Chunk 先根据知识库范围、稳定文档身份、原文位置和规范化内容生成稳定 ID。入库前批量查 ID,已有块跳过 Embedding,只有新增或变化块进入向量化和写入。最终存储还要用唯一 ID 兜底,避免并发任务同时通过查重后写出两份。
内容变化会产生新 ID,但旧 Chunk 不会自动消失。因此我会先在隔离状态生成并校验新版本,确认完整后切换当前版本,再清理旧内容。正常检索只看当前有效版本,避免新旧条款一起被召回。
对批量写入,我会逐条保存成功和失败 ID,临时错误有限重试,字段错误保留现场等待修复。补跑继续使用相同稳定 ID,只补未完成步骤。验证时覆盖完全重复、文件改名、局部修改和部分失败,并检查新增、跳过、失败、待清理 ID 以及最终检索结果。
收尾时要让面试官听见三个可验证结果:相同输入不会新增记录,新版本校验前不会污染当前检索,部分失败补跑后目标 ID 集合仍然一致。有增量入库经历,就使用自己项目里的身份规则、失败记录和验证结果;没有完成版本切换,就不要把 Chunk 去重描述成完整的动态更新系统。