
快速阅读(30 秒速览)
- EasyRAG 的 QueryEngine 支持 7 种查询模式:Naive / Local / Global / Hybrid / Mix / Structure / Bypass
- 多路召回用 RRF(Reciprocal Rank Fusion)融合:只看排名不看分数,天然免疫"不同来源分数量级不同"的问题
- Rerank 是可选增强:调用失败自动降级为原始顺序,重排挂了不影响主流程
- 查询缓存(LRU)+ SSE 流式输出:相同问题秒回,长答案逐字吐出
- 模式选择建议:默认 Mix,闲聊用 Bypass,结构化长文档试 Structure
前情提要:上一篇讲了护栏在生成流程中的位置。这篇看护栏上游的核心——检索引擎本身。
一个问题,七种答法
同一个问题"服务器支持哪些 RAID 级别",不同模式下走的路完全不同:
- Naive:只做向量检索,快而糙;
- Local:从问题实体出发做图扩展(第 3 篇);
- Global:图的全局视角,回答总结性问题;
- Hybrid:向量 + 关键词双路召回;
- Mix:向量 + 关键词 + 图,三路全开;
- Structure:走文档结构树,按章节定位;
- Bypass:跳过一切检索直接问模型(闲聊兜底)。
为什么不做一个"最强模式"通吃?因为每种模式的成本和延迟不同,而问题的价值密度也不同。闲聊用 Mix 是浪费,多跳问题用 Naive 是自杀。把选择权交给调用方(或查询路由),才是工程化做法。
一次查询的完整旅程
客户端请求(mode, top_k, stream…)
↓
① 缓存检查(非 bypass/非调试模式)
→ 命中 LRU 缓存 → 直接返回,全程 0 次外部调用
↓ 未命中
② (可选)QueryRouter 查询路由
→ 复杂问题分解为子问题 / 判断"无需检索"直接放行
↓
③ 多路召回
→ 向量检索:VectorStorage.query(支持时间范围、biz_id 过滤)
→ 关键词检索:PG tsvector 或 Tantivy
→ 图扩展:实体 → 邻居 → 关系(批量接口)
↓
④ RRF 融合(多路结果合并排序)
↓
⑤ (可选)Rerank 精排 —— 失败则降级为 ④ 的顺序
↓
⑥ 证据评分(护栏 1)→ 构造 Prompt → LLM 流式生成
↓
⑦ 声明验证 + 引用校验(护栏 2/3)
↓
⑧ 写缓存(拒答不写)→ SSE 返回

RRF 融合:为什么不用加权分数
多路召回后必须合并排序。直觉做法是加权:最终分 = 0.6×向量分 + 0.4×关键词分。
这是错的。 向量余弦相似度在 0~1,RRF 前的关键词 BM25 分数可能是 15.7,图扩展的权重又是另一套量纲。直接相加等于让量纲大的那路说了算,权重调到天荒地老也调不平。
RRF(Reciprocal Rank Fusion)的思路干脆利落——只看排名,不看分数:
RRF_score(doc) = Σ 1 / (k + rank_i(doc))
每一路
- 某文档在第 i 路排第 1 名,贡献 1/(k+1);排第 10 名,贡献 1/(k+10);
- k 是平滑常数(EasyRAG 可配置,常用 60),防止头部排名差距过大;
- 多路都召回的文档分数累加,天然奖励"多路共识"。
优点:量纲免疫、零调参(k 不敏感)、实现五行代码。这也是 EasyRAG Hybrid/Mix 模式的标准做法。
Rerank:精排,但要可降级
RRF 解决"谁该排前面",Rerank 解决"排得够不够准"。Cross-encoder 重排把(问题, 块)配对打分,精度远高于双塔向量相似度——代价是一次网络调用。
EasyRAG 对 Rerank 的态度:锦上添花,绝不阻塞。
// easyrag-llm/src/rerank.rs 的核心语义:
// 调用成功 → 用重排分数覆盖排序,写回每块的 rerank_score
// 调用失败 → tracing::warn 记日志,返回原始顺序,主流程继续
rerank_score 写回块元数据还有个副作用红利:上一篇的证据评分快路径直接复用这些分数——一次调用,两处受益。
缓存:哪些问题值得缓存
查询级 LRU 缓存(QueryCache,手写有界实现:HashMap + VecDeque 维护访问顺序,容量 256 条),规则:
- 缓存键 = 问题 + 模式 + 关键参数(top_k 等);
- Bypass 模式不缓存(对话上下文每次不同);
- 拒答不写缓存——用户补传了文档再问同样的问题,必须能拿到新答案;
- 命中缓存的查询零外部调用,延迟从秒级降到毫秒级。
顺带区分一下:moka 缓存用在更下游的 LLM 响应缓存(easyrag-llm/src/cache.rs,Caffeine 语义的本地缓存)——查询缓存管"整个答案",LLM 缓存管"单次模型调用",两层各管各的。
流式输出:首字延迟的战争
生成阶段用 complete_stream 产出 token 流,server 层转成 SSE:
// axum SSE:每个 token 包一个事件推给前端
let events = chunks.map(move |chunk| {
Ok::<_, Infallible>(Event::default().json_data(frame).unwrap_or_default())
});
Sse::new(events.boxed())
用户体感上,"等 8 秒看到全文"和"0.8 秒开始出字"完全是两个产品。流式不是性能优化,是体验优化。
模式选型速查表
| Naive | 1(向量) | 最低 | 简单事实问答、低延迟场景 |
| Hybrid | 2(向量+关键词) | 低 | 关键词重要的场景(型号、错误码) |
| Local | 图扩展 | 中 | 实体关系问题 |
| Global | 图全局 | 中 | 总结性问题 |
| Mix | 3(全开) | 较高 | 生产默认 |
| Structure | 结构树 | 中 | 章节清晰的长文档 |
| Bypass | 0 | 最低 | 闲聊、无需检索 |
一个实用的决策顺序:先跑 Mix 建立基线 → 看评测报告(第 7 篇)找短板 → 按短板降配或加配。别一上来就追求全开,成本和延迟都是真金白银。
踩坑记录
坑 1:关键词检索和向量检索的结果重复占坑。 同一个块两路都召回,合并时当成两个候选。修复:融合前按块 ID 去重,保留更优的那路分数。
坑 2:Rerank 超时拖垮整体延迟。 重排服务偶尔抖动 5 秒。修复:给 rerank 设置独立超时,超时即降级——任何可选环节的故障都不允许升级为必选环节的延迟。
坑 3:缓存毒化。 调整了知识库后旧缓存还在返回过期答案。修复:文档变更时失效相关缓存;调试模式强制绕过缓存。
写在最后
查询引擎的设计哲学可以总结成三句话:
下一篇回答一个灵魂拷问:你说你的方案好,证据呢?——Golden Set 评测体系。
你的检索用了几路召回?RRF 还是加权?评论区过过招。
本文为《手撸一个生产级 RAG:EasyRAG 实战系列》第 6 篇。上一篇:三层防幻觉护栏 | 下一篇:Golden Set 评测
开源仓库:haibingzhao/easyrag —— 欢迎 Star、Fork、提 Issue 和 PR。
网硕互联帮助中心






评论前必须登录!
注册