检索增强生成(RAG)是让 LLM 在你自己的数据上回答问题的办法——私有文档、知识库、上周的工单——而无需重新训练它。思路很简单:在调用模型之前,先检索出最相关的文本塞进 prompt,让答案基于真实来源,而不是模型那份冻结又模糊的记忆。但"简单"背后藏着一个真正的分布式系统:一条把文档变成可搜索向量索引的离线管线,以及一条在线管线——每个查询都要向量化、检索、重排、拼 prompt、生成带引用的答案——还要又快、又新、又便宜。本文把两端都设计一遍,以及面试官会追问的取舍。
- RAG 是两条管线: 离线索引管线(加载 → 分块 → 向量化 → 入索引)和在线查询管线(向量化 → 检索 → 重排 → 生成)。分开设计。
- 分块是最高杠杆的旋钮。 太大检索噪声多;太小丢上下文。按结构切、加重叠,并保留指回原文的指针。
- 向量索引用近似最近邻(ANN) —— HNSW 或 IVF —— 用一点召回率换取在百万级向量上的次线性搜索。
- 混合检索胜过纯向量。 把语义(向量)和关键词(BM25)结合、融合分数;再加元数据过滤做租户与新鲜度。
- 对候选做重排。 用 cross-encoder 把 top ~100 重排成真正塞进 prompt 的 top ~5 —— 最便宜的大质量提升。
- 生成必须有据且带引用。 指示模型只用检索到的上下文作答并引用切片,这样能展示来源、也能抓幻觉。
- 质量靠测量,不靠假设。 持续评测检索(recall@k)和答案的忠实度/相关性 —— "RAG 三元组"。
离线:加载文档、切成带重叠的块、给每块向量化、把向量连同元数据 upsert 进 ANN 索引。在线:把查询向量化,做混合(向量 + 关键词)检索 + 元数据过滤,用 cross-encoder 重排候选,把 top 块拼成有据 prompt,让 LLM 带引用作答。难点全在分块、混合 + 重排质量、新鲜度/增量索引,以及评测"检索到底有没有帮上忙"。
第 1 步 —— 收敛需求
界定范围:我们是在一个语料(支持文档、内部 wiki、合同)上做问答助手吗?语料多大、要多新、是否多租户?一个聚焦集合:
功能需求
- 摄入混合格式文档(PDF、HTML、Markdown、数据库行),并在它们变更时保持索引更新。
- 对自然语言查询,给出基于检索来源的回答,并返回引用。
- 支持检索时的元数据过滤(租户、文档类型、日期、访问控制)。
- 检索不到相关内容时回答"我不知道",而不是胡编。
非功能需求
- 低查询延迟 —— 检索几十毫秒;端到端由 LLM 调用主导。
- 新鲜度 —— 新增/编辑的文档几分钟内可搜到,而不是每晚全量重建。
- 规模 —— 数千万到数亿块;每秒数千查询。
- 正确与安全 —— 绝不把一个租户的文档泄露给另一个;有据回答以限制幻觉。
开篇就定调:RAG 不改模型,改的是 prompt。 每个设计决定都是为了把对的块塞进有限的上下文窗口——所以你真正在工程化的是检索质量,而不是 LLM。
第 2 步 —— 两条管线
最清晰的心智模型——也是面试官想要的结构——是两条在向量索引处交汇的独立管线。索引管线离线(并增量)运行,把文档变成可搜索的向量。查询管线在线、每个请求运行一次。把它们解耦,意味着你可以重建索引、换向量化模型、重新分块,而不碰服务路径;也能按各自的特征扩缩(索引是吞吐受限、批量的;查询是延迟受限的)。
第 3 步 —— 摄入与分块
索引管线先把异构来源加载并解析成干净文本(以及结构——标题、表格、页码——作为元数据保留)。接着就是 RAG 里最关键的一个选择:分块。
为什么分块决定质量
你向量化并检索的是块而不是整篇文档,因为一个 embedding 把一段文本压成一个向量——段越长,向量越模糊。块太大,查询会匹配到一个泛泛相关的页面、把噪声拖进来;块太小,块就失去成其意义所需的上下文。最佳点通常是几百个 token,按自然结构切(段落、标题、小节)而不是盲目定长,并在块之间留一点重叠,这样跨边界的句子不会成为孤儿。
for doc in source.stream():
text = parse(doc)
chunks = split(text, size=400, overlap=60, on="headings")
vecs = embedder.embed(chunks) # 在 GPU/加速器上批量
index.upsert([{
"id": hash(doc.id, i), "vector": v,
"text": c, "doc_id": doc.id, "tenant": doc.tenant,
"updated_at": doc.updated_at # 元数据 = 过滤 + 引用
} for i,(c,v) in enumerate(zip(chunks, vecs))])
注意每个向量旁边存了什么:原始文本(以便塞进 prompt 并引用)和元数据(租户、文档 id、时间戳)用于过滤和新鲜度。向量化模型的选择也很重要——选定一个就别动,因为换模型意味着对整个语料重新向量化(向量必须在同一空间才能比较)。
第 4 步 —— 向量索引
检索就是 embedding 空间里的最近邻搜索:找到与查询向量(按余弦相似度)最近的块向量。对上亿向量做精确搜索太慢,所以生产系统用近似最近邻(ANN)索引,用一丝召回率换取次线性查询时间。
| ANN 索引 | 取舍 |
|---|---|
| HNSW(图) | 召回/延迟极佳、查询快;内存更高、构建更慢。常见默认。 |
| IVF / IVF-PQ | 先聚类再搜几个簇;PQ 压缩向量以省内存、代价是一点召回。适合超大语料。 |
| Flat(精确) | 完美召回、暴力搜——中小规模可以,扛不住 1 亿+。 |
无论你用专门的向量库(Pinecone、Weaviate、Qdrant、Milvus)还是 Postgres 上的 pgvector,扩缩关注点一样:向量很大、索引常常常驻内存,所以要按文档/租户分片到多节点、并做副本以保证可用性和读吞吐。一次查询扇出到各分片再合并 top 结果。估算经验:一个 768 维 float32 向量约 3KB,所以 1 亿块 ≈ 索引开销之前就有约 300GB 原始向量——这正是大规模下量化(PQ、int8)重要的原因。
第 5 步 —— 检索:走混合
纯向量搜索擅长语义匹配("怎么重置密码"能找到"账号恢复步骤"),但对精确词项很弱——错误码、SKU、生僻名——这些恰恰是 embedding 会糊掉的关键 token。解法是混合检索:并行跑向量搜索和经典关键词搜索(BM25),再融合排名(如 Reciprocal Rank Fusion)。也在这里施加元数据过滤——租户与访问控制(为正确性,绝不能省)、加上日期或文档类型约束。
第 6 步 —— 重排
检索为召回优化:撒大网,便宜地拉回约 100 个候选块。但你只能负担把少数几个放进 prompt,而这几个的顺序/精度决定答案质量。所以加一道重排:用 cross-encoder 模型对每个(查询,块)对联合打分——比一阶段检索用的 bi-encoder embedding 准得多,但贵到没法跑全语料,这正是它只在约 100 个候选上跑的原因。这种"宽检索、窄重排"的两段式,是 RAG 里最便宜的大质量提升,也是面试里值得主动提的点。
第 7 步 —— 有据生成
现在拼 prompt:用户的问题、重排后的 top 块(每块标上来源 id),以及一条指令——只用提供的上下文作答并引用用到的块,如果上下文不足就说不知道。这种"有据"把 LLM 从自信的瞎猜变成有出处的助手,而引用让用户能核实、也让你能度量忠实度。
# system
只用下面的上下文作答。来源用 [n] 引用。
如果上下文里没有答案,就说你不知道。
# context (重排后的 top 块)
[1] (doc: billing-faq#refunds) "退款在 5–7 天内到账……"
[2] (doc: policy-v3#cancel) "取消请进 设置 → 账单……"
# user
退款要多久?怎么取消?
这里有两个预算决定:放几个块(更多上下文可能有帮助,但增加 token、延迟,以及模型"迷失在长上下文中间"的风险),以及检索很弱时怎么办——如果 top 分数都很低,返回"没找到好答案"比逼模型瞎编要好。
第 8 步 —— 新鲜度与增量索引
语料从不静止,而每晚全量重建既慢又陈旧。服务路径需要一个持续更新的索引,所以把摄入接到变更事件上:文档创建/更新/删除发出一个事件(来自源库的 CDC 或 webhook),走同一条 分块 → 向量化 → upsert 路径,删除就移除该文档的块。结果是一个几分钟内最终一致的索引。给每个块保留 updated_at,以便过滤到新鲜内容并对账/回收陈旧块。
把索引当成文档的一个物化、可重放的投影——就像搜索索引一样。真相之源是文档;向量索引是派生的,所以你总能重新分块或重新向量化再重建。有了这个心态,升级向量化模型和改分块就是一次重建任务,而不是迁移危机。
第 9 步 —— 评测:RAG 三元组
测不了就改不了,而 RAG 在两个不同的地方会失败——检索和生成——所以两个都要评。常见的框架是 RAG 三元组:
- 上下文相关性 —— 检索返回的块是否真的和查询相关?(用标注集量 recall@k / precision。)
- 忠实度(有据性) —— 答案是否被检索到的块支撑,还是模型瞎编的?
- 答案相关性 —— 答案是否真的回应了问题?
建一个小的黄金集(问题 → 理想来源/答案)做离线回归,并用 LLM-as-judge 大规模给忠实度和相关性打分(见 LLM 应用的评测)。也采集线上信号——点赞/点踩、"引用的来源对不对"——并回灌。检索 bug(取错块)和生成 bug(无视块)需要不同的修法,只有分开的指标才能区分它们。
第 10 步 —— 扩缩与成本
三个成本中心主导,各有杠杆:
| 成本中心 | 杠杆 |
|---|---|
| 向量化(索引) | 在加速器上批量;只重新向量化变更的块;按内容哈希缓存 embedding。 |
| 索引内存 | 量化向量(PQ / int8);跨节点分片;冷数据分层到磁盘 ANN。 |
| 生成(每查询) | 更少更好的块(重排)→ 更短 prompt;重复查询缓存答案;简单题用小模型。 |
延迟方面,检索 + 重排通常几十毫秒;LLM 调用主导端到端时间,所以聊天助手里那套流式和缓存技巧同样适用。激进缓存:查询 embedding、热门查询的检索结果、完全重复查询的整段答案。
第 11 步 —— 失败模式与取舍
- 检索到错的 → 答案错。 垃圾块进、自信的垃圾出。这就是为什么混合 + 重排 + 评测比选哪个 LLM 更重要。
- 块大小是真取舍。 没有单一最优尺寸;对着评测集调,并考虑存小块、生成时再扩展到其周围上下文。
- 索引陈旧。 没有增量索引,答案会引用已删除或过时的文档——正确性和信任问题;CDC 驱动更新 + TTL/GC 是解法。
- 迷失在中间。 塞太多块可能反而有害——模型对长上下文的中间部分注意更少;更少、重排过的块往往胜过更多块。
- 租户泄露。 过滤 bug 返回了别的租户的块就是安全事故;访问过滤要在查询里执行,而不仅仅在 UI 上。
- 它仍会幻觉。 有据能减少但消不掉;引用 + 忠实度评测 + "说我不知道"是护栏。
RAG 是一个尾巴上钉了个 LLM 的检索系统——所以把设计精力花在检索上。两条管线(离线索引、在线查询)、结构感知的带重叠分块、一个你分片并量化的 ANN 索引、带元数据过滤的混合(向量 + BM25)检索、一道 cross-encoder 重排,以及有据带引用的生成。然后用 RAG 三元组评测闭环,因为知道检索有没有帮上忙的唯一办法就是去测。
一句话说 RAG 是什么? 检索相关文本塞进 prompt,让 LLM 基于你的数据作答——改的是 prompt,不是模型。
为什么要分块、怎么分? 你向量化/检索的是文本段,越长向量越糊;按结构切(约几百 token)、带重叠,并保留来源元数据。
为什么要混合检索? 向量擅长语义但漏精确词(码、名);加 BM25 并融合——再用 cross-encoder 重排候选。
为什么要重排? 一阶段检索便宜地最大化召回;cross-encoder 精确地把 top ~100 重排成 top ~5——最便宜的大质量提升。
怎么保持新鲜? CDC/webhook 驱动的增量索引,走同一条 分块→向量化→upsert 路径;索引是文档的可重放投影。
怎么评测? RAG 三元组——上下文相关性(recall@k)、忠实度、答案相关性——在黄金集上,大规模用 LLM-as-judge。