Skip to content

引言

假设你的公司有一万份内部文档——产品手册、技术规范、会议纪要、项目报告。你想做一个 AI 助手,能回答关于这些文档的问题。

最直觉的想法:把所有文档塞进上下文窗口。但一万份文档可能有几千万 token,远远超出任何模型的上下文窗口。

RAG(Retrieval-Augmented Generation,检索增强生成) 给出了一个优雅的解决方案:不把所有文档都给模型,而是先找到最相关的几份,只把这些放进上下文

就像开卷考试——你不需要把整本教材背下来,只需要知道去哪页找答案。


RAG 的核心流程

RAG = Retrieval + Augmented + Generation

  ┌──────────────┐
  │  用户提问     │
  └──────┬───────┘


  ┌──────────────┐
  │  检索(R)    │  ← 从知识库中找到最相关的文档片段
  │  Retrieval   │
  └──────┬───────┘


  ┌──────────────┐
  │  增强(A)    │  ← 把检索到的内容放入 prompt
  │  Augmented   │
  └──────┬───────┘


  ┌──────────────┐
  │  生成(G)    │  ← 模型基于检索内容生成回答
  │  Generation  │
  └──────┬───────┘


  ┌──────────────┐
  │  最终回答     │
  └──────────────┘

第一步:Embedding——把文本变成向量

什么是 Embedding?

Embedding:把文本映射为高维空间中的向量(一组数字)

  "人工智能改变世界" → [0.123, -0.456, 0.789, ..., 0.234]
                         通常是 768 或 1536 维

关键特性:语义相近的文本 → 向量也相近

  "人工智能" → [0.12, -0.45, 0.79, ...]
  "AI"      → [0.11, -0.44, 0.80, ...]   ← 很接近
  "苹果"    → [0.78, 0.23, -0.56, ...]   ← 很远

Embedding 代码示例

python
from openai import OpenAI

client = OpenAI()

def get_embedding(text: str) -> list[float]:
    response = client.embeddings.create(
        model="text-embedding-3-small",
        input=text
    )
    return response.data[0].embedding

# 语义相近的文本,向量距离更近
v1 = get_embedding("机器学习")
v2 = get_embedding("深度学习")
v3 = get_embedding("红烧肉")

import numpy as np

def cosine_similarity(a, b):
    return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))

print(f"机器学习 vs 深度学习: {cosine_similarity(v1, v2):.3f}")  # ~0.85
print(f"机器学习 vs 红烧肉:  {cosine_similarity(v1, v3):.3f}")  # ~0.20

第二步:向量数据库

为什么需要专门的数据库?

传统数据库:精确匹配
  SELECT * FROM docs WHERE title = "AI 入门"
  → 只能找到标题完全匹配的文档

向量数据库:语义搜索
  查询 "人工智能入门"
  → 能找到标题为 "AI 导论"、"机器学习基础" 等语义相近的文档

主流向量数据库对比

数据库类型特点
Chroma嵌入式最简单,适合原型和小项目
FAISSMeta 开源,性能极高,不支持持久化
Pinecone云服务全托管,无需运维
Milvus分布式企业级,支持大规模部署
Qdrant独立服务Rust 实现,性能好
pgvectorPostgreSQL 扩展和现有 PG 数据库一起用

Chroma 快速上手

python
import chromadb

# 初始化客户端
client = chromadb.PersistentClient(path="./chroma_db")

# 创建集合
collection = client.get_or_create_collection(
    name="company_docs",
    metadata={"hnsw:space": "cosine"}
)

# 添加文档
docs = [
    {"id": "doc1", "text": "公司年假政策:入职满一年可享受 10 天年假。", "metadata": {"source": "hr handbook"}},
    {"id": "doc2", "text": "报销流程:填写报销单 → 直属领导审批 → 财务审核。", "metadata": {"source": "finance"}},
    {"id": "doc3", "text": "请假需提前三天通过 OA 系统提交申请。", "metadata": {"source": "hr handbook"}},
]

collection.add(
    ids=[d["id"] for d in docs],
    documents=[d["text"] for d in docs],
    metadatas=[d["metadata"] for d in docs]
)

# 查询
results = collection.query(
    query_texts=["我想请假,怎么操作?"],
    n_results=2  # 返回最相关的 2 条
)

for doc, dist in zip(results["documents"][0], results["distances"][0]):
    print(f"[距离: {dist:.3f}] {doc}")

第三步:Chunking——文档切分

为什么需要切分?

问题:
  一份文档可能有 10000 个 token
  你不可能把整篇文档放进上下文(浪费 token)
  也不可能把每个字都单独检索(丢失上下文)

解决方案:把文档切成适当大小的片段(chunk)

  完整文档:
  ┌──────────────────────────────────────────┐
  │ 第一章:概述...(2000 token)             │
  │ 第二章:技术方案...(3000 token)          │
  │ 第三章:实施计划...(2500 token)          │
  │ 第四章:预算...(1500 token)              │
  └──────────────────────────────────────────┘

  切分后:
  ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐
  │ Chunk 1    │ │ Chunk 2    │ │ Chunk 3    │ │ Chunk 4    │
  │ 第一章概述  │ │ 第二章技术  │ │ 第三章实施  │ │ 第四章预算  │
  │ ~500 token │ │ ~500 token │ │ ~500 token │ │ ~500 token │
  └────────────┘ └────────────┘ └────────────┘ └────────────┘

  检索时只返回最相关的 2-3 个 chunk

切分策略

python
# 策略 1:固定大小切分
from langchain.text_splitter import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=500,      # 每个 chunk 约 500 字符
    chunk_overlap=50,    # 相邻 chunk 重叠 50 字符
    separators=["\n\n", "\n", "。", "!", "?", " "]  # 优先在段落/句子边界切
)

chunks = splitter.split_text(long_document)
策略方法优点缺点
固定大小按 token/字符数切简单可能切断语义
递归切分按分隔符层级切保留段落/句子结构长短不一
语义切分按 embedding 相似度切语义完整计算成本高
文档结构按标题/章节切最符合文档逻辑需要解析文档结构

第四步:完整 RAG 管线

python
from openai import OpenAI
import chromadb
import json

openai_client = OpenAI()
chroma_client = chromadb.PersistentClient(path="./company_docs_db")
collection = chroma_client.get_or_create_collection("docs")

def rag_query(question: str, n_results: int = 3) -> str:
    """完整的 RAG 查询流程"""

    # Step 1: 检索(Retrieval)
    results = collection.query(
        query_texts=[question],
        n_results=n_results
    )

    # Step 2: 增强(Augmented)— 构造增强 prompt
    context = "\n\n".join(results["documents"][0])

    messages = [
        {"role": "system", "content": f"""你是公司内部知识库助手。
基于以下参考文档回答用户问题。
如果文档中没有相关信息,说「根据现有文档无法回答」。
不要编造信息。

参考文档:
{context}"""},
        {"role": "user", "content": question}
    ]

    # Step 3: 生成(Generation)
    response = openai_client.chat.completions.create(
        model="gpt-4o",
        messages=messages,
        temperature=0  # RAG 场景用低温度,减少编造
    )

    return response.choices[0].message.content

# 使用
answer = rag_query("公司年假怎么算?")
print(answer)
# → "根据文档,入职满一年可享受 10 天年假。"

RAG 的 Prompt 设计

python
RAG_SYSTEM_PROMPT = """你是公司内部知识库助手。

规则:
1. 只基于提供的参考文档回答
2. 如果文档中没有相关信息,说「现有文档中没有相关信息」
3. 引用具体的文档来源
4. 不要使用模型自身知识补充回答
5. 如果多个文档有冲突信息,全部列出并标注来源

参考文档:
{context}"""

本节小结

概念要点
RAG 定义Retrieval-Augmented Generation,检索增强生成
Embedding把文本映射为向量,语义相近的文本向量也相近
向量数据库存储和搜索 embedding,实现语义检索
Chunking文档切分:固定大小、递归、语义、结构化
RAG 管线查询 → Embedding → 向量搜索 → 构造增强 prompt → 生成
Prompt 设计明确指示模型只基于文档回答,不要编造

思考题

  1. 为什么 RAG 能减少幻觉?它的信息来源和纯模型回答有什么区别?
  2. 如果两个 chunk 的内容互相矛盾(比如两份文档有不同的政策),模型应该怎么处理?
  3. Chunk 的大小会影响 RAG 的效果。太大的 chunk 和太小的 chunk 分别有什么问题?