尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

检索主链路与问答闭环:父窗口回填、SSE流式输出与出处标注实战

检索主链路与问答闭环:父窗口回填、SSE流式输出与出处标注实战 1. 检索主链路到底在搭什么从一次提问到一段有出处的回答很多人做智能问答系统前面文档解析、切分、向量化都跑通了一到“用户提问→系统回答”这条链路就卡住。原因不复杂检索和生成是两套东西中间还夹着上下文拼装、流式输出、出处标注三件事任何一环没对齐用户看到的要么是转圈半天没反应要么是答非所问还找不到依据。我这次要聊的就是把这条链路第一次完整跑通的过程也就是所谓的“问答闭环”。先把概念说清楚。检索主链路指的是从用户输入问题开始经过查询改写、向量检索、结果重排、父窗口回填、提示词拼装最后交给大模型生成答案并流式返回的整条通路。问答闭环的意思是这条通路能稳定地跑完一轮用户问、系统答、答案带出处、前端能实时看到字一个个蹦出来。它解决的核心问题是——让问答系统从“能检索”变成“能回答”而且回答是可追溯的。这套东西适合谁参考如果你正在做企业知识库问答、内部文档助手、客服机器人或者你已经在用向量数据库做检索但还没打通生成环节那这篇内容基本就是给你写的。我会把父窗口回填、SSE 流式输出、出处标注这几个关键点拆开讲包括参数怎么定、坑在哪、为什么这么选。基础一般的读者也能看懂因为我会尽量用生活化的类比来解释。先说一个我踩过的坑。最早我把检索到的片段直接拼成一段文本丢给模型结果模型经常“脑补”——检索片段里没有的内容它也敢编。后来加了出处标注和父窗口回填情况才好转。父窗口回填的作用简单说就是你检索命中的是一个小片段子块但送给模型的是这个小片段所在的更大上下文父块。类比一下你查字典查到一个词的解释但真正理解这个词往往要看它所在的整段话。子块负责精准命中父块负责提供完整语义这就是父子窗口设计的核心逻辑。整条链路我拆成四段查询处理与检索、父窗口回填与上下文拼装、SSE 流式生成、出处标注与前端渲染。下面逐段展开每一段都会讲清楚“为什么这么做”和“具体怎么做”。2. 查询处理与检索别急着把问题直接丢给向量库2.1 查询改写为什么不能省用户问“报销流程是啥”和文档里写的“费用报销审批操作指引”字面重合度其实不高。如果你直接把原问题做向量化去检索召回率往往很难看。我实测过同一批文档不做查询改写直接检索Top5 命中率大概只有六成出头加了一层轻量改写之后能到八成五以上。查询改写我一般做两件事一是补全指代和省略比如用户问“它怎么申请”得结合上一轮对话把“它”还原成具体对象二是做同义扩展把口语化的表达映射到文档里的正式表述。这里不一定要上大模型早期我用规则同义词表就能覆盖大部分场景成本低还稳定。等业务量上来了再换成小模型做改写响应时间控制在 200ms 以内比较理想。注意查询改写不要改得太狠。我见过有人把用户问题改写成一大段话结果向量被稀释反而召回了不相关的文档。改写后的查询长度建议控制在原问题的 1.5 倍以内。2.2 向量检索的参数怎么定检索这块核心参数就三个TopK、相似度阈值、是否做混合检索。TopK 不是越大越好。你召回 20 条重排和拼装上下文的成本就上去了而且噪声也更多。我的经验是先召回 10 到 15 条重排后取前 3 到 5 条送进模型。这个比例是经过几轮测试定下来的召回太少容易漏太多则拖慢整条链路。相似度阈值要看你用的向量模型。不同模型的分数分布不一样不能照搬别人的 0.7 或 0.8。我的做法是拿一批标注好的问题跑一遍看正样本和负样本的分数分布取一个能分开两者的值。如果实在懒得标可以先设 0.5 兜底再根据实际效果微调。混合检索向量关键词在专有名词多的场景下特别有用。比如文档里有大量产品型号、内部术语纯向量检索容易漏加上 BM25 这类关键词检索做融合召回会稳很多。融合方式我用的是加权求和向量权重 0.7、关键词权重 0.3这个比例可以根据你的数据特点调。2.3 重排这一步值不值得做值得。检索出来的 TopK 是按向量相似度排的但相似度高不代表和问题最相关。重排模型Rerank会重新算一遍问题和文档的相关性把真正有用的排到前面。我实测下来加了重排之后送进模型的前 3 条里包含正确答案的比例比不加重排高了将近 20 个百分点。重排的代价是延迟。一个重排模型跑一次大概几十到几百毫秒取决于模型大小和候选数量。如果对响应速度要求极高可以只对 Top10 做重排或者用轻量级重排模型。我的建议是只要你的场景对答案质量有要求重排就别省这点延迟换来的准确率提升是划算的。3. 父窗口回填与上下文拼装让模型看到完整的语义3.1 子块检索、父块生成的设计逻辑文档切分的时候为了检索精准我们通常把文档切成小块比如 300 字一块。但小块的问题是语义不完整——一句话被拦腰截断模型拿到手里也懵。父窗口回填就是解决这个矛盾的用小块去检索命中后找到它所属的大块比如 1500 字把大块送给模型。这个设计的关键在于父子块的映射关系要存好。我一般会在切分时给每个子块记录一个 parent_id检索命中子块后通过 parent_id 反查父块内容。如果多个子块命中同一个父块要去重避免同一段内容重复送给模型。这里有个细节父块不是越大越好。父块太大一是会挤占模型的上下文窗口二是引入无关信息干扰生成。我一般把父块控制在 1000 到 2000 字之间具体看文档的段落结构。如果原文本身有清晰的章节就按章节做父块如果没有就按固定长度加重叠来切。3.2 上下文拼装的顺序和格式拼装上下文看着简单其实有讲究。我试过几种顺序最后固定下来的是按相关性从高到低排列每段前面加上出处标识。为什么按相关性排因为模型对上下文开头和结尾的内容注意力更集中这就是所谓的“迷失中间”现象把最相关的放前面能提高它被用到的概率。格式上我用的是类似这样的结构[出处1XX文档 第3节] 父块内容 [出处2YY文档 第1节] 父块内容出处标识一定要在拼装阶段就带上不然后面标注出处的时候对不上。我早期偷懒没带结果生成完了想标出处只能靠字符串匹配去反查又慢又不准后来全部改成拼装时带标识。注意上下文总长度要留够空间给模型生成答案。如果你的模型上下文窗口是 8K上下文拼装最好控制在 5K 以内留 3K 给回答。超了就得截断截断时优先保留相关性高的段落。3.3 提示词里怎么约束模型“别乱编”光给上下文还不够提示词里必须明确约束。我的提示词模板大概是这样先说明角色你是企业知识助手再给上下文然后明确要求“只根据提供的上下文回答上下文没有的信息不要编造如果无法回答就说明找不到依据”。最后附上用户问题。这个约束不是万能的但能大幅降低胡编的概率。我对比过加约束前后模型编造答案的比例从两成多降到了不到一成。剩下的那些主要靠出处标注来兜底——用户看到出处自己能判断答案可不可信。4. SSE 流式生成让用户看到字一个个蹦出来4.1 为什么选 SSE 而不是 WebSocket流式输出有两种主流方案SSE 和 WebSocket。我选 SSE理由很直接问答场景是单向的服务器推、客户端收SSE 天然契合而且实现简单、走标准 HTTP、断线重连浏览器自带支持。WebSocket 是双向的用在问答上属于杀鸡用牛刀还得自己处理心跳和重连。SSE 的另一个好处是和现有 HTTP 基础设施兼容。你不需要额外开端口、配协议升级反向代理和网关基本都能直接过。对于企业内网部署来说这点省了很多事。4.2 后端流式接口的实现要点后端这块核心是把模型的流式输出透传给前端。以 Python 为例如果用 FastAPI可以返回一个 StreamingResponse把模型吐出来的 token 逐个 yield 出去。关键点有几个第一响应头要设对。Content-Type 必须是 text/event-stream还要加上 Cache-Control: no-cache 和 Connection: keep-alive不然中间层可能给你缓存或者断开。第二每个事件要有固定格式。SSE 的数据格式是data: xxx\n\n注意结尾是两个换行。我见过有人只写一个换行前端死活收不到排查半天。第三要处理结束信号。流结束时发一个特殊事件比如data: [DONE]\n\n前端收到就知道可以关闭连接了。不然前端不知道啥时候算完只能靠超时体验很差。async def stream_answer(question: str): async for token in llm.astream(prompt): yield fdata: {json.dumps({token: token})}\n\n yield data: [DONE]\n\n4.3 前端怎么接 SSE 流前端用 EventSource 接就行。但有个坑EventSource 只支持 GET 请求如果你的问题内容比较长放 URL 里不合适。这时候可以用 fetch 加 ReadableStream 手动解析虽然麻烦点但灵活。解析的时候要注意SSE 的数据可能一次到达多个事件也可能一个事件分多次到达所以不能假设每次 onmessage 就是一个完整事件。稳妥的做法是维护一个缓冲区按\n\n分割攒够一个完整事件再处理。const reader response.body.getReader(); const decoder new TextDecoder(); let buffer ; while (true) { const { done, value } await reader.read(); if (done) break; buffer decoder.decode(value, { stream: true }); const parts buffer.split(\n\n); buffer parts.pop(); for (const part of parts) { if (part.startsWith(data: )) { const data part.slice(6); if (data [DONE]) return; // 处理 token } } }4.4 那个让人头疼的 idle timeout热词里提到的 “stream disconnected before completion: idle timeout waiting for sse”我太熟了。这个报错的意思是连接空闲太久被中间层网关、代理、负载均衡掐断了。模型生成慢的时候两个 token 之间可能隔好几秒中间层等不及就断连。解决办法有几个我一般组合用一是加心跳。在等待模型输出的间隙定期发一个注释行以冒号开头比如: keep-alive\n\n这样连接就不会被判定为空闲。心跳间隔设成 15 秒左右比较稳。二是调中间层的超时配置。Nginx 的话把 proxy_read_timeout 调大比如 300 秒。但光调这个不够因为有些云厂商的负载均衡有自己的空闲超时改不了所以心跳还是得有。三是前端做重连。EventSource 自带重连但重连后上下文就丢了。我的做法是记录已经收到的内容重连后从断点继续或者干脆提示用户重新提问。这个体验不算完美但比直接报错强。注意心跳和正常数据要区分开。心跳用注释行冒号开头前端解析时直接忽略不要当成 token 处理。5. 出处标注让每个答案都能追溯到源头5.1 出处信息从哪来出处标注的前提是你在拼装上下文的时候就把出处信息带上了。前面说的[出处1XX文档 第3节]这种标识就是出处的来源。生成答案后你需要把答案里引用的内容和出处对应起来。对应方式有两种。一种是让模型自己标在提示词里要求它引用时带上出处编号比如“根据[出处1]……”。这种方式简单但模型不一定听话有时候忘了标有时候标错。另一种是后处理匹配把答案按句子拆开和上下文段落做相似度匹配匹配上的就标上对应出处。这种方式更可靠但实现复杂点。我一般两种结合提示词里要求模型标同时后处理做校验和补全。模型标了的校验一下对不对模型没标的后处理补上。5.2 前端怎么展示出处出处展示的交互我试过几种。最早是把出处放在答案末尾列一个参考列表。后来发现用户更希望“看到哪句话就知道这句话出自哪”所以改成了行内标注——答案里每个引用点后面跟一个小的上标数字鼠标悬停或点击显示出处详情。行内标注的实现需要后端返回结构化的数据而不是纯文本。我一般返回这样的结构{ answer: 报销需要先提交申请..., citations: [ {index: 1, doc: 费用报销指引, section: 第3节, text: ...} ] }前端拿到 citations 后把 answer 里的引用标记渲染成可交互的上标。这样用户既能看答案又能随时查证。5.3 出处标注的准确率怎么保证出处标注最容易出的问题是“标错”——答案引用的内容其实来自另一段。这个问题在上下文段落相似度高的时候特别明显。我的应对办法是匹配时不仅看文本相似度还看位置关系。如果答案的某句话和上下文某段的相似度最高且这段在拼装顺序里靠前那标它的可信度就高。另外我会做一个阈值过滤。相似度低于某个值的宁可不标也不乱标。标错了比不标更伤信任。这个阈值我一般设在 0.6 左右具体看你的向量模型。6. 常见问题与排查技巧实录6.1 流式输出相关的问题现象可能原因排查方向前端收不到任何数据响应头不对或中间层缓冲检查 Content-Type关闭代理缓冲数据一次性全出来中间层做了缓冲关闭 Nginx 的 proxy_buffering生成到一半断开idle timeout加心跳调大超时配置中文乱码编码不一致统一用 UTF-8注意分块边界代理缓冲这个坑我重点说一下。Nginx 默认会缓冲后端响应等攒够一定量才发给客户端这会导致流式输出变成“一次性输出”。解决办法是在 location 里加proxy_buffering off;。这个配置不加前面所有流式的工作都白做。6.2 检索和生成对不上的问题有时候检索明明命中了正确文档但模型生成的答案却不对。这种情况我一般从三个方向查一是上下文拼装是不是把关键段落截断了二是提示词约束是不是太弱模型没按上下文答三是重排是不是把正确文档排到了后面导致没进最终的上下文。排查时我会把送给模型的完整提示词打印出来人工看一眼。十有八九问题就出在这——要么关键内容没进去要么进去的位置太靠后。这个习惯帮我省了大量瞎猜的时间。6.3 出处标注的典型故障出处标错、标漏、标重复是三个高频问题。标错多半是相似度匹配的阈值没调好标漏是模型没标且后处理也没匹配上标重复是多个子块命中同一父块去重没做好。我的经验是出处标注这块不要追求 100% 覆盖能覆盖八成以上、且标出来的都是对的就已经很好了。剩下的靠用户自己点开原文核对。追求全覆盖往往意味着放宽阈值结果就是标错变多得不偿失。6.4 性能优化的几个实操点整条链路的延迟主要花在检索、重排、生成三块。检索和重排可以并行化比如多个查询同时检索再合并。生成这块首 token 延迟是关键指标用户感知最强。我一般会把提示词精简减少输入 token 数首 token 能快不少。还有一个容易被忽略的点向量检索的索引要预热。服务刚启动时第一次检索往往特别慢因为索引还没加载进内存。我的做法是启动后先跑几个预热查询把索引热起来再对外服务。7. 我在实际搭建中的几点体会这套链路我从零搭到能跑通前后迭代了大概四五版。最大的体会是别想着一次把所有环节都做到完美先把闭环跑通再逐个优化。我第一版连重排都没加出处标注也是后补的但闭环跑通之后后面每一步优化都有明确的对比基准知道改进了多少。父窗口回填这个设计我强烈建议一开始就做。它看起来只是多存一个 parent_id 的事但对答案质量的提升是立竿见影的。我见过不少团队为了省事只存子块结果生成质量一直上不去回头再改数据都要重新处理成本更高。SSE 的心跳机制也是越早加越好。等到线上出现断连再补用户已经骂过一轮了。心跳代码就几行但能省掉大量客诉。最后分享一个小技巧把整条链路的每个环节都打上耗时日志检索多久、重排多久、首 token 多久、总时长多久。这些数据攒下来优化的时候你就知道该往哪使劲而不是凭感觉。我靠这些日志发现重排其实只占总延迟的一小部分真正的大头在生成于是把优化重点放到了提示词精简和模型选型上效果比盲目调检索参数好得多。
返回列表