真正让 RAG 管线的文档处理从"能用"变成"好用"的,是下面这几个环节:
表格怎么解析才不会丢结构 图片和图表怎么处理才能被检索到 文档怎么清洗去重,避免垃圾进垃圾出 怎么评估文档处理得好不好 怎么把这一切串成一条生产级管线
这篇全部用代码和可操作的策略来讲。
一、表格解析:RAG 里最难的一关,没有之一
先说一个数据:企业文档里超过 60% 的信息量藏在非文本内容中——表格、图表、图片。如果你只提取了纯文字,相当于丢了超过一半的信息。
而表格是其中最难处理的。为什么难?三个原因:
- 结构复杂:合并单元格、跨页表格、无边框表格,每种处理方式都不一样
- 语义在结构里:"营收 100亿 2022年" 和 "2022年 营收 100亿" 是不同的意思,取决于表头在哪一行
- 数值精度要求高:文字多一两个字没关系,数字差一个小数点就是事故
表格解析三阶段
业界公认的标准做法分三步:
表格检测 → 结构还原 → 语义编码第一阶段:表格检测
先定位文档里哪些区域是表格。最简单的方式是直接用 PyMuPDF4LLM,它内置了表格检测能力:
代码
import pymupdf4llm# 直接转 Markdown,表格会自动变成 Markdown 表格格式md_text = pymupdf4llm.to_markdown("report.pdf")# 输出里表格会变成:# | 指标 | 2022 | 2023 |# |-----|------|------|# | 营收 | 100亿 | 120亿 |
但如果你的 PDF 比较复杂(比如表格无边框、或者表格里有嵌套),就需要更专门的工具:
代码
import camelot# lattice 模式:适合有边框的表格tables = camelot.read_pdf("report.pdf", flavor="lattice", pages="1-5")# flavor 模式:适合无边框表格(基于文本对齐检测)tables = camelot.read_pdf("report.pdf", flavor="lattice", pages="1-5")for table in tables:print(table.df) # pandas DataFrameprint(table.parsing_report) # 解析精度报告
选型参考:
第二阶段:结构还原
检测到表格只是第一步,更难的是还原表格的结构——哪行是表头、哪列是什么字段、有没有合并单元格。
举个例子,一个合并单元格的表格:
| 营收情况 || 年份 | 营收 | 增长率 || 2022 | 100亿 | 15% || 2023 | 120亿 | 20% |
第一行的"营收情况"横跨三列,如果解析器不识别合并单元格,就会变成三个独立的"营收情况"、"营收情况"、"营收情况",直接污染数据。
处理合并单元格的参考代码:
import pandas as pddef handle_merged_cells(table_df):"""处理合并单元格:向前填充空值"""# 如果某行某列为空,继承上一行的值return table_df.ffill()# 示例raw_df = pd.DataFrame([["营收情况", "", ""],["年份", "营收", "增长率"],["2022", "100亿", "15%"],["2023", "120亿", "20%"]])processed_df = handle_merged_cells(raw_df)print(processed_df)# 0 1 2# 0 营收情况 NaN NaN# 1 年份 营收 增长率# 2 2022 100亿 15%# 3 2023 120亿 20%
第三阶段:语义编码
表格还原之后,怎么把它变成 LLM 能理解的形式?有三种策略:
策略一:转成 Markdown 表格(推荐,最通用)
def table_to_markdown(df, caption=""):md = f"<!-- {caption} -->\n" if caption else ""md += "| " + " | ".join(str(c) for c in df.columns) + " |\n"md += "| " + " | ".join(["---"] * len(df.columns)) + " |\n"for _, row in df.iterrows():md += "| " + " | ".join(str(v) for v in row) + " |\n"return md
策略二:生成语义描述(适合简单表格,LLM 友好)
def table_to_semantic(df):"""把表格转成自然语言描述"""lines = []headers = list(df.columns)for _, row in df.iterrows():desc = ",".join([f"{h}为{v}" for h, v in zip(headers, row)])lines.append(desc)return ";".join(lines)# 示例输出:# "年份为2022,营收为100亿,增长率为15%;年份为2023,营收为120亿,增长率为20%"
策略三:结构化 JSON 输出(适合下游程序处理)
def table_to_json(df):return {"headers": list(df.columns),"rows": df.values.tolist(),"semantic_units": [dict(zip(df.columns, row)) for _, row in df.iterrows()]}
💡建议:不要只用一种策略。实践中通常是 Markdown 表格 + 语义描述双重保留,让 LLM 既能看到结构化的数据,也能快速理解含义。
跨页表格处理
这是企业文档里最常见的坑之一——一个表格跨了两页,第一页有表头、第二页没有。
解决方案
def detect_and_merge_cross_page_tables(pages_tables):"""检测并合并跨页表格"""merged = []prev_table = Nonefor table in pages_tables:if prev_table is not None:# 如果当前表格的表头是数字列(无文字表头)first_row = table.df.iloc[0]if all(is_cell_numeric(cell) for cell in first_row):# 是上一页表格的延续,去掉第一行直接拼接table.df = table.df.iloc[1:]prev_table.df = pd.concat([prev_table.df, table.df])continueprev_table = tablemerged.append(table)return merged
二、图片与图表处理
表格之外,文档里的图片和图表是第二大信息源。
先判断要不要处理
不是所有图片都需要纳入 RAG。一个简单分级:
| 高 | ||
| 高 | ||
图表数据提取实战
数据图表在企业文档里非常常见,但纯文本 RAG 完全无法理解它。比如一张折线图显示"2023年Q3营收开始下滑",如果只提取了图片,模型根本不知道这个趋势。
2026 年推荐的做法是用多模态模型提取图表数据:
from openai import OpenAIimport base64client = OpenAI()def extract_chart_data(image_path):"""用视觉模型提取图表中的数据和趋势"""with open(image_path, "rb") as f:image_data = base64.b64encode(f.read()).decode("utf-8")response = client.chat.completions.create(model="gpt-4o", # 或 claude-3.5-sonnet 等视觉模型messages=[{"role": "user","content": [{"type": "text", "text": "请提取这张图表的完整数据。包含:1) X轴和Y轴标签 2) 所有数据点 3) 整体趋势描述。以JSON格式返回。"},{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{image_data}"}}]}])return response.choices[0].message.content
输出示例:
{"chart_type": "折线图","title": "2023年度营收趋势","x_axis": "月份","y_axis": "营收(万元)","data_points": [{"month": "1月", "revenue": 80},{"month": "2月", "revenue": 75}],"trend": "上半年平稳,Q3开始下滑,Q4回暖"}
提取出来的数据描述可以和原始图片一起入库,这样即使模型看不到图片,也能通过文本描述检索到图表的含义。
文本 + 图片双通道入库
对于含图片的文档,2026 年的最佳实践是双通道策略:
def dual_channel_index(document):"""文本通道 + 图片通道分别入库"""# 通道一:文本通道(主通道)text_chunks = chunk_text(document.text)# 通道二:图片通道(辅通道)image_chunks = []for image in document.images:# 生成图片描述caption = generate_image_caption(image)# 用描述文本来索引image_chunks.append({"text": f"[图片] {caption}","image_path": image.path,"embedding": get_clip_embedding(image) # 可选的:CLIP 向量})return text_chunks + image_chunks
这样设计的好处是:用户搜索"营收趋势"时,既能搜到文本段落,也能搜到图表描述。
三、清洗与去重:决定知识库的纯净度
很多人把文档解析完就直接入库了,结果向量库里充斥着重复内容、乱码字符、隐私信息。这些问题积累到检索阶段,轻则浪费 Token,重则直接导致答案错误。
三层去重流水线
2026 年生产级的做法:
文件层 (MD5) → 文本层 (SimHash) → Chunk层 (向量相似度)第一层:文件级去重(零成本)
import hashlibdef file_deduplicate(file_paths):"""MD5 哈希去重,完全一致的文件只保留一份"""seen = {}unique_files = []for path in file_paths:with open(path, "rb") as f:md5 = hashlib.md5(f.read()).hexdigest()if md5 not in seen:seen[md5] = pathunique_files.append(path)else:print(f"重复文件,已跳过: {path} (与 {seen[md5]} 相同)")return unique_files
第二层:文本级去重(相似度阈值)
from simhash import Simhashimport jiebadef simhash_deduplicate(documents, threshold=0.9):"""SimHash 去重,相似度 >= threshold 判定为重复"""unique_docs = []for doc in documents:tokens = jieba.cut(doc["text"])fingerprint = Simhash(" ".join(tokens))is_duplicate = Falsefor existing in unique_docs:sim = fingerprint.distance(existing["fingerprint"])# SimHash 距离越小越相似,通常 <3 就高度相似if sim < 3:is_duplicate = Truebreakif not is_duplicate:doc["fingerprint"] = fingerprintunique_docs.append(doc)return [d for d in unique_docs if "fingerprint" in d]
结论:字节级去重在干净场景下只减少 0.16% 的字节,但在企业场景下减少 24.03%。换句话说,你的知识库如果去重后发现没减多少,说明你的文档本来就比较干净;如果减了很多,说明之前混入了大量重复。
第三层:Chunk 级去重(入库后兜底)
在向量入库前,用向量相似度再做一次去重:
import numpy as npfrom sklearn.metrics.pairwise import cosine_similaritydef chunk_deduplicate(embeddings, chunks, threshold=0.95):"""向量相似度去重,防止高度相似的 chunk 重复入库"""unique_indices = []unique_embs = []for i, emb in enumerate(embeddings):if len(unique_embs) == 0:unique_embs.append(emb)unique_indices.append(i)continuesims = cosine_similarity([emb], unique_embs)[0]if np.max(sims) < threshold:unique_embs.append(emb)unique_indices.append(i)return [chunks[i] for i in unique_indices]
文本清洗五步走
去重之后是清洗。这里直接给一套可复用的代码:
import reimport unicodedataclass TextCleaner:"""RAG 文档清洗器"""def __init__(self):# 隐私脱敏规则self.patterns = {"phone": re.compile(r"1[3-9]\d{9}"),"id_card": re.compile(r"\d{18}[\dXx]"),"email": re.compile(r"\w+@\w+\.\w+"),}# 套话黑名单(可扩展)self.cliches = ["为进一步规范", "为了贯彻落实", "根据相关法律法规","在此背景下", "综上所述", "值得一提的是"]def desensitize(self, text):"""脱敏:保留关键信息,隐藏隐私"""text = self.patterns["phone"].sub(lambda m: m.group()[:3] + "****" + m.group()[-4:], text)text = self.patterns["id_card"].sub(lambda m: m.group()[:6] + "********" + m.group()[-4:], text)text = self.patterns["email"].sub(lambda m: m.group().split("@")[0][:2] + "***@" + m.group().split("@")[1], text)return textdef normalize_unicode(self, text):"""统一 Unicode 编码:全角转半角、统一引号"""text = unicodedata.normalize("NFKC", text)# 全角字母数字转半角result = []for char in text:code = ord(char)if 0xFF01 <= code <= 0xFF5E:result.append(chr(code - 0xFEE0))else:result.append(char)return "".join(result)def remove_cliches(self, text):"""去除公文套话"""for cliche in self.cliches:text = text.replace(cliche, "")return textdef clean(self, text, level="standard"):"""清洗入口level: minimal(只脱敏) / standard(脱敏+归一) / aggressive(全量)"""text = self.desensitize(text)if level in ("standard", "aggressive"):text = self.normalize_unicode(text)if level == "aggressive":text = self.remove_cliches(text)return text.strip()# 使用示例cleaner = TextCleaner()raw_text = "为进一步规范公司管理,联系电话:13812345678"cleaned = cleaner.clean(raw_text, level="aggressive")print(cleaned)# 输出: 公司管理,联系电话:138****5678
不同文档类型的清洗策略
不是所有文档都适合同一套清洗力度:
def get_cleaning_strategy(doc_type):strategies = {"contract": {"level": "minimal", "note": "合同只做脱敏,不改内容"},"meeting_minutes": {"level": "aggressive", "note": "会议纪要重度降噪"},"tech_doc": {"level": "standard", "note": "技术文档保留代码和公式"},"annual_report": {"level": "standard", "note": "年报保结构,去套话"},}return strategies.get(doc_type, {"level": "standard"})
四、质量评估:怎么知道你处理得好不好
做了这么多处理,怎么量化效果?这是从"玄学"走向"工程"的关键一步。
文档处理阶段的三维指标
一个可落地的评估流程
class DocQualityEvaluator:"""文档处理质量评估器"""def __init__(self, sample_size=100):self.sample_size = sample_sizedef check_structure_integrity(self, markdown_text):"""检测结构完整性"""# 检查是否有标题层级断裂(h1 跳到 h3 没有 h2)headings = re.findall(r"^(#{1,6})\s", markdown_text, re.MULTILINE)issues = []prev_level = 0for h in headings:level = len(h)if level > prev_level + 1 and prev_level > 0:issues.append(f"标题层级跳跃: {prev_level} -> {level}")prev_level = levelreturn {"total_headings": len(headings),"structure_issues": issues,"integrity_score": 1.0 - len(issues) / max(len(headings), 1)}def check_table_completeness(self, markdown_text):"""检测表格完整性"""tables = re.findall(r"\|.+\|\n\|[-| ]+\|\n", markdown_text)issues = []for table in tables:lines = table.strip().split("\n")header_cells = len(lines[0].split("|")) - 2for line in lines[2:]:cells = len(line.split("|")) - 2if cells != header_cells:issues.append(f"表格列数不一致: {header_cells} vs {cells}")return {"table_count": len(tables),"table_issues": issues,"completeness_score": 1.0 - len(issues) / max(len(tables), 1)}def evaluate(self, markdown_text):"""全量评估"""return {"structure": self.check_structure_integrity(markdown_text),"tables": self.check_table_completeness(markdown_text)
检索阶段的反向验证
文档质量好不好,最终还是要落到检索效果上。一个简单的验证方法:
def evaluate_doc_quality(vector_store, test_queries, ground_truth):"""用测试集验证文档处理质量"""results = []for query, expected_doc in zip(test_queries, ground_truth):retrieved = vector_store.similarity_search(query, k=5)# 检查预期文档是否在检索结果中retrieved_docs = [doc.metadata["source"] for doc in retrieved]hit = expected_doc in retrieved_docsresults.append({"query": query, "hit": hit})recall = sum(r["hit"] for r in results) / len(results)return {"recall@5": recall, "details": results}
诊断:如果 recall 偏低,问题很可能出在文档处理阶段——要么内容没提取出来,要么切分破坏了语义完整性。
五、串起来:一条生产级的文档处理管线
最后,把上面所有环节串成一条完整的管线。
class DocProcessingPipeline:"""生产级文档处理管线"""def __init__(self, cleaner=None, deduper=None, evaluator=None):self.cleaner = cleaner or TextCleaner()self.deduper = deduper or {}self.evaluator = evaluator or DocQualityEvaluator()def process(self, file_path, doc_type="general"):"""处理单个文档:解析 → 清洗 → 结构化 → 评估"""strategy = get_cleaning_strategy(doc_type)results = {"source": file_path, "status": "success"}# 1. 格式解析ext = file_path.split(".")[-1].lower()if ext == "pdf":raw_text = self._parse_pdf(file_path)elif ext in ("docx", "doc"):raw_text = self._parse_docx(file_path)elif ext in ("html", "htm"):raw_text = self._parse_html(file_path)else:raw_text = self._parse_text(file_path)results["raw_length"] = len(raw_text)# 2. 清洗cleaned_text = self.cleaner.clean(raw_text, level=strategy["level"])results["cleaned_length"] = len(cleaned_text)# 3. 提取表格tables = extract_tables(file_path)results["table_count"] = len(tables)# 4. 提取图片描述images = extract_image_captions(file_path)results["image_count"] = len(images)# 5. 合并输出final_doc = {"text": cleaned_text,"tables": [table_to_markdown(t) for t in tables],"images": images,"metadata": {"source": file_path,"doc_type": doc_type,"processed_at": datetime.now().isoformat()}}# 6. 质量评估quality = self.evaluator.evaluate(cleaned_text)results["quality"] = qualityreturn final_doc, resultsdef _parse_pdf(self, path):"""生产级 PDF 解析"""try:# 优先用 PyMuPDF4LLMimport pymupdf4llmreturn pymupdf4llm.to_markdown(path)except ImportError:# 回退到基础 PyMuPDFimport fitzdoc = fitz.open(path)return "\n\n".join([page.get_text() for page in doc])def _parse_docx(self, path):from docx import Documentdoc = Document(path)return "\n".join([p.text for p in doc.paragraphs])def _parse_html(self, path):import trafilaturawith open(path, "r", encoding="utf-8") as f:raw = f.read()return trafilatura.extract(raw, output_format="markdown")def _parse_text(self, path):with open(path, "r", encoding="utf-8") as f:return f.read()def batch_process(self, file_paths, doc_types=None):"""批量处理文档"""all_results = []for i, path in enumerate(file_paths):doc_type = doc_types[i] if doc_types else "general"doc, report = self.process(path, doc_type)all_results.append({"doc": doc, "report": report})return all_results# 使用示例pipeline = DocProcessingPipeline()# 批量处理一批文档file_paths = ["report_2023.pdf", "meeting_notes.docx", "policy.html"]doc_types = ["annual_report", "meeting_minutes", "general"]results = pipeline.batch_process(file_paths, doc_types)# 查看处理报告for r in results:report = r["report"]print(f"{report['source']}: {report['status']}")print(f" 原始长度: {report['raw_length']} → 清洗后: {report['cleaned_length']}")print(f" 表格: {report['table_count']}个, 图片: {report['image_count']}个")
2026 年完整工具链速查
PyMuPDF4LLM | ||
PaddleOCR | ||
Camelot | ||
trafilatura | ||
hashlib MD5 | ||
SimHash | ||
sklearn cosine | ||
Ragas |
总结
文档处理这个模块到这里就讲完了。上篇下篇加起来,覆盖了从文件加载到入库前所有关键环节。
给你一个自检清单,下次做 RAG 项目时可以对照:
- 是否区分了文本 PDF 和扫描 PDF,用了不同的处理方式?
- 表格是否做了结构化提取,而不是纯文本平铺?
- HTML 是否用了 Readability 提取正文,而不是直接爬?
- 是否有去重机制(文件级 + 文本级 + 向量级)?
- 是否做了隐私脱敏?
- 是否有质量评估流程,而不是凭感觉?
- 整个管线是否可以批量运行?
如果以上都是"是",你的文档处理已经到了生产级水平,可以放心进入下一个模块了。
下一篇预告:Chunking 策略深度拆解——怎么切文档效果最好?不同场景怎么选?
📦 每天一个 AI 开发实战技巧 · 第 3 篇
如果你对某个环节有更具体的疑问,欢迎留言,我可以在后续文章里展开。
夜雨聆风