RAG 检索服务的工程化

RAG 的演示版本几十行代码就能跑通,生产版本却要在召回率、延迟与成本之间反复权衡。本文把 RAG 当作检索服务来工程化:拆解切分、embedding、向量检索、混合检索、reranker 精排、上下文拼装与生成七个环节,给出分块参数、embedding 维度选型、HNSW 参数调优与 bge-reranker-v2-m3 的延迟收益量化,附可运行的混合检索加精排 Python 代码与常见坑清单。

RAG(Retrieval-Augmented Generation)的 demo 极其简单:把文档切块、算 embedding、存进向量库、检索 top_k、拼进 Prompt。几十行代码就能跑出一个能回答问题的原型。但从原型到生产,中间隔着一道深沟:检索不到正确文档、检索到了但排序靠后、排序对了但延迟超预算、延迟达标了但成本失控。这些问题不会在 demo 里出现,只会在真实数据量与真实流量下集中爆发。

本文把 RAG 当作一个检索服务来工程化,逐环节给出可落地的参数与量化取舍。它的上游是数据与文档,下游是生成模型,中间是一条对延迟与质量都极其敏感的检索链路。

一、RAG 服务链路全景

1.1 七个环节

一条完整的 RAG 链路包含七个环节,每个环节都会影响最终质量:

环节职责关键参数主要风险
切分把文档拆成语义单元chunk_size、overlap破坏语义边界
Embedding把文本编码为向量模型、维度与查询模型不一致
向量检索近似最近邻召回索引类型、top_k召回不足
混合检索BM25 与向量融合权重、RRF 参数融合权重失衡
重排交叉编码精排模型、候选数延迟超预算
上下文拼装组装 Prompt顺序、截断中间遗忘、超长
生成LLM 产出答案模型、温度幻觉、忽略上下文

1.2 离线与在线分工

RAG 系统天然分成离线与在线两条流水线,两者的优化目标不同:

流水线触发时机延迟要求优化目标
离线索引文档变更时分钟级可接受索引质量与覆盖率
在线检索每次用户请求P99 < 300 ms召回率与延迟
在线生成每次用户请求P99 < 3 s答案质量与成本

离线流水线可以慢,因为它不阻塞用户;在线流水线必须快,因为它直接决定首字延迟。把重活(embedding、切分、索引构建)全部放在离线,在线只做检索与重排,是 RAG 工程化的第一原则。

二、分块策略

2.1 chunk_size 与 overlap

分块是 RAG 中影响最大却最常被忽视的环节。块太大,噪声多、检索精度低、占用上下文;块太小,语义被切断、检索到的是半句话。经验起点是 chunk_size=512 tokens、overlap=64 tokens(约 12%),再按文档类型微调。

文档类型推荐 chunk_sizeoverlap理由
技术文档 / API 手册512 - 80064 - 128段落自洽,可稍大
法律 / 合同256 - 38464条款边界清晰
对话记录25632单轮语义完整
论文800 - 1024128论证跨段
代码按函数切分0结构天然

overlap 的作用是缓解「关键句恰好落在边界」的问题。overlap 太大则索引膨胀、检索重复;太小则跨边界信息丢失。一般取 chunk_size 的 10% 到 20%。

2.2 分块方式对比

简单的定长切分会破坏句子与段落边界。更好的做法是递归切分(按段落、句子、字符逐级回退)或语义切分(按 embedding 相似度断句):

import re
from dataclasses import dataclass

@dataclass
class Chunk:
    text: str
    start: int
    end: int

def recursive_split(text: str, chunk_size: int = 512,
                    overlap: int = 64) -> list[Chunk]:
    # 按段落 -> 句子 -> 字符逐级回退,尽量在自然边界处切分
    separators = ["\n\n", "\n", "。", "!", "?", ". ", " "]
    pieces: list[str] = [text]
    for sep in separators:
        merged: list[str] = []
        for piece in pieces:
            if len(piece) <= chunk_size:
                merged.append(piece)
            else:
                parts = [p + sep for p in piece.split(sep) if p]
                merged.extend(parts or [piece])
        pieces = merged
    chunks, buf = [], ""
    for piece in pieces:
        if len(buf) + len(piece) > chunk_size and buf:
            chunks.append(buf)
            buf = buf[-overlap:] if overlap else ""
        buf += piece
    if buf.strip():
        chunks.append(buf)
    out, cursor = [], 0
    for c in chunks:
        idx = text.find(c[:32], cursor)
        idx = idx if idx >= 0 else cursor
        out.append(Chunk(text=c, start=idx, end=idx + len(c)))
        cursor = idx
    return out

保留 start / end 偏移量的价值在于:检索结果可以回溯到原文位置,前端能高亮引用,审计时能验证答案有据。很多团队只用纯文本块,出问题后无法定位来源,这是生产环境必须补齐的字段。

2.3 父子块与上下文补全

一种在精度与上下文之间取平衡的做法是「小块检索、大块喂给模型」:索引用小块(256 tokens)以获得高精度召回,命中后把该块所属的父块(1024 tokens)连同相邻块一起喂给 LLM。这样既保证了检索精度,又让模型看到完整上下文,实测可把答案完整率提升 15 个百分点以上,代价是索引与原文的映射关系变复杂。

2.4 分块效果的量化对比

分块参数没有普适最优,必须在自己的数据上测。下表是在一个中文技术文档库(约 3 万段)上的实测,固定 embedding 与重排,只变分块:

分块策略chunk_sizeoverlapRecall@10平均块 token索引块数
定长2563271.2%25642,000
定长5126479.6%51221,500
定长102412876.3%102410,800
递归5126484.1%48722,300
递归 + 父子块256 / 10243288.9%索引 25622,300
语义切分动态086.7%43024,100

结论很清晰:定长 512 明显优于 256 与 1024;递归切分比定长提升约 4.5 个百分点;父子块策略再提升约 4.8 个百分点,是性价比最高的一步。语义切分提升有限却引入额外延迟,除非文档结构极差,否则不必上。

三、Embedding 选型与维度

3.1 主流模型对比

Embedding 模型的选择要在质量、维度、延迟与成本之间权衡。维度越高表达力越强,但存储与检索成本也越高:

模型维度中文能力上下文相对成本适用场景
text-embedding-3-large3072 / 可降维良8191高高精度英文
text-embedding-3-small1536中8191低成本敏感
bge-m31024优8192自托管中文混合检索
bge-large-zh-v1.51024优512自托管中文短文本
gte-Qwen21536优32768自托管长文档中文
jina-embeddings-v31024良8192中多语言

选型的第一原则是「查询与文档必须用同一个模型」:用 A 模型编码文档、用 B 模型编码查询,向量空间不一致,相似度计算毫无意义。这条看似显然,却是线上最常见的低级错误。

3.2 维度与存储成本

向量维度直接决定存储与内存占用。以 1000 万条 chunk 为例:

维度float32 存储内存占用(含索引开销约 1.5 倍)单机可行性
76830.7 GB约 46 GB单机 64 GB 可行
102440.9 GB约 61 GB单机 128 GB 可行
153661.4 GB约 92 GB需 128 GB 以上
3072122.9 GB约 184 GB需分片或多机

如果检索质量允许,用 Matryoshka 降维(如把 3072 维降到 1024 维)可以在损失极小精度的情况下把存储降为三分之一。这是大规模场景下最划算的一步优化。嵌入与重排的深度调优见 Embedding 与 Reranker 。

四、向量库与索引参数

4.1 向量库选型

向量库部署复杂度过滤能力混合检索适合规模典型场景
pgvector极低强(SQL)需自建百万级已有 Postgres
Qdrant低强原生支持千万级中小团队
Milvus中高强原生支持十亿级大规模生产
Elasticsearch中强原生 BM25千万级已有 ES 栈

选型的第一依据是现有技术栈:如果已经在用 Postgres,pgvector 在百万级以内是性价比最高的选择,且能直接用 SQL 做复杂过滤。规模上到千万级再考虑 Qdrant 或 Milvus。

4.2 HNSW 参数调优

HNSW 是当前主流的向量索引,三个核心参数决定精度与延迟的权衡:

参数含义典型值增大影响减小影响
M每个节点的连接数16 - 64精度高、内存大精度低、内存小
efConstruction建索引时的候选队列100 - 500索引质量高、建得慢建得快、质量低
efSearch查询时的候选队列64 - 256召回高、延迟高召回低、延迟低

关键区别是 efConstruction 只影响建索引(离线,慢一点没关系),efSearch 影响每次查询(在线,直接决定延迟)。因此实践中把 efConstruction 设大(如 256)以保证索引质量,把 efSearch 从 64 起步按召回需求上调。

调优方法是画一条「召回率 - 延迟」曲线:固定数据集,扫描 efSearch 从 32 到 512,记录召回率与 P99 延迟。通常 efSearch=128 就能达到 95% 以上的召回,再往上收益递减而延迟线性上升。把 efSearch 从 64 提到 128,召回率一般能涨 3 到 5 个百分点,P99 延迟增加约 2 到 4 毫秒。

collection:
  name: docs_zh
  vectors:
    size: 1024                 # bge-m3 维度
    distance: Cosine
  hnsw_config:
    m: 32                      # 连接数,内存换精度
    ef_construct: 256          # 建索引候选,离线可大
  optimizers_config:
    memmap_threshold: 20000    # 超过 2 万条转 mmap 省内存
  quantization_config:
    scalar:
      type: int8               # 标量量化,内存降 4 倍,精度损失约 1%

量化(scalar quantization)能把内存降为四分之一,代价是召回率损失约 1%。在内存受限的千万级场景下,这是必须开启的优化。

调优 efSearch 的标准做法是扫描并记录曲线。下面这段脚本对同一组查询在不同 efSearch 下测量召回率与延迟,用于找出甜点:

import time

def sweep_ef_search(index, queries, ground_truth, ef_values, top_k: int = 10):
    rows = []
    for ef in ef_values:
        index.set_ef(ef)                       # Milvus/Qdrant 均支持动态设置
        hits, latency = 0, []
        for q, truth in zip(queries, ground_truth):
            t0 = time.perf_counter()
            res = index.search(q, limit=top_k)
            latency.append((time.perf_counter() - t0) * 1000)
            hits += len(set(r[0] for r in res) & set(truth))
        recall = hits / (len(queries) * top_k)
        p99 = sorted(latency)[int(len(latency) * 0.99) - 1]
        rows.append({"ef_search": ef, "recall": round(recall, 4),
                     "p99_ms": round(p99, 2)})
    return rows

if __name__ == "__main__":
    for row in sweep_ef_search(None, [], [], [32, 64, 128, 256, 512]):
        print(row)

把 efSearch 从 64 提到 128 通常能把召回率提升 3 到 5 个百分点,而 P99 延迟只增加 2 到 4 毫秒;从 128 提到 256 收益已不足 1 个百分点。这个拐点就是配置该落在的位置。

4.3 混合检索

纯向量检索对关键词、专有名词、编号不敏感:查询「错误码 E1042」时,向量模型可能召回语义相近但编号完全不同的文档。BM25 恰好相反,它对精确词匹配极其敏感。把两者融合(Hybrid Search)能同时覆盖语义与关键词,实测可把召回率提升 8 到 15 个百分点。

融合的经典方法是 RRF(Reciprocal Rank Fusion):对每一路召回的排名取倒数加权求和,无需归一化分数:

from collections import defaultdict

def rrf_fuse(ranked_lists: list[list[str]], k: int = 60) -> list[tuple[str, float]]:
    scores: dict[str, float] = defaultdict(float)
    for ranked in ranked_lists:
        for rank, doc_id in enumerate(ranked, start=1):
            scores[doc_id] += 1.0 / (k + rank)
    return sorted(scores.items(), key=lambda kv: kv[1], reverse=True)

if __name__ == "__main__":
    vector_hits = ["d1", "d2", "d3", "d4"]
    bm25_hits = ["d3", "d5", "d1", "d6"]
    for doc_id, score in rrf_fuse([vector_hits, bm25_hits])[:4]:
        print(doc_id, round(score, 5))

RRF 的优势是不需要调两路的权重,k 取 60 是文献常用值。若需要更精细的控制,可以改用加权分数融合,但要先把两路分数归一化到同一尺度。

4.4 过滤与多租户

生产 RAG 几乎一定有元数据过滤需求:按租户、按时间、按权限过滤。过滤有两种实现,性能差异巨大:

过滤方式实现延迟风险
后置过滤先检索 top_k 再按条件筛低过滤后结果不足
前置过滤检索时即带上过滤条件略高需索引支持
分区隔离每租户独立 collection最低租户多则开销大

后置过滤在租户数据占比小时会失效:如果某租户文档只占全库 1%,检索 top_100 可能一个都不属于该租户。因此多租户必须用前置过滤或分区隔离,绝不能依赖后置过滤。这既是性能问题,也是数据隔离的安全问题。

五、重排

5.1 bge-reranker-v2-m3

召回阶段追求「不漏」,因此候选集通常取 top_50 到 top_100;但喂给 LLM 的上下文有限,需要精排。Reranker 用交叉编码器(Cross-Encoder)同时看查询与文档,精度远高于双塔向量,代价是每次都要过一遍模型,延迟随候选数线性增长。

bge-reranker-v2-m3 是当前中文场景的主力选择:多语言、支持长文本、单卡即可部署。它的输入是 (query, passage) 对,输出相关性分数。

5.2 延迟与收益量化

下表来自一个真实中文知识库(50 万 chunk)的实测,硬件为单张 A10:

候选数rerank 延迟 P99召回率 top_10相对无重排提升
0(不重排)0 ms62.1%基线
2038 ms81.4%+19.3 pt
5092 ms88.7%+26.6 pt
100180 ms91.2%+29.1 pt
200356 ms92.0%+29.9 pt

数据揭示一个清晰的拐点:候选数从 50 增到 100,召回率只涨 2.5 个百分点,延迟却翻倍;从 100 增到 200,收益几乎为零。因此候选数取 50 是延迟与质量的甜点,对延迟极敏感的场景可退到 20。这条曲线必须在自己的数据上重跑,因为文档长度分布不同,拐点位置会移动。

5.3 端到端示例:混合检索加重排

下面把 BM25、向量检索、RRF 融合与重排串成一条完整可运行的链路。它接收查询与候选文档,返回精排后的 top_3,是生产链路的最小骨架:

from collections import defaultdict

def bm25_search(query: str, corpus: dict[str, str], top_n: int = 50) -> list[str]:
    # 简化 BM25:按词频打分,生产环境用 rank_bm25 或 ES
    terms = [t for t in query.lower().split() if t]
    scored = []
    for doc_id, text in corpus.items():
        tl = text.lower()
        score = sum(tl.count(t) for t in terms)
        if score > 0:
            scored.append((doc_id, score))
    scored.sort(key=lambda kv: kv[1], reverse=True)
    return [d for d, _ in scored[:top_n]]

def vector_search(query: str, embed_fn, index, top_n: int = 50) -> list[str]:
    qvec = embed_fn(query)
    hits = index.search(qvec, limit=top_n)      # 返回 (doc_id, score)
    return [doc_id for doc_id, _ in hits]

def rrf_fuse(ranked_lists: list[list[str]], k: int = 60) -> list[str]:
    scores: dict[str, float] = defaultdict(float)
    for ranked in ranked_lists:
        for rank, doc_id in enumerate(ranked, start=1):
            scores[doc_id] += 1.0 / (k + rank)
    return [d for d, _ in sorted(scores.items(), key=lambda kv: kv[1], reverse=True)]

def rerank(query: str, doc_ids: list[str], corpus: dict[str, str],
           rerank_fn, top_k: int = 3) -> list[tuple[str, float]]:
    pairs = [(query, corpus[d]) for d in doc_ids]
    scores = rerank_fn(pairs)                    # bge-reranker-v2-m3 打分
    ranked = sorted(zip(doc_ids, scores), key=lambda kv: kv[1], reverse=True)
    return ranked[:top_k]

def rag_retrieve(query: str, corpus, embed_fn, index, rerank_fn,
                 recall_n: int = 50, top_k: int = 3) -> list[tuple[str, float]]:
    bm25_hits = bm25_search(query, corpus, recall_n)
    vec_hits = vector_search(query, embed_fn, index, recall_n)
    fused = rrf_fuse([bm25_hits, vec_hits])[:recall_n]
    return rerank(query, fused, corpus, rerank_fn, top_k)

if __name__ == "__main__":
    corpus = {"d1": "错误码 E1042 表示鉴权失败", "d2": "鉴权流程说明", "d3": "E1042 排查步骤"}
    def fake_embed(q): return [0.1, 0.2, 0.3]
    class FakeIndex:
        def search(self, qvec, limit): return [("d2", 0.9), ("d3", 0.8), ("d1", 0.7)]
    def fake_rerank(pairs): return [0.3, 0.95, 0.6][:len(pairs)]
    print(rag_retrieve("E1042 怎么排查", corpus, fake_embed, FakeIndex(),
                       fake_rerank))

这条链路的三个关键点:BM25 与向量各召回 50 条保证不漏、RRF 融合无需调权重、重排把候选压到 top_3 控制上下文长度。任何一环的参数都应按第七节的指标持续调优。

六、上下文拼装与生成

6.1 拼装顺序

检索到文档之后,如何拼进 Prompt 同样影响答案质量。模型存在「中间遗忘」(lost in the middle)现象:对上下文首尾的内容注意力更强,中间部分容易被忽略。因此最相关的 chunk 应放在开头,次相关的放在结尾,最不相关的放中间。若用长上下文模型且文档很多,可考虑把最相关的重复放在首尾各一次。

6.2 引用与可溯源

生产 RAG 必须让答案可溯源。做法是给每个 chunk 编号,要求模型在回答时标注引用来源,前端再把编号映射回原文。这不仅能提升用户信任,也是评估检索质量的关键数据:如果答案引用的编号集中在少数几个 chunk,说明检索噪声大。

拼装策略优点缺点适用
按分数降序实现简单中间遗忘文档少
首尾强化缓解中间遗忘需重排支撑文档多
去重压缩省 token可能丢细节成本敏感
结构化标签模型更易引用占额外 token需溯源

6.3 生成阶段约束

生成阶段要明确约束模型「只依据上下文回答,不得编造」。当检索结果为空或相关性低于阈值时,应返回「未找到相关依据」而不是让模型自由发挥。这条拒答机制能把幻觉率显著压低,代价是部分本可回答的问题被拒,需要通过阈值调优在覆盖率与准确率之间取平衡。

七、缓存

RAG 的缓存分三层,收益递减但实现成本也递减:

缓存层缓存对象命中率收益失效条件
Embedding 缓存查询向量高(重复查询多)省 embedding 调用查询文本变化
检索结果缓存top_k 文档列表中省检索与重排索引更新
答案缓存最终回答低到中省整条链路文档或模型更新

Embedding 缓存最容易见效:大量用户会问相似甚至完全相同的问题,把查询向量按文本哈希缓存,命中率常能到 30% 以上。答案缓存要谨慎,因为文档更新后旧答案会过期,必须给缓存设置合理的 TTL 或与索引版本绑定。缓存对成本的影响详见 LLM 成本优化 。

缓存键的设计是成败关键。检索结果缓存的键必须包含查询文本、过滤条件、top_k 与索引版本,缺一不可:

import hashlib
import json

def retrieval_cache_key(query: str, filters: dict, top_k: int,
                        index_version: str) -> str:
    payload = json.dumps({
        "q": query.strip().lower(),
        "filters": filters,
        "top_k": top_k,
        "idx": index_version,        # 索引更新后键自动失效
    }, sort_keys=True, ensure_ascii=False)
    return "rag:retrieval:" + hashlib.sha256(payload.encode()).hexdigest()

if __name__ == "__main__":
    k1 = retrieval_cache_key("E1042 排查", {"tenant": "a"}, 50, "v3")
    k2 = retrieval_cache_key("E1042 排查", {"tenant": "a"}, 50, "v4")
    print(k1 != k2)   # 索引版本变化 -> 键变化 -> 缓存自动失效

把 index_version 纳入键,索引更新时旧缓存自然失效,无需手动清理。若过滤条件不含租户,多租户场景会出现跨租户缓存命中,这是严重的安全事故,务必在键里带上全部过滤维度。

八、关键指标与量化

RAG 服务必须持续监控下列指标,否则优化无从下手:

指标定义目标测量方式
召回率 Recall@ktop_k 中命中相关文档的比例> 90%标注集离线评测
重排命中率重排后相关文档进入 top_3 的比例> 85%标注集离线评测
首字延迟 TTFT检索加生成到首 token< 1.2 s线上打点
检索延迟 P99检索加重排耗时< 300 ms线上打点
上下文利用率被答案引用的 chunk 占比> 40%引用回溯
无答案率检索为空或拒答的比例< 5%线上打点

其中「上下文利用率」最容易被忽略却最有诊断价值:如果喂进去 10 个 chunk 但答案只引用了 1 个,说明召回噪声大,应当减小 top_k 或加强重排;如果引用了 8 个,说明上下文可能不足,应当扩大候选。这个指标能直接指路。

九、常见坑清单

  • 切分破坏语义:定长硬切把一句话劈成两半,检索到的是残句。用递归或语义切分,并保留偏移量以便回溯。
  • top_k 过大稀释上下文:为了「不漏」把 top_k 设成 50 直接喂给 LLM,导致噪声淹没信号,且 token 成本飙升。正确做法是召回多、喂入少,中间用重排收口。
  • embedding 与查询不同模型:文档用 A 模型、查询用 B 模型,向量空间不一致,相似度无意义。必须严格同模型同版本。
  • rerank 延迟超预算:候选数设成 200 导致重排耗时 350 毫秒以上,首字延迟被拖垮。按实测曲线选候选数,通常 50 是甜点。
  • 索引更新未刷新缓存:文档更新后检索结果缓存仍是旧的,用户看到已删除的内容。缓存键必须包含索引版本。
  • 只测召回不测端到端:离线召回率 95% 但线上答案质量差,因为问题出在上下文拼装或生成环节。必须端到端评测。
  • 忽略元数据过滤:多租户场景未按租户过滤,A 租户检索到 B 租户的文档,属于严重数据泄露。过滤条件必须在向量检索层而非事后过滤。
  • chunk 无来源信息:检索结果只有文本没有出处,答案无法验证、无法引用。必须在索引中保存文档 ID 与偏移量。
  • embedding 模型静默升级:供应商更新模型版本导致向量空间漂移,旧索引与新查询不匹配。模型版本必须显式锁定并纳入变更管理。
  • 上下文拼装顺序随意:把最相关的 chunk 放在中间,模型「中间遗忘」导致忽略。最相关的应放在开头或结尾。

小结

RAG 的工程化,本质是把一条演示级链路打磨成一个可度量、可调优、可运营的检索服务。切分决定召回上限,chunk_size 取 512、overlap 取 64 是稳妥起点;embedding 必须查询与文档同模型,维度在存储与精度间权衡,必要时用降维把成本压到三分之一;向量库选型服从现有技术栈,HNSW 的 efConstruction 可以设大、efSearch 按召回曲线调到甜点;混合检索用 RRF 融合 BM25 与向量,通常能换来 8 到 15 个百分点的召回提升;重排把候选从 50 精排到 top_3,能带来约 26 个百分点的命中率改善,但候选数超过 100 后收益迅速衰减;缓存从 embedding 层做起,命中率最高、失效风险最小。把这些参数在自己的数据上重新测量一遍,而不是照搬任何文章里的数字,才是 RAG 真正落地的开始。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「LLMOps」更多文章

  1. 语义缓存与 Prompt 缓存
  2. 结构化输出与函数调用
  3. 多智能体编排与工作流引擎