检索观测数据记录(文档级观测与证据封装)
上一篇我们讲了通道级观测,它回答的是"每个通道整体表现如何"。这一篇我们来看文档级观测——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);
}
逐段拆解
付费内容提示
该文档的全部内容仅对「JavaUp项目实战&技术讲解」知识星球用户开放
加入星球后,你可以获得:
- 超级八股文:100万+字的全栈技术知识库,涵盖技术核心、数据库、中间件、分布式等深度剖析的讲解
- 讲解文档:超级AI智能体、黑马点评Plus、大麦、大麦pro、大麦AI、流量切换、数据中台的从0到1的详细文档
- 讲解视频:超级AI智能体、黑马点评Plus、大麦、大麦pro、大麦AI、流量切换、数据中台的核心业务详细讲解
- 1 对 1 解答:可以对我进行1对1的问题提问,而不仅仅只限于项目
- 针对性服务:有没理解的地方,文档或者视频还没有讲到可以提出,本人会补充
- 面试与简历指导:提供面试回答技巧,项目怎样写才能在简历中具有独特的亮点
- 中间件环境:对于项目中需要使用的中间件,可直接替换成我提供的云环境
- 面试后复盘:小伙伴去面试后,如果哪里被面试官问住了,可以再找我解答
- 远程的解决:如果在启动项目遇到问题,本人可以帮你远程解决
进入星球后,即可享受上述所有服务,保证不会再有其他隐藏费用。
