前言
近期基于 FastAPI + LangGraph 自研智能客服 RAG 平台,初期仅使用 Chroma 向量检索存在两大痛点:
- 单纯向量检索关键词召回能力弱,大量业务相关文档漏召,回答准确率偏低;
- 高频重复问答、会话历史频繁查询数据库与向量引擎,接口响应慢、频繁触发大模型 API 限流。
为此搭建一套分层优化架构:ES和Chroma 做粗粒度多路召回、BGE-Rerank 做语义精排、Redis 多层缓存减负,完整覆盖检索、排序、性能优化全流程。本文结合项目源码,拆解整套方案设计、实现细节与线上踩坑经验,所有代码均已开源。
一、整体架构链路
用户问答请求完整执行流程:
markdown
用户提问 → Redis缓存判断
├─ 缓存命中:直接返回历史回答,终止流程
└─ 缓存未命中 → ES 关键词+向量混合粗召回 → 多路文档合并去重
→ BGE-Rerank 相关性打分、过滤低相关分片
→ 截取高分片段拼接上下文送入LLM
→ 生成回答存入Redis缓存,返回前端
各组件分工:
表格
| 组件 | 核心职责 | 数据结构 |
|---|---|---|
| Redis | 会话缓存、问答结果缓存、热门分片缓存 | String、Set,差异化过期时间 |
| Elasticsearch | 全文关键词 + 向量混合粗召回,存储全部知识库分片 | 双路召回+IK分词 |
| BGE-Rerank | 对召回候选文档做语义精细打分、过滤无关内容 | 本地离线推理,限制候选数量控制耗时 |
二、Elasticsearch 混合检索实现
2.1 设计思路
单一向量检索缺少关键词匹配能力,纯文本检索缺少语义理解,采用双路召回逻辑进行检索
- 同时检索文本相似度与向量相似度,两路结果融合,扩大候选文档覆盖范围;
- 限制单次召回 20 条分片,平衡召回完整性与后续重排推理耗时。
2.2 核心简化代码
ini
# 异步ES混合检索示例
async def adual_retrieve(self, query: str, user_id: int, top_k_es=20) -> list[Document]:
skip_filter = (user_id == 0)
retriever = self.get_retriever(user_id)
vector_docs = await retriever.ainvoke(query)
# 异步ES
es_hits = await es_client.abm25_search(
query=query,
user_id=user_id,
top_k=top_k_es,
)
es_docs = []
for hit in es_hits:
doc = Document(
page_content=hit["content"],
metadata={
"source": hit["source"],
"file_md5": hit["file_md5"],
"user_id": hit["user_id"],
"chunk_idx": hit["chunk_idx"],
"_score": hit["_score"]
}
)
es_docs.append(doc)
seen_keys = set()
merged = []
for d in vector_docs + es_docs:
md5 = d.metadata.get("file_md5")
idx = d.metadata.get("chunk_idx")
if md5 and idx is not None:
key = f"{md5}_{idx}"
if key in seen_keys:
continue
seen_keys.add(key)
merged.append(d)
return merged
2.3 踩坑总结
- 无分词器:原生 ES 对中文切割混乱,必须安装 IK 分词;
- 频繁重复检索:高频相同 query 反复请求 ES,造成接口延迟升高,上层依赖 Redis 缓存拦截。
三、BGE-Rerank 重排精排优化
3.1 为什么需要重排
ES 粗召回会返回 20 条候选文档,其中包含大量语义无关、弱相关分片,直接送入大模型会产生两个问题:
- 无关文本占用 Token,增加调用成本;
- 干扰 LLM 判断,容易生成幻觉、答非所问。
重排模型输入「用户问题 + 单条文档」,计算精准相关性分数,过滤低分内容,保留高价值上下文。
3.2 实现逻辑
- ES 召回 20 条文档,提取纯文本列表;
- 批量送入 BGE-rerank-base-v2 模型打分;
- 文本与分数绑定排序,按自定义阈值剔除低相关内容;
- 截取前 5 条高分文档还原完整 Document 对象,拼接上下文。
3.3 核心代码
python
from FlagEmbedding import FlagReranker
class RerankClient:
def __init__(self):
self.ENABLE = True
self.reranker = FlagReranker("BAAI/bge-reranker-base-v2")
def rerank(self, query: str, text_list: list[str], top_k=5, threshold=-5.0):
# 降级兜底
if not self.ENABLE or self.reranker is None or len(text_list) == 0:
return text_list[:top_k]
# 批量打分
pairs = [[query, text] for text in text_list]
scores = self.reranker.compute_score(pairs)
text_score = list(zip(text_list, scores))
# 分数降序
text_score.sort(key=lambda x: x[1], reverse=True)
# 阈值过滤
filter_text = [t for t, s in text_score if s >= threshold]
return filter_text[:top_k]
3.4 关键工程处理
- 异步线程隔离:重排是 CPU 密集计算,使用
asyncio.to_thread执行,不阻塞 FastAPI 异步事件循环; - 自动降级:模型加载失败、内存不足时直接跳过重排,使用原始召回结果,保证服务不中断;
- 候选数量控制:召回上限 20 条,数量过多会大幅增加推理耗时。
四、Redis 多层缓存架构设计
4.1 三级缓存策略
- 对话会话缓存 以会话 ID 为 key,缓存历史对话记录,减少 SQLite 频繁读写,消除数据库锁报错;过期 2 小时。
- 问答结果缓存 对完全一致的用户问题缓存完整回答,规避大模型 API 重复调用、缓解 429 限流;过期 12 小时。
- 热门知识库分片缓存 高频检索文档存入 Redis,避免重复请求 ES,降低检索耗时。
4.2 核心特性
- 连接池复用:全局单例 Redis 客户端,复用连接减少握手开销;
- 差异化过期时间:会话短期失效、知识库分片长期缓存;
- 容错重试:Redis 连接失败自动降级,走原始 ES 检索链路;
- Set 集合去重:缓存文档 ID,避免重复加载相同分片。
五、通用落地经验总结
- RAG 检索分层标准范式:粗召回 (ES) → 精排 (Rerank) → 缓存削峰 (Redis) ,兼顾召回精度与服务性能;
- 分层容错设计是生产必备:向量引擎、重排模型、缓存任一组件异常均有兜底逻辑,不会导致服务不可用;
- 重排不要无限增加候选数量,20 条以内是速度与效果平衡点;
- 缓存核心解决两大问题:数据库压力、大模型 API 限流,是低成本性能优化方案。
六、后续优化方向
- 数据库升级:SQLite 替换为 PostgreSQL,支持高并发多用户访问;
- Redis 集群部署:单机 Redis 扩容为集群,支持分布式多实例项目;
- Rerank 模型轻量化 / 微调:针对业务领域语料微调,进一步提升行业文档匹配精度;
- 接入 LangSmith 全链路监控,可视化缓存命中率、重排耗时、检索召回指标。
结尾
整套检索优化方案完整开源在 Gitee, 在企业级知识库场景中,ES 混合检索 + BGE 重排是提升回答质量的标准方案,搭配 Redis 缓存可低成本解决并发性能瓶颈。 欢迎评论区交流你们在 RAG 落地过程中遇到的检索、缓存相关问题!