大语言模型(LLM)有几个根本性局限:
RAG(Retrieval-Augmented Generation,检索增强生成)正是为解决这些问题而生的。它的核心思想是:不依赖模型自身知识,而是让模型"先检索再回答"——先把私有知识存起来,问答时先去检索相关知识,再让模型基于检索到的内容生成答案。
本文从技术原理层面剖析RAG的每个环节:Embedding、向量化、向量索引、相似度检索、答案生成,手写代码带你理解每个细节。
RAG系统的数据流如下:
知识文档
│
▼
┌─────────────────────┐
│ 文档解析与切分 │ ← Text Splitter(文本切分器)
│ DocumentLoader │
└─────────┬───────────┘
│
▼
┌─────────────────────┐
│ 向量化嵌入 │ ← Embedding Model(嵌入模型)
│ Text → Vector │
└─────────┬───────────┘
│
▼
┌─────────────────────┐
│ 向量数据库存储 │ ← Vector DB(ChromaDB/Qdrant/Milvus)
│ (id, vector, meta) │
└─────────┬───────────┘
│
▼
┌─────────────────────┐
│ 用户查询 │
│ Query │
└─────────┬───────────┘
│
▼
┌─────────────────────┐
│ 查询向量化 │ ← 同上Embedding Model
│ Query → Vector │
└─────────┬───────────┘
│
▼
┌─────────────────────┐
│ 相似度检索 │ ← Vector Search(余弦相似度/内积/欧氏距离)
│ Top-K Retrieval │
└─────────┬───────────┘
│
▼
┌─────────────────────┐
│ 上下文构建 │ ← Prompt Template(提示词模板)
│ Context Builder │
└─────────┬───────────┘
│
▼
┌─────────────────────┐
│ LLM答案生成 │ ← LLM(GPT-4/Claude/本地模型)
│ Answer Generation │
└─────────┴───────────┘
│
▼
用户答案
知识文档通常以PDF、Word、HTML等格式存在。首先需要把文档解析成纯文本,然后按语义切分成小块。
import re
from pathlib import Path
class TextSplitter:
"""
文本切分器:将长文本按语义切分成适合检索的小块
切分策略有两种:
1. 固定长度切分:简单但破坏语义完整性
2. 语义切分:按句子/段落切分,保留语义完整性(推荐)
"""
def __init__(self, chunk_size=500, chunk_overlap=50):
"""
参数:
chunk_size: 每个文本块的目标字符数
chunk_overlap: 相邻块之间的重叠字符数(避免信息被切割开)
"""
self.chunk_size = chunk_size
self.chunk_overlap = chunk_overlap
def split_by_sentence(self, text: str) -> list[dict]:
"""
按句子切分(推荐方法)
原理:
1. 先用正则把文本分成句子(Sentence Tokenization)
2. 把句子组织成段落
3. 按段落累积,当达到chunk_size时切一刀
4. 相邻块之间保留chunk_overlap的重复内容,保证上下文连贯
"""
# 句子分割正则:匹配常见句子结束符
sentence_endings = r'[.!?。!?]+'
sentences = re.split(sentence_endings, text)
chunks = []
current_chunk = ""
current_size = 0
for sentence in sentences:
sentence = sentence.strip()
if not sentence:
continue
sentence_size = len(sentence)
# 如果当前块加上这个句子超过chunk_size,先保存当前块,再开新块
if current_size + sentence_size > self.chunk_size and current_chunk:
# 保存当前块(带ID和元数据)
chunks.append({
'text': current_chunk.strip(),
'length': current_size,
'start_idx': len(text) - len(current_chunk) - len(sentence),
})
# 开新块:保留chunk_overlap的重复内容(衔接上下文)
overlap_text = current_chunk[-self.chunk_overlap:] if len(current_chunk) > self.chunk_overlap else current_chunk
current_chunk = overlap_text + " " + sentence
current_size = len(current_chunk)
else:
current_chunk += " " + sentence
current_size += sentence_size + 1 # 加1是空格
# 处理最后一块
if current_chunk.strip():
chunks.append({
'text': current_chunk.strip(),
'length': current_size,
'start_idx': 0,
})
return chunks
# 使用示例
splitter = TextSplitter(chunk_size=500, chunk_overlap=50)
sample_text = """
人工智能(Artificial Intelligence,AI)是计算机科学的一个分支,致力于开发能够完成通常需要人类智能才能完成的任务的系统。
机器学习是人工智能的一个子集,它使用统计技术使计算机系统能够"学习"而无需明确编程。
深度学习是机器学习的子集,使用多层神经网络来分析各种形式的原始数据。
RAG(检索增强生成)结合了检索系统和语言模型的优势,用于知识密集型任务。
向量数据库是RAG系统中存储和检索嵌入向量的关键技术基础设施。
"""
chunks = splitter.split_by_sentence(sample_text)
for i, chunk in enumerate(chunks):
print(f"--- Chunk {i+1} (长度:{chunk['length']}) ---")
print(chunk['text'][:200] + "..." if len(chunk['text']) > 200 else chunk['text'])
print()
Embedding是RAG最核心的技术。它的作用是把文字转换成数学向量,让语义相似的文字在向量空间中距离相近。
最早的词向量是One-Hot编码:每个词表示为一个维度等于词表大小的向量,只有对应词的位置是1,其他都是0。这种表示丢失了语义信息——"猫"和"狗"的One-Hot向量在欧式空间中的距离和"猫"和"汽车"是一样的。
后来有了Word2Vec、GloVe等分布式表示,让语义相似的词在向量空间中靠近。但这些方法无法处理一词多义和上下文依赖。
现代RAG系统使用的是Transformer-based Embedding模型,典型代表是OpenAI的text-embedding-3-small、Google的Gemini Embedding、国产的Embedding模型如BGE等。
import numpy as np
def self_attention(Q, K, V, dk=64):
"""
简化版自注意力机制实现
原理:
1. QK^T 计算Query和Key的相似度(点积)
2. 除以√d_k 进行缩放,防止梯度消失
3. softmax归一化得到注意力权重
4. 权重乘以V得到加权和
"""
# 步骤1: 计算点积相似度
scores = np.dot(Q, K.T) # (seq_len, seq_len)
# 步骤2: 缩放(防止点积过大导致softmax梯度为0)
scaled_scores = scores / np.sqrt(dk)
# 步骤3: softmax归一化(得到注意力权重)
exp_scores = np.exp(scaled_scores - np.max(scaled_scores, axis=-1, keepdims=True))
attention_weights = exp_scores / np.sum(exp_scores, axis=-1, keepdims=True)
# 步骤4: 加权求和
output = np.dot(attention_weights, V)
return output, attention_weights
# 示例:计算"猫爱玩耍"和"狗喜欢运动"的注意力
# 实际Transformer中,这些向量是通过学习得到的,这里用简化示例
seq_len = 4 # 假设序列长度为4
d_model = 8 # 向量维度
# 模拟两个句子的词向量
sentence1 = np.random.randn(seq_len, d_model) # "猫爱玩耍"
sentence2 = np.random.randn(seq_len, d_model) # "狗喜欢运动"
# 计算注意力
output1, attn1 = self_attention(sentence1, sentence1, sentence1)
output2, attn2 = self_attention(sentence2, sentence2, sentence2)
print("句子1注意力权重形状:", attn1.shape)
print("句子2注意力权重形状:", attn2.shape)
两个向量在语义上是否相似,用余弦相似度来衡量:
import numpy as np
def cosine_similarity(vec_a: np.ndarray, vec_b: np.ndarray) -> float:
"""
计算两个向量的余弦相似度
公式:cos(θ) = (A·B) / (||A|| × ||B||)
"""
# 点积
dot_product = np.dot(vec_a, vec_b)
# L2范数(向量的模长)
norm_a = np.linalg.norm(vec_a)
norm_b = np.linalg.norm(vec_b)
# 避免除零
if norm_a == 0 or norm_b == 0:
return 0.0
return dot_product / (norm_a * norm_b)
def search_by_similarity(query_vector: np.ndarray,
document_vectors: dict[int, np.ndarray],
top_k: int = 5) -> list[tuple[int, float]]:
"""
向量检索:给定查询向量,从文档向量库中找出最相似的top_k个
参数:
query_vector: 查询的嵌入向量 (embedding_dim,)
document_vectors: 文档ID到向量的映射 {doc_id: vector}
top_k: 返回前k个最相似的结果
返回:
[(doc_id, similarity_score), ...],按相似度降序排列
"""
similarities = []
for doc_id, doc_vector in document_vectors.items():
sim = cosine_similarity(query_vector, doc_vector)
similarities.append((doc_id, sim))
# 按相似度降序排序
similarities.sort(key=lambda x: x[1], reverse=True)
return similarities[:top_k]
# 示例使用
np.random.seed(42)
# 模拟文档向量库:4个文档,嵌入维度768(实际常用)
doc_vectors = {
1: np.random.randn(768),
2: np.random.randn(768),
3: np.random.randn(768),
4: np.random.randn(768),
}
# 模拟查询向量(假设与文档1语义最接近)
query = doc_vectors[1] + np.random.randn(768) * 0.1 # 加噪声模拟微调
# 检索
results = search_by_similarity(query, doc_vectors, top_k=4)
print("检索结果(doc_id, similarity):")
for rank, (doc_id, score) in enumerate(results, 1):
print(f" Top {rank}: doc_{doc_id} = {score:.4f}")
结合上面的原理,下面是一个完整的RAG Pipeline代码:
Document self.vectors: dict[str, np.ndarray] = {} # doc_id -> vector # 提示词模板(实际项目中应该提取到配置) self.prompt_template = """你是一个智能助手。请根据以下参考材料回答用户问题。 参考材料: {context} 用户问题:{question} 回答要求: 1. 只基于参考材料回答,不要编造信息 2. 如果参考材料中没有相关信息,请如实说"我不知道" 3. 回答要具体、有条理 """ def add_documents(self, docs: list[Document], embeddings: list[np.ndarray]): """ 添加文档到知识库 实际调用时: 1. 文档先切分 2. 调用Embedding API获取每个块的向量 3. 存入向量数据库 """ for doc, emb in zip(docs, embeddings): self.documents[doc.id] = doc self.vectors[doc.id] = emb print(f"✅ 已添加 {len(docs)} 个文档到知识库") def retrieve(self, query: str, query_embedding: np.ndarray, top_k: int = 3, similarity_threshold: float = 0.7) -> list[检索Result]: """ 检索相关文档 参数: query: 查询文本 query_embedding: 查询的嵌入向量 top_k: 返回前k个结果 similarity_threshold: 相似度阈值,低于此值的结果被过滤 流程: 1. 计算查询向量与所有文档向量的相似度 2. 按相似度降序排列 3. 取top_k,同时过滤低于阈值的结果 """ results = [] for doc_id, doc_vector in self.vectors.items(): sim = cosine_similarity(query_embedding, doc_vector) if sim >= similarity_threshold: results.append(检索Result( document=self.documents[doc_id], similarity=sim, rank=0 )) # 按相似度排序 results.sort(key=lambda x: x.similarity, reverse=True) # 添加排名 for i, result in enumerate(results[:top_k], 1): result.rank = i return results[:top_k] def generate_context(self, query: str, retrieved_results: list[检索Result]) -> str: """ 构建提示词上下文 将检索到的文档内容拼接成LLM可读的格式 """ if not retrieved_results: return "没有找到相关的参考材料。" context_parts = [] for result in retrieved_results: source = result.document.metadata.get('source', '未知来源') context_parts.append( f"[来源:{source},相似度:{result.similarity:.2f}]\n" f"{result.document.content}" ) context = "\n\n---\n\n".join(context_parts) return context def build_prompt(self, question: str, context: str) -> str: """ 构建完整的提示词 """ return self.prompt_template.format( question=question, context=context ) def answer(self, question: str, query_embedding: np.ndarray, top_k: int = 3) -> dict: """ 完整的RAG问答流程 流程: 1. retrieve:检索相关文档 2. generate_context:构建上下文 3. build_prompt:构建提示词 4. LLM生成答案(这里用占位符,实际要调用LLM API) """ # Step 1: 检索 retrieved = self.retrieve(question, query_embedding, top_k=top_k) # Step 2: 构建上下文 context = self.generate_context(question, retrieved) # Step 3: 构建提示词 prompt = self.build_prompt(question, context) # Step 4: LLM生成(这里打印,实际项目要调用真实API) # answer = llm_api.call(prompt) answer = f"[模拟答案] 根据检索到的{len(retrieved)}个参考资料回答:..." return { 'answer': answer, 'prompt': prompt, 'retrieved_docs': [ { 'id': r.document.id, 'content_preview': r.document.content[:100] + '...', 'similarity': r.similarity, 'rank': r.rank } for r in retrieved ], 'context': context } # 使用示例(使用随机向量模拟Embedding) rag = SimpleRAG(embedding_dim=768) # 添加文档 docs = [ Document(id="doc1", content="Python是一种高级编程语言,由Guido van Rossum于1989年创建。", metadata={"source": "Python官方文档"}), Document(id="doc2", content="JavaScript是一种脚本语言,主要用于Web前端开发,现在也可用于服务端(Node.js)。", metadata={"source": "MDN文档"}), Document(id="doc3", content="Go语言由Google开发,于2009年发布,主要用于服务端编程和云计算。", metadata={"source": "Go官方文档"}), ] # 模拟Embedding(实际应调用API) embeddings = [np.random.randn(768) for _ in docs] rag.add_documents(docs, embeddings) # 模拟查询(与doc1最相关) query_emb = np.random.randn(768) * 0.1 + embeddings[0] # 加小噪声 # 执行RAG问答 result = rag.answer("Python是谁创建的?", query_emb, top_k=2) print("问题:Python是谁创建的?") print(f"答案:{result['answer']}") print(f"检索到的文档数:{len(result['retrieved_docs'])}") for doc in result['retrieved_docs']: print(f" - doc_{doc['id']}: 相似度={doc['similarity']:.4f}")
| 模型 | 提供商 | 维度 | 上下文 | 中文支持 | 推荐场景 |
|---|---|---|---|---|---|
| text-embedding-3-small | OpenAI | 1536(可压缩) | 8191 tokens | ✅ | 通用场景 |
| text-embedding-3-large | OpenAI | 3072 | 8191 tokens | ✅ | 高精度场景 |
| BAAI/bge-large-zh-v1.5 | 国产开源 | 1024 | 512 tokens | ✅✅(专训) | 中文场景 |
| sentence-transformers/all-MiniLM-L6-v2 | HuggingFace | 384 | 256 tokens | 一般 | 轻量快速 |
| Gemini Embedding | 768 | 2048 tokens | ✅ | 多语言 |
| 数据库 | 类型 | 索引算法 | 单机容量 | 部署难度 | 推荐度 |
|---|---|---|---|---|---|
| ChromaDB | 嵌入式 | HNSW | ~100万向量 | ⭐(Python直接用) | ⭐⭐⭐⭐(轻量首选) |
| Qdrant | 独立服务 | HNSW/SQ | ~1亿向量 | ⭐⭐(Docker一键) | ⭐⭐⭐⭐⭐(生产首选) |
| Milvus | 分布式 | HNSW/IVF | ~10亿向量 | ⭐⭐⭐⭐ | ⭐⭐⭐(超大规模) |
| Pinecone | 云服务 | 专有 | 无限 | ⭐(API直连) | ⭐⭐⭐⭐(不想运维) |
| pgvector | PostgreSQL插件 | HNSW/IVF | 取决于PG | ⭐⭐(有PG基础) | ⭐⭐⭐(已有PG) |
| 指标 | 含义 | 计算方式 | 理想值 |
|---|---|---|---|
| Recall@K | Top-K检索的相关文档召回率 | 检索到的相关文档数 / 总相关文档数 | 越高越好 |
| Precision@K | Top-K检索结果中相关文档的比例 | 检索到的相关文档数 / K | 越高越好 |
| MRR | 平均倒数排名(第一个相关文档的排名倒数) | mean(1/rank_i) | 越接近1越好 |
| NDCG@K | 考虑排名位置的质量指标 | 归一化DCG | 越接近1越好 |
| 方案 | 回答准确率 | 幻觉率 | 响应时间 | 适用场景 |
|---|---|---|---|---|
| 纯LLM(无RAG) | ~60% | ~40% | ~2s | 通用知识 |
| Naive RAG | ~75% | ~20% | ~3s | 基础问答 |
| Advanced RAG | ~85% | ~10% | ~4s | 生产环境 |
| RAG + 重排序 | ~90% | ~5% | ~5s | 高精度场景 |
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 检索不到相关内容 | Embedding质量差/切分策略不对 | 换更好的Embedding模型,调整chunk_size |
| 检索到的内容不相关 | 相似度阈值过高/索引构建错误 | 降低阈值,检查向量维度是否一致 |
| 回答有幻觉 | 提示词没强调"仅用参考材料"、LLM过于自信 | 优化提示词,加LLM的temperature调低 |
| 响应速度慢 | 向量数据库查询慢 + LLM生成慢 | 用HNSW索引,加LLM流式输出 |
# 快速启动一个生产可用的RAG系统(基于LangChain + ChromaDB + Ollama)
# 前提:已安装Ollama并下载模型
# Step 1: 安装依赖
pip install langchain langchain-community chromadb ollama
# Step 2: 初始化ChromaDB
python << 'EOF'
from langchain.document_loaders import TextLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain.embeddings import OllamaEmbeddings
from langchain.vectorstores import Chroma
# 加载文档
loader = TextLoader("./knowledge.txt")
documents = loader.load()
# 切分
splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=50)
chunks = splitter.split_documents(documents)
# 向量化存储
embeddings = OllamaEmbeddings(model="nomic-embed-text")
db = Chroma.from_documents(chunks, embeddings, persist_directory="./chroma_db")
# 查询
query = "你的问题"
docs = db.similarity_search(query, k=3)
print(docs[0].page_content)
EOF