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_size | overlap | 理由 |
|---|---|---|---|
| 技术文档 / API 手册 | 512 - 800 | 64 - 128 | 段落自洽,可稍大 |
| 法律 / 合同 | 256 - 384 | 64 | 条款边界清晰 |
| 对话记录 | 256 | 32 | 单轮语义完整 |
| 论文 | 800 - 1024 | 128 | 论证跨段 |
| 代码 | 按函数切分 | 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_size | overlap | Recall@10 | 平均块 token | 索引块数 |
|---|---|---|---|---|---|
| 定长 | 256 | 32 | 71.2% | 256 | 42,000 |
| 定长 | 512 | 64 | 79.6% | 512 | 21,500 |
| 定长 | 1024 | 128 | 76.3% | 1024 | 10,800 |
| 递归 | 512 | 64 | 84.1% | 487 | 22,300 |
| 递归 + 父子块 | 256 / 1024 | 32 | 88.9% | 索引 256 | 22,300 |
| 语义切分 | 动态 | 0 | 86.7% | 430 | 24,100 |
结论很清晰:定长 512 明显优于 256 与 1024;递归切分比定长提升约 4.5 个百分点;父子块策略再提升约 4.8 个百分点,是性价比最高的一步。语义切分提升有限却引入额外延迟,除非文档结构极差,否则不必上。
三、Embedding 选型与维度
3.1 主流模型对比
Embedding 模型的选择要在质量、维度、延迟与成本之间权衡。维度越高表达力越强,但存储与检索成本也越高:
| 模型 | 维度 | 中文能力 | 上下文 | 相对成本 | 适用场景 |
|---|---|---|---|---|---|
| text-embedding-3-large | 3072 / 可降维 | 良 | 8191 | 高 | 高精度英文 |
| text-embedding-3-small | 1536 | 中 | 8191 | 低 | 成本敏感 |
| bge-m3 | 1024 | 优 | 8192 | 自托管 | 中文混合检索 |
| bge-large-zh-v1.5 | 1024 | 优 | 512 | 自托管 | 中文短文本 |
| gte-Qwen2 | 1536 | 优 | 32768 | 自托管 | 长文档中文 |
| jina-embeddings-v3 | 1024 | 良 | 8192 | 中 | 多语言 |
选型的第一原则是「查询与文档必须用同一个模型」:用 A 模型编码文档、用 B 模型编码查询,向量空间不一致,相似度计算毫无意义。这条看似显然,却是线上最常见的低级错误。
3.2 维度与存储成本
向量维度直接决定存储与内存占用。以 1000 万条 chunk 为例:
| 维度 | float32 存储 | 内存占用(含索引开销约 1.5 倍) | 单机可行性 |
|---|---|---|---|
| 768 | 30.7 GB | 约 46 GB | 单机 64 GB 可行 |
| 1024 | 40.9 GB | 约 61 GB | 单机 128 GB 可行 |
| 1536 | 61.4 GB | 约 92 GB | 需 128 GB 以上 |
| 3072 | 122.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 ms | 62.1% | 基线 |
| 20 | 38 ms | 81.4% | +19.3 pt |
| 50 | 92 ms | 88.7% | +26.6 pt |
| 100 | 180 ms | 91.2% | +29.1 pt |
| 200 | 356 ms | 92.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@k | top_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 真正落地的开始。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。