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-m3、sentence-transformers/paraphrase-multilingual 等 开源免费,支持本地运行
OllamaEmbeddings nomic-embed-text、mxbai-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-m3 或 text-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(交叉编码器重排),并给出各方案的量化对比数据。

相关推荐
_不会dp不改名_4 小时前
leetcode_1614嵌套括号的最大深度
算法·leetcode·职场和发展
sali-tec4 小时前
C# 基于OpenCv的视觉工作流-章110-YOLO 实例分割
图像处理·人工智能·opencv·算法·yolo·计算机视觉
HySpark4 小时前
AI语音软件适配麒麟V10:从环境检查到业务验证
人工智能·语音识别
生活愉甜4 小时前
第五届数贸会:AI健康管理从概念走向生活 静博士MYAI生命质量智能管家引围观热潮
人工智能
龙亘川4 小时前
数智赋能民生保障:亘川智城智慧民政系统构建民政一体化业务闭环
大数据·人工智能·智慧城市·开源软件·数据可视化·政务
sunburn-4 小时前
Java 排序算法详细教学:从冒泡排序到快速排序
java·开发语言·数据结构·ide·算法
陈童学哦4 小时前
Agent密钥运行时落地实录:杜绝明文泄漏的安全工程实战
人工智能
王中阳Go4 小时前
面试官问"你怎么证明它有效",200个转AI的后端没几个答得上来
人工智能·后端·面试
LucianaiB4 小时前
华为云码道 AI 编程实战:我用 CodeArts 智能体打造了一款 HarmonyOS 游戏化专注应用「智办 ZhiBan」
人工智能·ai·华为云·harmonyos·codearts·材料
宋哥转AI4 小时前
AgentScope Java 实战 03:知识与工具层——给 Agent 装上手和书架
人工智能·agent·ai编程