RAG 存量数据清理
定位:新增数据走"增量清理流水线",已入库的旧数据才是真正的历史包袱。本篇专注存量治理:如何在不停服的前提下,对向量库中已有的 chunk 做审计、修复、淘汰和版本迁移。五段式:场景 → 方案 → 为什么 → 为什么别的不行 → 沉淀结论。
🧠 一句话记忆锚点
存量治理不能停服全量重建,铁律是"审计先行 + 增量操作 + 删前留证 + 用 Recall 度量"。四板斧:审计(scroll 找孤儿/过期) → 去重(DBSCAN 语义聚类保新删旧) → 修复(patch 元数据/重 embed) → 升级(Embedding 换代走蓝绿双写→后台重建→切流→删旧)。
名词速查
首次阅读可跳过本表,直接看「场景问题」,遇到生词再回查。
本篇是检索里「向量库运维 / 版本化」子域的名词归属页。这一组是纯粹的运维词汇——如果你做过数据库在线 DDL、索引重建、蓝绿发布,这里几乎没有新概念,只是被治理的对象从表和索引换成了向量和 embedding 版本。
| 名词 | 后台类比 | 在系统里干什么 | 类比失效边界 |
|---|---|---|---|
| 陈旧向量(Stale Vector) | 缓存脏数据 / 没跟上主库的从库行——内容已过期但还在被读到 | 源文档已更新或删除,但库里的旧 chunk 还在,检索时会把过期事实答给用户 | 缓存脏数据有 TTL 或失效通知兜底;向量库没有内建的过期机制——它不知道源文档变了。必须靠外部对账(按 updated_at / 源文档指纹 scroll 全库比对)主动发现,这是一条要自己搭的旁路链路 |
| 孤儿 chunk(Orphan) | 外键悬空的记录——父行已删,子行还在 | 源文档已下线但其 chunk 未被清理,成为无主数据。既占容量又会被检索命中 | 外键约束能在删除时级联或直接拒绝;向量库通常没有外键与级联删除,删源文档不会自动删向量。这条清理逻辑必须由应用层保证,漏了就静默积累 |
| 增量更新(Incremental Upsert) | 增量同步 / binlog 消费——只处理变化的部分 | 只对变更的文档重切重嵌入并 upsert,避免全量重建的停服与成本 | binlog 提供精确的变更流;文档源通常没有可靠的变更流——只能靠轮询比对指纹,因此存在检测延迟窗口。且 upsert 的粒度是 chunk,源文档改一个字可能导致其后所有 chunk 的边界位移,实际要重灌整篇 |
| DBSCAN 语义聚类去重 | 按相似度聚类后每组保留一条——比两两比对省得多 | 对已入库向量做密度聚类,把语义重复的 chunk 归成簇,按时效"保新删旧" | 业务去重的判重键是精确的,结果确定;DBSCAN 依赖 eps 半径与最小簇大小两个参数,参数变一点分簇结果就大幅变化,而且它会把"内容相近但版本不同"的文档聚到一起——删错了就是丢内容。所以必须"删前留证"(先软删或导出备份) |
| 重嵌入(Re-embedding) | 索引重建——数据没变,但索引的编码方式变了 | 换 embedding 模型或改分块策略后,把存量文本重新算一遍向量 | 索引重建期间旧索引仍可用、结果一致;重嵌入产出的向量与旧向量处在不同的语义空间,完全不可比——新旧向量混在同一个集合里检索,结果是乱的。所以只能整库切换,不能逐条渐进替换 |
| 蓝绿双写 | 蓝绿发布 / 双写迁移——新旧两套并存,验证通过再切流 | Embedding 换代时新旧两个集合并行写入,后台重建完历史数据、用 Recall 验证达标后切流,最后删旧集合 | 普通蓝绿的两套服务读同一份数据、行为等价,可随时来回切;这里新旧集合的检索结果本就不同(换了 embedding 就是换了语义空间),"验证"不是比对一致性而是比对召回质量指标谁更高。回滚窗口取决于旧集合何时删——删早了就没有退路 |
| 版本切换(Index Versioning) | 别名切换(如 ES 的 alias 指向新索引) | 用一个逻辑别名指向当前生效的集合版本,切流即改指向,回滚即改回来 | 别名切换是原子的、瞬时一致;这里在线请求可能跨越切换点(一个会话的前半段查了旧集合、后半段查了新集合),导致同一轮对话里检索口径不一致。长会话场景需要把版本固定在会话粒度 |
| Recall 度量(治理验收口径) | 迁移后的数据校验——用业务指标而非"没报错"来判断成功 | 用固定评测集的 Recall@K 作为清理/迁移是否达标的唯一判据 | 数据迁移可以用行数与校验和精确比对,等值即成功;这里没有"等值"可比——只能用统计指标看是否不劣于基线,且指标本身有波动。所以要预先定好"不劣于基线多少个点"的验收线,见 评测方法论 |
本篇引用的其他上下文名词
| 名词 | 在本篇里的角色 | 完整解释去哪读 |
|---|---|---|
| 文本 Embedding / HNSW / IVF-PQ / 召回率 | 被治理的对象与其索引结构 | RAG 主线 · 名词速查 |
| chunk_size / overlap / 元数据注入 | 入库前的策略,改动它们即触发本篇的重建流程 | RAG 数据清理 · 名词速查 |
| Golden Set / 回归评测 | Recall 验收线的基线来源 | 评测方法论 · 名词速查 |
1. 场景问题
典型触发场景
| 触发事件 | 症状 |
|---|---|
| 业务文档迭代(产品改版、政策更新) | LLM 给出"过时答案",新旧版本矛盾 |
| 早期接入时未做清洗直接灌库 | 向量空间被噪声污染,召回质量持续下降 |
| Embedding 模型升级 | 旧向量与新查询向量分布不一致,相似度虚高/虚低 |
| 知识库规模膨胀 | 存储成本飙升,大量无效 chunk 占用索引空间 |
| 数据合规/权限变更 | 部分文档需要下线(隐私、保密级别变化) |
核心难点
- 无法停服全量重建:业务 7×24 在线,不能直接清空重灌
- 不知道哪些脏:没有数据血缘,无法精确定位问题 chunk
- 向量不可读:float 向量不像文本,无法人工 review
- 大规模操作风险:误删会导致召回率骤降
2. 实现方案
2.1 存量审计:摸清家底
打个比方:这就是接手一个没人管过的数据库时的第一件事——先跑一遍统计:多少行、什么时候写的、哪些表没人读、有没有重复主键、有没有孤儿外键。不摸清家底就动手清理,等于闭着眼删数据。类比失效边界:数据库里"这行有没有被读过"能从慢查询日志和访问统计里查得明明白白;向量库里一条 chunk"有没有被召回过"默认是不记录的——除非你一开始就埋了召回日志。所以审计的前提往往得先补埋点,而已经过去的那段时间是查不回来的。
第一步:元数据统计
from qdrant_client import QdrantClient
client = QdrantClient(host="localhost", port=6333)
# 统计各来源文档的 chunk 数量和时间分布
def audit_metadata(collection: str):
result = {}
offset = None
while True:
points, offset = client.scroll(
collection_name=collection,
scroll_filter=None,
limit=1000,
offset=offset,
with_payload=True,
with_vectors=False, # 只读元数据,省内存
)
for p in points:
src = p.payload.get("source", "unknown")
ts = p.payload.get("created_at", "")
result.setdefault(src, {"count": 0, "oldest": ts})
result[src]["count"] += 1
if ts < result[src]["oldest"]:
result[src]["oldest"] = ts
if offset is None:
break
return result
第二步:识别孤儿 chunk(原文档已删除)
import os
def find_orphan_chunks(audit: dict, doc_root: str) -> list[str]:
"""返回源文件已不存在的 source 列表"""
orphans = []
for src in audit:
if src.startswith("http"):
continue # URL 类另行处理
if not os.path.exists(os.path.join(doc_root, src)):
orphans.append(src)
return orphans
第三步:检测过期文档(超过 TTL 或有新版本)
from datetime import datetime, timedelta
def find_stale_chunks(audit: dict, ttl_days: int = 180) -> list[str]:
cutoff = datetime.now() - timedelta(days=ttl_days)
stale = []
for src, info in audit.items():
try:
ts = datetime.fromisoformat(info["oldest"])
if ts < cutoff:
stale.append(src)
except Exception:
pass
return stale
2.2 存量去重:清除语义重复
存量去重比增量去重更复杂:需要在已有向量间做近似最近邻(ANN)搜索,聚合相似簇,再决策保留哪一个。
打个比方:向量库存量治理像衣柜换季——当季常穿的挂前排(热数据/新版本),过季但可能再穿的收纳箱塞床下(冷数据分层),再不穿的送人或扔(TTL + 蓝绿双写切流后删旧集合)。不定期清理,衣柜迟早爆炸、翻半天找不到想穿的那件。类比失效边界:现实里过期的旧衣服"留一件也没影响",向量库里过期的旧 chunk 却会主动污染检索结果——旧版文档和新版并存时,ANN 完全可能把旧版分数排更高、把过时答案顶回给用户,用户看不到"这是老版本"的标签。所以清理不能只打标签让它排后面(软标记),必须硬删或走版本隔离/蓝绿集合,并且删前留证(先备份或降级到冷存储),才能既不误伤召回又断掉污染路径。
import numpy as np
from sklearn.cluster import DBSCAN
def dedup_by_vector(collection: str, eps: float = 0.08, min_samples: int = 2):
"""
DBSCAN 聚类:同一簇内的 chunk 语义高度相似 → 保留最新/最长,删其余。
eps 对应余弦距离阈值(0.08 ≈ 余弦相似度 0.92)
"""
# 1. 批量拉取所有向量
points, _ = client.scroll(collection, limit=10000, with_vectors=True)
ids = [p.id for p in points]
vecs = np.array([p.vector for p in points])
metas = [p.payload for p in points]
# 2. 余弦距离矩阵(大规模用 FAISS 替代)
norms = np.linalg.norm(vecs, axis=1, keepdims=True)
normed = vecs / (norms + 1e-9)
dist = 1 - normed @ normed.T # 余弦距离
# 3. DBSCAN 聚类
labels = DBSCAN(eps=eps, min_samples=min_samples, metric="precomputed").fit_predict(dist)
# 4. 每个簇保留最新的 chunk,其余标记删除
to_delete = []
for cluster_id in set(labels):
if cluster_id == -1:
continue # 噪声点(唯一 chunk),保留
cluster_idx = [i for i, l in enumerate(labels) if l == cluster_id]
# 按 created_at 降序,保留最新
cluster_idx.sort(
key=lambda i: metas[i].get("created_at", ""),
reverse=True
)
to_delete.extend(ids[i] for i in cluster_idx[1:]) # 保留第一个
return to_delete
# 执行删除(分批,避免大事务)
def batch_delete(collection: str, ids: list, batch_size: int = 200):
for i in range(0, len(ids), batch_size):
client.delete(
collection_name=collection,
points_selector=ids[i:i+batch_size],
)
print(f"deleted {min(i+batch_size, len(ids))}/{len(ids)}")
2.3 版本迁移:Embedding 模型升级
打个比方:换 embedding 模型等于换了索引的排序规则(好比 MySQL 改了字符集 collation)——旧索引项还在,但比较规则变了,查出来的结果就不对了。所以标准做法也一样:双写 + 蓝绿切换,新索引在旁边建好、校验通过再切流量,而不是原地改。类比失效边界:换 collation 后查错了会报错或明显乱序,你立刻知道出事了;换 embedding 模型后新旧向量混用不报任何错——相似度照样算得出一个 0.83,只是这个数字毫无意义。故障是静默的,只表现为"召回质量莫名下降"。所以切换必须以"整库重算完毕"为前提,不能新旧混存,且切换前后要用同一批 query 对比召回集。
Embedding 模型升级后旧向量必须重建,否则 query 向量与 doc 向量分布不同,相似度失真。
蓝绿双写策略(不停服):
import asyncio
from sentence_transformers import SentenceTransformer
new_model = SentenceTransformer("BAAI/bge-large-zh-v1.5") # 新模型
async def rebuild_collection(old_col: str, new_col: str, batch_size: int = 64):
"""异步批量重建,后台运行,不影响线上查询"""
# 先建新集合(新向量维度可能不同)
client.recreate_collection(
collection_name=new_col,
vectors_config={"size": 1024, "distance": "Cosine"},
)
offset = None
total = 0
while True:
points, offset = client.scroll(
collection_name=old_col,
limit=batch_size,
offset=offset,
with_payload=True,
with_vectors=False, # 拿文本重 embed,不复用旧向量
)
if not points:
break
texts = [p.payload["text"] for p in points]
new_vecs = new_model.encode(texts, normalize_embeddings=True).tolist()
client.upsert(
collection_name=new_col,
points=[
{"id": p.id, "vector": v, "payload": p.payload}
for p, v in zip(points, new_vecs)
]
)
total += len(points)
print(f"rebuilt {total} chunks...")
await asyncio.sleep(0.01) # 让出 IO,避免打满 CPU
if offset is None:
break
print(f"✅ rebuild done: {total} chunks → {new_col}")
2.4 在线修复:patch 元数据 / 修正文本
有时只需修正 chunk 的元数据或文本内容,不需要重新 embed:
# 批量给某 source 的所有 chunk 打上新字段
def patch_metadata(collection: str, source: str, patch: dict):
from qdrant_client.models import Filter, FieldCondition, MatchValue, SetPayload
client.set_payload(
collection_name=collection,
payload=patch,
points=Filter(
must=[FieldCondition(key="source", match=MatchValue(value=source))]
),
)
# 示例:给旧文档标记为 deprecated
patch_metadata(
"knowledge_base",
source="docs/old-api-v1.pdf",
patch={"status": "deprecated", "deprecated_at": "2025-06-01"},
)
2.5 完整治理流程
3. 为什么这么做
| 决策 | 理由 |
|---|---|
| 不停服审计(scroll 分页) | 向量库无法像 DB 一样全量 dump,分批 scroll 是唯一安全方式 |
| DBSCAN 做语义去重 | 存量去重无法预知簇边界,DBSCAN 不需要预设 K,自动发现密集簇 |
| 蓝绿双写升级 | 向量维度变化后就地修改不可行;双写保证查询不中断 |
| 保留最新版本 chunk | 相似簇中时间最新的通常是最准确的版本 |
| 分批删除 | 单次大批量删除会触发向量库 compaction 阻塞查询 |
4. 为什么别的选择不行
4.1 "全量清空重建" → 服务中断 + 成本不可接受
对于百万级 chunk,重 embed 需数小时,期间 RAG 完全不可用。业务无法接受。
4.2 "只删孤儿,不处理语义重复" → 召回质量持续下降
文档改版不会删旧文件,只会新增文件。不做语义去重,新旧版本同时存在,LLM 会给出矛盾答案。
4.3 "直接复制旧向量到新集合(不重 embed)" → 向量分布失真
不同 Embedding 模型的向量空间完全不同,直接复用旧向量做相似度计算结果无意义。
4.4 "用精确文本 hash 去重代替 DBSCAN" → 漏掉改版文档
内容从"支持 iOS 14" 改为"支持 iOS 17",hash 完全不同,但 95% 内容重复,两个版本同时存在会误导 LLM。
4.5 "人工审核每个 chunk" → 不可扩展
百万级 chunk,人工审核成本 O(n),不现实。需要自动化 pipeline + 人工抽样验收。
5. 沉淀结论
存量治理四板斧:
| 阶段 | 动作 | 工具 |
|---|---|---|
| 审计 | scroll 统计 + 孤儿/过期识别 | Qdrant scroll API |
| 去重 | DBSCAN 语义聚类 → 保新删旧 | sklearn + 向量批量拉取 |
| 修复 | patch 元数据 / 重分块重 embed | set_payload + upsert |
| 升级 | 蓝绿双写 → 后台重建 → 切流量 | 双集合 + 异步重建 |
核心原则:
- 审计先行:不知道脏在哪,不要随意删
- 增量而非全量:以 source 为单位操作,最小化影响范围
- 删前留证:记录删除的 chunk ID 和原因,方便回溯
- 用 Recall 度量:清理效果必须用 query 集合的 Recall@10 验证,而不是靠"感觉"
- 定期治理:建立周/月级别的存量审计 cron,而非一次性清理
建议治理频率:
孤儿 chunk → 每次文档删除时实时清理
过期 chunk → 每月扫描一次
语义去重 → 季度一次(成本高)
模型升级 → 按需,蓝绿迁移
记忆口诀
- 四板斧:审计 / 去重 / 修复 / 升级
- 四原则:审计先行 / 增量非全量 / 删前留证 / Recall 度量
- 去重:DBSCAN 语义聚类 / 保新删旧 / 分批小事务
- 升级:蓝绿双写 → 后台重建 → 切流 → 删旧(全程不停服)
6. 面试常见问题清单(按主题分类)
为什么不能简单粗暴
- Q:为什么不全量清空重建? A:业务 7×24 在线,清空重灌会服务中断 + 重 embed 百万 chunk 成本不可接受;要不停服、增量治理。
- Q:向量不可读,怎么知道哪些脏? A:靠审计——scroll 全量扫元数据统计来源/时间分布,识别孤儿(源文档已删)、过期、异常长度 chunk;没有血缘就先补元数据。
去重与迁移
- Q:存量去重为什么用 DBSCAN 而非文本 hash? A:文本 hash 只拦字节相同的副本;改版文档语义重复但文本不同,需按向量做密度聚类(DBSCAN,eps≈0.15),同簇保最新删旧。
- Q:Embedding 模型升级,旧向量能直接复制到新集合吗? A:不能——新旧模型向量空间不一致,query 与 doc 分布不匹配会相似度失真;必须重新 embed。用蓝绿双写:双写→后台异步重建新集合→切查询流量→确认稳定删旧集合,全程不停服。
安全与度量
- Q:大规模删除怎么防误删? A:以 source 为单位增量操作、分批小事务;删前记录 chunk ID + 原因可回溯;灰度 + 抽样验收。
- Q:清理效果怎么衡量? A:用固定 query 集合的 Recall@10 前后对比,而不是靠"感觉数字好看"。
延伸阅读:RAG 数据清理 · RAG 检索增强生成 · RAG 上下文剪枝
自测:合上资料能说清楚吗?
- 为什么存量治理不能"全量清空重建"?应该用什么思路替代?
参考答案
业务 7×24 在线,清空重灌会服务中断,且百万 chunk 重 embed 成本不可接受。替代思路:不停服、增量治理,以 source 为单位分批 scroll 审计 + 小事务操作。
- 向量不可读,如何定位"哪些 chunk 是脏的"?
参考答案
靠审计:scroll 全量扫元数据统计各来源 chunk 数与时间分布,识别孤儿(源文档已删)、过期(超 TTL/有新版本)、异常 chunk。缺血缘就先 patch 补元数据。
- 对比:存量去重用 DBSCAN 语义聚类 和 文本 hash 各有什么问题,为什么选前者?
参考答案
文本 hash 只拦字节完全相同的副本,改版文档(如"iOS14→iOS17")hash 不同但语义 95% 重复会漏掉。DBSCAN 按向量密度聚类,无需预设 K,自动发现相似簇,同簇保新删旧,能识别语义重复。
- 对比:Embedding 模型升级时,"直接复制旧向量到新集合" 和 "蓝绿双写重建" 的差异?
参考答案
直接复制旧向量:新旧模型向量空间不一致,query 与 doc 分布不匹配导致相似度失真,结果无意义。蓝绿双写:双写→后台异步重新 embed→切查询流量→稳定后删旧集合,全程不停服且向量分布一致。
- 大规模删除如何防误删?清理效果怎么衡量?
参考答案
防误删:以 source 为单位增量操作、分批小事务(避免 compaction 阻塞)、删前记录 chunk ID + 原因可回溯、灰度抽样验收。度量:用固定 query 集合的 Recall@10 前后对比,而非"感觉"。