企业非结构化资料拆分成可引用片段:一套能跑通的预处理流水线

做检索增强(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)

```

两个细节值得注意:

  1. **overlap 只在章节内生效**。跨章节做重叠会把"产品 A 的参数"和"产品 B 的售后条款"粘到一起,检索时污染上下文。

  2. **块不是越小越好**。企业问答里,很多问题需要完整的一段条件描述("在 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,才有意义。顺序反了,只会越调越玄学。

六、小结

  1. PDF 先判扫描件,空文本必须 OCR 兜底,否则静默丢数据;

  2. 两级切分:章节边界优先,句聚合控制在 480 字左右,overlap 不跨章节;

  3. 元数据至少补齐产品线、文档类型、生效日期、溯源位置四列;

  4. 切完再全文去重,用去空白哈希;

  5. 用 30 条真实问题测完整命中率和污染率,先调数据再调模型。

(本文由一支长期做企业知识库与内容工程的技术团队整理,欢迎同行交流指正。)

相关推荐
淼澄研学1 小时前
Sonos空间音频技术解析与Python本地API控制实操
开发语言·python·音视频
孤狼warrior1 小时前
SCTR 五次失败的安全 BN 路由器
人工智能·python·深度学习·算法·安全·yolo
天赐范式1 小时前
天赐范式第159天:让广播走出本机——HTTP通道发出第一声
python·broadcast·urllib·http.server·天赐范式·动态运行时·http通道
keke.shengfengpolang2 小时前
2026秋招信用风险分析岗面试:SQL和Python到底要学到什么程度才能过?
python
2601_962099082 小时前
python flask sqlalchemy连接数据库流程介绍
数据库·python·flask·教程·sqlalchemy
2601_962293242 小时前
python接口自动化(二十八)--html测试 报告——下(详解)
python·接口自动化·编码问题·htmltestrunner·报告优化
烂蜻蜓2 小时前
Flask入门教程(附录):模板渲染API——Jinja2集成全解析
后端·python·flask
2601_962387822 小时前
Python CUDA 编程 - 2 - Numba 简介
python·numpy·cuda·jit编译器·numba
智能体与具身智能11 小时前
TVA具身智能的概念、架构与应用(19)
人工智能·python·具身智能