检索与监控回写
不依赖外部向量库的混合检索,以及监控如何把在线表现折算成惩罚分。
混合检索
服务台资料里既有自然语言描述,也有必须精确匹配的标识。“远程访问失败”与“VPN 连不上”可能描述同一件事,而错误码 691、具体软件名和资产编号又不能只依靠宽泛相似度处理。两路打分尝试兼顾词项的重要性与共享词片段。
知识库检索由两路得分加权相加:BM25 按词项频率与文档长度打分,哈希向量提供固定维度的词项重叠近似。两路各自归一化到 0~1,再按配置的权重融合,默认 BM25 占 0.65,向量占 0.35。
public List<SearchResult> search(String query, int topK) {
if (query == null || query.isBlank()) {
return List.of();
}
Map<String, Double> bm25 = bm25Scores(query);
Map<String, Double> vector = vectorScores(query);
Map<String, Double> fused = new HashMap<>();
documents.forEach(doc -> {
double score = properties.getRag().getBm25Weight() * bm25.getOrDefault(doc.id(), 0.0)
+ properties.getRag().getVectorWeight() * vector.getOrDefault(doc.id(), 0.0);
fused.put(doc.id(), score);
});
return documents.stream()
.sorted(Comparator.comparingDouble((KnowledgeDocument d) -> fused.getOrDefault(d.id(), 0.0)).reversed())
.limit(topK)
.map(doc -> new SearchResult(
doc.id(),
doc.title(),
doc.content(),
round(fused.getOrDefault(doc.id(), 0.0)),
doc.chunkIndex(),
doc.metadata()
))
.filter(result -> result.score() > 0.0)
.toList();
}
不用向量库的”向量”
这里的向量是 256 维的哈希向量。每个分词结果按哈希值落到某一维上,哈希的奇偶决定加一还是减一,查询和文档用同样的方法算出向量,再比余弦相似度:
private double[] embed(String text) {
double[] vector = new double[256];
Set<String> grams = new HashSet<>(tokenize(text));
for (String gram : grams) {
int hash = gram.hashCode();
int idx = Math.floorMod(hash, vector.length);
vector[idx] += (hash & 1) == 0 ? 1.0 : -1.0;
}
return vector;
}
它不理解语义,只是把共享的词片段折叠到固定维度上做近似,所以和真正的 embedding 模型不是一个量级。它的价值是零依赖:知识库规模不大、要求能离线跑通整条链路时,不用额外部署向量库。后续可以把 embed 换成真实的 embedding 服务,融合逻辑不用改。
private Map<String, Double> normalize(Map<String, Double> scores) {
double max = scores.values().stream().mapToDouble(Double::doubleValue).max().orElse(0.0);
if (max <= 0.0) {
return scores;
}
Map<String, Double> normalized = new HashMap<>();
scores.forEach((id, score) -> normalized.put(id, score / max));
return normalized;
}
从文档到检索片段
知识入库先将资料拆为片段,再保存文档主体、切片与标题等元数据。默认使用递归切分器,配置的片段规模为 500、重叠为 80;切分器异常时走简单文本切分。启动时把持久化片段加载为内存记录,并生成对应哈希向量。
BM25 的词项频率、文档频率和长度归一化帮助区分常见词与相对稀有的业务词。哈希向量把词片段压入固定维度,余弦相似度提供另一种重叠近似;不同词发生哈希碰撞仍可能增加噪声,没有真正获得语言模型式的语义理解。
两路各按本次候选中的最大分归一化,因此融合分是当前查询下的相对排序依据。不同问题的最高分不能直接横向比较,更不能把某个固定分值理解成统一的回答可信度。
检索之前和之后
/search 与 /chat 里的检索不是直接调用 search。外面还有一层:查询先被改写成多个子查询并行召回,按片段 ID 去重,再由大模型重排。这一层由 tool 包的工具管理器负责,同时加上缓存、等待上限、三态熔断和降级结果。熔断打开时返回降级结果,不会拖垮整条对话链路。
只有业务意图才会触发这一整套检索。问候和闲聊直接跳过。
flowchart TD
Q[原始问题] --> X[保留原问题并改写]
X --> P[并行子查询召回]
P --> D[按片段 ID 去重]
D --> R[模型重排或分数回退]
R --> C[知识上下文]
F[超时、异常或熔断] --> G[带原因的降级片段]
G --> C
原问题始终保留,避免改写漏掉专有名词;各子查询先扩大召回,再按片段 ID 合并。相同片段被多个查询命中只保留一次,当前实现不会因为重复命中而自动提高它的融合权重。模型重排返回的是候选索引顺序,解析失败时使用已有分数排序。
熔断与缓存的边界
工具缓存按查询与 topK 保存结果,五分钟内可以复用。知识新增不会在这段管理器实现中主动清空所有旧查询缓存,因此刚录入资料后相同查询可能短暂读到旧结果;多实例各自持有缓存,也没有统一失效广播。
熔断器达到失败阈值后进入 OPEN,恢复等待后进入 HALF_OPEN,成功再回到 CLOSED。当前半开状态没有限制为只允许单个探测请求;此外底层检索会把异常包装成降级片段,外层不一定将其统计为失败。因此熔断状态只能解释已被当前实现计入的失败,不能当成所有依赖异常的完整计数。
降级片段有固定标识和错误元数据,含义是“未能完成检索”。如果只看结果列表非空,或响应里的知识命中布尔值,可能将这类片段误当作真实文档命中;分析时需要查看片段来源与 fallback 标记。
监控与惩罚分
PerformanceMonitor.collect 每 10 秒运行一次,从编排器取各 Agent 的统计,做两件事:超过阈值就告警,同时算出每个 Agent 的惩罚分写回去。
public void collect() {
monitorCollectionTimer.record(() -> {
monitorCollections.increment();
Map<String, Object> agentStats = orchestrator.stats();
Map<String, Double> penalties = new HashMap<>();
double[] minSuccessRate = {1.0};
agentStats.forEach((key, value) -> {
if (value instanceof Map<?, ?> map) {
double sr = asDouble(map.get("success_rate"), 1.0);
double avg = asDouble(map.get("avg_ms"), 0.0);
if (sr < minSuccessRate[0]) {
minSuccessRate[0] = sr;
agentSuccessRate = sr;
}
if (sr < properties.getMonitor().getSuccessRateThreshold()) {
addAlert("agent_success_rate:" + key, sr, properties.getMonitor().getSuccessRateThreshold());
}
if (avg > properties.getMonitor().getLatencyMsThreshold()) {
addAlert("agent_avg_ms:" + key, avg, properties.getMonitor().getLatencyMsThreshold());
}
penalties.put(key, routingPenalty(sr, avg));
}
});
agentSuccessRate = minSuccessRate[0];
orchestrator.updateRoutingPenalties(penalties);
if (!penalties.isEmpty()) {
addSuggestion("路由权重已根据在线表现调整", "检查 /monitor 中低成功率或高延迟 Agent,必要时优化 prompt 或增加实例。", 7);
}
if (++collectCount % METRIC_PERSIST_EVERY_COLLECTIONS == 0) {
persistAgentMetrics(agentStats);
}
});
}
HTTP 请求指标、工具调用统计和 Agent 处理统计是不同层次。一次 HTTP 请求可能包含意图判断、多个检索子查询、多个 Agent 和校验调用;某一层的成功率良好,不保证整个用户请求成功或延迟可接受。
惩罚分的算法很直接:成功率低于 90% 时按差距加罚,平均延迟超过 3 秒时再按超出部分加罚,总惩罚不超过 0.9:
private double routingPenalty(double successRate, double avgMs) {
double penalty = 0.0;
if (successRate < 0.90) {
penalty += Math.min(0.5, (0.90 - successRate) * 2);
}
if (avgMs > 3000) {
penalty += Math.min(0.4, (avgMs - 3000) / 10000);
}
return Math.min(0.9, penalty);
}
惩罚分用在哪
惩罚分被写进每个 Agent 实例的统计里,参与它的路由得分:
public double routingScore() {
double latencyScore = 1.0 / (1.0 + avgLatencyMs() / 1000.0);
double base = successRate() * 0.7 + latencyScore * 0.3;
return base * Math.max(0.0, 1.0 - monitorPenalty);
}
编排器在同一类型的多个实例里,选路由得分最高的一个:
private Optional<BaseAgent> bestAgent(AgentType agentType) {
return pool.getOrDefault(agentType, List.of()).stream()
.max(Comparator.comparingDouble(a -> a.stats().routingScore()));
}
要说清楚的是它的作用范围:这个得分只用来在同一类型的实例之间挑选,不参与前面”领域打分”那一步,也就是它不会让路由从技术支持改投服务台。而现在每类 Agent 只部署一个实例(见 AgentConfig),所以惩罚分暂时不会改变任何一次路由结果,只会出现在 /monitor 的 monitor_penalty 和 routing_score 字段里。机制本身是完整的:给某一类增加实例后,它就开始起作用。
如何判断一次调优是否有效
检索调优先看正确片段是否进入候选,再看重排是否把它放进最终 topK,最后检查回答是否实际使用了资料。召回不足、重排丢失和回答忽略上下文分别对应不同问题,不能只用最终回答是否“看起来不错”来定位。
监控调优则应固定同一领域、请求类型和统计窗口。降低平均延迟可能只是更多请求走了降级;提高工具成功率也可能来自异常被包装。指标需要与降级标记、路由变化及逐条评测结果一起解释。
