文档加载、分割与向量化
RAG(Retrieval-Augmented Generation,检索增强生成)是一种结合了检索和生成的AI技术。 简单来说:先检索相关文档,再让AI基于这些文档回答问题。
加载文档
分割文本
向量化
输入问题
问题向量化
检索相关文档
结合文档
生成答案
返回结果
RAG 系统包含 6 个核心环节,分为三个阶段:文档处理、检索优化、生成增强
从各种数据源加载文档:
• PDF、Word、TXT
• 网页、数据库
• API接口
目标:统一为Document对象
将长文档切分成小块:
• 控制Token长度
• 保持语义完整
• 设置重叠区域
目标:适合检索的文本块
将文本转换为向量:
• 使用Embedding模型
• 存入向量数据库
• 支持相似度搜索
目标:可快速检索的向量
优化用户查询表达:
• 解决指代消解
• 补充上下文信息
• 明确查询意图
目标:独立、清晰的检索查询
从向量库检索相关文档:
• 余弦相似度计算
• Top-K候选结果
• 多样性优化(MMR)
目标:高相关性的文档片段
精细化检索结果排序:
• Cross-Encoder模型
• 语义相关性精排
• 提升Top-K准确率
目标:最优的上下文材料
将检索到的文档片段组织成连贯的上下文
基于上下文生成准确、相关的回答
为答案添加来源标注,确保可追溯性
将各种格式的数据源统一转换为LangChain的Document对象。
from langchain import Document
# Document对象包含两部分
doc = Document(
page_content="这是文档的文本内容", # 文本内容(例如来自 data/article.txt)
metadata={ # 元数据
"source": "data/article.txt",
"page": 1,
"author": "张三"
}
)📎 示例文件:下载 article.txt(可作为构造 Document 时的示例文档来源)。
用途:加载纯文本文件(.txt)
from langchain_community.document_loaders import TextLoader
# 加载文本文件
loader = TextLoader("data/article.txt", encoding="utf-8")
documents = loader.load()
# 查看加载结果
print(f"加载了 {len(documents)} 个文档")
print(f"内容: {documents[0].page_content[:100]}...")
print(f"元数据: {documents[0].metadata}")加载了 1 个文档
内容: 这是一篇关于人工智能的文章。人工智能(AI)是计算机科学的一个分支...
元数据: {'source': 'data/article.txt'}📎 本例示例文件:下载 article.txt
用途:加载PDF文件,按页分割
pip install pymupdf
有些 PDF(尤其是扫描件/不规范导出的 PDF)字体信息不完整,某些解析引擎可能会在解析字体时触发
KeyError: 'bbox'。这不是你代码写错,通常是 PDF 文件本身问题。
最简单的 demo 处理方式:直接使用 PyMuPDFLoader(更鲁棒)。
pip install pymupdffrom langchain_community.document_loaders import PyMuPDFLoader
pdf_path = "data/sample_document.pdf"
pages = PyMuPDFLoader(pdf_path).load()
print(f"PDF共有 {len(pages)} 页")
for i, page in enumerate(pages[:2]):
print(f"\n--- 第{i+1}页 ---")
print(f"内容: {page.page_content[:100]}...")
print(f"元数据: {page.metadata}")PDF共有 10 页
--- 第1页 ---
内容: 2024年度工作报告\n\n一、概述\n本报告总结了2024年的主要工作成果...
元数据: {'source': 'data/report.pdf', 'page': 0}
-内容: 二、技术进展\n在人工智能领域,我们取得了以下突破...
元数据: {'source': 'data/report.pdf', 'page': 1}📎 本例示例文件:下载 sample_document.pdf
本示例使用 sample_document.pdf 作为示例 PDF 文件,你可以直接下载并配合
PyMuPDFLoader("data/sample_document.pdf") 运行代码。
如需体验 Word 文档加载,可以使用我们提供的 data/6.docx,或替换为你自己的 .docx 文件。
用途:加载Word文档(.docx)
Docx2txtLoader 的导入写法是最新的(LangChain 0.2+),如果你遇到 ModuleNotFoundError: No module named 'docx2txt',
说明当前运行代码的 Python 环境里还没安装 docx2txt。
python -m pip install -U docx2txtfrom langchain_community.document_loaders import Docx2txtLoader
# 加载Word文档(示例:data/6.docx)
loader = Docx2txtLoader("data/6.docx")
documents = loader.load()
print(f"加载了 {len(documents)} 个文档")
print(f"内容: {documents[0].page_content[:200]}...")
print(f"元数据: {documents[0].metadata}")📎 本例示例文件:下载 6.docx
用途:从网页加载内容
pip install beautifulsoup4from langchain_community.document_loaders import WebBaseLoader
# 加载网页内容
loader = WebBaseLoader("https://python.langchain.com/docs/")
documents = loader.load()
print(f"加载了 {len(documents)} 个文档")
print(f"标题: {documents[0].metadata.get('title', 'N/A')}")
print(f"URL: {documents[0].metadata['source']}")
print(f"内容长度: {len(documents[0].page_content)} 字符")用途:加载CSV表格数据
from langchain_community.document_loaders.csv_loader import CSVLoader
# 加载CSV文件
loader = CSVLoader(
file_path="data/users.csv",
csv_args={
'delimiter': ',',
'quotechar': '"',
}
)
documents = loader.load()
print(f"加载了 {len(documents)} 行数据")
for i, doc in enumerate(documents[:3]): # 只看前3行
print(f"\n行{i+1}: {doc.page_content}")
print(f"元数据: {doc.metadata}")加载了 100 行数据
行1: name: 张三, age: 25, city: 北京
元数据: {'source': 'data/users.csv', 'row': 0}
行2: name: 李四, age: 30, city: 上海
元数据: {'source': 'data/users.csv', 'row': 1}
行3: name: 王五, age: 28, city: 深圳
元数据: {'source': 'data/users.csv', 'row': 2}📎 本例示例文件:下载 users.csv
用途:批量加载整个目录的文件
from langchain_community.document_loaders import DirectoryLoader, TextLoader
# 加载目录下所有txt文件
loader = DirectoryLoader(
"data/documents/",
glob="**/*.txt", # 匹配所有txt文件
loader_cls=TextLoader, # 使用TextLoader加载
show_progress=True # 显示进度条
)
documents = loader.load()
print(f"从目录加载了 {len(documents)} 个文档")
for doc in documents[:3]:
print(f"文件: {doc.metadata['source']}")
print(f"内容: {doc.page_content[:50]}...\n")加载的文档通常很长,需要分割成小块才能有效处理。
LangChain 0.2+ 将 Text Splitters 拆分为独立包 langchain-text-splitters。
python -m pip install -U langchain-text-splitters最常用!按字符递归分割,优先保持段落完整。
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_community.document_loaders import TextLoader
# 加载文档(示例:data/long_article.txt)
loader = TextLoader("data/long_article.txt")
documents = loader.load()
# 创建分割器
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=500, # 每块最大500字符
chunk_overlap=50, # 块之间重叠50字符
length_function=len, # 使用字符长度计算
separators=["\n\n", "\n", " ", ""] # 分割优先级
)
# 分割文档
chunks = text_splitter.split_documents(documents)
print(f"原文档: {len(documents)} 个")
print(f"分割后: {len(chunks)} 个块")
print(f"\n第1块内容:")
print(chunks[0].page_content)
print(f"\n第1块元数据: {chunks[0].metadata}")原文档: 1 个
分割后: 8 个块
第1块内容:
人工智能的发展历程
人工智能(Artificial Intelligence,简称AI)是计算机科学的一个分支,
它企图了解智能的实质,并生产出一种新的能以人类智能相似的方式做出反应的智能机器。
该领域的研究包括机器人、语言识别、图像识别、自然语言处理和专家系统等。
第1块元数据: {'source': 'data/long_article.txt'}📎 本例示例文件:下载 long_article.txt
绝对生效!纯字符滑窗,overlap 100% 可预测,但可能切断句子。
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_community.document_loaders import TextLoader
# 加载文档
loader = TextLoader("data/long_article.txt")
documents = loader.load()
# 严格滑窗切分器
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=200, # 每块最大200字符(固定窗口)
chunk_overlap=50, # 块之间重叠50字符(绝对生效)
length_function=len, # 使用字符长度计算
separators=[""], # 不使用任何分隔符,纯字符滑窗
add_start_index=True, # 在元数据中添加起始位置
strip_whitespace=True, # 去除每块首尾空白字符
)
# 分割文档
chunks = text_splitter.split_documents(documents)
print(f"原文档: {len(documents)} 个")
print(f"分割后: {len(chunks)} 个块")
# 验证重叠是否生效
def verify_overlap(chunks, overlap=50):
for i in range(min(3, len(chunks) - 1)):
tail = chunks[i].page_content[-overlap:]
head = chunks[i + 1].page_content[:overlap]
print(f"块{i+1}尾部: {repr(tail)}")
print(f"块{i+2}头部: {repr(head)}")
print(f"重叠匹配: {tail == head}")
print("---")
verify_overlap(chunks, overlap=50)按指定分隔符分割文本。
from langchain_text_splitters import CharacterTextSplitter
# 按段落分割
text_splitter = CharacterTextSplitter(
separator="\n\n", # 按双换行符分割
chunk_size=1000,
chunk_overlap=100
)
chunks = text_splitter.split_documents(documents)
print(f"分割成 {len(chunks)} 个段落")按Token数量分割,更精确控制长度。
pip install tiktokenfrom langchain_text_splitters import TokenTextSplitter
# 按Token分割
text_splitter = TokenTextSplitter(
chunk_size=100, # 每块100个Token
chunk_overlap=10
)
chunks = text_splitter.split_documents(documents)
print(f"分割成 {len(chunks)} 个Token块")智能分割!根据语义相似度分割,保持内容连贯性。
pip install langchain-experimentalfrom langchain_experimental.text_splitter import SemanticChunker
from langchain_community.embeddings import DashScopeEmbeddings
import os
# 设置API密钥
os.environ["DASHSCOPE_API_KEY"] = "your-api-key"
# 初始化Embedding模型
embeddings = DashScopeEmbeddings(model="text-embedding-v1")
# 尝试使用 SemanticChunker(可能只返回1个块)
semantic_splitter = SemanticChunker(
embeddings=embeddings,
breakpoint_threshold_type="standard_deviation",
breakpoint_threshold_amount=1.0,
)
# 示例文本
sample_text = """
人工智能的发展历程
人工智能(Artificial Intelligence,简称AI)是计算机科学的一个分支,
它企图了解智能的实质,并生产出一种新的能以人类智能相似的方式做出反应的智能机器。
机器学习是AI的核心技术之一。通过算法让机器从数据中学习规律,
无需显式编程即可完成特定任务。
自然语言处理(NLP)是AI的重要应用领域。
它让计算机能够理解、解释和生成人类语言。
计算机视觉让机器能够"看懂"图像和视频。
"""
# 语义分割
chunks = semantic_splitter.create_documents([sample_text])
print(f"语义分割后: {len(chunks)} 个块")
# 注意:在中文文本上,很可能只返回 1 个块语义分割后: 1 个块
--- 第1块 ---
人工智能的发展历程
人工智能(Artificial Intelligence,简称AI)
是计算机科学的一个分支, 它企图了解智能的实质,并生产出一种新的能以
人类智能相似的方式做出反应的智能机器。 该领域的研究包括机器人、语言识别、图像识别
、自然语言处理和专家系统等。 机器学习是AI的核心技术之一...from langchain_text_splitters import RecursiveCharacterTextSplitter
# 推荐配置:适合大多数中文场景
splitter = RecursiveCharacterTextSplitter(
chunk_size=400,
chunk_overlap=50,
separators=["\n\n", "\n", "。", ",", " "]
)
chunks = splitter.split_text(sample_text)
print(f"分割后: {len(chunks)} 个块")# 适合结构化文档
paragraph_splitter = RecursiveCharacterTextSplitter(
chunk_size=1000,
chunk_overlap=0,
separators=["\n\n", "\n"]
)# 先分句,再根据相似度分组
import nltk
sentences = nltk.sent_tokenize(text)
# 然后用 embeddings 计算相似度分组Markdown专用!根据标题层级(#、##、###)分割,保留结构信息。
from langchain_text_splitters import MarkdownHeaderTextSplitter
# 示例Markdown文档
markdown_text = """
# 人工智能概述
人工智能(AI)是计算机科学的重要分支。
## 机器学习
机器学习是AI的核心技术。
### 监督学习
监督学习使用标注数据训练模型。
### 无监督学习
无监督学习发现数据中的隐藏模式。
## 深度学习
深度学习使用神经网络。
### 卷积神经网络
CNN主要用于图像处理。
### 循环神经网络
RNN适合处理序列数据。
# 应用领域
AI在各个领域都有广泛应用。
"""
# 创建Markdown分割器
markdown_splitter = MarkdownHeaderTextSplitter(
headers_to_split_on=[
("#", "Header 1"),
("##", "Header 2"),
("###", "Header 3"),
]
)
# 分割Markdown文档
chunks = markdown_splitter.split_text(markdown_text)
print(f"Markdown分割后: {len(chunks)} 个块")
for i, chunk in enumerate(chunks, 1):
print(f"\n--- 第{i}块 ---")
print(f"元数据: {chunk.metadata}")
print(f"内容: {chunk.page_content[:100]}...")Markdown分割后: 6 个块
--- 第1块 ---
元数据: {'Header 1': '人工智能概述'}
内容: 人工智能(AI)是计算机科学的重要分支。
--- 第2块 ---
元数据: {'Header 1': '人工智能概述', 'Header 2': '机器学习'}
内容: 机器学习是AI的核心技术。
--- 第3块 ---
元数据: {'Header 1': '人工智能概述', 'Header 2': '机器学习', 'Header 3': '监督学习'}
内容: 监督学习使用标注数据训练模型。
--- 第4块 ---
元数据: {'Header 1': '人工智能概述', 'Header 2': '机器学习', 'Header 3': '无监督学习'}
内容: 无监督学习发现数据中的隐藏模式。
--- 第5块 ---
元数据: {'Header 1': '人工智能概述', 'Header 2': '深度学习'}
内容: 深度学习使用神经网络。
--- 第6块 ---
元数据: {'Header 1': '人工智能概述', 'Header 2': '深度学习', 'Header 3': '卷积神经网络'}
内容: CNN主要用于图像处理。RecursiveCharacterTextSplitter:通用场景,保持段落完整
CharacterTextSplitter:固定分隔符,如按章节分割
TokenTextSplitter:需要精确控制Token数量
SemanticChunker:需要保持语义连贯性,成本较高
MarkdownHeaderTextSplitter:处理Markdown文档,保留结构
Embedding(嵌入)是将文本转换为固定长度的数字向量的过程。 相似的文本会有相似的向量表示,这是向量检索和RAG的基础。
# 文本 → Embedding向量(简化示例,实际是高维向量)
"人工智能" → [0.82, 0.15, -0.33, 0.91, ...]
"AI技术" → [0.79, 0.18, -0.29, 0.88, ...] # 向量相似!
"苹果手机" → [-0.12, 0.73, 0.45, -0.21, ...] # 向量不同
# 计算相似度(余弦相似度)
similarity("人工智能", "AI技术") = 0.95 # 很相似
similarity("人工智能", "苹果手机") = 0.12 # 不相似通义千问提供了高质量的中文Embedding模型,我们可以直接在LangChain中使用。
pip install dashscopefrom langchain_community.embeddings import DashScopeEmbeddings
import os
os.environ["DASHSCOPE_API_KEY"] = "your-api-key"
# 初始化Embedding模型
embeddings = DashScopeEmbeddings(
model="text-embedding-v1" # 通义千问的Embedding模型
)
# 将文本转换为向量
text = "人工智能正在改变世界"
vector = embeddings.embed_query(text)
print(f"文本: {text}")
print(f"向量维度: {len(vector)}")
print(f"向量前5个值: {vector[:5]}")文本: 人工智能正在改变世界
向量维度: 1536
向量前5个值: [0.023, -0.015, 0.041, -0.008, 0.032]from langchain_community.embeddings import DashScopeEmbeddings
import os
os.environ["DASHSCOPE_API_KEY"] = "your-api-key"
# 初始化Embedding模型
embeddings = DashScopeEmbeddings(
model="text-embedding-v1" # 通义千问的Embedding模型
)
# 批量将多个文本转换为向量
texts = [
"人工智能的发展历程",
"机器学习算法介绍",
"深度学习神经网络"
]
vectors = embeddings.embed_documents(texts)
print(f"转换了 {len(vectors)} 个文本")
for i, text in enumerate(texts):
print(f"{i+1}. {text} → 向量维度: {len(vectors[i])}")向量数据库用于存储和快速检索Embedding向量,是RAG系统中"检索"这一步的核心组件。
为了避免本地安装 faiss 的兼容性问题,本章后续示例默认使用 Chroma(本地向量库)。
python -m pip install -U chromadb# 1. 创建 Collection 和设置索引
import chromadb
import hashlib
from typing import List
# 连接 Chroma(本地持久化)
client = chromadb.PersistentClient(path="./chroma_db")
# 创建 Collection
collection = client.get_or_create_collection(
name="tech_docs",
metadata={"hnsw:space": "cosine", "hnsw:M": 16}
)
def get_embedding(text: str, dim: int = 128) -> List[float]:
# 用确定性的 hash 生成固定维度向量(示例用途;生产请替换为真实 embedding 模型)
digest = hashlib.sha256(text.encode("utf-8")).digest()
seed = list(digest) # 32 bytes
out: List[float] = []
for i in range(dim):
b = seed[i % len(seed)]
out.append((b / 255.0) * 2.0 - 1.0)
return out
# 2. 批量插入向量 + 元数据
documents = [
"Redis 是一个高性能的键值存储系统",
"Redis 支持多种数据结构如字符串、哈希、列表",
"Redis 可以用作缓存、消息队列、分布式锁"
]
embeddings = [get_embedding(d) for d in documents]
metadatas = [
{"source": "redis-doc", "page": 1},
{"source": "redis-doc", "page": 2},
{"source": "redis-doc", "page": 3}
]
ids = ["doc_1", "doc_2", "doc_3"]
# 允许脚本重复运行:先删除同 ids(不存在则忽略)
try:
collection.delete(ids=ids)
except Exception:
pass
collection.add(
documents=documents,
embeddings=embeddings,
metadatas=metadatas,
ids=ids
)
# 3. 相似度检索
query_text = "如何使用Redis做缓存?"
query_embedding = get_embedding(query_text) # 生成查询向量
results = collection.query(
query_embeddings=[query_embedding],
n_results=3, # Top-3
include=["documents", "metadatas", "distances"]
)
# 4. 结果解析
for i, (doc, metadata, distance) in enumerate(zip(
results["documents"][0],
results["metadatas"][0],
results["distances"][0]
), 1):
print(f"第{i}名: {doc}")
print(f"来源: {metadata['source']} 第{metadata['page']}页")
print(f"相似度: {1-distance:.4f}") # 转换为相似度
print("---")
# 5. 按条件查询(元数据过滤)
filtered_results = collection.query(
query_embeddings=[query_embedding],
n_results=2,
where={"source": "redis-doc"}, # 只查询Redis文档
where_document={"$contains": "缓存"} # 文档包含"缓存"
)
# 6. 删除和更新
collection.delete(ids=["doc_1"]) # 删除
collection.update(
ids=["doc_2"],
documents=["Redis 支持持久化到磁盘"],
metadatas=[{"source": "redis-doc", "page": 2, "updated": True}],
embeddings=[get_embedding("Redis 支持持久化到磁盘")],
)| 算法 | 构建时间 | 查询时间 | 内存占用 | 适用规模 |
|---|---|---|---|---|
| FLAT | O(1) | O(n) | 1x | <10万 |
| IVF | O(n) | O(√n) | 1.2x | 10万-100万 |
| HNSW | O(n log n) | O(log n) | 1.3x | 100万-1000万 |
| PQ | O(n) | O(log n) | 0.2x | 内存受限场景 |
前面讲的是“离线构建知识库”的流程;进入实战前,还需要把“用户提问时在线发生了什么”讲清楚。
多轮对话里用户经常会用“它 / 这篇 / 上面那个”这类指代,系统需要把历史对话作为上下文,才能正确理解当前问题。
把“含指代 / 过短 / 过口语”的问题,改写成可独立检索的查询,例如补全论文名、方法名、指标名等检索关键词。
把改写后的查询向量化,在向量数据库中做 Top-K 相似度检索;必要时使用 MMR 做多样性优化,减少“同一段重复内容”。
向量检索得到的是“粗排候选”,Rerank 会用更强的匹配模型对候选做精排,把最相关的片段放在最前面,直接影响最终答案质量。
把 Top-N 文档片段按“相关性 + 结构”组织成上下文(常包含标题、来源、页码/段落、关键句),并控制总 Token 预算。
Prompt 要明确:只基于给定上下文回答;不确定要说“不知道”;输出格式要包含要点与引用标注,减少幻觉并提升可读性。
实战里你需要能回答这些验收问题:
• 这次检索到了哪些片段?为什么排在前面?(Rerank 结果)
• 上下文最终拼接了哪些来源?(引用溯源)
• 这次回答是否“只基于文档”?(防幻觉约束)
离线把文档变成“可检索的向量知识库”,在线把用户问题变成“可检索的查询”(Rewrite)→ 找到最相关证据(Retrieve + Rerank)→ 组织成上下文(Context)→ 让 LLM 基于证据生成可追溯答案。
下面示例假设你已经用离线流程构建好了 Chroma 向量库(即后面“实战案例”里的 build_vectorstore()),然后演示一次“用户提问 → 改写 → 检索 → 重排 → 生成”的在线过程。
在线链路(History → Rewrite → Retrieve → Rerank → Prompt → Answer)已经完整融合到下面的“🎯 实战案例”代码里。 你只需要运行一次实战代码,就能同时验证:改写查询、命中文档片段(含 source)、最终回答与引用。
结合文档加载、分割和通义千问,构建一个完整的文档问答系统。
import os
from langchain_community.document_loaders import PyMuPDFLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_community.embeddings import DashScopeEmbeddings
from langchain_community.vectorstores import Chroma
from langchain_community.chat_models import ChatTongyi
from langchain_core.messages import AIMessage, HumanMessage
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
# 方式1:直接在这里填写 key(仅 demo)
# os.environ["DASHSCOPE_API_KEY"] = "your-api-key"
# 方式2:推荐从环境变量读取
os.environ["DASHSCOPE_API_KEY"] = os.environ.get("DASHSCOPE_API_KEY", "your-api-key")
def build_vectorstore():
"""加载 PDF,构建向量库,返回 vectorstore 对象。"""
print("步骤1:加载 PDF 文档...")
loader = PyMuPDFLoader("data/sample_document.pdf")
documents = loader.load()
print(f"加载了 {len(documents)} 页文档")
print("\n步骤2:分割文档...")
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=500,
chunk_overlap=50,
)
chunks = text_splitter.split_documents(documents)
print(f"分割成 {len(chunks)} 个文本块")
print("\n步骤3:创建向量数据库...")
embeddings = DashScopeEmbeddings(model="text-embedding-v1")
vectorstore = Chroma.from_documents(chunks, embeddings)
print("向量数据库创建完成!")
return vectorstore
def rewrite_query(llm, history_msgs, question):
"""把用户问题改写成可独立检索的查询(减少指代、补全关键实体)。"""
history_text = "\n".join([f"{m.type}: {m.content}" for m in history_msgs])
prompt = ChatPromptTemplate.from_messages(
[
(
"system",
"你是一个严谨的检索查询改写助手。"
"请把用户当前问题改写为可独立检索的中文查询,输出一行即可。",
),
(
"human",
"对话历史:\n{history}\n\n当前问题:{question}\n\n改写后的查询:",
),
]
)
chain = prompt | llm | StrOutputParser()
return chain.invoke({"history": history_text, "question": question}).strip()
def rerank_with_llm(llm, query, docs, top_n=4):
"""对候选片段做精排(演示版:用 LLM 给候选打分)。"""
scored = []
score_prompt = ChatPromptTemplate.from_messages(
[
(
"system",
"你是一个文档重排序器。给定查询和候选片段,请输出一个 0-10 的相关性分数(只输出数字)。",
),
(
"human",
"查询:{query}\n\n候选片段:\n{chunk}\n\n分数:",
),
]
)
score_chain = score_prompt | llm | StrOutputParser()
for d in docs:
raw = score_chain.invoke({"query": query, "chunk": d.page_content})
try:
score = float("".join([c for c in raw if (c.isdigit() or c == ".")]))
except Exception:
score = 0.0
scored.append((score, d))
scored.sort(key=lambda x: x[0], reverse=True)
return [d for _, d in scored[:top_n]]
# 模拟"论文学习助手"的对话历史
history = [
HumanMessage(content="我在学习一份大语言模型必读论文合集。"),
AIMessage(content="好的,我会基于论文内容回答,并在结尾标注引用来源。"),
]
questions = [
"请用通俗语言解释 Transformer 的核心思想,并指出相关段落。",
"Self-Attention 相比 RNN 的优势是什么?给出依据。",
"训练 Transformer 时常见的优化技巧有哪些?请给引用。",
]
if __name__ == "__main__":
# 步骤1-3:构建向量库
vectorstore = build_vectorstore()
# 步骤4:创建问答链
print("\n步骤4:创建问答链...")
llm = ChatTongyi(model="qwen-turbo", temperature=0.1)
prompt = ChatPromptTemplate.from_template(
"""你是一个严谨的论文学习知识库问答助手。请只根据给定的上下文回答问题。
如果上下文不足以支撑结论,请明确回答"不知道"。
请在回答末尾用【引用】列出使用到的 source。
上下文:
{context}
问题:{input}
"""
)
def format_docs(docs):
return "\n\n".join(
[
f"[source={d.metadata.get('source', 'unknown')}]\n{d.page_content}"
for d in docs
]
)
qa_chain = prompt | llm | StrOutputParser()
# 步骤5:提问
print("\n步骤5:开始提问(含 Rewrite + Rerank)...")
# 模拟"论文学习助手"的对话历史
history = [
HumanMessage(content="我在学习一份大语言模型必读论文合集。"),
AIMessage(content="好的,我会基于论文内容回答,并在结尾标注引用来源。"),
]
for q in questions:
print(f"\n用户问题: {q}")
rewritten = rewrite_query(llm, history, q)
print(f"改写后查询: {rewritten}")
# Retrieve:先拿候选
candidates = vectorstore.as_retriever(search_kwargs={"k": 8}).invoke(rewritten)
# Rerank:精排出最相关的证据
top_docs = rerank_with_llm(llm, rewritten, candidates, top_n=4)
# 打印命中文档片段预览,便于验收
print("命中文档片段(Top4):")
for i, d in enumerate(top_docs, 1):
src = d.metadata.get("source", "unknown")
preview = d.page_content.replace("\n", " ")[:120]
print(f"- #{i} source={src} preview={preview}...")
# 用精排后的 docs 组装上下文,再交给 LLM 生成
ctx = format_docs(top_docs)
answer = qa_chain.invoke({"context": ctx, "input": q})
print(f"回答: {answer}")步骤1:加载文档...
加载了 50 页文档
步骤2:分割文档...
分割成 245 个文本块
步骤3:创建向量数据库...
向量数据库创建完成
步骤4:创建问答链...
步骤5:开始提问...
问题: 公司的年假政策是什么?
回答: 根据员工手册,正式员工每年享有10天带薪年假,工作满3年后增加到15天,
满5年后增加到20天。年假需提前一周申请,经部门主管批准后方可休假。
问题: 如何申请远程办公?
回答: 员工可以通过OA系统提交远程办公申请,需要说明远程办公的原因和时长。
申请需经直接主管和HR部门批准。每月最多可申请5天远程办公。
问题: 员工福利有哪些?
回答: 公司提供的员工福利包括:五险一金、补充商业保险、年度体检、
节日礼品、生日礼金、团建活动、培训机会等。
上面的实战案例中,我们使用 vectorstore.similarity_search 找到了最相关的几个文档。
在真实业务中,我们还可以用一些更聪明的检索方式,让结果既相关,又多样、可解释。
下面的代码示例可以单独复制运行,它会:
1)加载 data/sample_document.pdf,2)构建向量库,3)演示带分数检索和 MMR 检索。
from langchain_community.document_loaders import PyMuPDFLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_community.embeddings import DashScopeEmbeddings
from langchain_community.vectorstores import Chroma
import os
# 请先在环境变量中配置 DASHSCOPE_API_KEY,或在这里直接赋值
os.environ["DASHSCOPE_API_KEY"] = os.environ.get("DASHSCOPE_API_KEY", "your-api-key")
def build_vectorstore():
"""加载 PDF,构建向量库,返回 vectorstore 对象。"""
print("步骤1:加载 PDF 文档...")
loader = PyMuPDFLoader("data/sample_document.pdf")
documents = loader.load()
print(f"加载了 {len(documents)} 页文档")
print("\n步骤2:分割文档...")
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=500,
chunk_overlap=50,
)
chunks = text_splitter.split_documents(documents)
print(f"分割成 {len(chunks)} 个文本块")
print("\n步骤3:创建向量数据库...")
embeddings = DashScopeEmbeddings(model="text-embedding-v1")
vectorstore = Chroma.from_documents(chunks, embeddings)
print("向量数据库创建完成!")
return vectorstore
def advanced_search(vectorstore):
"""演示带分数检索和 MMR 检索。"""
query = "公司的加班和调休政策是什么?"
print(f"\n查询:{query}")
# 1. 带分数的相似度搜索
print("\n=== 带分数的相似度搜索 ===")
results_with_scores = vectorstore.similarity_search_with_score(query, k=3)
for doc, score in results_with_scores:
print(f"相似度分数: {score:.4f}")
print(f"内容: {doc.page_content[:80]}...\n")
# 2. MMR 检索(最大边际相关性)
print("\n=== MMR 检索(既相关又多样) ===")
results = vectorstore.max_marginal_relevance_search(
query,
k=5, # 最终返回5个结果
fetch_k=20, # 先取20个候选
lambda_mult=0.5 # 0=更看重多样性,1=更看重相关性
)
for i, doc in enumerate(results, 1):
print(f"{i}. {doc.page_content[:60]}...")
# 注意:Chroma 不支持 max_marginal_relevance_search_with_score
# 如果需要获取 MMR 结果的相似度分数,可以用原始查询计算每个结果的相似度
print("\n=== MMR 结果的相似度分数(与原始查询的相似度) ===")
# 先获取所有候选文档的分数
all_results_with_scores = vectorstore.similarity_search_with_score(query, k=20)
# 创建文档内容到分数的映射
content_to_score = {doc.page_content: score for doc, score in all_results_with_scores}
# 为 MMR 结果匹配分数
for i, doc in enumerate(results, 1):
score = content_to_score.get(doc.page_content, None)
if score is not None:
print(f"{i}. 相似度分数: {score:.4f}")
print(f" 内容: {doc.page_content[:60]}...\n")
else:
print(f"{i}. 相似度分数: N/A")
print(f" 内容: {doc.page_content[:60]}...\n")
if __name__ == "__main__":
vectorstore = build_vectorstore()
advanced_search(vectorstore)仅按相似度排序,有时会出现多个内容几乎一样的片段,信息冗余。 MMR(Maximal Marginal Relevance) 会在保证相关性的前提下,让结果更加多样化。
"服务器报错 ERR-502 怎么处理?"ERR-502 的文档,但可能错过"网关超时"等语义相关内容
MMR 解决的是"去重复 / 提升多样性";而 Hybrid Search 解决的是"不要漏召回 / 专有名词要命中"。 在真实业务里,两者经常配合:先 Hybrid 把候选集召回来,再用 MMR 或 Rerank 做二次优化。
LangChain 的 LCEL(Runnable)支持 pipe(|)语法将多个组件串联LCEL、| 不一定强命中;LCEL、| 这种强关键词会被精准命中;
下面代码展示一种工程上很常用的“稀疏+稠密”融合写法:BM25 负责关键词兜底,向量检索负责语义召回,最后用 EnsembleRetriever 做融合(相当于“用排名/权重做融合”,思路与 RRF 类似)。
# 先安装依赖(如果还没有安装)
# pip install rank_bm25
from langchain_community.document_loaders import PyMuPDFLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_community.vectorstores import Chroma
from langchain_community.embeddings import DashScopeEmbeddings
from langchain_community.retrievers import BM25Retriever
def load_sample_documents():
"""加载示例PDF文档,包含LangChain相关内容"""
# 加载实际PDF文件
loader = PyMuPDFLoader("../data/sample_document.pdf")
documents = loader.load()
print(f"✅ 成功加载PDF文件,共 {len(documents)} 页")
return documents
def build_hybrid_retriever(documents):
"""构建混合检索器:BM25 + 向量检索"""
# 1) 文本分割
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=200,
chunk_overlap=20,
)
chunks = text_splitter.split_documents(documents)
# 2) 构建向量库
embeddings = DashScopeEmbeddings(model="text-embedding-v1")
vectorstore = Chroma.from_documents(chunks, embeddings)
# 3) 稀疏检索:BM25(强关键词兜底)
bm25_retriever = BM25Retriever.from_documents(chunks)
bm25_retriever.k = 5
# 4) 稠密检索:向量检索(语义召回)
dense_retriever = vectorstore.as_retriever(search_kwargs={"k": 5})
return bm25_retriever, dense_retriever
def hybrid_search(query, bm25_retriever, dense_retriever, bm25_weight=0.6, dense_weight=0.4):
"""手动实现Hybrid Search:BM25 + 向量检索融合"""
# 1) 获取BM25检索结果
bm25_docs = bm25_retriever.invoke(query)
# 2) 获取向量检索结果
dense_docs = dense_retriever.invoke(query)
# 3) 手动融合结果
# 创建文档到分数的映射
doc_scores = {}
# BM25结果:根据排名给分(排名越前分数越高)
for rank, doc in enumerate(bm25_docs):
doc_content = doc.page_content
if doc_content not in doc_scores:
doc_scores[doc_content] = {"doc": doc, "bm25_score": 0, "dense_score": 0}
# BM25分数:排名越前分数越高
doc_scores[doc_content]["bm25_score"] = 1.0 / (rank + 1)
# 向量检索结果:根据排名给分
for rank, doc in enumerate(dense_docs):
doc_content = doc.page_content
if doc_content not in doc_scores:
doc_scores[doc_content] = {"doc": doc, "bm25_score": 0, "dense_score": 0}
# 向量检索分数:排名越前分数越高
doc_scores[doc_content]["dense_score"] = 1.0 / (rank + 1)
# 4) 计算融合分数
hybrid_docs = []
for content, scores in doc_scores.items():
# 加权融合分数
hybrid_score = (scores["bm25_score"] * bm25_weight +
scores["dense_score"] * dense_weight)
hybrid_docs.append((scores["doc"], hybrid_score))
# 5) 按融合分数排序
hybrid_docs.sort(key=lambda x: x[1], reverse=True)
# 返回排序后的文档
return [doc for doc, score in hybrid_docs]
def rrf_fusion(bm25_docs, dense_docs, k=60):
"""
真正的 RRF (Reciprocal Rank Fusion) 融合实现
这是业界标准做法,比简单加权更科学
"""
doc_rrf_scores = {}
# BM25 排名贡献:k / (rank + k)
for rank, doc in enumerate(bm25_docs):
doc_content = doc.page_content
if doc_content not in doc_rrf_scores:
doc_rrf_scores[doc_content] = {"doc": doc, "rrf_score": 0}
doc_rrf_scores[doc_content]["rrf_score"] += k / (rank + k)
# 向量检索排名贡献:k / (rank + k)
for rank, doc in enumerate(dense_docs):
doc_content = doc.page_content
if doc_content not in doc_rrf_scores:
doc_rrf_scores[doc_content] = {"doc": doc, "rrf_score": 0}
doc_rrf_scores[doc_content]["rrf_score"] += k / (rank + k)
# 按 RRF 分数排序
rrf_results = sorted(
doc_rrf_scores.values(),
key=lambda x: x["rrf_score"],
reverse=True
)
return [item["doc"] for item in rrf_results]
def explain_rrf_with_example():
"""
详细解释 RRF 融合原理(面试必备)
"""
print("🔄 RRF (Reciprocal Rank Fusion) 融合原理详解")
print("=" * 60)
# 完整示例:多个文档的 RRF 计算
print("\n📊 完整示例:用户查询 'Spring Boot 配置文件加载顺序'")
print("=" * 60)
# 模拟检索结果
bm25_results = [
"Spring Boot 配置文件加载机制", # 第1名
"Spring Boot 配置文件优先级详解", # 第2名
"Spring Boot 多环境配置切换", # 第3名
"Spring Boot 启动流程分析", # 第4名
"Spring Boot 自动配置原理", # 第5名
]
vector_results = [
"Spring Boot 配置文件加载机制", # 第1名
"Spring Boot 自动配置原理", # 第2名
"Spring Boot 配置文件优先级详解", # 第3名
"Spring Boot 启动流程分析", # 第4名
"Spring Boot 多环境配置切换", # 第5名
]
k = 60
print(f"平滑参数 k = {k}")
print("\n🔍 BM25 检索结果(关键词匹配):")
for i, doc in enumerate(bm25_results, 1):
print(f" 第{i}名: {doc}")
print("\n🧠 向量检索结果(语义相似):")
for i, doc in enumerate(vector_results, 1):
print(f" 第{i}名: {doc}")
# RRF 计算
print(f"\n🧮 RRF 融合计算过程:")
print("-" * 60)
# 计算所有文档的 RRF 分数
all_docs = set(bm25_results + vector_results)
rrf_scores = {}
for doc in all_docs:
# BM25 排名
bm25_rank = bm25_results.index(doc) + 1 if doc in bm25_results else None
# 向量排名
vector_rank = vector_results.index(doc) + 1 if doc in vector_results else None
# 计算贡献
bm25_score = k / (bm25_rank + k) if bm25_rank else 0
vector_score = k / (vector_rank + k) if vector_rank else 0
rrf_score = bm25_score + vector_score
rrf_scores[doc] = {
'bm25_rank': bm25_rank,
'vector_rank': vector_rank,
'bm25_score': bm25_score,
'vector_score': vector_score,
'rrf_score': rrf_score
}
# 打印计算过程
print(f"\n📄 {doc}")
if bm25_rank:
print(f" BM25第{bm25_rank}名: {k}/({bm25_rank}+{k}) = {bm25_score:.4f}")
else:
print(f" BM25未召回: 0")
if vector_rank:
print(f" 向量第{vector_rank}名: {k}/({vector_rank}+{k}) = {vector_score:.4f}")
else:
print(f" 向量未召回: 0")
print(f" 🎯 RRF总分: {rrf_score:.4f}")
# 排序并显示结果
print(f"\n🏆 RRF 融合最终排序:")
print("-" * 60)
sorted_docs = sorted(rrf_scores.items(), key=lambda x: x[1]['rrf_score'], reverse=True)
for i, (doc, scores) in enumerate(sorted_docs, 1):
bm25_rank = scores['bm25_rank'] or '-'
vector_rank = scores['vector_rank'] or '-'
rrf_score = scores['rrf_score']
rank_diff = ""
if bm25_rank != '-' and vector_rank != '-':
if abs(bm25_rank - vector_rank) <= 1:
rank_diff = "🎯 (共识强)"
elif abs(bm25_rank - vector_rank) <= 3:
rank_diff = "✅ (互补)"
else:
rank_diff = "⚠️ (差异大)"
print(f"第{i}名: {doc} (RRF:{rrf_score:.4f}) BM25:{bm25_rank} 向量:{vector_rank} {rank_diff}")
print(f"\n💡 关键洞察:")
print("• 🎯 共识效应: 在两个系统都排名靠前的文档,RRF分数最高")
print("• ✅ 互补效应: 在一个系统表现好、另一个表现差的文档,仍能获得较高分数")
print("• ⚠️ 不丢失: 只在某个系统出现的文档,仍有贡献,不会完全丢失")
print("• 🔄 公式优势: 不需要分数归一化,直接用排名即可,工程简单稳定")
def compare_retrieval_methods(query, bm25_retriever, dense_retriever):
"""对比不同检索方法的效果"""
print(f"查询:{query}")
print("=" * 60)
# 1) 纯 BM25 检索
print("\n🔹 BM25 检索结果(关键词匹配):")
bm25_docs = bm25_retriever.invoke(query)
for i, doc in enumerate(bm25_docs[:3], 1):
print(f"{i}. {doc.page_content}")
# 2) 纯向量检索
print("\n🔹 向量检索结果(语义相似):")
dense_docs = dense_retriever.invoke(query)
for i, doc in enumerate(dense_docs[:3], 1):
print(f"{i}. {doc.page_content}")
# 3) Hybrid 检索(手动融合)
print("\n🔹 Hybrid 检索结果(BM25 + 向量融合):")
hybrid_docs = hybrid_search(query, bm25_retriever, dense_retriever)
for i, doc in enumerate(hybrid_docs[:3], 1):
print(f"{i}. {doc.page_content}")
# 4) 分析结果差异
print("\n📊 结果分析:")
bm25_content = set(doc.page_content for doc in bm25_docs[:3])
dense_content = set(doc.page_content for doc in dense_docs[:3])
hybrid_content = set(doc.page_content for doc in hybrid_docs[:3])
print(f"• BM25 独有结果:{len(bm25_content - dense_content)} 条")
print(f"• 向量检索独有结果:{len(dense_content - bm25_content)} 条")
print(f"• Hybrid 融合后结果:{len(hybrid_content)} 条")
print(f"• Hybrid 覆盖了 BM25 的 {len(hybrid_content & bm25_content)}/{len(bm25_content)} 条")
print(f"• Hybrid 覆盖了向量检索的 {len(hybrid_content & dense_content)}/{len(dense_content)} 条")
if __name__ == "__main__":
# 加载示例PDF文档
documents = load_sample_documents()
# 构建混合检索器
bm25_retriever, dense_retriever = build_hybrid_retriever(documents)
# 测试查询(包含强关键词)
query = "LCEL 的 | 管道语法怎么用?"
# 对比不同检索方法
compare_retrieval_methods(query, bm25_retriever, dense_retriever)
print("\n✅ 运行完成!可以看到 Hybrid Search 如何结合 BM25 的关键词匹配能力和向量检索的语义理解能力。")
⚖️ RRF vs 简单加权融合:为什么业界更推荐 RRF?
简单加权融合(代码中的 hybrid_search):
• 公式:score = bm25_weight × (1/rank) + dense_weight × (1/rank)
• 问题:权重需要人工调参,不同数据集需要不同权重
• 局限:假设 BM25 和向量分数分布相同,实际往往不成立
RRF 融合(业界标准):
• 公式:score = Σ(k / (rank_i + k)),k=60
• 优势:无需调参,k=60 在各种场景下表现稳定
• 理论:基于排名融合,避免分数归一化问题
• 效果:天然偏向在多个检索系统中都排名靠前的文档
面试金句:"RRF 的核心思想是'信任共识'——如果一个文档在多个检索系统中都排前面,那它很可能真的相关。而且 RRF 不需要调权重参数,工程上更稳定。"
一句工程建议:Hybrid 的输出最好再接一步"去重/精排"(MMR 或 Rerank),典型链路是:Dense Top-20 + BM25 Top-20 → RRF 融合取 Top-12 → MMR 去重取 Top-8 → Rerank 精排取 Top-4。
📌 小结
similarity_search:只要简单、快速检索时使用。
similarity_search_with_score:需要可解释的分数时使用。
max_marginal_relevance_search:追求既相关又多样的结果时使用。
Hybrid Search(BM25 + Dense):专有名词/强关键词密集场景下,用来兜底召回,减少漏召回。
掌握这些检索方式后,你就可以根据业务需求,灵活地为 RAG 系统选择"最聪明"的检索策略。
📚 课后练习与常见面试题
🧩 基础理解题
1. 为什么 RAG 需要“文档分割”?如果不分割会出现什么问题?
请至少从:token 限制、检索粒度、成本/延迟 3 个角度回答。
2. chunk_size 与 chunk_overlap 怎么选?给出一个“经验法则”。
同时说明 overlap 太大或太小分别会有什么坏处。
3. Hybrid Search 什么时候用?请对比 BM25、向量检索、Hybrid Search 的适用场景。
并解释 RRF 融合的核心思想,以及为什么 k=60 是常用值。
💻 编程题(可直接运行)
C1. 【编程题】分割参数对比实验:不同 chunk_size/overlap 影响块数与内容
要求:同一段文本,分别用 2 组参数分割;打印块数以及前 2 个块的内容摘要。
🎯 常见面试题(偏工程/架构)
1.【工程题】你如何让 RAG 的答案“可追溯”(引用来源)?
请覆盖:metadata 设计、top-k 证据展示、prompt 约束、前端呈现方式。
📝 本章小结
🎓 你学到了什么?
- 文档加载器:支持TXT、PDF、Word、网页、CSV等多种格式
- 文档分割:RecursiveCharacterTextSplitter最常用
- 向量化与检索:用 Embedding + 向量数据库完成相似度检索
- 实战应用:构建完整的文档问答系统
🚀 下一步学习
下一章我们将深入学习向量数据库与检索策略,
实现更高效、更可控的 RAG 检索!