专栏第12篇:前面三篇文章分别讲了RAG的离线阶段(文档处理)、在线阶段(混合检索与重排序)和评估优化。但在真实的企业环境中,RAG不是一段Jupyter Notebook脚本,而是一个需要处理并发请求、支持知识库热更新、保证低延迟、还要能流式输出的完整系统。这篇文章把我搞建企业级RAG系统的架构设计、核心模块和踩坑经验分享出来。


目录


一、从Demo到生产:RAG系统面临的真实挑战

很多教程里的RAG是这样的:加载几个PDF,建个向量库,用户提问时检索一下,把结果塞给LLM。这在笔记本上跑通没问题,但放到生产环境会暴露出各种问题。

维度 Demo级RAG 企业级RAG
并发 单用户串行 多用户同时请求
知识更新 手动重新建索引 自动热更新、不停机
延迟 几秒钟可接受 首字延迟要控制在1秒内
输出 一次性返回 流式输出、实时渲染
容错 出错就报错 优雅降级、超时保护
来源追溯 可有可无 必须准确、可点击

二、系统架构全景

知识库更新层

生成层

检索层

查询层

用户请求

查询重写

意图路由

Dense向量检索

BM25关键词检索

RRF融合排序

上下文组装

LLM生成

SSE流式输出

文档变更

MD5指纹检测

增量索引

原子切换

系统分为四层:

  • 查询层:把用户问题转化为更适合检索的形式,并判断意图
  • 检索层: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系统,核心不只是把检索和生成串起来,还要解决工程层面的挑战:

  1. 查询层:查询重写让问题更精准,意图路由让系统更智能,失败时自动兜底
  2. 检索层:Dense + BM25双路并行召回,RRF融合排序,缓存高频查询降低延迟
  3. 生成层:检索和生成职责分离,独立优化、独立迭代,上下文长度可控
  4. 流式输出:SSE实现首字秒出,图片即时渲染提升体验,必须有结束标记
  5. 知识库热更新:MD5指纹检测真实变更,增量索引减少开销,原子切换保证不停机

💡 企业级RAG checklist

  • 路由有兜底策略,异常时默认走知识库
  • 检索双路并行,单路失败不影响整体
  • 流式输出有明确的开始和结束标记
  • 知识库支持热更新,更新期间服务不中断
  • 上下文长度可控,不超LLM窗口限制

参考资源

  • FastAPI StreamingResponse 文档
  • SSE(Server-Sent Events)规范
  • RRF(Reciprocal Rank Fusion)论文
Logo

欢迎加入 MCP 技术社区!与志同道合者携手前行,一同解锁 MCP 技术的无限可能!

更多推荐