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(高亮区),就把"骑在边界上"的语义缝合回来。
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
这一节记住三句话
- 切块是整条链路最高杠杆、也最不可逆的一步。分表切错了还能按主键 join 回来;chunk 切断了语义就永久丢失——下游的 rerank 与剪枝再聪明也变不出没召回的内容。
- overlap 的作用是缝合"骑在边界上"的语义。无重叠时跨块的实体、数字、句子会被硬切断,检索到的块自己不成立。
- 优先按结构切而不是按定长切,并用父子块解耦两个矛盾的目标:小块(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. 沉淀结论
核心结论:
- 数据质量 > 模型选型:同样的 Embedding 模型,干净数据 Recall@10 能从 60% 提到 85%。
- 分块是 RAG 最高杠杆点:chunk_size 和 overlap 是最值得 A/B 测试的超参数。
- 元数据是 RAG 的"索引":向量检索解决语义相关,元数据 filter 解决权限/时效/来源约束。
- 去重要两阶段:精确去重 O(1) 拦截副本,语义去重拦截改版近似文档。
- 质量门是保险丝:宁可漏掉 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%;分块是最高杠杆超参。
自测:合上资料能说清楚吗?
- RAG 链路的五个环节按顺序是什么?哪一步是"最高杠杆",为什么?
参考答案
抽取→分块→元数据→去重→质量门。分块杠杆最高:chunk_size 与 overlap 直接决定检索到的语义是否完整,最值得 A/B 测试。
- 为什么分块要保留 overlap?不做会出什么问题?
参考答案
相邻块保留重叠区,把骑在边界上的实体/数字/句子缝合回来。不做 overlap 时如"98%"与"告警阈值"被切到两块,单独检索都无意义。
- 对比精确去重(MD5)与语义去重(MinHash LSH):各拦什么?为什么只做前者不够?
参考答案
MD5 拦完全相同的副本(O(1));MinHash 拦改版/近似重复(阈值 ≈0.85)。改版文档字节不同但内容 90% 重复,只做精确去重会漏网,导致 LLM 给出矛盾/过时答案。
- 元数据注入解决了向量检索本身解决不了的哪些问题?
参考答案
向量检索只管语义相关;元数据 filter 补上权限隔离、时效控制、来源溯源,并满足合规审计(答案出自哪篇哪页)。
- 质量门禁通常卡哪些指标?它的设计取舍原则是什么?
参考答案
卡长度上下限、可打印/中文字符比例、重复率、语言判定(可选困惑度)。原则是宁缺毋滥——宁可漏 5% 边界文档,也不让噪声污染 Embedding 空间。