一、背景:大模型为什么需要 RAG
大模型有两个硬伤,靠换更大的模型也解决不了:
- 知识截止:模型只知道训练时见过的东西。上周发的论文、公司内部文档、昨天的销售数据,它一概不知,问就是编(幻觉)。
- 更新贵:为了一批新文档去微调模型,又贵又慢还容易灾难性遗忘。
检索增强生成(RAG)的思路朴素而有效:问问题时,先从你自己的知识库里检索出相关文档片段,把它们塞进提示词,再让模型”照着材料答”。答案有出处、可追溯,加新文档只需重新索引,不用动模型。这就是 2026 年企业 AI 落地最主流架构的原因:准确、可更新、可审计、便宜。
本教程用 LangChain 生态从零搭一条完整文档问答管线:加载 → 切分 → 嵌入建库 → 检索 → 生成,并讲透每个环节的调参直觉,最后给出重排与 RAGAS 评测的生产化路线。
二、原理:RAG 五步到底在干什么
离线(建库):
PDF/网页/表格 → Load(文档对象) → Chunk(512+overlap) → Embed(向量)
→ 存入向量库(Chroma)
在线(问答):
用户问题 → Embed(同一模型) → 向量库找 Top-k 最相似chunk
→ 拼成 context + 提示词模板 → LLM 生成 grounded 答案
2.1 为什么这样work
Embedding 模型把文本映射成向量,语义相近的文本在向量空间里距离近。检索就是”找离问题向量最近的 k 个文档向量”(余弦相似度)。LLM 拿到这些片段后做的是它最擅长的事:阅读理解 + 组织语言,而不是凭记忆编造。
2.2 每个环节的直觉
- Load:LangChain 的 Loader 把各种格式统一成
Document(page_content, metadata),metadata(页码、来源)是答案引用的基础。 - Chunk(最被低估的一环):块太大混入无关文字干扰检索,太小则语义破碎答不了。512 tokens + 50 overlap 是经验起点,递归 splitter 优先在段落/句 boundary 处切。
- Embed:
text-embedding-3-small这类小 embedding 性价比最高;检索质量七成看 embedding 和切分,三成看 LLM。 - Retrieve:
k=4是平衡点——context 太少答不全,太多(长 context)模型会”迷失在中间”且 token 烧钱。 - Generate:提示词必须写死”只能依据 context 回答,不知道就说不知道”,否则 RAG 白搭,模型照样编。
三、环境准备
pip install langchain langchain-community langchain-openai chromadb pypdf
需要一个 OpenAI 兼容 API(OpenAI / DeepSeek / 通义等均可,代码用环境变量切换,embedding 与 chat 模型可分开配):
export LLM_API_KEY="你的key"
export LLM_BASE_URL="https://api.openai.com/v1"
export LLM_MODEL="gpt-4o-mini"
export EMB_MODEL="text-embedding-3-small"
准备一份测试 PDF(公司手册、论文、政策文件都行),命名为 doc.pdf 放当前目录。没有现成 PDF?用 Word 导出一页也行,但至少准备 20 页以上,否则检索太 trivial、体会不到调参差异。
四、分步实战
4.1 步骤一:加载与解析文档
# step1_load.py
from langchain_community.document_loaders import PyPDFLoader
loader = PyPDFLoader("doc.pdf")
documents = loader.load()
print(f"加载 {len(documents)} 页")
print("第一页前200字:", documents[0].page_content[:200])
print("元数据示例:", documents[0].metadata)
# metadata 里有 source 与 page,后面做"答案引用第几页"全靠它
Loader 一览(接口都是 .load(),换数据源只换类):
from langchain_community.document_loaders import (
PyPDFLoader, # PDF
WebBaseLoader, # 网页
TextLoader, # 纯文本
CSVLoader, # 表格
UnstructuredWordDocumentLoader, # Word
)
注意:PyPDFLoader 一页是一个 Document;扫描版 PDF 没有文本层,读出来是空串——这种情况先走 OCR(或直接看本系列的像素级 RAG 教程)。
4.2 步骤二:切分(chunk 策略讲透)
# step2_chunk.py
from langchain_community.document_loaders import PyPDFLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
documents = PyPDFLoader("doc.pdf").load()
splitter = RecursiveCharacterTextSplitter(
chunk_size=512,
chunk_overlap=50,
separators=["\n\n", "\n", ". ", " ", ""],
)
chunks = splitter.split_documents(documents)
print(f"切出 {len(chunks)} 个块")
print("块0长度:", len(chunks[0].page_content))
print(chunks[0].page_content[:300])
递归 splitter 的工作方式:先试 \n\n(段落)切得下吗?切不下再试 \n、句号、空格,最后才硬切。chunk_overlap=50 让相邻块有 50 字符重叠,跨块的句子不断裂。
调参指南:
| 文档类型 | chunk_size | overlap | 说明 |
|---|---|---|---|
| 通用问答起点 | 512 | 50 | 本教程默认 |
| 长篇报告/论文 | 800~1000 | 100~150 | 论述完整优先 |
| FAQ/短条目 | 256~300 | 30 | 条目级精确命中 |
| 表格密集 | 按行/按表切 | — | 别用字符切,用结构切 |
验证切分质量的方法:随机抽 10 个块人工读一遍——块内是否语义完整、有没有半句话、有没有把表格拦腰斩断。切分是 RAG 里 ROI 最高的优化点。
4.3 步骤三:嵌入与建库(Chroma 持久化)
# step3_index.py
import os
from langchain_community.document_loaders import PyPDFLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import Chroma
documents = PyPDFLoader("doc.pdf").load()
chunks = RecursiveCharacterTextSplitter(
chunk_size=512, chunk_overlap=50).split_documents(documents)
embeddings = OpenAIEmbeddings(
model=os.environ.get("EMB_MODEL", "text-embedding-3-small"),
api_key=os.environ["LLM_API_KEY"],
base_url=os.environ.get("LLM_BASE_URL"))
vectorstore = Chroma.from_documents(
documents=chunks,
embedding=embeddings,
persist_directory="./chroma_db",
)
print(f"已索引 {len(chunks)} 个块到 ./chroma_db")
要点:embedding 模型一旦选定就不要轻易换——换模型=全库重算向量;persist_directory 落盘后下次直接 Chroma(persist_directory=..., embedding_function=...) 加载,不用重建;生产环境换 Pinecone/pgvector 只换这一行,上层检索代码不动,这就是 LangChain 抽象的价值。
4.4 步骤四:检索(Top-k 与调试)
# step4_retrieve.py
import os
from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import Chroma
embeddings = OpenAIEmbeddings(model=os.environ.get("EMB_MODEL", "text-embedding-3-small"),
api_key=os.environ["LLM_API_KEY"],
base_url=os.environ.get("LLM_BASE_URL"))
vectorstore = Chroma(persist_directory="./chroma_db",
embedding_function=embeddings)
retriever = vectorstore.as_retriever(search_type="similarity",
search_kwargs={"k": 4})
query = "这份文档的主要结论是什么?"
hits = retriever.invoke(query)
for i, h in enumerate(hits, 1):
print(f"---命中{i}(页{h.metadata.get('page')})---")
print(h.page_content[:250], "\n")
检索调试三板斧:
- 看命中的块人眼是否相关——不相关先调切分/k,再怀疑 embedding;
- MMR 检索(
search_type="mmr"):结果同质化严重时用,兼顾相关性与多样性; - 带分数检索(
similarity_search_with_score):分数普遍偏低说明 query 与文档表述 gap 大,考虑 query 改写。
4.5 步骤五:生成 grounded 答案(提示词是安全带)
# step5_generate.py
import os
from langchain_openai import ChatOpenAI
from langchain.prompts import ChatPromptTemplate
llm = ChatOpenAI(model=os.environ.get("LLM_MODEL", "gpt-4o-mini"),
temperature=0,
api_key=os.environ["LLM_API_KEY"],
base_url=os.environ.get("LLM_BASE_URL"))
prompt_template = ChatPromptTemplate.from_template("""
你是严谨的文档问答助手,只能依据【资料】回答问题。
- 资料不足以回答时,直接说"我没有足够信息回答这个问题",不要编造。
- 回答后注明引用来源(第几页)。
【资料】:
{context}
【问题】:{question}
【回答】:
""")
def rag_query(question: str, retriever, k: int = 4) -> str:
hits = retriever.invoke(question)
context = "\n\n---\n\n".join(
f"[第{h.metadata.get('page', '?')}页] {h.page_content}" for h in hits)
chain = prompt_template | llm
resp = chain.invoke({"context": context, "question": question})
return resp.content
temperature=0 是问答场景的铁律:要的是稳定复述材料,不是发散创作。
4.6 步骤六:完整可跑主程序
# rag_app.py —— 五步合一,开箱即问
import os
from langchain_community.document_loaders import PyPDFLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings, ChatOpenAI
from langchain_community.vectorstores import Chroma
from langchain.prompts import ChatPromptTemplate
PDF, DB = "doc.pdf", "./chroma_db"
EMB = os.environ.get("EMB_MODEL", "text-embedding-3-small")
GEN = os.environ.get("LLM_MODEL", "gpt-4o-mini")
def build_index():
docs = PyPDFLoader(PDF).load()
chunks = RecursiveCharacterTextSplitter(
chunk_size=512, chunk_overlap=50).split_documents(docs)
emb = OpenAIEmbeddings(model=EMB, api_key=os.environ["LLM_API_KEY"],
base_url=os.environ.get("LLM_BASE_URL"))
Chroma.from_documents(chunks, emb, persist_directory=DB)
print(f"索引构建完成:{len(chunks)} chunks")
def get_chain():
emb = OpenAIEmbeddings(model=EMB, api_key=os.environ["LLM_API_KEY"],
base_url=os.environ.get("LLM_BASE_URL"))
vs = Chroma(persist_directory=DB, embedding_function=emb)
retriever = vs.as_retriever(search_kwargs={"k": 4})
llm = ChatOpenAI(model=GEN, temperature=0,
api_key=os.environ["LLM_API_KEY"],
base_url=os.environ.get("LLM_BASE_URL"))
prompt = ChatPromptTemplate.from_template(
"只能依据【资料】回答,不足时说不知道,并注明页码。\n"
"【资料】:{context}\n【问题】:{question}\n【回答】:")
def ask(q: str) -> str:
hits = retriever.invoke(q)
ctx = "\n\n---\n\n".join(
f"[第{h.metadata.get('page', '?')}页] {h.page_content}"
for h in hits)
return (prompt | llm).invoke(
{"context": ctx, "question": q}).content
return ask
if __name__ == "__main__":
import sys
if "--build" in sys.argv or not os.path.exists(DB):
build_index()
ask = get_chain()
print("文档问答已就绪,输入问题(quit退出):")
while True:
q = input("> ").strip()
if q.lower() in ("quit", "exit", "q"):
break
print(ask(q), "\n")
用法:python rag_app.py --build 建库一次,之后直接 python rag_app.py 提问。
4.7 步骤七:生产化两件套——重排与评测
重排(re-rank):向量检索快但糙,cross-encoder 把 query 和每个候选块拼在一起精读打分,精度高一个档次。代价是慢,所以只对 Top-20 重排取 Top-2:
# rerank.py
from langchain.retrievers import ContextualCompressionRetriever
from langchain.retrievers.document_compressors import CrossEncoderReranker
from langchain_community.cross_encoders import HuggingFaceCrossEncoder
reranker = CrossEncoderReranker(
model=HuggingFaceCrossEncoder(
model_name="cross-encoder/ms-marco-MiniLM-L-6-v2"),
top_n=2,
)
compression_retriever = ContextualCompressionRetriever(
base_compressor=reranker,
base_retriever=retriever, # 先放宽到 k=20,再压缩到 2
)
评测(RAGAS):准备 30~50 个(问题, 参考答案)对,跑三个核心指标:
- faithfulness:答案是否被 context 支持(抓幻觉);
- answer_relevancy:答案是否真在回答问题;
- context_precision:检索出的块是否真相关(定位检索 vs 生成的责任)。
pip install ragas datasets
评测驱动迭代:faithfulness 低→收紧提示词/降 temperature;context_precision 低→调切分、k、embedding 或加 rerank;relevancy 低→检查 query 改写。
五、常见坑
- 扫描版 PDF 直接喂:读出来是空串,库建了等于没建。先抽查
page_content非空率,空就走 OCR。 - chunk_size 照抄不验证:FAQ 用 512 会把多条问答搅在一个块里,检索全是噪音。按文档类型选参数并抽查块质量。
- k 越大越好:k=10 的长 context 又贵又容易让模型抓不住重点。先 k=4,不够再加 rerank,而不是无脑加 k。
- 提示词没写”不知道就说不知道”:没有这句安全带,RAG 退化成”带着参考资料编”,幻觉照旧。
- temperature 没设 0:问答要确定性,默认 temperature=0.7 会让同一问题每次答案不同,评测也复现不了。
- embedding 模型中途换:向量空间变了,旧库全废。定下来就写进配置并版本化,换模型=重建索引+回归评测。
- 不做引用:答案不标页码/来源,业务方无法核验,RAG 的”可审计”优势荡然无存。metadata 页码从第一天就要透传。
- 跳过评测直接上线:没有 RAGAS 基线,改切分、换模型全凭感觉。30 条评测集半天写完,是后续一切优化的地基。
六、总结
- RAG = 检索(找材料)+ 生成(照材料答),五步 Load→Chunk→Embed→Retrieve→Generate。
- 切分与检索决定下限,提示词与 temperature 决定稳定性,重排与评测决定生产质量。
- 今天就能跑:
rag_app.py建库提问,先让第一版转起来,再用评测集驱动迭代。 - 下一步:HyDE/query 改写、混合检索(BM25+向量)、本系列的像素级 RAG(富版式文档)与 LLM 可观测性(线上质量监控),拼成完整生产拼图。
参考资料:SuperML RAG 入门教程与 LangChain 官方文档。 点击阅读原文