ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

构建智能文档问答系统:从RAG到混合检索的完整实战

构建智能文档问答系统:从RAG到混合检索的完整实战 8.4构建智能文档问答主手听起来像是一门课程里的章节号但如果你正好在做企业知识库、本地文档助理或者想把手头几百份PDF、Word变成能对话的资产这一节绝对值得认真对待。所谓“主手”我的理解就是一套能跑通、可控的核心打法——从文档解析、文本切分、向量化到混合检索、重排序再到LLM生成答案的完整链路。这篇文章就是我落地这套系统时的完整实操笔记里面包括我踩过的坑、换过三次的方案以及最终稳定跑起来的那套配置。先说结论这个项目解决的核心问题是让一个LLM在没有针对特定文档做过微调的前提下也能针对私有文档给出有依据的回答。不是靠“喂”给模型训练而是靠“接”一个外置知识库。适合的人群很广后端工程师想做知识库服务、算法同学想跑通RAG基线、甚至产品经理想验证“文档问答”这个功能是否靠谱都能从中找到直接能用的代码和参数。1. 项目定位与整体方案设计1.1 为什么选RAG而不是微调模型很多人看到“文档问答”第一反应是“用文档微调大模型”。这个思路不是不行但对大多数场景来说是杀鸡用牛刀而且会引发一连串麻烦。微调要求有大量高质量的对齐数据问题和答案对你得找人逐条标注光这一项就够呛而且模型微调后知识是“固化”在权重里的文档一更新就得重新训练一轮成本高、周期长更头疼的是微调后的模型产生幻觉时你很难溯源因为答案不是从具体某一段文本里“查”出来的。RAG检索增强生成的思路相反——模型不负责记忆文档内容只负责“阅读理解”。用户提问后系统先从文档库里检索出最相关的几个片段再把这些片段拼接进提示词交给LLM生成答案。所以RAG的核心优势是知识实时更新换文档索引就行、答案可追溯能指到原文段落、对算力要求低只要有向量化模型不需要训练。就我的实践而言只要文档规模在几十万字以下RAG的效果和稳定性都显著优于小模型微调。当然RAG也不是银弹。它对“检索质量”极度敏感——检索不到关键内容再怎么调大模型也没用。所以这个项目的重心必须放在文档处理和检索链路这两个环节而不是只盯着最后的生成模型。这也是我把方案命名为“问答主手”的原因它不是某一个模型而是一套以检索为主干、以生成为输出的系统。1.2 技术栈选型我自己用的这套搭配选型原则只有一条能本地跑就不上云能开源就不闭源。因为文档问答最常部署在数据敏感的内网环境不可能把文档都丢给外部API。我的最终选择是文档解析PyMuPDF python-docx扫描件用PaddleOCR兜底文本切分自实现递归切分器兼顾段落完整性和长度控制嵌入模型BGE-M3BAAI开源维度1024中英文混合效果好向量库Chroma原型阶段 / Qdrant生产阶段混合检索BM25全文关键词 向量检索语义召回用RRF算法融合重排序BGE-Reranker-v2-M3对话模型支持OpenAI协议的大模型均可本地部署可用Qwen系列或DeepSeek蒸馏版这套组合的优点是全部可以在普通开发机上跑起来。嵌入模型和重排序模型都是百亿参数以下显存占用合理向量库用Qdrant的Docker单机模式即可支撑百万级向量。成本方面除了调用LLM可能需要API费用之外其余全部开源免费。环节我使用的方案备选方案选型理由文档解析PyMuPDF python-docxUnstructured、LlamaParse轻量、可控、无需外部API切片策略递归字符切分TokenTextSplitter保留语义单元长度可控嵌入模型BGE-M3text-embedding-3-small中文效果好支持多粒度检索向量库QdrantChroma、Milvus支持混合检索、过滤、快照重排序BGE-Reranker-v2-M3Cross-Encoder显著提升top-k命中精度生成模型Qwen / GLM / 任意OpenAI协议模型本地LLaMA系列协议统一便于替换1.3 整体架构梳理整个系统可以拆成两条链路离线的索引构建链和在线的问答推理链。离线链负责把原始文档变成“可检索的向量关键词索引”在线链负责接收用户问题、检索相关片段、组装提示词、生成答案。离线索引构建原始文档 - 解析 - 清洗 - 切块 - 嵌入向量化 - 写入向量库 构建BM25索引。这里每一步都可能出问题比如PDF里面是图片、表格跨页、标题层级混乱都会直接影响切片质量。在线问答用户提问 - 改写多轮场景 - 向量召回top-N - BM25召回top-N - RRF融合 - 重排序 - 拼提示词 - LLM生成 - 返回答案和引用来源。我要强调的一点是索引链路的质量决定了系统效果的上限。很多RAG项目做得“像弱智”问题不在大模型而在于切片切得乱七八糟、检索召回的内容跟问题不相关。所以后面这三个章节我会把每个环节都展开讲给出可以直接抄作业的代码和参数。2. 文档解析与知识库构建2.1 文档解析PDF、Word、扫描件各有各的坑先处理最基础的文档读取。PDF看起来简单实际藏着一堆暗坑文本可以用page.get_text()提取但PDF里文字可能被切成一行行的碎片顺序也是乱的表格内容提取出来经常和正文混在一起同一份PDF里可能既有文字层又有图片和扫描页混合排版。我最初直接全用PyMuPDF提取结果遇到一个项目文档表格里的数字全变成乱序单字符检索效果直接崩掉。所以我的文档解析流程是这样的先用fitz提取每页文本判断该页文本密度——如果一页的字数少于50个字符大概率是扫描图片页就送入OCR流程。OCR我用的PaddleOCR它对中文表格和自然场景文字效果都还可以但速度偏慢一张A4纸大约2-3秒。对纯文本PDFfitz的提取顺序基本可靠但遇到“文本被切碎”的情况我的经验是按坐标排序后再拼接——page.get_text(blocks)返回带坐标的文本块按y坐标从上到下、x坐标从左到右排序比按字符流提取更符合阅读顺序。import fitz def extract_pdf_pages(pdf_path): doc fitz.open(pdf_path) pages_text [] for page_num in range(len(doc)): page doc[page_num] blocks page.get_text(blocks) if not blocks: pages_text.append({page: page_num, text: [扫描页需OCR], blocks: []}) continue blocks.sort(keylambda b: (round(b[1], 1), b[0])) text \n.join(b[4].strip() for b in blocks if b[4].strip()) pages_text.append({page: page_num, text: text, blocks: blocks}) return pages_textWord文档相对简单python-docx可以按段落读取基本保留结构。但要注意Word里的表格、文本框、页眉页脚都可能被遗漏。我的做法是遍历document.paragraphs拿正文段落遍历document.tables拿表格并转成Markdown表格文本这样后续切片时能保住结构化信息。另外实践中有个细节很多Word文档是WPS生成的兼容性没问题但样式信息标题级别有时不标准不能完全依赖style.name来判断标题层级要结合字号和加粗特征做兜底。2.2 文本切分没你想的那么简单文本切分是整个项目中技术含量被低估的环节。切得太粗一个块几千字检索召回时会把不相关的信息混进来切得太细一个块一两百字语义不完整模型很难从中提炼答案。而且机械地按字符数切分会把段落、句子硬生生撕开破坏语义。我最终采用的方案是“两段式切分”第一步按文档的天然结构标题、段落、表格划出候选片段第二步对超长片段做递归切分固定块大小为400个汉字重叠区为80字。这个参数是我通过评测集试出来的不同业务文档的最佳参数不同——对外产品说明书偏结构化块可以大到500字政府公文或法规条文经常引用定义、条款块设小一点反而更精准。def recursive_split_text(text, chunk_size400, overlap80): if len(text) chunk_size: return [text] split_point chunk_size # 优先在段落边界切分 boundary text.rfind(\n, 0, split_point) if boundary chunk_size * 0.6: split_point boundary else: # 再退一步在句号、问号等句末标点处切 punct_pos 0 for punct in 。: pos text.rfind(punct, 0, split_point) if pos chunk_size * 0.6: punct_pos max(punct_pos, pos) if punct_pos: split_point punct_pos 1 head text[:split_point].strip() tail text[max(0, split_point - overlap):].strip() return [head] recursive_split_text(tail, chunk_size, overlap)这里的核心思路是“切点优先落在语义边界”。写代码时要注意overlap只在块与块之间共享文本不能简单地把整个尾部都塞进下一块否则相邻块会重复文本导致检索时同一片段被重复召回浪费上下文空间。我见过有人直接text[split_point:]作为下一块然后在块头部拼上overlap这样会造成文本重复量过大。正确的做法是让下一块从split_point - overlap处开始并且在拼提示词时不显示重复部分。2.3 向量化与向量库写入文本块准备好之后下一步是向量化。我用的嵌入模型是BGE-M3它对中文、英文和代码混排的文档都很友好1024维向量在检索质量上比老的m3e-base强不少。加载之后直接把文本批量转成向量写入向量库即可。向量库我推荐从Chroma起步。Chroma的API非常友好代码量少适合快速验证。但正式部署我换成了Qdrant——因为它原生支持payload过滤、内置BM25混合检索还有快照备份和增量更新。如果你的业务量不大比如几万块文本Chroma够用如果打算做成生产服务一步到位选Qdrant能省去后面迁移的麻烦。import chromadb from sentence_transformers import SentenceTransformer model SentenceTransformer(BAAI/bge-m3) chunks [...] # 来自上一步切分的结果 vectors model.encode(chunks, normalize_embeddingsTrue) client chromadb.PersistentClient(path./kb_chroma) collection client.get_or_create_collection( namedoc_kb, metadata{hnsw:space: cosine} ) collection.add( ids[fchunk_{i} for i in range(len(chunks))], embeddingsvectors.tolist(), documentschunks, metadatas[{source: doc_name, chunk_index: i} for i in range(len(chunks))] )写入向量库之后别急着进入问答环节。我做过一件很重要的事把向量库里的文本块按“来源文档”做抽样检查随机抽几十块看看切分结果是否合理。如果切分结果里大量出现“半句话开头、半句话结尾”的块说明切分策略还需要调整这时候排查效率最高等上线后再改就晚了。这个动作成本极低但能避免很多检索阶段的问题。3. 检索增强问答的核心链路3.1 混合检索为什么纯向量检索不保险第一版系统我只用了向量检索因为嵌入模型对语义的理解确实很惊艳。但实际测试发现一个典型问题当用户问“产品保修倒是几年”这种带具体实体词保修、几年的问题时向量检索对“保修”这个关键词的敏感度并不高反而容易被“三包”“维护”“售后”这些语义相近的词带走导致召回的片段里根本没有“保修期”的字样。问题出在嵌入模型的特性上它是把整个句子压缩成一个稠密向量具体的专有名词、数字、缩写会被“语义化冲淡”。所以我在实践中加入了BM25关键字检索走“混合检索”路线。BM25是经典的词频-逆文档频率算法擅长抓精确词匹配向量检索擅长抓语义相关。两者融合后既有精确匹配的稳定性又有语义扩展的灵活性。融合策略我用的是RRFReciprocal Rank Fusion公式很简单score Σ(1 / (k rank))其中k一般取60。把两个检索结果按排名融合再取top-N。这个办法的好处是不需要调两个分数之间的权重因为RRF只看排名而非原始分数非常稳健。from rank_bm25 import BM25Okapi import numpy as np def rrf_fusion(ranked_lists, k60): scores {} for ranked in ranked_lists: for rank, doc_id in enumerate(ranked, start1): scores[doc_id] 1.0 / (k rank) return sorted(scores.items(), keylambda x: x[1], reverseTrue) tokenized_chunks [chunk.split() for chunk in chunks] bm25 BM25Okapi(tokenized_chunks) # 向量召回伪代码 vector_top vector_search(question, top_k20) # 关键字召回 bm25_top bm25.get_top_n(question.split(), chunks, n20) # 融合取分再映射回实际文本 fused rrf_fusion([vector_top, bm25_top], k60)注意一个细节中文的BM25必须先分词。直接用split()按空格切分对英文没问题对中文会变成整句一个tokenBM25基本失效。我在生产环境用jieba分词后再做BM25索引效果立竿见影。如果嫌分词麻烦也可以考虑用ES自带的IK分词器但那样就多了个中间件单机场景用jiebarank_bm25更轻。3.2 重排序一个被低估的“短小精悍”环节混合检索召回top-20之后顺序依然不够精准。向量检索的相似度分数和语义相关度并不是严格正相关的排名前三的片段有时并不是最应该被生成的依据。这时候需要加一个重排序模型Reranker。重排序模型的思路是把“问题候选片段”拼成一对文本用交叉编码器Cross-Encoder计算它们之间的相关性分数再按分数重排。和嵌入模型的“双塔结构”不同交叉编码器让问题和文档在同一个网络中交互精度明显更高——但缺点是不能预计算文档向量只能在线对小规模候选集20个以内做打分这正是重排序适合的场景候选量小、实时性要求高。我用的BGE-Reranker-v2-m3是轻量级模型单次打分毫秒级20个候选全部跑一遍大约200毫秒完全可接受。实践中我把top-20重排后只保留top-5作为最终上下文效果提升非常明显。之前向量检索直接取top-5时答案经常“东拉西扯”加了重排序后引用来源基本都能命中真正的答案段落。3.3 提示词模板与答案生成把检索结果“喂”给大模型的正确姿势检索链路做好了最后的生成环节也不能随便。提示词模板的设计直接影响答案质量和形式。我的核心提示词结构如下扮演角色、约束要求只能根据上下文回答、提供上下文、提供问题。这里有几个实操要点必须明确要求“如果上下文中没有相关信息请直接说明不知道”否则模型会强行编造。要求“引用来源编号”让用户能溯源到原始文档。控制上下文长度——top-5的块每块400字大约2000字加上问题本身普通模型的上下文窗口绰绰有余。如果没有特殊需求不需要在提示词里写“请你扮演一个智能助手”之类的废话那只会浪费上下文空间。def build_prompt(question, context_chunks): context_text \n\n.join( f[{i1}] {chunk} for i, chunk in enumerate(context_chunks) ) prompt f你是一个严谨的文档问答助手。请根据以下提供的文档片段回答问题。 文档片段 {context_text} 问题{question} 要求 1. 只能使用上述文档片段中的信息进行回答不要使用外部知识。 2. 如果片段中没有相关信息请直接回答“根据现有资料无法回答”不要编造。 3. 回答时请在句末标注引用来源编号例如[1][2]。 回答 return prompt生成阶段我通过OpenAI兼容协议统一接入Qwen或GLM的API。这个协议的好处是模型切换几乎零成本今天用云端API明天换本地模型只要改一个base_url和model字段就行。我在生产切换过一次模型只改了环境变量其余代码没有动过。这也是我建议所有RAG项目都走OpenAI协议的原因——模型厂商换得比什么都快接口层一定要解耦。3.4 多轮对话别把上一轮的上下文直接拼进去当系统要支持多轮对话时会遇到一个经典问题用户第二句问“那它的价格呢”如果只看这五个字检索系统根本不知道“它”指代什么。最简单的方案是把历史对话拼进新的问题比如“用户第一轮问P100 GPU用户第二轮问那它的价格呢”直接拼成“P100 GPU 的价格呢”再去做检索和生成。这个query改写的动作我一开始尝试用规则实现检查当前问题是否包含代词如果包含就把最近一轮的用户问题拼接上去。但规则覆盖不了复杂情形比如“那轻量版呢”“换成国产的怎么样”这种隐含对比关系的句子。后来改成用LLM改写rewrite_system 你负责把多轮对话中的最新用户问题改写成独立问题保留关键实体和上下文不要回答用户的问题。 rewrite_user f历史问题1{q1}\n历史问题2{q2}\n最新问题{current_q}\n\n改写结果这个改写一般用轻量模型跑成本很低但对检索质量提升很大。我在评测集上对比过加了改写后多轮场景的答案正确率从54%提升到81%这个提升完全来自检索召回准确率的改善。这里也提醒一下改写时一定要让模型保留实体词很多模型会把“那”这种指代词直接去掉改写反而丢信息所以改写结果最好再拿原始问题里的实体词做一次“覆盖检查”。4. 工程化落地与效果评测4.1 系统架构与性能开销控制问答系统要正式对外服务就不能只是Jupyter Notebook里的一段脚本。我把它整理成了三层服务文档索引服务离线、检索服务在线、问答服务在线。离线索引服务是一个独立进程监听新文档上传事件。新文档到达后自动执行解析、切片、向量化、写入向量库BM25索引。这里我用了Redis的异步队列来承接文档上传请求因为大PDF解析和OCR耗时长不能同步阻塞在HTTP请求里。文档解析完成后通过WebSocket通知前端索引状态。在线问答服务是FastAPI应用接口接收{question, session_id}参数内部调用query改写 - 混合检索 - 重排序 - 拼提示词 - 调用LLM - 返回回答引用来源。这个链路全程无状态所以可以直接横向扩容。还需要加一层缓存对相同或相似问题在Redis里做缓存我设置的是10分钟过期。实测产线上大约30%的重复问题能命中缓存显著降低了LLM的调用成本。from fastapi import FastAPI from pydantic import BaseModel app FastAPI() class QARequest(BaseModel): question: str session_id: str app.post(/v1/qa) async def qa(request: QARequest): rewritten rewrite_query(request.question, request.session_id) top_chunks hybrid_search(rewritten, top_k20) reranked rerank(request.question, top_chunks, top_k5) prompt build_prompt(request.question, reranked) answer call_llm(prompt) return {answer: answer, sources: [c.metadata for c in reranked]}性能方面我测过一组数据单机8核16G的配置下并发20路请求检索重排序耗时约350msLLM生成耗时1-2秒取决于模型和长度。瓶颈几乎完全在LLM生成阶段所以生产调优的重点是减少无效生成、增加缓存命中率。如果你用本地部署的小参数模型建议开启vLLM做批处理推理并发表现会好很多。4.2 评测集构建不评测就不知道系统真实水平做RAG系统最容易犯的错误是“凭感觉调优”。改了一个参数感觉回答好了但说不清到底好在哪里。所以我建了一份评测集包含50个标准问答对覆盖不同难度层次单点事实查询“保修期是多久”、综合总结型“这份文档对部署环境提出了哪些要求”、否定型“文档里有没有提过XX功能”、跨文档型“A文档和B文档对XX问题的描述一致吗”。前两种各30个后两种各10个。评测时我主要看三个指标检索命中率标准答案对应的段落是否出现在top-5召回结果中、生成准确率答案是否包含关键信息点且无幻觉、引用溯源率回答附带的引用来源是否是正确答案所在段落。这三个指标分别对应检索能力和生成能力哪个环节出问题一目了然。我调参过程中最有说服力的一次对比把chunk_size从200改为400后检索命中率从72%提升到88%。原因是块太碎导致关键信息被切散上下文不完整。另外把重排序从“跳过”改为“启用”后生成准确率从61%提升到79%——这个数据说明重排序花钱花得很值。没有评测集我根本不可能量化这些改进。4.3 高频问题排查实录现象根因解决方案问什么都答“无法从资料中找到”检索召回的相关片段完全不相关检查切片质量确认embedding模型是否加载正确确认query改写是否丢失实体答案有来源标注但内容是编造的生成的上下文里混入了无关片段降低top-k数量加强重排序提示词里强调“只能依据片段回答”同一问题每次答案不一样模型采样参数未固定将temperature设为0或0.1关闭top_p随机采样文档更新后旧内容仍被检索向量库索引未同步更新增加版本控制按文档ID批量删除旧向量再重新写入检索速度越来越慢向量库向量量级过大且未建索引确保向量库使用HNSW索引或分批重建索引我踩过印象最深的一个坑是某次新上线一批文档向量库直接追加写入了但BM25索引没有重建。导致的结果是——向量检索能召回新文档内容BM25却检索不到融合排序后新文档的排名被拉低很多本应回答正确的问题都只返回了旧文档的信息。从那以后我固定了一个流程文档以批次为单位更新索引构建和写入必须同步完成两者不能出现一边新一边旧的状态。4.4 私有化部署与调优如果你的文档问答系统需要部署在纯内网环境还有一个问题要解决嵌入模型、重排序模型、LLM都必须在本地运行。嵌入模型和重排序模型用vLLM或者Transformers加载显存占用不大LLM的本地部署是主要成本7B-14B的量化模型一张24G显卡可以跑得比较流畅。这里我建议用vLLM做推理服务它支持OpenAI兼容协议这样业务代码零改动切换。量化级别我试过INT4速度最快但14B模型在复杂总结任务上会有轻微质量下降INT8的平衡点比较好建议优先考虑。额外的优化点启用prefix caching对反复出现的系统提示词和上下文前缀做缓存可以让多发并发请求的TTFT首token延迟降低一半以上。5. 从问答到Agent的演进空间5.1 问答系统的边界当前这套系统能解决“基于文档回答问题”的需求但它有一个明显边界只能读不能做。用户问“帮我总结一下这份合同里有哪些风险”它能回答但如果用户说“帮我根据这份报价生成一份采购合同”它就无能为力了——因为后者需要调用工具、操作文档、生成新文件。这就是从“问答助手”进化到“智能体”的典型场景。我在热词里看到“基于react模式构建能思考与行动的ai智能体”说的就是这个方向——在RAG的基础上让模型具备调用外部工具的能力。比如让它检索到相关信息后再调用一个模板渲染服务生成合同或者调用计算器完成价格换算。问答系统是智能体的“记忆模块”工具调用是智能体的“手脚”两者结合才能完成真正的闭环任务。5.2 我可以实操的最小演进路径如果你想把当前这个文档问答系统演进成一个小智能体最简单的方式是给LLM增加一个“工具调用协议”——OpenAI的function calling或者用prompt JSON Schema约束。我这边已经做的一个实验是当用户问“帮我找出文档里所有涉及付款条件的段落并生成风险清单”时系统先调用RAG检索再调用一个“风险规则匹配”的Python函数去扫描段落最后用结构化模板输出结果。这时的流程变成用户意图解析 - 判断是否需要工具调用 - RAG检索 - 执行工具 - 汇总结果 - 生成答案。每一步都可以用评测集单独检验比端到端盲目调LLM要可靠得多。我建议任何想往Agent方向走的人先把手头的RAG链路做到稳定可评测再一步步加外部工具千万不要一开始就搞一堆复杂的Agent动作编排——调试成本会指数级上升。5.3 我的最终心得做完整套系统之后我最大的感受是文档问答这个项目难点不在大模型而在工程细节。文档解析的杂七杂八、切分边界的取舍、检索融合的权重、提示词里一句“只根据资料回答”的约束每一个小点都在决定系统最终的体验。网上有大量现成的LangChain教程但照着教程把流程跑通很容易真正要打磨到业务可用还得回到自己的文档和评测集上来一步一步量化调优。这个项目后续我还会继续扩展一是增加多模态文档支持图片表格、流程图二是把问答历史接成一个可检索的记忆模块让系统能回答“我之前问过什么”这类元问题。如果你也在做类似的方向希望这篇实操记录能帮你少踩几个坑。有什么问题欢迎留言交流。
返回列表