RAG 检索增强生成:让模型用上你的语料 本节摘要:你的 LLM 知道训练截止日之前的一切,却对公司内部文档、你的代码库、上周的会议纪要一无所知。RAG(检索增强生成,Retrieval-Augmented Generation)解决的就是这个问题:在回答前先检索相关文档,把它们拼进提示,让模型基于这些段落作答。它是生产 AI 里部署最广的模式,如果你整套课程只做一件东西,就做一条 RAG 管线。
本节摘要:你的 LLM 知道训练截止日之前的一切,却对公司内部文档、你的代码库、上周的会议纪要一无所知。RAG(检索增强生成,Retrieval-Augmented Generation)解决的就是这个问题:在回答前先检索相关文档,把它们拼进提示,让模型基于这些段落作答。它是生产 AI 里部署最广的模式,如果你整套课程只做一件东西,就做一条 RAG 管线。本节带你吃透 RAG 的全貌:为什么它比微调更适合知识接地型应用(成本、新鲜度、可审计、可与任意模型搭配);嵌入模型如何把文本变成稠密向量;三种向量相似度(余弦、点积、L2)的差异;固定/语义/递归三种分块策略与 256~512 token 的甜蜜区;向量数据库的选型;以及一条从零实现的「分块-TF-IDF 嵌入-余弦检索-提示增强-生成」完整管线,只换两个函数就能接 OpenAI/Anthropic 真实模型。
对应原课程:Phase 11 · Lesson 06 ·
rag(原英文phases/11-llm-engineering/06-rag/docs/en.md)。
阅读完本节,你应当能够:
你给公司做了个客服聊天机器人。客户问:「企业版的退款政策是什么?」LLM 给出了一段关于「典型 SaaS 退款政策」的泛泛回答。而真正的政策——企业客户有 60 天窗口、按比例退款——埋在一份 200 页的内部 wiki 里。LLM 从没见过这份文档,它不可能知道训练数据里没有的东西。
微调是一种解法:把 LLM 拿来,在你的内部文档上训练,部署更新后的模型。可行,但问题严重:每次训练要花几千美元算力;文档一改模型就过时;你无法追溯答案来自哪个来源;下个月公司收购新产品线,又得重训一次。
RAG 是另一种解法:模型原封不动。问题进来时,搜索你的文档库找相关段落,把它们贴在问题前面拼进提示,让模型基于这些段落作答。文档库几分钟就能更新;你能看到具体检索到了哪些文档;模型本身永不改变。这就是 RAG 在生产里占主导的原因:更便宜、更新鲜、更可审计、可与任意 LLM 搭配。
整个模式只有四步:
查询 → 检索 → 增强提示 → 生成。每个 RAG 系统都遵循这个模式。生产级 RAG 之间的差异,全在每一步的细节:怎么分块、怎么嵌入、怎么搜、怎么拼提示。
| 关注点 | 微调 | RAG |
|---|---|---|
| 成本 | 每次训练 1000~100000 美元以上 | 每次查询 0.01~0.10 美元(嵌入 + LLM) |
| 新鲜度 | 重训前一直过时 | 重新索引文档,几分钟更新 |
| 可审计 | 无法追溯答案来源 | 能展示具体检索到的段落 |
| 幻觉 | 依然自由幻觉 | 锚定于检索文档 |
| 数据隐私 | 训练数据烘焙进权重 | 文档留在你的向量库里 |
微调永久改变模型权重,RAG 临时改变模型上下文。对多数应用,临时上下文才是你要的。
微调胜出的唯一场景:你需要模型习得某种特定的风格、语气或推理模式,而这种模式靠提示做不到。对事实知识检索,RAG 永远胜出。
嵌入模型把文本转成稠密向量。语义相近的文本在这个高维空间里向量也靠近。「如何重置密码?」和「我需要改密码」几乎没有共同词,却产生几乎相同的向量;而「猫坐在垫子上」的向量则截然不同。
主流嵌入模型(2026 年阵容,详见第 5 章 22 节):
| 模型 | 维度 | 提供方 | 备注 |
|---|---|---|---|
| text-embedding-3-small | 1536(Matryoshka) | OpenAI | 多数场景的最佳性价比 |
| text-embedding-3-large | 3072(Matryoshka) | OpenAI | 精度更高,可截到 256/512/1024 |
| Gemini Embedding 2 | 3072(Matryoshka) | MTEB 检索榜首,8K 上下文 | |
| voyage-4 | 1024/2048(Matryoshka) | Voyage AI | 有代码/金融/法律领域变体 |
| Cohere embed-v4 | 1024(Matryoshka) | Cohere | 多语言强,128K 上下文 |
| BGE-M3 | 1024(稠密+稀疏+ColBERT) | 智源(开源) | 一个模型三种视图 |
| Qwen3-Embedding | 4096(Matryoshka) | 阿里(开源) | 开源检索分榜首 |
| all-MiniLM-L6-v2 | 384 | 开源(Sentence Transformers) | 原型基线 |
本节我们用 TF-IDF 自己做一个简单嵌入。不是因为生产系统用 TF-IDF,而是因为它把概念变得具体:文本进去,向量出来,相似文本产生相似向量。
给定两个向量,怎么量相似度?三种选择。
余弦相似度:两向量夹角的余弦,范围 -1(相反)到 1(相同)。忽略模长,只看方向。这是 RAG 的默认选择。
cosine_sim(a, b) = dot(a, b) / (||a|| * ||b||)
点积:原始内积。模长大的向量得分更高。当模长承载信息时(更长的文档可能更相关)有用。
dot(a, b) = sum(a_i * b_i)
L2(欧氏)距离:向量空间里的直线距离。距离越小越相似,对模长差异敏感。
L2(a, b) = sqrt(sum((a_i - b_i)^2))
余弦相似度是标准。它优雅地处理不同长度文档,因为它按模长归一化。当有人说「向量搜索」,几乎总是指余弦相似度。
文档太长,不能整篇嵌成一个向量。一份 50 页 PDF 嵌出来的向量会很糟,因为它涉及几十个主题。正确做法是把文档切成块,每块单独嵌。
固定大小分块:每 N 个 token 切一刀。简单可预测。512 token 块、50 token 重叠,意味着块 1 是 token 0511,块 2 是 token 462973,依此类推。重叠确保你不会在倒霉的边界上把句子劈成两半。
语义分块:在自然边界切。段落、小节、Markdown 标题。每块是一个连贯的语义单元。实现更复杂,但检索质量更好。
递归分块:先尝试在最大的边界切(小节标题);某节还是太大就在段落边界切;某段还是太大就在句子边界切。这是 LangChain RecursiveCharacterTextSplitter 的思路,实战效果好。
块大小比人们以为的更重要:
多数生产 RAG 系统用 256~512 token 块、50 token 重叠。Anthropic 的 RAG 指南推荐这个区间。
有了嵌入,得有个地方存和搜。选项:
| 数据库 | 类型 | 适合 |
|---|---|---|
| FAISS | 库(进程内) | 原型、中小数据集 |
| Chroma | 轻量数据库 | 本地开发、小规模部署 |
| Pinecone | 托管服务 | 不想运维的生产 |
| Weaviate | 开源数据库 | 自托管生产 |
| pgvector | Postgres 扩展 | 已经在用 Postgres |
| Qdrant | 开源数据库 | 高性能自托管 |
本节我们做一个简单的内存向量库:把向量存在列表里,做暴力余弦搜索。这等价于 FAISS 的 flat 索引,大概能撑到 10 万向量才变慢。生产系统用 HNSW 这类近似最近邻(ANN)算法,毫秒级搜百万向量。
索引阶段每份文档跑一次(或文档更新时跑);查询阶段每次请求都跑。生产里,索引可能数小时处理百万文档;查询必须在 1 秒内响应。
多数生产 RAG 系统用这些参数:
完整代码见原课程 code/rag_pipeline.py,这里给出关键骨架。
def chunk_text(text, chunk_size=200, overlap=50): 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
TF-IDF(词频-逆文档频率)不是神经嵌入,但它把文本转成向量,且能体现词的重要性:文档里高频的词 TF 高,整个语料里罕见的词 IDF 高,乘积让重要且独特的词拿到高值。
import math from collections import Counter def build_vocabulary(documents): vocab = set() for doc in documents: vocab.update(doc.lower().split()) return sorted(vocab) def compute_tf(text, vocab): words = text.lower().split() count = Counter(words) total = len(words) return [count.get(w, 0) / total for w in vocab] def compute_idf(documents, vocab): n = len(documents) idf = [] for w in vocab: doc_count = sum(1 for doc in documents if w in doc.lower().split()) idf.append(math.log((n + 1) / (doc_count + 1)) + 1) # 平滑 return idf def tfidf_embed(text, vocab, idf): tf = compute_tf(text, vocab) return [t * i for t, i in zip(tf, idf)]
def cosine_similarity(a, b): dot = sum(x * y for x, y in zip(a, b)) norm_a = math.sqrt(sum(x * x for x in a)) norm_b = math.sqrt(sum(x * x for x in b)) if norm_a == 0 or norm_b == 0: return 0.0 return dot / (norm_a * norm_b) def search(query_embedding, stored_embeddings, top_k=5): scores = [(i, cosine_similarity(query_embedding, emb)) for i, emb in enumerate(stored_embeddings)] scores.sort(key=lambda x: x[1], reverse=True) return scores[:top_k]
「增强」就发生在这里。把检索到的块格式化进提示,要求 LLM 只基于给定上下文作答。
def build_rag_prompt(query, retrieved_chunks): context = "\n\n---\n\n".join( f"[来源 {i+1}]\n{chunk}" for i, chunk in enumerate(retrieved_chunks) ) return f"""只根据以下上下文回答问题。 若上下文信息不足,就说"我没有足够的信息来回答这个问题"。 上下文: {context} 问题:{query} 答案:"""
💡 「只根据上下文」这句护栏很关键。没有它,模型会偷偷调用训练知识填补空白,产生「看起来对、其实编造」的答案——这正是 RAG 要消灭的幻觉。
class RAGPipeline: def __init__(self): self.chunks, self.embeddings = [], [] self.vocab, self.idf = [], [] def index(self, documents): all_chunks = [] for doc in documents: all_chunks.extend(chunk_text(doc)) self.chunks = all_chunks self.vocab = build_vocabulary(all_chunks) self.idf = compute_idf(all_chunks, self.vocab) self.embeddings = [tfidf_embed(c, self.vocab, self.idf) for c in all_chunks] def query(self, question, top_k=5): q_emb = tfidf_embed(question, self.vocab, self.idf) results = search(q_emb, self.embeddings, top_k) retrieved = [(self.chunks[i], score) for i, score in results] prompt = build_rag_prompt(question, [c for c, _ in retrieved]) return prompt, retrieved
生产中这里调 LLM API。本节用一个简化版:从检索上下文里抽出与查询重叠最多的句子。
def simple_generate(prompt, retrieved_chunks): query_words = set(prompt.lower().split("问题:")[-1].split()) best_sentence, best_score = "", 0 for chunk in retrieved_chunks: for sentence in chunk.split("。"): sentence = sentence.strip() if not sentence: continue words = set(sentence.lower().split()) overlap = len(query_words & words) if overlap > best_score: best_score, best_sentence = overlap, sentence return best_sentence if best_score else "我没有足够的信息。"
换成真实嵌入模型和 LLM,代码几乎不变:
# from openai import OpenAI # client = OpenAI() # def embed(text): # r = client.embeddings.create(model="text-embedding-3-small", input=text) # return r.data[0].embedding # def generate(prompt): # r = client.chat.completions.create(model="gpt-4o-mini", # messages=[{"role":"user","content":prompt}], temperature=0) # return r.choices[0].message.content
Anthropic 同理:
# import anthropic # client = anthropic.Anthropic() # def generate(prompt): # r = client.messages.create(model="claude-sonnet-5", max_tokens=1024, # messages=[{"role":"user","content":prompt}]) # return r.content[0].text
管线不变:换嵌入函数、换生成函数。检索逻辑、分块、提示构造——无论用哪家模型都一样。
大规模向量存储,把暴力搜索换成正经向量库:
# import chromadb # client = chromadb.Client() # collection = client.create_collection("my_docs") # collection.add(documents=chunks, ids=[f"chunk_{i}" for i in range(len(chunks))]) # results = collection.query(query_texts=["退款政策是什么?"], n_results=5)
Chroma 内部处理嵌入(默认用 all-MiniLM-L6-v2),把向量存进本地数据库。模式相同,管道不同。
LangChain 与 LlamaIndex 提供更高层抽象:RecursiveCharacterTextSplitter 做分块,VectorStoreRetriever 做检索,RetrievalQA 链把检索与生成串起来。优点是少写胶水代码,代价是把分块大小、重叠、k 值这些关键旋钮藏在默认值后面——你不调,就吃它的默认。本节的从零管线让你看见每一个旋钮。
本节产出两个可复用文件(位于原课程 outputs/):
prompt-rag-architect.md:一个元提示,为特定用例设计 RAG 系统。喂给它你的语料规模、查询类型、延迟预算,它给出分块策略、嵌入模型、k 值、提示模板的建议。skill-rag-pipeline.md:一个技能文件,教 Agent 如何搭建和调试 RAG 管线。Python 代码(code/rag_pipeline.py)是一条独立管线,把 tfidf_embed 换成真实嵌入 API、simple_generate 换成真实 LLM,即可投产;分块、检索、提示构造逻辑无需改动。
换词袋嵌入:把 TF-IDF 换成简单的二值词袋(词出现为 1,不现为 0),在样例文档上对比检索质量。TF-IDF 应当胜出,因为它给罕见词更高权重。
块大小实验:在同一文档集上试 50、100、200、500 词四种块大小。每种跑同样 5 条查询,统计有多少查询在 top-3 里返回了相关块。找出检索质量峰值的甜蜜区。
加元数据与溯源:给每块加元数据(源文档名、块位置)。修改提示模板加入来源归因,让 LLM 引用来源。
检索评估:给定 10 个问答对,让每个问题过 RAG 管线,测量有多少比例的检索块包含答案——这就是 retrieval recall@k。
对话感知 RAG:维护最近 3 轮对话历史,与检索块一起放进提示。用「那企业版呢?」这种追问测试,看历史上下文是否让追问被正确理解。
下一节,我们进入进阶 RAG——更聪明的分块、混合检索(稠密 + 稀疏 BM25)、重排序、查询改写,把这条朴素管线打磨成生产级。