首页 / AI教程 / 正文
AI教程

搭建支持多文件上传的问答工具:从上传解析到RAG检索生成的一站式实战指南

chuanbook chuanbook
发布于 2026 年 10 月 25 日
阅读 约36分钟
浏览 1
评论 0

我自己在做这个多文件问答工具之前,先花了不少时间跟不同角色聊。产品经理关心用户会不会用、能不能留住人,开发同学关心接口怎么拆、性能扛不扛得住,实际用的人只想知道:我把一堆文件丢进去,问一句话,能不能快速拿到靠谱答案。这三种视角其实指向同一个问题——需求得先理清楚,架构才不至于拍脑袋。

1.1 搭建支持多文件上传的RAG问答系统的目标、场景与用户价值

我最初的想法很朴素:让用户把多个文件一次性传上来,系统能读懂内容,然后像跟人聊天一样回答问题。这个目标拆开看有三层。第一层是“传得上去”,支持多文件、常见格式、批量操作。第二层是“读得明白”,系统要把文档里的文字、表格、图片信息提取出来,变成可检索的知识。第三层是“答得准确”,用户问一个问题,答案要能追溯到具体文件的具体位置,不能胡编。

从场景看,我遇到过几类典型用户。一类是法务和审计人员,手里有几十份合同、报告,想快速定位某个条款或风险点。一类是学生和研究者,收集了一堆论文、讲义,希望跨文件对比观点。还有一类是客服和运营,需要从产品手册、FAQ里找标准答案。这些场景的共同点是文件多、格式杂、问题碎,靠人工翻找效率很低。

用户价值这块,我自己的体会是“省时间”只是表面。更深的价值在于降低信息遗漏的风险。一个人看十份文件,难免漏掉关键段落。系统把多文件内容统一表征、检索、生成,相当于给用户配了一个不知疲倦的阅读助手。再加上引用溯源,用户能自己核对答案来源,信任感就慢慢建立起来了。

1.2 多文件问答工具的功能边界、交互流程与核心指标

功能边界得提前说清楚。我设计的时候把能力限定在“文档内容问答”上,不做通用聊天,也不做超出文件范围的开放域回答。用户上传什么,系统就答什么。多文件之间可以交叉检索,但不会主动去网上找资料。这样做的原因很简单:边界越清晰,用户预期越稳,系统也更容易评估效果。另一个边界是文件大小和数量,初期我定了一个相对保守的上限,避免单个用户把资源占满。

交互流程我反复改了好几版。现在走的是“上传—解析—索引—提问—回答”这条线。用户拖拽或选择多个文件,前端给出格式校验和进度反馈。文件传完后,后台排队解析、切块、向量化。索引就绪后,用户在对话框里提问,系统先检索相关片段,再生成带引用的答案。整个流程里,我特意加了状态提示,让用户知道文件处理到哪一步了,不至于干等着心里没底。

核心指标我关注几个:文件上传成功率、解析完成率、问答响应时间、答案引用准确率、用户追问率。上传成功率和解析完成率反映基础体验,响应时间决定用户愿不愿意等,引用准确率决定用户信不信。追问率是个很有意思的指标,追问多不一定坏事,说明用户愿意继续挖,但追问太频繁也可能意味着首轮答案不够好。我一般会结合用户反馈一起看。

1.3 上传、解析、向量化、检索、生成五大模块的技术选型

技术选型这块,我尽量从实际落地角度出发。上传模块前端用原生拖拽API加文件切片,后端用对象存储暂存原始文件,数据库只存元数据。这样做的好处是文件本身不压在应用服务器上,扩容和备份都方便。解析模块按格式分流:文本直接读,PDF用解析库抽文字和版面,Word和PPT走结构化解析,表格单独处理单元格,图片走OCR。每个解析器都输出统一的结构化文本加元数据,方便后面统一处理。

向量化模块我选的是可替换的嵌入模型接口,初期用轻量模型跑通链路,后面根据效果再换更强的模型。批量向量化时注意控制并发,避免把GPU或API配额打满。检索模块用向量数据库加关键词检索做混合召回,向量库负责语义相似,关键词负责精确匹配。生成模块接大语言模型,提示词里强调“只根据检索到的上下文回答,并标注引用来源”。这几个模块之间用消息队列解耦,解析和向量化可以异步跑,上传接口不用等太久。

选型时我也纠结过要不要自建向量索引。后来决定先用成熟向量数据库,把精力放在解析质量和检索策略上。模型方面,嵌入模型和生成模型都留了配置项,方便后续替换。我的原则是:链路先跑通,再逐段优化,不在一开始追求极限性能。

1.4 系统数据流、权限边界与安全合规设计

数据流我画过好几版草图。用户上传文件后,原始文件进对象存储,解析结果和切块文本进文档库,向量进向量库,问答日志单独存。每一步都带文件ID和用户ID,方便追踪。检索时只查当前用户有权限的文件对应向量,生成时也只拿这些片段做上下文。数据流清晰了,排查问题会容易很多,比如答案不对,我能快速定位是解析丢了内容,还是检索没召回到。

权限边界我分了租户级、用户级和文件级。租户之间数据隔离,用户只能看自己上传或别人共享给他的文件。文件级权限跟着文档走,删除文件时同步删向量和解析结果,避免“删了还能搜到”的尴尬。管理后台有审计日志,记录谁上传了什么、谁问了什么、系统返回了什么。安全合规方面,文件传输走加密通道,存储加密,敏感场景可以配私有化部署。用户数据不拿去训练模型,这一点我在产品说明里写得很清楚。

合规设计还包括保留期限和删除机制。用户删除文件后,系统在约定时间内清理原始文件、解析结果和向量索引。日志脱敏后再留存,方便审计但不泄露内容。这些设计不一定显眼,但真到企业客户那里,往往是能不能过采购流程的关键。我自己的经验是,安全合规早点想,比上线后再补要省事得多。

说实话,第一章把架构图画完的时候我挺得意的,觉得后面按部就班写代码就行。真到动手做上传这块,我才发现坑比想象中多。用户上传文件这个动作看起来简单,拖一下、转个圈、显示个对勾,背后要处理的事情一大堆。前端得让用户传得顺手,后端得扛住并发、接住失败,文件传上来之后还得管得住、找得到、删得掉。这一章我按自己踩坑的顺序来聊,从用户手指头碰到的那个上传按钮说起。

2.1 前端多文件选择、拖拽上传、格式校验与进度反馈

我在做前端上传交互的时候,先把自己当成一个普通用户来用。人的直觉就是拖文件进来,或者点一下按钮去选,没人愿意看说明书。所以我把整个上传区域做成一个大拖拽框,鼠标悬上去有高亮,松手直接触发上传。多选这块用原生input的multiple属性配合文件选择对话框,用户按住Ctrl或者Shift能一次挑十几个文件。拖拽事件里我特别处理了拖入非文件的场景,比如用户从网页上拖了一段文字进来,得给个友好提示而不是让页面跳转。

格式校验我放在前端先做一遍,后端再兜底做一遍。前端只做粗筛,把后缀名不在白名单里的文件拦下来,弹个小提示告诉用户哪个文件不支持,别让他们传了半天才发现失败。文件大小和数量也在这层检查,超限的直接标红但不阻断其他文件上传,用户能继续传合规的那些。进度反馈我觉得比很多人想的要重要,尤其是多文件场景,用户得知道整体进度和每个文件的进度。我做了个列表,每个文件一行,显示文件名、大小、状态和进度条,正在传的转圈,传完的打勾,失败的红字标出原因。有个细节我后来才补上——上传按钮在所有文件处理完之前置灰,避免用户手抖重复提交。

拖拽上传我还加了个小优化,就是文件夹拖入。有些用户习惯把整个文件夹拖进来,浏览器给的DataTransfer里能拿到目录结构,我做了递归展开,把文件夹里的文件自动铺平。这个功能代码量不大,但用户反馈出奇地好,特别是那些一次要传几十份材料的场景。进度反馈这块我还试过顶部全局进度条,后来砍掉了,因为多文件下每个文件状态不一样,全局条反而让人困惑,保留列表式反馈就够用。

2.2 后端分片上传、并发控制、断点续传与失败重试

后端这块我一开始图省事,走了最直接的表单上传,小文件没问题,一上大文件就原形毕露。几十兆的PDF传一半网络抖一下,整个请求废掉,用户得从头再来。后来改成文件分片上传,前端把大文件切成固定大小的块,每块单独发请求,后端收到块先落盘或者塞进对象存储,所有块到齐了再合并。分片大小我调过几轮,太小请求次数多、调度开销大,太大单块失败重传成本高,最后定了个折中的值,实际跑下来比较稳。

并发控制是分片上传里容易被忽略的一环。前端如果一股脑把所有分片同时发出去,浏览器连接数被占满,其他请求全卡住。我做了个并发窗口,同时最多发三到四个分片,完成一个补一个。后端也做了限制,每个用户同时处理的合并任务有上限,防止有人开一堆标签页使劲传。分片元数据我单独存了一张表,记录文件标识、分片序号、分片哈希和状态,这样中途断开再连上来,前端先问后端哪些分片已经收到了,只传缺的那些,断点续传就自然实现了。

失败重试我设计了两层。分片级别失败,前端自动重试两到三次,间隔逐渐拉长,避免网络瞬时抖动导致的误判。整个文件级别的重试留给用户手动触发,列表里失败的条目旁边放个重试按钮,点一下只重传失败的分片。我遇到过一种情况,某些分片老是失败,排查发现是用户网络环境对特定大小的请求有限制,后来在重试逻辑里加了动态调整分片大小的兜底。合并阶段也有失败的可能,比如某分片校验不通过,这时候我会把合并任务标记为待修复,清理已合并的临时数据,然后通知前端重新拉取分片状态。这块逻辑写起来琐碎,但真到弱网环境下,用户能不能把文件传上来就靠这些兜底。

2.3 文件去重、版本管理、批量任务队列与状态同步

文件传上来之后我很快发现一个问题:同一个文件用户可能传好几遍。有的是手滑,有的是隔了几天忘了自己传过。不去重的话,解析、向量化全白跑一遍,存储也浪费。我做的去重是内容级哈希,文件流式计算摘要,算完拿摘要去查已有记录。命中的话不重复存储原始文件,直接复用已有的解析结果和向量,用户这边看到的是“已存在相同文件”的提示,可以选择跳过或者强制新建一份。哈希计算我放在分片合并之后做,边合并边算,不额外多读一遍文件。

版本管理是后来加的。有些用户的文件会更新,比如合同改了一版、报告修订了数据。我让同一个逻辑文件下可以挂多个版本,每个版本有自己的哈希、上传时间和解析结果。用户默认看到最新版,需要的时候能切到历史版本对比。检索的时候只索引最新版,避免新旧内容混在一起干扰答案。删除旧版本我做了延迟清理,先标记为待删除,过一段时间再真正物理删除,给用户一个后悔的窗口。

批量任务队列是支撑多文件场景的骨架。文件传完后不是立刻解析,而是给每个文件生成解析任务丢进队列,消费者按顺序取任务执行。队列我用的是成熟的消息中间件,支持优先级和重试。状态同步这块我花了不少心思,文件状态从“已上传”到“解析中”到“已索引”再到“失败”,每次变更都推一条事件出去,前端通过轮询或者长连接拿最新状态。批量上传几十个文件的时候,用户能看到列表里状态一个个变绿,心里踏实。有个坑是状态乱序,比如解析完成的事件比上传完成的事件先到,我加了个状态机校验,只允许合法的状态流转,非法事件直接丢弃并记日志。

2.4 文件列表管理、删除重建索引与用户操作提示

文件列表看起来就是个表格,做起来细节很多。我按上传时间倒序排,支持按文件名搜索、按状态筛选。每个文件显示名称、大小、格式、上传时间、当前状态和可执行操作。状态用颜色区分,绿色就绪、黄色处理中、红色失败、灰色已删除。列表我做了分页和懒加载,文件多的时候不至于一次拉全量卡住页面。批量操作也加了,勾选多个文件能一起删除或者一起重新解析,省得用户一个个点。

删除这块我格外小心,因为涉及索引清理。用户删文件的时候,我先删逻辑记录,把文件标记为已删除,前端立刻从列表里移除,体验上很跟手。后台异步去清理原始文件、解析文本和向量索引,清理完成再物理删除记录。为什么要分两步,因为向量库删除可能有延迟,同步删的话用户删完立刻提问还能搜到内容,那就尴尬了。重建索引是给解析失败或者换了嵌入模型之后用的,用户点重建,系统清掉旧向量重新跑一遍解析和向量化流程。重建期间文件状态显示为处理中,问答暂时不包含这个文件,避免半新半旧的数据混用。

用户操作提示我坚持一个原则:每个动作都要有回音。上传成功弹个轻提示,删除前弹确认框说明影响范围,重建索引告诉用户大概要等多久。错误提示尽量具体,别只说“操作失败”,要说清楚是格式不支持、大小超限还是网络问题。我还在列表顶部放了个全局状态条,显示当前有多少文件在处理、多少失败、多少就绪,用户一眼能看清整体情况。这些提示看着是小事,但多文件场景下用户很容易迷失,清晰的反馈能省掉大量客服沟通。

上一章末尾我聊到文件状态会从“已上传”跳到“解析中”,这个跳变背后就是这一章要讲的事。说真的,做这个问答工具之前我以为解析是最没技术含量的环节,调个库读出来文字不就完了。等我把用户真实上传的文件收上来一看,PDF有扫描的、Word有带修订痕迹的、Excel一个单元格塞了三行字、PPT里全是图没有字、图片拍得歪歪扭扭还带反光,我才明白解析是整个链路里最脏最累的活。它要是没做好,后面的向量化再漂亮、检索再精准,喂进去的都是渣,答出来的东西自然没法看。

3.1 多格式文档解析与向量化实现方案的总体流程

我最后把这条流水线定成这个样子:解析任务从队列里出来,先做格式嗅探,不看后缀名看文件头,避免用户把docx改成pdf传上来。嗅探完分发给对应的解析器,每个解析器负责把自己的格式翻译成一套统一的中间结构。这套中间结构是我吃过亏之后才定的,一开始我图省事,直接让下游代码去读各个库的原始输出,结果清洗模块里全是if pdf、elif docx的分支,加到第六种格式的时候我快疯了。统一之后每个块带上类型、内容、页码、层级、样式这些字段,后面的清洗、分块、元数据注入都只认这一种结构,清爽太多。

中间结构定好,流程就顺了:原始文件 → 格式嗅探 → 专用解析器 → 统一块结构 → 清洗 → 语义分块 → 元数据标注 → 批量向量化 → 入库。每个箭头之间我都留了个落盘检查点,解析完的中间结果先存一份,向量化挂了不用从头再解一遍。这个决定救过我一次,嵌入服务升级那会儿向量要全量重算,几万个文件的解析结果直接复用,省了差不多一整天的算力。流程里我唯一纠结过的是清洗放在解析器里还是单独抽出来,想过让每个解析器自己清,后来觉得那样规则没法统一,还是抽成独立模块,解析器只管尽可能完整地把内容掏出来,脏活交给后面统一干。

3.2 文本、PDF、Word、表格、PPT与图片OCR的解析策略

纯文本是最省心的,但也没我想的那么简单。编码就是第一道坎,用户传上来的txt有UTF-8的、有GBK的、还有带BOM的,直接按UTF-8读就是一片乱码。我写了个编码嗅探,先试几个常见编码,把解码失败率最低的那个当结果,实在判断不了就按GB18030兜底,中文场景下这个稳。换行符也得归一化,Windows的CRLF、老Mac的CR、Unix的LF混在一起,不统一后面分块会切出诡异的空行。

PDF是重灾区,我把它分成两类处理。文本型PDF用PyMuPDF直接抽,速度快、版面信息保留得也不错;扫描型PDF整页都是图,抽出来是空的,得走OCR路线。判断依据我用了每页字符数和图片占比两个信号,字符少图又大就判成扫描件。文本型PDF的坑主要在版式,双栏排版的论文直接按顺序抽会把两栏文字串成一句话读都读不通,我加了分栏检测,按块的坐标做聚类再决定阅读顺序。页眉页脚也烦,每页重复出现的那行字会被当成正文切进块里,我拿前几页做频率统计,重复率高的短行直接干掉。表格在PDF里更麻烦,线框表格还能靠坐标还原,无线表格基本靠猜,我现在的策略是抽成文本描述,宁可损失结构也别输出一堆错位的单元格。

Word用python-docx,docx格式还算友好,段落、样式、标题层级都能拿到。标题层级对分块特别有用,我按Heading样式把文档切成章节树,后面分块优先顺着这棵树走。老式的doc文件我没直接支持,让用户转存成docx,与其花大力气啃二进制格式不如把提示写清楚。Excel和CSV我单独做了处理,表格不能当纯文本切,一个单元格里的“2023年营收”和“1200万”被切开就失去意义了。我的做法是把每行转成“列名: 值”的键值描述拼成一段,或者整表转成markdown表格保留结构,行数特别多的表按表头分组切片,每片都带上表头,不然检索到中间几行根本不知道在说什么。合并单元格我也踩过,读出来是空白,得回溯填充。

PPT按页解析,每页抽标题、正文文本框和备注,图片里的字走OCR。PPT的阅读顺序挺随缘的,我按文本框位置从上到下、从左到右排,效果勉强能接受。图片OCR我用的PaddleOCR,中文识别比Tesseract强不少,前提是图得先处理一下。用户拍的照片经常是歪的、暗的、带反光的,我加了一步预处理,做灰度、自适应二值化、倾斜校正,识别率能提一截。OCR结果我保留了置信度,低于阈值的块标记出来,检索时可以降权或者干脆不参与,免得把认错的字当成事实喂给模型。

3.3 文档清洗、分块策略、元数据标注与权限继承

清洗这块我写了一大堆规则,每一条都是被真实文件逼出来的。页眉页脚去重、连续空行压缩、全角半角统一、零宽字符剔除、水印文字过滤、目录页和封面页识别后降权。有份用户传的制度文件,每页底部都有“内部资料 请勿外传”,不清掉的话每个块里都带着这句话,检索时它出现的频率高得离谱,能把真正的内容挤下去。清洗规则我做成可配置的,不同格式挂不同的规则集,PDF那套规则套到Excel上会误伤,这个边界得分清楚。

分块策略我调了很久。固定长度加重叠是最简单的,按字符数切,块之间重叠一百来个字保证上下文不断。实际跑下来问题不少,句子被拦腰截断是常态,表格切碎了更灾难。后来改成混合策略:有标题层级的文档先按标题切章节,章节内部再按段落和句子边界切,超长段落才退回到固定长度切分;表格和代码块设为原子块不切开;列表项尽量整组保留。块大小我试过256、512、1024几个档,中文场景下512个字符左右召回效果比较均衡,太小了语义不完整,太大了检索精度下降。重叠我留了百分之十几,主要为了跨块的指代和问句能接上。

元数据标注是让检索结果可解释的关键。每个块除了内容本身,还带来源文件名、页号或段落号、所属章节标题路径、文档创建时间、文件哈希。用户看到答案里的引用能直接定位到原文第几页,信任感完全不一样。权限这块我在文件上传时就打了标签,部门、密级、可见范围这些字段一路继承到每个块上,向量库那边把权限字段做成过滤条件,检索时先按用户身份过滤再算相似度。我一开始想的是查出来再过滤,测试时发现不对,过滤后剩下的结果可能全是低分的,答案质量崩了。改成检索时就带权限过滤,虽然索引结构复杂了点,但至少不会答出用户不该看的内容。

3.4 解析质量评估、异常文档处理与格式兼容性测试

解析质量我怎么判断,这事我想了挺久。纯靠肉眼看几十个文件不现实,我搭了个自动评估的小工具,统计每个文件的文本抽取率、乱码字符比例、空块占比、OCR平均置信度,指标异常的自动标红进人工复核队列。文本抽取率特别低的,多半是扫描件没走对路线或者加密了;空块占比高的,可能是分块规则和这个格式不匹配。我每周抽一批标红的文件人工看一遍,把典型问题补进规则库,这套机制跑下来解析失败率降了一大截。

异常文档的处理我分了几档。加密PDF解不开,直接标成“需要密码”推给用户,让他们上传解密版,别在后台死循环重试。文件损坏的读都读不了,标记失败并给出具体原因,用户知道该换文件而不是干等。超大文件我设了个上限,超过的走异步慢速通道,先返回“排队中”的提示。还有一种阴间情况是零字节文件或者只有一页封面的文档,解析出来啥也没有,我让流程正常结束但状态标成“内容过少”,避免它占着索引位置污染检索结果。每种异常我都配了用户能看懂的提示文案,这点比技术处理本身还重要,用户不知道发生了什么就会一直点重试。

兼容性测试我是拿真实样本堆出来的。项目初期我建了个测试集,塞了上百份不同来源的文档,招标文件、学术论文、财务报表、产品手册、会议纪要、手机拍的合同照片都有。每次改解析器或者升级依赖库,这套样本全量跑一遍,对比块数量、抽取字数、关键字段命中情况,有回归就拦住。新格式支持之前也走这套流程,先扔十几份样本进去看解析器扛不扛得住,再决定要不要上线。这套东西看着土,但它让我在换OCR引擎、调分块参数的时候心里有底,不至于上线之后被用户的一纸投诉打个措手不及。解析这层就是这样,平时没人夸它,一旦出问题整个问答系统都跟着丢人。

上一章收尾时我说的那句“解析没做好,后面全是渣”,这话说得有点绝对。我后来发现,解析做得再好,向量化这层要是跑偏,一样白搭。第3章流水线里最后一环是批量向量化,我当时在文档里就写了一个箭头,实际写代码的时候这个箭头背后有差不多两千行。这一章我把这个箭头拆开,讲嵌入模型怎么挑、向量库怎么建、检索怎么调、答案怎么拼。这四件事是整条 RAG 链路里我最反复改过的部分,改到第三版的时候我把前两版的代码全删了重写,不为别的,就是第一版的设计撑不住用户真实提问的刁钻程度。

4.1 嵌入模型选择、批量向量化与多文件内容表征

嵌入模型这块我前后换过三次。最开始图快,直接调 OpenAI 的 text-embedding-3-small,1536 维,效果确实稳,但两个问题很快就冒出来了:一是费用,用户一天传几百份文件,向量化账单比我预想的高出一截;二是延迟,跨境调用那段时间的抖动让我在测试环境里见过单次请求三秒多才回来的情况。换到 bge-large-zh-v1.5 之后本地推理,768 维,中文语义明显更贴,但长块超过 512 token 就被截断,我的分块又恰好卡在边界上,截断那段意思经常断掉。最后落定的是 bge-m3,1024 维,支持 8192 token 的长输入,中英混排也不用切换模型,多语言的文件用户传上来也能扛。这个模型比前两个都重,单张卡上跑批量的时候我专门做了显存监控,跑满的时候会降 batch size,不然整批一起 OOM 更亏。

维度这件事我纠结过一阵。从 768 提到 1024,存储量涨了三分之一,检索反而没觉得明显变准,是我加了别的策略之后才把差距拉开的。所以别迷信维度,够用就行。真要提效果,把力气花在内容表征上回报更大。我现在的做法是给每个块拼一段前缀,把文件标题、章节路径、块类型都塞进去,形如“《XX制度》第三章 报销标准 / 正文:……”,表格块前面再加一句“以下为表格,表头为:部门|金额|时间”。就这么一个改动,我拿同一个测试集跑,命中率涨了七个百分点左右。裸文本直接喂进去,模型不知道这段文字在原文里的位置,语义是散的。

批量向量化听着简单,做起来全是坑。我要保证幂等,同一段内容不能因为重试被写进库两次,所以主键用的是“文件哈希 + 块序号 + 内容哈希”,先查后写。批量大小我一开始设成 128,跑起来内存扛不住,降到 32 又太慢,最后按 token 数动态算,每批控制在四万 token 上下,比按条数控制稳。并发我开了四个 worker,配合一个令牌桶限流,嵌入服务的 QPS 上限是什么就配多少,多开的那些请求只会排队等着超时。失败重试我分了三档,网络类的退避重试,输入超长的截断重试,模型内部报错的直接落盘进人工队列,不要无限重试,那玩意儿能把队列堵死。

多文件场景下内容表征还有一层要考虑的:同一个概念在好几份文件里都出现过,我要让检索的时候能区分这是哪份文件说的。除了 metadata 过滤,我在块内容里保留文件名和章节名,好处是重排模型看到文本就知道出处,坏处是有时候问句太像文件名本身,会误命中。这个问题我在 4.3 的重排环节加了个轻微惩罚项来解决。还有个小细节,向量我统一做了 L2 归一化之后再入库,余弦相似度用点积算,省一次开方,量大之后这点开销也是开销。

4.2 向量数据库选型、索引构建与多文件检索机制

向量库我真正上手跑过的是四个:Faiss、Chroma、pgvector、Qdrant。Faiss 快是快,但它就是个库不是服务,元数据过滤得自己套一层,我拿它做过原型,写到过滤逻辑那步就放弃了。Chroma 上手十分钟能跑起来,适合做 demo,多租户和权限过滤那块撑不住我要的复杂度。pgvector 是我最想用的,图省事,业务表都在 Postgres 里,向量放进去 join 一下权限表就能过滤,多舒服。我拿它跑了大概两个月,两百万块的数据量下,带过滤条件的查询开始变慢,调索引参数也不见明显好转。最后上的 Qdrant,payload 过滤能力在带权限的检索场景里帮了大忙,一个 filter 表达式里能塞十几个条件,还能按租户分 collection,隔离干净。

索引参数是慢慢磨出来的。HNSW 的 m 我定在 32,ef_construct 调到 256,ef_search 按场景分两档,普通的问答走 128,带复杂过滤条件的提到 256,不然召回不够。这几个数字没什么玄学,就是拿我那套测试问题集一遍遍跑出来的,跑一次记一组召回率和耗时,挑那条曲线的拐点。索引构建我走的是异步队列,入库一批建一批,建完打个标记,检索只查已建索引的数据,避免用户刚传完就问、问到一半的数据。全量重建的场景我也准备了脚本,模型换版本、维度变了、索引结构变了都得走这条路,脚本里带进度日志,几百万块重建一次大概二十几分钟。

多文件检索的真实麻烦不在检索本身,在过滤条件怎么组织。用户问“上季度华东区的销售政策怎么定的”,我要在几万块里先去过滤掉他没权限看的文件,再按文件类型和时间范围缩一圈,剩下的才算相似度。这个顺序不能反,先算相似度再过滤,结果是过滤完只剩三五条低分的,答案质量当场崩盘。我把权限字段直接冗余到 payload 里,部门、密级、可见范围、上传人、文件状态都带上,检索入口统一走一个封装函数,业务层只传用户身份和查询条件,过滤表达式在里面拼,别让调用方自己写,写错一次就是数据泄漏。

删除和更新这层我也踩过坑。文件被删掉的时候不能只删业务表里的记录,向量库里的块得同步清掉,我一开始就漏了这一步,测试时发现删掉的文件还能被检索到,答案里还带着引用,那场面挺尴尬。现在做的是软删除打标记,检索时默认过滤掉,后台有个定时任务做物理清理。文件版本更新的时候处理更麻烦一些,我按哈希判断内容有没有变化,变了就删旧块写新块,没变就只更新元数据,省一大堆重复向量化的开销。

4.3 查询理解、混合检索、重排序与上下文压缩

用户的提问不能直接拿去检索,这句话我是在线上被教育出来的。有人问“它里面那个期限是多少”,这个“它”指向上一轮的某份合同,检索器拿到这句话,向量化出来是一团模糊的东西,召回的全是无关块。我加了一层查询理解,拿对话历史做指代消解,把“它”换成“XX 采购合同”,再补上隐含的约束条件。改写我用的是小模型加规则,规则部分处理明显的槽位,比如时间、部门、文件名,模型部分处理指代和省略。改写完还有个分流判断,让模型先看一眼这问题是闲聊还是需要查文档,不需要查就直接答,别什么话都往检索里送,浪费算力也污染日志。

混合检索是我认为收益最大的一次改造。单纯向量检索在语义上很强,碰到编号、型号、人名、合同条款号这些精确字符串就开始发飘,用户问“GB/T 19001 里要求的文件控制程序”,向量召回回来的东西常常是沾边但不精确的。我加了 BM25 一路,两路各出前 50 条,用 RRF 做融合排序,权重上给向量多一点点,具体是 0.6 对 0.4,也是跑测试集跑出来的。中文的 BM25 分词我用的是 jieba 加自定义词典,词典里塞了用户高频出现的专有名词和产品型号,不加词典的话“XX-2000A 型”这种会被切得面目全非。

重排序是我最不愿意省的一步。召回 50 条,用 bge-reranker-base 做 cross-encoder 精排,取前 8 条进上下文。这一步的耗时确实肉疼,一次大概两百多毫秒,我试过用更小的模型换速度,效果掉得挺明显,就没换。重排有个额外好处,它给出的分数分布能帮我判断“这问题到底有没有答案”,最高分低于某个阈值的时候,我就走兜底回复,告诉用户文档里没找到相关内容,别硬编。这个阈值不好定,我是拿标注过的问题集画分布图定的,卡在能覆盖大部分有答案问题、又不至于把弱相关的放进来那个位置。

上下文压缩这块我走了一条折中的路子。试过 LLMLingua,压缩率高但偶尔把关键数字和否定词压没了,答出来的意思正好反了,这个风险在合同、制度类文件上不能接受。改成抽取式压缩:重排模型输出到句子级别的分数,我在每个命中块里保留分数最高的两三句加上前后各一句做窗口,剩下的扔掉。八条上下文从原来九千多 token 压到三千出头,答案质量基本没掉,延迟下来了,token 成本也跟着降。边界情况我遇到过,答案本身散在相隔很远的两个段落里,窗口一裁就丢了,所以我在压缩之后加了一次覆盖度检查,如果问题里的实体在压缩后的上下文里出现次数太少,就把原始块补回来一部分。

4.4 答案生成、引用溯源与多文件上下文融合策略

生成这层我用的是带编号的上下文注入。每个块前面标一个 [1] [2] 这样的序号,system 提示里明确要求回答里的每个关键结论后面必须带上对应编号,找不到依据的部分就不写。这个约束不是百分之百有效,模型偶尔会漏标或者标错,我在后端做了一遍校验,把回答里出现的编号跟实际注入的块做比对,出现不存在的编号直接拦下来重新生成一次,还不行就降级成不带编号的版本并提示用户核对原文。校验这步看着笨,但它把引用造假的概率压得很低,用户看到每句话后面都能点开原文,信任感是完全不一样的。

引用溯源我做成了可点击的形式,点编号直接跳到原文对应页,PDF 的话还会高亮那一段。实现上是块 ID 到文件 ID 加页码加字符偏移的映射,偏移量在解析阶段就存好了,一路传到前端。这里有个细节我打磨了挺久,同一个块被引用多次的时候合并成一个引用,别让用户看到 [1][1][1] 这种;跨文件的引用按文件名分组展示,用户一眼能看出答案是从三份文件里拼出来的。这套东西上线之后我收到的最多反馈就是“能看到原文我就信了”,比任何准确率数字都有说服力。

多文件上下文融合是这条链路最后也最难的一块。同一个问题,A 文件说流程要三级审批,B 文件说两级,两份都进了上下文,模型会怎么答?我第一版就是硬塞进去让它自己看着办,答案是“有的说三级有的说两级”,用户看完更迷糊。现在的处理是给上下文加优先级信号,文件版本新的优先,正式发布的优先于草稿,部门级文件优先于公司级通用文件,这些规则写在 prompt 里,也体现在上下文排列顺序上,靠前的块权重更高。碰到明显的冲突,我让模型把冲突本身说出来,标注两份文件的出处和差异,让用户自己判断,这比替用户做决定安全得多。

还有一类问题得单独说,就是跨文件的多跳问答。用户问“按新政策,上季度的超额部分该怎么补”,答案要在政策文件和财务台账两份文件里各取一段才能拼出来。这类问题我第一版召回的块全来自一份文件,另一份只召回了半截。后来在检索阶段加了个文件级多样性约束,同一文件的块最多取三条,剩下的配额留给其他文件,用 MMR 做相似度和多样性的平衡。改完多跳类问题的回答完整度提升挺明显。token 预算我按问题复杂度动态分配,简单问题三千够用,多跳问题放宽到六千,超了就按重排分数砍尾部,砍之前先保证每份被引用的文件至少留一个块。

流式输出这层我没什么花活,就是边生成边推,第一段文字大概八百毫秒内能出来,用户不会觉得卡。生成结束之后还有个事后检查,回答为空、或者回答里出现“根据提供的资料无法确定”这类兜底句式的时候,我会把上下文和问题一起记进一条低分样本日志,每周挑一批看看是检索没召回还是真没答案,前者补测试集,后者就算了。这条日志我维护了大半年,比什么评测框架都实在,因为它记的都是真实用户问出来的问题。

这条链路整体跑下来,一个问题的端到端延迟在一点五秒到三秒之间,取决于要不要重排和生成多长。比我最早那版慢了不少,但那版快是因为什么都没做。做 RAG 这事儿我最大的体会是,链路里每一步的收益都不线性,你得知道自己的瓶颈在哪一段。我的瓶颈长期在检索召回,不在生成,所以力气都花在 4.2 和 4.3 这两节上。下一章我把这些零件装到一起,讲端到端集成、测试和上线之后那些鸡飞狗跳的运维故事。

第4章收尾时我说要把零件装到一起,讲讲上线之后的鸡飞狗跳。真到写这一章我才发现,集成阶段的故事比前面四章加起来都碎——没有哪个 bug 是惊心动魄的,全是些鸡零狗碎的接口对不齐、状态不同步、日志看不懂。可恰恰是这些东西,决定了一个用户传完文件之后到底能不能顺利问出第一句话。

5.1 端到端集成、接口联调与多文件上传问答闭环

前面几章每个模块我都是单独跑的,单测写得挺欢,嵌入服务自己启一个脚本灌数据进去,检索模块自己读本地向量文件,看上去一切正常。第一次把这些模块串成一个 docker-compose 拉起来,问题瞬间全冒出来。最典型的是文件状态机对不上:前端以为上传完就 ready 了,后端其实卡在解析队列里,用户点了“开始提问”,检索查不到任何块,返回“未找到相关内容”,用户当场就以为这工具坏了。我在上传接口的返回里加了一个 status 字段和轮询用的 task_id,前端每两秒拉一次,状态从 uploading 到 parsing 到 indexing 到 ready 或 failed 一路展示,用户看得见进度就不慌了。这个状态机后来我还加了一个 partial 状态,多文件批量上传的时候有的成功有的失败,不能一刀切说整批失败。

接口联调这活儿听着枯燥,真正耗时间的是那些“文档里没写”的约定。前端用 axios 传 FormData,后端 chunk 接口要的 content-type 是 multipart,结果 axios 自动加 boundary 的时候跟后端解析库对不上,报了个什么 “boundary not found”,查了一下午。还有并发度的问题,前端并行传 6 个文件,后端分片接口限流只放 3 个,剩下 3 个全 429 了,前端把 429 当失败处理,UI 上显示“上传失败”,用户以为文件坏了,其实是排队。我把后端的限流配置通过一个 /api/config 接口暴露出来,前端启动时拉一次,按后端能力动态调并发,这个改动之后投诉少了一大半。

端到端验收我写了一个脚本,塞进 CI 里,每次发版必跑。脚本干的事很朴素:注册账号、建知识库、依次上传 PDF、Word、Excel、PPT、一张扫描图共五个文件,轮询等到全部 ready,然后问十个预先准备好的问题,校验回答里出现的引用文件名和页码跟预期一致。这个脚本第一次跑通花了我三天,跑通那天我请自己喝了杯咖啡。它拦下过好几次回归:有一次我把分块逻辑改了,Word 文件的分块边界跟 PDF 不一致,脚本里 Word 那个问题直接失分,我在发版前一刻才发现。没有这个脚本,这种问题会到用户手里才暴露。

闭环里还有个容易被忽略的点——失败之后的出路。用户传了一个加密的 PDF,后端解析直接抛异常,状态机走到 failed。第一版我只给了一个红色的“解析失败”,用户完全不知道下一步干什么。后来我在失败状态里带上了原因码和一句人话建议,比如“该 PDF 受密码保护,请解除密码后重新上传”“文件超过 50MB,请拆分后上传”。这几个错误码是我从线上失败日志里一个个看出来的,第一批统计出来一共七种高频失败原因,把七种都写成人话,用户自助解决率一下子上去了,客服消息少了七成。

5.2 检索问答效果评测、多文件场景测试与回归验证

评测这件事我从一开始就没指望什么通用 benchmark,那些榜单跟我实际遇到的用户提问差太远。我建了三层评测集,第一层是黄金集,大概一百二十条,每条都是人工标注的标准答案和应该引用的文件与段落,这批问题我压着没让任何人动,专门用于版本对比。第二层是场景集,专攻多文件:同一概念散在三份文件里、两份文件说法冲突、跨文件多跳、权限边界、还有故意问文档里没有的东西测拒答。第三层是回归集,就是第4章末尾提到的那条低分日志,每周我从里面挑一批标注一下补进去,现在攒了三百多条,全是真实用户问出来的刁钻问题。

指标我只对外报两个数字:回答成功率和引用准确率。内部的看板会细到召回率、MRR、重排前后分数分布、拒答准确率这些,但这两个数字是产品经理和老板唯一关心的。回答成功率我定义得比较严,回答里必须带至少一个有效引用、并且人工抽检判定内容正确才算成功,那种“根据资料无法确定”的兜底回答不计入成功也不算失败,单独归一类。引用准确率是随机抽一百条回答,人工核对每个引用编号指向的原文段落是否真的支撑了那句话。这两个数字一起看才有意义,单看成功率,模型瞎编反而能刷高。

多文件场景测试是我单独拿出来做的专项。第一组测去重:把同一份制度文件换个文件名再传一遍,问同一个问题,答案和引用不能重复出现两份。第二组测冲突提示:我故意准备了两份版本不同的报销制度,问“报销上限是多少”,期望答案是同时列出两份并标注差异,模型如果只答一份或者硬拼一个数字就算失败。第三组是多跳完整度,问题要跨两份文件才能答全,我检查答案里有没有两份文件的信息都覆盖到。第四组是权限,用普通账号问只有管理员上传的文件里的内容,答案可以是“没有找到”,但引用列表里绝不允许出现文件名——这一点我们真踩过坑,引用列表当时没走权限过滤,文件名泄漏了,虽然内容没出去,性质也挺严重。

回归验证我做了个半自动的流程。每次改了 prompt、换了模型版本、调了召回或重排参数,先跑黄金集和场景集,把两次结果做 diff,成功率下降超过两个百分点或者引用准确率下降超过三个百分点,CI 直接拦住不允许合并。这个阈值是拍脑袋定的,后面根据实际误报情况调过两次,现在用着还行。回归验证拦下来最有价值的一次是我只改了 system prompt 里的一句话,把“尽量引用”改成了“必须引用”,结果模型变得过度保守,好多本来能答的问题全变成“无法确定”,成功率掉了六个点。要是没有回归,这种改动上线之后用户体感会非常差,而且很难归因。

5.3 性能优化、缓存策略、并发扩容与成本控制

性能优化我先看的火焰图,看之前我一直以为瓶颈在生成那一步,毕竟大模型慢是常识。看完才发现最大的那块红色是 PDF 解析,PyMuPDF 在解析大文档的时候基本把事件循环占死了,其他请求全在排队。我把解析拆进独立的 worker 进程池,主服务只负责接收文件、写队列、查状态,解析进程池按核数起 N 个,单文档超时就重试一次再落死信队列。这个改动之后,用户传文件的时候问问题不再卡了。生成那层反而好办,流式输出一开,首 token 七八百毫秒出来,用户感知的等待时间远小于实际总耗时。

缓存我做了三层,每层都栽过跟头。第一层是解析结果缓存,key 用文件内容的 sha256,同一份文件重复上传直接复用解析结果和向量,省掉最重的那一步。这层的坑在文件名不算进哈希里,用户改了文件名重新上传,引用里显示的还是旧名字,后来我把文件名从哈希里剔出来,但引用展示改成读业务表里的最新名。第二层是查询向量缓存,用户问过的问题向量化结果存一份,TTL 设一天,命中率不算高但成本敏感。第三层是答案缓存,这层我一开始没做权限维度,结果测试环境里一个普通账号秒答出了管理员问过的敏感问题,吓出一身冷汗,赶紧改成 key 里带用户权限指纹,并且文件状态一变就按 file_id 前缀批量失效。缓存这玩意儿,省的那点算力跟泄漏一次的风险比,太不值了。

并发扩容我把上传服务和问答服务拆成两个部署单元,它们负载模型完全不同。上传是突发型的,用户一来就传一堆,走消息队列削峰,worker 池按队列积压深度动态伸缩。问答是持续型的,走无状态水平扩,扩容指标我用的不是 CPU 而是检索队列等待时长,因为问答服务的 CPU 经常看着不高但 P95 已经飙了,那是卡在向量库连接池上。连接池我调到跟 Qdrant 的并发上限对齐,调之前 P95 三秒多,调完一点二秒左右。这两件事做完之后,压测到五百并发问答 QPS 还能稳住。

成本控制这块我盯的是三个大头:嵌入调用量、生成 token 数、重排调用次数。嵌入这层靠解析缓存和块级幂等已经压掉不少,重复内容基本不进模型。生成这层我按问题类型设了 max_tokens 上限,普通问答两千,多跳问答四千,超过就截断按重排分数砍尾部,不让模型无限制往外吐。重排这层我加了个判断,候选块数少于二十个的时候跳过重排直接进生成,省一次调用,代价是那一小部分问题的精度略降,我拿测试集验证过,这部分问题占比不到百分之八,影响可以接受。这三项加起来,单次问答的平均成本比接入之初降了六成左右,具体数字不方便写,反正是财务那边看了会点头的那个量级。

5.4 部署监控、日志审计、权限管理与迭代路线

部署一开始就是 docker-compose,前后端、Qdrant、Postgres、Redis 一锅端,跑在单台机器上,方便是真方便,出事也是真出事。第一次迁到 K8s 的时候我忘了给 Qdrant 挂 PVC,Pod 重启之后向量全没了,所有文件状态还是 ready,用户一问全空,测试环境当场炸掉。从那以后 Qdrant 走 StatefulSet 加定时快照,快照存到对象存储里,恢复演练我每月跑一次,模拟把库删了再从快照拉起来,别到真出事才发现快照是坏的。应用侧全走无状态,滚动升级的前提是接口兼容,新老 Pod 同时在线那一两分钟里,请求落到哪个版本都得能正常处理,所以我的接口只加字段不删字段,删字段要跨两个版本。

监控看板我分四块:上传成功率、解析队列积压深度、检索 P95 延迟、生成首 token 延迟。指标不多,多了没人看,就这四个是我一上班先扫一眼的。告警只配了三条:上传成功率五分钟低于百分之九十五、解析队列积压超过五百、生成首 token P95 超过三秒。这三条都直接对应用户能感知的故障,其他指标异常我先看看板再判断。日志用 Loki,每个请求从上传那一刻就带一个 trace id,一路传到检索和生成,用户报“我那个文件问不出东西”的时候,我拿 trace id 一搜,能看出是解析失败、向量没建、还是检索没召回,排查时间从半小时压到几分钟。

审计日志我单独存了一套,跟业务日志分开,存一年。记的内容是:谁、在什么时间、传了什么文件、问过什么问题、答案引用了哪些块、有没有点开原文。这套东西不是为了好看,是权限管理的基础。RBAC 我做了三级——系统管理员、部门管理员、普通用户,文件权限继承上传者设定,可以细化到单个文件、指定部门、指定人。审计日志一进来,权限事故就有据可查:谁传的、谁看的、什么时候改的。开发这层的时候我在检索入口写了一个强制过滤的封装函数,业务代码不允许绕开它直接调向量库,谁绕谁把代码评审打回去,这条规矩从第一天立到现在没破过。

迭代路线我不是拍脑袋定的,是把过去半年用户反馈和 badcase 日志按频次排了个序。排在最前面的是表格结构问答,现在 Excel 解析出来还是按行铺平,用户问“某部门去年的差旅总额”,模型经常把相邻行的数字拼错,下一步我打算上表格感知的分块加结构化元数据。第二是文件版本对比问答,“新政策和旧政策差在哪”这类问题现在答得比较虚,要把版本间 diff 作为独立上下文注入。第三是对话式多轮追问,现在多轮已经能做指代消解,但跨轮次的上下文融合还比较糙。第四是批量离线问答 API,几个企业用户点名要,传一批文件和一批问题,异步出结果,这块跟现有的批量上传链路能复用不少东西。这些事我排了个优先级,但没定死时间,原因是每一件的实际工作量都取决于测试集上能跑出多少提升,跑不出来我就往下换一件做。做这类系统我最深的体会是,功能不是规划出来的,是测出来的、用户骂出来的。

赞0
踩0
☆收藏0
版权声明
文章版权声明:除非注明,否则均为ZBLOG原创文章,转载或复制请以超链接形式并注明出处。
分享到
chuanbook

链接已复制到剪贴板