Ch13 RAG修订于 二〇二六年七月二日
13.2 RAG 系统架构
RAG 看起来很直观,但背后是一套工程系统。理解它的架构,你才能理解为什么”分块大小”、“检索策略”、“重排序”这些细节如此重要。
13.2.1 两条管线:离线索引 + 在线查询
RAG 系统由两条管线组成,一条在用户提问前运行(离线),一条在用户提问时运行(在线)。
离线索引管线(用户提问前):这条管线的目标是把你的文档库变成可检索的向量数据库。它不赶时间,可以慢慢跑,但必须跑得完整。
文档收集 → 文档解析 → 文本分块 → Embedding编码 → 存入向量数据库
每一步的细节:
- 文档收集:从各种来源拉取文档——本地文件、网页、数据库、API、飞书文档、Notion、Confluence。你需要一个”连接器”(Connector)层,对接各种数据源。
- 文档解析:把 PDF、Word、HTML、Markdown 等格式转成纯文本。这一步最容易被忽视,但解析质量直接影响后续所有环节。PDF 里的表格、图片、双栏排版,解析不好,文本就乱了。
- 文本分块:把长文档切成小块。为什么?因为 Embedding 模型有输入长度限制(通常 512 token),而且小块检索更精准。分块策略是 RAG 性能的”地基”——分得不好,后面全白搭。详细策略见下一章。
- Embedding 编码:把每个文本块转换成一个向量(一串数字)。语义相近的文本,向量也相近。这个向量就是”语义指纹”。
- 存入向量数据库:把向量和原始文本一起存到向量数据库里。向量数据库支持”近似最近邻”(ANN)搜索,能在毫秒级从百万甚至十亿级别的向量中找到最相似的几个。
在线查询管线(用户提问时):这条管线要快,用户等不了。
用户提问 → Query处理 → Query Embedding → 向量检索 → 重排序 → Prompt拼接 → LLM生成 → 返回答案
每一步的细节:
- Query 处理:用户的问题可能很口语化、很模糊、缺少关键信息。你可以做查询改写(把”那个东西怎么用”改写成”XX产品使用教程”)、查询扩展(生成多个相关查询,从不同角度检索)。
- Query Embedding:把处理后的查询转成向量。
- 向量检索:在向量数据库中搜索最相似的 Top-K 个文档块(K 通常取 10-50)。
- 重排序:初检结果可能不够精准。用更强大的重排序模型(Cross-Encoder)对 Top-K 结果重新打分,选出最相关的 Top-N(N 通常取 3-10)。
- Prompt 拼接:把检索到的文档块和用户问题按照模板拼成完整的 Prompt。模板里通常包含系统指令、检索到的文档内容、用户问题、以及格式要求(如”请标注引用来源”)。
- LLM 生成:LLM 读取 Prompt,基于检索到的文档生成答案。
- 返回答案:把答案和引用来源一起返回给用户。
13.2.2 为什么两条管线要分开
因为它们的性能要求完全不同。离线管线追求”完整”和”准确”——宁可慢,不能漏。在线管线追求”快”——用户期望秒级响应。把耗时的索引工作放在离线做,查询时只需要做快速的向量检索和 LLM 生成。
13.2.3 一个完整的 RAG 系统代码示例
下面是一个最小但完整的 RAG 系统,用 Python 伪代码展示核心流程。每一行都对应架构图中的一个环节:
# ===== 离线索引管线 =====
# 1. 文档收集 & 解析
documents = load_documents("knowledge_base/") # 从文件夹加载所有文档
texts = [parse_document(doc) for doc in documents] # PDF/Word/HTML → 纯文本
# 2. 文本分块
from langchain.text_splitter import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=512, # 每块最多 512 个 token
chunk_overlap=50, # 相邻块重叠 50 token,避免语义断裂
separators=["\n\n", "\n", "。", ".", " ", ""] # 优先在自然边界切分
)
chunks = splitter.split_text("\n\n".join(texts))
# 3. Embedding 编码
from openai import OpenAI
client = OpenAI()
embeddings = []
for chunk in chunks:
resp = client.embeddings.create(
model="text-embedding-3-small", # OpenAI 的 Embedding 模型
input=chunk
)
embeddings.append(resp.data[0].embedding)
# 4. 存入向量数据库
import chromadb
vector_db = chromadb.Client()
collection = vector_db.create_collection(name="my_knowledge_base")
for i, (chunk, emb) in enumerate(zip(chunks, embeddings)):
collection.add(
ids=[str(i)],
embeddings=[emb],
documents=[chunk],
metadatas=[{"source": f"doc_{i}"}]
)
# ===== 在线查询管线 =====
def rag_query(user_question: str, top_k: int = 10, top_n: int = 3) -> str:
# 5. Query Embedding
query_emb = client.embeddings.create(
model="text-embedding-3-small",
input=user_question
).data[0].embedding
# 6. 向量检索
results = collection.query(
query_embeddings=[query_emb],
n_results=top_k # 先取 top_k 个候选
)
# 7. 重排序(这里用简单的 Cross-Encoder 示意)
from sentence_transformers import CrossEncoder
reranker = CrossEncoder("BAAI/bge-reranker-v2-m3")
pairs = [[user_question, doc] for doc in results["documents"][0]]
scores = reranker.predict(pairs)
# 按分数重排,取 top_n
ranked = sorted(
zip(results["documents"][0], scores),
key=lambda x: x[1], reverse=True
)[:top_n]
retrieved_docs = [doc for doc, score in ranked]
# 8. Prompt 拼接
context = "\n\n---\n\n".join(retrieved_docs)
prompt = f"""你是一个专业的知识助手。请根据以下参考资料回答用户问题。
## 参考资料
{context}
## 用户问题
{user_question}
## 要求
1. 基于参考资料回答,不要编造信息
2. 如果参考资料不足以回答问题,请明确说明
3. 在答案中标注引用来源(如"[来源1]")"""
# 9. LLM 生成
response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": prompt}],
temperature=0.3, # RAG 场景建议低温,减少幻觉
stream=True # 流式输出
)
# 10. 返回答案
answer = ""
for chunk in response:
if chunk.choices[0].delta.content:
answer += chunk.choices[0].delta.content
return answer
这个例子虽然只有约 60 行代码,但它完整覆盖了 RAG 系统的全部核心环节。理解了每一行在做什么,你就理解了 RAG 的工程全貌。从这个小系统出发,你可以逐步优化每一个环节——换更好的分块策略、升级 Embedding 模型、加入混合检索、用专门的重排序服务——每一步优化都会带来可感知的质量提升。
← 上一节:13.1 为什么需要 RAG | 下一节:13.3 分块策略 —— RAG 的地基 →