2026-07-11-show-retrieval-stage-ranks 是一个适合解释纵向交付的归档 change。它没有新建一套检索算法,而是把后端已经掌握的向量、BM25 和 rerank 三阶段排名,稳定地穿过共享契约、聊天 SSE、持久化引用、AIOps、Vue 摘要与详情,并用多层测试锁定语义。

这个案例的价值在于目标单一、边界清楚,却必须协调多个工程层。它能说明 OpenSpec 为什么不只适合“大功能”,也适合控制一次会跨越协议和界面的可观察行为变化。

📷 [图片 token=DhXybFeS8o7v5NxT9sxcE3cSnVd(未能下载,见飞书原文)]

问题不是没有分数,而是信息在链路中丢失

变更前,混合检索内部已经知道向量召回和 BM25 召回的源列表名次,rerank 结果也有最终顺序,后端还保存向量、BM25、RRF 和 rerank 分数。但聊天引用摘要只展示类似“精排 99%”的结果。

这会造成两个问题。第一,使用者无法知道一条引用是同时被语义召回和关键词召回命中,还是只来自其中一路。第二,排查检索质量时只能看到最终精排分,无法观察某条文档在三个阶段的位置变化。

因此真正需求不是“前端加三个标签”,而是建立一条端到端的检索阶段轨迹:后端生成正确语义,共享契约允许表达空值和兼容旧消息,SSE 与持久化不丢字段,前端统一展示。

📷 [图片 token=LbyybrRfYoLgWCx9Pyfc9jYan3g(未能下载,见飞书原文)]

proposal 如何控制改动范围

proposal 将变化限定为三项:

  1. 检索命中和引用增加 vectorRankbm25RankrerankRank

  2. 聊天 SSE、持久化引用和 AIOps 引用保持相同三阶段语义。

  3. 前端摘要和详情展示名次、分数以及“未召回”状态。

proposal 同时明确不修改数据库 schema、Milvus collection、召回/RRF/rerank 算法和模型配置。这些 Non-Goals 防止一个展示可观测性需求演变成检索算法重构,也避免为了新增三个字段引入不必要的数据迁移。

📷 [图片 token=X7YVbORntoE4rUxJcI2cdEdTnsD(未能下载,见飞书原文)]

为什么需要三份 delta spec

一次纵向变化会同时改变多个长期能力,因此该 change 修改三个 capability:

Capability负责的行为边界关键要求
knowledge-retrieval-tool检索领域语义保留三阶段一基排名;单路缺失为空;rerankRank 表示最终输出位置
api-and-sse-contracts跨进程传输语义HTTP/SSE 引用支持排名字段,并允许未召回的粗排字段为空
knowledge-answer-citation-view可见交互语义摘要和详情展示阶段轨迹;未命中显示“未召回”;窄视口不得水平溢出

如果只写前端 spec,就无法约束后端字段来源;只写后端 spec,又无法约束 SSE 兼容和用户可见状态。三个 capability 不是重复描述,而是分别保护领域、协议和界面边界。

📷 [图片 token=BsYebQMKYouTPrx8jTHc99M7npe(未能下载,见飞书原文)]

三个关键 design 决策

**排名从 1 开始。**向量、BM25 和 rerank 使用一基序号,与界面中的“第 1 名”一致,契约还通过 minimum: 1 排除 0 和负数。

**未召回必须为空,不能伪造。**关键词独占候选的 vectorRank/vectorScore 为 null,向量独占候选的 bm25Rank/bm25Score 为 null。0 分或第 0 名会把“没有进入该召回列表”误写成“进入了但表现很差”。

📷 [图片 token=IPcebEZhvoHE2JxhI75chlmcn4e(未能下载,见飞书原文)]

**最终 rerank 名次按输出位置产生。**实现遍历 rerank 返回列表时使用 enumerate(..., start=1),不把 provider 的输入 index 当作最终名次。因为 rerank 的目的正是改变候选顺序,输入位置不能代表输出排名。

📷 [图片 token=Dc1cb0wfMo8m2ux1rbPcnJujnx2(未能下载,见飞书原文)]

展示规则也在 design 中统一:向量相似度与 rerank 相关度显示百分比,BM25 保留三位小数,详情继续展示 RRF 融合分。旧引用缺少新增字段,因此 SSE/历史引用字段保持兼容,而不是强迫已有消息补数据。

📷 [图片 token=Q84BbSK5coCDs7xpFelcOJ8cnxf(未能下载,见飞书原文)]

共享契约先定义跨层共同语言

OncallAgent 规定 packages/api-contracts 是 HTTP、OpenAPI 和 SSE 的唯一事实来源。这个 change 没有在 Vue 和 Python 中各自临时发明字段,而是先把语义固化在共享契约:

packages/api-contracts/src/retrieval.ts:检索 Hit/Citation 的 vectorRankbm25Rank 是必有键但允许 null,rerankRank 必填。

packages/api-contracts/src/sse.tsreference.source 中三类 rank 保持可选,以兼容旧的流式引用和历史消息。

packages/api-contracts/src/openapi.ts:同步字段、nullability 和一基排名的最小值约束。

📷 [图片 token=JGJFbqhTgopANLxYKd5cwe7pnxg(未能下载,见飞书原文)]

“retrieval 对象允许 null”与“SSE 字段可选”是两种不同语义。前者表示当前对象明确知道某一路没有命中;后者允许旧数据根本没有这些新字段。把两者都笼统说成“可选”会丢失兼容设计。

📷 [图片 token=V9Ddb27GJoVmBGxCfNLcY3VUnQg(未能下载,见飞书原文)]

后端怎样传递而不改排序算法

apps/backend/src/super_ai/retrieval/hybrid.py 已经能够从 RRF 候选中得到 vector_rankbm25_rank。这一基础不是本 change 新发明的,也没有在该变更中修改检索算法。

变更的主要工作从 apps/backend/src/super_ai/retrieval/tool.py 开始:把源排名保留到 fused candidate、最终 hit 和 citation;遍历 rerank 输出时生成 rerank_rank;对外 payload 使用共享契约约定的 camelCase 字段。

📷 [图片 token=MueFb9oYtorkGmxSjb1cRui2nBh(未能下载,见飞书原文)]

apps/backend/src/super_ai/chat/streaming.py 从工具 citation 读取排名,并把它们继续放入引用 payload 与 SSE。apps/backend/src/super_ai/aiops/diagnostics.py 对 SOP hit、citation 和 AIOps reference.source 做同样传递。这样聊天与 AIOps 不会出现两套引用语义。

📷 [图片 token=C203bHShNouqRAxCoV2cbnKYnLe(未能下载,见飞书原文)]

已有 vector/BM25 源排名
  → fused candidate
  → rerank 最终位置
  → KnowledgeRetrievalHit / Citation
  → chat reference / AIOps reference
  → SSE 与持久化 metadata

前端怎样保证摘要与详情一致

apps/frontend/src/chat/retrievalPresentation.ts 集中处理百分比、BM25 三位小数和“未召回”文案。格式规则没有散落在多个组件里。

apps/frontend/src/components/RetrievalStageTrace.vue 封装三阶段展示,并提供 ARIA 描述、自动换行和窄屏样式。ChatTranscript.vue 在引用摘要中复用它,ChatCitationDetail.vue 在详情中复用同一组件并保留 RRF 融合分。

📷 [图片 token=R6Z5b16nJoTvaKx7uWEcZgCPnvG(未能下载,见飞书原文)]

这种设计避免摘要说“精排”,详情却使用另一套术语,也减少后续格式调整时的重复修改。实现中存在 flex-wrap 与窄屏媒体查询,但当前组件自动化测试没有设置真实 viewport 或浏览器截图,所以不能把它描述成已经完成视觉验收。

📷 [图片 token=JMxcblbmCoXKKkx0gfzc2TLDnMh(未能下载,见飞书原文)]

测试如何按风险分层

测试位置主要证明什么
test_knowledge_retrieval_tool.py双路命中、单路缺失为 null、rerank 改序后的最终名次和 payload
test_hybrid_retrieval.pyRRF 候选保留向量与 BM25 源排名;这是既有基础测试
api-contracts.test.ts共享检索对象与引用包含三类排名字段
chatComponents.test.ts摘要和详情格式,以及“向量未召回”等用户可见状态

📷 [图片 token=PnytbOnopoTrH8xNscwcEcAAnCb(未能下载,见飞书原文)]

本次为案例核对,在当前工作树定向运行了四组测试:

cd apps/backend
uv run python -m pytest tests/test_knowledge_retrieval_tool.py -q
# 14 passed
uv run python -m pytest tests/test_hybrid_retrieval.py -q
# 6 passed
cd ../..
npm --workspace packages/api-contracts test -- --run tests/api-contracts.test.ts
# 24 passed
npm --workspace apps/frontend test -- --run tests/chatComponents.test.ts
# 6 passed

四组共 50 项通过。这是定向证据,不代表 Ruff、Pyright、前后端全量测试、OpenSpec validate、docs build、浏览器窄视口验收或外部 Milvus/LLM 真链路都已经在本轮验证。AIOps 排名传播能在实现中找到,但该 change 没有新增独立的 AIOps 专项断言,也不应夸大。

📷 [图片 token=M2k6bDdWHoaNuIxalrMcIhL8nzf(未能下载,见飞书原文)]

从 delta 到 main specs,再到 archive 与 WIKI

三条 Requirement 已分别出现在当前主规格:

  • openspec/specs/knowledge-retrieval-tool/spec.md

  • openspec/specs/api-and-sse-contracts/spec.md

  • openspec/specs/knowledge-answer-citation-view/spec.md

完整 change 位于 openspec/changes/archive/2026-07-11-show-retrieval-stage-ranks/。仓库 WIKI 页面 docs/changes/archive/2026-07-11-show-retrieval-stage-ranks/index.md 标记为 archived,并通过 @include 引用 proposal、design、tasks 和三份 delta spec。

📷 [图片 token=GFrEbEWdbo5PL2xy15wcsPxundf(未能下载,见飞书原文)]

这个终态可以证明主规格、归档与 WIKI 当前存在并互相对应,但不能反推归档当天具体执行过哪些 CLI 命令,也不能只凭历史 tasks 的 [x] 宣称当时的全量检查和真实前端检索已经被本轮复核。

📷 [图片 token=XLkCbu3l3oafWlxrvEMcKaLCndc(未能下载,见飞书原文)]

这个案例怎样讲得专业

我做过一次三阶段检索排名可见化的纵向变更。问题不是后端没有分数,而是向量、BM25 和 rerank 的排名没有穿过引用链路。先用三个 delta capability 分别约束检索领域、HTTP/SSE 契约和 Vue 展示;设计上一基排名,未召回用 null,最终 rerankRank 按输出位置生成,并保留旧消息兼容。实施时先更新共享契约,再把字段穿过检索工具、聊天和 AIOps,前端用统一组件展示摘要与详情。测试覆盖双路命中、单路缺失、rerank 改序、契约对象和用户可见文案,最后同步三份主规格、归档 change 并生成 WIKI。整个过程没有修改排序算法、数据库或 Milvus schema,范围始终受 proposal 和 Non-Goals 约束。

这段表达包含问题、边界、规格拆分、关键设计、实施顺序、测试证据和非目标。它比“我给页面加了三个字段”更能说明端到端交付能力,也避免把现有 RRF 能力误说成这次变更新增。

📷 [图片 token=ZfbAbWwhFoQj3CxKONNckYIKn0e(未能下载,见飞书原文)]