Initial commit

This commit is contained in:
2026-05-12 09:41:56 +08:00
commit 572283e101
936 changed files with 133949 additions and 0 deletions
+419
View File
@@ -0,0 +1,419 @@
# 第一节 混合检索
混合检索(Hybrid Search)是一种结合了 **稀疏向量(Sparse Vectors****密集向量(Dense Vectors** 优势的先进搜索技术。旨在同时利用稀疏向量的关键词精确匹配能力和密集向量的语义理解能力,以克服单一向量检索的局限性,从而在各种搜索场景下提供更准确、更鲁棒的检索结果。
在本节中,我们将首先分析这两种核心向量的特性,然后讨论它们如何融合,最后通过milvus实现混合检索。
## 一、稀疏向量 vs 密集向量
为了更好地理解混合检索,首先需要厘清两种向量的本质区别。
### 1.1 稀疏向量
稀疏向量,也常被称为“词法向量”,是基于词频统计的传统信息检索方法的数学表示。它通常是一个维度极高(与词汇表大小相当)但绝大多数元素为零的向量。它采用精准的“词袋”匹配模型,将文档视为一堆词的集合,不考虑其顺序和语法,其中向量的每一个维度都直接对应一个具体的词,非零值则代表该词在文档中的重要性(权重)。这类向量的经典权重计算方法是 TF-IDF。在信息检索领域,BM25 则是基于这种稀疏表示的成功且应用广泛的排序算法之一,其核心公式如下:
$$ Score(Q, D) = \sum_{i=1}^{n} IDF(q_i) \cdot \frac{f(q_i, D) \cdot (k_1 + 1)}{f(q_i, D) + k_1 \cdot (1 - b + b \cdot \frac{|D|}{avgdl})} $$
其中:
- $IDF(q_i)$: 查询词 $q_i$ 的逆文档频率,用于衡量一个词的普遍程度。越常见的词,IDF值越低。
- $f(q_i, D)$: 查询词 $q_i$ 在文档 $D$ 中的词频。
- $|D|$: 文档 $D$ 的长度。
- $avgdl$: 集合中所有文档的平均长度。
- $k_1, b$: 可调节的超参数。 $k_1$ 用于控制词频饱和度(一个词在文档中出现10次和100次,其重要性增长并非线性), $b$ 用于控制文档长度归一化的程度。
这种方法的优点是可解释性极强(每个维度都代表一个确切的词),无需训练,能够实现关键词的精确匹配,对于专业术语和特定名词的检索效果好。主要缺点是无法理解语义,例如它无法识别“汽车”和“轿车”是同义词,存在“词汇鸿沟”。
### 1.2 密集向量
密集向量,也常被称为“语义向量”,是通过深度学习模型学习到的数据(如文本、图像)的低维、稠密的浮点数表示。这些向量旨在将原始数据映射到一个连续的、充满意义的“语义空间”中来捕捉“语义”或“概念”。在理想的语义空间中,向量之间的距离和方向代表了它们所表示概念之间的关系。一个经典的例子是 `vector('国王') - vector('男人') + vector('女人')` 的计算结果在向量空间中非常接近 `vector('女王')`,这表明模型学会了“性别”和“皇室”这两个维度的抽象概念。它的代表包括 Word2Vec、GloVe、以及所有基于 Transformer 的模型(如 BERT、GPT)生成的嵌入(Embeddings)。
其主要优点是能够理解同义词、近义词和上下文关系,泛化能力强,在语义搜索任务中表现卓越。但缺点也同样明显:可解释性差(向量中的每个维度通常没有具体的物理意义),需要大量数据和算力进行模型训练,且对于未登录词(OOV)[^1]的处理相对困难。
> **OOVOut-of-Vocabulary)未登录词**:指在模型训练时没有出现在词汇表中,但在实际使用时遇到的新词汇。例如,如果模型训练时词汇表中没有"ChatGPT"这个词,那么在实际应用中遇到它时就是OOV。传统的稀疏向量方法(如BM25)对OOV词汇会完全忽略,而现代的密集向量方法通过子词分割(如BPE、WordPiece)可以更好地处理OOV问题。
### 1.3 实例对比
**稀疏向量表示:**
稀疏向量的核心思想是只存储非零值。例如,一个8维的向量 `[0, 0, 0, 5, 0, 0, 0, 9]`,其大部分元素都是零。用稀疏格式表示,可以极大地节约空间。常见的稀疏表示法有两种:
1. **字典 / 键值对 (Dictionary / Key-Value):**
这种方式将非零元素的 `索引` (0-based) 作为键,`值` 作为值。上面的向量可以表示为:
```json
// {索引: 值}
{
"3": 5,
"7": 9
}
```
2. **坐标列表 (Coordinate list - COO):**
这种方式通常用一个元组 `(维度, [索引列表], [值列表])` 来表示。上面的向量可以表示为:
```
(8, [3, 7], [5, 9])
```
这种格式在 `SciPy` 等科学计算库中非常常见。
假设在一个包含5万个词的词汇表中,“西红柿”在第88位,“炒”在第666位,“蛋”在第999位,它们的BM25权重分别是1.2、0.8、1.5。那么它的稀疏表示(采用字典格式)就是:
```json
// {索引: 权重}
{
"88": 1.2,
"666": 0.8,
"999": 1.5
}
```
如果采用坐标列表(COO)格式,它会是这样:
```
(50000, [88, 666, 999], [1.2, 0.8, 1.5])
```
这两种格式都清晰地记录了文档的关键信息,但它们的局限性也很明显:如果我们搜索“番茄炒鸡蛋”,由于“番茄”和“西红柿”是不同的词条(索引不同),模型将无法理解它们的语义相似性。
**密集向量表示:**
与稀疏向量不同,密集向量的所有维度都有值,因此使用**数组 `[]`** 来表示是最直接的方式。一个预训练好的语义模型在读取“西红柿炒蛋”后,会输出一个低维的密集向量:
```json
// 这是一个低维(比如1024维)的浮点数向量
// 向量的每个维度没有直接的、可解释的含义
[0.89, -0.12, 0.77, ..., -0.45]
```
这个向量本身难以解读,但它在语义空间中的位置可能与“番茄鸡蛋面”、“洋葱炒鸡蛋”等菜肴的向量非常接近,因为模型理解了它们共享“鸡蛋类菜肴”、“家常菜”、“酸甜口味”等核心概念。因此,当我们搜索“蛋白质丰富的家常菜”时,即使查询中没有出现任何原文关键词,密集向量也很有可能成功匹配到这份菜谱。
## 二、混合检索
通过上文可以看出稀疏向量和密集向量各有千秋,那么将它们结合起来,实现优势互补,就成了一个不错的选择。混合检索便是基于这个思路,通过结合多种搜索算法(最常见的是稀疏与密集检索)来提升搜索结果相关性和召回率。
- **主要目标**:解决单一检索技术的局限性。例如,关键词检索无法理解语义,而向量检索则可能忽略掉必须精确匹配的关键词(如产品型号、函数名等)。混合检索旨在同时利用稀疏向量的**精确性**和密集向量的**泛化性**,以应对复杂多变的搜索需求。
### 2.1 技术原理与融合方法
混合检索通常并行执行两种检索算法,然后将两组异构的结果集融合成一个统一的排序列表。以下是两种主流的融合策略:
#### 2.1.1 倒数排序融合 (Reciprocal Rank Fusion, RRF)
RRF 不关心不同检索系统的原始得分,只关心每个文档在各自结果集中的**排名**。其思想是:一个文档在不同检索系统中的排名越靠前,它的最终得分就越高。
其计分公式为:
$$ RRF_{score}(d) = \sum_{i=1}^{k} \frac{1}{rank_i(d) + c} $$
其中:
- $d$ 是待评分的文档。
- $k$ 是检索系统的数量(这里是2,即稀疏和密集)。
- $rank_i(d)$ 是文档 $d$ 在第 $i$ 个检索系统中的排名。
- $c$ 是一个常数(通常设为60),用于降低排名靠前文档的相对权重,实现更稳健的排名融合。
#### 2.1.2 加权线性组合
这种方法需要先将不同检索系统的得分进行归一化(例如,统一到 0-1 区间),然后通过一个权重参数 `α` 来进行线性组合。
$$ Hybrid_{score} = \alpha \cdot Dense_{score} + (1 - \alpha) \cdot Sparse_{score} $$
通过调整 `α` 的值,可以灵活地控制语义相似性与关键词匹配在最终排序中的贡献比例。例如,在电商搜索中,可以调高关键词的权重;而在智能问答中,则可以侧重于语义。
### 2.2 优势与局限
| 优势 | 局限 |
| :--- | :--- |
| **召回率与准确率高**:能同时捕获关键词和语义,显著优于单一检索。 | **计算资源消耗大**:需要同时维护和查询两套索引。 |
| **灵活性强**:可通过融合策略和权重调整,适应不同业务场景。 | **参数调试复杂**:融合权重等超参数需要反复实验调优。 |
| **容错性好**:关键词检索可部分弥补向量模型对拼写错误或罕见词的敏感性。 | **可解释性仍是挑战**:融合后的结果排序理由难以直观分析。 |
## 三、代码实践:通过 Milvus 实现混合检索
接下来使用 Milvus 来实现一个完整的混合检索流程,从定义 Schema、插入数据,到执行查询。
### 3.1 步骤一:定义 Collection
在上一章中我们实现了多模态图文检索,现在还是同样的步骤先创建一个 Collection。
```python
import json
import os
os.environ["HF_ENDPOINT"] = "https://hf-mirror.com"
import numpy as np
from pymilvus import connections, MilvusClient, FieldSchema, CollectionSchema, DataType, Collection, AnnSearchRequest, RRFRanker
from pymilvus.model.hybrid import BGEM3EmbeddingFunction
# 1. 初始化设置
COLLECTION_NAME = "dragon_hybrid_demo"
MILVUS_URI = "http://localhost:19530" # 服务器模式
DATA_PATH = "../../data/C4/metadata/dragon.json" # 相对路径
BATCH_SIZE = 50
# 2. 连接 Milvus 并初始化嵌入模型
print(f"--> 正在连接到 Milvus: {MILVUS_URI}")
connections.connect(uri=MILVUS_URI)
print("--> 正在初始化 BGE-M3 嵌入模型...")
ef = BGEM3EmbeddingFunction(use_fp16=False, device="cpu")
print(f"--> 嵌入模型初始化完成。密集向量维度: {ef.dim['dense']}")
# 3. 创建 Collection
milvus_client = MilvusClient(uri=MILVUS_URI)
if milvus_client.has_collection(COLLECTION_NAME):
print(f"--> 正在删除已存在的 Collection '{COLLECTION_NAME}'...")
milvus_client.drop_collection(COLLECTION_NAME)
fields = [
FieldSchema(name="pk", dtype=DataType.VARCHAR, is_primary=True, auto_id=True, max_length=100),
FieldSchema(name="img_id", dtype=DataType.VARCHAR, max_length=100),
FieldSchema(name="path", dtype=DataType.VARCHAR, max_length=256),
FieldSchema(name="title", dtype=DataType.VARCHAR, max_length=256),
FieldSchema(name="description", dtype=DataType.VARCHAR, max_length=4096),
FieldSchema(name="category", dtype=DataType.VARCHAR, max_length=64),
FieldSchema(name="location", dtype=DataType.VARCHAR, max_length=128),
FieldSchema(name="environment", dtype=DataType.VARCHAR, max_length=64),
FieldSchema(name="sparse_vector", dtype=DataType.SPARSE_FLOAT_VECTOR),
FieldSchema(name="dense_vector", dtype=DataType.FLOAT_VECTOR, dim=ef.dim["dense"])
]
# 如果集合不存在,则创建它及索引
if not milvus_client.has_collection(COLLECTION_NAME):
print(f"--> 正在创建 Collection '{COLLECTION_NAME}'...")
schema = CollectionSchema(fields, description="关于龙的混合检索示例")
# 创建集合
collection = Collection(name=COLLECTION_NAME, schema=schema, consistency_level="Strong")
print("--> Collection 创建成功。")
# 创建索引
print("--> 正在为新集合创建索引...")
sparse_index = {"index_type": "SPARSE_INVERTED_INDEX", "metric_type": "IP"}
collection.create_index("sparse_vector", sparse_index)
print("稀疏向量索引创建成功。")
dense_index = {"index_type": "AUTOINDEX", "metric_type": "IP"}
collection.create_index("dense_vector", dense_index)
print("密集向量索引创建成功。")
collection = Collection(COLLECTION_NAME)
collection.load()
print(f"--> Collection '{COLLECTION_NAME}' 已加载到内存。")
```
**fields字段类型分析:**
- **pk**: 主键设计,`auto_id=True` 让 Milvus 自动生成唯一标识,避免主键冲突
- **标量字段**: 7个VARCHAR字段用于存储元数据,`max_length` 根据实际数据分布优化存储
- **稀疏向量**: `SPARSE_FLOAT_VECTOR` 类型,存储关键词权重
- **密集向量**: `FLOAT_VECTOR` 类型,固定1024维,存储语义特征
### 3.2 步骤二:BGE-M3 双向量生成
这里使用 BGE-M3 作为向量生成器,它能够同时生成稀疏向量和密集向量。
#### 3.2.1 数据加载与预处理
```python
if collection.is_empty:
print(f"--> Collection 为空,开始插入数据...")
with open(DATA_PATH, 'r', encoding='utf-8') as f:
dataset = json.load(f)
docs, metadata = [], []
for item in dataset:
parts = [
item.get('title', ''),
item.get('description', ''),
item.get('location', ''),
item.get('environment', ''),
]
docs.append(' '.join(filter(None, parts)))
metadata.append(item)
```
Collection 此时已加载到内存但为空状态。通过 `is_empty` 检查避免重复插入。多字段文本合并中每个实体对应一个完整的数据记录。
#### 3.2.2 向量生成
```python
print("--> 正在生成向量嵌入...")
embeddings = ef(docs)
print("--> 向量生成完成。")
# 获取两种向量
sparse_vectors = embeddings["sparse"] # 稀疏向量:词频统计
dense_vectors = embeddings["dense"] # 密集向量:语义编码
```
#### 3.2.3 Collection 批量数据插入
```python
# 为每个字段准备批量数据
img_ids = [doc["img_id"] for doc in metadata]
paths = [doc["path"] for doc in metadata]
titles = [doc["title"] for doc in metadata]
descriptions = [doc["description"] for doc in metadata]
categories = [doc["category"] for doc in metadata]
locations = [doc["location"] for doc in metadata]
environments = [doc["environment"] for doc in metadata]
# 插入数据
collection.insert([
img_ids, paths, titles, descriptions, categories, locations, environments,
sparse_vectors, dense_vectors
])
collection.flush()
```
- **字段映射**: 严格按照 Schema 定义的字段顺序插入,9个字段(7个标量+2个向量)
- **`flush()` 作用**: 强制将内存缓冲区数据写入磁盘,使数据立即可搜索
- **最终状态**: Collection 包含6个Entity,索引层使用稀疏向量的 `SPARSE_INVERTED_INDEX` 和密集向量的 `AUTOINDEX`
### 3.3 步骤三:实现混合检索
最后使用 milvus 中封装好的 RRF 排序算法来完成混合检索:
#### 3.3.1 查询向量生成
```python
# 6. 执行搜索
search_query = "悬崖上的巨龙"
search_filter = 'category in ["western_dragon", "chinese_dragon", "movie_character"]'
top_k = 5
print(f"\n{'='*20} 开始混合搜索 {'='*20}")
print(f"查询: '{search_query}'")
print(f"过滤器: '{search_filter}'")
# 生成查询向量
query_embeddings = ef([search_query])
dense_vec = query_embeddings["dense"][0]
sparse_vec = query_embeddings["sparse"]._getrow(0)
```
尝试打印向量信息可以看到如下输出:
```bash
=== 向量信息 ===
密集向量维度: 1024
密集向量前5个元素: [-0.0035305 0.02043397 -0.04192593 -0.03036701 -0.02098157]
密集向量范数: 1.0000
稀疏向量维度: 250002
稀疏向量非零元素数量: 6
稀疏向量前5个非零元素:
- 索引: 6, 值: 0.0659
- 索引: 7977, 值: 0.1459
- 索引: 14732, 值: 0.2959
- 索引: 31433, 值: 0.1463
- 索引: 141121, 值: 0.1587
稀疏向量密度: 0.00239998%
```
#### 3.3.2 混合检索执行
使用 RRF 算法进行混合检索,通过 milvus 封装的 RRFRanker 实现。RRFRanker 的核心参数是 `k` 值(默认60),用于控制 RRF 算法中的排序平滑程度。
其中 `k` 值越大,排序结果越平滑;越小则高排名结果的权重越突出
```python
# 定义搜索参数
search_params = {"metric_type": "IP", "params": {}}
# 先执行单独的搜索
print("\n--- [单独] 密集向量搜索结果 ---")
dense_results = collection.search(
[dense_vec],
anns_field="dense_vector",
param=search_params,
limit=top_k,
expr=search_filter,
output_fields=["title", "path", "description", "category", "location", "environment"]
)[0]
for i, hit in enumerate(dense_results):
print(f"{i+1}. {hit.entity.get('title')} (Score: {hit.distance:.4f})")
print(f" 路径: {hit.entity.get('path')}")
print(f" 描述: {hit.entity.get('description')[:100]}...")
print("\n--- [单独] 稀疏向量搜索结果 ---")
sparse_results = collection.search(
[sparse_vec],
anns_field="sparse_vector",
param=search_params,
limit=top_k,
expr=search_filter,
output_fields=["title", "path", "description", "category", "location", "environment"]
)[0]
for i, hit in enumerate(sparse_results):
print(f"{i+1}. {hit.entity.get('title')} (Score: {hit.distance:.4f})")
print(f" 路径: {hit.entity.get('path')}")
print(f" 描述: {hit.entity.get('description')[:100]}...")
print("\n--- [混合] 稀疏+密集向量搜索结果 ---")
# 创建 RRF 融合器
rerank = RRFRanker(k=60)
# 创建搜索请求
dense_req = AnnSearchRequest([dense_vec], "dense_vector", search_params, limit=top_k)
sparse_req = AnnSearchRequest([sparse_vec], "sparse_vector", search_params, limit=top_k)
# 执行混合搜索
results = collection.hybrid_search(
[sparse_req, dense_req],
rerank=rerank,
limit=top_k,
output_fields=["title", "path", "description", "category", "location", "environment"]
)[0]
# 打印最终结果
for i, hit in enumerate(results):
print(f"{i+1}. {hit.entity.get('title')} (Score: {hit.distance:.4f})")
print(f" 路径: {hit.entity.get('path')}")
print(f" 描述: {hit.entity.get('description')[:100]}...")
```
最终输出如下:
```bash
--- [单独] 密集向量搜索结果 ---
1. 悬崖上的白龙 (Score: 0.7219)
路径: ../../data/C3/dragon/dragon02.png
描述: 一头雄伟的白色巨龙栖息在悬崖边缘,背景是金色的云霞和远方的海岸。它拥有巨大的翅膀和优雅的身姿,是典型的西方奇幻生物。...
2. 中华金龙 (Score: 0.5131)
路径: ../../data/C3/dragon/dragon06.png
描述: 一条金色的中华龙在祥云间盘旋,它身形矫健,龙须飘逸,展现了东方神话中龙的威严与神圣。...
3. 驯龙高手:无牙仔 (Score: 0.5119)
路径: ../../data/C3/dragon/dragon05.png
描述: 在电影《驯龙高手》中,主角小嗝嗝骑着他的龙伙伴无牙仔在高空飞翔。他们飞向灿烂的太阳,下方是岛屿和海洋,画面充满了冒险与友谊。...
--- [单独] 稀疏向量搜索结果 ---
1. 悬崖上的白龙 (Score: 0.2319)
路径: ../../data/C3/dragon/dragon02.png
描述: 一头雄伟的白色巨龙栖息在悬崖边缘,背景是金色的云霞和远方的海岸。它拥有巨大的翅膀和优雅的身姿,是典型的西方奇幻生物。...
2. 中华金龙 (Score: 0.0923)
路径: ../../data/C3/dragon/dragon06.png
描述: 一条金色的中华龙在祥云间盘旋,它身形矫健,龙须飘逸,展现了东方神话中龙的威严与神圣。...
3. 驯龙高手:无牙仔 (Score: 0.0691)
路径: ../../data/C3/dragon/dragon05.png
描述: 在电影《驯龙高手》中,主角小嗝嗝骑着他的龙伙伴无牙仔在高空飞翔。他们飞向灿烂的太阳,下方是岛屿和海洋,画面充满了冒险与友谊。...
--- [混合] 稀疏+密集向量搜索结果 ---
1. 悬崖上的白龙 (Score: 0.0328)
路径: ../../data/C3/dragon/dragon02.png
描述: 一头雄伟的白色巨龙栖息在悬崖边缘,背景是金色的云霞和远方的海岸。它拥有巨大的翅膀和优雅的身姿,是典型的西方奇幻生物。...
2. 中华金龙 (Score: 0.0320)
路径: ../../data/C3/dragon/dragon06.png
描述: 一条金色的中华龙在祥云间盘旋,它身形矫健,龙须飘逸,展现了东方神话中龙的威严与神圣。...
3. 霸王龙的怒吼 (Score: 0.0318)
路径: ../../data/C3/dragon/dragon03.png
描述: 史前时代的霸王龙张开血盆大口,发出震天的怒吼。在它身后,几只翼龙在阴沉的天空中盘旋,展现了白垩纪的原始力量。...
4. 奔跑的奶龙 (Score: 0.0313)
路径: ../../data/C3/dragon/dragon04.png
描述: 一只Q版的黄色小恐龙,有着大大的绿色眼睛和友善的微笑。是一部动画中的角色,非常可爱。...
5. 驯龙高手:无牙仔 (Score: 0.0310)
路径: ../../data/C3/dragon/dragon05.png
描述: 在电影《驯龙高手》中,主角小嗝嗝骑着他的龙伙伴无牙仔在高空飞翔。他们飞向灿烂的太阳,下方是岛屿和海洋,画面充满了冒险与友谊。...
```
> [本节完整代码](https://github.com/datawhalechina/all-in-rag/blob/main/code/C4/01_hybrid_search.py)
## 练习
- 分析代码为什么在密集向量检索和稀疏向量检索中,排名第三的驯龙高手在混合检索中反而排在了第五?
- 基于上一节的多模态检索代码 `04_multi_milvus.py` ,结合本节的检索代码加入多模态信息融合的功能并尝试使用混合检索。([参考代码](https://github.com/datawhalechina/all-in-rag/blob/main/code/C3/work_multimodal_dragon_search.py)
+212
View File
@@ -0,0 +1,212 @@
# 第二节 查询构建
在前面的章节中,我们探讨了如何通过向量嵌入和相似度搜索来从非结构化数据中检索信息。然而,在实际应用中,我们常常需要处理更加复杂和多样化的数据,包括结构化数据(如SQL数据库)、半结构化数据(如带有元数据的文档)以及图数据。用户的查询也可能不仅仅是简单的语义匹配,而是包含复杂的过滤条件、聚合操作或关系查询。
**查询构建(Query Construction**[^1] 正是应对这一挑战的关键技术。它利用大语言模型(LLM)的强大理解能力,将用户的自然语言查询“翻译”成针对特定数据源的结构化查询语言或带有过滤条件的请求。这使得RAG系统能够无缝地连接和利用各种类型的数据,从而极大地扩展了其应用场景和能力。
下图展示了查询构建在一个高级RAG流程中所处的位置:
![Advanced RAG Pipeline](./images/4_2_1.webp)
## 一、文本到元数据过滤器
在构建向量索引时,常常会为文档块(Chunks)附加元数据(Metadata),例如文档来源、发布日期、作者、章节、类别等。这些元数据为我们提供了在语义搜索之外进行精确过滤的可能。
**自查询检索器(Self-Query Retriever** 是LangChain中实现这一功能的核心组件。它的工作流程如下:
1. **定义元数据结构**:首先,需要向LLM清晰地描述文档内容和每个元数据字段的含义及类型。
2. **查询解析**:当用户输入一个自然语言查询时,自查询检索器会调用LLM,将查询分解为两部分:
* **查询字符串(Query String**:用于进行语义搜索的部分。
* **元数据过滤器(Metadata Filter**:从查询中提取出的结构化过滤条件。
3. **执行查询**:检索器将解析出的查询字符串和元数据过滤器发送给向量数据库,执行一次同时包含语义搜索和元数据过滤的查询。
例如,对于查询“关于2022年发布的机器学习的论文”,自查询检索器会将其解析为:
* **查询字符串**: "机器学习的论文"
* **元数据过滤器**: `year == 2022`
### 代码示例
接下来以B站视频为例来看看如何使用`SelfQueryRetriever`
```python
import os
from langchain_deepseek import ChatDeepSeek
from langchain_community.document_loaders import BiliBiliLoader
from langchain.chains.query_constructor.base import AttributeInfo
from langchain.retrievers.self_query.base import SelfQueryRetriever
from langchain_community.vectorstores import Chroma
from langchain_huggingface import HuggingFaceEmbeddings
import logging
logging.basicConfig(level=logging.INFO)
# 1. 初始化视频数据
video_urls = [
"https://www.bilibili.com/video/BV1Bo4y1A7FU",
"https://www.bilibili.com/video/BV1ug4y157xA",
"https://www.bilibili.com/video/BV1yh411V7ge",
]
bili = []
try:
loader = BiliBiliLoader(video_urls=video_urls)
docs = loader.load()
for doc in docs:
original = doc.metadata
# 提取基本元数据字段
metadata = {
'title': original.get('title', '未知标题'),
'author': original.get('owner', {}).get('name', '未知作者'),
'source': original.get('bvid', '未知ID'),
'view_count': original.get('stat', {}).get('view', 0),
'length': original.get('duration', 0),
}
doc.metadata = metadata
bili.append(doc)
except Exception as e:
print(f"加载BiliBili视频失败: {str(e)}")
if not bili:
print("没有成功加载任何视频,程序退出")
exit()
# 2. 创建向量存储
embed_model = HuggingFaceEmbeddings(model_name="BAAI/bge-small-zh-v1.5")
vectorstore = Chroma.from_documents(bili, embed_model)
```
在上面的代码中,首先使用 `BiliBiliLoader` 加载了几个B站视频的文档和元数据。需要注意的是,由于 `BiliBiliLoader` 返回的原始元数据结构较为复杂(例如,作者和观看数信息嵌套在其他字典中),所以进行了一些预处理工作:遍历每个文档,手动提取需要的字段(如`title`, `author`, `view_count`, `length`),并构建一个干净、扁平化的新 `metadata` 字典。这个过程确保了后续的自查询检索器能够直接、可靠地访问这些字段。最后,将处理好的文档和元数据存入 `Chroma` 向量数据库中,为下一步的查询构建做好准备。
```python
# 3. 配置元数据字段信息
metadata_field_info = [
AttributeInfo(
name="title",
description="视频标题(字符串)",
type="string",
),
AttributeInfo(
name="author",
description="视频作者(字符串)",
type="string",
),
AttributeInfo(
name="view_count",
description="视频观看次数(整数)",
type="integer",
),
AttributeInfo(
name="length",
description="视频长度,以秒为单位的整数",
type="integer"
)
]
# 4. 创建自查询检索器
llm = ChatDeepSeek(
model="deepseek-chat",
temperature=0,
api_key=os.getenv("DEEPSEEK_API_KEY")
)
retriever = SelfQueryRetriever.from_llm(
llm=llm,
vectorstore=vectorstore,
document_contents="记录视频标题、作者、观看次数等信息的视频元数据",
metadata_field_info=metadata_field_info,
enable_limit=True,
verbose=True
)
# 5. 执行查询示例
queries = [
"时间最短的视频",
"时长大于600秒的视频"
]
for query in queries:
print(f"\n--- 查询: '{query}' ---")
results = retriever.invoke(query)
if results:
for doc in results:
title = doc.metadata.get('title', '未知标题')
author = doc.metadata.get('author', '未知作者')
view_count = doc.metadata.get('view_count', '未知')
length = doc.metadata.get('length', '未知')
print(f"标题: {title}")
print(f"作者: {author}")
print(f"观看次数: {view_count}")
print(f"时长: {length}")
print("="*50)
else:
print("未找到匹配的视频")
```
这部分代码是实现自查询检索的核心。主要分为三个步骤:
1. **配置元数据字段 (`metadata_field_info`)** :这是与LLM沟通的蓝图。通过 `AttributeInfo` 为每个元数据字段定义名称、类型和一份清晰的自然语言 `description`。LLM 将依赖这份描述来理解如何处理用户的查询,例如,它会根据“视频长度(整数)”的描述来解析关于“时长”的过滤和排序请求。因此,一份准确、无歧义的描述很重要。
2. **创建自查询检索器 (`SelfQueryRetriever.from_llm`)** `from_llm` 方法在底层执行了两个核心操作:
* **加载查询构造器**:利用传入的 `llm``document_contents``metadata_field_info`,创建一个专门的“查询构造链”。这个链的核心职责是将用户的自然语言查询(如“时长大于600秒的视频”)转换为一个通用的、结构化的查询对象。
* **获取内置翻译器**:接着,检查使用的向量数据库(这里是 `Chroma`),并为其匹配一个内置的“翻译器”。这个翻译器负责将上一步生成的通用查询对象,翻译成 `Chroma` 数据库能够原生理解和执行的过滤语法。
3. **执行查询 (`retriever.invoke`)** :最后,用自然语言发起调用。检索器内部会依次执行“构造”和“翻译”两个步骤,最终向 `Chroma` 发起一个同时包含语义搜索和精确元数据过滤的复合查询,从而返回最相关的结果。
> **提示**:在代码中可以看到 `temperature` 参数被设置为 `0`。这个值是用于控制模型输出的随机性。值越高(如 0.8),输出越随机、越有创意;值越低,输出越确定、越集中。设置为 `0` 可以让模型的输出变得完全确定,即对于相同的输入,总是生成完全相同的输出。在自查询这种需要精确地将自然语言转换为结构化查询的场景下,可以确保转换结果的稳定和可复现。
**输出结果:**
```bash
--- 查询: '时间最短的视频' ---
INFO:httpx:HTTP Request: POST https://api.deepseek.com/v1/chat/completions "HTTP/1.1 200 OK"
INFO:langchain.retrievers.self_query.base:Generated Query: query=' ' filter=None limit=1
标题: 《吴恩达 x OpenAI Prompt课程》【专业翻译,配套代码笔记】02.Prompt 的构建原则
作者: 二次元的Datawhale
观看次数: 18788
时长: 1063秒
==================================================
--- 查询: '时长大于600秒的视频' ---
INFO:httpx:HTTP Request: POST https://api.deepseek.com/v1/chat/completions "HTTP/1.1 200 OK"
INFO:langchain.retrievers.self_query.base:Generated Query: query=' ' filter=Comparison(comparator=<Comparator.GT: 'gt'>, attribute='length', value=600) limit=None
WARNING:chromadb.segment.impl.vector.local_hnsw:Number of requested results 4 is greater than number of elements in index 3, updating n_results = 3
标题: 《吴恩达 x OpenAI Prompt课程》【专业翻译,配套代码笔记】03.Prompt如何迭代优化
作者: 二次元的Datawhale
观看次数: 7090
时长: 806秒
==================================================
标题: 《吴恩达 x OpenAI Prompt课程》【专业翻译,配套代码笔记】02.Prompt 的构建原则
作者: 二次元的Datawhale
观看次数: 18788
时长: 1063秒
```
## 二、文本到Cypher
除了处理扁平化的元数据,查询构建技术还能应用于更复杂的数据结构,如图数据库。
### 2.1 什么是 Cypher
Cypher 是图数据库(如 Neo4j)中最常用的查询语言,其地位类似于 SQL 之于关系数据库。它采用一种直观的方式来匹配图中的模式和关系,例如 `(:Person {name:"Tomaz"})-[:LIVES_IN]->(:Country {name:"Slovenia"})` 描述了一个人和一个国家以及他们之间的“居住在”关系。
### 2.2 “文本到Cypher”的原理
与“文本到元数据过滤器”类似,“文本到Cypher”技术利用大语言模型(LLM)将用户的自然语言问题直接翻译成一句精准的 Cypher 查询语句。LangChain 提供了相应的工具链(如 `GraphCypherQAChain`),其工作流程通常是:
1. 接收用户的自然语言问题。
2. LLM 根据预先提供的图谱模式(Schema),将问题转换为 Cypher 查询。
3. 在图数据库上执行该查询,获取精确的结构化数据。
4. (可选)将查询结果再次交由 LLM,生成通顺的自然语言答案。
由于生成有效的 Cypher 查询是一项复杂的任务,通常使用性能较强的 LLM 来确保转换的准确性。通过这种方式,用户可以用最自然的方式与高度结构化的图数据进行交互,极大地降低了数据查询的门槛。
## 思考
- 为什么本节的代码中查询“时间最短的视频”时,得到的结果是错误的?
## 参考文献
[^1]: [*LangChain Blog: Query Construction*](https://blog.langchain.ac.cn/query-construction/)
+404
View File
@@ -0,0 +1,404 @@
# 第三节 文本到SQL
继上一节探讨了如何为元数据和图数据构建查询后,本节将聚焦于结构化数据领域中一个常见的应用。在数据世界中,除了向量数据库能够处理的非结构化数据,关系型数据库(如 MySQL, PostgreSQL, SQLite)同样是存储和管理结构化数据的重点。**文本到SQLText-to-SQL**[^1] 正是为了打破人与结构化数据之间的语言障碍而生。它利用大语言模型(LLM)将用户的自然语言问题,直接翻译成可以在数据库上执行的SQL查询语句。
![](./images/4_3_1.webp)
## 一、业务挑战
- **“幻觉”问题**:LLM 可能会“想象”出数据库中不存在的表或字段,导致生成的SQL语句无效。
- **对数据库结构理解不足**:LLM 需要准确理解表的结构、字段的含义以及表与表之间的关联关系,才能生成正确的 `JOIN``WHERE` 子句。
- **处理用户输入的模糊性**:用户的提问可能存在拼写错误或不规范的表达(例如,“上个月的销售冠军是谁?”),模型需要具备一定的容错和推理能力。
## 二、优化策略
1. **提供精确的数据库模式**:这是最基础也是最关键的一步。我们需要向LLM提供数据库中相关表的 `CREATE TABLE` 语句。这就像是给了LLM一张地图,让它了解数据库的结构,包括表名、列名、数据类型和外键关系。
2. **提供少量高质量的示例**:在提示(Prompt)中加入一些“问题-SQL”的示例对,可以极大地提升LLM生成查询的准确性。这相当于给了LLM几个范例,让它学习如何根据相似的问题构建查询。
3. **利用RAG增强上下文**:这是更进一步的策略。我们可以像RAGFlow一样,为数据库构建一个专门的“知识库”[^2],其中不仅包含表的DDL(数据定义语言),还可以包含:
* **表和字段的详细描述**:用自然语言解释每个表是做什么的,每个字段代表什么业务含义。
* **同义词和业务术语**:例如,将用户的“花费”映射到数据库的 `cost` 字段。
* **复杂的查询示例**:提供一些包含 `JOIN``GROUP BY` 或子查询的复杂问答对。
当用户提问时,系统首先从这个知识库中检索最相关的信息(如相关的表结构、字段描述、相似的Q&A),然后将这些信息和用户的问题一起组合成一个内容更丰富的提示,交给LLM生成最终的SQL查询。这种方式极大地降低了“幻觉”的风险,提高了查询的准确度。
4. **错误修正与反思 (Error Correction and Reflection)**:在生成SQL后,系统会尝试执行它。如果数据库返回错误,可以将错误信息反馈给LLM,让它“反思”并修正SQL语句,然后重试。这个迭代过程可以显著提高查询的成功率。
## 三、实现一个简单的Text2SQL框架
本节基于RAGFlow方案实现了一个简单的Text2SQL框架。该框架使用Milvus向量数据库作为知识库,BGE-M3模型进行语义检索,DeepSeek作为大语言模型,专门针对SQLite数据库进行了优化。
![Text2SQL框架工作流程](./images/4_3_2.webp)
### 3.1 知识库模块 (`knowledge_base.py`)
知识库模块是整个框架的核心,负责存储和检索SQL相关的知识信息。
```python
class SimpleKnowledgeBase:
"""知识库"""
def __init__(self, milvus_uri: str = "http://localhost:19530"):
self.milvus_uri = milvus_uri
self.client = MilvusClient(uri=milvus_uri)
self.embedding_function = BGEM3EmbeddingFunction(use_fp16=False, device="cpu")
self.collection_name = "text2sql_kb"
self._setup_collection()
```
**设计思想:**
1. **统一知识管理**:将DDL定义、Q-SQL示例和表描述三种类型的知识统一存储在一个Milvus集合中,通过 `type` 字段区分。
2. **语义检索能力**:使用BGE-M3模型进行向量化,支持中英文混合的语义相似度搜索。
```python
def _setup_collection(self):
"""设置集合"""
# 定义字段
fields = [
FieldSchema(name="pk", dtype=DataType.VARCHAR, is_primary=True, auto_id=True, max_length=100),
FieldSchema(name="content", dtype=DataType.VARCHAR, max_length=4096),
FieldSchema(name="type", dtype=DataType.VARCHAR, max_length=32), # ddl, qsql, description
FieldSchema(name="dense_vector", dtype=DataType.FLOAT_VECTOR, dim=self.embedding_function.dim["dense"])
]
```
**数据加载策略:**
```python
def load_data(self):
"""加载所有知识库数据"""
# 加载DDL数据 - 表结构定义
# 加载Q->SQL数据 - 问答示例
# 加载描述数据 - 表和字段的业务描述
```
框架支持三种类型的知识:
- **DDL知识**[^3]:表的结构定义,包括字段类型、约束等
- **Q-SQL知识**[^4]:历史问答对,为新问题提供参考模式
- **描述知识**[^5]:表和字段的业务含义,帮助理解数据语义
**检索机制:**
```python
def search(self, query: str, top_k: int = 5) -> List[Dict[str, Any]]:
"""搜索相关内容"""
query_embeddings = self.embedding_function([query])
search_results = self.client.search(
collection_name=self.collection_name,
data=query_embeddings["dense"],
anns_field="dense_vector",
search_params={"metric_type": "IP"}, # 内积相似度
limit=top_k,
output_fields=["content", "type"]
)
```
### 3.2 SQL生成模块 (`sql_generator.py`)
SQL生成模块负责将自然语言问题转换为SQL查询语句,并具备错误修复能力。
```python
class SimpleSQLGenerator:
"""简化的SQL生成器"""
def __init__(self, api_key: str = None):
self.llm = ChatDeepSeek(
model="deepseek-chat",
temperature=0, # 确保结果的确定性
api_key=api_key or os.getenv("DEEPSEEK_API_KEY")
)
```
**SQL生成策略:**
```python
def generate_sql(self, user_query: str, knowledge_results: List[Dict[str, Any]]) -> str:
"""生成SQL语句"""
# 构建上下文
context = self._build_context(knowledge_results)
# 构建提示
prompt = f"""你是一个SQL专家。请根据以下信息将用户问题转换为SQL查询语句。
数据库信息:
{context}
用户问题:{user_query}
要求:
1. 只返回SQL语句,不要包含任何解释
2. 确保SQL语法正确
3. 使用上下文中提供的表名和字段名
4. 如果需要JOIN,请根据表结构进行合理关联
SQL语句:"""
```
**关键设计原则:**
1. **上下文驱动**:通过知识库检索结果构建丰富的上下文信息
2. **结构化提示**:明确的任务要求和格式约束
3. **确定性输出**:设置temperature=0确保相同输入产生相同输出
**错误修复机制:**
```python
def fix_sql(self, original_sql: str, error_message: str, knowledge_results: List[Dict[str, Any]]) -> str:
"""修复SQL语句"""
context = self._build_context(knowledge_results)
prompt = f"""请修复以下SQL语句的错误。
数据库信息:
{context}
原始SQL
{original_sql}
错误信息:
{error_message}
请返回修复后的SQL语句(只返回SQL,不要解释):"""
```
**上下文构建策略:**
```python
def _build_context(self, knowledge_results: List[Dict[str, Any]]) -> str:
"""构建上下文信息"""
# 按类型分组
ddl_info = [] # 表结构信息
qsql_examples = [] # 查询示例
descriptions = [] # 表描述信息
# 分层次组织信息:结构 → 描述 → 示例
if ddl_info:
context += "=== 表结构信息 ===\n"
if descriptions:
context += "=== 表和字段描述 ===\n"
if qsql_examples:
context += "=== 查询示例 ===\n"
```
### 3.3 代理模块 (`text2sql_agent.py`)
代理模块是整个框架的控制中心,协调知识库检索、SQL生成和执行的完整流程。
```python
class SimpleText2SQLAgent:
"""Text2SQL代理"""
def __init__(self, milvus_uri: str = "http://localhost:19530", api_key: str = None):
self.knowledge_base = SimpleKnowledgeBase(milvus_uri)
self.sql_generator = SimpleSQLGenerator(api_key)
# 配置参数
self.max_retry_count = 3 # 最大重试次数
self.top_k_retrieval = 5 # 检索数量
self.max_result_rows = 100 # 结果行数限制
```
**主要查询流程:**
```python
def query(self, user_question: str) -> Dict[str, Any]:
"""执行Text2SQL查询"""
# 1. 从知识库检索相关信息
knowledge_results = self.knowledge_base.search(user_question, self.top_k_retrieval)
# 2. 生成SQL语句
sql = self.sql_generator.generate_sql(user_question, knowledge_results)
# 3. 执行SQL(带重试机制)
retry_count = 0
while retry_count < self.max_retry_count:
success, result = self._execute_sql(sql)
if success:
return {"success": True, "sql": sql, "results": result}
else:
# 尝试修复SQL
sql = self.sql_generator.fix_sql(sql, result, knowledge_results)
retry_count += 1
```
**安全执行策略:**
```python
def _execute_sql(self, sql: str) -> Tuple[bool, Any]:
"""执行SQL语句"""
# 添加LIMIT限制,防止大量数据返回
if sql.strip().upper().startswith('SELECT') and 'LIMIT' not in sql.upper():
sql = f"{sql.rstrip(';')} LIMIT {self.max_result_rows}"
# 结构化结果返回
if sql.strip().upper().startswith('SELECT'):
columns = [desc[0] for desc in cursor.description]
rows = cursor.fetchall()
results = []
for row in rows:
result_row = {}
for i, value in enumerate(row):
result_row[columns[i]] = value
results.append(result_row)
return True, {"columns": columns, "rows": results, "count": len(results)}
```
### 3.4 完整流程模拟
以查询"年龄大于30的用户有哪些"为例,演示框架三个核心模块的完整协作过程:
#### 3.4.1 模拟数据
假设数据库中的users表包含以下用户数据:
| ID | 姓名 | 邮箱 | 年龄 | 城市 |
|----|------|------|------|------|
| 1 | 张三 | zhangsan@email.com | 25 | 北京 |
| 2 | 李四 | lisi@email.com | 32 | 上海 |
| 3 | 王五 | wangwu@email.com | 28 | 广州 |
| 4 | 赵六 | zhaoliu@email.com | 35 | 深圳 |
| 5 | 陈七 | chenqi@email.com | 29 | 杭州 |
#### 3.4.2 Step 1: 知识库检索
**用户输入**:"年龄大于30的用户有哪些"
**检索过程**
1. BGE-M3模型将查询文本转换为768维向量
2. Milvus在知识库中进行语义相似度搜索
3. 返回最相关的5条知识,按相似度排序
**检索结果**
**DDL知识** (相似度: 0.85)
- 表名:users
- 结构:包含id、name、email、age、city字段
- 约束:id为主键,email唯一
**Q-SQL示例** (相似度: 0.82)
- 问题:"查询年龄超过25岁的用户"
- SQL`SELECT * FROM users WHERE age > 25`
> 这是检索到的相似示例,最终SQL会基于用户实际问题调整为age > 30
**表描述** (相似度: 0.78)
- age字段:用户年龄,整数类型
- name字段:用户姓名,文本类型
#### 3.4.3 Step 2: SQL生成
**上下文构建**
系统将检索到的知识整理成结构化的上下文信息:
**表结构信息**
- 表名:users
- DDL定义:完整的CREATE TABLE语句
- 字段约束:主键、唯一性等
**表和字段描述**
- age字段:用户年龄,INTEGER类型
- name字段:用户姓名,TEXT类型
**查询示例**
- 相似问题:查询年龄超过25岁的用户
- 参考SQL`SELECT * FROM users WHERE age > 25`
**SQL生成过程**
1. DeepSeek分析用户问题的意图:查询满足年龄条件的用户
2. 识别关键信息:年龄字段(age)、比较操作(大于)、阈值(**30**)
3. 参考示例模式:从`WHERE age > 25`学习到`WHERE age > 数值`的模式
4. 模式应用:将用户的实际数值30替换示例中的25
5. 生成目标SQL`SELECT * FROM users WHERE age > 30`
#### 3.4.4 Step 3: SQL执行与结果处理
**安全处理**
- 原始SQL`SELECT * FROM users WHERE age > 30`
- 自动添加限制:`SELECT * FROM users WHERE age > 30 LIMIT 100`
**数据库执行**
SQLite引擎逐行检查users表中的数据:
| 用户 | 年龄检查 | 结果 |
|------|----------|------|
| 张三 | 25 > 30? | ❌ 不符合 |
| 李四 | 32 > 30? | ✅ 符合 |
| 王五 | 28 > 30? | ❌ 不符合 |
| 赵六 | 35 > 30? | ✅ 符合 |
| 陈七 | 29 > 30? | ❌ 不符合 |
**结果处理**
- 筛选出2条符合条件的记录
- 转换为结构化JSON格式
- 包含字段名称和数据类型信息
**最终输出**
```json
{
"success": true,
"error": null,
"sql": "SELECT * FROM users WHERE age > 30 LIMIT 100",
"results": {
"columns": ["id", "name", "email", "age", "city"],
"rows": [
{"id": 2, "name": "李四", "email": "lisi@email.com", "age": 32, "city": "上海"},
{"id": 4, "name": "赵六", "email": "zhaoliu@email.com", "age": 35, "city": "深圳"}
],
"count": 2
},
"retry_count": 0
}
```
通过这个**语义理解 → 结构化查询 → 数据过滤 → 结果输出**的完整流程,框架成功将用户的自然语言问题转换为精确的数据库查询结果。
### 3.5 代码运行
如果你想测试这个Text2SQL框架,可以通过以下方式进行:
**快速体验**:运行演示程序
```bash
python code/C4/03_text2sql_demo.py
```
> 完整演示代码:[03_text2sql_demo.py](https://github.com/datawhalechina/all-in-rag/blob/main/code/C4/03_text2sql_demo.py)
**核心代码获取**:三个核心模块的完整实现
- `knowledge_base.py` - 知识库模块
- `sql_generator.py` - SQL生成模块
- `text2sql_agent.py` - 代理协调模块
> 源码地址:[code/C4/text2sql/](https://github.com/datawhalechina/all-in-rag/tree/main/code/C4/text2sql)
**数据资源**:框架使用的JSON知识数据
- `ddl_examples.json` - DDL结构示例
- `qsql_examples.json` - 问题-SQL对示例
- `db_descriptions.json` - 表和字段描述
> 数据文件:[code/C4/text2sql/data/](https://github.com/datawhalechina/all-in-rag/tree/main/code/C4/text2sql/data)
### 3.6 为什么不直接使用封装好的框架?
> 因为淋过雨,所以想为你撑把伞🤪
市面上确实有很多成熟的Text2SQL框架,但这些高度封装的工具往往存在**黑盒问题**——当查询结果不符合预期时,很难定位是检索环节、SQL生成环节还是执行环节出了问题。正如上一节LangChain示例中遇到的查询异常,我们很难深入到框架内部进行精确调试和优化。这一点在索引优化那节中也提到过。
## 参考文献
[^1]: [*LangChain Docs: Text to SQL*](https://python.langchain.com/docs/tutorials/sql_qa/)
[^2]: [*RAGFlow Blog: Implementing Text2SQL with RAGFlow*](https://ragflow.io/blog/implementing-text2sql-with-ragflow)
[^3]: DDLData Definition Language)是数据定义语言,用于定义数据库结构,如CREATE TABLE语句。
[^4]: Q-SQL示例是指"问题-SQL"对,即自然语言问题与对应SQL查询的配对示例,用于少样本学习。
[^5]: 表描述是对数据库表和字段的业务语义说明,帮助模型理解数据的实际含义和用途。
+315
View File
@@ -0,0 +1,315 @@
# 第四节 查询重构与分发
此前已经学习了如何从不同类型的数据源(如向量数据库、关系型数据库)中构建查询。然而,用户的原始问题往往不是最优的检索输入。它可能过于复杂、包含歧义,或者与文档的实际措辞存在偏差。为了解决这些问题,我们需要在检索之前对用户的查询进行“预处理”,这就是本节要探讨的**查询重构与分发**。
这个阶段主要包含两个关键技术:
1. **查询翻译(Query Translation**:将用户的原始问题转换成一个或多个更适合检索的形式。
2. **查询路由(Query Routing**:根据问题的性质,将其智能地分发到最合适的数据源或检索器。
本节将重点介绍几种主流的查询翻译技术,并简要讨论查询路由的概念。
## 一、查询翻译
查询翻译的目标是弥合用户自然语言提问与文档库中存储信息之间的“语义鸿沟”。通过重写、分解或扩展查询,我们可以显著提升检索的准确率。
### 1.1 提示工程
这是最直接的查询重构方法。通过精心设计的提示词(Prompt),可以引导 LLM 将用户的原始查询改写得更清晰、更具体,或者转换成一种更利于检索的叙述风格。
在第二节查询构建的代码示例中,我们发现 `SelfQueryRetriever` 无法正确处理“时间最短的视频”这类需要排序或进行比较的查询。
为了解决这个问题,可以采用一种更高级的提示工程技巧:**让 LLM 直接构建出查询指令**。
这种方法的思路是,要求 LLM 直接分析用户的意图,并生成一个结构化(例如 JSON 格式)的指令,告诉我们的代码应该如何操作。对于“时间最短的视频”这个问题,我们期望 LLM 能直接告诉我们:“请按‘时长’字段进行升序排序,并返回第一条结果”。
下面,来看看如何修改代码来实现这一思路。我们不再使用 `SelfQueryRetriever`,而是直接与 LLM 交互,并根据其返回的指令在代码中执行排序逻辑。
关键的修改主要有两部分:
(1)**设计一个新的提示词(Prompt),要求 LLM 输出 JSON 格式的排序指令。**
```python
# 使用大模型将自然语言转换为排序指令
prompt = f"""你是一个智能助手,请将用户的问题转换成一个用于排序视频的JSON指令。
你需要识别用户想要排序的字段和排序方向。
- 排序字段必须是 'view_count' (观看次数) 或 'length' (时长) 之一。
- 排序方向必须是 'asc' (升序) 或 'desc' (降序) 之一。
例如:
- '时间最短的视频''哪个视频时间最短' 应转换为 {{"sort_by": "length", "order": "asc"}}
- '播放量最高的视频''哪个视频最火' 应转换为 {{"sort_by": "view_count", "order": "desc"}}
请根据以下问题生成JSON指令:
原始问题: "{query}"
JSON指令:"""
```
(2)**在代码中调用 LLM,解析其返回的 JSON 指令,并执行相应的排序操作。**
```python
# ... (前略,初始化LLM客户端)
# 请求LLM生成指令,并指定返回JSON格式
response = client.chat.completions.create(
model="deepseek-chat",
messages=[
{"role": "user", "content": prompt}
],
temperature=0,
response_format={"type": "json_object"}
)
# 解析指令并执行排序
try:
import json
instruction_str = response.choices[0].message.content
instruction = json.loads(instruction_str)
print(f"--- 生成的排序指令: {instruction} ---")
sort_by = instruction.get('sort_by')
order = instruction.get('order')
if sort_by in ['length', 'view_count'] and order in ['asc', 'desc']:
# 在代码中执行排序
reverse_order = (order == 'desc')
sorted_docs = sorted(all_documents, key=lambda doc: doc.metadata.get(sort_by, 0), reverse=reverse_order)
# 获取排序后的第一个结果并打印
if sorted_docs:
doc = sorted_docs[0]
# ... (打印结果的代码)
except (json.JSONDecodeError, KeyError) as e:
print(f"解析或执行指令失败: {e}")
```
通过这种方式,成功地将 LLM 从一个简单的“文本改写员”提升为了一个能够理解复杂意图并生成可执行计划的“智能代理”,从而优雅地解决了“最值”查询的难题。
> [完整代码](https://github.com/datawhalechina/all-in-rag/tree/main/code/C4/04_text_to_metadata_filter_v2.py)
### 1.2 多查询分解 (Multi-query)
当用户提出一个复杂的问题时,直接用整个问题去检索可能效果不佳,因为它可能包含多个子主题或意图。分解技术的核心思想是将这个复杂问题拆分成多个更简单、更具体的子问题。然后,系统分别对每个子问题进行检索,最后将所有检索到的结果合并、去重,形成一个更全面的上下文,再交给 LLM 生成最终答案。
**示例**
- **原始问题**:“在《流浪地球》中,刘慈欣对人工智能和未来社会结构有何看法?”
- **分解后的子问题**
- “《流浪地球》中描述的人工智能技术有哪些?”
- “《流浪地球》中描绘的未来社会是怎样的?”
- “刘慈欣关于人工智能的观点是什么?”
LangChain 提供了 `MultiQueryRetriever` 来完成这一过程[^1]。它在内部利用 LLM 将原始问题从不同角度分解成多个子问题,然后并行为每个子问题检索相关文档。最后,它将所有检索到的文档合并并去重,形成一个更全面的上下文,再传递给语言模型生成最终答案。通过这种策略,极大地丰富了检索结果,在有些应用中可以有效提升后续生成环节的质量。
### 1.3 退步提示(Step-Back Prompting
退步提示是由 Google DeepMind 团队提出的一种旨在提升大语言模型推理能力的提示工程技巧[^2]。当面对一个细节繁多或过于具体的问题时,模型直接作答(即便是使用思维链)也容易出错。退步提示通过引导模型“退后一步”来解决这个问题。
其核心流程分为两步:
(1)**抽象化**:首先,引导 LLM 从用户的原始具体问题中,生成一个更高层次、更概括的“退步问题”(Step-back Question)。这个退步问题旨在探寻原始问题背后的通用原理或核心概念。
(2)**推理**:接着,系统会先获取“退步问题”的答案(例如,一个物理定律、一段历史背景等),然后将这个通用原理作为上下文,再结合原始的具体问题,进行推理并生成最终答案。
![“退步提示”与“思维链”对比图](./images/4_4_1.webp)
**示例**
- **原始问题**:“如果理想气体的温度增加2倍,体积增加8倍,其压力会如何变化?”
- **退步问题**:“这个问题背后的物理原理是什么?”
- **推理过程**:首先回答退步问题,得到“理想气体定律 PV=nRT”。然后基于这个定律,代入具体数值进行计算,最终得出压力变为原来的1/4。
通过先检索或生成高层知识,再进行具体推理,退步提示能够帮助模型构建一个更坚实的逻辑基础,从而提高在复杂问答场景下的准确性。
### 1.4 假设性文档嵌入 (HyDE)
假设性文档嵌入(Hypothetical Document Embeddings, HyDE)是一种无需微调即可显著提升向量检索质量的查询改写技术,由 Luyu Gao 等人在其论文中首次提出[^3]。其核心是解决一个普遍存在于检索任务中的难题:用户的查询(Query)通常简短、关键词有限,而数据库中存储的文档则内容详实、上下文丰富,两者在语义向量空间中可能存在“鸿沟”,导致直接用查询向量进行搜索效果不佳。Zilliz 的一篇技术博客[^4]也对该技术进行了深入浅出的解读。
![HyDE](./images/4_4_2.webp)
HyDE 通过一种巧妙的方式来“绕过”这个问题:它不直接使用用户的原始查询,而是先利用一个生成式大语言模型(LLM)来生成一个“假设性”的、能够完美回答该查询的文档。然后,HyDE 将这个内容详实的假设性文档进行向量化,用其生成的向量去数据库中寻找与之最相似的真实文档。HyDE 的工作流程可以分为三个步骤:
(1)**生成**:当接收到用户查询时,首先调用一个生成式 LLM(例如,GPT-3.5)。提示该模型根据查询生成一个详细的、可能是理想答案的文档。这个文档不必完全符合事实,但它必须在语义上与一个好的答案高度相关。
(2)**编码**:将上一步生成的假设性文档输入到一个对比编码器(如 Contriever)中,将其转换为一个高维向量嵌入。这个向量在语义上代表了一个“理想答案”的位置。
(3)**检索**:使用这个假设性文档的向量,在向量数据库中执行相似性搜索,找出与这个“理想答案”最接近的真实文档。这些被检索出的文档将作为最终的上下文信息。
通过这种方式,HyDE 将困难的“查询到文档”的匹配问题,转化为了一个相对容易的“文档到文档”的匹配问题,从而提升检索的准确率。
## 二、查询路由
**查询路由(Query Routing** 是用于优化复杂 RAG 系统的一项关键技术。当系统接入了多个不同的数据源或具备多种处理能力时,就需要一个“智能调度中心”来分析用户的查询,并动态选择最合适的处理路径。其本质是替代硬编码规则,通过语义理解将查询分发至最匹配的数据源、处理组件或提示模板,从而提升系统的效率与答案的准确性。
### 2.1 应用场景
查询路由的应用场景十分广泛。
1. **数据源路由**:这是最常见的场景。根据查询意图,将其路由到不同的知识库。例如:
* 查询“最新的 iPhone 有什么功能?” -> 路由到**产品文档向量数据库**。
* 查询“我上次订购了什么?” -> 路由到**用户历史SQL数据库**(执行Text-to-SQL)。
* 查询“A公司和B公司的投资关系是怎样的?” -> 路由到**企业知识图谱数据库**。
2. **组件路由**:根据问题的复杂性,将其分配给不同的处理组件,以平衡成本和效果。
* 简单FAQ → 直接进行向量检索,速度快、成本低。
* 复杂操作或需要与外部API交互 → 调用 Agent 来执行任务。
3. **提示模板路由**:为不同类型的任务动态选择最优的提示词模板,以优化生成效果。
* 数学问题 → 选用包含分步思考(Step-by-Step)逻辑的提示模板。
* 代码生成 → 选用专门为代码优化过的提示模板。
### 2.2 实现方法
实现查询路由主要有两种主流方法[^5]:
#### 2.2.1 基于LLM的意图识别
这是最灵活的方法。通过设计一个包含路由选项的提示词,让大语言模型(LLM)直接对用户的查询进行分类,并输出一个代表路由选择的标签。
![逻辑路由](./images/4_4_3.webp)
* **实现流程**
1. 定义清晰的路由选项(例如,数据源名称、功能分类)。
2. LLM 分析查询并输出决策标签。
3. 代码根据标签调用相应的检索器或工具。
该方法的核心在于构建一个“分类-分发”的流水线。这里以一个菜谱问答为例,系统需要根据用户提问的菜系(川菜、粤菜或其他)调用不同的专家模型。
> 接下来的代码示例广泛使用了 **LCEL**[^6],它是 LangChain 中用于构建链(Chain)的声明式方法。其核心是 `|` (管道)符号,可以将不同的组件(如提示、模型、解析器)串联起来,形成一个处理流水线。例如,`prompt | llm | parser` 就清晰地定义了一个“提示->模型->解析器”的调用顺序。这种方式不仅代码可读性强,而且 LangChain 会在底层自动进行并行、异步和流式等优化。
**第一步:定义分类器**
首先创建一个 `classifier_chain`,它的任务是读取用户问题,并利用 LLM 的理解能力给问题打上分类标签(例如 '川菜', '粤菜', '其他')。
```python
# 假设 llm 已经定义
classifier_prompt = ChatPromptTemplate.from_template(
"""根据用户问题中提到的菜品,将其分类为:['川菜', '粤菜', 或 '其他']。
不要解释你的理由,只返回一个单词的分类结果。
问题: {question}"""
)
classifier_chain = classifier_prompt | llm | StrOutputParser()
```
**第二步:定义路由分支**
接着,使用 `RunnableBranch` 来定义路由规则。它就像一个 `if-elif-else` 语句,根据输入的 `topic` 字段来选择执行哪一个处理链(`sichuan_chain`, `cantonese_chain``general_chain`)。
```python
# 假设 sichuan_chain, cantonese_chain, general_chain 已定义
router_branch = RunnableBranch(
(lambda x: "川菜" in x["topic"], sichuan_chain),
(lambda x: "粤菜" in x["topic"], cantonese_chain),
general_chain # 默认选项
)
```
**第三步:组合完整路由链**
最后,将分类器和路由分支组合起来。这个 `full_router_chain` 首先会并行执行两个操作:用 `classifier_chain` 为问题生成 `topic`,同时保留原始的 `question`。然后,它将这个包含 `topic``question` 的字典传递给 `router_branch`,由后者根据 `topic` 做出最终的路由决策。
```python
full_router_chain = {"topic": classifier_chain, "question": lambda x: x["question"]} | router_branch
# 调用示例
# result = full_router_chain.invoke({"question": "麻婆豆腐怎么做?"})
```
> [完整代码](https://github.com/datawhalechina/all-in-rag/blob/main/code/C4/05_llm_based_routing.py)
#### 2.2.2 嵌入相似性路由
这种方法不依赖 LLM 进行分类,延迟更低。它通过计算用户查询与预设的“路由示例语句”之间的向量嵌入相似度来做出决策。
![语义路由](./images/4_4_4.webp)
**第一步:定义路由描述并向量化**
为每个路由创建一个详细的文本描述,并使用嵌入模型将其转换为向量,供后续相似度计算使用。
```python
# 假设 embeddings 模型已经初始化
sichuan_route_prompt = "你是一位处理川菜的专家。用户的问题是关于麻辣、辛香、重口味的菜肴,例如水煮鱼、麻婆豆腐、鱼香肉丝、宫保鸡丁、花椒、海椒等。"
cantonese_route_prompt = "你是一位处理粤菜的专家。用户的问题是关于清淡、鲜美、原汁原味的菜肴,例如白切鸡、老火靓汤、虾饺、云吞面等。"
route_prompts = [sichuan_route_prompt, cantonese_route_prompt]
route_names = ["川菜", "粤菜"]
route_prompt_embeddings = embeddings.embed_documents(route_prompts)
```
**第二步:定义目标链**
创建路由最终要分发到的目标处理链,并用一个字典 `route_map` 将路由名称和链对应起来。
```python
# 假设 llm 已经定义
sichuan_chain = (
PromptTemplate.from_template("你是一位川菜大厨。请用正宗的川菜做法,回答关于「{query}」的问题。")
| llm
| StrOutputParser()
)
cantonese_chain = (
PromptTemplate.from_template("你是一位粤菜大厨。请用经典的粤菜做法,回答关于「{query}」的问题。")
| llm
| StrOutputParser()
)
route_map = { "川菜": sichuan_chain, "粤菜": cantonese_chain }
```
**第三步:定义路由函数**
定义一个 `route` 函数,接收用户问题,计算与各路由描述的相似度,选择最相似的路由并调用相应的处理链。
```python
def route(info):
# 1. 对用户查询进行嵌入
query_embedding = embeddings.embed_query(info["query"])
# 2. 计算与各路由提示的余弦相似度
similarity_scores = cosine_similarity([query_embedding], route_prompt_embeddings)[0]
# 3. 找到最相似的路由名称
chosen_route_index = np.argmax(similarity_scores)
chosen_route_name = route_names[chosen_route_index]
# 4. 获取并调用对应的处理链,返回结果
chosen_chain = route_map[chosen_route_name]
return chosen_chain.invoke(info)
```
**第四步:组合并调用**
最后,将 `route` 函数包装成一个 `RunnableLambda`,形成一个完整的、可执行的路由链。
```python
full_chain = RunnableLambda(route)
# 调用示例
# result = full_chain.invoke({"question": "如何做一碗清淡的云吞面?"})
```
> [完整代码](https://github.com/datawhalechina/all-in-rag/blob/main/code/C4/06_embedding_based_routing.py)
### 2.3 LlamaIndex 拓展
与 LangChain 类似,LlamaIndex 也提供了强大的查询路由功能[^7],其思路是将不同的数据源或查询策略包装为“工具(Tool)”,然后通过一个“路由器(Router)”来为用户查询动态选择最合适的工具。实现方式与 LangChain 有异曲同工之处:
* **基于LLM的意图识别**:这是 LlamaIndex 的主要实现方式。通过 `RouterQueryEngine` 来管理一组 `QueryEngineTool`。每个 `Tool` 都包含一个查询引擎和一段描述其功能的文本。路由器会利用一个 `Selector`(如 `LLMSingleSelector` 或更稳定的 `PydanticSingleSelector`)来让 LLM 根据工具的描述文本和用户问题进行语义匹配,从而选择一个或多个最合适的工具来执行。
* **嵌入相似性路由**LlamaIndex 没有提供直接基于向量相似度计算的独立路由组件。它的“语义路由”是融合在基于 LLM 的意图识别中的——即让 LLM 理解每个 `Tool` 描述的 *语义*,并据此做出决策。这种方式更灵活,能够处理更复杂的路由逻辑,而不仅仅是文本相似度匹配。
## 参考文献
[^1]: [*How to use the MultiQueryRetriever*](https://python.langchain.com/docs/how_to/MultiQueryRetriever/)
[^2]: [Zheng, H. S. et al. (2023). *Take a Step Back: Evoking Reasoning via Abstraction in Large Language Models*](https://arxiv.org/abs/2310.06117).
[^3]: [Gao, L. et al. (2022). *Precise Zero-Shot Dense Retrieval without Relevance Labels*](https://arxiv.org/abs/2212.10496).
[^4]: [*使用假设性文档嵌入(HyDE)改进信息检索和 RAG*](https://zilliz.com.cn/blog/improve-rag-and-information-retrieval-with-hyde-hypothetical-document-embeddings).
[^5]: [*How to route between sub-chains*](https://python.langchain.com/docs/how_to/routing/).
[^6]: [*LangChain Expression Language*](https://python.langchain.com/docs/concepts/lcel/).
[^7]: [*LlamaIndex Routing*](https://docs.llamaindex.ai/en/stable/module_guides/querying/router/).
@@ -0,0 +1,354 @@
# 第五节 检索进阶
在基础的 RAG 流程中,依赖向量相似度从知识库中检索信息。不过,这种方法存在一些固有的局限性,例如最相关的文档不总是在检索结果的顶端,以及语义理解的偏差等。为了构建更强大、更精准的生产级 RAG 应用,需要引入更高级的检索技术。
![retrieval](images/4_5_1.webp)
## 一、重排序 (Re-ranking)
### 1.1 RRF (Reciprocal Rank Fusion)
我们在 [**混合检索章节**](./11_hybrid_search.md) 中已经接触过 RRF。它是一种简单而有效的**零样本**重排方法,不依赖于任何模型训练,而是纯粹基于文档在多个不同检索器(例如,一个稀疏检索器和一个密集检索器)结果列表中的**排名**来计算最终分数。
一个文档如果在多个检索结果中都排名靠前,那么它很可能更重要。RRF 通过计算排名的倒数来为文档打分,有效融合了不同检索策略的优势。但是如果只考虑排名信息,会忽略原始的相似度分数,可能丢失部分有用信息。
### 1.2 RankLLM / LLM-based Reranker
![rankllm](images/4_5_2.webp)
RankLLM 代表了一类直接利用大型语言模型本身来进行重排的方法[^1]。其基本逻辑非常直观:既然 LLM 最终要负责根据上下文来生成答案,那么为什么不直接让它来判断哪些上下文最相关呢?
这种方法通过一个精心设计的提示词来实现。该提示词会包含用户的查询和一系列候选文档(通常是文档的摘要或关键部分),然后要求 LLM 以特定格式(如 JSON)输出一个排序后的文档列表,并给出每个文档的相关性分数。
一个提示词示例如下:
```text
以下是一个文档列表,每个文档都有一个编号和摘要。同时提供一个问题。请根据问题,按相关性顺序列出您认为需要查阅的文档编号,并给出相关性分数(1-10分)。请不要包含与问题无关的文档。
示例格式:
文档 1: <文档1的摘要>
文档 2: <文档2的摘要>
...
文档 10: <文档10的摘要>
问题: <用户的问题>
回答:
Doc: 9, Relevance: 7
Doc: 3, Relevance: 4
Doc: 7, Relevance: 3
```
### 1.3 Cross-Encoder 重排
Cross-Encoder(交叉编码器)能提供出色的重排精度[^2]。它的工作原理是将查询(Query)和每个候选文档(Document)**拼接**成一个单一的输入(例如,`[CLS] query [SEP] document [SEP]`),然后将这个整体输入到一个预训练的 Transformer 模型(如 BERT)中,模型最终会输出一个单一的分数(通常在 0 到 1 之间),这个分数直接代表了文档与查询的**相关性**。
> 注:**[SEP]** 是在 BERT 这类基于 Transformer 架构的模型中,用于分隔不同文本片段(如查询和文档)的特殊标记。
<div align="center">
<img src="./images/4_5_3.svg" alt="cross-encoder" width="600">
</div>
上图清晰地展示了 Cross-Encoder 的工作流程:
1. **初步检索**:搜索引擎首先从知识库中召回一个初始的文档列表(例如,前 50 篇)。
2. **逐一评分**:对于列表中的**每一篇**文档,系统都将其与原始查询**配对**,然后发送给 Cross-Encoder 模型。
3. **独立推理**:模型对每个“查询-文档”对进行一次完整的、独立的推理计算,得出一个精确的相关性分数。
4. **返回重排结果**:系统根据这些新的分数对文档列表进行重新排序,并将最终结果返回给用户。
这个流程凸显了其高精度的来源(同时分析查询和文档),也解释了其高延迟的原因(需要N次独立的模型推理)。
常见的 Cross-Encoder 模型包括 `ms-marco-MiniLM-L-12-v2``ms-marco-TinyBERT-L-2-v2` 等。
### 1.4 ColBERT 重排
ColBERTContextualized Late Interaction over BERT)是一种创新的重排模型,它在 Cross-Encoder 的高精度和双编码器(Bi-Encoder)的高效率之间取得了平衡[^3]。采用了一种“**后期交互**”机制。
其工作流程如下:
1. **独立编码**ColBERT 分别为查询(Query)和文档(Document)中的每个 Token 生成上下文相关的嵌入向量。这一步是独立完成的,可以预先计算并存储文档的向量,从而加快查询速度。
2. **后期交互**:在查询时,模型会计算查询中每个 Token 的向量与文档中每个 Token 向量之间的最大相似度(MaxSim)。
3. **分数聚合**:最后,将查询中所有 Token 得到的最大相似度分数相加,得到最终的相关性总分。
通过这种方式,ColBERT 避免了将查询和文档拼接在一起进行昂贵的联合编码,同时又比单纯比较单个 `[CLS]` 向量的双编码器模型捕捉了更细粒度的词汇级交互信息。
### 1.5 重排方法对比
为了更直观地理解不同重排方法的特点和适用场景,下表对讨论过的几种主流方法进行了总结:
| 特性 | RRF | RankLLM | Cross-Encoder | ColBERT |
| :--- | :--- | :--- | :--- | :--- |
| **核心机制** | 融合多个排名 | LLM 推理,生成排序列表 | 联合编码查询与文档,计算单一相关分 | 独立编码,后期交互 |
| **计算成本** | 低(简单数学计算) | 中 (API 费用与延迟) | 高(N次模型推理) | 中(向量点积计算) |
| **交互粒度** | 无(仅排名) | 概念/语义级 | 句子级(Query-Doc Pair | Token 级 |
| **适用场景** | 多路召回结果融合 | 高价值语义理解场景 | Top-K 精排 | Top-K 重排 |
## 二、压缩 (Compression)
“压缩”技术旨在解决一个常见问题:初步检索到的文档块(Chunks)虽然整体上与查询相关,但可能包含大量无关的“噪音”文本。将这些未经处理的、冗长的上下文直接提供给 LLM,不仅会增加 API 调用的成本和延迟,还可能因为信息过载而降低最终生成答案的质量。
压缩的目标就是对检索到的内容进行“压缩”和“提炼”,只保留与用户查询最直接相关的信息。这可以通过两种主要方式实现:
1. **内容提取**:从文档中只抽出与查询相关的句子或段落。
2. **文档过滤**:完全丢弃那些虽然被初步召回,但经过更精细判断后认为不相关的整个文档。
### 2.1 LangChain 的 ContextualCompressionRetriever
LangChain 提供了一个强大的组件 `ContextualCompressionRetriever` 来实现上下文压缩[^4]。它像一个包装器,包裹在基础的检索器(如 `FAISS.as_retriever()`)之上。当基础检索器返回文档后,`ContextualCompressionRetriever` 会使用一个指定的 `DocumentCompressor` 对这些文档进行处理,然后再返回给调用者。
LangChain 内置了多种 `DocumentCompressor`
* `LLMChainExtractor`: 这是最直接的压缩方式。它会遍历每个文档,并利用一个 LLM Chain 来判断并提取出其中与查询相关的部分。这是一种“内容提取”。
* `LLMChainFilter`: 这种压缩器同样使用 LLM,但它做的是“文档过滤”。它会判断整个文档是否与查询相关,如果相关,则保留整个文档;如果不相关,则直接丢弃。
* `EmbeddingsFilter`: 这是一种更快速、成本更低的过滤方法。它会计算查询和每个文档的嵌入向量之间的相似度,只保留那些相似度超过预设阈值的文档。
### 2.2 自定义重排器与压缩管道
在前面我们就提到根据实际应用,需要自己进行一些功能的实现。这里以 ColBERT 为例,展示如何集成未被官方支持的功能。
整个探索和实现过程如下:
1. **从官方文档出发**:首先,通过 LangChain 官方文档,了解到可以通过 `DocumentCompressorPipeline` 来组合多个压缩器和文档转换器。
2. **需求缺口**:希望使用 ColBERT 模型进行重排,但发现 LangChain 并没有内置的 `ColBERT` 重排器。
3. **分析示例与源码**:回头分析 `ContextualCompressionRetriever` 的用法和源码。我们发现,其处理逻辑分为两步:首先使用 `base_retriever` 获取原始文档,然后将这些文档交给 `base_compressor` 进行压缩或重排。这说明,实现自定义后处理(如重排)功能的关键在于 `base_compressor`
4. **定位核心基类**:通过f12查看源码,确定 `base_compressor` 参数接收的是 `BaseDocumentCompressor` 类型的对象。这就是实现自定义功能的核心切入点。
5. **参考与实现**:最后,参考 LangChain 中其他重排器的实现方式,通过继承 `BaseDocumentCompressor` 基类并实现其关键方法,创建自己的 `ColBERTReranker` 类。
> PS:如果代码基础薄弱,想借助大模型帮你完成 `ColBERTReranker` ,需要提供给大模型的关键信息:`BaseDocumentCompressor` 的源码和 `ContextualCompressionRetriever` 的源码及其使用示例、你的明确目标(实现 ColBERT 重排逻辑)、以及 LangChain 中其他重排器的代码作为参考。信息越充分,模型生成的代码越准确。
#### 代码示例
自定义 `ColBERTReranker` 的代码实现:
```python
class ColBERTReranker(BaseDocumentCompressor):
"""ColBERT重排器"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
model_name = "bert-base-uncased"
# 加载模型和分词器
object.__setattr__(self, 'tokenizer', AutoTokenizer.from_pretrained(model_name))
object.__setattr__(self, 'model', AutoModel.from_pretrained(model_name))
self.model.eval()
print(f"ColBERT模型加载完成")
def encode_text(self, texts):
"""ColBERT文本编码"""
inputs = self.tokenizer(
texts,
return_tensors="pt",
padding=True,
truncation=True,
max_length=128
)
with torch.no_grad():
outputs = self.model(**inputs)
embeddings = outputs.last_hidden_state
embeddings = F.normalize(embeddings, p=2, dim=-1)
return embeddings
def calculate_colbert_similarity(self, query_emb, doc_embs, query_mask, doc_masks):
"""ColBERT相似度计算(MaxSim操作)"""
scores = []
for i, doc_emb in enumerate(doc_embs):
doc_mask = doc_masks[i:i+1]
# 计算相似度矩阵
similarity_matrix = torch.matmul(query_emb, doc_emb.unsqueeze(0).transpose(-2, -1))
# 应用文档mask
doc_mask_expanded = doc_mask.unsqueeze(1)
similarity_matrix = similarity_matrix.masked_fill(~doc_mask_expanded.bool(), -1e9)
# MaxSim操作
max_sim_per_query_token = similarity_matrix.max(dim=-1)[0]
# 应用查询mask
query_mask_expanded = query_mask.unsqueeze(0)
max_sim_per_query_token = max_sim_per_query_token.masked_fill(~query_mask_expanded.bool(), 0)
# 求和得到最终分数
colbert_score = max_sim_per_query_token.sum(dim=-1).item()
scores.append(colbert_score)
return scores
def compress_documents(
self,
documents: Sequence[Document],
query: str,
callbacks=None,
) -> Sequence[Document]:
"""对文档进行ColBERT重排序"""
if len(documents) == 0:
return documents
# 编码查询
query_inputs = self.tokenizer(
[query],
return_tensors="pt",
padding=True,
truncation=True,
max_length=128
)
with torch.no_grad():
query_outputs = self.model(**query_inputs)
query_embeddings = F.normalize(query_outputs.last_hidden_state, p=2, dim=-1)
# 编码文档
doc_texts = [doc.page_content for doc in documents]
doc_inputs = self.tokenizer(
doc_texts,
return_tensors="pt",
padding=True,
truncation=True,
max_length=128
)
with torch.no_grad():
doc_outputs = self.model(**doc_inputs)
doc_embeddings = F.normalize(doc_outputs.last_hidden_state, p=2, dim=-1)
# 计算ColBERT相似度
scores = self.calculate_colbert_similarity(
query_embeddings,
doc_embeddings,
query_inputs['attention_mask'],
doc_inputs['attention_mask']
)
# 排序并返回前5个
scored_docs = list(zip(documents, scores))
scored_docs.sort(key=lambda x: x[1], reverse=True)
reranked_docs = [doc for doc, _ in scored_docs[:5]]
return reranked_docs
```
1. **继承与实现**`ColBERTReranker` 类继承自 `BaseDocumentCompressor`,并实现了其核心的抽象方法 `compress_documents`。这个方法接收基础检索器返回的文档列表 `documents` 和原始查询 `query` 作为输入。
2. **实现ColBERT逻辑**`compress_documents` 方法的内部逻辑遵循了在 “1.4 ColBERT 重排” 中描述的“后期交互”原理。
* **独立编码**:在 `_colbert_score` 辅助函数中,查询和文档分别被独立编码,通过 `self.model` 得到各自所有 Token 的嵌入向量(`query_embeddings``doc_embeddings`)。
* **后期交互**:代码 `similarity_matrix.max(dim=1).values` 实现了最大相似度(MaxSim)计算。为查询中的每一个 Token 向量,都从文档的所有 Token 向量中寻找一个最相似的,并记录下这个最大相似度值。
* **分数聚合**:最后的 `.sum()` 操作将查询中所有 Token 算出的最大相似度值相加,得到该文档与查询的最终相关性总分。
3. **排序与返回**`compress_documents` 方法遍历所有文档、计算出各自的分数后,根据分数从高到低对文档进行重新排序,并返回排序后的文档列表。
接下来,将这个自定义的 `ColBERTReranker` 与 LangChain 的其他组件(如 `LLMChainExtractor`)组合成一个强大的“重排+压缩”管道,并应用在实际的检索任务中。
```python
# 初始化配置...(略)
# 1. 加载和处理文档
loader = TextLoader("../../data/C4/txt/ai.txt", encoding="utf-8")
documents = loader.load()
text_splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=100)
docs = text_splitter.split_documents(documents)
# 2. 创建向量存储和基础检索器
vectorstore = FAISS.from_documents(docs, hf_bge_embeddings)
base_retriever = vectorstore.as_retriever(search_kwargs={"k": 20})
# 3. 设置ColBERT重排序器
reranker = ColBERTReranker()
# 4. 设置LLM压缩器
compressor = LLMChainExtractor.from_llm(llm)
# 5. 使用DocumentCompressorPipeline组装压缩管道
# 流程: ColBERT重排 -> LLM压缩
pipeline_compressor = DocumentCompressorPipeline(
transformers=[reranker, compressor]
)
# 6. 创建最终的压缩检索器
final_retriever = ContextualCompressionRetriever(
base_compressor=pipeline_compressor,
base_retriever=base_retriever
)
# 7. 执行查询并展示结果
query = "AI还有哪些缺陷需要克服?"
print(f"\n{'='*20} 开始执行查询 {'='*20}")
print(f"查询: {query}\n")
# 7.1 基础检索结果
print(f"--- (1) 基础检索结果 (Top 20) ---")
base_results = base_retriever.get_relevant_documents(query)
for i, doc in enumerate(base_results):
print(f" [{i+1}] {doc.page_content[:100]}...\n")
# 7.2 使用管道压缩器的最终结果
print(f"\n--- (2) 管道压缩后结果 (ColBERT重排 + LLM压缩) ---")
final_results = final_retriever.get_relevant_documents(query)
for i, doc in enumerate(final_results):
print(f" [{i+1}] {doc.page_content}\n")
```
这段代码展示了如何将各个组件串联起来,形成一个完整的检索流程:
1. **创建基础组件**:首先创建一个标准的 `FAISS` 向量存储和一个基础检索器 `base_retriever`,负责从向量库中初步召回20个可能相关的文档。
2. **准备处理单元**:实例化两个关键的处理单元:
* `reranker`: 自定义的 `ColBERTReranker` 实例。
* `compressor`: LangChain 内置的 `LLMChainExtractor`,用于从文档中提取与查询相关的句子。
3. **构建处理管道 (`DocumentCompressorPipeline`)**:这是整个流程的核心。创建一个 `DocumentCompressorPipeline` 实例,并将 `reranker``compressor` 按顺序放入 `transformers` 列表中。根据 `DocumentCompressorPipeline` 的源码,它会依次调用列表中的每个处理器。因此,文档会先经过 `ColBERTReranker` 重排,重排后的结果再被送入 `LLMChainExtractor` 进行压缩。
4. **组装最终检索器**:最后,用 `ContextualCompressionRetriever``base_retriever` 和我们创建的 `pipeline_compressor` 包装在一起。当调用 `final_retriever` 时,它会自动执行“基础检索 -> 管道处理(重排 -> 压缩)”的完整流程。
> [完整代码](https://github.com/datawhalechina/all-in-rag/blob/main/code/C4/07_rerank_and_refine.py)
### 2.3 LlamaIndex 中的检索压缩
LlamaIndex 同样提供了封装好的压缩功能,其代表是 `SentenceEmbeddingOptimizer`[^5]。它也是一个后处理器(Node Postprocessor),工作在检索之后。
它的工作原理是,对于每个检索到的文档,将其分解成句子。然后计算每个句子与用户查询的嵌入相似度,最后只保留那些相似度最高的句子,从而“优化”文档,去除无关信息。
## 三、校正 (Correcting)
传统的 RAG 流程有一个隐含的假设:检索到的文档总是与问题相关且包含正确答案。然而在现实世界中,检索系统可能会失败,返回不相关、过时或甚至完全错误的文档。如果将这些“有毒”的上下文直接喂给 LLM,就可能导致幻觉(Hallucination)或产生错误的回答。
**校正检索(Corrective-RAG, C-RAG** 正是为解决这一问题而提出的一种策略[^6]。思路是引入一个“自我反思”或“自我修正”的循环,在生成答案之前,对检索到的文档质量进行评估,并根据评估结果采取不同的行动。
C-RAG 的工作流程可以概括为 **“检索-评估-行动”** 三个阶段:
![C-RAG](images/4_5_4.webp)
1. **检索 (Retrieve)** :与标准 RAG 一样,首先根据用户查询从知识库中检索一组文档。
2. **评估 (Assess)** :这是 C-RAG 的关键步骤。如图所示,一个“检索评估器 (Retrieval Evaluator)”会判断每个文档与查询的相关性,并给出“正确 (Correct)”、“不正确 (Incorrect)”或“模糊 (Ambiguous)”的标签。
3. **行动 (Act)** :根据评估结果,系统会进入不同的知识修正与获取流程:
* **如果评估为“正确”**:系统会进入“知识精炼 (Knowledge Refinement)”环节。如图,它会将原始文档分解成更小的知识片段 (strips),过滤掉无关部分,然后重新组合成更精准、更聚焦的上下文,再送给大模型生成答案。
* **如果评估为“不正确”**:系统认为内部知识库无法回答问题,此时会触发“知识搜索 (Knowledge Searching)”。它会先对原始查询进行“查询重写 (Query Rewriting)”,生成一个更适合搜索引擎的查询,然后进行 Web 搜索,用外部信息来回答问题。
* **如果评估为“模糊”**:同样会触发“知识搜索”,但通常会直接使用原始查询进行 Web 搜索,以获取额外信息来辅助生成答案。
通过这种方式,C-RAG 极大地增强了 RAG 系统的鲁棒性。不再盲目信任检索结果,而是增加了一个“事实核查”层,能够在检索失败时主动寻求外部帮助,从而有效减少幻觉,提升答案的准确性和可靠性。
在 LangChain 的 `langgraph` 库中,可以利用其图结构来灵活地构建这种带有条件判断和循环的复杂 RAG 流程[^7]。
## 练习
- 本节“自定义重排器与压缩管道”部分的代码运行后的输出会出现重复的情况,思考为什么会出现这个问题并尝试修改代码解决。([参考代码](https://github.com/datawhalechina/all-in-rag/blob/main/code/C4/work_rerank_and_refine.py)
## 参考文献
[^1]: [*Using LLMs for Retrieval and Reranking*](https://www.llamaindex.ai/blog/using-llms-for-retrieval-and-reranking-23cf2d3a14b6).
[^2]: [Nogueira, R., & Cho, K. (2019). *Passage Re-ranking with BERT*](https://arxiv.org/abs/1901.04085).
[^3]: [*Advanced RAG: ColBERT Reranker*](https://www.pondhouse-data.com/blog/advanced-rag-colbert-reranker).
[^4]: [*How to do retrieval with contextual compression*](https://python.langchain.com/docs/how_to/contextual_compression/).
[^5]: [*Sentence Embedding Optimizer*](https://docs.llamaindex.ai/en/stable/examples/node_postprocessor/OptimizerDemo/).
[^6]: [Jiang, Z. et al. (2024). *Corrective Retrieval Augmented Generation*](https://arxiv.org/pdf/2401.15884.pdf).
[^7]: [*Corrective-RAG (CRAG)*](https://langchain-ai.github.io/langgraph/tutorials/rag/langgraph_crag/).
Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB