引言

检索增强生成(Retrieval Augmented Generation, RAG)自2020年提出以来,已经成为大语言模型落地最主流的工程范式。不同于微调(Fine-tuning),RAG 通过将外部知识库与 LLM 的推理能力结合,实现了低成本、高时效、可溯源的知识注入。然而,从 Demo 级的「能跑」到生产级的「好用」,中间隔着无数工程细节。

本文将从架构设计、分块策略、Embedding 选型、向量检索、混合检索、重排序、评估体系到生产部署,完整拆解一个生产级 RAG 系统的构建过程。每一节都包含具体代码实现与工程经验总结。

一、RAG 架构总览

现代生产级 RAG 系统远不止「检索→拼接→生成」三步流水线。完整的架构包含以下核心模块:

模块职责关键决策
文档摄取多格式解析、清洗、分块分块大小、重叠策略
Embedding 引擎文本向量化模型选型、维度权衡
向量存储ANN 索引与元数据管理索引类型、一致性级别
查询理解意图识别、查询改写HyDE、Multi-query
检索层密集/稀疏/混合检索BM25 + 向量融合
重排序精排跨编码器BGE-reranker、Cohere Rerank
生成层上下文组装、LLM 调用Prompt 模板、Token 预算
评估模块离线/在线指标追踪Faithfulness、Relevance
运维层监控、降级、A/B 测试延迟、成本、可用性

二、文档分块策略:分块决定上限

分块(Chunking)是 RAG 系统中看似简单却最影响最终效果的环节。分块的好坏直接决定了检索器能否精准定位到与用户问题相关的上下文片段。

2.1 固定大小分块

def fixed_size_chunk(text: str, chunk_size: int = 512, overlap: int = 64) -> list[str]:
    chunks = []
    start = 0
    while start < len(text):
        end = min(start + chunk_size, len(text))
        chunks.append(text[start:end])
        start += chunk_size - overlap
    return chunks

优点是实现简单、计算快,但缺点是切断了语义连贯的段落,导致检索时匹配到不完整的上下文。

3.2 递归字符分块

这是目前最常用的默认策略。递归地使用多个分隔符(段落→句子→词语→字符)进行切分,优先在语义边界处断开。

def recursive_chunk(text: str, chunk_size: int = 512,
                     separators: list[str] = None) -> list[str]:
    if separators is None:
        separators = ["\n\n", "\n", "。", "!", "?", " ", ""]
    if len(text) <= chunk_size:
        return [text]
    
    sep = separators[0]
    parts = text.split(sep)
    chunks = []
    current = ""
    for part in parts:
        if len(current) + len(part) + len(sep) > chunk_size:
            if current:
                chunks.append(current.strip())
            current = part
        else:
            current += (sep + part) if current else part
    if current:
        chunks.append(current.strip())
    
    # 对过大的 chunk 递归使用下一个分隔符
    result = []
    next_seps = separators[1:]
    for chunk in chunks:
        if len(chunk) > chunk_size and next_seps:
            result.extend(recursive_chunk(chunk, chunk_size, next_seps))
        else:
            result.append(chunk)
    return [c for c in result if c]

3.3 语义分块(推荐生产使用)

语义分块使用 Embedding 模型计算相邻句子间的语义距离,在语义突变点进行切分。这种方法能确保每个 Chunk 内部语义高度内聚。

import numpy as np
from sentence_transformers import SentenceTransformer

def semantic_chunk(text: str, model: SentenceTransformer,
                    threshold_percentile: int = 80,
                    min_chunk_size: int = 128,
                    max_chunk_size: int = 512) -> list[str]:
    sentences = split_sentences(text)  # 按句号、问号等切句
    embeddings = model.encode(sentences, normalize_embeddings=True)
    
    # 计算相邻句子余弦距离
    distances = []
    for i in range(len(embeddings) - 1):
        sim = np.dot(embeddings[i], embeddings[i+1])
        distances.append(1 - sim)
    
    threshold = np.percentile(distances, threshold_percentile)
    
    chunks, current_sentences = [], []
    for i, sent in enumerate(sentences):
        current_sentences.append(sent)
        
        is_break = distances[i] >= threshold if i < len(distances) else True
        current_text = " ".join(current_sentences)
        
        if (is_break and len(current_text) >= min_chunk_size) or len(current_text) >= max_chunk_size:
            chunks.append(current_text)
            current_sentences = []
    
    if current_sentences:
        chunks.append(" ".join(current_sentences))
    return chunks

3.4 分块大小选择指南

场景推荐分块大小重叠说明
FAQ/短答案256-512字20%精准匹配为主
技术文档512-1024字15%平衡上下文与精度
长文问答1024-2048字10%保留完整论述段落
法律/合同按条款切分0%保持条款独立性

三、Embedding 模型选型

Embedding 是 RAG 系统的基石。一个差的 Embedding 模型会让后续所有检索优化事倍功半。

3.1 主流模型对比

模型维度语种特点MTEB得分
text-embedding-3-large3072(可降维)多语种商业闭源,通用最强64.6
BAAI/bge-m31024多语种开源最强,支持稀疏+稠密63.5
Cohere embed-v31024多语种商业,压缩效率高64.0
mistral-embed1024多语种Mistral出品,推理快62.8
jina-embeddings-v31024多语种8K上下文,支持LoRA62.3

3.2 降维优化

高维 Embedding 虽检索精度更高,但带来了巨大的存储和计算开销。实际生产中常用 PCA 或 Matryoshka 训练进行降维。

def pca_reduce_embeddings(embeddings: np.ndarray, target_dim: int = 512):
    from sklearn.decomposition import PCA
    pca = PCA(n_components=target_dim)
    reduced = pca.fit_transform(embeddings)
    print(f"保留方差比: {sum(pca.explained_variance_ratio_):.4f}")
    # 剩余归一化
    norms = np.linalg.norm(reduced, axis=1, keepdims=True)
    return reduced / norms

经验值:text-embedding-3-large 从 3072 降到 1024,精度损失通常不超过 2%;降到 512 约损失 5-8%。

四、向量存储与索引

4.1 主流向量数据库对比

数据库规模索引类型特点
Milvus十亿级HNSW/IVF分布式、成熟生态
Qdrant十亿级HNSWRust编写、性能高、过滤强
Weaviate十亿级HNSW/graphql混合检索、模块化
pgvector百万级IVFFlat/HNSW基于PG、无需新组件
ElasticSearch十亿级HNSW混合搜索、已有ES场景

4.2 HNSW 索引调优

# Qdrant HNSW 配置示例
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, HnswConfigDiff

client.create_collection(
    collection_name="knowledge_base",
    vectors_config=VectorParams(size=1024, distance=Distance.COSINE),
    hnsw_config=HnswConfigDiff(
        m=16,              # 每个节点的连接数,增大提升精度
        ef_construct=200,  # 构建时搜索宽度,增大提升精度减慢构建
        full_scan_threshold=10000  # 低于此数量使用暴力搜索
    )
)

4.3 元数据过滤设计

生产系统中,纯向量搜索通常不够。需要结合元数据过滤实现「权限隔离+时效控制+来源筛选」的三级过滤体系。

from qdrant_client.models import Filter, FieldCondition, MatchValue

def build_filter(tenant_id: str, valid_after: str = None,
                 doc_types: list[str] = None) -> Filter:
    must = [
        FieldCondition(key="tenant_id", match=MatchValue(value=tenant_id))
    ]
    if valid_after:
        must.append(FieldCondition(
            key="updated_at",
            range=Range(gte=valid_after)  # 时间范围过滤
        ))
    if doc_types:
        must.append(FieldCondition(
            key="doc_type",
            match=MatchAny(any=doc_types)
        ))
    return Filter(must=must)

五、混合检索架构

单一检索方式各有局限:密集检索擅长语义理解但不理解术语精确匹配;稀疏检索(BM25)擅长关键词匹配但缺乏语义理解。混合检索结合两者优势。

5.1 倒数排名融合(RRF)

def reciprocal_rank_fusion(ranked_lists: list[list[str]],
                            k: int = 60) -> list[str]:
    """
    多路检索结果的通用融合算法
    k: 平滑参数,增大可减小排名靠前结果的优势
    """
    score_map = {}
    for ranked in ranked_lists:
        for rank, doc_id in enumerate(ranked):
            if doc_id not in score_map:
                score_map[doc_id] = 0
            score_map[doc_id] += 1.0 / (k + rank + 1)
    
    return sorted(score_map.keys(),
                  key=lambda x: score_map[x], reverse=True)

5.2 加权线性融合

def weighted_fusion(dense_results: list[tuple[str, float]],
                     sparse_results: list[tuple[str, float]],
                     alpha: float = 0.7) -> list[tuple[str, float]]:
    """
    alpha: 密集检索权重,1-alpha 为稀疏检索权重
    实际生产中 alpha 通常取 0.6-0.8
    """
    dense_scores = {doc_id: score for doc_id, score in dense_results}
    sparse_scores = {doc_id: score for doc_id, score in sparse_results}
    
    all_docs = set(dense_scores.keys()) | set(sparse_scores.keys())
    
    # Min-Max 归一化各通道分数
    def normalize(scores: dict) -> dict:
        if not scores:
            return {}
        min_s, max_s = min(scores.values()), max(scores.values())
        range_s = max_s - min_s if max_s != min_s else 1.0
        return {k: (v - min_s) / range_s for k, v in scores.items()}
    
    dense_norm = normalize(dense_scores)
    sparse_norm = normalize(sparse_scores)
    
    fused = []
    for doc_id in all_docs:
        score = alpha * dense_norm.get(doc_id, 0) + (1 - alpha) * sparse_norm.get(doc_id, 0)
        fused.append((doc_id, score))
    
    return sorted(fused, key=lambda x: x[1], reverse=True)

六、重排序:精排的最后一公里

重排序器(Reranker)使用交叉编码器对第一阶段检索出的候选集进行精确打分。虽然延迟更高,但精度提升显著,是生产系统的标配组件。

6.1 主流 Reranker 对比

模型架构延迟特点
bge-reranker-v2-m3Encoder-Only~50ms/doc开源最强,支持多语种
Cohere Rerank 3.5Encoder-Only~30ms/doc商业,多语言好
bce-reranker-baseEncoder-Only~20ms/doc中英双语,速度快
flashrank轻量模型~5ms/doc最快但精度略低

6.2 重排序实现

class RerankPipeline:
    def __init__(self, reranker_model):
        self.reranker = reranker_model  # 如 BGERerankerV2M3
    
    def rerank(self, query: str, documents: list[str],
               top_k: int = 10, first_stage_k: int = 50) -> list[tuple[str, float]]:
        # 第一阶段:混合检索召回 top-50
        first_stage = self._hybrid_retrieve(query, first_stage_k)
        
        # 第二阶段:重排序精排
        pairs = [(query, doc) for doc in first_stage]
        scores = self.reranker.compute_score(pairs)
        
        scored_docs = list(zip(first_stage, scores))
        scored_docs.sort(key=lambda x: x[1], reverse=True)
        return scored_docs[:top_k]

七、生产级评估体系

7.1 离线评估指标

指标计算方式合格线
Hit Rate@K正确答案是否在 top-K 中>0.9 (@5)
MRR@K第一个正确答案的倒数排名均值>0.85 (@10)
nDCG@K考虑位置的加权相关性>0.80 (@10)
Faithfulness生成内容是否忠实于检索上下文>0.95
Relevance回答与问题的相关性评分>0.90

7.2 端到端 RAG 评估框架

# 使用 RAGAS 框架
from ragas import evaluate
from ragas.metrics import faithfulness, answer_relevancy, context_recall, context_precision
from datasets import Dataset

def evaluate_rag_pipeline(questions, ground_truths, retrieved_contexts, generated_answers):
    data = Dataset.from_dict({
        "question": questions,
        "answer": generated_answers,
        "contexts": [[c] if isinstance(c, str) else c for c in retrieved_contexts],
        "ground_truth": ground_truths
    })
    
    result = evaluate(
        data,
        metrics=[faithfulness, answer_relevancy, context_recall, context_precision]
    )
    return result

八、生产部署经验

8.1 延迟优化

  • Embedding 缓存:对高频问题缓存向量,命中率可达 40-60%
  • 批量重排序:使用 ONNX/TensorRT 量化模型加速推理
  • 预检索策略:对定时更新的知识库预计算检索结果
  • 流式输出:LLM 侧使用 Server-Sent Events 实现 Token 级流式响应
  • CDN 缓存:对热点问题组装好的 Prompt+上下文做 HTTP 级缓存

8.2 高可用设计

# 降级策略示例
async def rag_query_with_fallback(query: str, tenant_id: str) -> str:
    try:
        # 正常路径:完整 RAG 流程
        results = await asyncio.wait_for(
            hybrid_search(query, tenant_id), timeout=2.0
        )
        return await generate_with_context(query, results)
    except asyncio.TimeoutError:
        # 降级路径:仅使用缓存或简化检索
        cached = await search_cache(query)
        if cached:
            return await generate_with_context(query, cached)
        return await generate_without_rag(query)  # 最终兜底

8.3 监控体系

监控维度关键指标告警阈值
检索质量平均召回率< 0.85
响应延迟端到端 P99> 5s
生成质量Faithfulness 均值< 0.90
成本Token/day 消耗超预算 120%
可用性错误率> 1%

九、完整 Pipeline 示例

class ProductionRAGPipeline:
    """生产级 RAG Pipeline 完整封装"""
    
    def __init__(self, config: RAGConfig):
        self.chunking_strategy = config.chunking
        self.embedder = load_embedding_model(config.embedding_model)
        self.vdb = QdrantClient(url=config.qdrant_url)
        self.sparse_retriever = BM25Retriever(index_path=config.bm25_index)
        self.reranker = load_reranker(config.reranker_model)
        self.llm = ChatOpenAI(model=config.llm_model, temperature=0)
    
    def ingest_documents(self, docs: list[Document], tenant_id: str):
        """文档摄取:解析→分块→向量化→入库"""
        all_chunks = []
        for doc in docs:
            chunks = self.chunking_strategy.split(doc.text)
            all_chunks.extend([
                Chunk(text=c, metadata={
                    "tenant_id": tenant_id,
                    "doc_id": doc.id,
                    "doc_type": doc.doc_type,
                    "updated_at": doc.updated_at
                }) for c in chunks
            ])
        
        texts = [chunk.text for chunk in all_chunks]
        embeddings = self.embedder.encode(texts, batch_size=64, show_progress=True)
        
        # 批量写入向量库
        points = [
            PointStruct(id=chunk.uuid, vector=emb.tolist(), payload=chunk.metadata)
            for chunk, emb in zip(all_chunks, embeddings)
        ]
        self.vdb.upsert(collection_name=f"rag_{tenant_id}", points=points)
        
        # 同时更新 BM25 索引
        self.sparse_retriever.add(texts, tenant_id=tenant_id)
    
    async def query(self, question: str, tenant_id: str,
                    stream: bool = False) -> str:
        """端到端查询:检索→重排序→生成"""
        # 1. 并行执行两路检索
        dense_future = asyncio.create_task(
            self._dense_search(question, tenant_id)
        )
        sparse_future = asyncio.create_task(
            self._sparse_search(question, tenant_id)
        )
        dense_results, sparse_results = await asyncio.gather(dense_future, sparse_future)
        
        # 2. 融合 + 重排序
        fused = weighted_fusion(dense_results, sparse_results, alpha=0.7)
        top_candidates = fused[:50]
        reranked = self.reranker.rerank(question, [doc for doc, _ in top_candidates])
        final_context = [doc for doc, _ in reranked[:10]]
        
        # 3. 组装 Prompt
        prompt = self._build_prompt(question, final_context)
        
        # 4. LLM 生成
        if stream:
            async for chunk in self.llm.astream(prompt):
                yield chunk.content
        else:
            response = await self.llm.ainvoke(prompt)
            return response.content

十、常见陷阱与最佳实践

  1. 分块过小导致上下文碎片化:建议技术文档最小分块不低于 256 字,确保包含完整概念
  2. 忽略时态数据:对法律法规、产品规格等时效性强的内容纳入时间过滤,定期重建过期 Chunk 的 Embedding
  3. Token 预算溢出:检索结果按 Token 计数而非字数控制,预留至少 20% Buffer 给系统 Prompt 和模型输出
  4. Embedding 模型与服务模型不匹配:重新训练或更换 Embedding 模型后,必须全量重建向量索引
  5. 过度追求召回率:检索过多低质量上下文会稀释 LLM 注意力,反而降低生成质量。一般 Top-5 到 Top-10 是最佳区间
  6. 忽略可观测性:每一跳的检索延迟、召回率、重排序分数都应记录到 Tracing 系统(如 LangSmith、Phoenix/Langfuse)

结语

构建一个生产级 RAG 系统是一场没有终点的迭代。从分块策略到 Embedding 选型,从混合检索到重排序精排,每一个环节的优化都能带来可量化的质量提升。

最重要的是:没有万能的配置,一切取决于你的数据特征和用户场景。建立评估体系、持续追踪指标、用数据驱动迭代,才是 RAG 系统持续保持高质量的真正秘诀。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部