Skip to content

Agent 实战(一)脏文档工程:真实公司文档为什么让 RAG 翻车

🕒 Published at:

这是一个新系列。前面的入门和进阶讲的是"通用能力",而这个系列专挑那些真到公司里才会遇到、面试官一听就知道你干过真项目的硬骨头。第一块,就是几乎所有 RAG 落地都会栽的坑——脏文档

教程里的 RAG 都拿干净的 txt 演示,效果很好。可你到公司一看,知识库全是带表格的 PDF、扫描件、多栏排版、图文混排的 Word。直接丢进上一套流程,效果往往惨不忍睹。这一篇讲清楚:脏在哪、怎么治。

真实文档"脏"在哪

先建立一个认知:RAG 的效果上限,在"把文档变成干净文本"这一步就基本定了。 检索、重排再花哨,喂进去的文本本身是乱的,也白搭。常见的几种"脏":

表格被拉成一团乱麻。 PDF 里一个规整的表格,用普通工具提取出来,往往变成「产品 型号 价格 A 100 B 200」这样行列全糊在一起的文字。模型根本看不懂哪个价格对应哪个型号。

扫描件 / 图片里的字,根本提不出来。 很多合同、老文档是扫描的 PDF,本质是一张图,普通解析提取到的文本是空的。不做 OCR(光学字符识别),这些内容对 RAG 完全不存在。

排版噪音。 每页的页眉、页脚、水印、页码,会被一股脑提进正文,变成一堆重复的噪音干扰检索。

跨页 / 多栏被切乱。 一句话跨了两页、或者双栏排版被按整行读取,导致左栏一句右栏一句交错,语义全断。

治理一:先把文档"解析干净"

第一步不是急着分块,而是用对解析工具。几个方向:

表格:用能保留结构的解析器,把表格转成 Markdown 表格或结构化文本(| 型号 | 价格 | 这种),模型就能读懂行列关系了。像 unstructured、PyMuPDF 这类库,以及很多云端文档解析服务,都对表格有专门处理。

扫描件:接 OCR(开源如 PaddleOCR、Tesseract,或云端 OCR 服务),先把图片转成文字,再进后续流程。判断一个 PDF 要不要 OCR 很简单:普通提取出来文本是空的或极少,基本就是扫描件。

去噪:解析后做一遍清洗,去掉重复的页眉页脚、多余空白。

一句话:别指望一个 TextLoader 打天下。真实项目里,"文档解析"常常是单独的、要认真打磨的一环——这也是面试时能体现你"趟过实战"的细节。

治理二:父子分块(Small-to-Big)——一个很"秀"的技巧

解析干净后,还有个经典矛盾:

  • 分块切小,检索才精准(小块主题单一,向量匹配得准);
  • 但喂给模型的块要大,上下文才完整(太小的块缺前后文,模型答不好)。

又要小又要大,怎么办?父子分块把这俩解耦:把大块(父块)再切成小块(子块);用子块去建索引、做检索(保证精准命中),命中后却返回它所属的父块喂给模型(保证上下文完整)。 检索用小的、喂模型用大的,两全其美。

用户问题(一句话)用「子块」检索小块 → 命中精准返回「父块」大块 → 上下文完整喂给大模型命中子块→取其父块

LangChain 有现成的 ParentDocumentRetriever,帮你自动维护这套"子块建索引、返回父块"的关系:

python
from langchain_classic.retrievers import ParentDocumentRetriever
from langchain_core.stores import InMemoryStore
from langchain_core.vectorstores import InMemoryVectorStore
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings
import os

embeddings = OpenAIEmbeddings(model="text-embedding-v3", api_key=os.getenv("DASHSCOPE_API_KEY"),
                              base_url="https://dashscope.aliyuncs.com/compatible-mode/v1")

# 子块小(检索精准)、父块大(上下文完整);注意子块的 overlap 要小于子块大小
child_splitter = RecursiveCharacterTextSplitter(chunk_size=200, chunk_overlap=20)
parent_splitter = RecursiveCharacterTextSplitter(chunk_size=800, chunk_overlap=50)

retriever = ParentDocumentRetriever(
    vectorstore=InMemoryVectorStore(embeddings),  # 存子块的向量
    docstore=InMemoryStore(),                     # 存父块原文
    child_splitter=child_splitter,
    parent_splitter=parent_splitter,
)

# 加文档时,把来源信息放进 metadata(下一节做「引用溯源」要用)
from langchain_core.documents import Document
retriever.add_documents([
    Document(page_content="……你解析干净后的长文本……",
             metadata={"source": "保养手册.pdf", "page": 12})
])

docs = retriever.invoke("滤网多久清洗一次")
print(docs[0].page_content)          # 返回的是完整的父块,不是那个小子块
print(docs[0].metadata)              # {'source': '保养手册.pdf', 'page': 12}

治理三:引用溯源,让答案"有据可查"

企业场景几乎都会要求:答案要能点回原文,方便用户核对、也让人敢信。做法的关键,是从一开始就把来源信息(文件名、页码、章节)放进每个文档块的 metadata,让它一路跟着检索结果传下来。回答时,把这些来源附在后面:

python
def answer_with_citation(question: str) -> str:
    docs = retriever.invoke(question)
    context = "\n\n".join(d.page_content for d in docs)
    # ……把 context 和 question 组装成 prompt,调用模型得到 answer(略,见入门第七篇)……
    answer = "……模型的回答……"

    # 关键:把来源附在答案后面,做到有据可查
    sources = {f"{d.metadata.get('source')}{d.metadata.get('page')}页" for d in docs}
    return answer + "\n\n📎 依据:" + "".join(sources)

用户看到「保养手册.pdf 第 12 页」,一点就能核对。"答案带出处"这一个细节,就能让一个 RAG 从"玩具"显得像"产品"——面试时也是个很实在的加分项。

Node 侧:LangChain.js 同样有 ParentDocumentRetriever;文档解析/OCR 这类"脏活"两边都常常交给专门的服务或库来做(和用什么语言写 Agent 无关)。核心思路——先解析干净、再父子分块、全程带 metadata 做溯源——完全通用。

小结

真实公司文档的"脏",是 RAG 落地的头号拦路虎。治理三板斧:解析干净(表格转结构化、扫描件走 OCR、去页眉页脚噪音)、父子分块(子块检索精准 + 父块上下文完整)、引用溯源(全程带 source/page,答案标出处)。

把这三步做扎实,你的 RAG 才算真正能扛真实数据。下一篇聊另一个企业绕不开的硬需求——不同的人只能看到自己有权限的资料:权限 / 多租户 RAG