乐于分享
好东西不私藏

文档加载与处理(下):表格、图片、清洗去重、质量评估,手把手搭生产级管线

文档加载与处理(下):表格、图片、清洗去重、质量评估,手把手搭生产级管线
上篇讲了 PDF、HTML、Office 文档的基础解析。但那些只是"把文字读出来"的层面。

真正让 RAG 管线的文档处理从"能用"变成"好用"的,是下面这几个环节:

  • 表格怎么解析才不会丢结构
  • 图片和图表怎么处理才能被检索到
  • 文档怎么清洗去重,避免垃圾进垃圾出
  • 怎么评估文档处理得好不好
  • 怎么把这一切串成一条生产级管线

这篇全部用代码和可操作的策略来讲。


一、表格解析:RAG 里最难的一关,没有之一

先说一个数据:企业文档里超过 60% 的信息量藏在非文本内容中——表格、图表、图片。如果你只提取了纯文字,相当于丢了超过一半的信息。

而表格是其中最难处理的。为什么难?三个原因:

  1. 结构复杂:合并单元格、跨页表格、无边框表格,每种处理方式都不一样
  2. 语义在结构里:"营收 100亿 2022年" 和 "2022年 营收 100亿" 是不同的意思,取决于表头在哪一行
  3. 数值精度要求高:文字多一两个字没关系,数字差一个小数点就是事故

表格解析三阶段

业界公认的标准做法分三步:

表格检测 → 结构还原 → 语义编码

第一阶段:表格检测

先定位文档里哪些区域是表格。最简单的方式是直接用 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 DataFrame    print(table.parsing_report)  # 解析精度报告

选型参考:

场景
推荐工具
理由
有边框表格
Camelot lattice
精度高,97%+
无边框表格
Camelot flavor
基于文本对齐检测
扫描件表格
PaddleOCR + 版面分析
先 OCR 再检测
需要深度学习的
TableTransformer
基于 DETR 的目标检测

第二阶段:结构还原

检测到表格只是第一步,更难的是还原表格的结构——哪行是表头、哪列是什么字段、有没有合并单元格。

举个例子,一个合并单元格的表格:

|       营收情况       || 年份  | 营收 | 增长率 || 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 = None    for 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])                continue        prev_table = table        merged.append(table)    return merged

二、图片与图表处理

表格之外,文档里的图片和图表是第二大信息源。

先判断要不要处理

不是所有图片都需要纳入 RAG。一个简单分级:

图片类型
处理策略
优先级
纯装饰性图片
直接丢弃
低
含文字的截图
OCR 提取文字
高
数据图表(折线图/柱状图)
提取数据点 + 趋势描述
高
流程图/架构图
保留 + 语义描述
中
产品图片/照片
CLIP 向量化
中

图表数据提取实战

数据图表在企业文档里非常常见,但纯文本 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] = path            unique_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 = False        for existing in unique_docs:            sim = fingerprint.distance(existing["fingerprint"])            # SimHash 距离越小越相似,通常 <3 就高度相似            if sim < 3:                is_duplicate = True                break        if not is_duplicate:            doc["fingerprint"] = fingerprint            unique_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)            continue        sims = 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 text    def 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 text    def 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"})

四、质量评估:怎么知道你处理得好不好

做了这么多处理,怎么量化效果?这是从"玄学"走向"工程"的关键一步。

文档处理阶段的三维指标

维度
指标
达标线
怎么测
准确率
OCR 字符错误率
< 5%
抽检 100 页,人工比对
完整性
表格完整度
> 90%
表格检测召回率
结构还原度
标题层级正确率
> 85%
检查 h1→h2→h3 层级是否丢失

一个可落地的评估流程

class DocQualityEvaluator:    """文档处理质量评估器"""    def __init__(self, sample_size=100):        self.sample_size = sample_size    def check_structure_integrity(self, markdown_text):        """检测结构完整性"""        # 检查是否有标题层级断裂(h1 跳到 h3 没有 h2)        headings = re.findall(r"^(#{1,6})\s", markdown_text, re.MULTILINE)        issues = []        prev_level = 0        for h in headings:            level = len(h)            if level > prev_level + 1 and prev_level > 0:                issues.append(f"标题层级跳跃: {prev_level} -> {level}")            prev_level = level        return {            "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("|")) - 2            for line in lines[2:]:                cells = len(line.split("|")) - 2                if 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_docs        results.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"] = quality        return final_doc, results    def _parse_pdf(self, path):        """生产级 PDF 解析"""        try:            # 优先用 PyMuPDF4LLM            import pymupdf4llm            return pymupdf4llm.to_markdown(path)        except ImportError:            # 回退到基础 PyMuPDF            import fitz            doc = fitz.open(path)            return "\n\n".join([page.get_text() for page in doc])    def _parse_docx(self, path):        from docx import Document        doc = Document(path)        return "\n".join([p.text for p in doc.paragraphs])    def _parse_html(self, path):        import trafilatura        with 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 年完整工具链速查

环节
首选方案
备选方案
PDF 解析
PyMuPDF4LLM
PyMuPDF + 自建逻辑
扫描件 OCR
PaddleOCR
Tesseract / 商用 API
表格检测
Camelot
TableTransformer
HTML 正文提取
trafilatura
readability-lxml
图片描述
GPT-4o / Claude 视觉
CLIP + 简单描述
去重(文件级)
hashlib MD5
sha256
去重(文本级)
SimHash
MinHash LSH
去重(向量级)
sklearn cosine
FAISS
清洗
自建 TextCleaner
opencc + regex
评估
Ragas
 + 自建指标
Langfuse

总结

文档处理这个模块到这里就讲完了。上篇下篇加起来,覆盖了从文件加载到入库前所有关键环节。

给你一个自检清单,下次做 RAG 项目时可以对照:

  • 是否区分了文本 PDF 和扫描 PDF,用了不同的处理方式?
  • 表格是否做了结构化提取,而不是纯文本平铺?
  • HTML 是否用了 Readability 提取正文,而不是直接爬?
  • 是否有去重机制(文件级 + 文本级 + 向量级)?
  • 是否做了隐私脱敏?
  • 是否有质量评估流程,而不是凭感觉?
  • 整个管线是否可以批量运行?

如果以上都是"是",你的文档处理已经到了生产级水平,可以放心进入下一个模块了。

下一篇预告:Chunking 策略深度拆解——怎么切文档效果最好?不同场景怎么选?


📦 每天一个 AI 开发实战技巧 · 第 3 篇

如果你对某个环节有更具体的疑问,欢迎留言,我可以在后续文章里展开。

相关学习资料