问答看起来是”检索完把结果丢给模型”,真正容易坏的地方在时序:什么时候检索、什么时候开流、流式期间能不能碰数据库、生成失败算不算这一轮失败。这个项目把这些问题都压到开流之前或流结束之后,中间的 SSE 只做一件事——把生成器的增量吐出去。

event: sources ← 检索一结束就发,前端立刻能画引用卡片
event: delta ← 一个或多个,逐字/逐句
event: done ← 引用校验、会话 id、耗时、每腿状态、是否降级
sources 必须先于任何 delta。前端靠它区分”还在检索”与”在生成”;如果先来 delta,引用卡片就只能在答案出来之后补,首屏会空一段时间。
done 里的字段都不是装饰:
| 字段 | 用途 |
|---|---|
citations |
答案里合法角标,升序去重 |
dangling_citations |
引用了不存在的编号;应为空,非空时前端要提示”这条引用失效了” |
session_id |
新建会话时回传,前端靠它续问 |
history_turns |
这一轮带进生成的历史条数,第一轮是 0 |
quality |
ok / weak / none 与原因 |
degraded |
外部生成器失败并降级抽取式的原因;未降级为 null |
legs |
每条腿的命中数、耗时、跳过原因 |
stream=false 时仍然返回 SSE,只是所有增量合并成一个 delta。MCP 和 A2A 不在乎逐字输出,它们要的是完整答案;让三方共用同一条响应格式,比维护一条”非流式 JSON”分支更不容易漂。
请求级数据库会话在第一个 SSE 帧之前就已经关闭——FastAPI 在 yield 依赖上先执行退出码,再开始发送响应体。所以检索、取生成器、组装证据都必须在这之前完成。
按顺序:
session_id 就校验会话属于当前用户、属于当前 KB。engine.search,top_k 默认 8;单库时读该库的 rerank_model,多库时不用精排。审计也在这之前提交。SSE 一旦开始就改不了 HTTP 状态码,检索失败时审计不能跟着回滚——那正是最需要留痕的时候。
检索结果先去重:同一条腿可能命中同一个 chunk,重复的证据会让 [1] 和 [2] 指向同一段文字,角标失去意义。
然后按文档分组,同一文档的相邻块按 chunk_index 升序,文档之间按首次出现的位置排。RRF 给的是相关性序,同一篇文档的相邻段可能被打散;按原文顺序放回,模型读到的才是连贯的一段,而不是跳着读。角标在重排后重新从 1 编号,否则会跳号。
只有当前版本的文档会进来:检索侧已经挡掉 is_current=false 和软删的行,所以”同一主题的多个版本混进上下文”在当前结构下不可能发生。这是结构保证,不是事后标注。
正文上限:普通证据取前 400 字,Parent-Child 库命中子块时改用父块,上限 1200 字。父块不进向量库,只随 payload 回传;召回靠小子块,生成上下文靠父段落。
默认生成器不调用任何模型。它做三件事:
[昵称] 标记切说话人,再按句末标点切;句子下限 4 字(聊天消息很短,设成 8 会把”你吃饭了吗”整条丢掉),上限 160 字。bm25.tokenize 切查询词,算覆盖率,超长句轻微降权。必须用同一个分词器,日期归一化 token(d20250214)才会一起进来。- 句子 [1]。如果一句都选不出来,如实说”检索到的内容里没有能回答这个问题的证据”,不编。检索到图片但没有文字时,单独说”有图片内容,但没有可用于回答的文字证据”。
抽取式故意不用对话历史。它是”从证据里挑句子”,没有语言模型可以条件在对话上;真正让多轮起作用的是检索侧的指代扩展,那一条对两种生成器都有效。所以”抽取式 + 多轮”的效果是”追问能检索到”,不是”追问会接着聊”。
生成层可插拔,但默认必须是抽取式。检索出的内容是聊天记录、简历、他人文档片段,发给外部 API 等于把私人数据送出本机。所以:
provider=openai_compat 要同时配齐 base_url、model、api_key,并且 enabled=true;usable() 会如实说差哪一项,不静默假装在用 LLM;use_for_ask / use_for_contextual / use_for_rewrite 分开控制哪些功能可以外传;外部生成器在流式过程中失败(401、超时、5xx)时:
done.degraded 会说明这一轮降级了;证据质量为 none 且生成器不是抽取式时,直接拒答:”没有检索到可用于回答的证据。请换个说法,或用「精确搜索」直接找原文片段。” 不把空证据送给模型编。
答案文本里的 [n] 用正则抽出来,合法的(1..证据数)进 citations,越界的进 dangling_citations。悬空角标必须为空;出现 [9] 而只有 3 条证据,意味着答案引用了不存在的东西,比没有引用更糟——用户点进去发现是空的。
抽取式的每个句子都来自某条证据,天然不会悬空;外部 LLM 可能编,所以这一步是必需的。dangling_citations 不是错误码,这一轮仍然成功,但前端必须能提示”这条引用失效了”。
流结束后新开一个 session_scope():
session_id 就建一条 chat_sessions,标题取问题前 80 字;citations,回看时知道当时依据了什么。流式期间不碰数据库。违反这条的后果不是报错,而是 session.execute() 返回的协程永远不会被 await,消息静默丢掉——和”任务表 0 行、worker 干等”是同一种失败形状。
历史窗口只影响生成侧;检索侧的指代扩展只处理”短问题 + 指代词 + 有历史”三条同时成立的情况。超过 24 字说明用户把话说全了,不该加料;没有指代词说明这一句是自足的。宁可漏扩展(回答稍差),也不要错误扩展(把不相关的上文塞进检索,污染召回)。
会话续问时范围不变:单库会话继续单库,多库会话沿用自己创建时的 kb_ids。让范围中途变化,同一段对话前后的召回范围不同,答案会自相矛盾且无法解释。