AI: RAG 检索增强
最后更新:2026-08-26
1. 你将学到
❶ RAG 解决什么问题——LLM 的知识截止与幻觉
❷ 向量嵌入(Embedding)的直觉理解
❸ 相似度搜索——余弦相似度
❹ RAG 流程:检索 → 增强 → 生成
❺ 用 Python 实现一个简易 RAG 系统
2. 故事
Alice 在一家中型企业负责内部知识管理。公司积累了 1000 多份 PDF 文档——从报销政策到安全规程,应有尽有。每当新员工提问,Alice 只能手动翻文档,效率很低。
她尝试直接用 ChatGPT 回答内部问题,但 ChatGPT 对公司文档一无所知,还经常"编造"不存在的政策——比如声称"差旅补贴每天 $80",而实际政策是 $45。
Charlie 提出了一个方案:"先从知识库中找到与问题相关的段落,再让 LLM 基于这些段落回答——这样它就不会瞎编了。" 这就是 RAG(Retrieval-Augmented Generation,检索增强生成)。
Alice 照着做了:把文档分块、生成向量、存入向量数据库;用户提问时,先检索最相关的段落,再拼进 Prompt 让 LLM 回答。从此,回答有了"依据",幻觉大幅减少,新员工也能快速获得准确答案。
3. LLM 的知识截止与幻觉问题
(1) 知识截止
LLM 的知识来自训练数据,训练完成后它就"冻结"了。GPT-4 的训练数据截止到 2023 年,之后发生的事它一概不知。更关键的是,你的私有数据(公司文档、个人笔记)从未进入训练集,LLM 天然无法回答。
(2) 幻觉
当 LLM 遇到不知道的问题时,它不会说"我不知道",而是自信地编造一个看起来合理的答案——这就是幻觉(Hallucination)。幻觉在事实性问答中尤为危险。
(3) RAG 的核心思路
与其让 LLM"记住"所有知识,不如在提问时动态提供相关资料,让 LLM 基于给定上下文回答。这就是 RAG:
- Retrieval:从知识库检索相关文档
- Augmented:将检索结果拼入 Prompt 作为上下文
- Generation:LLM 基于增强后的 Prompt 生成回答
| 特性 | 纯 LLM | RAG 增强 |
|---|---|---|
| 知识来源 | 仅训练数据 | 训练数据 + 外部文档 |
| 私有数据 | 无法访问 | 可检索提供 |
| 幻觉风险 | 高(易编造) | 低(有上下文约束) |
| 知识更新 | 需重新训练 | 更新文档即可 |
| 成本 | 推理成本 | 推理 + 检索成本 |
| 可溯源性 | 无 | 可引用来源段落 |
4. 向量嵌入(Embedding)
(1) 文本如何变成向量
Embedding 是将文本映射为高维向量的过程。例如,一个句子经过 Embedding 模型后变成一个 1536 维的浮点数组。语义相近的文本,向量也相近;语义不同的文本,向量距离远。
可以把 Embedding 想象成"语义坐标系":每个文本在坐标系中有一个位置,越近的文本语义越相似。
(2) 常见 Embedding 模型
| 模型 | 维度 | 提供方 | 特点 |
|---|---|---|---|
| text-embedding-3-small | 1536 | OpenAI | 性价比高,速度快 |
| text-embedding-3-large | 3072 | OpenAI | 精度更高,成本更高 |
| text-embedding-ada-002 | 1536 | OpenAI | 上一代,仍广泛使用 |
| bge-large-en-v1.5 | 1024 | BAAI | 开源,英文表现优秀 |
| bge-large-zh-v1.5 | 1024 | BAAI | 开源,中文表现优秀 |
| m3e-base | 768 | Moka AI | 开源,中英文兼顾 |
▶ 示例:用 OpenAI API 生成文本 Embedding(难度⭐)
from openai import OpenAI
client = OpenAI()
def get_embedding(text: str, model: str = "text-embedding-3-small") -> list[float]:
response = client.embeddings.create(input=text, model=model)
return response.data[0].embedding
text = "The company travel allowance is $45 per day."
vector = get_embedding(text)
print(f"Dimension: {len(vector)}")
print(f"First 5 values: {vector[:5]}")
Dimension: 1536
First 5 values: [0.0123, -0.0045, 0.0378, -0.0211, 0.0056]
5. 相似度搜索与余弦相似度
(1) 余弦相似度
余弦相似度衡量两个向量方向的相似程度,取值范围 [-1, 1]:
$$\text{cosine_similarity}(A, B) = \frac{A \cdot B}{|A| \times |B|}$$
- 1:方向完全相同(语义最相似)
- 0:正交(无关联)
- -1:方向相反(语义相反)
(2) 向量数据库
向量数据库是专门存储和检索向量的系统,支持高效的近似最近邻(ANN)搜索。
| 数据库 | 类型 | 特点 |
|---|---|---|
| Chroma | 开源,嵌入式 | Python 原生,适合原型开发 |
| FAISS | 开源,库 | Meta 出品,速度极快,纯本地 |
| Pinecone | 云服务 | 全托管,易扩展,按用量付费 |
| Milvus | 开源,分布式 | 适合大规模生产环境 |
| Qdrant | 开源 | Rust 实现,性能优秀 |
▶ 示例:计算两个句子的余弦相似度(难度⭐)
import numpy as np
def cosine_similarity(a: list[float], b: list[float]) -> float:
a_arr = np.array(a)
b_arr = np.array(b)
dot = np.dot(a_arr, b_arr)
norm_a = np.linalg.norm(a_arr)
norm_b = np.linalg.norm(b_arr)
return float(dot / (norm_a * norm_b))
sentences = {
"travel_allowance": get_embedding("The company travel allowance is $45 per day."),
"reimbursement": get_embedding("How do I claim travel reimbursement?"),
"unrelated": get_embedding("The weather is sunny today."),
}
sim1 = cosine_similarity(sentences["travel_allowance"], sentences["reimbursement"])
sim2 = cosine_similarity(sentences["travel_allowance"], sentences["unrelated"])
print(f"Related sentences: {sim1:.4f}")
print(f"Unrelated sentences: {sim2:.4f}")
Related sentences: 0.8234
Unrelated sentences: 0.3012
▶ 示例:简易向量检索——找最相似的文档(难度⭐⭐)
def top_k_search(
query_embedding: list[float],
doc_embeddings: list[list[float]],
doc_texts: list[str],
k: int = 3,
) -> list[tuple[str, float]]:
similarities = [
(doc_texts[i], cosine_similarity(query_embedding, doc_embeddings[i]))
for i in range(len(doc_texts))
]
similarities.sort(key=lambda x: x[1], reverse=True)
return similarities[:k]
documents = [
"Travel allowance is $45 per day for domestic trips.",
"International travel requires VP approval in advance.",
"Employees must submit receipts within 30 days.",
"Remote work policy allows up to 2 days per week at home.",
"The office kitchen is stocked with free coffee and snacks.",
]
doc_embeddings = [get_embedding(doc) for doc in documents]
query = "How much can I spend on travel per day?"
query_embedding = get_embedding(query)
results = top_k_search(query_embedding, doc_embeddings, documents, k=3)
for text, score in results:
print(f"[{score:.4f}] {text}")
[0.8912] Travel allowance is $45 per day for domestic trips.
[0.7634] Employees must submit receipts within 30 days.
[0.7102] International travel requires VP approval in advance.
6. RAG 架构:检索 → 增强 → 生成
(1) 完整 RAG 流程
RAG 将"检索"与"生成"结合,形成一条完整链路:
- 用户提问:用户输入自然语言问题
- 查询 Embedding:将问题转为向量
- 向量检索:在向量数据库中搜索最相似的文档片段(Top-K)
- 上下文增强:将检索到的文档拼入 Prompt
- LLM 生成:LLM 基于增强后的 Prompt 回答问题
(2) Mermaid 流程图
graph TB
A["User Question"] --> B["Query Embedding"]
B --> C["Vector Search"]
D["Document Chunks<br/>+ Embeddings"] --> C
C --> E["Top-K Results"]
E --> F["Augmented Prompt<br/>Context + Question"]
F --> G["LLM Generation"]
G --> H["Answer"]
(3) 关键设计决策
- Top-K 选择:K 太小可能遗漏相关信息,K 太大会引入噪声。通常 K=3~5。
- Prompt 模板:明确指示 LLM "仅基于提供的上下文回答"以减少幻觉。
- 重排序(Reranking):可选步骤,用交叉编码器对检索结果二次排序,提升精度。
▶ 示例:完整 RAG 调用流程(难度⭐⭐)
from openai import OpenAI
client = OpenAI()
def rag_query(question: str, documents: list[str], k: int = 3) -> str:
query_emb = get_embedding(question)
doc_embs = [get_embedding(doc) for doc in documents]
top_docs = top_k_search(query_emb, doc_embs, documents, k=k)
context = "\n\n".join([f"[Doc {i+1}] {text}" for i, (text, _) in enumerate(top_docs)])
prompt = f"""Answer the question based ONLY on the following context.
If the context does not contain the answer, say "I don't know."
Context:
{context}
Question: {question}
Answer:"""
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
temperature=0,
)
return response.choices[0].message.content
docs = [
"Travel allowance is $45 per day for domestic trips.",
"International travel requires VP approval at least 2 weeks in advance.",
"Employees must submit expense reports within 30 days of the trip.",
"The maximum hotel reimbursement is $200 per night.",
"Flight bookings must use the company travel portal.",
]
answer = rag_query("What is the daily travel allowance?", docs)
print(answer)
The daily travel allowance is $45 for domestic trips.
▶ 示例:对比有无 RAG 的回答质量(难度⭐⭐)
def ask_without_rag(question: str) -> str:
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": question}],
temperature=0,
)
return response.choices[0].message.content
question = "What is Acme Corp's daily travel allowance?"
print("=== Without RAG ===")
print(ask_without_rag(question))
print("\n=== With RAG ===")
print(rag_query(question, docs))
=== Without RAG ===
I don't have specific information about Acme Corp's daily travel allowance.
Company travel allowances vary, but a common range is $50-$100 per day.
Please check your company's travel policy for the exact amount.
=== With RAG ===
The daily travel allowance is $45 for domestic trips.
没有 RAG 时,LLM 要么承认不知道,要么编造一个通用答案;有了 RAG,回答精确且有依据。
7. Chunk 分块策略
(1) 为什么需要分块
文档通常很长,无法直接嵌入。需要将长文档切分为较小的块(Chunk),再分别生成 Embedding。分块策略直接影响检索质量。
(2) 常见分块策略
| 策略 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 固定大小分块 | 每 N 个 token 切一段 | 实现简单 | 可能切断语义 |
| 句子级分块 | 按句号/换行切分 | 语义完整 | 块可能太小或太大 |
| 段落级分块 | 按段落切分 | 语义连贯 | 长度不均匀 |
| 语义分块 | 基于Embedding相似度检测语义边界 | 语义最完整 | 计算成本高 |
| 递归字符分块 | 按分隔符层级递归切分 | 平衡语义与长度 | 需调参 |
(3) 分块参数建议
- Chunk Size:通常 256~1024 token,取决于模型和场景
- Chunk Overlap:相邻块重叠 10%~20%,避免语义断裂
- 经验法则:问答场景用较小块(256~512),摘要场景用较大块(512~1024)
(4) 使用 LangChain 进行递归分块
from langchain.text_splitter import RecursiveCharacterTextSplitter
text = """Acme Corp Travel Policy
1. Domestic Travel
The daily travel allowance is $45. This covers meals and incidental expenses.
Hotel reimbursement is capped at $200 per night.
2. International Travel
All international trips require VP approval at least 2 weeks in advance.
The daily allowance for international travel is $75.
Hotel reimbursement is capped at $300 per night.
3. Expense Reporting
All expense reports must be submitted within 30 days of trip completion.
Receipts are required for all expenses over $25."""
splitter = RecursiveCharacterTextSplitter(
chunk_size=200,
chunk_overlap=50,
separators=["\n\n", "\n", ". ", " "],
)
chunks = splitter.split_text(text)
for i, chunk in enumerate(chunks):
print(f"--- Chunk {i+1} ---")
print(chunk)
print()
--- Chunk 1 ---
Acme Corp Travel Policy
1. Domestic Travel
The daily travel allowance is $45. This covers meals and incidental expenses.
Hotel reimbursement is capped at $200 per night.
--- Chunk 2 ---
2. International Travel
All international trips require VP approval at least 2 weeks in advance.
The daily allowance for international travel is $75.
--- Chunk 3 ---
international travel is $75.
Hotel reimbursement is capped at $300 per night.
3. Expense Reporting
All expense reports must be submitted within 30 days of trip completion.
8. 综合示例:迷你知识库问答系统
构建一个完整的 RAG 链路:3 篇文档 → 分块 → Embedding → 存储 → 用户提问 → 检索 → 拼接 Prompt → LLM 回答。
▶ 示例:迷你知识库问答系统(难度⭐⭐⭐)
import numpy as np
from openai import OpenAI
client = OpenAI()
# --- Step 1: Prepare documents ---
documents = [
{
"title": "Travel Policy",
"content": (
"Acme Corp Travel Policy\n\n"
"1. Domestic Travel\n"
"The daily travel allowance is $45 for domestic trips. "
"This covers meals and incidental expenses. "
"Hotel reimbursement is capped at $200 per night.\n\n"
"2. International Travel\n"
"All international trips require VP approval at least 2 weeks in advance. "
"The daily allowance for international travel is $75. "
"Hotel reimbursement is capped at $300 per night."
),
},
{
"title": "Leave Policy",
"content": (
"Acme Corp Leave Policy\n\n"
"1. Annual Leave\n"
"Full-time employees receive 15 days of paid annual leave per year. "
"Unused leave can be carried over to the next year, up to a maximum of 5 days.\n\n"
"2. Sick Leave\n"
"Employees receive 10 days of paid sick leave per year. "
"A doctor's note is required for absences exceeding 3 consecutive days."
),
},
{
"title": "IT Security Policy",
"content": (
"Acme Corp IT Security Policy\n\n"
"1. Password Requirements\n"
"All passwords must be at least 12 characters long and include uppercase, "
"lowercase, numbers, and special characters. "
"Passwords must be changed every 90 days.\n\n"
"2. Device Policy\n"
"Personal devices may not connect to the corporate network. "
"All company devices must have approved antivirus software installed."
),
},
]
# --- Step 2: Chunk documents ---
def chunk_text(text: str, chunk_size: int = 200, overlap: int = 50) -> list[str]:
words = text.split()
chunks = []
start = 0
while start < len(words):
end = start + chunk_size
chunks.append(" ".join(words[start:end]))
start += chunk_size - overlap
return chunks
all_chunks: list[str] = []
chunk_sources: list[str] = []
for doc in documents:
chunks = chunk_text(doc["content"])
for chunk in chunks:
all_chunks.append(chunk)
chunk_sources.append(doc["title"])
print(f"Total chunks: {len(all_chunks)}")
# --- Step 3: Generate embeddings ---
def get_embedding(text: str, model: str = "text-embedding-3-small") -> list[float]:
response = client.embeddings.create(input=text, model=model)
return response.data[0].embedding
chunk_embeddings = [get_embedding(chunk) for chunk in all_chunks]
print(f"Embedding dimension: {len(chunk_embeddings[0])}")
# --- Step 4: Vector search ---
def cosine_similarity(a: list[float], b: list[float]) -> float:
a_arr = np.array(a)
b_arr = np.array(b)
return float(np.dot(a_arr, b_arr) / (np.linalg.norm(a_arr) * np.linalg.norm(b_arr)))
def search(query: str, k: int = 3) -> list[tuple[str, str, float]]:
query_emb = get_embedding(query)
scores = [
(all_chunks[i], chunk_sources[i], cosine_similarity(query_emb, chunk_embeddings[i]))
for i in range(len(all_chunks))
]
scores.sort(key=lambda x: x[2], reverse=True)
return scores[:k]
# --- Step 5: RAG query ---
def rag_answer(question: str, k: int = 3) -> str:
results = search(question, k=k)
context_parts = []
for i, (chunk, source, score) in enumerate(results):
context_parts.append(f"[Source: {source}, Relevance: {score:.2f}]\n{chunk}")
context = "\n\n---\n\n".join(context_parts)
prompt = f"""Answer the question based ONLY on the following context.
If the context does not contain enough information, say "I don't have enough information."
Always cite which source document your answer comes from.
Context:
{context}
Question: {question}
Answer:"""
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
temperature=0,
)
return response.choices[0].message.content
# --- Step 6: Test ---
questions = [
"What is the daily travel allowance for domestic trips?",
"How many days of annual leave do employees get?",
"What are the password requirements?",
"What is the company's remote work policy?",
]
for q in questions:
print(f"Q: {q}")
print(f"A: {rag_answer(q)}")
print()
Total chunks: 9
Embedding dimension: 1536
Q: What is the daily travel allowance for domestic trips?
A: The daily travel allowance for domestic trips is $45, according to the Travel Policy.
Q: How many days of annual leave do employees get?
A: Full-time employees receive 15 days of paid annual leave per year, according to the Leave Policy.
Q: What are the password requirements?
A: Passwords must be at least 12 characters long and include uppercase, lowercase, numbers, and special characters. They must be changed every 90 days, according to the IT Security Policy.
Q: What is the company's remote work policy?
A: I don't have enough information. The provided context does not contain any remote work policy.
注意最后一个问题:RAG 正确地回答"信息不足",而不是编造答案——这正是 RAG 的价值。
❓ 常见问题
📖 小节
- LLM 存在知识截止和幻觉问题,无法直接回答私有数据相关的问题
- RAG 通过"检索 → 增强 → 生成"的流程,让 LLM 基于外部文档回答,大幅减少幻觉
- Embedding 将文本转为向量,语义相近的文本向量也相近
- 余弦相似度衡量向量方向的一致性,是语义搜索的核心度量
- 分块策略影响检索质量,递归字符分块是最常用的平衡方案
- 向量数据库(Chroma / FAISS / Pinecone)提供高效的向量存储与检索
- 完整 RAG 链路:文档分块 → Embedding → 存储 → 查询 → 检索 → 拼接 Prompt → LLM 生成
📝 作业
基础(⭐)
用 OpenAI Embeddings API 对以下 5 段文本生成向量,并计算两两之间的余弦相似度:
texts = [
"Cats are popular pets known for their independence.",
"Dogs are loyal companions that love to play fetch.",
"The stock market rose 3% today on strong earnings.",
"Felines enjoy climbing and sleeping in sunny spots.",
"Interest rates were raised by the central bank.",
]
将结果输出为 5×5 的相似度矩阵,观察哪些文本相似度最高。
进阶(⭐⭐)
实现一个通用的 Top-K 检索函数,支持以下功能:
def vector_search(
query: str,
corpus: list[str],
k: int = 5,
embedding_model: str = "text-embedding-3-small",
) -> list[dict]:
"""
Returns top-k most similar documents with scores.
Each result is a dict: {"text": ..., "score": ..., "rank": ...}
"""
# Your implementation here
pass
要求:用 10 段以上的文档测试,验证检索结果的合理性。
挑战(⭐⭐⭐)
构建一个包含至少 5 篇文档的迷你 RAG 系统,完整实现以下链路:
- 文档加载与递归分块(chunk_size=300, overlap=60)
- 生成 Embedding 并存储
- 用户提问 → 检索 Top-3 → 拼接增强 Prompt → LLM 回答
- 对比同一问题有无 RAG 的回答差异
- 测试一个知识库中不存在的问题,验证 RAG 是否正确拒绝回答
扩展挑战:加入 Reranking 步骤——先用 Top-10 检索,再用 LLM 对 10 个结果评分排序,取 Top-3 送入最终生成。