检索观测数据记录(文档级观测与证据封装)
上一篇我们讲了通道级观测,它回答的是"每个通道整体表现如何"。这一篇我们来看文档级观测——recordRetrievalResultObservations,它回答的是一个更细粒度的问题:每一个候选文档经历了什么?从哪来的?分数多少?为什么被选中或被淘汰?
recordRetrievalResultObservations 完整实现
这个方法比较长,我们先看完整代码,再逐段拆解:
/**
* 记录文档级检索观测数据。
*
* <p>该方法会为每个原始召回文档记录通道排名、原始分数、RRF 分数、rerank 分数、
* 是否通过证据闸门、是否进入最终 Prompt,以及未入选原因。</p>
*/
private void recordRetrievalResultObservations(ConversationTraceRecorder traceRecorder,
int subQuestionIndex,
String subQuestion,
List<RetrievalChannelResult> rawResults,
List<RetrievalChannelResult> filteredResults,
List<Document> mergedCandidates,
List<Document> rerankedCandidates,
List<Document> finalDocuments) {
// 收集所有文档级观测记录,最后统一写入 traceRecorder。
List<RetrievalResultView> results = new ArrayList<>();
// finalRankMap 用于快速判断某个 doc 是否被选入最终 Prompt,以及它的最终排名。
Map<String, Integer> finalRankMap = new LinkedHashMap<>();
if (finalDocuments != null) {
for (int i = 0; i < finalDocuments.size(); i++) {
// docId 是跨阶段关联同一个候选块的主键。
String docId = finalDocuments.get(i).getId();
if (docId != null) {
// 最终排名从 1 开始,更符合前端展示习惯。
finalRankMap.put(docId, i + 1);
}
}
}
// 原始召回结果是文档级观测的起点;没有 rawResults 就没有可解释的候选来源。
if (rawResults != null) {
for (RetrievalChannelResult rawResult : rawResults) {
// 每个候选都要保留它来自哪个通道。
String channelName = rawResult.getChannelName();
List<Document> rawDocs = rawResult.getDocuments();
// 当前通道没有候选时跳过,不生成空观测记录。
if (rawDocs == null || rawDocs.isEmpty()) {
continue;
}
// 逐个原始候选生成 RetrievalResultView,保留它在通道内的原始排名。
for (int i = 0; i < rawDocs.size(); i++) {
Document doc = rawDocs.get(i);
RetrievalResultView view = new RetrievalResultView();
// exchangeId/traceId 用于把观测数据关联回本轮对话 trace。
view.setId(traceRecorder.exchangeId());
view.setTraceId(traceRecorder.traceId());
view.setSubQuestionIndex(subQuestionIndex);
view.setSubQuestion(subQuestion);
view.setChannelType(channelName);
// channelRank 表示该候选在原始通道结果中的排名,排名从 1 开始。
view.setChannelRank(i + 1);
// 原始召回分数通常来自向量相似度、关键词匹配分或结构通道自定义分。
Object scoreObj = doc.getMetadata().get(DocumentKnowledgeMetadataKeys.SCORE);
if (scoreObj instanceof Number) {
view.setOriginalScore(BigDecimal.valueOf(((Number) scoreObj).doubleValue()));
}
// RRF 分数由融合阶段写回 metadata;原始召回阶段可能尚未包含该字段。
Object rrfScoreObj = doc.getMetadata().get(DocumentKnowledgeMetadataKeys.RRF_SCORE);
if (rrfScoreObj instanceof Number) {
view.setRrfScore(BigDecimal.valueOf(((Number) rrfScoreObj).doubleValue()));
}
// rerankScore 由 rerank 处理器写入,用于解释二次重排后的相关性。
Object rerankScoreObj = doc.getMetadata().get("rerankScore");
if (rerankScoreObj instanceof Number) {
view.setRerankScore(BigDecimal.valueOf(((Number) rerankScoreObj).doubleValue()));
}
// 以下字段从 metadata 提取文档和 chunk 信息,用于前端定位证据来源。
Object docIdObj = doc.getMetadata().get(DocumentKnowledgeMetadataKeys.DOCUMENT_ID);
if (docIdObj != null) {
view.setDocumentId(Long.parseLong(String.valueOf(docIdObj)));
}
// 文档名称用于在 trace 或引用列表中直接展示。
Object docNameObj = doc.getMetadata().get(DocumentKnowledgeMetadataKeys.DOCUMENT_NAME);
if (docNameObj != null) {
view.setDocumentName(String.valueOf(docNameObj));
}
// chunkId 是知识库切块的唯一标识。
Object chunkIdObj = doc.getMetadata().get(DocumentKnowledgeMetadataKeys.CHUNK_ID);
if (chunkIdObj != null) {
view.setChunkId(Long.parseLong(String.valueOf(chunkIdObj)));
}
// chunkNo 是文档内的切块序号,便于按原文顺序回溯。
Object chunkNoObj = doc.getMetadata().get(DocumentKnowledgeMetadataKeys.CHUNK_NO);
if (chunkNoObj != null) {
view.setChunkNo(Integer.parseInt(String.valueOf(chunkNoObj)));
}
// sectionPath 记录章节路径,帮助判断证据是否来自正确章节。
Object sectionPathObj = doc.getMetadata().get(DocumentKnowledgeMetadataKeys.SECTION_PATH);
if (sectionPathObj != null) {
view.setSectionPath(String.valueOf(sectionPathObj));
}
// chunk 文本可能很长,trace 里只存前 500 字预览,同时记录完整字符数。
String content = doc.getText();
if (content != null && !content.isEmpty()) {
view.setChunkTextPreview(content.length() > 500 ? content.substring(0, 500) : content);
view.setChunkCharCount(content.length());
}
// 判断该候选是否通过证据闸门:同通道过滤结果中存在相同 docId 即视为通过。
boolean passedGate = filteredResults != null && filteredResults.stream()
.anyMatch(fr -> channelName.equals(fr.getChannelName()) &&
fr.getDocuments() != null &&
fr.getDocuments().stream().anyMatch(d -> Objects.equals(d.getId(), doc.getId())));
view.setGatePassed(passedGate);
// 判断该候选是否进入最终 Prompt:最终文档集合中有同一 docId 即视为选中。
boolean isSelected = doc.getId() != null && finalRankMap.containsKey(doc.getId());
view.setSelected(isSelected);
// 入选时记录最终排名和正向原因。
if (isSelected) {
view.setFinalRank(finalRankMap.get(doc.getId()));
view.setSelectionReason("已选入最终 Prompt");
} else if (!passedGate) {
// 未通过闸门时,根据通道类型生成更具体的过滤原因。
Object scoreObj2 = doc.getMetadata().get(DocumentKnowledgeMetadataKeys.SCORE);
double score = scoreObj2 instanceof Number ? ((Number) scoreObj2).doubleValue() : 0.0;
if ("vector".equals(channelName)) {
// 向量通道用绝对相似度阈值解释过滤原因。
view.setSelectionReason(String.format(
"向量闸门过滤:分数 %.4f < 阈值 %.4f",
score, properties.getMinVectorSimilarity()
));
} else if ("keyword".equals(channelName)) {
// 关键词通道用相对最高分阈值解释过滤原因。
view.setSelectionReason(String.format(
"关键词闸门过滤:分数 %.4f 低于相对阈值(floor=%.2f)",
score, properties.getKeywordRelativeScoreFloor()
));
} else {
// 其他通道没有专用阈值文案时,保留通用过滤分数说明。
view.setSelectionReason("闸门过滤:分数 " + String.format("%.4f", score));
}
} else {
// 通过闸门但未进入最终 Prompt,通常是排序后超出 finalTopK。
view.setSelectionReason("超出 finalTopK 限制(topK=" + properties.getFinalTopK() + ")");
}
// 单条文档观测记录完成,加入待写入列表。
results.add(view);
}
}
}
// 批量写入文档级检索结果观测。
traceRecorder.recordRetrievalResults(results);
}
逐段拆解
付费内容提示
该文档的全部内容仅对「码力全开」项目实战&技术讲解 知识星球用户开放
加入星球,一次获得完整项目资料、全栈技术知识库和长期答疑服务。
100万+字全栈技术知识库深入讲解技术核心、数据库、中间件和分布式等内容
8套热门的实战项目持续更新的企业级项目覆盖高并发、微服务、数据中台 和 AI Agent 等方向
AI 技术知识大模型面试详解覆盖 AI 模型原理、Agent、RAG、MCP、Skills、Harness 等核心知识点
文档 + 视频两种讲解形式既能系统阅读,也能跟随视频理解核心业务
完整项目实战资料
每套项目均包含从 0 到 1 讲解文档核心业务讲解视频从基础项目到复杂业务场景,项目资料会持续更新。
- 01Nexus Agent AI 智能体
- 02Nexus Agent Pro 完全版
- 03黑马点评Plus
- 04大麦
- 05大麦Pro
- 06大麦AI
- 07流量切换
- 08数据中台
加入后还能获得
进入星球后,即可享受上述所有服务,保证不会再有其他隐藏费用。从学习、面试到项目启动,都可以继续获得支持。
- 1 对 1 解答项目和技术问题都可以提问
- 针对性补充没有讲清楚的内容会继续补充
- 面试与简历指导梳理回答技巧和项目亮点
- 中间件云环境项目依赖可以直接接入使用
- 面试后复盘被问住的问题可以继续交流
- 远程问题解决项目启动问题可协助排查
