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 上下文拼装的顺序和格式
拼装上下文看着简单,其实有讲究。我试过几种顺序,最后固定下来的是:按相关性从高到低排列,每段前面加上出处标识。为什么按相关性排?因为模型对上下文开头和结尾的内容注意力更集中(这就是所谓的“迷失中间”现象),把最相关的放前面,能提高它被用到的概率。
格式上,我用的是类似这样的结构:
[出处1:XX文档 第3节] (父块内容) [出处2:YY文档 第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 f"data: {json.dumps({'token': token})}\n\n" yield "data: [DONE]\n\n"4.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 出处信息从哪来
出处标注的前提是,你在拼装上下文的时候就把出处信息带上了。前面说的[出处1:XX文档 第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 多久、总时长多久。这些数据攒下来,优化的时候你就知道该往哪使劲,而不是凭感觉。我靠这些日志发现,重排其实只占总延迟的一小部分,真正的大头在生成,于是把优化重点放到了提示词精简和模型选型上,效果比盲目调检索参数好得多。