
我们从立项时的能用就行走到现在发现真正的工程量全在那些PPT里不会写的细节上。文海问津这个创新实训项目做的是文献知识问答平台——用户上传文档用自然语言提问系统从文档里检索依据并生成带引用的回答。上一期记录写了立项背景、需求拆解和技术选型这期把开发过程中最折磨人的部分整理出来文档解析、文本切分、检索调优和问答生成。如果你也在做类似的RAG检索增强生成项目这篇里的坑你大概率会碰到。1. 项目踩到中段我们为什么坚持RAG而不是微调先说个背景。项目启动会上组里有同学提议直接微调一个大模型让模型记住所有文档内容。这个方案听起来很省事但仔细一算就知道不靠谱我们的语料是持续增长的老师那边每学期都会更新一批文献。如果走微调路线每来一批新文档就要重新训练一次训练卡的费用和人力成本根本扛不住。更关键的是微调模型回答问题时说不出这句话出自哪篇论文的第几页这对于学术场景是致命的——用户需要验证答案需要引用出处。所以我们最终锁定了RAG路线文档先切分、向量化存入向量库用户提问时先从库里检索相关片段再把片段塞进Prompt交给大模型生成回答。这样做的好处有三个文档更新只需要重新跑一遍解析和向量化不用动模型回答可以精确到来源片段溯源天然可做不同专业方向的语料可以分库存储互不干扰。架构上分为四层前端页面负责上传文档和展示问答结果后端服务用FastAPI承载接口包括文档处理接口和问答接口中间层是向量库和文件存储最上层是大模型服务。文档处理管线是独立的一个模块解析、清洗、切分、向量化串成一条流水线。这条流水线看似简单实际调试起来让我连续三周每天晚上都在跟PDF解析结果较劲。2. 文档接入层PDF解析与文本清洗的踩坑全记录2.1 PDF解析三个库轮着用才凑齐能力PDF是所有文档格式里最让人头疼的没有之一。我们一开始天真地以为装一个PyMuPDF就能通吃结果第一个测试文档就翻车——那是一篇双栏排版的期刊论文PyMuPDF按物理行序提取文本把左栏和右栏的内容交错混在一起检索出来的片段语义完全断裂答非所问。后来我们做了个对比测试把PyMuPDF、pdfplumber、PDFMiner三个库跑在同一批文档上。结论是PyMuPDF速度最快单篇论文秒级出结果但对复杂版式的处理比较粗糙pdfplumber提取表格和坐标信息最准但速度慢一篇几十页的PDF要跑十几秒PDFMiner对文本顺序的处理在某些场景更好但API用起来比较别扭。我们的最终方案是一个路由策略先让PyMuPDF跑一遍全文提取统计页面的文本块坐标分布如果检测到页面里文本块的平均宽度明显小于页面宽度的一半就判定为多栏排版改用按坐标重排的提取逻辑——把每页的文本块按x坐标先分列再按y坐标排序最后按左栏从上到下、右栏从上到下的顺序拼接。这个逻辑听起来简单但实现的时候要处理跨栏的标题、页眉页脚、图表下方注释这些边界情况前前后后改了四版才稳定下来。另外还有一个大坑是扫描版PDF。有些资料是扫描件整页就是一张图片文本提取出来完全是空的。我们最开始想跳过这类文档但老师给的测试语料里确实有十几篇扫描件。最后在管线里加了一层OCR兜底用PyMuPDF检测到某页提取文本长度低于阈值比如少于50个字符就自动把这一页渲染成图片送入PaddleOCR识别。PaddleOCR的精度在扫描论文这种场景下够用但识别速度一般一页大概要两三秒。我们加了缓存策略已经识别过的文档会保存识别结果避免重复计算。2.2 文本清洗噪声定时炸弹必须拆干净解析出来的原始文本里头尾通常是论文标题、作者、单位、摘要、参考文献中间夹杂着页眉页脚、页码、图表标题、公式编号。这些噪声如果不清理直接切分向量化检索时会带来大量误召回。比如用户问注意力机制是什么检索结果可能命中参考文献列表里的某条条目而不是正文里的相关内容。清洗规则我们按优先级做了三层。第一层是规则过滤用正则去掉页码范围、页眉页脚通过对比多页相同位置的重复文本识别、孤立的行号、公式编号。第二层是结构归一化把全角字符转半角、统一换行符、去掉多余空行、把连续空白字符压缩成单个空格。第三层是段落重组根据缩进和换行特征把文本拼回语义完整的段落——这一步对后续切分非常重要因为很多PDF解析结果会把一个自然段拆成好几行如果不重组切分出来的chunk就是支离破碎的。清洗规则上线后又发现了一个隐蔽问题参考文献部分的文本质量参差不齐有些条目缺作者、缺年份格式混乱。这些内容向量化后会成为检索噪声。我们把参考文献识别出来单独存储不进入向量库的正文索引但在回答时可以作为补充信息查询。这样既避免了噪声污染主体检索又保留了元数据链路。3. 切分策略决定检索质量的第一道分水岭3.1 三种切分方式的对比结论文档清洗干净后下一步是切分。切分粒度直接决定了检索结果的语义完整性。chunk太小单个片段信息量不足召回的片段缺乏上下文chunk太大片段里包含太多无关内容向量相似度被稀释而且塞进Prompt会浪费token额度。我做了三类切分方案的对比实验固定长度切分512字符、overlap 50、按自然段切分、语义切分用嵌入相似度动态确定切分边界。固定长度切分实现最简单但经常把一句话拦腰截断尤其中文里虽然……但是……这种关联结构被切断后语义严重受损。按自然段切分保住了语义完整性但段落长短差异太大短的一两行长的能占一页向量化后短段落容易被长段落淹没。语义切分效果最好但计算开销大处理一篇30页的论文要多花不少时间。最终我们采用了一个折中方案按段落切分作为基础段落过长时再按句子边界二次切分把超长段落拆成多个有重叠的chunk。具体参数是目标chunk长度400-500字overlap设60字每个chunk保留段落的起始索引信息方便溯源时定位原文位置。这套方案在后续评估中检索命中率比固定长度切分高出约8个百分点。3.2 为什么overlap不能省有同学问过overlap的意义是什么。我用一个实际案例解释文档里有一句话该模型的参数量为七亿训练数据规模达两千亿token这句话正好被切在两个chunk的边界上前一个chunk以该模型的参数量为七亿结尾后一个chunk以训练数据规模达两千亿token开头。如果没有overlap用户问模型参数量是多少时前一个chunk能命中但上下文不完整用户问训练数据规模多大时后一个chunk缺失了主语该模型语义悬空。加了overlap之后两个chunk都包含完整句子检索和回答的容错性大幅提升。Overlap的大小也有讲究。太小比如20字兜不住长句的尾部太大比如200字会造成大量重复内容占用存储空间。我们实测60-80字对中文论文场景比较合适基本能覆盖一句话的主干长度。3.3 切分对中文场景的特殊处理中文没有天然的空格分词切分时的边界判定比英文更复杂。我们最初实现按句子边界切分时只识别句号、问号、感叹号后来发现引号内的内容经常被错误截断比如作者指出该方法是有效的。实验结果验证了这一点句号在引号内导致句子被切开。修复方案是加了一层引号配对检查句号出现在引号内时只有在引号闭合处才允许作为句子边界。公式和表格的处理也花了不少功夫。文档里的数学公式如果直接文本化变成一串Unicode符号切分后语义全无。我们做了个特殊处理把公式整体识别出来作为不可分割的单元保留在chunk中不做边界切分。表格则按行转成字段:值的文本形式再作为一个整体放入chunk这样检索时表格数据能保持结构化语义。4. 检索链路从能召回到召得准向量、BM25与重排的组合拳4.1 向量库和embedding模型的选择向量库我们对比了FAISS、Chroma和Milvus。FAISS是纯内存库跑demo很爽但数据量大了以后持久化和并发访问都麻烦Milvus功能齐全但部署较重单机版也要起好几个组件Chroma轻量级支持持久化对中小规模的实训项目最合适最终选了Chroma。Embedding模型在中文语料上的选择直接影响检索质量。我们测了三个模型m3e-base768维、bge-large-zh-v1.51024维、text2vec-large-chinese1024维。用一组20个问题的人工评测集跑了一遍bge-large-zh-v1.5在语义相似度检索上的Top-5命中率最高达到78%比m3e-base高出11个百分点。维度高带来的存储开销在几千篇文档的场景下可以接受所以最终选定了bge-large-zh-v1.5。一个容易被忽略的细节是query的向量化处理。用户的提问往往很短、口语化直接向量化效果不好。我们做了个简单改造提问先做一次轻量的扩展加上请从文档中找到与以下内容相关的信息这类任务前缀再向量化。别小看这一步在bge模型上带任务前缀的query向量化能稳定提升几个百分点的召回率官方README里也推荐这么做。4.2 混合检索向量召回的短板必须用BM25补纯向量检索的最大问题是它对精确的术语匹配不敏感。比如用户搜索Transformer向量检索会把注意力机制自编码器这些语义相近的内容排到前面反而不一定把原文中的Transformer精确匹配出来。在学术文献场景专有名词和模型缩写非常多精确匹配的需求很高。所以我们加了BM25关键词检索通道。用jieba分词后建立倒排索引和向量检索并行各自返回Top-30结果再用RRFReciprocal Rank Fusion做结果融合。RRF的公式很简单对每个文档在多个检索结果中的排名取倒数求和作为融合得分。我们实测发现向量结果和BM25结果按权重0.6:0.4融合比纯向量检索的Top-5命中率高出9个百分点。融合之后还有个问题Chroma本身没有内置BM25我们用的是Elasticsearch里的BM25实现。项目早期图省事直接把BM25写在后端代码里处理几百篇文档没问题但测试语料加到两千篇时性能明显下降。迁移到ES后中文分词用IK插件倒排索引的构建和查询都交给ES处理检索延迟从几百毫秒降到了几十毫秒。4.3 Rerank重排最后一步的质量提升融合后我们拿到了候选片段但这时的排序仍然是粗排排名靠前的片段未必最适合回答当前问题。我们引入了rerank模型——bge-reranker-large对融合后的Top-50候选片段逐一计算与query的相关性分数再按分数重新排序取Top-5作为最终送入Prompt的上下文。Reranker的增益在长文档场景尤其明显。候选片段多的文档粗排结果经常出现语义相似但答非所问的情况rerank之后会把真正包含答案的片段顶到前面。我们测试集上的答案准确率从54%提升到71%主要就是rerank的功劳。代价是每个query要跑50次打分单次查询总延迟增加了大约200毫秒在可接受范围内。5. 问答生成与溯源让大模型的输出变成可验证的结论5.1 Prompt设计的三次迭代检索做好了问答生成这块同样不能轻视。我们用的生成模型是qwen-plus通过API调用。最初的Prompt就一句话根据以下文档内容回答问题。结果模型经常自由发挥回答里出现文档里根本没有的信息。第一次迭代加了强约束明确要求只能使用提供的上下文不能凭空捏造如果上下文不足以回答问题直接说文档中未找到相关信息。这个约束把编造率降了一些但出现了新问题——模型过于保守遇到稍微需要推理的问题就不肯回答了。第二次迭代在Prompt里加入了任务分解说明先判断上下文是否包含答案核心信息如果包含基于上下文组织回答并标注信息来自哪个片段编号如果不包含明确告知需要扩充文档。这次调整后回答质量明显提升但标注引用时经常标错片段号模型把不同片段的内容混在一起标了同一个出处。第三次迭代把溯源机制从Prompt层面挪到了代码层面。我们在生成接口返回结果后对模型回答的每个关键句做一次回指匹配——把句子向量化后在Top-5候选片段里做相似度检索找到最接近的片段作为该句的引用来源。这样即使模型在生成时没按我们要求的格式标注后处理也能自动补上引用。实测下来这个事后溯源方案的准确率比纯Prompt标注稳定得多。5.2 引用溯源的前端呈现溯源信息的前端展示也经过了几轮调整。最开始只是把参考片段贴在答案下面用户反馈看不出哪句话对应哪篇文档。后来改成段落级高亮——回答里的每个关键句后面加一个数字角标点击角标右侧面板显示对应的原文片段原文里的关键部分用底色标出与回答句相关的部分。这个交互实现起来不算难但有一个细节需要注意原文片段在清洗和切分时已经被改写过比如换行被压缩、全角转半角直接展示会跟原PDF的排版对不上。我们的解决思路是保留切分chunk与原始解析文本之间的偏移量映射前端展示时用原始解析文本渲染只在定位时用chunk的偏移信息。这样视觉效果上跟原文档一致又保留了检索链路的结构。5.3 答案质量的评估方法实训项目要结题得有数据支撑。我们建了一个120条问答的评测集覆盖摘要总结、细节查找、对比分析、概念解释四类问题。评测维度有三个命中率Top-5片段是否包含答案依据、准确率模型回答内容是否正确、溯源率回答关键句能否匹配到正确的引用来源。三轮迭代后的数据是命中率76%准确率71%溯源率82%。其中细节查找类问题表现最好准确率接近85%对比分析类最差只有56%主要原因是需要跨文档整合信息而我们的检索流程默认返回单文档片段跨文档的线索被截断了。这个问题目前还没有完全解决是我们下一阶段的核心优化方向。6. 中期实测翻车现场三个印象最深的Bug6.1 同义词导致的召回失败有一次测试用户问贝叶斯网络的推理算法有哪些系统答不上来。排查后发现文档里通篇说的是信念传播而用户用的词是贝叶斯网络推理。BM25对贝叶斯和信念完全没有交集向量检索虽然能捕捉到部分语义关联但bge-large-zh-v1.5在这个专业术语对上的相似度得分恰好没过阈值。最后是通过扩展同义词词典解决的——对用户query先做一次术语扩展把贝叶斯网络推理扩展为信念传播||贝叶斯网络||概率图模型推理再分头检索。这个方案带来一个问题扩展过度会导致召回结果变杂词典的维护需要结合具体语料的术语表来做。我们目前用的是半自动方式从语料库里高频共现的词对中挖掘候选同义词人工审核后加入扩展词典。6.2 长文档切分后的上下文断裂有一份六十多页的技术报告中间有一大段是横向表格表格转文本后内容被切成了十多个chunk每个chunk只有表格的局部。用户问各年份的指标对比检索到的chunk只包含其中一年的数据回答自然不完整。这个问题的根源在于表格切分逻辑没有按语义边界处理。修复方案是识别到连续表格段落时把整个表格作为一个独立的chunk即使它远超预设的chunk大小也不强行切分。这个特殊处理牺牲了一点向量检索的精度但换来了表格场景的完整性整体效果是赚的。6.3 并发上传导致的内存溢出开发后期我们做了一轮压力测试模拟三十个用户同时上传文档。结果后端服务直接OOM。定位后发现是文档解析模块里有个全局缓存PDF解析后的中间结果全部放在内存里没有及时释放。修复方案不复杂解析完一篇文档把中间结果序列化落盘内存里只保留文件路径和状态标志开启解析任务时用信号量限制并发数最高同时跑三个解析任务其余排队。修复后同样的压力测试下内存占用峰值从1.8GB降到了600MB以内。这三件事给我的直接教训是Demo阶段能跑通的功能在数据量和并发上来之后完全是另一种复杂度。做创新实训项目前期多想一步上线后的场景能省下后期大量的返工时间。7. 下一阶段的扩展方向目前系统已经达到中期验收标准但离好用还有距离。我们下一步的优化重点有三个。一是解决跨文档问答的能力。现有的单文档检索链路在对比A方案和B方案的优劣这类问题上表现不佳我们打算在检索阶段增加一个多文档聚类的预处理——把检索到的片段按来源文档聚类每个聚类分别生成一次局部回答再用汇总Prompt把多个局部回答整合成完整答案。这个方案会引入更多的生成调用成本会增加但对比分析类问题的质量应该会有明显改善。二是文档更新后的索引同步。目前文档重新上传后是整体重建向量效率很低。我们计划在切分阶段就给每个chunk打上文档版本的标签更新时只做增量向量化删除旧版本的chunk。这样对频繁更新的语料库会友好很多。三是页面交互的细节打磨。测试用户反馈最多的是看完回答想直接跳到原文对应位置。我们在前端做了定位跳转的原型点击引用角标浏览器打开原始PDF并滚动到对应页面同时高亮相关行。这个功能依赖PDF渲染组件的能力跨浏览器兼容性问题比较多我们还在验证技术方案。总的来说文海问津这个项目进行到现在最大的收获不是模型调得多好、指标涨了多少而是完整走通了一条从文档到答案的真实链路——包括那些文档解析的脏活、切分调参的细活、检索兜底的累活。实训项目的时间有限但这些问题和解决过程比任何课程设计都更接近真实的生产环境。后续的记录我会更新跨文档问答和多文档聚类的实现细节有相同场景的朋友可以留言交流。