笃行
首页
个人 & 心法
互联网/硬件后台
游戏基础架构
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 数据清理

定位:RAG 质量的天花板在数据,检索再好也救不了噪声文档。本篇聚焦数据清理全链路:从原始文件抽取、分块、元数据注入、去重,到质量过滤,每环节说清楚"是什么 → 怎么做 → 为什么这么做 → 为什么别的不行 → 沉淀结论"。

本篇是 RAG 主线 入库前环节(Chunking/数据准备)的实现级深潜,主线只做概览、细节在此不重复。

🧠 一句话记忆锚点

RAG 质量的天花板在数据,不在模型:干净数据能把 Recall@10 从 60% 拉到 85%。链路记五步——格式专用抽取(+OCR 兜底) → 分块(chunk_size + overlap 是最高杠杆超参) → 元数据注入(溯源/权限/时效) → 两阶段去重(精确 MD5 + 语义 MinHash) → 质量门禁(垃圾进垃圾出)。

名词速查

首次阅读可跳过本表,直接看「场景问题」,遇到生词再回查。

本篇是检索里「入库前 / 数据准备」子域的名词归属页。这一组几乎就是一条 ETL 流水线——抽取、清洗、打标、去重、质量门禁,你在数据同步里做过的每一步都能对上。

名词后台类比在系统里干什么类比失效边界
结构化抽取(Extraction)按格式选专用解析器——不同 Content-Type 走不同 decoder从 PDF / Word / HTML / 扫描件里取出正文与结构(标题层级、表格、列表),OCR 兜底解析器失败会抛异常;文档抽取的失败往往是静默的——PDF 双栏被读成交错乱序、表格被拉平成一行、页眉页脚混进正文,全都不报错。所以必须抽样人工核对,不能只看"有没有报错"
元数据注入(Metadata)给每行数据加上 source / tenant_id / updated_at 等系统列给每个 chunk 挂上来源、权限域、时效、章节路径,供检索时过滤与答案溯源数据库的系统列由框架自动维护、天然一致;chunk 的元数据靠抽取环节猜(章节归属尤其容易错),一旦错了,权限过滤就会失效——把 A 部门的文档答给 B 部门的人。这是安全问题,见 LLM 应用安全
精确去重(MD5)主键 / 唯一索引去重——字节级完全一致才算重复按内容哈希拦掉完全相同的文档或段落,成本极低,先跑这一层唯一索引能保证库内无重复;MD5 只能拦字节完全一致的——同一份文档改了一个标点、多了一个空行,哈希就完全不同,需要下一层语义去重接手
语义去重(MinHash / SimHash / LSH)布隆过滤器式的近似判重——用签名快速判"大概重复"用 shingle 签名的重合度估计文本相似度,拦掉"改了几个字的转载稿"布隆过滤器只有假阳性、没有假阴性,边界明确;MinHash 两种错误都有——阈值定高了漏掉重复稿,定低了误删了内容相近但事实不同的两份文档(如同一产品的 v1/v2 说明书)。阈值必须按语料实测
chunk_size / overlap批处理的批大小 + 滑动窗口的重叠量分块的两个核心超参:块多大、相邻块重叠多少 token。是整条链路里最高杠杆的超参批大小调错了只影响吞吐,随时可改;这两个参数一旦定下并灌完库,改动意味着整库重切重灌(成本等同一次全量迁移)。所以要在小样本上先把它调好,见 RAG 存量清理 的版本迁移方案
质量门禁(Quality Filter)入库前的数据校验规则 / 脏数据拦截器按长度、语言、乱码率、重复率、信息密度等规则丢掉低质片段——垃圾进垃圾出业务校验规则的通过标准是明确的(字段非空、格式合法);"内容质量"没有客观阈值,规则定严了会丢掉合法的短文档(如术语表、FAQ 条目),定松了噪声照旧进库。需要按语料抽样标定

本篇引用的其他上下文名词

名词在本篇里的角色完整解释去哪读
Chunking / 文本 Embedding / 召回率 / HNSW本篇服务的主线环节RAG 主线 · 名词速查
陈旧向量 / 版本切换 / 蓝绿双写已入库数据的治理(本篇之后的环节)RAG 存量清理 · 名词速查
PII / 权限最小化元数据错误导致的越权风险LLM 应用安全 · 名词速查

1. 场景问题

你在做什么:把企业内部文档(PDF、Word、网页、数据库 FAQ 表)接入 RAG,供 LLM 问答。

典型痛点:

症状根因
LLM 答非所问chunk 里夹杂页眉/水印/乱码,信噪比低
相同内容被检索两次文档多版本共存,语义重复
长 chunk 超上下文窗口分块策略粗暴(整段塞进去)
检索命中但 LLM 无法定位缺少元数据(来源、时间、章节)
低质量文档拉低 Recall没有质量门禁,垃圾进垃圾出

2. 实现方案

2.1 原始文件抽取

不同格式用对应工具,统一输出 { text, metadata } 结构:

# PDF
import fitz  # PyMuPDF
def extract_pdf(path: str) -> list[dict]:
    doc = fitz.open(path)
    pages = []
    for i, page in enumerate(doc):
        text = page.get_text("text")          # 优先 text 层
        if len(text.strip()) < 50:            # 疑似扫描件
            text = page.get_text("blocks")    # fallback 块模式
        pages.append({
            "text": clean_noise(text),
            "meta": {"source": path, "page": i + 1}
        })
    return pages

# HTML(网页)
from trafilatura import extract
def extract_html(html: str, url: str) -> dict:
    text = extract(html, include_tables=True, no_fallback=False)
    return {"text": text or "", "meta": {"source": url}}

# Excel / CSV
import pandas as pd
def extract_table(path: str) -> list[dict]:
    df = pd.read_excel(path)  # 或 read_csv
    rows = []
    for _, row in df.iterrows():
        rows.append({
            "text": " | ".join(f"{k}: {v}" for k, v in row.items() if pd.notna(v)),
            "meta": {"source": path, "row": _}
        })
    return rows

噪声清洗 clean_noise:

import re
def clean_noise(text: str) -> str:
    text = re.sub(r'\f', '\n', text)                    # 换页符
    text = re.sub(r'[\x00-\x08\x0b-\x0c\x0e-\x1f]', '', text)  # 控制字符
    text = re.sub(r'第 \d+ 页.*?共 \d+ 页', '', text)   # 页眉/页脚
    text = re.sub(r'[ \t]{3,}', ' ', text)              # 多余空白
    text = re.sub(r'\n{3,}', '\n\n', text)              # 多余空行
    return text.strip()

2.2 分块策略(Chunking)

打个比方:切块就是分库分表选拆分键——粒度定下来,后面所有查询的效率上限就定了。选粗了一个块混多主题(像大表扫描,捞回来一堆无关行);选细了语义被切断(像把一条业务记录拆到两张表,单看哪张都不成立)。类比失效边界:分表切错了还能按主键 join 回来,数据没丢;chunk 切断了语义是永久损失——被切散的因果关系、跨段的表格、指代关系在检索时无法还原,下游的 rerank 和剪枝再聪明也变不出没召回的内容。所以它是整条链路上杠杆最高、且唯一不可逆的超参。

三种主流策略,按场景选:

下图直观展示为什么要 overlap:滑动窗口逐块右移,若无重叠,跨块的实体/数字/句子会被硬切断;相邻块保留一段 overlap(高亮区),就把"骑在边界上"的语义缝合回来。

原始长文本……上下文连续的文本流,实体与数字可能骑在任意边界上……切出的块(相邻块 overlap 高亮) chunk A(512 token)chunk Bchunk C绿色 = overlap,缝合被边界切断的语义

a) 固定长度分块

from langchain.text_splitter import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=512,       # token 数(用 tiktoken 计算)
    chunk_overlap=64,     # 重叠防止语义断裂
    separators=["\n\n", "\n", "。", ";", " "],  # 优先按自然段
)
chunks = splitter.split_text(text)

适用:均质文本(新闻、日志)、快速上线。

b) 语义分块(Semantic Chunking)

from sentence_transformers import SentenceTransformer
import numpy as np

model = SentenceTransformer("BAAI/bge-small-zh-v1.5")

def semantic_chunk(sentences: list[str], threshold: float = 0.8) -> list[str]:
    embs = model.encode(sentences, normalize_embeddings=True)
    chunks, cur = [], [sentences[0]]
    for i in range(1, len(sentences)):
        sim = float(np.dot(embs[i-1], embs[i]))
        if sim < threshold:       # 语义跳跃 → 切块
            chunks.append("".join(cur))
            cur = []
        cur.append(sentences[i])
    if cur:
        chunks.append("".join(cur))
    return chunks

适用:技术文档、长篇论文,语义连贯性要求高。

c) 层次分块(Hierarchical / Parent-Child)

文档
├── 章节(parent chunk,存入向量库做粗排)
│   ├── 段落(child chunk,存入向量库做精排)
│   └── ...

检索时:命中 child → 返回 parent 给 LLM,保留上下文完整性。

# 伪代码
parent_chunks = split_by_heading(doc)  # 按 H2/H3 切
for p in parent_chunks:
    child_chunks = fixed_split(p, size=256, overlap=32)
    for c in child_chunks:
        c["parent_id"] = p["id"]       # 关联回 parent

这一节记住三句话

  1. 切块是整条链路最高杠杆、也最不可逆的一步。分表切错了还能按主键 join 回来;chunk 切断了语义就永久丢失——下游的 rerank 与剪枝再聪明也变不出没召回的内容。
  2. overlap 的作用是缝合"骑在边界上"的语义。无重叠时跨块的实体、数字、句子会被硬切断,检索到的块自己不成立。
  3. 优先按结构切而不是按定长切,并用父子块解耦两个矛盾的目标:小块(child)做精准召回,喂给模型时补上所在大块(parent)保上下文完整。

2.3 元数据注入

每个 chunk 必须带元数据,供过滤和引用:

chunk_doc = {
    "id": sha256(text + source),       # 全局唯一 ID
    "text": chunk_text,
    "metadata": {
        "source": "docs/faq.pdf",
        "page": 3,
        "section": "安装指南",
        "created_at": "2024-06-01",
        "doc_type": "pdf",             # 供检索时 filter
        "language": "zh",
    }
}

向量库(Weaviate / Qdrant / Milvus)支持 metadata filter,检索时可以:

results = collection.query(
    vector=query_emb,
    filter={"doc_type": "pdf", "created_at": {"gte": "2024-01-01"}},
    limit=5
)

打个比方:RAG 数据清理就像图书馆入库前的编目——撕坏页、去掉旧目录、清页眉水印(噪声清洗),按学科切成合适的册数(chunking + overlap 缝合骑边语义),贴分类号 / RFID / 出版年份(元数据注入 source/page/created_at),再查重下架(精确 MD5 + 语义 MinHash 两阶段去重)。不做这步,架子上堆的就是散装书,检索再快也捞不到有用的那本。类比失效边界:图书馆编目一旦贴上就用几十年(物理书不会变),但向量库的编目常因 embedding 模型换代整批过期——同一段文本换了模型就落在不同的向量空间,旧库和新 query 相似度直接失真。所以清理不能只想着"入库当下干净",还必须记录每条 chunk 用的 embedding 模型版本、切分策略、清洗规则,方便按需重灌(见 存量清理的蓝绿双写),否则升级 embedding 就等于把整个图书馆的分类号作废。


2.4 去重

精确去重(MD5/SHA256)

import hashlib, redis

r = redis.Redis()
def dedup_exact(text: str) -> bool:
    h = hashlib.md5(text.encode()).hexdigest()
    if r.sismember("seen_hashes", h):
        return True   # 重复
    r.sadd("seen_hashes", h)
    return False

语义去重(MinHash LSH)

from datasketch import MinHash, MinHashLSH

lsh = MinHashLSH(threshold=0.85, num_perm=128)

def get_minhash(text: str) -> MinHash:
    m = MinHash(num_perm=128)
    for word in text.split():
        m.update(word.encode())
    return m

def dedup_semantic(doc_id: str, text: str) -> bool:
    m = get_minhash(text)
    result = lsh.query(m)
    if result:
        return True   # 近似重复
    lsh.insert(doc_id, m)
    return False

2.5 质量过滤

def quality_filter(chunk: dict) -> bool:
    text = chunk["text"]
    # 1. 长度过滤
    if len(text) < 50 or len(text) > 2000:
        return False
    # 2. 中文字符比例(防止乱码/纯英文/代码块混入正文)
    zh_ratio = sum(1 for c in text if '\u4e00' <= c <= '\u9fff') / len(text)
    if zh_ratio < 0.1:
        return False
    # 3. 重复字符检测(如 "。。。。。。")
    if max(text.count(c) for c in set(text)) / len(text) > 0.3:
        return False
    # 4. 困惑度过滤(可选,用小型 LM 打分)
    # ppl = compute_perplexity(text)
    # if ppl > 500: return False
    return True

3. 为什么这么做

决策理由
格式专用抽取器通用正则无法处理 PDF 跨列、表格合并单元格、Word 样式
512 token + 64 overlap主流 Embedding 模型(BGE/OpenAI)训练长度,overlap 防止关键词被截断
层次分块LLM 需要完整上下文;子块提高检索精度,父块保证生成质量
元数据 filter多租户/多版本场景,纯向量检索无法做权限隔离和时效控制
MinHash 语义去重文档改版后旧版本大量相似块会稀释检索结果,导致 LLM 看到"过时答案"
长度+字符质量门极短 chunk 信息量不足,极长 chunk 超上下文;乱码污染 Embedding 空间

4. 为什么别的选择不行

4.1 "直接用 pdfplumber 全部提取" → 扫描件全军覆没

pdfplumber 依赖 PDF 文字层,扫描件返回空字符串。需要 OCR(PaddleOCR / Tesseract)兜底。

4.2 "固定长度 2048 token 大块" → 检索噪声暴增

大块召回时不相关内容多,LLM 上下文窗口浪费;且 Recall 提升有限(相关句被淹没)。

4.3 "不做 overlap" → 实体/数字被截断

"CPU 使用率达到 98%" 如果 "98%" 在上一块末尾而"告警阈值"在下一块开头,两块单独检索都无意义。

4.4 "只做精确去重" → 改版文档漏网

文档从"v1.0 支持 MySQL 5.7" 改成 "v2.0 支持 MySQL 8.0",MD5 完全不同,但 90% 内容重复,两版本同时存在会让 LLM 给出矛盾答案。

4.5 "不加元数据" → 无法溯源、无法审计

LLM 生成答案后用户追问"出自哪里"时,无法提供引用;合规场景(金融/医疗)要求文档来源可审计。


5. 沉淀结论

核心结论:

  1. 数据质量 > 模型选型:同样的 Embedding 模型,干净数据 Recall@10 能从 60% 提到 85%。
  2. 分块是 RAG 最高杠杆点:chunk_size 和 overlap 是最值得 A/B 测试的超参数。
  3. 元数据是 RAG 的"索引":向量检索解决语义相关,元数据 filter 解决权限/时效/来源约束。
  4. 去重要两阶段:精确去重 O(1) 拦截副本,语义去重拦截改版近似文档。
  5. 质量门是保险丝:宁可漏掉 5% 的边界文档,也不让噪声文档污染整个知识库。

记忆口诀

  • 五步链路:抽取(OCR 兜底) / 分块(size+overlap) / 元数据 / 去重 / 质量门
  • 分块选型:均质→固定 / 技术文档→语义 / 长文→层次(子精排 父喂 LLM)
  • 两阶去重:精确 MD5(拦副本) / 语义 MinHash(拦改版 ≈0.85)
  • 一句总纲:数据质量 > 模型选型,干净数据 Recall@10 从 60%→85%

6. 面试常见问题清单(按主题分类)

抽取与分块

  • Q:为什么不能 pdfplumber 一把梭全抽? A:扫描件是图片、无文本层,会全军覆没;要按格式选解析器 + OCR 兜底。
  • Q:chunk_size 怎么定?为什么要 overlap? A:太大噪声多、稀释检索;太小语义不完整。overlap(如 64 token)把骑在边界上的实体/数字/句子缝回来,防止被硬切断。
  • Q:固定 / 语义 / 层次分块怎么选? A:均质文本用固定长度;技术文档用语义分块(相似度阈值切);长文档用层次分块(Parent-Child,检索子块、喂父块)。

去重与质量

  • Q:为什么只做精确去重不够? A:精确 hash 只拦完全相同的副本;改版/微调文档语义重复但字节不同,要靠语义去重(MinHash LSH / 向量聚类,阈值 ~0.85)。
  • Q:不加元数据会怎样? A:无法溯源、无法做权限/时效/来源过滤,也无法审计"答案出自哪篇哪页"。
  • Q:质量门禁卡什么? A:长度下限、可打印字符比例、重复率、语言判定——垃圾进垃圾出,宁缺毋滥。

总纲

  • Q:RAG 效果不好,先调模型还是先洗数据? A:先洗数据——数据质量 > 模型选型,同一 Embedding 下干净数据可把 Recall@10 从 60% 提到 85%;分块是最高杠杆超参。

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

  1. RAG 链路的五个环节按顺序是什么?哪一步是"最高杠杆",为什么?
参考答案

抽取→分块→元数据→去重→质量门。分块杠杆最高:chunk_size 与 overlap 直接决定检索到的语义是否完整,最值得 A/B 测试。

  1. 为什么分块要保留 overlap?不做会出什么问题?
参考答案

相邻块保留重叠区,把骑在边界上的实体/数字/句子缝合回来。不做 overlap 时如"98%"与"告警阈值"被切到两块,单独检索都无意义。

  1. 对比精确去重(MD5)与语义去重(MinHash LSH):各拦什么?为什么只做前者不够?
参考答案

MD5 拦完全相同的副本(O(1));MinHash 拦改版/近似重复(阈值 ≈0.85)。改版文档字节不同但内容 90% 重复,只做精确去重会漏网,导致 LLM 给出矛盾/过时答案。

  1. 元数据注入解决了向量检索本身解决不了的哪些问题?
参考答案

向量检索只管语义相关;元数据 filter 补上权限隔离、时效控制、来源溯源,并满足合规审计(答案出自哪篇哪页)。

  1. 质量门禁通常卡哪些指标?它的设计取舍原则是什么?
参考答案

卡长度上下限、可打印/中文字符比例、重复率、语言判定(可选困惑度)。原则是宁缺毋滥——宁可漏 5% 边界文档,也不让噪声污染 Embedding 空间。

最近更新: 2026/9/10 11:38
Prev
RAG 上下文剪枝实战(Listwise Pruning 复现)
Next
RAG 存量数据清理