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:

特性 纯 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(难度⭐)

PYTHON
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]}")
TEXT 📖 仅展示
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|}$$

(2) 向量数据库

向量数据库是专门存储和检索向量的系统,支持高效的近似最近邻(ANN)搜索。

数据库 类型 特点
Chroma 开源,嵌入式 Python 原生,适合原型开发
FAISS 开源,库 Meta 出品,速度极快,纯本地
Pinecone 云服务 全托管,易扩展,按用量付费
Milvus 开源,分布式 适合大规模生产环境
Qdrant 开源 Rust 实现,性能优秀

▶ 示例:计算两个句子的余弦相似度(难度⭐)

PYTHON
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}")
TEXT 📖 仅展示
Related sentences:   0.8234
Unrelated sentences: 0.3012

▶ 示例:简易向量检索——找最相似的文档(难度⭐⭐)

PYTHON
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}")
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 将"检索"与"生成"结合,形成一条完整链路:

  1. 用户提问:用户输入自然语言问题
  2. 查询 Embedding:将问题转为向量
  3. 向量检索:在向量数据库中搜索最相似的文档片段(Top-K)
  4. 上下文增强:将检索到的文档拼入 Prompt
  5. LLM 生成:LLM 基于增强后的 Prompt 回答问题

(2) Mermaid 流程图

100%
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) 关键设计决策

▶ 示例:完整 RAG 调用流程(难度⭐⭐)

PYTHON
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)
TEXT 📖 仅展示
The daily travel allowance is $45 for domestic trips.

▶ 示例:对比有无 RAG 的回答质量(难度⭐⭐)

PYTHON
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))
TEXT 📖 仅展示
=== 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) 分块参数建议

(4) 使用 LangChain 进行递归分块

PYTHON
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()
TEXT 📖 仅展示
--- 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 回答。

▶ 示例:迷你知识库问答系统(难度⭐⭐⭐)

PYTHON
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()
TEXT 📖 仅展示
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 的价值。


❓ 常见问题

Q RAG 和微调哪个好?
A 两者解决不同问题。RAG 适合知识密集型任务(让 LLM 访问外部知识),无需重新训练;微调适合风格/格式适配(让 LLM 学会特定的输出格式)。很多场景下两者互补——先用 RAG 提供知识,再用微调优化风格。RAG 成本更低、更新更快,通常是首选。
Q 向量数据库是什么?
A 向量数据库是专门存储高维向量并支持快速相似度搜索的系统。传统数据库按精确匹配查询,向量数据库按"语义相似度"查询。常见选择:Chroma(嵌入式,适合开发)、FAISS(纯库,速度快)、Pinecone(全托管云服务)。
Q Embedding 维度越高越好吗?
A 不一定。维度越高,理论上能编码更多语义信息,但也意味着更大的存储和计算开销。实际效果取决于模型质量和训练数据。例如 text-embedding-3-small(1536 维)在很多任务上已足够,text-embedding-3-large(3072 维)只在特定场景有显著提升。选择时平衡精度与成本。
Q 为什么分块大小很重要?
A 块太大:检索不够精准,可能引入大量无关内容,浪费 Token 且干扰 LLM。块太小:缺少上下文,检索到的片段可能无法回答完整问题。一般问答场景推荐 256~512 token,配合 10%~20% 的重叠以避免语义断裂。
Q RAG 能完全消除幻觉吗?
A 不能完全消除,但能大幅减少。RAG 提供了上下文约束,LLM 更倾向于基于给定信息回答。但 LLM 仍可能:忽略上下文、过度推理、或上下文本身不完整时编造。可通过以下方式进一步降低:明确指令"仅基于上下文回答"、设置 temperature=0、加入 Reranking、以及回答中要求引用来源。

📖 小节


📝 作业

基础(⭐)

用 OpenAI Embeddings API 对以下 5 段文本生成向量,并计算两两之间的余弦相似度:

PYTHON
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 检索函数,支持以下功能:

PYTHON
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 系统,完整实现以下链路:

  1. 文档加载与递归分块(chunk_size=300, overlap=60)
  2. 生成 Embedding 并存储
  3. 用户提问 → 检索 Top-3 → 拼接增强 Prompt → LLM 回答
  4. 对比同一问题有无 RAG 的回答差异
  5. 测试一个知识库中不存在的问题,验证 RAG 是否正确拒绝回答

扩展挑战:加入 Reranking 步骤——先用 Top-10 检索,再用 LLM 对 10 个结果评分排序,取 Top-3 送入最终生成。

Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏