“文档都没有加载对,后面检索质量再调也是白搭。”这句话是我在一堆RAG项目里踩坑之后最想说的。很多朋友搭RAG知识库,第一个想到的词是向量库、是Embedding、是Prompt,结果真正动手才发现,第一道坎根本不是模型选型,而是那份PDF里表格被拆得跟乱码一样,Word文档页眉页脚满天飞,扫描件压根提不出一行字。RAG检索增强生成,中文里也叫检索增强生成,本质上就是给大模型外挂一个可检索的知识库,而文档加载解析就是这座知识库的地基。
这篇文章会从文档加载解析这个角度切入,把RAG链路里最容易忽视却最要命的一步讲透。我会结合LangChain、Unstructured、PyMuPDF这些常见的开源工具,一步步拆解不同格式文档的加载策略、切块参数怎么选、元数据怎么设计、解析后怎么接入PGVector这样的向量库,并把实操中经常踩的坑整理给你。适合正在搭企业知识库的工程师、准备做RAG进阶项目的同学,以及那些“检索效果总是不好,但不知道问题出在哪”的开发者。
1. 先把RAG链路看清楚,才知道文档加载有多重要
1.1 RAG不是“脚本”那么简单
好多教程把RAG写成三段式:加载文档、向量化、问答。代码一跑,效果稀碎,然后就开始调Embedding模型、换Prompt模板,忙了半天问题还在。我做了几个项目以后发现,大部分RAG效果不好,根因都出在第一阶段——文档解析。文档加载解析的任务不是把文字从文件里抄出来,而是要把人类阅读的文档,转造成大模型和向量检索系统都能理解的、结构化的、信息无损的文本单元。这里有个关键概念要拎清楚:RAG检索增强生成的目标是“让答案有据可依”,那么“据”的质量就取决于原始文档被还原得有多完整;还原得越差,后面检索和生成的天花板就越低。
很多朋友一上来就用最快的方案,pip install一个库、十行代码跑通,看到控制台输出几段文字就觉得完事了。实际上,这种“能跑”和“能用”之间差了十万八千里。文档加载解析要考虑的东西非常具体:这个PDF是文字版还是扫描版?扫描版要不要OCR?Word里表格和段落的顺序是不是乱了?HTML页面里的导航和广告有没有混进正文?每一类问题都会直接污染后面的向量质量,而这些问题在项目初期往往又不会立刻暴露,要等到检索结果莫名奇妙时才想起回头查。
1.2 文档加载在整个链路里的位置
可以把RAG知识库拆成五个环节:文档加载、解析清洗、切块与元数据构建、向量化与入库、检索生成。文档加载和解析清洗是前两环,很多人误以为它们不创造“智能”,实际上却是决定知识库上限的环节。你想想,如果切块阶段拿到的文本是残缺的、错位的,那么Embedding模型学到的语义就是残缺的,向量检索召回的内容自然也是残缺的。LLM再聪明,也只能基于残缺的上下文作答。所以这一步不是“体力活”,而是真正的技术活,它有大量格式兼容、编码处理、结构恢复等细节需要处理。
从成本和效率来看,文档加载解析也是整个链路里性价比最高的优化点。调Embedding模型、换大模型、写复杂的Prompt,效果提升通常都是微调级别的;但如果你把一个原本错乱漏字的PDF,换成一份干净有序、带好元数据的文本,检索效果的提升通常是肉眼可见的,甚至可以说是质变。原因很简单:信息是零散的还是完整的,直接决定了召回内容的上限。做项目要抓主要矛盾,文档加载解析是典型的用20%工作量撬动80%效果提升的环节。
1.3 一个可以直接落地的架构示例
在深入具体格式之前,我先给一个我最近常用的架构草图,对应现在的热词“基于FastAPI+LangChain+LangGraph+RAG+PGVector的AI Agentic RAG”。这个架构的特点是:用FastAPI提供API层,LangChain负责文档加载和切块,LangGraph管理多轮对话和Agent状态,PGVector存储向量并做相似度检索。这个架构里文档加载解析模块独立成服务,不同格式的文件走不同的Loader,统一输出带元数据的Document对象,再进入切块和向量化。这样的好处是:一处解析,多处复用,后续不管接检索还是接Agent,拿到手的永远是一份干净的数据源。
选择这个架构还有一个现实原因:企业知识库的场景通常不是单一格式的。今天上传一批PDF制度文件,明天可能补一批Word合同,后天又有什么Markdown格式的技术文档。如果全套流程写死在单个脚本里,后期维护成本极高。把文档加载解析独立出来,前端只负责上传文件,后端按文件类型自动分发到不同解析器,输出统一结构,这对后续做多轮对话、Agent规划都是很舒服的。后面章节的代码我会围绕这个架构思路展开。
2. 文档加载:不同格式的解析策略与工具选型
2.1 文本类文档:别以为纯文本就没有解析问题
纯文本(.txt)和Markdown看起来最没技术含量,但恰恰是很多人翻车的地方。TXT文件最常见的坑是编码问题,尤其是中文环境下的GBK和UTF-8混用,直接导致乱码;建议加载时统一尝试UTF-8、GB18030、GBK等编码,加一个兜底逻辑。还有一种情况是文件表面上编码正常,但中间混入了一些特殊不可见字符,比如零宽空格、全角空格,这些在人工阅读时看不出来,进了向量库却会成为噪音,所以字符级清洗在这一步就很重要。
Markdown和HTML则是结构丢失的问题:Markdown里的标题层级、代码块、列表如果被当作普通文本处理,切块时语义边界就会被破坏;HTML更是复杂,标签、脚本、样式、导航栏这些噪音如果不过滤掉,向量库里会混入大量无效内容。我的建议是:用LangChain的TextLoader先做基础加载,再配合MarkdownHeaderTextSplitter或自定义HTML解析器,把文档结构信息保留下来,作为后续切块的依据。像HTML这种,我通常会先用BeautifulSoup把正文区域提取出来,再去掉script、style、header、footer等噪声节点。这一步做得好,能直接减少后面30%以上的无效向量。
2.2 PDF:真实世界的硬骨头
PDF是RAG项目里最常见的格式,也是坑最多的一种。原因很简单:PDF本身不是一种为“提取文本”设计的数据格式,它记录的是图形绘制指令,文字从PDF解析出来全靠逆推。如果直接用PyPDF2这类库对复杂的PDF做一次性解析,结果往往惨不忍睹:多栏布局变成一行,表格单元格顺序颠倒,文字缺字漏字。我目前用得比较多的是三类方案:
第一类是PyMuPDF(也就是fitz),解析速度快、对文本做保留位置的提取效果不错,适合以文字为主的普通文档。第二类是pdfplumber,它对表格和定位信息支持更好,适合需要恢复结构化数据的场景。第三类是Unstructured的PDF解析,它能做版面分析,识别标题、表格、图片块,输出的元素本身带类型标签,省掉不少二次处理工作。选择标准不是哪个库最流行,而是文档类型和你的下游需求。我的经验是:先小批量跑几个真实文档,看输出文本的质量,再决定主线加载器。
另外,PDF不一定是从头到尾都是正常的。有些PDF前半部分是正常的文字层,后半部分却是扫描图片。这类混合型PDF在政府公文、老企业档案里很常见,一定要在解析流程里做好页级别的判断,哪一页提不出文本就走OCR。否则你会看到前半部分检索效果很好,一到后面的扫描页就全员“失明”,而且很难定位是数据问题还是模型问题。
2.3 Word文档和表格如何处理
Word文档在很多企业知识库里比PDF还常见,因为文档的源头就是Word。走python-docx可以读取段落和表格,读取时有一个容易忽略的点:Word里的内容顺序不一定是“段落-段落-表格”的简单排列,有些内容嵌在文本框、页眉页脚、批注里,直接读正文很可能会漏。用python-docx遍历document.paragraphs只能拿到正文段落,表格要额外走document.tables,如果文档里表格和段落交错,需要按文档的body顺序重新拼装。
我在实际项目里会写一个小函数,从Word的XML结构里按顺序提取段落和表格,表格保留为Markdown风格或有分隔符的纯文本,而不是直接拍平成一个长字符串。这样切块时表格语义还在,后续大模型也更易读懂。至于Excel类数据,常见做法是把每一行变成一条独立记录,或者按Sheet拆分,切块时的重点是如何保留行与行的关系,一般不建议把整张超大Excel表格直接塞进一个切块里。之前做过一个销售合同台账的项目,Excel里几万行记录,我们最后就是按“客户+合同编号”为粒度拆成独立文本块,再辅以结构化查询,效果比整表向量化好得多。
2.4 扫描件与图像型PDF的OCR路径
现在很多企业知识库里还有老照片一样的扫描件PDF,这类文件没有文本层,任何文本提取库都拿不到字符。要让RAG读懂它们,必须走OCR流程。我的选择是PaddleOCR,它对中文的支持比Tesseract好很多,特别是在中英文混排场景下,识别率和版面还原能力都更优。实际操作时,建议先用PyMuPDF把PDF的每一页转成高分辨率图片(DPI大概200到300),再做OCR,同时保留每页的页码信息。
OCR出来的文本很可能顺序凌乱,特别是多栏排版,需要后处理。这块没有银弹,我的经验是按需取舍:如果文档是公告、政策文件这类以正文为主的,直接按阅读顺序输出文本即可;如果是报销单、发票这类强结构文档,则要提取关键字段,而不是盲目全文识别。另外要提醒一句,OCR不是万能的,手写体、盖章遮挡、模糊扫描件的识别率都很难保证,预算允许的情况下,对核心单据做人工校对仍然是必要的。做RAG项目时要提前跟业务方说清楚这个边界,否则后期会有一堆“OCR识别错了”的工单找你。
3. 解析之后:清洗、分块、元数据与去重
3.1 文本清洗:页眉页脚、噪音与乱码
加载和解析完成之后,拿到的原始文本通常是脏的。页眉、页脚、页码、目录、水印、参考文献引用块,这些“文档本身有效但对检索无益”的内容,如果不做清洗,会被切块进入向量库,检索时经常因为这些噪音召回一堆无关片段。我的清洗流程按优先级分为三步:第一步是字符级清洗,处理全角半角、不可见字符、乱码替换,可以用正则或编码纠错;第二步是结构级清洗,根据文档类型去掉页眉页脚、目录和网站导航信息,这一步最好在加载时用元数据或者预处理正则做;第三步是语义级清洗,比如去掉“第X页,共Y页”这类提示语、去掉无意义的制表符等。
清洗并不是越狠越好,过度正则会把正文里正常内容误删。曾经有个项目,我把所有连续空白字符替换成单个空格,结果代码块里的缩进全没了,技术文档的源码示例变得不可读。后来调整了清洗规则,只在代码块外做空白压缩。我的原则是:宁可漏掉一部分噪声,也不要误删核心信息;清洗规则务必先跑一遍样例再全量应用。可以准备一个“清洗规则清单”,每加一条规则就标记适用范围和预期效果,防止规则之间互相打架。
3.2 分块策略的底层逻辑
分块是RAG里最容易被讨论、也最容易过度纠结的环节。MCP和Agentic RAG这类热词不断出现,但无论架构多高级,最终喂进Embedding模型的还是一个一个文本块,块的质量直接决定检索效果。分块的本质是:在“单块语义完整”和“单块体积可控”之间找平衡。块太小,语义碎片化,检索到的是半句话;块太大,语义信息被稀释,Embedding向量趋同,检索精度下降,并且超过模型最大token限制后会被截断。
常见的分块方式有三种:固定长度切块、递归字符切块、语义切块。固定长度最容易实现,但会硬生生切断句子甚至切断词;递归字符切块(LangChain的RecursiveCharacterTextSplitter)是目前最常用的方案,它按段落、句子、字符的优先级逐级切分,能在多数情况下保持语义边界;语义切块更复杂,一般会结合嵌入向量的相似度或者标题结构来动态划定边界。我的建议是大部分项目先用递归字符切块跑通,再根据效果决定要不要上语义切块,不要一上来就用复杂的方案。
关于参数,chunk_size和chunk_overlap是两个最核心的调参对象。chunk_size一般按token数计算,而不是按字符数,不同模型有自己的tokenizer,常用值在300到800之间。chunk_overlap建议设为chunk_size的10%到20%,它的作用是让跨块上下文在相邻块之间保留一部分,减少信息断层。举个例子,如果chunk_size是500,overlap设为75,那么第一块覆盖token 1到500,第二块覆盖425到925,这样第425到500之间的内容在两个块里都出现,检索时可以避免因为切块导致的边界断句。实际操作时,建议把每个项目的文档类型固定下来,先用不同chunk_size做一个对照实验,观察检索命中率,再确定默认值。
3.3 元数据:让检索结果可追溯
很多新手做RAG跳过元数据,只把文本向量化入库,结果回答问题的时候没有任何出处可查。元数据是RAG知识库的“档案”,至少要包含来源文件、页码、文档类型、创建时间、所属业务线等。有了元数据,才能在下游检索时做过滤,比如只查某个业务线的文档、只查最近半年的文档;也才能在生成时给出可追溯的引用来源,这对企业知识库尤其重要。
在建Document对象时,我会把source设为文件路径或URL,page_number设为页码或块序号,document_type设为pdf、md、html等。切块时要让切出来的每一个块都继承父文档的元数据,避免有的块缺source导致检索后无法溯源。这里有一个细节:LangChain的RecursiveCharacterTextSplitter默认会保留Document对象的metadata字段,但如果你自己写切块函数,很容易把metadata丢掉。我见过不止一次,向量库里存了一堆内容却不知道来自哪个文件,排查起来非常痛苦。
元数据的另一个用途是做权限控制。企业知识库会有不同部门、不同密级的文档,检索时如果能在向量召回前按元数据过滤,比召回后再判断权限高效得多。PGVector支持metadata过滤和向量检索的组合查询,这也是我推荐它做企业级RAG的原因之一。
3.4 去重与版本管理
企业里最容易被忽略的是文档去重。同一份材料,可能以PDF、Word、HTML三种形态都存在,也可能被多次上传。如果不去重,向量库里全是冗余数据,检索时同样的内容被召回好几条,不仅浪费存储,还会干扰大模型的答案。去重的手段建议在入库前用文本哈希粗排,再结合相似度判断精确去重。
更讲究一点的团队会做文档版本管理,新版本入库的同时,把旧版本的向量标记为失效或直接删除。这块投入产出比很高,尤其是知识库规模上来之后,去重带来的收益非常明显。我有一个朋友的团队,知识库里十万个文本块,去重后发现将近20%是同一份材料的重复上传,清理之后检索准确率直接提升了一截,也把向量存储成本降了下来。去重逻辑不用很复杂,一个基于内容哈希的判重表就能解决大部分问题。
4. 实操配置与代码演示
4.1 环境准备与依赖
下面进入可直接复现的实操环节。我用的是Python 3.10,核心依赖有langchain、langchain-community、langchain-text-splitters、langchain-openai、unstructured、pymupdf、pdfplumber、fastapi、pgvector、psycopg2-binary等。安装命令在一开始执行一遍:
pip install langchain langchain-community langchain-text-splitters langchain-openai pip install unstructured[pdf] pymupdf pdfplumber pip install fastapi uvicorn pgvector psycopg2-binary如果你的服务端已经有一个PostgreSQL数据库,建议手动启用pgvector扩展:先创建扩展,再建表,建表时把embedding字段类型设置为vector。不管是用LangChain的PGVector封装,还是自己写SQL,都绕不开这一步。启用和建表的SQL大致如下:
CREATE EXTENSION IF NOT EXISTS vector; CREATE TABLE IF NOT EXISTS documents ( id serial PRIMARY KEY, content text, metadata jsonb, embedding vector(1536) );这里的vector(1536)取决于Embedding模型的输出维度,如果是OpenAI的text-embedding-3-small就是1536,换成BGE或其他开源模型要对应修改。这个维度直接影响表的存储成本和检索性能,不要随便填。插一句,存储成本在文档量大到百万级时会非常明显,1536维向量在PGVector里一张普通表可能要占几个GB,设计表结构时就要考虑分表还是分区。
4.2 核心代码:文档加载解析管道
我封装了一个解析管道,入口接收文件路径和文件类型,输出成列表形式的Document对象。核心逻辑是先按文件类型分发到对应的加载器,再做清洗,最后分块。这里用一个简化版的代码示例来解释流程:
from langchain_community.document_loaders import PyMuPDFLoader, UnstructuredWordDocumentLoader, TextLoader from langchain_text_splitters import RecursiveCharacterTextSplitter from langchain_core.documents import Document def load_document(file_path: str, file_type: str) -> list[Document]: if file_type == "pdf": loader = PyMuPDFLoader(file_path) docs = loader.load() elif file_type == "docx": loader = UnstructuredWordDocumentLoader(file_path) docs = loader.load() else: loader = TextLoader(file_path, encoding="utf-8") docs = loader.load() return docs def clean_document(docs: list[Document]) -> list[Document]: cleaned = [] for doc in docs: text = doc.page_content text = text.replace("\u3000", " ").replace("\xa0", " ") text = "\n".join([line.strip() for line in text.splitlines()]) doc.page_content = text cleaned.append(doc) return cleaned def split_documents(docs: list[Document], chunk_size=500, chunk_overlap=75): splitter = RecursiveCharacterTextSplitter( chunk_size=chunk_size, chunk_overlap=chunk_overlap, separators=["\n\n", "\n", "。", ",", " ", ""], length_function=len, ) return splitter.split_documents(docs)这里有一个细节:separators的顺序很重要。它决定了切块时优先按什么粒度断开,我把“。, ”放进去,是为了让中文文本尽可能在句子边界断开。如果直接用默认分隔符,中文文本容易被切成残句。这个经验是我经过不少项目试出来的,很多人代码跑通了但效果差,就是因为切块时句子被拦腰截断。
另外,用len做length_function是按字符数计算长度,如果Embedding模型计费或限制按token走,可以换成一个tokenizer来统计,比如tiktoken的encode函数。字符数和token数在中文场景下差异很大,一般一个中文汉字算一个token左右,但英文单词和标点就不好说了,最好按客户端的模型来选。
4.3 入库到PGVector
解析、清洗、切块完成后,下一步就是把文本块向量化并写入PGVector。这里可以使用LangChain的PGVector封装:
from langchain_openai import OpenAIEmbeddings from langchain_community.vectorstores import PGVector embeddings = OpenAIEmbeddings(model="text-embedding-3-small") vector_store = PGVector.from_documents( documents=split_documents, embedding=embeddings, collection_name="knowledge_base", connection_string="postgresql://user:password@localhost:5432/ragdb", )如果你的场景对隐私要求比较高,不想用云端Embedding API,也可以换成开源的Embedding模型,比如BGE系列,或者通过vLLM、Ollama这类工具本地部署。核心流程都是一样的:先是文档加载解析,再是切块,然后向量化入库。关于“RAG必须用API吗”这个疑问,答案是:不一定。云端API只是方便,本地模型完全可行,关键看你的数据隐私和性能要求。
真正上线时,我不建议每来一批文档就从头重新入库,而是要做增量更新。PGVector封装里提供了add_documents方法,新文档解析后直接追加即可;删除时可以通过metadata里的source或文档ID定位删除。增量更新的好处是知识库可以持续扩展,不用每次全量重建。全量重建在数据量小的时候很爽,但数据量上来以后,一次全量可能要几小时,业务上根本无法接受。
4.4 参数选择与验证
接入实际业务之前,我建议先做一个小规模的参数验证实验。步骤是:挑选5到10份真实文档作为样本,分别用不同chunk_size(比如300、500、800)切块,对同样的测试问题做检索,统计召回结果的相关性。不用太复杂,肉眼就能判断哪个参数下召回内容更完整。要重点观察两点:一是切出来的块有没有断句;二是检索问题时,命中的块是不是包含答案的关键信息。我用这个方法调过很多项目,基本一轮就能确定合适的默认参数。之后可以将这套参数固化到配置中心,避免每次都要手改代码。
测试问题怎么设计也有讲究,不能只挑一个“最好的问题”自我感动,要覆盖常见问答、模糊问答、跨文档问答三类。常见问答看基础命中率,模糊问答看语义泛化能力,跨文档问答看元数据和切块的配合。很多项目调参时只看了一两个例子,上线后一测就崩,就是因为测试集太片面。
5. 常见问题与排查技巧实录
5.1 PDF乱码与提取内容不完整
PDF解析最容易遇到的就是乱码。先判断是编码问题还是字体问题:如果是常见中文乱码,尝试换PyMuPDF或pdfplumber看是否复现;如果内容是以符号、方框形式出现,多半是字体子集或自定义映射导致,这时候可以借助OCR做辅助识别。我的经验是:不要盲目相信某一个加载器,一个文档解析失败,换一个工具再试往往就好了。实在不行,就回归到“转图片+OCR”这条保底路径,虽然慢,但结果相对可控。
还有一个常见坑是PDF页面里嵌入了图片型文字,比如某些扫描件虽然能选中文字,但选中后复制出来是乱的,这种其实是隐藏在图片后面的OCR文字层。判断方法很简单:把PDF页面放大到400%看文字是否清晰,如果文字边缘是锯齿状、实际上是图片渲染的,那就要按扫描件处理。
5.2 表格结构错乱
表格错乱是RAG文档解析的重灾区。PDF里的表格,文本提取库往往把单元格内容按位置或顺序拼成一串,行和列的对应关系基本丢失。一种处理方式是改用能够识别表格结构的工具,比如pdfplumber提取表格原始结构,或者用Camelot这类专门处理表格的库;另一种方式是把表格转为Markdown格式,让单元格结构以明确语法保留下来,这样切块时表格能被当作一个整体语义块。
项目里如果表格不多,我甚至建议把关键表格直接单独存成结构化数据文件,再在RAG链路里单独处理,查询时走精确匹配或结构化检索,效果会好很多。比如一个“产品参数表”,与其让RAG去猜“某型号的功率是多少”,不如把参数表抽出来放到关系型数据库里,先做精确查询再做生成。这也是所谓“RAG和结构化数据结合”的典型实践。
5.3 超长文档的加载与切块
超长文档,比如几百页的规章制度,常见问题是加载过程内存占用过高、切块后重复内容过多,或者文档中不同章节被切块时标题错位。我的建议是:加载前先用工具读取目录或章节结构,按章节切成大段,再对大段做二次切块,而不是一次把整个文本丢给切块器。同时在切块前打上章节标题的元数据,这样检索时能直接定位到某个章节,召回的内容会更有条理。
这里有个实操技巧:先把文档按空行或章节标题预切一次,得到一组“预块”,再对每个预块做固定大小切块。这样既避免了整篇文档的切块时标题和正文错位,又保留了章节的上下文语义。很多开源框架里已经有类似的递归切分方案,但默认参数不一定适合中文长文档,需要自己改。
5.4 检索效果不好,是解析的锅还是切块的锅?
这是排查时最常问的问题。我的排查顺序是:先人工查看被检索到的原始文本块,确认内容是否完整正确。如果文本本身就缺字、错位,那就是解析环节的问题;如果文本内容完整、但检索命中的块包含答案却不相关、或者相关内容被切成了两半,那是切块参数的问题;如果块没问题、检索也命中了相关片段,但生成答案质量差,那才轮到Embedding模型、Prompt和生成模型去背锅。按这个顺序逐层排查,能省下大量无效调参时间。
我见过一个团队调了一个星期的Embedding模型和Prompt,最后发现原因是PDF表格解析时把列顺序搞反了,检索到的“正确文档”内容本身就是错的。这种问题靠调模型永远解决不了,只能回头修解析。所以我在每个项目的索引流程里都会做一个“抽样审计”步骤:随机挑几十个文本块人工看一眼,确认解析结果可信后再大量建索引。
5.5 和Agentic RAG、MCP的关系
最近总有人问RAG和MCP的区别,以及Agentic RAG是怎么回事。这里简单说一句,MCP解决的是“模型如何标准化调用外部工具和数据源”的问题,RAG解决的是“模型如何从知识库中检索相关信息”的问题,两者定位不同,可以配合使用。Agentic RAG则是把RAG从一次性的“检索-生成”变成带决策和规划的Agent流程。但无论架构名词怎么变,文档加载解析都是它们的基础。模型调用工具、Agent规划执行步骤,最终还是要落到“知识库里的数据是否干净、是否结构化、是否可检索”上面。地基不牢,上层越复杂,问题反而越难排查。
做RAG项目的体会是,文档加载解析考验的不是掌握了多少高级框架,而是是否愿意把脏活、碎活干细致。很多团队一上来就想着切多高级的块、用多新的模型,最后卡住的却是一份PDF里的表格。我现在接手一个新项目,第一件事永远是先看一批真实文档的加载结果,人工翻一翻解析出来的文本长什么样,再决定后面的方案。这套方法看着笨,实则最快。如果你也在搭RAG知识库,建议先把第一份文档从加载到入库的全流程走通,再谈优化,很多问题都会在第一步暴露出来。