← 返回上一页

第4章: RAG 快速上手:文档→向量→可检索问答

文档加载、分割与向量化

🤔 什么是RAG?

📖 RAG核心概念

RAG(Retrieval-Augmented Generation,检索增强生成)是一种结合了检索和生成的AI技术。 简单来说:先检索相关文档,再让AI基于这些文档回答问题。

🔄 RAG工作流程

1️⃣ 数据准备

加载文档
分割文本
向量化

2️⃣ 用户提问

输入问题
问题向量化
检索相关文档

3️⃣ AI回答

结合文档
生成答案
返回结果

💡 为什么需要RAG?

  • 知识更新:LLM训练数据有时间限制,RAG可以使用最新文档
  • 专业领域:企业内部文档、专业知识库,LLM没有学过
  • 减少幻觉:基于真实文档回答,降低AI编造内容的风险
  • 可追溯性:可以展示答案来源,增强可信度
  • 成本降低:不需要重新训练模型,只需更新文档库

🔄 RAG完整流程:从文档处理到智能问答

RAG 系统包含 6 个核心环节,分为三个阶段:文档处理、检索优化、生成增强

📥 步骤1:文档加载

从各种数据源加载文档:
• PDF、Word、TXT
• 网页、数据库
• API接口

目标:统一为Document对象

✂️ 步骤2:文本分割

将长文档切分成小块:
• 控制Token长度
• 保持语义完整
• 设置重叠区域

目标:适合检索的文本块

🔢 步骤3:向量化存储

将文本转换为向量:
• 使用Embedding模型
• 存入向量数据库
• 支持相似度搜索

目标:可快速检索的向量

🔄 步骤4:查询改写

优化用户查询表达:
• 解决指代消解
• 补充上下文信息
• 明确查询意图

目标:独立、清晰的检索查询

🔍 步骤5:向量检索

从向量库检索相关文档:
• 余弦相似度计算
• Top-K候选结果
• 多样性优化(MMR)

目标:高相关性的文档片段

⚡ 步骤6:重排序优化

精细化检索结果排序:
• Cross-Encoder模型
• 语义相关性精排
• 提升Top-K准确率

目标:最优的上下文材料

🎯 生成增强阶段(基于检索结果)

📝
上下文组装

将检索到的文档片段组织成连贯的上下文

🤖
LLM生成答案

基于上下文生成准确、相关的回答

🔗
引用溯源

为答案添加来源标注,确保可追溯性

🔄 完整RAG流程概览

原始文档 PDF/Word/TXT 网页/数据库 文档加载 统一格式 Document对象 文本分割 控制长度 保持语义 向量化 Embedding 文本→向量 向量数据库 Chroma/Qdrant 相似度搜索 用户问题 自然语言查询 查询改写 指代消解 问题向量化 查询向量 向量检索 Top-K候选 重排序 语义精排 相关文档 上下文信息 LLM生成 结合上下文 最终答案 带引用来源 数据准备与存储阶段 检索与生成阶段 • 离线处理:文档预处理 • 批量操作:一次性处理 • 存储优化:向量索引 • 实时响应:毫秒级检索 • 动态查询:用户驱动 • 上下文增强:提高准确性

💡 流程要点

  • 两阶段设计:数据准备(离线)+ 检索生成(在线),提高响应速度
  • 向量化核心:文本和查询使用相同Embedding模型,确保语义空间一致
  • 相似度匹配:通过余弦相似度找到最相关的文档片段
  • 上下文增强:LLM基于检索到的文档生成答案,提高准确性
  • 可追溯性:最终答案可以追溯到具体的文档来源

📥 步骤1:文档加载(Document Loading)

将各种格式的数据源统一转换为LangChain的Document对象。

📖 Document对象结构

python
from langchain import Document

# Document对象包含两部分
doc = Document(
    page_content="这是文档的文本内容",  # 文本内容(例如来自 data/article.txt)
    metadata={                          # 元数据
        "source": "data/article.txt",
        "page": 1,
        "author": "张三"
    }
)

📎 示例文件:下载 article.txt(可作为构造 Document 时的示例文档来源)。

📚 常用文档加载器详解

1. TextLoader - 文本文件加载器 📄

用途:加载纯文本文件(.txt)

python
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}")

✅ 运行结果

text
加载了 1 个文档
内容: 这是一篇关于人工智能的文章。人工智能(AI)是计算机科学的一个分支...
元数据: {'source': 'data/article.txt'}

📎 本例示例文件:下载 article.txt

2. PyMuPDFLoader - PDF文件加载器 📕

用途:加载PDF文件,按页分割

📦 安装依赖

text
pip install pymupdf

⚠️ 常见坑:PDF 解析报错 KeyError: 'bbox'

有些 PDF(尤其是扫描件/不规范导出的 PDF)字体信息不完整,某些解析引擎可能会在解析字体时触发 KeyError: 'bbox'。这不是你代码写错,通常是 PDF 文件本身问题。

最简单的 demo 处理方式:直接使用 PyMuPDFLoader(更鲁棒)。

text
pip install pymupdf
python
from 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}")

✅ 运行结果

text
PDF共有 10 页

--- 第1页 ---
内容: 2024年度工作报告\n\n一、概述\n本报告总结了2024年的主要工作成果...
元数据: {'source': 'data/report.pdf', 'page': 0}

-内容: 二、技术进展\n在人工智能领域,我们取得了以下突破...
元数据: {'source': 'data/report.pdf', 'page': 1}

📎 本例示例文件:下载 sample_document.pdf

⚠️ 关于 PDF / Word 示例

本示例使用 sample_document.pdf 作为示例 PDF 文件,你可以直接下载并配合 PyMuPDFLoader("data/sample_document.pdf") 运行代码。 如需体验 Word 文档加载,可以使用我们提供的 data/6.docx,或替换为你自己的 .docx 文件。

3. Docx2txtLoader - Word文档加载器 📘

用途:加载Word文档(.docx)

📦 安装依赖

Docx2txtLoader 的导入写法是最新的(LangChain 0.2+),如果你遇到 ModuleNotFoundError: No module named 'docx2txt', 说明当前运行代码的 Python 环境里还没安装 docx2txt

text
python -m pip install -U docx2txt
python
from 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

4. WebBaseLoader - 网页加载器 🌐

用途:从网页加载内容

📦 安装依赖

text
pip install beautifulsoup4
python
from 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)} 字符")

5. CSVLoader - CSV文件加载器 📊

用途:加载CSV表格数据

python
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}")

✅ 运行结果

text
加载了 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

6. DirectoryLoader - 目录加载器 📁

用途:批量加载整个目录的文件

python
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")

📎 本例示例文件:doc1.txtdoc2.txt

✂️ 文档分割(Text Splitting)

加载的文档通常很长,需要分割成小块才能有效处理。

🤔 为什么要分割文档?

  • Token限制:模型有最大Token限制,长文档无法一次处理
  • 检索精度:小块文本更容易匹配用户查询
  • 成本控制:只传递相关片段,节省Token
  • 上下文保持:通过overlap保持片段间的连贯性

📦 安装依赖

LangChain 0.2+ 将 Text Splitters 拆分为独立包 langchain-text-splitters

text
python -m pip install -U langchain-text-splitters

1. RecursiveCharacterTextSplitter - 递归字符分割器

最常用!按字符递归分割,优先保持段落完整。

python
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}")

✅ 运行结果

text
原文档: 1 个
分割后: 8 个块

第1块内容:
人工智能的发展历程

人工智能(Artificial Intelligence,简称AI)是计算机科学的一个分支,
它企图了解智能的实质,并生产出一种新的能以人类智能相似的方式做出反应的智能机器。
该领域的研究包括机器人、语言识别、图像识别、自然语言处理和专家系统等。

第1块元数据: {'source': 'data/long_article.txt'}

📎 本例示例文件:下载 long_article.txt

2. 严格滑窗切分 - Fixed Window Splitting

绝对生效!纯字符滑窗,overlap 100% 可预测,但可能切断句子。

python
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)

📋 参数详细说明

🔧 chunk_size(必需)
作用:每个文本块的最大字符数
类型:int
建议:根据 embedding 模型的 token 上限设置。OpenAI ada-002 推荐 500-800,bge-m3 推荐 300-500
示例:chunk_size=200 表示每块最多200个字符
🔄 chunk_overlap(可选)
作用:相邻文本块之间的重叠字符数
类型:int
建议:通常为 chunk_size 的 10-25%。太小丢失上下文,太大冗余过多
示例:chunk_overlap=50 表示每块与下一块重叠50个字符
📏 length_function(可选)
作用:计算文本长度的函数
类型:Callable[[str], int]
选项:len(字符数)、lambda x: len(x.split())(词数)、自定义 token 计数函数
示例:length_function=len 使用字符长度
✂️ separators(可选)
作用:分割优先级列表,按顺序尝试分割
类型:List[str]
严格滑窗:separators=[""] 表示不使用任何分隔符,纯字符滑窗
递归模式:separators=["\n\n", "\n", " ", ""] 按段落→句子→单词→字符递归
示例:separators=[""] 确保重叠绝对生效
📍 add_start_index(可选)
作用:在元数据中添加文本块的起始位置
类型:bool
用途:便于定位原文位置、生成引用、调试分割结果
示例:add_start_index=True 会在 metadata 中包含 'start_index' 键
🧹 strip_whitespace(可选)
作用:是否去除每块首尾的空白字符
类型:bool
建议:True(默认)确保文本干净;False 保留原始格式
影响:开启后可能影响重叠匹配的视觉效果
⚠️ 重要提醒:严格滑窗 vs 语义分割
严格滑窗优势:overlap 绝对生效,适合需要精确重叠的场景(如滑动窗口分析)
严格滑窗劣势:可能切断句子、段落,影响语义完整性
语义分割优势:保持句子、段落完整,更适合 RAG 场景
语义分割劣势:overlap 可能受分隔符影响,不是绝对生效

3. CharacterTextSplitter - 字符分割器

按指定分隔符分割文本。

python
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)} 个段落")

3. TokenTextSplitter - Token分割器

按Token数量分割,更精确控制长度。

📦 安装依赖

text
pip install tiktoken
python
from 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块")

4. SemanticChunker - 语义分割器

智能分割!根据语义相似度分割,保持内容连贯性。

📦 安装依赖

text
pip install langchain-experimental
⚠️ 重要提醒:SemanticChunker 在中文文本上表现不稳定
实测问题:即使使用标准差阈值和长文本,仍经常只返回 1 个块。
原因:SemanticChunker 对中文支持不完善,语义相似度计算不准确。
建议:对于中文文本,推荐使用方案 3(固定大小分割)或方案 2(Markdown分割)。
python
from 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 个块

✅ 实际运行结果(中文文本)

text
语义分割后: 1 个块

--- 第1块 ---
人工智能的发展历程

人工智能(Artificial Intelligence,简称AI)
是计算机科学的一个分支,                   它企图了解智能的实质,并生产出一种新的能以
人类智能相似的方式做出反应的智能机器。     该领域的研究包括机器人、语言识别、图像识别
、自然语言处理和专家系统等。               机器学习是AI的核心技术之一...
🔧 推荐的替代方案
方案1:固定大小分割(最稳定)
python
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)} 个块")
方案2:按段落分割(保持语义完整性)
text
# 适合结构化文档
paragraph_splitter = RecursiveCharacterTextSplitter(
    chunk_size=1000,
    chunk_overlap=0,
    separators=["\n\n", "\n"]
)
方案3:使用 NLTK 分句后分组(高级)
python
# 先分句,再根据相似度分组
import nltk
sentences = nltk.sent_tokenize(text)
# 然后用 embeddings 计算相似度分组

5. MarkdownHeaderTextSplitter - Markdown标题分割器

Markdown专用!根据标题层级(#、##、###)分割,保留结构信息。

python
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]}...")

✅ 运行结果

text
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?

📖 Embedding定义

Embedding(嵌入)是将文本转换为固定长度的数字向量的过程。 相似的文本会有相似的向量表示,这是向量检索和RAG的基础。

📊 示例对比

text
# 文本 → 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

通义千问提供了高质量的中文Embedding模型,我们可以直接在LangChain中使用。

📦 安装依赖

text
pip install dashscope

1. 基础使用

python
from 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]}")

✅ 运行结果

text
文本: 人工智能正在改变世界
向量维度: 1536
向量前5个值: [0.023, -0.015, 0.041, -0.008, 0.032]

2. 批量转换

python
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系统中"检索"这一步的核心组件。

🧠 向量数据库核心概念(面试必懂)
📚 Collection(集合)
概念:向量数据库中的"表",用于组织相关向量
类比:MySQL中的表,MongoDB中的集合
特点:同一Collection内向量维度必须一致
示例:collection_name="docs",存储1536维向量
🔍 Index(索引)
概念:加速向量检索的数据结构
类比:MySQL中的B+树索引,但针对向量空间
类型:FLAT、IVF、HNSW、PQ等
权衡:索引越复杂,查询越快,但内存占用越大
🕸️ HNSW(分层小世界图)
全称:Hierarchical Navigable Small World
原理:多层图结构,上层稀疏、下层密集
优势:查询速度快,内存占用适中
参数:M(每层连接数)、efConstruction(构建参数)
🎯 ANN(近似最近邻)
全称:Approximate Nearest Neighbor
核心思想:用"足够好"的结果换取"极快"的速度
数学原理:在高维空间中,精确最近邻往往不必要,近似解已足够
优势:查询速度提升100-1000倍,适合千万级向量
代价:精度损失1-5%,但在多数场景可接受
🔢 ANN 算法分类:
基于图的算法:HNSW、NSG(构建图结构导航)
基于分区的算法:IVF、LSH(空间分区索引)
基于量化的算法:PQ、OPQ(向量压缩存储)
混合算法:IVF+PQ、HNSW+PQ(组合优化)
💡 面试要点:
Collection:数据组织单元,类似数据库表
Index:加速检索,不同索引类型有不同性能特点
HNSW:目前最常用的ANN索引,平衡速度与精度
ANN vs 精确搜索:ANN快但可能不精确,精确搜索慢但100%准确
📊 向量数据库工作原理(举例说明)
场景:存储100万篇技术文档的向量,检索相似文档

🔧 数据准备阶段
1. 创建Collection:"tech_docs"
2. 设置向量维度:1536(text-embedding-v3)
3. 选择索引:HNSW(平衡速度与精度)
4. 批量插入100万个向量+元数据
🚀 检索阶段
1. 用户问题:"如何优化Redis性能?"
2. 向量化:生成1536维查询向量
3. HNSW搜索:在图中导航找最近邻
4. 返回Top-K:最相似的10篇文档

⚡ HNSW检索过程详解:
用户查询向量(1536维)→ 进入HNSW第0层(最稀疏)→ 快速定位到大概区域 → 逐层向下细化 → 第1层、第2层... → 到达最底层(最密集)→ 找到Top-K最近邻 → 返回结果

🕸️ HNSW 多层图结构可视化
第0层(最稀疏) 查询向量 第1层(中等密度) 第2层(最密集) 图例: 第0层节点 第1层节点 第2层节点 跨层连接 查询向量
🔍 搜索步骤详解:
1️⃣ 第0层:查询向量进入最稀疏层,快速定位到大概区域(绿色节点)
2️⃣ 第1层:向下进入中等密度层,细化搜索范围(蓝色节点)
3️⃣ 第2层:到达最密集层,精确找到最近邻(橙色节点)
4️⃣ 跨层连接:紫色虚线连接不同层级,实现快速导航
5️⃣ 红色路径:实际搜索路径,从顶层到底层逐层细化

📈 性能数据(参考):
• 无索引线性搜索:O(n) = 100万次计算,约1-2秒
• HNSW索引搜索:O(log n) ≈ 几十次计算,约10-50毫秒
• 内存占用:索引约占用原始数据的20-30%

📦 安装依赖(推荐:Chroma)

为了避免本地安装 faiss 的兼容性问题,本章后续示例默认使用 Chroma(本地向量库)。

text
python -m pip install -U chromadb

📊 常用向量数据库详细对比(多维度分析)

数据库 类型 性能 扩展性 成本 维护 适用场景
FAISS 开源库 极快 单机 免费 需运维 本地开发、大规模检索
Chroma 开源库 中等 单机 免费 简单 小型项目、快速原型
Pinecone 云服务 很快 分布式 付费 托管 生产环境、无需维护
Milvus 开源分布式 很快 分布式 运维成本 复杂 企业级应用
Weaviate 开源云原生 中等 分布式 运维成本 复杂 GraphQL、语义搜索
Qdrant Rust实现 很快 单机 运维成本 中等 内存安全、高性能
📋 维度说明:
类型:开源库 vs 云服务 vs 分布式系统
性能:查询速度和吞吐量表现
扩展性:单机限制 vs 水平扩展能力
成本:免费 vs 付费 vs 运维成本
维护:部署和运维复杂度
适用场景:最佳使用场景推荐
💡 选择建议:
学习/原型:Chroma(简单易用)
性能优先:FAISS(最快速度)
快速上线:Pinecone(托管服务)
企业级:Milvus(开源分布式)
内存安全:Qdrant(Rust实现)
语义搜索:Weaviate(GraphQL支持)
🔧 向量数据库进阶概念(深度理解)
📏 Distance Metrics(距离度量)
Cosine Similarity:余弦相似度,最常用
Euclidean Distance:欧几里得距离
Dot Product:点积,速度快
选择依据:Cosine适合文本,Euclidean适合图像
⚡ Index Types(索引类型)
FLAT:精确搜索,小数据集
IVF:倒排索引,中等规模
HNSW:图索引,大规模首选
PQ:乘积量化,压缩存储
🎯 ANN 算法深度对比
IVF(倒排文件):K-means分区+向量搜索
LSH(局部敏感哈希):哈希碰撞相似向量
PQ(乘积量化):向量压缩,内存友好
NSG(导航图):单层图,构建简单
📊 ANN vs 精确搜索权衡
精确搜索:100%准确,O(n)复杂度
ANN搜索:95-99%准确,O(log n)复杂度
速度提升:100-1000倍查询加速
内存开销:索引占20-50%额外内存
🎯 Top-K Search
K值选择:通常5-20,根据上下文窗口
召回率vs精确率:K越大召回率越高
计算复杂度:O(K log n) vs O(n)
业务权衡:更多上下文 vs 更高成本
🔄 CRUD Operations
Create:批量插入向量+元数据
Read:相似度检索、ID查询
Update:更新向量或元数据
Delete:按ID或条件删除
💻 Chroma 实战代码示例(核心操作)
python
# 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 支持持久化到磁盘")],
)
💡 代码要点:
PersistentClient:本地持久化,重启后数据不丢失
metadata过滤:可以按元数据条件筛选,提高检索精度
include参数:控制返回内容,减少网络传输
距离转换:Chroma返回距离,需要转换为相似度(1-distance)
🔍 ANN 算法详细对比与应用场景
📊 主流 ANN 算法性能对比表
算法 构建时间 查询时间 内存占用 适用规模
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 内存受限场景
🎯 实际应用场景选择
小规模(<10万):FLAT,精确搜索
中等规模(10-100万):IVF,平衡性能
大规模(100万+):HNSW,最佳选择
内存受限:PQ,压缩存储
超高精度:HNSW+重排
🔧 参数调优经验
IVF nlist:√数据量,如100万→1000
HNSW M:16-32,连接数vs内存
HNSW ef:查询时,值越大越精确
PQ M:量化参数,影响压缩率
综合建议:先默认,再根据业务调
💡 ANN 核心洞察:
维度诅咒:高维空间中距离计算困难,ANN通过结构化解决
精度-速度权衡:没有完美算法,只有最适合场景的算法
索引构建成本:一次性构建,多次查询受益
混合策略:IVF+PQ、HNSW+重排等组合效果更佳

🎯 RAG vs 传统问答对比

对比维度 传统LLM问答 RAG问答
知识来源 训练数据(固定) 外部文档(可更新)
知识更新 需要重新训练 只需更新文档
专业知识 泛化能力有限 可加载专业文档
答案来源 无法追溯 可展示来源文档
幻觉问题 容易编造内容 基于真实文档
成本 训练成本高 只需存储和检索

💬 用户提问(在线链路):从问题到可信答案

前面讲的是“离线构建知识库”的流程;进入实战前,还需要把“用户提问时在线发生了什么”讲清楚。

✅ 在线链路的 7 个关键点

1)输入与对话历史(History)

多轮对话里用户经常会用“它 / 这篇 / 上面那个”这类指代,系统需要把历史对话作为上下文,才能正确理解当前问题。

2)查询改写(Rewrite)

把“含指代 / 过短 / 过口语”的问题,改写成可独立检索的查询,例如补全论文名、方法名、指标名等检索关键词。

3)检索(Retrieve)

把改写后的查询向量化,在向量数据库中做 Top-K 相似度检索;必要时使用 MMR 做多样性优化,减少“同一段重复内容”。

4)重排序(Rerank)

向量检索得到的是“粗排候选”,Rerank 会用更强的匹配模型对候选做精排,把最相关的片段放在最前面,直接影响最终答案质量。

5)上下文组装(Context)

把 Top-N 文档片段按“相关性 + 结构”组织成上下文(常包含标题、来源、页码/段落、关键句),并控制总 Token 预算。

6)提示词与生成(Prompt + LLM)

Prompt 要明确:只基于给定上下文回答;不确定要说“不知道”;输出格式要包含要点与引用标注,减少幻觉并提升可读性。

7)可观测性与验收(Tracing & Evaluation)

实战里你需要能回答这些验收问题:
• 这次检索到了哪些片段?为什么排在前面?(Rerank 结果)
• 上下文最终拼接了哪些来源?(引用溯源)
• 这次回答是否“只基于文档”?(防幻觉约束)

🧭 一句话总结

离线把文档变成“可检索的向量知识库”,在线把用户问题变成“可检索的查询”(Rewrite)→ 找到最相关证据(Retrieve + Rerank)→ 组织成上下文(Context)→ 让 LLM 基于证据生成可追溯答案。

🧪 可执行示例:在线链路最小闭环(含 Rewrite + Rerank)

下面示例假设你已经用离线流程构建好了 Chroma 向量库(即后面“实战案例”里的 build_vectorstore()),然后演示一次“用户提问 → 改写 → 检索 → 重排 → 生成”的在线过程。

落地方式

在线链路(History → Rewrite → Retrieve → Rerank → Prompt → Answer)已经完整融合到下面的“🎯 实战案例”代码里。 你只需要运行一次实战代码,就能同时验证:改写查询、命中文档片段(含 source)、最终回答与引用。

🎯 实战案例:构建文档问答系统

结合文档加载、分割和通义千问,构建一个完整的文档问答系统。

python
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}")

✅ 运行结果

text
步骤1:加载文档...
加载了 50 页文档

步骤2:分割文档...
分割成 245 个文本块

步骤3:创建向量数据库...
向量数据库创建完成

步骤4:创建问答链...

步骤5:开始提问...

问题: 公司的年假政策是什么?
回答: 根据员工手册,正式员工每年享有10天带薪年假,工作满3年后增加到15天,
满5年后增加到20天。年假需提前一周申请,经部门主管批准后方可休假。

问题: 如何申请远程办公?
回答: 员工可以通过OA系统提交远程办公申请,需要说明远程办公的原因和时长。
申请需经直接主管和HR部门批准。每月最多可申请5天远程办公。

问题: 员工福利有哪些?
回答: 公司提供的员工福利包括:五险一金、补充商业保险、年度体检、
节日礼品、生日礼金、团建活动、培训机会等。

🔍 进阶:更聪明的检索方式

上面的实战案例中,我们使用 vectorstore.similarity_search 找到了最相关的几个文档。 在真实业务中,我们还可以用一些更聪明的检索方式,让结果既相关,又多样、可解释。

1. 带分数的相似度搜索 + MMR(图文原理 + 完整示例)

🎯 带分数相似度搜索原理
📊 核心概念:
相似度分数:0-1之间的数值,越高越相似
余弦相似度:最常用的文本相似度计算
Top-K检索:返回相似度最高的K个文档
分数意义:量化相关性,便于阈值判断
🔍 计算过程:
1. 查询向量化:query → [0.1, 0.2, ...]
2. 余弦计算:cos(query, doc_i)
3. 排序:按相似度从高到低
4. 返回:Top-K结果 + 分数
📈 相似度搜索可视化
查询向量 余弦相似度 计算 Doc1 0.95 Doc2 0.82 Doc3 0.67 Doc4 0.45 Top-K=3 选择 Doc1 (0.95) Doc2 (0.82) Doc3 (0.67)
🔍 处理流程:
1️⃣ 查询向量化 → 2️⃣ 计算余弦相似度 → 3️⃣ 按分数排序 → 4️⃣ Top-K选择 → 5️⃣ 返回结果+分数
🔄 MMR(最大边际相关性)原理
🧠 核心思想:
相关性:与查询的相似度
多样性:与已选结果的差异度
边际收益:相关性 - λ×多样性
平衡参数:λ控制相关vs多样权重
📊 MMR 公式:
MMR(doc) = λ × Sim(doc, query) - (1-λ) × max(Sim(doc, selected))

Sim(doc, query):文档与查询相似度
Sim(doc, selected):与已选文档最大相似度
λ:平衡参数(通常0.5-0.8)
🎯 MMR 选择过程可视化
初始候选集 DocA (相似度:0.95) DocB (相似度:0.92) DocC (相似度:0.88) DocD (相似度:0.75) DocE (相似度:0.72) MMR 计算 (λ=0.7) 第1轮: DocA (MMR:0.67) 第2轮: DocD (MMR:0.53) 第3轮: DocE (MMR:0.50) 排除: DocB, DocC (太相似) MMR 结果 DocA (0.95) DocD (0.75) DocE (0.72) ✅ 相关 + 多样
🎯 MMR 优势:
1️⃣ 避免重复内容 → 2️⃣ 提升信息密度 → 3️⃣ 覆盖多个角度 → 4️⃣ 更好的用户体验

下面的代码示例可以单独复制运行,它会: 1)加载 data/sample_document.pdf,2)构建向量库,3)演示带分数检索和 MMR 检索。

python
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)

💡 可以看到什么?

  • score 越小,说明越相似(具体含义取决于向量库实现,一般是距离或相似度的变体)。
  • 可以用分数做阈值过滤:太不相似的结果直接丢弃。
  • 方便在前端展示:例如“匹配度 92%”。

2. 什么时候用 MMR?

仅按相似度排序,有时会出现多个内容几乎一样的片段,信息冗余。 MMR(Maximal Marginal Relevance) 会在保证相关性的前提下,让结果更加多样化。

🧠 什么时候用 MMR?

  • 希望答案覆盖不同角度的信息,而不是重复类似内容。
  • 报告生成 / 总结时,希望看到更丰富的证据文档。
  • 为 LLM 提供上下文时,避免喂给它几乎一样的段落,提升信息密度。

3. 深入理解 Hybrid Search:稀疏 + 稠密 + 融合机制

🔍 Hybrid Search 的三大组件(面试必讲)
📝 BM25 稀疏检索
原理:基于词频-逆文档频率(TF-IDF)改进的统计模型
特点:只匹配出现过的词,专有名词、编号、符号精准命中
优势:强关键词(人名、产品名、错误码)召回稳定
劣势:无法理解同义词,"充电桩断电"搜不到"设备失去电源"
公式:score = Σ IDF(qi) × (f(qi,d) × (k1+1)) / (f(qi,d) + k1 × (1-b+b×|d|/avgdl))
🧠 稠密向量检索
原理:文本通过 Embedding 模型映射到高维向量空间,余弦相似度计算
特点:理解语义相似性,同义词、近义表达都能召回
优势:语义理解强,"充电桩断电"和"设备失去电源"相似度高
劣势:专有名词可能被"语义泛化"稀释,精确匹配弱
模型:text-embedding-v3(1536维)、ada-002(1536维)、BGE-large-zh(1024维)
🔄 RRF 融合机制
全称:Reciprocal Rank Fusion(倒数排名融合)
核心思想:"信任共识"——如果在多个检索系统都排前面,那它很可能真的相关
优势:简单高效,不需要分数归一化,天然偏向排名靠前的结果
公式:RRF(d) = Σ(k / (rank_i(d) + k)),通常 k=60
效果:BM25 第1名 + 向量第3名 → 融合后通常排前2,兼顾精确与语义
💡 关键洞察:
共识效应:在多个系统都排前面的文档,RRF分数最高
互补效应:一个系统表现好、另一个表现差的文档,仍能获得较高分数
不丢失:只在某个系统出现的文档,仍有贡献,不会完全丢失
平滑参数k:k=60让排名差异适度,不会过度偏向某个系统
🎯 为什么需要 Hybrid Search?真实业务场景举例
场景:IT 运维知识库问答
用户问题:"服务器报错 ERR-502 怎么处理?"

🔴 纯 BM25:
精准命中包含 ERR-502 的文档,但可能错过"网关超时"等语义相关内容
🔵 纯向量:
召回"网关超时"、"负载均衡异常"等语义相关文档,但可能漏掉具体的错误码说明
🟢 Hybrid:
RRF 融合后:
1) ERR-502 精准文档(BM25 第1)
2) 网关超时处理(向量 第1)
3) 502 错误码大全(BM25 第3)
💡 关键洞察:Hybrid Search 不是简单"两路检索相加",而是通过 RRF 融合让精确匹配语义理解形成互补,既保证专有名词不漏掉,又不丢失语义相关的上下文。
📊 RRF 融合详细计算示例(k=60)
场景:用户查询 "Spring Boot 配置文件加载顺序"

Step 1: BM25 检索结果(关键词匹配)
第1名: "Spring Boot 配置文件加载机制"
第2名: "Spring Boot 配置文件优先级详解"
第3名: "Spring Boot 多环境配置切换"

Step 2: 向量检索结果(语义相似)
第1名: "Spring Boot 配置文件加载机制"
第4名: "Spring Boot 自动配置原理"
第7名: "Spring Boot 配置文件优先级详解"

Step 3: RRF 融合计算
📄 文档A
"Spring Boot 配置文件加载机制"

BM25第1名: 60/(1+60) = 0.9836
向量第1名: 60/(1+60) = 0.9836
RRF总分: 1.9672
📄 文档B
"Spring Boot 配置文件优先级详解"

BM25第2名: 60/(2+60) = 0.9677
向量第7名: 60/(7+60) = 0.8955
RRF总分: 1.8632
📄 文档C
"Spring Boot 自动配置原理"

BM25未召回: 0
向量第4名: 60/(4+60) = 0.9375
RRF总分: 0.9375

🏆 最终排序:
🥇 第1名: 文档A (1.9672) - 两个系统都排第1,共识最强
🥈 第2名: 文档B (1.8632) - BM25第2,向量第7,综合不错
🥉 第3名: 文档C (0.9375) - 只在向量中出现,排名靠后
💡 计算洞察:
共识效应:文档A在两个系统都排第1,RRF分数最高
互补效应:文档B在BM25表现好,在向量表现差,RRF仍然给较高分数
不丢失:文档C只在向量中出现,但仍有贡献,不会完全丢失
平滑参数k:k=60让排名差异适度,不会过度偏向某个系统

4. Hybrid Search:BM25 + 向量检索(放在 MMR 下面一起对比)

MMR 解决的是"去重复 / 提升多样性";而 Hybrid Search 解决的是"不要漏召回 / 专有名词要命中"。 在真实业务里,两者经常配合:先 Hybrid 把候选集召回来,再用 MMR 或 Rerank 做二次优化。

✅ Hybrid Search vs MMR:优劣对比(面试常问)
MMR(Maximal Marginal Relevance)
优点:减少重复 chunk,结果更“多样”,信息密度更高
缺点:本质仍依赖向量召回的候选集,专有名词漏召回时无能为力
适用:总结/报告/需要覆盖多个角度的问答
Hybrid Search(BM25 + Dense)
优点:强关键词(人名/编号/符号)命中更稳,同时兼顾同义表达的语义召回
缺点:工程复杂度更高(两路检索+融合),调参需要评估集支撑
适用:专有名词密集、业务术语多、必须命中关键字的场景
结合实际案例:为什么 Hybrid Search 能兜住“强关键词”?
知识库原文:LangChain 的 LCEL(Runnable)支持 pipe(|)语法将多个组件串联
用户问题:“LCEL 的 | 管道语法怎么用?”
· 纯向量检索:容易把“组件编排/链式调用”这类语义相近内容排前面,但 LCEL| 不一定强命中;
· BM25:LCEL| 这种强关键词会被精准命中;
· Hybrid:两路召回后做融合(工程上常用 RRF/Ensemble),既保住强关键词,又不丢语义相似段落。

4. LangChain 里怎么写 Hybrid Search?(BM25 + 向量 + 融合示例)

下面代码展示一种工程上很常用的“稀疏+稠密”融合写法:BM25 负责关键词兜底,向量检索负责语义召回,最后用 EnsembleRetriever 做融合(相当于“用排名/权重做融合”,思路与 RRF 类似)。

python
# 先安装依赖(如果还没有安装)
# 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_sizechunk_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 检索!