我花2个月搭了一个企业级RAG系统:混合检索+智能路由+流式输出的全链路复盘
专栏第12篇:前面三篇文章分别讲了RAG的离线阶段(文档处理)、在线阶段(混合检索与重排序)和评估优化。但在真实的企业环境中,RAG不是一段Jupyter Notebook脚本,而是一个需要处理并发请求、支持知识库热更新、保证低延迟、还要能流式输出的完整系统。这篇文章把我搞建企业级RAG系统的架构设计、核心模块和踩坑经验分享出来。
目录
- 一、从Demo到生产:RAG系统面临的真实挑战
- 二、系统架构全景
- 三、查询层:不只是"把问题丢给检索器"
- 四、检索层:双引擎混合召回的工程实现
- 五、生成层:检索与生成的职责分离
- 六、流式输出:SSE与用户体验
- 七、知识库热更新:不停机更新
- 八、踩过的5个坑
- 九、总结
一、从Demo到生产:RAG系统面临的真实挑战
很多教程里的RAG是这样的:加载几个PDF,建个向量库,用户提问时检索一下,把结果塞给LLM。这在笔记本上跑通没问题,但放到生产环境会暴露出各种问题。
| 维度 | Demo级RAG | 企业级RAG |
|---|---|---|
| 并发 | 单用户串行 | 多用户同时请求 |
| 知识更新 | 手动重新建索引 | 自动热更新、不停机 |
| 延迟 | 几秒钟可接受 | 首字延迟要控制在1秒内 |
| 输出 | 一次性返回 | 流式输出、实时渲染 |
| 容错 | 出错就报错 | 优雅降级、超时保护 |
| 来源追溯 | 可有可无 | 必须准确、可点击 |
二、系统架构全景
系统分为四层:
- 查询层:把用户问题转化为更适合检索的形式,并判断意图
- 检索层:Dense向量 + BM25双路召回,RRF融合排序
- 生成层:上下文组装、LLM生成、流式输出
- 知识库更新层:监控文档变化、增量更新索引、原子切换
三、查询层:不只是"把问题丢给检索器"
用户的问题往往不能直接用于检索,需要经过两层处理。
3.1 查询重写(Query Rewriting)
用户输入可能口语化、有歧义、或者缺少上下文。查询重写用LLM把问题转化成更利于检索的形式。
def rewrite_query(original_query: str, chat_history: list) -> str:
"""
把用户的口语化问题重写为适合向量检索的查询
"""
prompt = f"""基于对话历史,把用户的问题改写成适合向量检索的查询。
要求:
1. 补充指代和省略的上下文
2. 去除口语化表达
3. 保留关键技术术语
对话历史:{chat_history}
用户问题:{original_query}
"""
return llm.invoke(prompt)
示例:
- 原问题:“那个怎么配?”
- 重写后:“Python虚拟环境的配置步骤是什么?”
3.2 意图路由(Intent Router)
不是所有问题都需要走RAG检索。系统需要判断用户的意图,选择不同的处理路径:
| 意图类型 | 处理方式 | 示例 |
|---|---|---|
| 知识查询 | 走RAG检索 + LLM生成 | “怎么配置环境变量?” |
| 工具调用 | 调用外部API/工具 | “帮我查一下今天的天气” |
| 闲聊/问候 | 直接回复,不走检索 | “你好” |
class IntentRouter:
"""意图路由器:决定用户请求走哪条处理通道"""
ROUTES = {
"knowledge": "知识库查询",
"tool": "工具调用",
"chat": "直接对话"
}
def route(self, query: str, chat_history: list) -> str:
prompt = f"""判断用户意图,只能输出以下之一:knowledge/tool/chat
用户问题:{query}
"""
intent = llm.invoke(prompt).strip().lower()
# 兜底:如果模型输出异常,默认走知识库
if intent not in self.ROUTES:
intent = "knowledge"
return intent
⚠️ 重要:路由失败必须有兜底策略。如果LLM判断出错或超时,默认降级到知识库查询,而不是报错。
四、检索层:双引擎混合召回的工程实现
检索层的核心是"双路召回 + RRF融合",这在第10篇已经详细讲过原理。这里重点讲工程实现层面的优化。
4.1 检索流程
class RetrievalEngine:
"""检索引擎:Dense向量 + BM25 双路召回"""
def __init__(self, vector_store, bm25_index):
self.vector_store = vector_store
self.bm25_index = bm25_index
def retrieve(self, query: str, top_k: int = 6) -> list:
# 1. 向量检索
vector_results = self.vector_store.similarity_search(query, k=top_k)
# 2. BM25检索
bm25_results = self.bm25_index.search(query, top_k=top_k)
# 3. RRF融合
fused_results = reciprocal_rank_fusion(
[vector_results, bm25_results],
k=60
)
return fused_results[:top_k]
4.2 工程优化:并发检索
向量检索和BM25检索互不依赖,可以并行执行,降低整体延迟:
import asyncio
async def retrieve_parallel(self, query: str, top_k: int = 6):
# 两个检索任务并行执行
vector_task = asyncio.create_task(
self.vector_store.asimilarity_search(query, k=top_k)
)
bm25_task = asyncio.create_task(
self.bm25_index.asearch(query, top_k=top_k)
)
vector_results, bm25_results = await asyncio.gather(
vector_task, bm25_task
)
return reciprocal_rank_fusion(
[vector_results, bm25_results], k=60
)[:top_k]
4.3 工程优化:检索缓存
高频查询可以缓存结果,避免重复计算Embedding:
from functools import lru_cache
@lru_cache(maxsize=1000)
def cached_retrieve(query_hash: str):
"""用查询文本的hash作为缓存key"""
return retrieval_engine.retrieve(query_hash)
4.4 检索结果后处理
融合排序后,还需要过滤低质量结果:
def post_filter(results: list, min_score: float = 0.01) -> list:
"""过滤RRF分数过低的文档"""
return [r for r in results if r.score >= min_score]
五、生成层:检索与生成的职责分离
这是架构设计中最关键的一个决策:把检索和生成分成两个独立模块。
5.1 为什么要分离?
| 方面 | 耦合在一起 | 分离后 |
|---|---|---|
| 优化维度 | 互相牵制 | 检索优化不影响生成,生成优化不影响检索 |
| 测试 | 难以单独测试 | 可以独立测试检索召回率和生成质量 |
| 迭代 | 改一处可能影响另一处 | 模块独立迭代 |
| 复用 | 检索逻辑和生成逻辑绑死 | 检索模块可被其他场景复用 |
5.2 模块职责划分
+---------------------+ +---------------------+
| 检索模块 | | 生成模块 |
| (rag_core) | --> | (llm_generator) |
+---------------------+ +---------------------+
| - 查询重写 | | - 上下文组装 |
| - 多路检索 | | - 提示词构建 |
| - RRF融合 | | - LLM调用 |
| - 结果过滤 | | - 流式输出 |
+---------------------+ +---------------------+
5.3 上下文组装
检索模块返回文档列表,生成模块负责把文档组装成LLM可用的上下文:
def build_context(query: str, documents: list, max_tokens: int = 3000) -> str:
"""
把检索结果组装成上下文,控制总长度
"""
context_parts = []
total_length = 0
for doc in documents:
# 估算token数(粗略:1个汉字约1个token)
doc_text = f"[来源:{doc.metadata.get('source', '未知')}]\n{doc.page_content}\n\n"
doc_length = len(doc_text)
if total_length + doc_length > max_tokens:
break
context_parts.append(doc_text)
total_length += doc_length
context = "".join(context_parts)
return f"""基于以下参考资料回答问题:
{context}
用户问题:{query}
请基于参考资料回答,如果资料中没有相关信息,请明确说明。"""
5.4 来源追溯
生成回答时,必须保留文档来源信息,让用户知道答案来自哪里:
def generate_with_sources(query: str, documents: list):
# 给每篇文档编号
for i, doc in enumerate(documents, 1):
doc.metadata["cite_id"] = f"[{i}]"
# 构建带编号的上下文
context = build_context(query, documents)
# LLM生成(流式)
for chunk in llm.stream(context):
yield chunk
# 返回来源列表
sources = [
{
"cite_id": doc.metadata["cite_id"],
"source": doc.metadata.get("source", "未知"),
"score": doc.metadata.get("score", 0)
}
for doc in documents
]
yield {"type": "sources", "data": sources}
六、流式输出:SSE与用户体验
企业级RAG不能等LLM全部生成完再返回——用户看到"加载中"超过3秒就会焦虑。流式输出(Streaming)是必选项。
6.1 SSE流式响应
SSE(Server-Sent Events)是一种服务器向客户端推送数据的技术,天然适合流式场景:
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
import json
app = FastAPI()
@app.post("/chat")
async def chat(request: ChatRequest):
async def event_stream():
# 1. 查询重写
rewritten_query = rewrite_query(request.query, request.history)
# 2. 检索
documents = await retrieval_engine.retrieve_parallel(rewritten_query)
# 3. 生成(流式)
async for chunk in llm.astream(
build_context(request.query, documents)
):
# SSE格式:data: {...}\n\n
yield f"data: {json.dumps({'type': 'content', 'text': chunk})}\n\n"
# 4. 来源信息
sources = extract_sources(documents)
yield f"data: {json.dumps({'type': 'sources', 'data': sources})}\n\n"
# 5. 结束标记
yield f"data: {json.dumps({'type': 'done'})}\n\n"
return StreamingResponse(
event_stream(),
media_type="text/event-stream"
)
6.2 前端逐帧渲染
前端收到SSE流后,需要逐帧渲染,而不是等全部内容:
const eventSource = new EventSource('/chat');
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'content') {
// 逐字追加到回答区域
appendText(data.text);
} else if (data.type === 'sources') {
// 渲染来源卡片
renderSourceCards(data.data);
} else if (data.type === 'done') {
eventSource.close();
}
};
6.3 图片即时渲染
如果知识库中有图片(如架构图、流程图),流式输出时需要特殊处理:
# 检测到Markdown图片标记时,发送特殊事件
if contains_image_markdown(chunk):
yield f"data: {json.dumps({'type': 'image', 'url': extract_image_url(chunk)})}\n\n"
前端收到 type: "image" 事件时,直接渲染成 <img> 标签,而不是纯文本。
七、知识库热更新:不停机更新
企业知识库是动态变化的——文档会新增、修改、删除。系统必须支持不停机热更新。
7.1 更新检测:MD5指纹
为每篇文档计算MD5指纹,对比前后状态判断是否需要更新:
import hashlib
def compute_doc_fingerprint(doc_path: str) -> str:
"""计算文档内容的MD5指纹"""
with open(doc_path, 'rb') as f:
return hashlib.md5(f.read()).hexdigest()
class KnowledgeBaseMonitor:
"""监控知识库文件变化"""
def __init__(self, kb_dir: str):
self.kb_dir = kb_dir
self.fingerprints = {} # 路径 -> MD5
def scan_changes(self) -> dict:
"""扫描变更:新增、修改、删除"""
current_files = set()
changes = {"added": [], "modified": [], "deleted": []}
for doc_path in list_documents(self.kb_dir):
current_files.add(doc_path)
current_md5 = compute_doc_fingerprint(doc_path)
if doc_path not in self.fingerprints:
changes["added"].append(doc_path)
elif self.fingerprints[doc_path] != current_md5:
changes["modified"].append(doc_path)
self.fingerprints[doc_path] = current_md5
# 检测删除
for old_path in set(self.fingerprints.keys()) - current_files:
changes["deleted"].append(old_path)
del self.fingerprints[old_path]
return changes
7.2 增量索引
向量库支持真正的增量更新,BM25 由于内部需要重新计算所有文档的频率统计,不支持多单文档的增量添加,必须全量重建。因此实际策略是:只解决向量库的增量问题,BM25 一律全量重建,但结合原子切换保证重建期间不影响查询:
def incremental_update(changes: dict, vector_store, all_docs: list):
"""向量库增量更新;BM25 必须全量重建"""
# 向量库:支持增量删除 + 增量添加
for doc_path in changes["deleted"] + changes["modified"]:
doc_id = get_doc_id(doc_path)
vector_store.delete(doc_id)
for doc_path in changes["added"] + changes["modified"]:
docs = load_and_split(doc_path)
vector_store.add_documents(docs)
# BM25:不支持增量,必须用全量文档重建
# all_docs 是当前知识库的完整文档列表
new_bm25_index = BM25Index(all_docs)
return new_bm25_index # 调用方通过原子切换更新活跃索引
7.3 并发安全与索引切换
索引重建期间,不能影响正在进行的查询。可以用 asyncio.Lock(普通互斥锁) 保证同时只有一个更新任务在运行,读操作不加锁——靠的是先在锁内构建好新索引,最后一次性替换对象引用。Python GIL 保证引用赋値是原子操作,切换前的查询读旧索引,切换后读新,不会出现中间状态。
class RAGCore:
def __init__(self):
self._index = None
self._bm25_index = None
self._init_lock = asyncio.Lock() # 更新锁(互斥)
async def search(self, query: str):
"""查询不加锁,直接读当前引用"""
index = self._index # 先拿到引用,即使后续更新也不影响本次查询
return await index.query(query)
async def incremental_update(self, new_docs: list):
"""更新加互斥锁:避免并发重建"""
async with self._init_lock:
# 1. 锁内构建新索引(耗时操作)
new_bm25 = BM25Index(all_docs) # BM25 全量重建
new_index = await build_vector_index(new_docs) # 向量增量刷新
# 2. 原子替换引用(GIL 保证切换原子性)
self._bm25_index = new_bm25
self._index = new_index
💡 关键点:读操作不加锁,重建期间新查询不会被阻塞,读到的是旧索引。如果需要差异化对并发更新的控制(比如限制并发更新次数、排队),可以升级为读写锁(
aiorwlock)。
八、踩过的5个坑
坑1:路由失败没有兜底,直接报错
问题:LLM路由判断异常时,系统直接抛错,用户体验极差。
解决:路由必须设置默认降级策略——异常时自动走知识库查询通道。
坑2:BM25索引和向量库数据不一致
问题:增量更新时只更新了向量库,忘了同步更新BM25索引,导致两套索引返回的结果完全不同。
解决:增量更新必须同时更新两套索引,用一个统一变更日志保证一致性。
坑3:SSE流没有结束标记,前端一直转圈
问题:SSE流输出完后没有发送 done 事件,前端不知道何时关闭连接。
解决:SSE流最后必须发送明确的结束标记,前端收到后立即关闭EventSource。
坑4:知识库指纹用文件修改时间,被Git操作误触发
问题:最初用文件的 mtime(修改时间)判断变更,但Git clone/pull会批量修改时间,导致不必要的全量重建。
解决:改用内容MD5指纹,只检测真实内容变化,忽略文件系统时间戳变化。
坑5:上下文长度超限,LLM忽略后面的文档
问题:检索返回6篇文档,每篇都很长,拼接后超过LLM的上下文窗口,导致后面的文档被截断。
解决:上下文组装时严格控制token预算,超长文档做摘要,确保所有检索结果都能进入上下文。
九、总结
搭建一个企业级RAG系统,核心不只是把检索和生成串起来,还要解决工程层面的挑战:
- 查询层:查询重写让问题更精准,意图路由让系统更智能,失败时自动兜底
- 检索层:Dense + BM25双路并行召回,RRF融合排序,缓存高频查询降低延迟
- 生成层:检索和生成职责分离,独立优化、独立迭代,上下文长度可控
- 流式输出:SSE实现首字秒出,图片即时渲染提升体验,必须有结束标记
- 知识库热更新:MD5指纹检测真实变更,增量索引减少开销,原子切换保证不停机
💡 企业级RAG checklist:
- 路由有兜底策略,异常时默认走知识库
- 检索双路并行,单路失败不影响整体
- 流式输出有明确的开始和结束标记
- 知识库支持热更新,更新期间服务不中断
- 上下文长度可控,不超LLM窗口限制
参考资源:
- FastAPI StreamingResponse 文档
- SSE(Server-Sent Events)规范
- RRF(Reciprocal Rank Fusion)论文
更多推荐


所有评论(0)