做检索增强(RAG)的人都知道一句话:检索质量的上限,是切分质量决定的。
但企业侧的文档和公开语料完全不同------PDF 是扫描件混杂排版文本,Word 是十几年攒下来的各种模板,表格嵌在段落里,图片里还压着关键参数。直接按固定长度切,切出来的块要么截断在半句话上,要么把三件不相关的事揉在一起。
本文记录一套在多个项目里验证过的预处理流水线:抽取 → 清洗 → 语义切分 → 元数据补全 → 落库。所有代码可直接运行。
一、先解决抽取:PDF 的坑比想象中多
PDF 抽取的第一原则是:**不要假设 PDF 里有文字层**。扫描件必须先过 OCR,否则你拿到的是空字符串,而且后续环节不会报错,只会静默丢数据。
```python
extract.py
import fitz # PyMuPDF
from pathlib import Path
def extract_pdf(path: str) -> listdict:
"""返回按页组织的文本块,附带页面坐标用于版面还原"""
doc = fitz.open(path)
pages = \[\]
for i, page in enumerate(doc):
text = page.get_text("text").strip()
if len(text) < 20: # 疑似扫描页
text = _ocr_page(page)
blocks = page.get_text("blocks") # (x0, y0, x1, y1, text, block_no, type)
pages.append({
"page": i + 1,
"text": text,
"blocks": b\[4 for b in blocks if b6 == 0],
})
return pages
def _ocr_page(page) -> str:
pix = page.get_pixmap(dpi=200)
接本地 OCR 服务即可,这里用占位函数表示
return ocr_client.recognize(pix.tobytes("png"))
```
Word 文档用 `python-docx` 处理时,表格必须单独走一条路,否则表格内容会被当成普通段落,行列关系全丢:
```python
import docx
def extract_docx(path: str) -> listdict:
d = docx.Document(path)
units = \[\]
body = d.element.body
for child in body.iterchildren():
if child.tag.endswith('}p'):
p = docx.text.paragraph.Paragraph(child, d)
if p.text.strip():
units.append({"type": "para", "text": p.text.strip()})
elif child.tag.endswith('}tbl'):
tbl = docx.table.Table(child, d)
units.append({
"type": "table",
"rows": \[c.text.strip() for c in r.cells for r in tbl.rows],
})
return units
```
把表格转成 Markdown 再入库,比直接拼字符串好得多------大模型对 Markdown 表格的理解稳定性远高于乱序文本。
二、语义切分:别再用固定长度了
固定长度切分(`RecursiveCharacterTextSplitter` 默认行为)在企业文档上表现很差,因为它不理解章节边界。
更稳的做法是**两级切分**:先按标题层级切成"章节",再在章节内按句子聚合,控制到目标长度。
```python
chunk.py
import re
from dataclasses import dataclass, field
HEADING = re.compile(r'^(#{1,4}\s|\d+(\.\d+)*\\s、|第一二三四五六七八九十+章节条)')
@dataclass
class Chunk:
text: str
section: str = ""
tokens: int = 0
meta: dict = field(default_factory=dict)
def split_by_section(units: listdict) -> listChunk:
chunks, cur_title, buf = \[\], "前言", \[\]
for u in units:
t = u"text" if u"type" == "para" else ""
if u"type" == "table":
t = table_to_md(u"rows")
if HEADING.match(t):
if buf:
chunks.append(Chunk("\n".join(buf), cur_title))
buf = \[\]
cur_title = t.strip()
else:
buf.append(t)
if buf:
chunks.append(Chunk("\n".join(buf), cur_title))
return chunks
def pack_sentences(chunk: Chunk, max_len: int = 480, overlap: int = 60) -> listChunk:
"""在章节内按句聚合,避免跨章节拼接"""
sents = re.split(r'(?<=。!?;)', chunk.text)
out, buf, size = \[\], \[\], 0
for s in sents:
n = len(s)
if size + n > max_len and buf:
out.append(Chunk("".join(buf), chunk.section, size, dict(chunk.meta)))
buf, size = \[\], 0
buf.append(s)
size += n
if buf:
out.append(Chunk("".join(buf), chunk.section, size, dict(chunk.meta)))
return _apply_overlap(out, overlap)
```
两个细节值得注意:
-
**overlap 只在章节内生效**。跨章节做重叠会把"产品 A 的参数"和"产品 B 的售后条款"粘到一起,检索时污染上下文。
-
**块不是越小越好**。企业问答里,很多问题需要完整的一段条件描述("在 XX 情况下,YY 不适用于 ZZ"),切成 100 字会把条件句和结论句分开,检索只命中一半,答案就错了。480 字左右是个经验值。
三、元数据:决定了你能不能精准过滤
切完不补全元数据,等于建了个没有索引的库。企业场景里最有用的四个字段:
| 字段 | 作用 | 获取方式 |
|---|---|---|
| `product_line` | 按产品线过滤,避免跨产品串答 | 从文件路径/章节标题正则提取 |
| `doc_type` | 区分说明书/合同/推文,权重不同 | 文件名规则 + 内容特征 |
| `effective_date` | 过期内容降权 | 正文日期正则 + 文件 mtime 兜底 |
| `source` | 溯源,答错能定位原文 | 文件路径 + 页码/段落号 |
```python
meta.py
import re
from datetime import datetime
DATE_PAT = re.compile(r'(20\d{2})-/年(\d{1,2})-/月?(\d{1,2})?')
def enrich(chunk: Chunk, file_path: str) -> Chunk:
text = chunk.text
m = DATE_PAT.search(text)
eff = None
if m:
y, mo, d = m.group(1), int(m.group(2)), int(m.group(3) or 1)
eff = datetime(int(y), mo, d).date().isoformat()
chunk.meta.update({
"source": file_path,
"section": chunk.section,
"effective_date": eff,
"doc_type": guess_type(file_path, text),
"has_number": bool(re.search(r'\d+(\.\d+)?\s*(mm|kg|℃|%|MPa)', text)),
})
return chunk
```
`has_number` 这个字段看着奇怪,实践里很有用:检索时可以给"含可核实参数"的块加权,因为这类内容更适合作为回答的事实依据。
四、落库与去重
最后落到 JSONL,一行一个块,方便后续灌入任意向量库:
```python
import json, hashlib
def dump(chunks: listChunk, out_path: str):
seen = set()
with open(out_path, "w", encoding="utf-8") as f:
for c in chunks:
key = hashlib.md5(re.sub(r'\s+', '', c.text).encode()).hexdigest()
if key in seen: # 全文级去重,模板化文档大量重复
continue
seen.add(key)
f.write(json.dumps({"text": c.text, **c.meta}, ensure_ascii=False) + "\n")
```
去重必须在切分之后做,而且要用"去空白后的全文哈希"------企业文档里同一段免责声明可能出现在几十份文件里,不去重会严重稀释向量空间。
五、怎么验证切得好不好
给一条笨但有效的验证法:人工挑 30 条真实业务问题,让检索器返回 top-5,统计两个指标------
-
**完整命中率**:top-5 里是否存在一个块,能独立回答这个问题(不需要拼其他块);
-
**污染率**:top-5 里是否存在跨产品/跨时间线的块。
完整命中率低于 70%,通常是切太大;污染率高于 20%,通常是元数据没补全或者 overlap 跨了章节。
这两个指标调上去之后,再去动模型和 prompt,才有意义。顺序反了,只会越调越玄学。
六、小结
-
PDF 先判扫描件,空文本必须 OCR 兜底,否则静默丢数据;
-
两级切分:章节边界优先,句聚合控制在 480 字左右,overlap 不跨章节;
-
元数据至少补齐产品线、文档类型、生效日期、溯源位置四列;
-
切完再全文去重,用去空白哈希;
-
用 30 条真实问题测完整命中率和污染率,先调数据再调模型。
(本文由一支长期做企业知识库与内容工程的技术团队整理,欢迎同行交流指正。)