“这个问答系统回答得完全不对你确定是在回答我们技术手册里的内容”——当 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 资源的消耗较高解析大文件时的速度是三者中最慢的。PyMuPDFfitz则是我最终主力使用的方案因为它解析速度快、文本层提取质量高而且在处理带复杂排版的 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_size800, overlap100): 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( nametech_manual, metadata{hnsw:space: cosine}, embedding_functionef ) # 批量写入示例 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_embeddingsTrue) ids [fchunk-{j} for j in range(i, i len(batch_texts))] metadatas metadatas_batch[i:i batch_size] collection.add( embeddingsbatch_embeddings.tolist(), documentsbatch_texts, metadatasmetadatas, idsids )提示写入 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_embeddingsTrue) candidates collection.query( query_embeddingsquery_embedding.tolist(), n_results20, 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 优化数据基础打不牢后面花再多力气也是事倍功半。希望这篇文章里的实践经验能帮你少走一段弯路。