Skip to content

4.3.5 如何避免旧 Chunk 残留、重复索引和脏数据? ​

先给结论:旧 chunk 残留、重复索引和脏数据,通常不是单次写入失误,而是同步机制没有把“替换旧对象”和“清理失效对象”设计完整。

所以如果你只关心怎么把新块写进去,不关心旧块怎么退出,索引早晚会变脏。

这类问题为什么这么常见 ​

因为很多系统的第一版同步逻辑往往都很简单:

  • 文档变了
  • 重新切块
  • 新块写进去

看起来没问题,但如果旧块没有同步退出,就会出现:

  • 同一文档新旧块同时存在
  • 切块策略改了以后旧块全部残留
  • 同一段内容被多次重复写入
  • 删除过的文档还能被检索到

这些问题不一定会立刻爆出来,但随着文档多次更新,结果会越来越明显。

第一层:先把 ID 设计稳 ​

避免残留和重复的第一步,不是写删除脚本,而是先让系统有能力认出“谁是谁”。

至少要有这几类标识:

  • doc_id:原始文档身份
  • chunk_id:具体索引对象身份
  • version 或 updated_at:版本和时间边界
  • content_hash:内容是否变化

如果这些标识不稳定,后面系统就很难区分:

  • 这是旧块被更新了
  • 还是一条新块
  • 还是同一条内容被重复写了一次

第二层:新增和替换不能混成一件事 ​

很多脏数据都是这样来的:

  • 系统把“修改”也当成“新增”

更稳的做法通常是:

  1. 先找到某个 doc_id 当前已存在的所有 chunk
  2. 重新生成该文档的新 chunk 集合
  3. 做新旧集合对比
  4. 该保留的保留,该替换的替换,该删除的删除

只有这样,系统才不会每同步一次就多出一套旧块。

第三层:删除链路必须单独成立 ​

很多团队会认真做新增和修改,却忽略删除。

这会直接导致:

  • 源文档已经没了,索引里还有
  • 某些段落已经删掉,旧 chunk 还能被搜到
  • 回答中混入早就不该出现的历史内容

所以删除链路最好单独设计并验证:

  • 是按 doc_id 删除全部 chunk
  • 还是按 chunk_id 精确删除
  • 是物理删除还是逻辑失效
  • 删除后如何验证真的退出检索

第四层:定期做“索引对账” ​

即使增量同步做得不错,长期运行后仍然可能出现脏数据。

原因包括:

  • 某次同步任务失败
  • 某个删除事件漏了
  • 某批数据部分写入成功、部分失败
  • 某次切块规则变化导致兼容性问题

所以更稳的系统通常会有定期对账机制,比如:

  • 抽样检查某个 doc_id 在索引中的 chunk 数是否合理
  • 比对源文档数量和索引对象数量变化趋势
  • 检查已删除文档是否还有残留索引对象
  • 检查相同 content_hash 是否异常重复

这类对账不一定每天都跑大扫描,但至少要有能力在怀疑变脏时做核对。

一个最小示意 ​

python
def reconcile_document(doc_id, new_chunks):
    old_chunks = load_indexed_chunks(doc_id)

    old_ids = {chunk["chunk_id"] for chunk in old_chunks}
    new_ids = {chunk["chunk_id"] for chunk in new_chunks}

    to_delete = list(old_ids - new_ids)
    to_upsert = list(new_chunks)

    upsert_chunks(to_upsert)
    delete_chunks(to_delete)

这段示意代码想说明的是:

  • 同步不是只做 upsert
  • 更重要的是:新集合和旧集合要能对上账

第五层:把“是否可检索”从“是否存在”里拆出来 ​

有些场景下,一个对象可能还需要保留,但不应该继续参与检索。

这时可以把:

  • 对象仍存在
  • 对象仍可检索

拆成两个层次。

例如给对象增加:

  • status
  • is_deleted
  • valid_from
  • valid_to

这样即使短时间内物理删除还没完成,也能先通过过滤让它退出检索结果。

这对处理:

  • 延迟删除
  • 灰度切换
  • 分阶段下线

尤其有用。

上线前最值得做的自查 ​

如果你担心系统会不会越用越脏,至少先拿几篇高频文档做这几项检查:

  1. 同一篇文档连续修改 3 次后,索引对象数量是否异常增长
  2. 删除一篇文档后,按关键词还能不能搜到它
  3. 切块规则变化后,旧块是否还残留
  4. 同一段内容是否因为重复同步出现多次
  5. 某次同步失败后,系统能不能补偿而不是半残状态停在那里

这几项如果过不了,后面知识库越大,清理成本越高。

一个常见误区 ​

很多人会觉得:

  • 只要每次 upsert 都用同一个 ID,就不会脏

这不一定。

因为一篇文档一旦重新切块,旧块和新块的集合关系就可能变了:

  • 有些块变了
  • 有些块合并了
  • 有些块拆开了

这时即使部分 ID 覆盖成功,仍然可能留下那些“不再应该存在”的旧块。

所以真正要解决的不是单条覆盖,而是整份文档对应对象集合的一致性。

一句话总结 ​

避免旧 chunk 残留、重复索引和脏数据,关键不只是写入新块,而是让系统能稳定识别身份、对比新旧集合、清理失效对象,并定期做索引对账。索引一旦长期运行,脏数据治理本身就是系统能力的一部分。