ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

RAG技术实战:从PDF解析到智能问答的完整落地指南

RAG技术实战:从PDF解析到智能问答的完整落地指南

“这个问答系统回答得完全不对,你确定是在回答我们技术手册里的内容?”——当 A 同事拿着售后文档的 PDF 来找我时,我意识到自己交付的 PDF 智能问答系统还没到能安心睡觉的水平。好在经过两周的折腾、把 RAG 链路从解析、切块、向量化到召回重读逐个环节重新捋了一遍,最终把回答准确率从最初的六成拉到了九成以上。这篇文章就把整套系统的设计思路、实现细节和踩坑记录整理出来,希望能给正在做类似项目的朋友省点时间。

先说清楚这套系统是干什么的:输入一批 PDF 文档,系统自动解析、切分、向量化,把语义索引存进向量数据库;用户提问时,系统先从库里召回最相关的片段,再交给大模型组织语言回答。它本质上是一个限定域问答助手,适合产品说明书问答、企业内部知识库检索、法律合同条款查询这类场景,不用微调模型就能让大模型“学会”你的私有资料。适合刚接触 RAG 架构、想快速落地一个完整项目的开发者,也适合在已有问答系统上想优化检索质量的技术人员。

1. 需求拆解与架构设计:搭建整套系统前必须先回答四个问题

1.1 系统边界:读 PDF 与必须“读懂”其中含义的差距

很多人拿到“PDF 智能问答”这个需求,第一反应就是“把 PDF 喂给大模型问就好了”。实际操作后会发现完全不是这么回事。PDF 本质上是页面排版描述文件,里面的文字可能是文本层、也可能是图片扫描件,还有可能带着复杂的表格、页眉页脚、多栏排版。直接把 PDF 内容灌给大模型,一是超出上下文窗口,二是排版干扰严重,回答会变得极其不可靠。

我当时先列了一个明确的能力清单,用来界定系统到底要做到什么程度:支持文字型 PDF 的自动解析,支持表格转文本,支持长文档切片,支持语义检索,支持带引用来源的回答。至于扫描件 OCR、多模态图表理解这类需求被砍到了 V2 版本。这个边界非常重要,在实际项目里,需求永远是越做越多的,如果前期不定清楚范围,后面会被各种边缘场景拖到失焦。

1.2 为什么选 RAG 而不是微调大模型

面对私有文档问答,最常见的两个技术路线是 RAG(检索增强生成)和微调。我的选择非常明确:优先用 RAG。原因也很简单,企业文档是高频变更的,今天更新一版说明书,明天补充一份新合同模板,RAG 只需要重新解析对应文档即可,成本极低。而微调则要重新准备训练集、重新跑训练流程,周期按天计算,对快速迭代来说完全不可接受。

另外还有一个关键约束:微调更适合改变模型的行为模式或领域风格,而不适合塞入大量事实性知识。大模型微调后的知识记忆能力并不稳定,经常出现“知道但记不准”的情况,具体到文档编号、规格参数这种精确内容,反而容易一本正经地胡说八道。RAG 本身就把答案限制在检索到的片段范围内,配合“没有找到相关内容时就直说”的 Prompt 约束,事实错误率会低得多。从算力成本看,RAG 也不需要 GPU 训练资源,一个普通的开发机就能跑通全流程。

1.3 向量数据库选型:当红选项与最终方案

选定 RAG 之后,最核心的基础设施就是向量数据库。我对比了三个主流方案:Chroma、FAISS、Milvus。

维度ChromaFAISSMilvus
部署难度极低,pip 即装即用低,本质是库而非服务较复杂,依赖 etcd、MinIO 等组件
数据持久化支持本地持久化需要自行管理索引文件自带高可用能力
适合规模百万级向量以内百万到千万级千万级以上
生态成熟度高,与 LangChain 集成方便高,学术和工程都用得多企业级方案,功能最全
运维成本几乎为零低需要专门学习

考虑到该项目预计文档量在 10 万片段量级以内,而且需要快速交付、方便演示,我最终选择了 Chroma。一个最直接的原因是它在我的开发环境里五分钟就能跑起来,而 Milvus 光搭环境就要折腾半天。另一个原因是 Chroma 自带的集合管理、元数据过滤和持久化能力刚好够用,没必要为一个中小型项目引入整套分布式存储体系。做技术选型时我的原则是:在当前规模下选择最简单且不过度设计的方案,不要为了未来可能出现的百万级数据提前背上架构包袱。

2. PDF 解析与预处理:这一步决定检索质量的上限

2.1 PDF 解析引擎的横评对比

很多做 RAG 的人把大量精力花在调 Prompt 和选模型上,却忽略了文档解析。实际上,解析质量决定检索上限,检索上限决定回答上限。如果解析出来是乱码或段落错乱,后面做再多优化都白费。

我实测过三款 Python 解析库,分别是 PyPDF2、pdfplumber、PyMuPDF。

PyPDF2 的优势是轻量、依赖少,但它对非标准编码的 PDF 支持很差,遇到部分扫描版文档会直接提取出空白或乱码。pdfplumber 更擅长处理表格,能够比较精准地还原表格结构,但对 CPU 资源的消耗较高,解析大文件时的速度是三者中最慢的。PyMuPDF(fitz)则是我最终主力使用的方案,因为它解析速度快、文本层提取质量高,而且在处理带复杂排版的 PDF 时鲁棒性更好。

结合一批混合来源的技术手册进行测试后,我最终的策略是:先用 PyMuPDF 做首轮解析,如果提取出的文本字符数异常少或乱码率高,再自动回退到 pdfplumber 重新解析。两份解析结果会根据长度与可读性评分择优使用。这个双引擎策略虽然实现上只多了几十行代码,但在提升稳定性的效果上非常明显。

2.2 清洗规则:消除页眉页脚与排版噪声

解析出的原始文本如果直接拿去切分,会出现一个很头疼的问题:页眉页脚和页码会被切进正文片段里,从而污染语义,导致检索时召回一批包含页码但毫无内容的片段。我在这里踩过几次坑,最后沉淀出三条清洗规则。

第一条是页眉页脚过滤。大多数企业文档的页眉是固定文本,连续一两页重复出现同样的短句,可以按“连续多页出现相同文本”的启发式规则剔除。第二条是空白字符压缩,把制表符、不间断空格、多个连续空格统一替换为单个空格,这一步能显著改善后续切分时的语义完整性。第三条是无效字符过滤,处理 PDF 中常见的乱码符、控制字符和非语言符号,避免它们干扰 embedding 效果。

清洗时要注意保留文档原有的章节标题信息。我通常在清洗阶段把“第 X 章”、“1.2.3”这类标题行单独标记出来,比如在前面加一个特殊占位符,这样后续切分时就能按章节做更合理的边界判断,而不是无脑按固定长度硬切。

2.3 文本分块策略:一个不能拍脑袋定死的重要参数

文本分块(chunking)是 RAG 链路里最容易被低估的一环。块太大,检索粒度粗,混入大量无关信息,稀释了语义;块太小,上下文碎片化,召回后缺少足够背景,大模型无法组织出完整答案。

我当时针对中文文档做了对比实验:

分块方式块大小重叠字符问题回答准确率(自建测试集)
固定 500 字符5005076%
固定 300 字符3005071%
固定 800 字符80010082%
按段落切分,超长再截断最大 80010088%

结论很明显,对于技术文档这种段落结构较完整的文本,“按段落切分、超长再截断”的方式远优于固定长度硬切。原因也容易理解:段落本身是一个语义完整的单元,固定长度硬切会把一句话拦腰截断,导致语义损坏。此外我设置了 100 字符的重叠,让相邻块之间保留一部分公共信息,防止关键语句恰好被边界切走。

def smart_chunk(text, max_chunk_size=800, overlap=100): paragraphs = text.split("\n") chunks = [] current_chunk = "" for para in paragraphs: if len(current_chunk) + len(para) + 1 <= max_chunk_size: current_chunk += para + "\n" else: # 段落超长时按句号截断,形成多个子块 if len(para) > max_chunk_size: sub_parts = [] current_sub = "" for sent in re.split(r'(?<=[。;])', para): if len(current_sub) + len(sent) <= max_chunk_size: current_sub += sent else: if current_sub: sub_parts.append(current_sub) current_sub = sent if current_sub: sub_parts.append(current_sub) chunks.extend(sub_parts) if current_chunk.strip(): chunks.append(current_chunk) current_chunk = para if current_chunk.strip(): chunks.append(current_chunk) # 拼接相邻块重叠 final_chunks = [] for i, ch in enumerate(chunks): if i > 0: prev_tail = chunks[i - 1][-overlap:] ch = prev_tail + ch final_chunks.append(ch) return final_chunks

注意:不同领域的文档结构差异极大,法律合同按条款切、技术手册按章节切、论文按段落切,根本没有通用最优参数。落地时务必结合自己的文档样本做小规模实验,而不是直接抄网上推荐的 chunk size。

3. Embedding 与向量检索:从文本到向量的关键跃迁

3.1 Embedding 模型选择:中文场景下别急着用海外模型

向量质量直接决定检索效果,而向量质量取决于 embedding 模型。中文文本和英文文本的语义空间差异很大,直接使用在英文语料上主导训练的模型,表现往往不如针对中文优化的模型。

我测试了几种常见方案,最终选择了中文语义向量模型,其 768 维向量、最大输入长度 512 token,实测在内部技术文档的 top-5 召回准确率比通用英文模型高出 12 个百分点左右。选择时我重点关注了三个指标:语义相似度任务上的准确率、最大输入长度与分块大小的匹配度、以及本地推理时的耗时。最后一个指标容易被忽略,如果 embedding 接口每次请求要几百毫秒,构建几万个块的数据集时耗时就会令人崩溃。

3.2 批量向量化入库:用异步任务替代逐条请求

确定了模型后,下一步就是把清洗、切分好的文本块全部向量化并写入 Chroma。这里我要提醒一个常见误区:用 for 循环逐条调用 embedding 接口,然后把结果逐条写入向量库。在小规模测试中这么写没问题,但在几千个块时就慢得难以接受。

我采用批量处理方式,每次发送 64 个文本块给 embedding 模型,一次性拿到矩阵后进行批量写入。同时为文本块保留元数据,包括来源文件名、页码、章节标题、原始文本内容。这些元数据在后面的引用溯源功能中非常关键。

import chromadb from chromadb.utils import embedding_functions # 使用自定义 embedding 模型 ef = embedding_functions.PrecomputedEmbeddingFunction() client = chromadb.PersistentClient(path="./db") collection = client.get_or_create_collection( name="tech_manual", metadata={"hnsw:space": "cosine"}, embedding_function=ef ) # 批量写入示例 batch_size = 64 for i in range(0, len(text_chunks), batch_size): batch_texts = text_chunks[i:i + batch_size] batch_embeddings = embed_model.encode(batch_texts, normalize_embeddings=True) ids = [f"chunk-{j}" for j in range(i, i + len(batch_texts))] metadatas = metadatas_batch[i:i + batch_size] collection.add( embeddings=batch_embeddings.tolist(), documents=batch_texts, metadatas=metadatas, ids=ids )

提示:写入 Chroma 时可以同时传入 embeddings 和 documents 两种形式。前者直接提供预计算向量,省去重复调用 embedding 的开销;后者供 Chroma 内部管理原始文本。我建议两者都要传,因为后续检索返回结果时可以直接拿到原始文本展示,不用再做一次索引查询。

3.3 相似度检索:只取 Top-K 还不够,还得做相关性判断

向量检索本身并不神秘,就是将用户查询做同样的 embedding 转换,然后在向量空间中计算余弦相似度,返回最相似的 K 个片段。但实测中我发现一个关键问题:即使是最相似的片段,也不一定真的能回答用户的提问,尤其是当文档集中存在大量语义相近的知识点时。

为了解决这个问题,我在原始检索之上加了一层相关性分数阈值过滤,并写了一个简单的重排逻辑。首先向量召回 Top-20 候选片段,然后利用相关性的语义交互模型或基于关键词命中比例的轻量重排器,对这些候选片段重新打分,最后只保留分数达标的前 5 个。这一层过滤机制极大地减少了“召回相关但答案错误”的情况。

另一个实践细节是查询改写。用户在真实使用场景中提问通常很口语化,而文档中的原文是书面化表达,两者的词面重合度往往很低。我在问答链路中在前加一个可选的查询改写步骤:让大模型把用户的模糊问题改写为更可能命中文档表述的检索 query。这个技巧对中文技术文档特别有效,比如“这机器怎么上电”改写成“设备上电操作步骤”,检索命中率会有明显提升。

4. 大模型接入与问答链路:从检索结果到最终答案的组合动作

4.1 LLM 服务接入:标准 API 对接与备用降级方案

系统开发过程中,大模型部分我将重点放在设计稳定的 API 接入层,而不是依赖某个具体的模型品牌。因为底层模型是经常会切换的,可能是不同供应商提供的,也可能是从 API 服务切换到本地部署模型。为了不让上层逻辑跟着频繁变动,我封装了一层统一的调用接口。

接口层核心是消息格式标准化,将系统提示词、检索上下文、用户问题组合成消息数组,再请求对应的模型服务。这里一个容易出现的问题是把上下文拼接成一大段字符串喂给模型,而不是使用规范的消息结构。多数模型服务对超长上下文的处理并不理想,如果文档片段都塞进 system 消息,很容易超过模型的上下文窗口导致截断。合理的做法是只把检索到的 top-5 片段拼入上下文,并明确告诉模型“如果这些片段中没有答案,请回复‘资料中未找到相关信息’”,避免模型强行胡编。

def build_messages(query, retrieved_chunks): system_prompt = "你是企业技术文档助手。请严格基于提供的资料回答,不要编造。" context = "\n\n---\n\n".join( f"【文档{idx + 1}】来源:{c['source']} 第{c['page']}页\n{c['text']}" for idx, c in enumerate(retrieved_chunks) ) user_content = f"请根据以下资料回答问题:\n\n{context}\n\n问题:{query}\n" user_content += "\n如果资料中没有足够信息,请直接答复:资料中未找到明确说明。" return [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_content} ]

当时我还在接入层做了超时重试与降级策略。当主模型服务响应超时超过 30 秒时,会自动切换备用模型服务,并在日志中记录。这是实际生产环境里极其重要的兜底逻辑,因为大模型 API 的稳定性远不如传统后端服务,没有降级方案的演示很容易在现场翻车。

4.2 Prompt 设计:用规则约束大模型不越界

RAG 场景下的 Prompt 设计和普通的对话 Prompt 有很大区别。普通对话追求开放性,而企业文档问答要求约束性。我最终确定的 prompt 包含四个要素:角色定义、引用要求、拒答要求、格式化输出要求。

角色定义让模型以“企业技术文档助手”的身份回答,提示它应该严谨使用资料中的表述。引用要求是要求回答时尽量列出信息来源的文档名与页码,这一点对实际使用场景极为重要,因为使用者需要确认答案出处。拒答要求是当资料不足时必须明说,绝不推测。格式化输出要求则是让回答先说结论,再展开说明,方便用户第一时间获取要点。

我还尝试了一个技巧:在 prompt 中让模型先“提取证据再回答”。也就是让模型在正式回答前,先复述一段据以回答的资料原文,再进行概括。实测这个技巧能有效减少幻觉,因为模型必须先看到文本、再组织语言,而不是凭印象自由发挥。如果对延迟不敏感,建议都把这一步加进去。

4.3 完整问答链路串联:从提问到带引用回答的实战流程

当把前面所有模块串起来时,系统已经具备了一个完整的 RAG 问答链路。完整流程是:用户输入问题,系统判断是否需要查询改写;将改写后的查询做 embedding 转换;在向量数据库中进行相似度检索,取回候选片段;对候选片段做重排与阈值过滤;将过滤后的片段与问题组装成 prompt;调用大模型 API 得到最终回答;对回答进行后处理,提取引用来源并返回给前端展示。

def answer_question(query): # 1. 查询改写 rewritten_query = rewrite_query(query) # 2. 向量检索 query_embedding = embed_model.encode([rewritten_query], normalize_embeddings=True) candidates = collection.query( query_embeddings=query_embedding.tolist(), n_results=20, include=["documents", "metadatas", "distances"] ) # 3. 重排与过滤 filtered_chunks = rerank_and_filter(rewritten_query, candidates) # 4. 组装 Prompt 并调用大模型 messages = build_messages(order_query, filtered_chunks) answer = llm_client.chat(messages) # 5. 提取引用信息 citations = extract_citations(filtered_chunks) return {"answer": answer, "citations": citations}

整个链路从请求到响应,在本地 CPU 环境下耗时约 3 到 5 秒,其中 embedding 约 0.2 秒,向量检索约 0.05 秒,大模型生成占了大头。如果对响应速度有更高要求,可考虑将大模型换成延迟更低的服务,或者对小规模知识库使用语义缓存提前命中。

5. 常见问题与排障实录:那些只会在实战中遇到的状况

5.1 检索结果不相关时的系统化排障法

这是被问得最多的问题:“为什么我的系统答非所问?”我在实践中总结了一套排障顺序,严格按序检查,能快速定位问题。

第一步是检查检索结果本身是否准确,先跳过生成环节,只打印 top-5 片段看内容是否与问题相关。如果不相关,那是检索链路的问题,和 Prompt、模型都无关。第二步查看解析后的原始文本是否有乱码或错位,如果解析层出问题,检索必然失败。第三步检查分块参数是否合理,我见过不少案例是 chunk size 设得过大,一个块里包含多个主题,导致语义被稀释。第四步测试 embedding 质量,用精确的语义相似用例直接验证两个已知相似句子的相似度得分是否偏高。第五步才轮到 Prompt 和模型,排查它们是否遵循了引用资料的要求。

这套顺序已经多次帮我规避“头疼医头”的困境。最典型的情况是,问题明明出在分块策略上,很多人却一个劲去调整 Prompt,白费半天力气。

5.2 表格与扫描件处理:最难啃的两块硬骨头

技术手册里最常出现的失败场景是表格。表格在 PDF 中被切成单元格坐标信息,按行列提取时顺序可能错乱,导致表格语义支离破碎。我试过多种方案,比较有效的做法是在解析时对包含表格的页面单独处理,用 pdfplumber 的表格抽取能力将表格转成 Markdown 格式文本,再嵌入到文档流中。诸如“| 参数 | 数值 | 说明 |”这样的 Markdown 结构能被大模型较好理解,比纯文本拼串的效果好得多。

另一个坑是扫描件 PDF。这类文件本质上没有文本层,PyMuPDF 提取出来是一堆空白。我的 V1 版本没有做 OCR,但做了一个明显标记:扫描件会被单独存储在未处理队列中,在管理后台提示“该文档需要 OCR 处理”。这是主动规避错误答案的一种策略。V2 如果要支持这类文档,可以考虑接入 OCR 方案,但必须评估每次识别的时间成本与准确率是否值得。

5.3 中文语义检索的性能与成本观察

从性能角度看,几十万片段的规模下,Chroma 的检索耗时仍能控制在 100 毫秒以内,嵌入式部署的优势很明显。环境上,embedding 模型在普通 CPU 上批量推理的速度大约每分钟 1000 到 2000 个文本块,如果文档量是十万级,一次全量建库要一个小时左右。建议将 embedding 服务单独跑在 GPU 机器上,或者在数据量级较大时使用异步批处理框架。

成本上则要重点关注大模型 API 的调用量。每一条用户问题都会附带多个文档片段一同传给大模型,这在 token 计费模式下不可忽视。一次问答消耗的 token 数大约在 1500 到 3000 之间,如果每天要服务几百个问题,月度成本会变得很可观。可以通过设置语义缓存来减少重复计算,即在问同一个问题时直接返回缓存答案;同时控制 top-K 数量,不要一味追求多,召回 3 到 5 个高质量片段,效果往往既不差、成本又低。实际使用中我还会在用户提问前加一道路由判断,明显超出知识库范围的问题直接拒绝请求,而不是白费一次大模型调用。

6. 一些可以继续深入的方向

这套系统做完之后,可扩展的方向还有不少。比如引入重排模型来替代目前的轻量重排逻辑,虽然会增加一点推理开销,但对答案精度的提升通常很可观,尤其是在文档语义相近的领域。再比如增加对话记忆能力,让用户可以追问“第二个方案的成本呢”,系统要能记住前文提到的“第二个方案”指的是什么。这一块不仅需要维护对话历史,还要在当前检索时结合历史上下文做查询改写,复杂度会上一个台阶。

另一个值得做的方向是文档增量更新。实际业务的文档不是一次性导入就完事的,而是频繁新增修订。目前开源向量库大多支持单条删除和更新,但如何高效定位“哪个分块属于哪份文档、更新后哪些分块要废除”,需要一个配套的文档版本管理机制。这些扩展方向从工程角度看都很有意思,等有进一步的实战成果再单独写一篇分享。

从解析、分块、向量化到生成链路,RAG 系统没有哪个环节是可以偷懒绕过的。如果你正在做类似的项目,我的建议是优先把关文档解析和分块质量,再谈模型调参和 Prompt 优化,数据基础打不牢,后面花再多力气也是事倍功半。希望这篇文章里的实践经验能帮你少走一段弯路。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进