一、背景介绍
在 AI 应用开发中,大语言模型(LLM)虽然具备强大的理解和生成能力,但其知识存在两个固有局限:知识截止日期的边界 和无法访问私有数据。检索增强生成(RAG, Retrieval-Augmented Generation)正是为解决这两个问题而生的架构范式------它让模型在回答问题时,能够先检索相关的外部知识,再基于检索到的上下文进行推理和生成。
本文将完整演示一个 RAG 系统的核心链路:从 PDF 文档读取 → 文本分割 → 向量化 → 向量库存储 → 语义检索。我们会使用 LangChain 1.0 的抽象接口,配合 Ollama 本地嵌入模型和 Chroma 轻量级向量数据库,全程无需联网即可完成索引构建与检索。
二、方案分析:RAG 的核心组件与数据流
RAG 系统的本质是一个"先查后答"的流水线。要让电脑"读懂"一本 PDF 并建立可检索的知识库,需要经过四个关键步骤,类比于给实体书建立图书馆检索系统:
| 步骤 | 技术动作 | 类比理解 |
|---|---|---|
| 1. 读 PDF | 将 PDF 按页解析为 Document 对象 | 把整本书拆成一页一页的纸 |
| 2. 分文本 | 将每页内容切成有重叠的小段落 | 把每页纸剪成若干小纸条(相邻纸条有重叠,避免信息断裂) |
| 3. 向量化 | 将小段落转为数学向量 | 给每个小纸条贴上书架定位标签(数字列表) |
| 4. 存向量库 | 把向量存入数据库,供后续搜索 | 把所有小纸条按标签整理到图书馆的检索柜 |
2.1 核心概念
Document(文档)
LangChain 的 Document 是文本数据的基本单元,包含三个属性:
page_content:文本内容字符串metadata:元数据字典(来源、页码、创建时间等)id(可选):文档唯一标识
单个 Document 通常代表较大文档的一个片段,而非整本书。
Embeddings(嵌入)
嵌入是将文本映射为高维数值向量的技术。核心直觉是:语义相近的文本,在向量空间中的距离也相近。例如:
- "苹果很好吃" →
[0.23, -0.56, 0.89, ...](768 维) - "苹果是一家公司" →
[0.11, -0.32, 0.55, ...](数字接近,因为共享"苹果"一词) - "香蕉味道不错" →
[-0.78, 0.23, -0.41, ...](数字差异大,语义不同)
向量的维度由模型决定(如 nomic-embed-text 输出 768 维),与输入文本长度无关------无论"你好"还是一万字长文,都输出固定维度的向量。
Vector Stores(向量存储)
向量数据库负责存储向量并提供相似度检索能力。LangChain 集成了数十种向量数据库,从云端托管服务到本地轻量级方案均有覆盖。本文选用 Chroma,它是一个类似 SQLite 的本地文件型向量数据库,适合开发和轻量级场景。
Retrievers(检索器)
检索器是 LangChain 的 Runnable 子类,实现了标准接口(invoke、batch、stream 等)。它可以从向量存储构建,也可以对接非向量数据源(如外部 API)。检索器的价值在于将"找相关文档"这个动作封装为可复用的组件,便于在 LangChain 的链式调用中集成。
三、实操步骤
3.1 环境准备
安装依赖
bash
# 使用 uv(推荐)
uv add langchain langchain-community langchain-chroma langchain-ollama pypdf
# 或使用 pip
pip install langchain langchain-community langchain-chroma langchain-ollama pypdf
拉取嵌入模型
通过 Ollama 本地部署 nomic-embed-text(开源免费,输出 768 维向量):
bash
ollama pull nomic-embed-text
验证模型已就绪:
bash
ollama list
# 应显示 nomic-embed-text:latest

3.2 第一阶段:构建索引(让电脑"读懂"PDF)
索引构建是 RAG 的"离线"阶段,只需执行一次,后续反复检索。
步骤 1:读取 PDF
使用 PyPDFLoader 按页解析 PDF,每页生成一个 Document 对象:
python
from langchain_community.document_loaders import PyPDFLoader
loader = PyPDFLoader("data/nke-10k-2023.pdf")
docs = loader.load()
# 验证输出
print(len(docs)) # 107 ------ PDF 共 107 页
print(type(docs[0])) # <class 'langchain_core.documents.base.Document'>
print(docs[0].page_content[:200]) # 第一页的文本内容
print(docs[0].metadata) # {'source': 'data/nke-10k-2023.pdf', 'page': 0, 'total_pages': 107, ...}
PyPDFLoader 为 LangChain 提供的 PDF 读取器。Docs 返回一个列表,每个元素是一个 Document 对象,代表 PDF 的一页。Document 对象包含 page_content、metadata。page_content 表示这一页的纯文本内容。Metadata 表示元数据(页码、来源、创建时间等)。
步骤 2:分割文本
一页 PDF 可能很长(如财报一页 3000 字),而嵌入模型有输入长度限制(通常 512-8192 tokens)。过长的文本会导致信息截断,因此需要有策略地切分。
更关键的是重叠设计:相邻切片共享部分文本,避免在切分边界处切断完整语义。例如"苹果公司发布财报"若被切成"苹果公司发"和"布财报",重叠 200 字可确保完整语义至少被一个 chunk 保留。
python
from langchain_text_splitters import RecursiveCharacterTextSplitter
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=1000, # 每个 chunk 包含约 1000 个字符
chunk_overlap=200, # 相邻 chunk 共享 200 个字符,保证语义连续性
add_start_index=True # 记录每个 chunk 在原文档中的起始位置,便于溯源
)
all_splits = text_splitter.split_documents(docs)
# 验证输出
print(len(all_splits)) # 516 ------ 107 页被切分为 516 个文本段
print(all_splits[0].metadata) # 包含 'start_index': 0,标记在原文中的位置
步骤 3:向量化
所谓向量化是指把一段文本转成一组数组(这组数组被称为向量)。相似含义的文本,它们的向量在数学空间中也相近。例如,"苹果很好吃" → 0.12, -0.34, 0.56, ... (768 个数字);"苹果是一家公司" → 0.11, -0.32, 0.55, ... (注意数字很接近,因为"苹果"这个词);"香蕉味道不错" → -0.78, 0.23, -0.41, ... (数字差异很大,因为语义不同)。向量化需要使用嵌入模型,这里使用的是nomic-embed-text 模型。这个模型是开源免费向量模型,输出 768 维向量(768 个数字),通过 Ollama 本地运行,不需要联网。执行 uv add chromadb、uv add langchain-chroma 安装向量数据库。向量的长度是固定的(这里是768),和输入文本的长度无关,和模型有关。例如"你好"和"一篇一万字的文章"都输出 768 个数字。
使用 Ollama 本地运行的 nomic-embed-text 模型,将每个文本段转为 768 维向量:
python
from langchain_ollama import OllamaEmbeddings
embeddings = OllamaEmbeddings(model="nomic-embed-text")
# 验证向量维度
vector_0 = embeddings.embed_query(all_splits[0].page_content)
print(len(vector_0)) # 768 ------ 维度由模型决定,与文本长度无关
print(type(vector_0)) # <class 'list'>
# 输出示例:[-0.027, 0.033, -0.195, -0.084, 0.040, -0.030, ...]
步骤 4:存入向量库
使用 Chroma 持久化存储。collection_name 类似数据库中的表名,persist_directory 指定磁盘存储路径,下次启动可直接加载无需重建索引:
python
from langchain_chroma import Chroma
vector_store = Chroma(
collection_name="example_collection", # 集合名称
embedding_function=embeddings, # 向量化函数
persist_directory="./chroma_langchain_db" # 本地持久化路径
)
# 批量添加文档,自动向量化并存储
ids = vector_store.add_documents(documents=all_splits)
print(len(ids)) # 516 ------ 与 all_splits 数量一致
print(ids[:10]) # [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] ------ 每个 chunk 的唯一 ID
add_documents 对每个 all_splits 中的 Document 调用 embeddings 模型,得到向量后存储到 Chroma,最后返回每个向量的 ID(0, 1, 2, ...)。存储后的目录结构如下图所示:

3.3 第二阶段:执行检索(从知识库"找答案")
索引构建完成后,进入"在线"检索阶段。给定一个问题,从知识库中找出最相关的文本片段。LangChain 提供了四种查询方式,覆盖不同使用场景:
| 查询方式 | 核心特点 | 适用场景 |
|---|---|---|
similarity_search |
传入文本,返回最相似的 Document 列表 | 最常用,直接用自然语言查询 |
similarity_search_with_score |
返回 Document + 相似度分数 | 需要设置置信度阈值,过滤低质量结果 |
similarity_search_by_vector |
传入预计算的向量,跳过文本嵌入步骤 | 性能优化、非文本查询(如图片向量)、缓存场景 |
| Retriever 封装 | 用 @chain 装饰器包装为 Runnable |
LangChain 链式调用中复用,统一接口 |
方式一:相似度查询(最常用)
python
results = vector_store.similarity_search(
query="What is the company's business?",
k=3 # 返回最相似的 3 个结果
)
for i, doc in enumerate(results):
print(f"{i}: {doc.page_content[:150]}...")
内部机制:将查询文本通过嵌入模型转为向量 → 计算与所有 chunk 向量的余弦相似度 → 返回相似度最高的 3 个 Document。
方式二:带分数的相似度查询
python
results = vector_store.similarity_search_with_score(
query="What is the company's business?",
k=3
)
for doc, score in results:
print(f"Score: {score:.4f}") # Chroma 返回距离,越小越相似(0 为完全相同)
print(f"Content: {doc.page_content[:150]}...")
print("-" * 50)
输出示例:
text
Score: 0.6309
the Company's financial position or results of operations. In the ordinary course of business, the C...
--------------------------------------------------
Score: 0.6848
Table of Contents PART I ITEM 1. BUSINESS GENERAL NIKE, Inc. was incorporated in 1967 under the laws...
--------------------------------------------------
Score: 0.7150
Because NIKE is a consumer products company, the relative popularity and availability of various spo...
分数可用于设置阈值过滤:如只接受 score < 0.7 的结果,避免召回低质量内容。
方式三:向量直接查询
适用于已预计算查询向量的场景,避免重复嵌入:
python
# 预计算查询向量(可从缓存获取)
query_vector = embeddings.embed_query("What is the company's business?")
results = vector_store.similarity_search_by_vector(
vector=query_vector,
k=3
)
for i, doc in enumerate(results):
print(f"{i}: {doc.page_content[:150]}...")
方式四:检索器封装(LangChain 风格)
用 @chain 装饰器将检索逻辑包装为标准的 Runnable,便于在复杂链路中复用:
python
from typing import List
from langchain_core.documents import Document
from langchain_core.runnables import chain
@chain
def retriever(query: str) -> List[Document]:
return vector_store.similarity_search(query, k=3)
# 像调用函数一样使用,但具备 LangChain 的标准接口(invoke、batch、stream 等)
results = retriever.invoke("What is the company's business?")
for i, doc in enumerate(results):
print(f"{i}: {doc.page_content[:150]}...")
四、验证效果:完整数据流回顾
整个 RAG 索引与检索的数据流可以用下图概括:
PDF 文件(107 页)
↓ PyPDFLoader
107 个 Document 对象(每页一个)
↓ RecursiveCharacterTextSplitter(chunk_size=1000, overlap=200)
516 个 Document 对象(每个 chunk 一个,含 start_index 溯源信息)
↓ OllamaEmbeddings(nomic-embed-text)
516 个向量(每个 768 维)
↓ Chroma.add_documents
向量数据库(持久化到 ./chroma_langchain_db)
↑
└── 后续检索:query → embedding → similarity_search → top-k Documents
关键验证点:
| 检查项 | 预期结果 | 意义 |
|---|---|---|
len(docs) |
107 | PDF 页数正确解析 |
len(all_splits) |
516 | 文本分割粒度合理(约 1000 字符/chunk) |
len(vector_0) |
768 | 嵌入模型输出维度正确 |
len(ids) |
516 | 所有 chunk 均已入库 |
检索结果 score |
0.6-0.8 | 相似度合理,与查询语义相关 |
五、总结与延伸
本文演示了 RAG 系统最核心的一环------检索(Retrieval)。完整的 RAG 链路还应包括:
- 检索(本文已覆盖):从知识库召回相关文档;
- 增强:将检索到的文档拼接为上下文,注入 Prompt;
- 生成:让 LLM 基于上下文回答问题。
LangChain 1.0 的 init_chat_model 与检索器可以无缝衔接,例如:
python
from langchain.chat_models import init_chat_model
from langchain_core.prompts import ChatPromptTemplate
# 检索相关文档
docs = retriever.invoke("耐克 2023 年的营收是多少?")
context = "\n\n".join([d.page_content for d in docs])
# 构建增强 Prompt
prompt = ChatPromptTemplate.from_template("""
基于以下上下文回答问题。如果上下文中没有相关信息,请说"我不知道"。
上下文:
{context}
问题:{question}
""")
# 生成回答
model = init_chat_model(model="ollama:deepseek-r1:latest", base_url="http://localhost:11434")
chain = prompt | model
response = chain.invoke({"context": context, "question": "耐克 2023 年的营收是多少?"})
print(response.content)
从 PDF 到向量库,从语义搜索到增强生成,LangChain 1.0 提供了一整套统一且可组合的抽象。掌握这套工具链,就能快速构建数据驱动的 AI 应用。