RAG 流水线全景

一个生产级 RAG 系统远不止"Embedding + 向量检索 + LLM 生成"这么简单。完整的优化链路:

用户查询 → 查询改写 → 多路召回 → 重排序 → 上下文压缩 → LLM 生成 → 流式输出
                ↑                              ↓
              缓存                          引用标注

每个环节都有优化空间。下面逐一拆解。

1. 分块策略

分块决定了文档被切分成什么粒度的片段,直接影响检索精度。

固定长度分块

最简单的策略,按固定 token 数切分:

from langchain.text_splitter import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=512,
    chunk_overlap=64,
    separators=["\n\n", "\n", "。", "!", "?", ",", " ", ""]
)

chunks = splitter.split_text(long_document)

chunk_overlap 很关键:64-128 的重叠可以避免句子被截断导致语义丢失。但重叠太大会增加存储和检索冗余。

语义分块

按语义完整性切分,而非固定长度:

from langchain.text_splitter import SemanticChunker
from langchain.embeddings import HuggingFaceEmbeddings

embeddings = HuggingFaceEmbeddings(model_name="BAAI/bge-large-zh-v1.5")
splitter = SemanticChunker(
    embeddings,
    breakpoint_threshold_type="percentile",  # 或 "standard_deviation"
    breakpoint_threshold_amount=95
)

chunks = splitter.split_text(long_document)

语义分块在文档结构复杂时效果更好,但计算成本高(每句话都要算 Embedding)。

结构感知分块

利用文档结构(Markdown 标题、HTML 标签)分块:

from langchain.text_splitter import MarkdownHeaderTextSplitter

headers_to_split_on = [
    ("#", "Header 1"),
    ("##", "Header 2"),
    ("###", "Header 3"),
]

splitter = MarkdownHeaderTextSplitter(headers_to_split_on)
chunks = splitter.split_text(markdown_doc)
# 每个 chunk 保留了所属标题的 metadata

实战推荐:技术文档用结构感知分块,通用文本用固定长度 + 64 重叠,预算充足再考虑语义分块。

分块大小对比

chunk_size检索精度上下文完整性存储
256
512
1024

512 token 是大多数场景的最佳平衡点。

2. 多路召回

单一向量检索容易遗漏。多路召回结合不同检索方式,提高召回率:

import asyncio

async def multi_retrieve(query: str, top_k: int = 5):
    # 路路1:稠密向量检索
    dense_results = await vector_search(query, top_k=top_k*2)
    
    # 路路2:稀疏检索 (BM25)
    bm25_results = await bm25_search(query, top_k=top_k*2)
    
    # 路路3:混合检索 (bge-m3 sparse)
    sparse_results = await sparse_search(query, top_k=top_k*2)
    
    # 融合去重
    all_docs = {}
    for results in [dense_results, bm25_results, sparse_results]:
        for doc in results:
            doc_id = doc["id"]
            if doc_id not in all_docs:
                all_docs[doc_id] = doc
    
    return list(all_docs.values())[:top_k*3]

RRF(Reciprocal Rank Fusion) 是最常用的融合算法,无需分数归一化:

def rrf_fusion(result_lists, k=60):
    """Reciprocal Rank Fusion"""
    scores = {}
    for results in result_lists:
        for rank, doc in enumerate(results):
            doc_id = doc["id"]
            scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank)
    
    sorted_ids = sorted(scores, key=scores.get, reverse=True)
    return sorted_ids

3. 重排序(Reranker)

召回阶段用轻量模型快速初筛,重排阶段用 Cross-Encoder 精排:

from FlagEmbedding import FlagReranker

reranker = FlagReranker('BAAI/bge-reranker-v2-m3', use_fp16=True)

# 对召回的 top-20 文档重排
pairs = [[query, doc["text"]] for doc in candidates]
scores = reranker.compute_score(pairs)

# 按分数排序取 top-5
ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
final_docs = [doc for doc, _ in ranked[:5]]

Cross-Encoder vs Bi-Encoder

特性Bi-Encoder (召回)Cross-Encoder (重排)
速度极快(预计算)慢(实时计算)
精度中等
计算query 和 doc 独立编码query 和 doc 拼接编码
用途从百万文档初筛从 top-20 精选 top-5

bge-reranker-v2-m3 是当前最强开源重排模型,支持多语言、长文本。

4. 查询改写

用户提问往往不完整或含糊,直接检索效果差。查询改写技术可以显著提升召回率。

查询扩展

# 用 LLM 生成多个改写版本
rewrite_prompt = """请将以下问题改写为 3 个不同角度的检索查询,用于提升检索效果。

原始问题:{question}

输出格式(JSON 数组):
["改写1", "改写2", "改写3"]
"""

response = await llm.chat.completions.create(
    model="gpt-4o-mini",
    messages=[{"role": "user", "content": rewrite_prompt.format(question=query)}]
)
rewrites = json.loads(response.choices[0].message.content)

# 对每个改写查询分别检索,再融合
all_results = []
for q in [query] + rewrites:
    results = await vector_search(q, top_k=5)
    all_results.append(results)

fused = rrf_fusion(all_results)

HyDE(假设性文档嵌入)

让 LLM 先生成一个"假答案",用假答案的向量去检索:

hyde_prompt = """请为以下问题生成一个简短的假设性答案(2-3句话),不需要准确,但要用专业术语。

问题:{question}

假设性答案:"""

fake_doc = await llm.generate(hyde_prompt.format(question=query))
# 用 fake_doc 而非原始 query 做向量检索
embedding = embed_model.encode(fake_doc)
results = vector_db.search(embedding, top_k=5)

HyDE 在 query 很短或很口语化时效果显著,因为生成的假答案更接近文档的"书面语"风格。

5. 上下文压缩

检索到的文档可能很长,包含大量无关信息。上下文压缩可以减少 token 消耗、提升生成质量。

compress_prompt = """以下是从知识库检索到的文档片段。请提取与用户问题最相关的内容,去除无关信息。

用户问题:{question}

文档片段:
{documents}

请输出压缩后的相关内容(保留关键信息,去除冗余):"""

compressed = await llm.generate(compress_prompt.format(
    question=query,
    documents="\n\n".join(doc["text"] for doc in final_docs)
))

更高级的方案是用专门的 extractive 模型做关键句提取,成本更低。

6. 流式生成

RAG 最终输出可能很长,流式输出改善用户体验:

from fastapi import FastAPI
from fastapi.responses import StreamingResponse

app = FastAPI()

@app.post("/chat")
async def chat(req: ChatRequest):
    # 1. 检索
    docs = await multi_retrieve(req.query)
    context = "\n\n".join(d["text"] for d in docs)
    
    # 2. 构造 prompt
    messages = [
        {"role": "system", "content": f"基于以下资料回答问题:\n{context}"},
        {"role": "user", "content": req.query}
    ]
    
    # 3. 流式生成
    async def stream():
        async for chunk in await llm.chat.completions.create(
            model="deepseek-chat",
            messages=messages,
            stream=True
        ):
            if chunk.choices[0].delta.content:
                yield f"data: {chunk.choices[0].delta.content}\n\n"
        yield "data: [DONE]\n\n"
    
    return StreamingResponse(stream(), media_type="text/event-stream")

7. 缓存策略

语义缓存

对相似问题复用答案,大幅降低成本:

import redis
import numpy as np

r = redis.Redis()
SIM_THRESHOLD = 0.95

async def cached_answer(query: str, query_embedding: np.ndarray):
    # 遍历缓存中所有 query 向量
    cached_keys = r.keys("query:*")
    for key in cached_keys:
        cached_emb = np.frombuffer(r.get(key), dtype=np.float32)
        sim = np.dot(query_embedding, cached_emb) / (
            np.linalg.norm(query_embedding) * np.linalg.norm(cached_emb)
        )
        if sim > SIM_THRESHOLD:
            # 命中缓存
            cached_answer_key = key.replace("query:", "answer:")
            return r.get(cached_answer_key).decode()
    
    # 未命中,走 RAG 流程
    answer = await rag_pipeline(query)
    
    # 写入缓存
    query_id = f"query:{hash(query)}"
    r.set(query_id, query_embedding.tobytes())
    r.set(f"answer:{hash(query)}", answer)
    
    return answer

文档缓存

对已嵌入的文档,缓存其向量避免重复计算。用文档 hash 作为 key:

import hashlib

def get_or_embed(text: str, embed_model, redis_client):
    doc_hash = hashlib.md5(text.encode()).hexdigest()
    cached = redis_client.get(f"emb:{doc_hash}")
    if cached:
        return np.frombuffer(cached, dtype=np.float32)
    
    embedding = embed_model.encode(text)
    redis_client.set(f"emb:{doc_hash}", embedding.tobytes())
    return embedding

优化效果汇总

优化手段精度提升延迟影响实现难度
分块调优+5-10%
多路召回+10-15%+50ms
重排序+10-20%+100ms
查询改写+5-15%+200ms
上下文压缩-5~+5%+300ms
语义缓存-90%

推荐实施顺序:分块调优 → 重排序 → 多路召回 → 查询改写 → 缓存 → 上下文压缩。前三项投入产出比最高。

小结

RAG 优化是一个系统工程,每个环节都有提升空间。没有银弹,需要根据业务数据和用户 query 模式针对性优化。先做分块和重排序(立竿见影),再做多路召回和查询改写(锦上添花),最后加缓存降本。务必建立评测集,每一步都用数据说话。

加入讨论

这篇文章有姊妹讨论帖在硅基AGI论坛 — 全球首个碳基硅基认知交流平台。

碳基与硅基的智慧碰撞,认知差异创造无限可能。