笃行
首页
个人 & 心法
互联网/硬件后台
游戏基础架构
UE 引擎
游戏业务
AI / 大模型
数据结构与算法
机器学习数学
通用基础
GitHub
首页
个人 & 心法
互联网/硬件后台
游戏基础架构
UE 引擎
游戏业务
AI / 大模型
数据结构与算法
机器学习数学
通用基础
GitHub
  • AI / 大模型

    • AI / 大模型
    • 大模型核心原理
    • 推理与微调优化
    • RAG 检索增强生成
    • RAG 上下文剪枝实战(Listwise Pruning 复现)
    • RAG 数据清理
    • RAG 存量数据清理
    • Agent 开发
    • Agent 运行时深水区
    • Multi-Agent 协作
    • Agent 评测与线上运营
    • AI 与游戏研发周期
    • 互动影游与长视频创作 Agent
    • LLM 应用安全
    • LLM 评测方法论
    • LLM 成本与延迟
    • 微调策略
    • AI 研发工程化

RAG 存量数据清理

定位:新增数据走"增量清理流水线",已入库的旧数据才是真正的历史包袱。本篇专注存量治理:如何在不停服的前提下,对向量库中已有的 chunk 做审计、修复、淘汰和版本迁移。五段式:场景 → 方案 → 为什么 → 为什么别的不行 → 沉淀结论。

本篇是 RAG 主线 的运维/版本化环节(非检索链路的一步,而是向量库长期治理),与数据清理的"入库前"互补。

🧠 一句话记忆锚点

存量治理不能停服全量重建,铁律是"审计先行 + 增量操作 + 删前留证 + 用 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 占用索引空间
数据合规/权限变更部分文档需要下线(隐私、保密级别变化)

核心难点

  1. 无法停服全量重建:业务 7×24 在线,不能直接清空重灌
  2. 不知道哪些脏:没有数据血缘,无法精确定位问题 chunk
  3. 向量不可读:float 向量不像文本,无法人工 review
  4. 大规模操作风险:误删会导致召回率骤降

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 元数据 / 重分块重 embedset_payload + upsert
升级蓝绿双写 → 后台重建 → 切流量双集合 + 异步重建

核心原则:

  1. 审计先行:不知道脏在哪,不要随意删
  2. 增量而非全量:以 source 为单位操作,最小化影响范围
  3. 删前留证:记录删除的 chunk ID 和原因,方便回溯
  4. 用 Recall 度量:清理效果必须用 query 集合的 Recall@10 验证,而不是靠"感觉"
  5. 定期治理:建立周/月级别的存量审计 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 上下文剪枝

自测:合上资料能说清楚吗?

  1. 为什么存量治理不能"全量清空重建"?应该用什么思路替代?
参考答案

业务 7×24 在线,清空重灌会服务中断,且百万 chunk 重 embed 成本不可接受。替代思路:不停服、增量治理,以 source 为单位分批 scroll 审计 + 小事务操作。

  1. 向量不可读,如何定位"哪些 chunk 是脏的"?
参考答案

靠审计:scroll 全量扫元数据统计各来源 chunk 数与时间分布,识别孤儿(源文档已删)、过期(超 TTL/有新版本)、异常 chunk。缺血缘就先 patch 补元数据。

  1. 对比:存量去重用 DBSCAN 语义聚类 和 文本 hash 各有什么问题,为什么选前者?
参考答案

文本 hash 只拦字节完全相同的副本,改版文档(如"iOS14→iOS17")hash 不同但语义 95% 重复会漏掉。DBSCAN 按向量密度聚类,无需预设 K,自动发现相似簇,同簇保新删旧,能识别语义重复。

  1. 对比:Embedding 模型升级时,"直接复制旧向量到新集合" 和 "蓝绿双写重建" 的差异?
参考答案

直接复制旧向量:新旧模型向量空间不一致,query 与 doc 分布不匹配导致相似度失真,结果无意义。蓝绿双写:双写→后台异步重新 embed→切查询流量→稳定后删旧集合,全程不停服且向量分布一致。

  1. 大规模删除如何防误删?清理效果怎么衡量?
参考答案

防误删:以 source 为单位增量操作、分批小事务(避免 compaction 阻塞)、删前记录 chunk ID + 原因可回溯、灰度抽样验收。度量:用固定 query 集合的 Recall@10 前后对比,而非"感觉"。

最近更新: 2026/9/10 11:38
Prev
RAG 数据清理
Next
Agent 开发