LangChain 系列·(四):RAG 基础——给大模型装上“外脑“

LangChain 系列 · 第四篇:RAG 基础------给大模型装上"外脑"

🎯 适合人群:已了解 LangChain 基本概念与 LCEL 用法,想构建基于私有数据问答系统的工程师

⏱️ 阅读时间:约 30 分钟

💬 本文讲解 RAG(检索增强生成)的完整工作原理与工程实现,涵盖文档加载、文本分割、向量化、存储与检索全链路


一、为什么需要 RAG

大语言模型的知识来自训练数据,存在两个根本性局限:

知识截止日期:模型不知道训练结束后发生的事情。

私有数据盲区:模型对企业内部文档、代码库、产品手册一无所知。

解决这两个问题有两条路:

方案 原理 适用场景 代价
Fine-tuning(微调) 用私有数据重新训练模型,将知识"烧入"参数 固定领域、风格迁移 训练成本高、数据更新困难
RAG(检索增强生成) 每次回答前先从外部知识库检索相关内容,注入上下文 知识频繁更新、文档量大 实现相对简单、知识可实时更新

绝大多数"基于私有文档的问答"场景,RAG 都是更合适的选择。

RAG 的完整工作流分为两个阶段:

复制代码
=== Indexing (offline) ===

Raw Documents
    |
    v
[Document Loader] --> Document objects (page_content + metadata)
    |
    v
[Text Splitter]   --> Smaller chunks
    |
    v
[Embedding Model] --> Vectors
    |
    v
[Vector Store]    --> Stored index

=== Retrieval & Generation (online) ===

User Question
    |
    v
[Embedding Model] --> Question vector
    |
    v
[Vector Store]    --> Top-K relevant chunks (similarity search)
    |
    v
[LLM + Prompt]    --> Answer grounded in retrieved context

二、Document Loader:加载原始数据

Document Loader 负责将各种格式的原始数据统一转换为 LangChain 的 Document 对象(包含 page_content 文本和 metadata 元数据)。

2.1 常用 Loader 一览

langchain_community.document_loaders 提供了数十种 Loader:

用途 安装依赖
TextLoader 纯文本文件(.txt.md 等)
PyPDFLoader PDF 文件,按页分割为 Document pip install pypdf
PyMuPDFLoader PDF 文件,速度更快,保留更丰富的元数据 pip install pymupdf
WebBaseLoader 网页内容,基于 BeautifulSoup 提取正文 pip install bs4
DirectoryLoader 批量加载目录下多个文件,支持 glob 过滤
CSVLoader CSV 文件,每行转为一个 Document
UnstructuredMarkdownLoader Markdown 文件,保留文档结构 pip install unstructured
GitLoader Git 仓库中的代码文件 pip install gitpython
WikipediaLoader Wikipedia 文章 pip install wikipedia
ArxivLoader arXiv 学术论文 pip install arxiv

2.2 常用加载示例

python 复制代码
from langchain_community.document_loaders import (
    TextLoader,
    PyPDFLoader,
    WebBaseLoader,
    DirectoryLoader,
)

# 加载单个文本文件
text_loader = TextLoader("./docs/readme.txt", encoding="utf-8")
docs = text_loader.load()
print(f"加载了 {len(docs)} 个 Document")
print(docs[0].page_content[:200])  # 文本内容
print(docs[0].metadata)            # {'source': './docs/readme.txt'}

# 加载 PDF(按页分割)
pdf_loader = PyPDFLoader("./docs/report.pdf")
pages = pdf_loader.load()
print(f"PDF 共 {len(pages)} 页")
print(pages[0].metadata)  # {'source': '...', 'page': 0}

# 加载网页
web_loader = WebBaseLoader(
    web_paths=["https://python.langchain.com/docs/introduction/"],
    bs_kwargs={
        "parse_only": bs4.SoupStrainer(class_=("article", "main"))  # 只提取正文
    }
)
web_docs = web_loader.load()

# 批量加载目录(加载所有 .md 文件)
dir_loader = DirectoryLoader(
    path="./docs/",
    glob="**/*.md",
    loader_cls=TextLoader,
    loader_kwargs={"encoding": "utf-8"},
    show_progress=True,   # 显示进度条
)
all_docs = dir_loader.load()
print(f"加载了 {len(all_docs)} 个文档")

💡 metadata 字段非常重要。它记录了每个 Document 的来源(文件路径、URL、页码等),RAG 系统在返回答案时可以附带来源引用,显著提升可信度。


三、Text Splitter:切分文档

加载后的文档通常过长,无法直接放入模型上下文窗口,需要切分为更小的 chunk(语义片段)

3.1 常用 Text Splitter 一览

分割策略 适用场景
RecursiveCharacterTextSplitter 按字符列表递归分割,优先在段落/句子边界切分 通用首选,绝大多数场景
CharacterTextSplitter 按单一分隔符(如 \n\n)分割 结构简单、段落清晰的文本
TokenTextSplitter 按 token 数分割,精确控制 token 消耗 需要严格控制 token 预算时
MarkdownTextSplitter 按 Markdown 标题层级(###)分割 Markdown 文档
PythonCodeTextSplitter 按 Python 函数/类语法结构分割 Python 代码库
HTMLHeaderTextSplitter 按 HTML 标题层级分割,保留语义结构 HTML 网页文档
SemanticChunker 基于 Embedding 相似度分割,保证语义完整性 质量要求高、不在乎速度的场景

3.2 chunk_size 与 chunk_overlap 的选择原则

这是 RAG 效果调优中最关键的参数,直接影响检索质量。

chunk_size(每个 chunk 的最大字符数):

复制代码
chunk 过小  -->  单个 chunk 缺少上下文,模型难以理解 --> 回答片面
chunk 过大  -->  一个 chunk 包含太多无关信息 --> 检索噪声增加

实践中的参考值:

文档类型 建议 chunk_size 原因
通用文章、FAQ 512 ~ 1024 字符 平衡语义完整性与检索精度
技术文档、教程 1024 ~ 2048 字符 技术概念需要更多上下文
代码文件 1500 ~ 3000 字符 函数/类需要完整呈现
短问答、条目 128 ~ 256 字符 每条信息本身就是独立单元

chunk_overlap(相邻 chunk 的重叠字符数):

重叠的目的是防止关键信息恰好被切断在两个 chunk 的边界处。通常设为 chunk_size 的 10%~20%。

python 复制代码
from langchain_text_splitters import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=1000,       # 每个 chunk 最大 1000 字符
    chunk_overlap=100,     # 相邻 chunk 重叠 100 字符(10%)
    length_function=len,   # 用字符数计算长度;也可传入 tiktoken 的 token 计数函数
    add_start_index=True,  # 在 metadata 中记录 chunk 在原文中的起始位置
)

# 分割文档列表
chunks = splitter.split_documents(docs)
print(f"原始文档 {len(docs)} 篇 --> 切分后 {len(chunks)} 个 chunk")

# 查看第一个 chunk
print(chunks[0].page_content)
print(chunks[0].metadata)  # {'source': '...', 'start_index': 0}

3.3 按 token 精确控制

当下游使用的模型有严格的 context window 限制时,按字符数估算 token 不够精确。使用 TokenTextSplitter 或在 RecursiveCharacterTextSplitter 中替换 length_function

python 复制代码
import tiktoken
from langchain_text_splitters import RecursiveCharacterTextSplitter

enc = tiktoken.encoding_for_model("gpt-4o-mini")

def token_len(text: str) -> int:
    return len(enc.encode(text))

splitter = RecursiveCharacterTextSplitter(
    chunk_size=512,          # 每个 chunk 最多 512 tokens
    chunk_overlap=50,
    length_function=token_len,
)

四、Embedding:将文本转为向量

Embedding 是将文本映射到高维向量空间的技术。语义相近的文本,其向量在空间中的距离也更近------这正是向量检索能够找到相关内容的数学基础。

可以把 Embedding 想象成"语义坐标":每段文本对应空间中的一个点,"向量检索"就是找距离用户问题最近的若干个点。

4.1 常用 Embedding 模型一览

模型 特点
OpenAIEmbeddings text-embedding-3-small / text-embedding-3-large 质量高,按 token 计费
HuggingFaceEmbeddings BAAI/bge-m3sentence-transformers/paraphrase-multilingual 开源免费,支持本地运行
OllamaEmbeddings nomic-embed-textmxbai-embed-large 本地 Ollama 部署,完全离线
CohereEmbeddings embed-multilingual-v3.0 多语言优化,按调用次数计费
FastEmbedEmbeddings BAAI/bge-small-zh 轻量快速,适合资源受限环境

4.2 使用示例

python 复制代码
from langchain_openai import OpenAIEmbeddings

embeddings = OpenAIEmbeddings(model="text-embedding-3-small")

# 对单条文本生成向量
vector = embeddings.embed_query("什么是向量数据库?")
print(f"向量维度:{len(vector)}")  # text-embedding-3-small 输出 1536 维

# 对多条文本批量生成向量(索引阶段)
texts = ["文本一", "文本二", "文本三"]
vectors = embeddings.embed_documents(texts)
print(f"生成了 {len(vectors)} 个向量")

💡 选型建议

  • 开发/原型阶段:OpenAIEmbeddings + text-embedding-3-small,性价比高
  • 生产阶段且数据敏感:HuggingFaceEmbeddings + BAAI/bge-m3,本地运行,数据不出境
  • 中文场景:优先考虑 BAAI/bge-m3text-embedding-3-large(对中文支持更好)

五、Vector Store:存储与检索向量

向量数据库(Vector Store) 负责存储文本对应的向量,并在接收到查询向量时,快速找出最相似的若干条记录。

5.1 常用 Vector Store 一览

定位 适用场景
FAISS Meta 开源,纯内存索引 本地开发、快速原型,不需要持久化
Chroma 轻量级本地向量数据库 开发/小规模生产,支持持久化到磁盘
Milvus 分布式向量数据库 亿级向量、高并发生产场景
Qdrant 开源,支持复杂元数据过滤 需要混合检索(向量 + 关键词过滤)的场景
Pinecone 全托管云服务 不想运维、快速上云
PGVector PostgreSQL 扩展 已有 PG 基础设施,不想引入新组件
Weaviate 开源,支持多模态 图文混合检索场景

5.2 Chroma:持久化本地向量库

python 复制代码
from langchain_chroma import Chroma
from langchain_openai import OpenAIEmbeddings

embeddings = OpenAIEmbeddings(model="text-embedding-3-small")

# 从 chunks 创建向量库(首次运行,自动调用 Embedding API)
vectorstore = Chroma.from_documents(
    documents=chunks,
    embedding=embeddings,
    persist_directory="./chroma_db",  # 持久化到本地目录
    collection_name="my_docs",
)

# 下次运行直接加载,无需重新 Embedding
vectorstore = Chroma(
    persist_directory="./chroma_db",
    embedding_function=embeddings,
    collection_name="my_docs",
)

5.3 FAISS:快速内存索引

python 复制代码
from langchain_community.vectorstores import FAISS
from langchain_openai import OpenAIEmbeddings

embeddings = OpenAIEmbeddings(model="text-embedding-3-small")

# 创建 FAISS 索引
vectorstore = FAISS.from_documents(chunks, embeddings)

# 保存到本地(可选)
vectorstore.save_local("./faiss_index")

# 从本地加载
vectorstore = FAISS.load_local(
    "./faiss_index",
    embeddings,
    allow_dangerous_deserialization=True,
)

5.4 检索参数调优

向量库提供了多种检索方式,参数选择直接影响 RAG 的回答质量:

python 复制代码
# 方式一:相似度搜索(最常用)
# k 控制返回的 chunk 数量
results = vectorstore.similarity_search(
    query="什么是 LCEL?",
    k=4,  # 返回最相似的 4 个 chunk
)

# 方式二:带相似度分数
results_with_score = vectorstore.similarity_search_with_score(
    query="什么是 LCEL?",
    k=4,
)
for doc, score in results_with_score:
    print(f"score: {score:.4f} | {doc.page_content[:80]}")

# 方式三:MMR(最大边际相关性)------平衡相关性与多样性,避免检索到内容重复的 chunk
results_mmr = vectorstore.max_marginal_relevance_search(
    query="什么是 LCEL?",
    k=4,
    fetch_k=20,    # 先取 20 个候选,再从中挑选多样性最高的 4 个
    lambda_mult=0.5,  # 0=最大多样性,1=最大相关性
)

🔬 k 值的选择:k=3~5 适合大多数场景。k 过小会遗漏关键信息,k 过大会引入噪声并增加 Token 消耗。当检索效果不理想时,优先考虑改善 chunk 策略,而非无限增大 k


六、构建完整 RAG Chain

将以上组件用 LCEL 串联,构建一个端到端的 RAG 问答系统:

python 复制代码
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_chroma import Chroma
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough

load_dotenv()

# --------- 索引阶段(离线执行一次)---------
from langchain_community.document_loaders import DirectoryLoader, TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter

loader = DirectoryLoader("./docs/", glob="**/*.md", loader_cls=TextLoader)
docs = loader.load()

splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=100)
chunks = splitter.split_documents(docs)

embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
vectorstore = Chroma.from_documents(
    chunks, embeddings, persist_directory="./chroma_db"
)

# --------- 检索+生成阶段(在线执行)---------
retriever = vectorstore.as_retriever(
    search_type="similarity",
    search_kwargs={"k": 4},
)

# RAG Prompt:要求模型严格基于检索内容回答
rag_prompt = ChatPromptTemplate.from_messages([
    (
        "system",
        "你是一个问答助手,严格根据以下检索到的上下文内容回答用户问题。\n\n"
        "规则:\n"
        "- 只使用上下文中明确提到的信息作答\n"
        "- 如果上下文中没有相关信息,回复:'根据现有文档,无法回答该问题。'\n"
        "- 不要捏造或推测上下文中未提及的信息\n\n"
        "上下文:\n{context}"
    ),
    ("human", "{question}"),
])

def format_docs(docs):
    """将检索到的 Document 列表格式化为字符串"""
    return "\n\n---\n\n".join(
        f"[来源: {doc.metadata.get('source', 'unknown')}]\n{doc.page_content}"
        for doc in docs
    )

model = ChatOpenAI(model="gpt-4o-mini", temperature=0)
parser = StrOutputParser()

# 完整 RAG Chain
rag_chain = (
    {
        "context":  retriever | format_docs,  # 检索并格式化上下文
        "question": RunnablePassthrough(),    # 原样传递用户问题
    }
    | rag_prompt
    | model
    | parser
)

# 调用
answer = rag_chain.invoke("什么是 LCEL?它和旧版 LLMChain 有什么区别?")
print(answer)

6.1 带来源引用的 RAG

生产环境中,用户通常需要知道答案来自哪个文档:

python 复制代码
from langchain_core.runnables import RunnableParallel

# 同时返回答案和检索到的来源文档
rag_chain_with_source = RunnableParallel(
    {
        "answer": rag_chain,
        "sources": retriever,   # 单独保留检索结果
    }
)

result = rag_chain_with_source.invoke("什么是 LCEL?")
print("回答:", result["answer"])
print("\n来源文档:")
for doc in result["sources"]:
    print(f"  - {doc.metadata['source']}")

七、常见坑与最佳实践

坑一:chunk_size 设置不合理导致检索效果差

复制代码
# ❌ chunk_size=100,切得太碎
# 每个 chunk 只有一两句话,缺乏上下文,模型无法理解

# ❌ chunk_size=5000,切得太大
# 一个 chunk 包含多个主题,检索时引入大量无关内容

# ✅ 根据文档类型调整:通用文章 512~1024,技术文档 1024~2048
# ✅ 实验时打印几个 chunk,人工判断语义完整性

坑二:向量库未持久化,每次启动重新 Embedding

python 复制代码
# ❌ 每次运行都调用 from_documents,重复消耗 Embedding API
vectorstore = Chroma.from_documents(chunks, embeddings)

# ✅ 检查是否已有索引,有则直接加载
import os
if os.path.exists("./chroma_db"):
    vectorstore = Chroma(
        persist_directory="./chroma_db",
        embedding_function=embeddings,
    )
else:
    vectorstore = Chroma.from_documents(
        chunks, embeddings, persist_directory="./chroma_db"
    )

坑三:RAG Prompt 没有约束模型,导致"幻觉"

python 复制代码
# ❌ 没有约束,模型会混入自身知识
system = "根据以下内容回答问题:{context}"
# 当上下文信息不足时,模型会用训练数据补充,输出难以验证

# ✅ 明确约束:上下文中没有的信息不能回答
system = (
    "严格根据以下上下文回答问题,不得使用上下文以外的信息。\n"
    "若上下文不足以回答,输出:'文档中未找到相关信息。'\n\n"
    "上下文:{context}"
)

坑四:中文文档检索效果差

python 复制代码
# ❌ 使用默认的 text-embedding-ada-002(对中文支持有限)
embeddings = OpenAIEmbeddings()  # 默认 ada-002

# ✅ 中文场景优先选择:
# 方案一:OpenAI text-embedding-3-large(中文效果显著优于 ada-002)
embeddings = OpenAIEmbeddings(model="text-embedding-3-large")

# 方案二:本地 BGE 模型(专门针对中文优化,免费)
from langchain_community.embeddings import HuggingFaceEmbeddings
embeddings = HuggingFaceEmbeddings(model_name="BAAI/bge-m3")

坑五:忽略 metadata 过滤,检索结果来源混乱

python 复制代码
# ✅ 利用 metadata 过滤,只在特定文档集合中检索
results = vectorstore.similarity_search(
    query="退款政策是什么?",
    k=4,
    filter={"source": "policy_2024.pdf"},  # 只检索指定文档
)

八、总结

组件 核心作用 关键参数/选择
Document Loader 将原始文件转为 Document 对象 按文件格式选择对应 Loader
Text Splitter 将长文档切分为可检索的 chunk chunk_size(5122048)、`chunk_overlap`(10%20%)
Embedding Model 将文本映射为高维向量 中文场景选 BGE 或 text-embedding-3-large
Vector Store 存储向量并支持相似度检索 开发用 FAISS/Chroma,生产用 Milvus/Qdrant
Retriever 执行相似度搜索 k=3~5,质量差时优先调整 chunk 策略
RAG Prompt 约束模型基于上下文回答 必须明确禁止使用上下文以外的信息

🎯 RAG 的效果上限由检索质量决定,而检索质量由 chunk 策略和 Embedding 模型决定。调优时的顺序:先确认 chunk 质量(打印几个 chunk 人工检查),再评估 Embedding 模型,最后才调整 k 值。


参考资料


下期预告

基础 RAG 的检索准确率往往只有 60%~70%------用户的提问方式与文档的表述方式不一致时,相似度搜索就会失效。

第五篇《RAG 进阶:让检索真的准》 将介绍提升检索质量的四种核心技术:Multi-query(多角度提问)、Contextual Compression(压缩冗余上下文)、HyDE(假设文档嵌入)和 Reranking(交叉编码器重排),并给出各方案的量化对比数据。

相关推荐
深念Y1 小时前
哈希与向量:计算机理解现实的两座桥梁
人工智能·数学·机器学习·向量·hash·哈希·空间
何雷 — 智能网联汽车2 小时前
Harness Engineering学习一 —— 基本概念
langchain·openai·harness·智能体编程·ai驱动编程
TImCheng06092 小时前
AI认证等级体系深度对比:能力与应用场景
人工智能
探物 AI2 小时前
【感知·医学分割】当 YOLOv11 杀入医学赛道:先检测后分割的级联架构
算法·yolo·计算机视觉·架构
掘金安东尼2 小时前
谁才真正拥有 Agent Loop?从 OpenClaw、Claude Code 到 LangGraph、Temporal 的一次工程级拆解
人工智能
隔壁大炮2 小时前
Day06-08.CNN概述介绍
人工智能·pytorch·深度学习·算法·计算机视觉·cnn·numpy
白云千载尽2 小时前
前馈与反馈——经典控制理论中的基础概念
人工智能·算法
盘古信息IMS2 小时前
全域场景重构,激活智造新未来!盘古信息机加行业数智化解决方案深度解析
大数据·人工智能
跨境卫士-小汪2 小时前
多国站点利润分化加剧跨境卖家如何重新排优先级
大数据·人工智能·产品运营·跨境电商·跨境