Skip to main content

P2.4 — Golden Answer 检索设计(复用 Haystack)

Date: 2026-07-08 Status: Draft for review(两个架构岔路待拍板:D-A 检索可见性、D-B golden 识别) Ancestry: master plan P2.4「golden answers 优先检索路径(检索时 join published versions)」· 设计文档 2026-07-07-unified-config-asset-model.md §6 layer⑦「golden 优先 → hybrid」/ §9「策展型进底座,KB 文档本体不迁入」· ADR-008 K1/K2 · ADR-019(Haystack 最终一致)· ADR-033 L1 决策前提(用户确认 2026-07-08): 真相源在配置资产底座,只复用 KB 的检索能力——不自造关键词匹配,golden 走同一套 Haystack 语义检索。匹配度(语义命中)是这个功能的命脉,不是规模到了才需要。


1. 为什么需要这份设计(推翻了原 P2.4 简化做法)

原 KB 线计划的 Task 5/6 在「不复用 KB、配置侧自造关键词匹配」的前提下写的——那个前提被否决。关键词重叠匹配不了语义(「能退吗」命中不了「退款政策」),而 golden answer 的全部价值就是被可靠命中;一个静默失效的优先层比没有更糟。正确做法是复用已有的 Haystack 语义检索。但这不是小改动——核实真实接口后确认它跨 NestJS 三处 + rag 服务 + 一致性契约,必须先设计。

已完成且仍有效:KB 策展 schema 层(Task 1-4:applicability / kb_golden_answer / kb_domain_rule schema + 注册)与检索方式无关,保留。 作废:Task 5 KbCurationRetrievalService(关键词打分)——前提错误,本设计定案后删除。

2. 目标 / 非目标

目标:published golden answer 能被 /chat 的现有 Haystack 混合检索命中(语义匹配),命中后在结果中被识别并置顶(design §6 ⑦),真相源不迁出配置资产底座。

非目标

  • 不把 golden 的真相源迁进 KB(版本/审批/回滚/审计仍在 config-asset 底座)——Haystack 里只是派生检索索引
  • 不做 golden 的独立向量库/独立相似度算法(那就是重复造轮子)。
  • kb_domain_rule 的检索注入不在本设计(domain rule 是装配层①的硬约束,Phase 3 装配器消费,不走 KB 检索通道)——本设计只管 kb_golden_answer

3. 核实到的硬约束(决定设计形状,均已亲验 @ abfb30ec,并于 490517fb(KB 线合并后)复核仍成立——KB 合并仅新增 config-assets schema 文件,本节 6 个源文件逐字节未变)

  1. upload() 双写 + 检索 DB 双校验(含 fail-open 兜底,见下方 ⚠️)HaystackIngestionService.upload(orgId, dataset, file, attrs)haystack-ingestion.service.ts:36)既 ingest 进 Haystack,又 upsert 一行 org_documentsOrgDocument)。attrs 真实 8 字段sourceOrgDocumentSource必填)、contentHash必填)、specialistId?metadata?uploaderUserId?status?OrgDocumentReviewStatus,默认 "approved":72)、synthesisRunId?sourceTag?。成功路径回写 parseStatus="ingested":128)。检索侧 HaystackRetrievalService.allowedDocumentIds()haystack-retrieval.service.ts:186-268)把 Haystack 返回的 doc_idorg_documents 行做 scope 求交,硬性要求 parseStatus ∈ {"ingested","parsed"}:223/:235)+ status ∈ {"approved",NULL}:200)+ specialist/effectiveDate/audience/jurisdiction arm。
    • ⚠️ 关键偏差(490517fb 复核发现,原描述「无匹配 org 行的结果被静默丢弃」不准确):真实是双分支:253-266)——有 org 行但不满足门槛 → 丢弃(fail-closed);完全没有 org 影子行 → 走兜底 exist({orgId, haystack_doc_id}) 检查,查不到行反而 fail-open 放行:265 if (!exists) allowed.add(id),注释解释是防跨 org 负空间泄漏)。
    • ⟹ 这直接决定 D-A 的正确选择(见 §4):若 golden 影子行不写 org_documents,它的 doc_id 会走兜底被静默放行,绕过 status/specialist/effectiveDate/audience 全部治理门槛。要让 golden 受门槛治理,必须建影子行status="approved" + parseStatus="ingested"。「不建影子行也能被检索到」是 fail-open 漏网,不是设计意图。
  2. 检索 meta allowlist 剥字段:rag 的 _serialize_documentrag/pipelines/retrieval/pipeline_wrapper.py:203-220)只回传白名单 8 个 meta key(org_id, dataset, specialist_id, audience, jurisdiction, effective_date, version, filename——filename 是 #4033 补回)。自定义 meta 标记(如 kind="golden_answer")在 ingest 时会存进 org_documents.metadata、但检索序列化时被剥掉——除非把该 key 加进这个 allowlist(跨仓库改 rag 服务,#4033 同类 bug)。correction 先例的 metadata.kind="correction_refinement" 正是只在 DB 侧用、不出现在检索结果里(印证此约束)。
  3. HaystackDataset 闭合 3 值"default-kb"|"conversations"|"corrections"haystack.types.ts:1)。加新 dataset 值要同步改 3 处(types + 两个 rag Literal + retrieval toContextResult source mapper)。sourcedataset 派生:default-kb → "kb",无独立 golden source。
  4. scope 靠 chunk meta + DB 双校验:ingest 传 specialist_id非 null 才只对该 specialist 可见(null = org-wide 所有 specialist 可见)。
  5. 发布/回滚钩子点ConfigPublishServiceconfig-publish.service.ts)——requestPublish(T1→T2 同步事务 :75,切 publishedVersionId 指针 :123)、rollback:227 指针切回旧版本)、overridePublish:162)、discardDraft:298)。审计走 emitAuditsaudit.tryLog:418事务外 fire-and-forget,ADR-019)。Haystack 同步必须挂在 post-commit 侧(像审计),绝不进业务事务——否则 Haystack 故障会回滚发布。
  6. 最接近的先例correction-refinement.processor.ts:251——非文件的派生内容 → 合成 → upload()metadata.kind 标记进 Haystack。golden 同步应研究并模仿这个 shape(BullMQ 异步 + 派生内容 + meta 标记)。
  7. delete best-effortdeleteDocument/deleteByMetahaystack-ingestion.service.ts:229,260)按 haystack_doc_id 删,try/catch 吞错、总是继续删 DB 行(ADR-019,orphan 可接受)。删除 content_hash 决定 haystack_doc_id = ${orgId}:${dataset}:${contentHash}:339)——同 id 幂等 upsert。

4. 两个架构岔路(✅ 用户已拍板 2026-07-08:A1 + B1

D-A:golden 怎么变成「可被检索到」(过 org_documents 双校验)

选项做法
A1(推荐)走 upload()发布 golden 时,把 question(+answer) 合成一个内存 Multer 文件,upload(orgId, "default-kb", file, { specialistId, source, contentHash, metadata:{...} })。自动建 org_documents 行 + ingest。检索天然过双校验。复用现成双写、零改检索允许路径、org_documents 行使 golden 天然被 allowedDocumentIds 放行、回滚删除有现成 deleteDocumentgolden 在 org_documents 里有一行「影子文档」(但真相源仍在 config-asset;影子行是派生索引的一部分,语义可接受);要选 OrgDocumentSource/status 使其 parseStatus="ingested"+allowed status
A2 改检索允许路径不建 org_documents 行,改 allowedDocumentIds 让 golden doc_id 走 config-asset 校验分支无影子文档行改生产检索的安全关键路径(allowedDocumentIds 是跨 org 泄漏防御)——高风险,得重验隔离;且要在检索热路径加一次 config-asset 查询

✅ 定案 A1:复用既有双写通道,不碰检索安全路径。org_documents 的影子行是「派生索引」的正当组成(就像 Haystack 里的向量是派生的),真相源归属不变。490517fb 复核后 A1 的理由更强:约束 1 的 fail-open 兜底意味着 A2「不建影子行、改检索允许路径」若做错会静默放行绕过治理门槛;A1 通过写一个 status="approved"+parseStatus="ingested" 的显式授权影子行,让 golden 走的是 fail-closed 授权分支而非 fail-open 漏网分支——这是安全正确性理由,不只是「省事」。

D-B:检索结果里怎么识别 golden 并置顶

选项做法
B1(推荐)meta flag + 改 rag allowlistingest 时 metadata.kind="golden_answer" + metadata.golden_asset_id改 rag _serialize_document allowlist 加这两个 key;检索侧 toContextResultmeta.kind==="golden_answer" → 标记结果 → conversations 检索块把 golden 命中置顶复用 "default-kb" dataset(不动 dataset 闭合union);meta 是既有 correction 先例的同款 pattern;识别精确到 asset_id(可追溯回 config-asset)需改 rag 服务(跨仓库两行 + 部署);rag 改动要与 NestJS 同步上线
B2 新 dataset 值 "golden"加第 4 个 dataset 值,全链路 3 处改dataset 走现成 allowlist 免改序列化;source 可给 golden 独立值改闭合 union 3 处 + 两个 rag Literal + source mapper;比 meta flag 面更大;且 golden 与普通 KB 用不同 dataset 会让「golden+hybrid 一次检索」变成两次(dataset 过滤是 AND)
B3 靠 org_documents 的 source/sourceTag用 A1 建行时打 sourceTag,检索后 join org_documents 拿标记不改 rag检索侧多一次 join;ContextResult 不直接带标记,要额外查;置顶逻辑更绕

✅ 定案 B1:meta flag 精确、可追溯、复用 default-kb dataset(golden 和 hybrid 同一次检索里,靠 meta 区分+置顶,符合 §6 ⑦「golden 优先 → hybrid」是一次检索内排序而非两次检索)。代价是改 rag allowlist 两行——可接受,且核实已确认这是该类需求的标准做法(#4033 先例)。490517fb 复核确认 allowlist 现为 8 个 key,新增 kind+golden_asset_id 后为 10 个;correction 先例已证 kind 若不进 allowlist 就在检索序列化被剥掉。

5. 端到端流程(基于 A1 + B1 的推荐)

发布 golden(ConfigPublishService.requestPublish T2 post-commit)
└─(异步 BullMQ, best-effort)→ GoldenAnswerHaystackSync.onPublished(assetId, versionId)
取 published version content → 合成检索文本(question+answer)
upload(orgId, "default-kb", syntheticFile, {
specialistId, // 非 null,只对本 specialist 可见
source: <OrgDocumentSource 选定值>,
contentHash: sha256(合成文本),
metadata: { kind: "golden_answer", golden_asset_id: assetId, golden_version_id: versionId },
})

回滚 / 归档 / discard published golden(rollback / overridePublish 切走 / archive)
└─(异步 best-effort)→ GoldenAnswerHaystackSync.onUnpublished(assetId, oldVersionId)
deleteByMeta(orgId, { golden_asset_id: assetId }) // 或按 haystack_doc_id

/chat 检索(conversations.service.ts:2993-3059,现有混合检索)
ctx = haystackRetrieval.search(orgId, query, { specialistId, ... }) // 不变
→ golden 命中随普通 KB 一起回来(同一次检索)
→ toContextResult 读 meta.kind → ContextResult 带 golden 标记
→ 检索块识别 golden 命中,unshift 到 retrievedKbContext 最前(置顶)
→ 既有 formatKbContext(<kb_context> 包裹 + MAX_KB_CONTEXT_CHARS 截断)不变

一致性契约:golden→Haystack 同步 = 最终一致 best-effort(继承 ADR-019 KB delete 语义)。发布事务成功但 Haystack 同步失败 → golden 暂不可检索(下次发布/重试补上),绝不回滚发布。同步走 BullMQ(stable jobId = versionId,跨 pod 幂等,符合 active-active)。回滚删除失败 → orphan 向量可接受(同 KB delete)。

6. 影响面 / 工作量(说明为什么这不是「小改动」)

改动规模
config-publish 钩子post-commit 触发 golden 同步/清理(发布/回滚/override/discard/archive 五处指针变化)M
GoldenAnswerHaystackSync 服务 + BullMQ processor合成文本 + upload + deleteByMeta,best-effortM
rag 服务 _serialize_document allowlistkind + golden_asset_id 两 keyXS(但跨仓库/跨部署)
conversations 检索块识别 golden 命中 + 置顶(方案仍是 unshift 到最前,复用 formatKbContext)S
一致性/幂等jobId 幂等、回滚清理、org_documents 影子行生命周期S
测试同步服务单测 + 检索识别置顶测 + 隔离测(golden 只对本 specialist 可见)M

合计 ≈ 原 master plan 给 P2.4 的「M」但跨 rag 服务,实际偏 M→L。建议 P2.4 独立成一个 PR(与 schema 层/前端编辑器/coverage report 分开),因为它跨 NestJS+rag、需协调部署、且碰生产检索安全路径。

7. 开放问题(✅ 490517fb 复核后定案)

#问题定案
Q1OrgDocumentSource / status 给 golden 影子行选哪个值status="approved" + parseStatus="ingested"upload() 成功路径默认即此,天然满足);allowedDocumentIds 的 where 完全不过滤 source(source 只在 listAmInputChunks 用),故 source 任选合法值皆能过放行。为语义清晰与可追溯,golden 影子行新增专用 source 值 golden_answerOrgDocumentSource 现 10 值,entities.ts:1357-1367;加第 11 个值是 expand-only 枚举扩展)。绝不能不建影子行——见 §3 约束 1 的 fail-open 兜底:不建行会静默放行绕过治理。
Q2合成检索文本只放 question 还是 question+answer✅ 都 ingest(question 主导召回、answer 文本也贡献语义召回);但 golden 命中后 formatKbContext 呈现用完整 golden 内容(从 config-asset 真相源取,非影子行文本)。
Q3rag allowlist 改动的部署协调(NestJS 与 rag 同步上线)✅ rag 改动向后兼容(多回传两个 meta key 不影响老消费者),先上 rag 再上 NestJS 安全。本长期分支统一 PR 内两处都改,最终一次上线。
Q4本 PR 是否先只做 schema+前端+coverage(不含 P2.4),P2.4 独立 PR⚠️ scope 已变(2026-07-08 用户拍板):KB schema+前端+coverage 已作为 PR #4066 独立合入 dev。P2.4 及后续所有 Phase 改为攒进一个长期分支/一个 PRfeat/specialist-value-output-p2plus,基点 490517fb),先不逐 Phase 合 dev。原「P2.4 独立 PR」的建议被此 scope 决策取代。

据本设计写 P2.4 实施计划 docs/superpowers/plans/2026-07-08-p2.4-golden-answer-retrieval-implementation.md(删除已作废的关键词 service 路线,实现 golden-Haystack 同步 + 检索识别置顶)。