RAG 的完整流程是什么?
结论:RAG 由离线索引与在线查询两条流水线组成,二者只在向量库(Vector Store)处汇合。 离线把"文档"变成"可检索的向量 + 原文 + 元数据",在线把"问题"变成"带证据的提示词(Prompt)"。 面试时按"离线四步、在线五步"讲,再点出每一步的失败模式,比背名词更有说服力。
1. 全景图
离线索引(一次性 / 增量)
采集 → 解析 → 清洗 → 分块 → 向量化 → 入库
raw text text chunk vector index + metadata
└──────┬──────┘
向量库(唯一交汇点)
┌──────┴──────┐
在线查询(每次请求)
问题 → 改写 → 向量化 → 召回 → 重排 → 编排 → 生成 → 引用
query rewrite vector top-k top-n prompt answer cite
两条线唯一的强耦合是向量化必须用同一个 Embedding 模型,模型一换,全库重算。
2. 离线阶段:把文档变成可检索的数据
| 步骤 | 输入 | 输出 | 关键决策 | 常见故障 |
|---|---|---|---|---|
| 采集 | PDF/Word/网页/库表 | 原始字节 | 全量还是增量、去重策略 | 版本混杂,旧制度和新制度同时入库 |
| 解析 | 原始字节 | 带结构的文本 | 版式还原、表格转 Markdown | 多栏 PDF 读成乱序,页眉页脚混进正文 |
| 分块 | 长文本 | 300~800 token 的片段 | 切分单位、重叠比例 | 一句话被切成两半,主语和谓语分家 |
| 向量化 | 片段文本 | 定长浮点向量 | 模型、维度、是否归一化 | 中英混排、缩写术语召回差 |
| 入库 | 向量 + 原文 | 索引记录 | 元数据字段设计 | 缺 source/page,导致无法给出处 |
解析这一步最容易被低估:一份扫描件 OCR 出错,后面所有环节都在为它擦屁股。
3. 在线阶段:把问题变成带证据的回答
| 步骤 | 作用 | 典型耗时 | 不做会怎样 |
|---|---|---|---|
| 改写 | 补全指代、拆子问题 | 100~300 ms | "它多少钱" 检索不到任何东西 |
| 向量化 | 问题转向量 | 30~80 ms | — |
| 召回 | top-k 粗排 | 5~30 ms | 没有证据,模型只能编 |
| 重排 | top-k → top-n | 50~200 ms | 噪声片段挤掉正确片段 |
| 编排 | 拼提示词、截断、排序 | <5 ms | 长上下文里关键证据被忽略 |
| 生成 | 基于证据作答 | 0.5~3 s | — |
4. 一个能跑通的主干实现
# rag.py —— 只保留主干,异常与重试从略
import hashlib
from openai import OpenAI
import chromadb
llm = OpenAI()
store = chromadb.PersistentClient(path="./chroma").get_or_create_collection(
name="kb", metadata={"hnsw:space": "cosine"}
)
def embed(texts: list[str]) -> list[list[float]]:
# 一次批量送,比逐条调用省一个数量级的往返开销
resp = llm.embeddings.create(model="text-embedding-3-small", input=texts)
return [d.embedding for d in resp.data]
def index(chunks: list[str], source: str) -> None:
# 用内容哈希做主键:同一片段重复入库自然覆盖,增量更新无需删表
ids = [hashlib.md5(c.encode()).hexdigest() for c in chunks]
store.upsert(
ids=ids, documents=chunks, embeddings=embed(chunks),
metadatas=[{"source": source, "seq": i} for i in range(len(chunks))],
)
def answer(question: str, k: int = 5) -> str:
hits = store.query(query_embeddings=embed([question]), n_results=k)
ctx = "\n\n".join(f"[{i + 1}] {d}" for i, d in enumerate(hits["documents"][0]))
resp = llm.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system",
"content": "只依据资料回答;资料不足就直说不知道;引用资料时用 [n] 标注编号。"},
{"role": "user", "content": f"资料:\n{ctx}\n\n问题:{question}"},
],
)
return resp.choices[0].message.content
5. 常见追问
Q:能边查边切块吗? 可以把切片放到查询时做,但每次请求都要重新解析和向量化,延迟翻倍;只有文档极少时才划算。
Q:k 取多少? 召回阶段宁多勿少,k 取 2050,交给重排压到 38 条。直接 k=3 是把召回和精排的职责混在一起。
Q:引用怎么落地? 靠入库时写进元数据的 source、page、section,生成阶段让模型输出编号,前端再把编号映射回原文,别指望模型自己复述文件路径。
Q:文档更新了怎么办? 按内容哈希做幂等写入,改过的片段覆盖,删掉的文档按 source 批量清理,避免旧版本残留污染召回。
小结
RAG 的流程本身不复杂,复杂的是每个环节的数据质量与可观测性。先把解析和分块做扎实,再谈容器与索引选型。