知识范围路由服务的核心实现(下)
上一篇讲了 route 方法的入口、buildQueryContext 的实现,以及 rankScopes 的前两个步骤。这一篇我们继续讲解 rankScopes 的后续步骤。
rankScopes 的后续步骤
步骤三:计算语义相似度
List<Double> semanticScores = computeSemanticScores(queryContext, routeTexts);
computeSemanticScores 方法会批量计算问题向量和每个知识域路由文本向量的余弦相似度。
/**
* 批量计算候选文本的语义相似度。
* 这是知识路由中最核心的“语义打分”入口之一,
* 会拿“问题向量”和一批候选文本向量逐一计算余弦相似度,并保持结果顺序与 `routeTexts` 严格对齐。
*
* 设计上有几个关键点:
* 1. 这里采用“批量 embedding + 顺序写回”的方式,而不是逐条 embedding,
* 目的是减少 embedding 调用次数,提高路由阶段吞吐。
* 2. 结果列表会预先按候选数量填充 0 分,
* 这样后续可以按原下标直接回写分数,保证“第 N 个候选文本 -> 第 N 个语义分”始终成立。
* 3. 只要语义链路不可用,就统一返回全 0 分,而不是抛错中断主流程。
* 上层仍可继续依赖词法命中、实体词辅助分等机制完成保守路由。
*
* 语义链路会在以下场景直接退化:
* 1. 当前 query 没有可用向量;
* 2. 没有注入可用的 EmbeddingModel;
* 3. 批量 embedding 返回结果数量与输入数量不一致;
* 4. embedding 过程中抛出异常。
*
* @param queryContext 查询上下文,内部应已包含问题向量
* @param routeTexts 候选文本列表
* @return 与 `routeTexts` 等长、顺序一一对应的语义相似度列表
*/
private List<Double> computeSemanticScores(RouteQueryContext queryContext, List<String> routeTexts) {
if (!queryContext.semanticEnabled() || routeTexts == null || routeTexts.isEmpty()) {
// 没有 query 向量或没有候选文本时,直接返回一组 0 分,占位但不中断后续路由。
return routeTexts == null ? List.of() : routeTexts.stream().map(item -> 0D).toList();
}
EmbeddingModel embeddingModel = embeddingModelProvider.getIfAvailable();
if (embeddingModel == null) {
// 运行环境未提供 embedding 能力时,整体退回词法路径。
return routeTexts.stream().map(item -> 0D).toList();
}
try {
// 先把候选文本归一化为非 null 字符串,避免 embedding SDK 因 null 值报错。
List<String> normalizedRouteTexts = routeTexts.stream()
.map(item -> StrUtil.blankToDefault(item, ""))
.toList();
// 预先按原候选个数填满 0 分,后续按下标回写,确保结果和输入严格对齐。
List<Double> scores = new ArrayList<>(normalizedRouteTexts.size());
for (int index = 0; index < normalizedRouteTexts.size(); index++) {
scores.add(0D);
}
// 根据配置好的批大小切分候选,避免一次性 embedding 过多文本造成调用压力或超时。
int totalBatchCount = (normalizedRouteTexts.size() + ROUTE_EMBEDDING_BATCH_SIZE - 1) / ROUTE_EMBEDDING_BATCH_SIZE;
for (int startIndex = 0; startIndex < normalizedRouteTexts.size(); startIndex += ROUTE_EMBEDDING_BATCH_SIZE) {
int endIndex = Math.min(startIndex + ROUTE_EMBEDDING_BATCH_SIZE, normalizedRouteTexts.size());
List<String> currentBatch = normalizedRouteTexts.subList(startIndex, endIndex);
int currentBatchIndex = (startIndex / ROUTE_EMBEDDING_BATCH_SIZE) + 1;
// 候选文本按批次向量化,避免一次性 embedding 过大导致调用压力过高。
log.debug("知识路由候选向量分批计算: batchIndex={}/{}, candidateRange=[{}, {}], batchSize={}",
currentBatchIndex, totalBatchCount, startIndex + 1, endIndex, currentBatch.size());
List<float[]> embeddings = embeddingModel.embed(currentBatch);
if (embeddings.size() != currentBatch.size()) {
// 批量结果不完整时,统一回退为 0 分,避免误判。
return routeTexts.stream().map(item -> 0D).toList();
}
for (int batchIndex = 0; batchIndex < embeddings.size(); batchIndex++) {
// 当前批次里的第 batchIndex 个向量,要写回到全局 scores 的真实下标 startIndex + batchIndex 上。
// 这样即使分批处理,最终结果仍与 routeTexts 原顺序完全一致。
scores.set(startIndex + batchIndex, cosineSimilarity(queryContext.queryEmbedding(), embeddings.get(batchIndex)));
}
}
// 所有批次都成功后,返回与原候选顺序完全对齐的相似度列表。
return scores;
}
catch (Exception exception) {
// 候选向量化失败也不阻断主流程,直接退回词法匹配。
log.warn("知识路由生成候选向量失败,退回词面匹配: candidateCount={}", routeTexts.size(), exception);
return routeTexts.stream().map(item -> 0D).toList();
}
}
这个方法的关键点:
1. 批量向量化
为了避免一次性向量化太多文本导致调用压力过高,代码采用了批量处理策略:
- 每批最多处理 10 个文本(
ROUTE_EMBEDDING_BATCH_SIZE = 10) - 分批调用
embeddingModel.embed(currentBatch) - 记录每批的处理进度,方便调试
2. 计算余弦相似度
对每个候选文本的向量,调用 cosineSimilarity 方法计算与问题向量的余弦相似度:
付费内容提示
该文档的全部内容仅对「JavaUp项目实战&技术讲解」知识星球用户开放
加入星球后,你可以获得:
- 超级八股文:100万+字的全栈技术知识库,涵盖技术核心、数据库、中间件、分布式等深度剖析的讲解
- 讲解文档:超级AI智能体、黑马点评Plus、大麦、大麦pro、大麦AI、流量切换、数据中台的从0到1的详细文档
- 讲解视频:超级AI智能体、黑马点评Plus、大麦、大麦pro、大麦AI、流量切换、数据中台的核心业务详细讲解
- 1 对 1 解答:可以对我进行1对1的问题提问,而不仅仅只限于项目
- 针对性服务:有没理解的地方,文档或者视频还没有讲到可以提出,本人会补充
- 面试与简历指导:提供面试回答技巧,项目怎样写才能在简历中具有独特的亮点
- 中间件环境:对于项目中需要使用的中间件,可直接替换成我提供的云环境
- 面试后复盘:小伙伴去面试后,如果哪里被面试官问住了,可以再找我解答
- 远程的解决:如果在启动项目遇到问题,本人可以帮你远程解决
进入星球后,即可享受上述所有服务,保证不会再有其他隐藏费用。
