在学习了检索前处理和索引优化技术之后,Alex、Anna 和 Lewis 讨论了检索后处理技术,但 Anna 仍然有一些疑问。
图 7.1:Lewis 和 Anna 正在深入讨论检索前处理技术。
Anna:能不能讲讲检索前处理、索引优化和检索后处理技术之间的关键区别?
Lewis:检索前处理和索引优化技术,重点在于提升检索过程的效率;而检索后处理技术,则旨在提升检索结果的质量。由于初始检索结果往往并不理想,因此需要进一步优化和处理,以确保生成模型能够产出高质量答案。
检索后处理会采用一系列步骤,把最相关的文本块放到结果列表前面,同时减少冗余信息并提升内容质量。这会增强整个系统的表现,使生成内容更加准确,并且与上下文更加紧密相关。
图 7.2:搜索后处理示意图。
常见检索后处理技术
常见的检索后处理技术包括以下几类:
Re-ranking,重排序:在初始检索结果中,可能存在大量与 query 相关程度不同的文本块。通过评估这些结果,可以将最相关的内容放到前面。需要注意的是,重排序会增加额外计算资源需求。
Compression,压缩:检索到的文本块可能又长又复杂。对其进行压缩,可以降低生成器的计算负担,并加快生成过程。
Correction,校正:在生成之后检查和修正结果,以确保输出的准确性和连贯性。这在高风险或高标准领域,例如医疗和法律中尤其重要。校正过程通常也会增加系统复杂度和资源需求。
这些技术可以由开发者手动集成进 RAG 工作流中。LangChain 中的一些 retrievers 也内置了某些检索后处理技术。在 LlamaIndex 中,检索后处理通常通过 node post-processors 来完成。
Reranking
当学习者提出问题时,即使正确答案存在于某个文本块中,如果该文本块在检索结果中的排名不够靠前,并且没有被传给大模型用于生成回答,例如系统只考虑 Top 3 文本块,而包含正确答案的文本块排在第 4 位,那么系统就无法提供正确答案。
reranking 的目的,是提升初始检索候选文本块的排序质量。通过应用更精细的评分机制,reranking 可以确保最相关的文档排在最前面。reranking 有多种实现方式,我们将按照从简单到复杂的顺序依次说明。
RRF 重排序
在 hybrid retrieval 中,你可以从多个检索路径,或者多个 retrievers 中获得多样化检索结果。这些路径可以是关键词搜索和向量搜索的组合,也可以是向量存储和结构化数据库存储的组合,或者是不同检索策略的组合,例如不同 chunking 策略和 indexing 策略。
hybrid retrieval 的核心思想是优势互补。例如,关键词检索擅长精确匹配,而语义检索擅长捕捉语义相关性。将这两种检索方法的结果结合起来,可以同时提升召回率和准确率。不过,这也会带来一些新问题:
结果冗余:不同检索方法可能返回相同或相似的文本块,导致结果重复。
排序不一致:不同检索方法使用不同评分标准,例如 BM25 分数基于关键词匹配,而语义检索使用余弦相似度。直接合并这些结果,未必能得到最优结果。
效率问题:hybrid retrieval 可能产生大量候选文本块,如何筛选出最相关文档成为一个重要挑战。
为了解决上述问题,最常见的方案,是对初始检索得到的候选文本块应用 Reciprocal Rank Fusion,RRF 重排序。它可以减少结果冗余,并统一不同检索方法的评分标准。
图 7.3:流程图展示问题被拆分,并通过文档块处理后生成答案。
RRF 会合并多个 retrievers 的结果列表,并为每个结果分配一个融合分数。如果某个文本块在多个结果列表中排名都很靠前,它就会拥有更高的 RRF 总分。这种方法体现了 ensemble learning 的思想。
RRF 算法的关键重排序公式如下:
图 7.4:RRF 分数公式,即在 N 个结果中对 1 / rank + k 求和。
其中,d 表示某个具体文本块,ScoreRRF(d) 是该文本块的融合分数;N 是 rankers 的数量,也就是输入检索结果列表的数量;ranki(d) 是文本块 d 在第 i 个 ranker 中的排名,从 1 开始;k 是 smoothing parameter,用来控制排名对分数的影响,通常设置为一个常数,例如 60。
这样,每个文本块的最终分数,就是所有 rankers 为它贡献的分数累加。排名越高,也就是数字越小的文本块,会获得更高贡献分数。如果某个文本块在某些 rankers 中排名较高,但没有出现在其他 rankers 中,这种方法会调整其排名,以平衡所有 rankers 的权重。因此,即使一个文本块在某个 ranker 中表现不好,RRF 也可以通过它在其他 rankers 中的表现修正结果,使该方法具有鲁棒性。
应该如何理解 smoothing parameter?
Alex:应该如何理解 smoothing parameter?
Lewis:smoothing parameter 主要用于解决高排名文本块对最终分数影响过大的问题。通过增大 smoothing parameter k,可以进一步削弱那些排名特别靠前文本块的影响。换句话说,更大的 k 值会稀释这些 top documents 的影响,从而增强整体排序的平滑性。这会让 RRF 更倾向于融合不同 rankers 提供的“长尾贡献”,而不是过度偏向某一个 ranker 中排名特别高的结果。
下面是一个简单直观的例子:
图 7.5:图示解释使用两个 rankers 进行 rank fusion scoring,并展示公式和结果。
从这个例子可以看到,因为我们设置了一个较大的 k 值,融合排序后的分数差异非常接近。注意,在重排序之前,分数越低,也就是检索序列中位置越靠前,表示文本块越重要;而在重排序之后,分数越高表示文本块越重要。
Alex:看起来很不错。RRF 不依赖复杂的超参数调优;它的核心逻辑只是对文本块排名做简单平滑计算,可以融合任何格式的排序列表。
Lewis:确实如此。不过,真正有价值的重排序仍然依赖原始 retrievers 的效果。如果原始 retrievers 的结果质量不高,即便经过重排序,结果仍然可能不理想。
下面的代码示例展示了如何实现 RRF:
def reciprocal_rank_fusion(results: list[list], k=60):
"""
RRF 算法用于合并多个 rankers 的检索结果。
参数:
- results: 包含多个检索结果列表的列表,每个子列表代表一个 ranker 的输出。
子列表中的每个元素代表一个文档,按检索分数从高到低排序。
- k: RRF 公式中的参数,用于控制文档排名对融合分数的影响。默认值为 60。
返回:
- reranked_results: 经过 RRF 算法重排序后的文档列表,按融合分数从高到低排序。
"""
## 初始化一个字典,用来存储每个文档的融合分数
fused_scores = {}
## 遍历每个 ranker 的检索结果列表,也就是 results 中的每个子列表
for docs in results:
## 遍历每个文档及其在结果列表中的排名
for rank, doc in enumerate(docs):
## 将文档序列化为字符串,以便作为字典 key 使用
## 使用 dumps(doc) 将文档转换成字符串,便于存入字典
doc_str = dumps(doc)
## 如果该文档还没有出现在字典中,则将其分数初始化为 0
if doc_str not in fused_scores:
fused_scores[doc_str] = 0
## 根据 RRF 公式计算当前文档的分数
## rank 是文档位置,从 0 开始;k 是用于按排名平滑的常数参数
fused_scores[doc_str] += 1 / (rank + k)
## 按融合分数降序排列字典中的文档,分数高的排在前面
reranked_results = [
## loads(doc) 将序列化后的文档字符串还原为原始文档格式,并附带对应融合分数
(loads(doc), score)
## sorted 函数按分数对字典中的文档排序;reverse=True 表示降序
for doc, score in sorted(fused_scores.items(),
key=lambda x: x[1], reverse=True)
]
## 返回重排序后的文档列表,其中每个元素是一个元组:(文本块, 融合分数)
return reranked_results
reciprocal_rank_fusion 函数接收多个检索结果列表组成的列表,以及一个可选参数 k,默认值为 60。它会为每个 retriever 返回的每个文本块计算融合分数,根据这些分数重新排序,并最终返回一个按融合分数降序排列的新列表。每个元素都是一个元组,包含文本块及其对应的融合分数。
下面的代码示例演示如何使用 LangChain,结合 HuggingFace embeddings 和 Chroma vector database,实现 RRF 重排序。在这个例子中,“山西文旅”目录包含了大量关于山西旅游的信息。完整代码可参考 github.com/PacktPublis…
import os
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_community.document_loaders import PyPDFLoader, TextLoader
from langchain_huggingface import HuggingFaceEmbeddings
from langchain_community.vectorstores import Chroma
from langchain.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_deepseek import ChatDeepSeek
from langchain.load import dumps, loads
doc_dir = "./data/山西文旅"
def load_documents(directory):
"""读取目录中的所有文档,包括 PDF、TXT、DOCX"""
documents = []
for filename in os.listdir(directory):
filepath = os.path.join(directory, filename)
if filename.endswith(".pdf"):
loader = PyPDFLoader(filepath)
elif filename.endswith(".txt"):
loader = TextLoader(filepath)
else:
continue # 跳过不支持的文件类型
documents.extend(loader.load())
return documents
docs = load_documents(doc_dir)
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=300,
chunk_overlap=50
)
splits = text_splitter.split_documents(docs)
embed_model = HuggingFaceEmbeddings(model_name="all-MiniLM-L6-v2")
vectorstore = Chroma.from_documents(documents=splits, embedding=embed_model)
retriever = vectorstore.as_retriever()
def reciprocal_rank_fusion(results: list[list], k=60):
fused_scores = {}
for docs in results:
for rank, doc in enumerate(docs):
doc_str = dumps(doc)
if doc_str not in fused_scores:
fused_scores[doc_str] = 0
fused_scores[doc_str] += 1 / (rank + k)
reranked_results = [
(loads(doc), score)
for doc, score in sorted(fused_scores.items(), key=lambda x: x[1], reverse=True)
]
return reranked_results
生成多个搜索查询:
template = """You are an assistant that helps users generate multiple search queries.\n
Please generate several related search queries based on the following question: {question} \n
Output (4 queries):"""
prompt_rag_fusion = ChatPromptTemplate.from_template(template)
llm = ChatDeepSeek(model="deepseek-chat")
generate_queries = (
prompt_rag_fusion
| llm
| StrOutputParser()
| (lambda x: x.split("\n"))
)
示例问题:
questions = [
"What are some famous tourist attractions in Shanxi?",
"What is the historical background of the Yungang Grottoes?",
"What is the cultural and religious significance of Mount Wutai?"
]
使用 RRF 进行检索和处理:
for question in questions:
retrieval_chain_rag_fusion = generate_queries | retriever.map() | reciprocal_rank_fusion
docs = retrieval_chain_rag_fusion.invoke({"question": question})
print(f"\n[Question] {question}")
print(f"Number of documents: {len(docs)}")
for doc, score in docs[:3]: # 展示前 3 个结果
print(f"Document content: {doc.page_content[:200]}...") # 只展示前 200 个字符
输出示例:
Historical background of the Yungang Grottoes:
Number of documents: 6.
Document 1: Yungang Grottoes.
The Yungang Grottoes are located on the southern foot of Wuzhou Mountain, 17 kilometers west of Datong City, Shanxi Province, northern China. The grottoes are carved into the mountain.
Document 2: Yungang Wuhua Caves.
Located in the central part of the Yungang Grottoes, Caves 9 to 13 are named for their richly adorned appearance after being coated and painted during the Qing Dynasty to depict scenes from the Yungang Grottoes. The decorative carvings are splendid.
Cross-encoder 重排序
RRF 重排序是一种类似 ensemble learning 的方法;它不涉及检索结果和 query 之间的语义关系。相比之下,下面介绍的 Cross-Encoder re-ranking 是基于 Cross-Encoder 模型,在语义层面执行重排序。
Cross-Encoder 的思想源自 Google 在 2018 年发布的 BERT。作为一种双向 Transformer 模型,BERT 的目标是从大量未标注文本数据中学习通用语言表示,并不是专门为 query 和 document 之间的语义匹配或排序任务设计的。2019 年,Nogueira 等人在论文 Passage Re-ranking with BERT 中提出了使用预训练语言模型 BERT 进行 passage re-ranking 的概念。
Cross-Encoder 会直接将 query 和检索到的候选文本块拼接起来,中间用特殊分隔符 [SEP] 分隔,然后输入到预训练语言模型中,例如 BERT、RoBERTa 或其他 Transformer models。通过 Transformer 的 self-attention 机制,query 可以与文本块中的每个 token 充分交互,模型也能理解它们之间的语义关联。最后,将 CLS token 的输出送入分类层,直接输出相关性分数,从而实现对文本块的高精度排序。
图 7.6:流程图展示一个问题和候选文本块如何经过 Transformer 得到相关性分数。
在 RAG 系统中,Cross-Encoder 通常被配置为初始排序之后的细粒度重排序模块。首先使用 Bi-Encoder 进行快速初始检索,然后使用 Cross-Encoder 对初步选出的文本块集合,例如 Top-100,进行精细重排序,最终返回最相关文本块,例如 Top-10。
Lewis 的提示
这里提到的 Bi-Encoder retrieval,实际上指的是一种常见的基于稠密向量的检索方法。
在各种 embedding models 出现之前,研究人员通常使用两个相同或不同的神经网络,通常是 Transformer 或 LSTM 模型,分别独立地将 query 和 documents 编码成固定长度的 embedding vectors。
然后,再使用 cosine similarity 或 dot product 等相似度匹配方法,通过这些 embedding vectors 检索相关 chunks。
Alex:这不就是 RAG 的检索过程吗?只不过是用传统 BERT 模型替代现代 embedding model 来生成向量。
Lewis:完全正确。
下面的代码示例演示了如何实现 Cross-Encoder 重排序。完整代码可参考 github.com/PacktPublis…
from transformers import AutoTokenizer, AutoModelForSequenceClassification
import torch
### 加载用于句子对相关性计算的预训练 BERT 模型
model_name = "cross-encoder/ms-marco-MiniLM-L-12-v2" # 适合检索任务
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForSequenceClassification.from_pretrained(model_name)
### 查询与山西文旅相关的文档
query = "What are some famous tourist attractions in Shanxi?"
documents = [
"Mount Wutai is one of the four great Buddhist mountains in China, renowned as the bodhimanda of Manjushri.",
"Yungang Grottoes is one of China’s three largest grottoes, famous for its exquisite Buddhist sculptures.",
"Pingyao Ancient City is one of the best-preserved ancient county towns in China, listed as a World Cultural Heritage site."
]
### 计算相关性分数
def encode_and_score(query, docs):
scores = []
for doc in docs:
inputs = tokenizer(query, doc, return_tensors="pt", truncation=True, max_length=512, padding="max_length")
with torch.no_grad():
outputs = model(**inputs)
score = outputs.logits[0][0].item()
scores.append(score)
return scores
### 获取排序结果
scores = encode_and_score(query, documents)
ranked_docs = sorted(zip(documents, scores), key=lambda x: x[1], reverse=True)
### 输出结果
print("Query:", query)
print("\nRanking results:")
for rank, (doc, score) in enumerate(ranked_docs, start=1):
print(f"{rank}. Relevance Score: {score:.4f} | Document: {doc}")
输出如下:
Query: What are some famous tourist attractions in Shanxi?
Sorting results:
Relevance score: 7.1072 | Document: Pingyao Ancient City is one of the best-preserved ancient county towns in China and has been listed as a World Cultural Heritage Site.
Relevance score: 7.0976 | Document: Mount Wutai is one of the Four Sacred Buddhist Mountains in China and is renowned as the bodhimanda of Manjushri Bodhisattva.
Relevance score: 5.8538 | Document: Yungang Grottoes is one of the three major grottoes in China and is famous for its exquisite Buddhist sculptures.
对于需要精准排序的应用场景,例如法律、医疗或金融领域的问答系统,使用 Cross-Encoder 进行 reranking 是非常合适的选择。此外,它支持针对不同任务进行微调,可以根据具体应用场景进行训练,以提升 reranking 表现。Cross-Encoder 的缺点是计算成本较高。因此,在传统文档检索系统中,它通常用于 reranking 阶段,而不是初始检索阶段。当初始检索阶段能够有效缩小候选文本块范围时,Cross-Encoder 的计算成本就是可接受的。
ColBERT 重排序
Contextualized Late Interaction over BERT,也就是 ColBERT,是斯坦福大学在 2020 年 SIGIR 会议上提出的一种稠密向量检索技术。该技术的创新点在于引入了 late interaction 的概念。
与 Cross-Encoder 直接让 query 和 documents 完全交互不同,ColBERT 会先分别编码 query 和 text chunk,获得各自表示,然后只在最后一层执行 token-level interaction。在这一阶段,query 和 text chunk 中的每个 token vector 会通过 dot product 逐一交互,再将结果聚合生成相关性分数。
图 7.7:流程图展示一个基于 BERT 的 token similarity 和 relevance scoring 过程。
Alex:ColBERT 看起来不就是普通的 cosine similarity 比较吗?为什么还需要它?传统 retrievers 不是已经用 cosine similarity 做过类似工作了吗?
Lewis:虽然表面看起来,ColBERT 和传统基于 cosine similarity 的方法有些相似,但它的核心区别在于粒度。传统方法直接计算 query vector 和 document vector 之间的 cosine similarity;而 ColBERT 会保留 query 和 text chunk 中所有 tokens 的向量,通过交互计算来判断相关性。这种方法实现了更细粒度的语义匹配,可以捕捉局部对齐关系,因此能提供更准确的匹配结果。
Alex:那它和 Cross-Encoder 有什么不同?
Lewis:在 ColBERT 中,query 和 text chunk 的编码是分离的,这意味着 text chunks 可以预先编码并存储。检索时,只需要计算 token-level interactions。相比之下,Cross-Encoder 要求每一对 query-text chunk 都整体输入模型处理,其中包括句子中所有 tokens 的全面交互。因此,ColBERT 在重排序阶段具有更高计算效率,因为它直接使用每个 token 的 embedding vector 进行相似度计算。不过,这种方法也会带来更大的存储开销,因为需要存储大量 token-level representations。
下面是一段 ColBERT 示例代码。完整代码可参考 github.com/PacktPublis…
from transformers import AutoTokenizer, AutoModel
import torch
import faiss
### 加载 ColBERT 模型和 tokenizer
model_name = "bert-base-uncased" # 可以替换为经过 ColBERT 微调的模型
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModel.from_pretrained(model_name)
### Query 和文档集合
query = "What are some famous tourist attractions in Shanxi?"
documents = [
"Mount Wutai is one of the Four Sacred Buddhist Mountains in China, renowned as the bodhimanda of Manjushri Bodhisattva.",
"Yungang Grottoes is one of the three major grottoes in China, famous for its exquisite Buddhist sculptures.",
"Pingyao Ancient City is one of the best preserved ancient counties in China and has been listed as a World Cultural Heritage site."
]
### 编码函数
def encode_text(texts, max_length=128):
inputs = tokenizer(
texts,
return_tensors="pt",
padding=True,
truncation=True,
max_length=max_length
)
with torch.no_grad():
outputs = model(**inputs)
return outputs.last_hidden_state # 返回 [CLS] 和其他 token embeddings
### 编码 Query 和 Documents
query_embeddings = encode_text([query]) # Query 向量
doc_embeddings = encode_text(documents) # Document 向量
### 计算 Cosine Similarity
def calculate_similarity(query_emb, doc_embs):
## ColBERT 使用 late interaction,即 token-wise comparison。这里简化为使用 cosine similarity 进行比较
query_emb = query_emb.mean(dim=1) # 对 query vector 做平均池化
doc_embs = doc_embs.mean(dim=1) # 对 document vectors 做平均池化
query_emb = query_emb / query_emb.norm(dim=1, keepdim=True) # 归一化
doc_embs = doc_embs / doc_embs.norm(dim=1, keepdim=True)
scores = torch.mm(query_emb, doc_embs.t()) # 计算 cosine similarity
return scores.squeeze().tolist()
### 对 Documents 排序
scores = calculate_similarity(query_embeddings, doc_embeddings)
ranked_docs = sorted(zip(documents, scores), key=lambda x: x[1], reverse=True)
### 输出排序结果
print("Query:", query)
print("\nRanked Results:")
for rank, (doc, score) in enumerate(ranked_docs, start=1):
print(f"{rank}. Score: {score:.4f} | Document: {doc}")
输出如下:
Query: What are some famous tourist attractions in Shanxi?
Ranked Results:
Score: 0.9420 | Document: Yungang Grottoes is one of China’s three major grottoes, famous for its exquisite Buddhist sculptures.
Score: 0.9158 | Document: Pingyao Ancient City is one of the best-preserved ancient county-level cities in China and is listed as a World Cultural Heritage site.
Score: 0.9132 | Document: Wutai Mountain is one of China’s four major Buddhist sacred mountains, renowned as the bodhimanda of Manjushri Bodhisattva.
为了简化示例,我们使用平均池化结合 cosine similarity,替代了 ColBERT 原本基于 query 和 document 每个 token 之间交互进行评分的方法。虽然这种方式简化了实现,但可能无法充分体现 ColBERT 捕捉细粒度语义信息的优势。对于大规模文档集合,建议使用向量数据库构建索引,以加速向量检索。此外,在实际生产环境中,建议使用经过特定领域数据训练或微调的 ColBERT 模型,而不是通用的 bert-base-uncased 模型。
| Feature | ColBERT | Cross-encoder | RRF |
|---|---|---|---|
| Design goal | 高效稠密检索 + 精细重排序 | 用于精细重排序的深层语义匹配 | 通过合并多模型结果进行轻量重排序 |
| Semantic interaction | token-level interaction,捕捉细粒度语义 | query 和 document 全句交互,精确语义匹配 | 无语义交互,基于排序融合 |
| Computational cost | 中等,query 和 document 分别编码 + dot product | 高,每个 query-document pair 都要完整模型推理 | 低,直接融合已有排序分数 |
| Applicable scenario | 稠密检索或 top-k 文档重排序 | top-k 文档精细重排序 | 信号融合或轻量级重排序 |
| Contextual awareness | 强,基于 token-level embeddings | 非常强,全句语义建模 | 弱 |
| Suitable for large-scale retrieval | 是,支持预计算 document vectors | 否,计算成本过高 | 是 |
表 7.1:ColBERT、Cross-encoder 和 RRF 对比。
由于 Cross-Encoder 实现了 query 和 document 之间的完全交互,因此通常表现更好,但计算成本高,并且每次都需要重新计算。相比之下,ColBERT 通过 late interaction 设计,允许预先计算并存储 document vector representations,查询时只计算相似度,大幅提升效率。因此,在实际应用中,如果需要在小规模上进行精确重排序,可以选择 Cross-Encoder;对于大规模文档检索和重排序任务,ColBERT 可能是更好的选择。
Cohere re-ranking 和 Jina re-ranking
Alex:Lewis,除了 RRF、Cross-Encoder 和 ColBERT,我还听说过其他重排序技术,例如 Cohere re-ranking 和 Jina Re-ranking。它们有什么不同?
Lewis:Cohere re-ranking 和 Jina Re-ranking 都是基于大模型的方法。我们先说 Cohere re-ranking。
Cohere 类似于 OpenAI,是通过 API 提供大模型服务的大模型服务商之一。它推出了专门面向企业级搜索需求的 Rerank API。该 API 基于 Cohere 自研的 Command 系列大模型,例如 Command R,利用大模型强大的语义理解能力,并采用 Cross-Encoder 架构,实现对文档的深层语义理解,从而对候选文档进行细粒度重排序。
正如我们所知,Cross-Encoder 会将 query 和 document 拼接起来,然后输入同一个模型进行联合编码,直接计算二者之间的相关性分数,从而捕捉细粒度语义匹配关系。
由于 Cohere 的 Rerank API 是商业 API,用户不需要训练自己的模型。只需要将已有检索结果,例如 BM25 或向量搜索返回的 Top-100 结果,通过 API 发送给 Cohere re-ranking 工具,就可以获得优化后的排序结果。即使没有特定领域训练数据,Cohere 的模型也可以直接应用于不同排序任务,并表现出很强适应性。
Cohere re-ranking 支持多种语言,默认支持英语。如果要支持中文和其他语言,需要申请定制模型。此外,该服务针对高并发和低延迟场景做了优化。根据官方数据,在处理 100 个候选文档时,平均响应时间低于 300ms。
图 7.8:流程图展示 query 检索 documents、对其 rerank 并展示排序结果的过程。
下面的代码示例展示如何使用 LangChain 框架结合 Cohere re-ranker,对 BM25 初步排序后的文档进行重排序。
首先,安装 LangChain 和 Cohere 接口包,并配置 Cohere API Key:
pip install langchain-cohere
export CO_API_KEY="Your Cohere API Key"
完整代码如下:
## 导入必要库
from langchain_cohere import CohereRerank
from langchain_core.documents import Document
from langchain_community.retrievers import BM25Retriever
## 准备示例文档
documents = [
Document(
page_content="Mount Wutai is one of China’s four great Buddhist mountains, renowned as Manjusri Bodhisattva’s sacred site.",
metadata={"source": "Shanxi Travel Guide"}
),
Document(
page_content="The Yungang Grottoes are one of China’s three major grottoes, famous for their exquisite Buddhist sculptures.",
metadata={"source": "Shanxi Travel Guide"}
),
Document(
page_content="Pingyao Ancient City is one of the best-preserved ancient county towns in China and is listed as a World Cultural Heritage site.",
metadata={"source": "Shanxi Travel Guide"}
)
]
## 创建 BM25 Retriever
retriever = BM25Retriever.from_documents(documents)
retriever.k = 3 # 设置返回前 3 个结果
## 设置 Cohere Reranker
reranker = CohereRerank(model="rerank-multilingual-v2.0")
## 执行 Query 和 Rerank。先获取初始检索结果
initial_docs = retriever.invoke(query)
## 使用 Reranker 重新排序结果
reranked_docs = reranker.compress_documents(documents=initial_docs, query=query)
## 打印重排序后的结果
print(f"Query: {query}\n")
print("Reranked results:")
for i, doc in enumerate(reranked_docs, 1):
print(f"{i}. {doc.page_content}")
输出如下:
Query: What are some famous tourist attractions in Shanxi?
Pingyao Ancient City is one of the best-preserved ancient county towns in China and is listed as a World Cultural Heritage site
Mount Wutai is one of the four sacred Buddhist mountains in China, renowned as the bodhimanda of Manjushri Bodhisattva
Yungang Grottoes is one of China’s three major grottoes, famous for its exquisite Buddhist sculptures
Jina reranker v2 简介
Jina Reranker v2 模型具备优秀的多语言支持、function call 理解能力、代码检索表现和极快推理速度。与上一代相比,它的吞吐量提升了六倍,任务理解能力也得到增强,是当前顶级通用 reranker 之一。
Jina Reranker v2 模型支持超过 100 种语言,可以准确理解用户 query,并跨语言重排序文档。无论是在 MKQA 多语言问答任务中,还是在 BEIR 和 AirBench 等检索基准中,jina-reranker-v2-base-multilingual 模型都优于 bge-reranker-v2-m3 等竞争模型,根据 Jina 官网提供的评估结果。
应用和能力
Jina Reranker v2 模型进一步扩展了 agent 应用范围,尤其擅长 function call 和结构化数据检索场景。它不仅可以识别自然语言中的 function call 意图,还可以基于 query 对 SQL 表结构或外部 APIs 进行排序,并选择最合适的调用项,因此非常适合具备 function-aware 能力的 agent 应用。此外,在 CodeSearchNet 等代码检索任务中,该模型支持 docstrings 与 code snippets 之间的语义配对和 reranking,为构建智能代码助手提供了强大支持。
性能与效率
在速度方面,Jina Reranker v2 模型同样表现突出。通过采用 Flash Attention 2 技术和轻量级架构,它在保持高准确率的同时显著提升推理效率。例如,在 RTX 4090 上,它每 50ms 能处理的文档数量远超竞争模型。这种性能提升不仅适合 API 调用,也为私有化部署提供了很高性价比。
集成和访问
Jina Reranker v2 模型提供 API access、open source models,并与多个框架集成,例如 LangChain、LlamaIndex 和 Haystack,方便开发者根据不同场景灵活使用。你可以访问 Jina 官方网站查找相关 API 调用代码。此外,Jina 也将 jina-reranker-v2-base-multilingual 模型开放在 Hugging Face 社区中,用于研究和评估。
RankGPT 和 RankLLM
无论是 Cross-Encoder 重排序还是 ColBERT 重排序,都是基于经典深度学习模型的重排序方法,相关论文也都发表于 2022 年 ChatGPT 出现之前。进入大语言模型时代后,重排序技术也取得了新的进展。
RankGPT 由 Weiwei Sun 等人在论文 Is ChatGPT Good at Search? Investigating Large Language Models as Re-Ranking Agents 中提出。该方法利用大模型,例如 ChatGPT 或 GPT-4 的强大语义理解能力,以 zero-shot 方式对初始检索得到的候选文档进行精细排序,从而提升检索结果的相关性和准确性。RankGPT 会生成候选文档的不同排列,并使用 sliding window strategy 高效重排段落。该方法的优势在于不需要对模型进行专门微调,可以直接使用预训练大模型实现高效重排序。
RankLLM 则是开源社区,例如 Castorini,开发的 Python 工具包,目标是为信息检索研究提供可复用的重排序工具,特别关注 list-style re-ranking tasks。与 RankGPT 类似,RankLLM 也利用大模型执行重排序任务,但它更关注使用专门针对重排序任务优化过的微调开源模型,例如 RankVicuna 和 RankZephyr,以提升性能。
下面的代码示例展示如何在 LangChain 中使用 GPT 模型进行 RankLLM 重排序。完整代码可参考 github.com/PacktPublis…
from langchain_community.document_loaders import TextLoader
from langchain_community.vectorstores import FAISS
from langchain_huggingface import HuggingFaceEmbeddings
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain.retrievers.contextual_compression import ContextualCompressionRetriever
from langchain_community.document_compressors.rankllm_rerank import RankLLMRerank
import torch
### 加载文档并进行切分
documents = TextLoader("data/ShanxiCultureAndTourism/YungangGrottoes.txt").load()
text_splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=100)
texts = text_splitter.split_documents(documents)
for idx, text in enumerate(texts):
text.metadata["id"] = idx
### 生成 Embeddings 并创建 Retriever
embed_model = HuggingFaceEmbeddings(model_name="BAAI/bge-small-zh")
retriever = FAISS.from_documents(texts, embed_model).as_retriever(search_kwargs={"k": 20})
### 设置 RankLLM Re-Ranker
compressor = RankLLMRerank(top_n=3, model="gpt", gpt_model="gpt-4o-mini")
### 创建 Contextual Compression Retriever
compression_retriever = ContextualCompressionRetriever(
base_compressor=compressor,
base_retriever=retriever
)
### 执行 Query 并获取重排序后的文档
query = "What are the famous statues in Yungang Grottoes?"
compressed_docs = compression_retriever.invoke(query)
### 输出结果
def pretty_print_docs(docs):
print(
f"\n{'-' * 100}\n".join(
[f"Document {i+1}:\n\n" + d.page_content for i, d in enumerate(docs)]
)
)
预期输出:
pretty_print_docs(compressed_docs)
Document 1: Yungang Grottoes
The Yungang Grottoes are located on the southern foot of Mount Wuzhou, 17 kilometers west of Datong City, Shanxi Province, northern China......
LlamaIndex 的 LLM Reranker 也提供了类似的 reranking 功能。LLM Reranker 是 LlamaIndex 中的一个 node post-processor,可以与其他 retrieval modules 配合使用。
RankGPT 和 RankLLM 都展示了将大模型应用于 reranking 任务的潜力,但它们采用了不同方法。RankGPT 强调 zero-shot 能力,即无需任何额外训练步骤,就可以直接利用预训练模型执行 reranking 任务。相比之下,RankLLM 更关注通过微调开源模型来适配特定需求。
时间加权重排序
LangChain 提供的 time-weighted vector store retriever,可以广义上看作一种重排序机制。它不仅考虑文档和 query 之间的语义相似度,还会引入时间因素来调整文档相关性分数。该方法模拟人类记忆的特点:被频繁访问的信息会保持“新鲜”,而很少使用的信息会逐渐被“遗忘”。
该算法基于文档的最后访问时间构造一个衰减函数。其基本公式如下:
图:电脑屏幕上显示一行代码。
这里,semantic_similarity 是文档和 query 之间的语义相似度分数。hours_passed 是距离文档上次被访问所经过的小时数,而不是距离文档创建所经过的时间。每次文档被访问时,hours_passed 会重置为 0,以保持文档的“新鲜度”。decay_rate 是衰减率参数,范围从 0 到 1,用于决定分数随时间衰减的速度。值越高,表示文档越快“过时”;值越低,表示文档可以保持更长时间的“新鲜”。
例如,如果 decay_rate=0.99,那么一个 24 小时未被访问的文档,其时间分数将接近 (1-0.99)^24,几乎为 0。反过来,如果 decay_rate=0.01,即使经过很长时间,时间分数仍然接近 1。这样,我们就可以灵活调整 decay_rate 参数,控制系统记忆持久性和时效性之间的平衡。
该 retriever 特别适合既需要考虑相关性、又需要考虑时效性的应用场景,例如个性化推荐系统或知识管理系统。通过适当调整 decay_rate 参数,用户可以根据具体需求,在两者之间找到合适平衡。
下面的代码示例演示如何使用 LangChain 中的 TimeWeightedVectorStore Retriever,实现一种结合语义相似度和时间衰减率的检索方法。完整代码可参考 github.com/PacktPublis…
from datetime import datetime, timedelta
import faiss
from langchain.retrievers import TimeWeightedVectorStoreRetriever
from langchain_community.docstore import InMemoryDocstore
from langchain_community.vectorstores import FAISS
from langchain_core.documents import Document
from langchain_openai import OpenAIEmbeddings
## 定义 Embedding 模型
embeddings_model = OpenAIEmbeddings()
## 初始化 Vector Store
index = faiss.IndexFlatL2(1536)
vectorstore = FAISS(embeddings_model, index, InMemoryDocstore({}), {})
## 创建一个高衰减率的 TimeWeightedVectorStoreRetriever
retriever = TimeWeightedVectorStoreRetriever(
vectorstore=vectorstore,
decay_rate=0.999,
k=1
)
## 将文档的最后访问时间设置为昨天
yesterday = datetime.now() - timedelta(days=1)
## 添加文档
retriever.add_documents(
[Document(page_content="hello world", metadata={"last_accessed_at": yesterday})]
)
## 添加一个未指定访问时间的文档;默认将当前时间作为最后访问时间
retriever.add_documents([Document(page_content="hello foo")])
由于设置了较高衰减率,“hello foo” 因为“访问”时间更新,可能会被优先返回。
results = retriever.get_relevant_documents("hello world")
## 输出检索结果
for doc in results:
print(f"Document Content: {doc.page_content}")
预期输出:
Document Content: hello foo
关键参数是通过衰减率调整文档被“遗忘”的速度。在低衰减率,即接近 0 时,文档几乎不会因为时间流逝而被遗忘,这类似传统向量检索。在高衰减率,即接近 1 时,文档会随时间失去权重,尤其是那些长时间未被访问的文档,其权重会快速下降,使查询结果更偏向最近访问过的文档。
为了模拟未来或过去的查询,可以使用 mock_now 功能控制时间,从而测试不同时间段下的检索结果。下面的代码模拟了在 2028 年 8 月 8 日执行的查询:
from langchain_core.utils import mock_now
import datetime
## 模拟未来查询
with mock_now(datetime.datetime(2028, 8, 8, 10, 11)):
print(retriever.get_relevant_documents("hello world"))
输出如下:
[Document(metadata={'last_accessed_at': MockDateTime(2028, 8, 8, 10, 11), 'created_at': datetime.datetime(2025, 4, 12, 14, 42, 33, 978711), 'buffer_idx': 0}, page_content='hello world')]
LlamaIndex 中也存在类似实现,称为 Recency Filtering,用于处理多版本文档检索中的时效性问题。当文档存在不同版本时,它会优先返回最新版本的信息。该方法根据文档 timestamp 判断哪个版本最新,并在检索结果中提升这些最新信息的权重或排名。
当查询系统遇到多个带有时间 metadata 的文档版本时,recency filter 会检查这些文档的创建 / 更新日期,并根据时间属性对它们排序或加权,优先返回最近文档的内容。
LlamaIndex 官方示例展示了两种 recency filters 的实现:
FixedRecencyPostprocessor:直接根据文档固定 timestamp 排序,简单优先返回最新文档版本。
EmbeddingRecencyPostprocessor:结合文档语义相似度和时间信息,在相似度评分基础上加入时间因素。
下面的示例展示了某篇博客文章的三个不同版本 V1、V2、V3,它们对融资金额的描述不同:
V1,2020-01-01:提到融资 $50,000。
V2,2020-02-03:提到融资 $30,000。
V3,2022-04-12:提到融资 $10,000。
当用户查询 “How much seed funding did the author raise” 时,recency filter 会确保系统返回最新版本 V3 的信息,也就是 $10,000,而不是旧版本中的过时信息。
LangChain 和 LlamaIndex recency weighting 的区别
Anna:Lewis,我注意到 LangChain 中的 TimeWeightedVectorStoreRetriever 使用的是访问时间加权,而 LlamaIndex 的 Recency Filtering 使用的是文档创建和修改时间加权。
Lewis:这个观察很敏锐。具体选择哪种机制,取决于你的需求。这些技术非常适合需要处理频繁更新信息的场景,例如新闻报道、产品规格、金融数据,或者任何会随时间变化的知识库内容。它们可以在保持相关性的同时,确保最新版本始终可访问,并减少过时数据造成的混淆。
Compression
现代生成模型可以处理显著更长的上下文长度。如果知识库不超过 200,000 tokens,大致相当于 500 页材料,可以考虑跳过检索步骤,直接把整个知识库放入 prompt,而不需要 chunking。
图 7.9:图示展示自然语言模型工作流、prompt 增长以及相关挑战。
不过,当生成模型面对过长上下文时,很容易出现 “lost in the middle” 问题。这意味着需要有效压缩知识库,让它更加简洁,使模型能够更高效地处理这些信息。
Contextual compression retrievers
LangChain 提供了一个 Contextual Compression Retriever,其中包括以下两个组件:
Base retriever:例如 Faiss、ChromaDB,用于执行标准向量检索。
Document compressor:在检索文档之后,过滤或缩短内容,只保留最相关信息。
下面的代码示例重构了第 7.1.4 节中的示例。在使用 Cohere re-ranking 的同时,也将 Cohere re-ranker 传入 LangChain 的 ContextualCompressionRetriever,用于压缩检索结果。完整代码可参考 github.com/PacktPublis…
## 导入所需库
from langchain_cohere import CohereRerank
from langchain.retrievers.contextual_compression import ContextualCompressionRetriever
from langchain_core.documents import Document
from langchain_community.retrievers import BM25Retriever
## 准备示例文档
documents = [
Document(
page_content="Mount Wutai is one of the four great Buddhist mountains in China and is famous as the bodhimanda of Manjusri.",
metadata={"source": "Shanxi Travel Guide"}
),
Document(
page_content="The Yungang Grottoes are one of China’s three major grottoes, renowned for exquisite Buddhist sculptures.",
metadata={"source": "Shanxi Travel Guide"}
),
Document(
page_content="Pingyao Ancient City is one of the best-preserved ancient county towns in China and is listed as a World Cultural Heritage site.",
metadata={"source": "Shanxi Travel Guide"}
)
]
## 创建 BM25 Retriever
retriever = BM25Retriever.from_documents(documents)
retriever.k = 3 # 设置返回 top 3 结果
## 设置 Cohere Reranker
compressor = CohereRerank(model="verank-multilingual-v2.0")
## 创建 ContextualCompressionRetriever
compression_retriever = ContextualCompressionRetriever(
base_compressor=compressor,
base_retriever=retriever
)
## 执行 Query、Re-Rank 和 Compress
query = "What are some famous tourist attractions in Shanxi?"
compressed_docs = compression_retriever.invoke(query)
### 输出压缩结果
print("Re-ranked and compressed results:")
for i, doc in enumerate(compressed_docs, 1):
print(f"{i}. {doc.page_content}")
预期输出:
Re-ranked and compressed results:
Pingyao Ancient City is one of the best-preserved ancient county-level cities in China and is listed as a World Cultural Heritage site.
Mount Wutai is one of China’s four great Buddhist mountains, famous as the bodhimanda of Manjushri Bodhisattva.
Yungang Grottoes are one of the three major grottoes in China, renowned for their exquisite Buddhist sculptures.
ContextualCompressionRetriever 会从原始检索结果中移除无关或冗余信息。例如,在课程章节中,可能只有一部分内容与 query 相关;此时,compressor 会保留相关部分。此外,它还会根据文档与 query 的相关性对文档重新排序。
LangChain 为 Contextual Compression Retriever 提供了多种压缩策略,可以单独使用,也可以按顺序组合使用:
LLMChainExtractor,基于大模型的内容提取:使用大语言模型从文档中提取最相关部分,移除无关内容。适合需要从长文档中提取关键信息的场景。
LLMChainFilter,基于大模型的文档过滤:只保留与 query 高度相关的文档,直接丢弃无关文档,而不是修改内容。适合需要减少 LLM 处理文档数量的场景。
LLMListwiseRerank,基于大模型的文档重排序:对所有检索文档重新排序,只返回最相关的 Top N 文档。适合在搜索结果较多时进行细粒度选择。
EmbeddingsFilter,基于 embeddings 的相似度过滤:计算 query 和 documents 之间的 embedding 相似度,丢弃低于设定阈值的文档,以减少无关信息。适合不调用大语言模型的快速过滤场景。
多个 compressors 可以组合使用,按顺序处理文档。例如,先使用 EmbeddingsFilter 移除无关文档,再使用 LLMChainExtractor 提取最相关内容。这样可以高效清理文档,只把最重要部分传给大模型。
使用 LLMLingua 压缩 prompts
LLMLingua 是 Microsoft 开发的一项技术,专注于缓解大模型中的“中途遗忘”问题,并增强其处理长上下文信息的能力。它通过压缩 prompts 和 key-value cache,KV-Cache,加速大模型推理过程,在性能损失极小的情况下,最高实现 20 倍压缩率。
Lewis 的提示
Key-value caching 是大语言模型推理过程中的核心优化机制。这个概念源自 Transformer 架构中的 attention mechanism。
当模型生成文本时,每个 token 都必须与之前生成的所有 tokens 进行 attention 计算。如果没有 key-value caching,每当模型生成一个新 token,就需要重新计算所有先前 tokens 的 key 和 value vectors,从而产生大量重复计算。
key-value cache 的作用如下:
缓存历史:已经计算过的 tokens 的 key 和 value vectors 会存储在内存中。
增量计算:生成新 token 时,只需要计算新 token 的 key 和 value vectors,再与缓存中的历史 key 和 value vectors 组合即可。
性能提升:显著减少重复计算需求,可以将推理速度提升数倍。
内存折中:虽然需要额外内存存储 key-value cache,但实践中这种折中通常是值得的。
例如,假设模型正在生成句子 “I like to eat apples.” 当生成字符 “ap” 时,模型已经计算了 “I”“like”“to”“eat” 的 key 和 value vectors。有了 key-value cache 后,在生成字符 “ple” 时,就不需要重新计算前面字符的 key 和 value vectors,而是可以直接使用缓存计算结果。
原始 LLMLingua 论文提出使用紧凑且训练良好的小模型,例如 GPT2-small、LLaMA-7B,来识别并移除 prompts 中不必要的 tokens,从而降低计算负担。该方法不仅避免了对大模型进行额外训练,也保留了原始 prompt 信息的完整性和准确性。论文指出,LLMLingua 通过快速压缩技术降低成本并提升效率,在只使用四分之一 token 数量的情况下,实现了 21.4% 的 RAG 性能提升。
图 7.10:图示展示 prompt compression framework,将冗长 prompts 压缩成简洁 prompts 供 AI 使用。
随后,LLMLingua 又推出了 LongLLMLingua 和 LLMLingua-2。
图 7.11:流程图解释如何使用 LLMLingua 方法压缩大语言模型 prompts。
LLMLingua-2 使用 GPT-4 模型进行数据蒸馏训练,并采用类似 BERT 的 encoder 进行 token classification,在任务无关压缩方面表现出色。尤其是在处理领域外数据时,LLMLingua-2 的性能比 LLMLingua 高 3 到 6 倍。
如何使用 LLMLingua 压缩 prompts
下面的代码示例演示如何使用 LLMLingua 压缩 prompts。在这个例子中,原始 prompt 会被压缩到指定 token 数,同时保留关键信息。
首先,安装 LLMLingua:
pip install llmlingua
接下来,使用下面的代码压缩 prompt:
from llmlingua import PromptCompressor
llm_lingua = PromptCompressor()
compressed_prompt = llm_lingua.compress_prompt(
context="Yungang Grottoes are located at the southern foot of Wuzhou Mountain, 17 kilometers west of Datong City, Shanxi Province, northern China...",
instruction="Compress and retain main content",
question="",
target_token=100 # 设置目标 token 数
)
print(compressed_prompt['compressed_prompt'])
你也可以压缩 JSON 数据,并控制每个 JSON key-value pair 的压缩率:
json_data = {
"id": 1,
"name": "Wukong",
"biography": "In the beginning of the cosmos, before heaven and earth were separated..."
}
json_config = {
"id": {"rate": 1, "compress": False, "pair_remove": False, "value_type": "number"},
"name": {"rate": 0.7, "compress": False, "pair_remove": False, "value_type": "string"},
"biography": {"rate": 0.3, "compress": True, "pair_remove": False, "value_type": "string"}
}
使用 LLMLingua 压缩 JSON Prompt 的示例:
import llm_lingua
json_data = {
"prompt": "Write a summary of the following article...",
"metadata": {
"author": "John Doe",
"date": "2023-10-01"
}
}
json_config = {
"keep_keys": ["prompt"],
"compression_level": 2
}
compressed_json = llm_lingua.compress_json(json_data, json_config)
print(compressed_json['compressed_prompt'])
LLMLingua 目前已经集成进 LangChain 和 LlamaIndex。学习者可以通过导入相关库使用其功能,或者访问 LLMLingua 的 GitHub 项目页面获取更多信息。
RECOMP 方法
RECOMP 是一种通过压缩检索文档来生成简洁文本摘要的方法。该方法在检索和生成之间引入一个中间步骤,专门通过文本摘要压缩检索文档中的关键信息,然后将其追加到模型输入中。
图 7.12:流程图展示关于 Nissan Xterra 何时停产的问题如何被处理。
在 RECOMP 中,会使用两类 compressors 来实现这一目标:
Extractive compressor,抽取式压缩器:这类压缩器从检索文档中选择最相关句子。为了优化句子选择,它使用对比学习,以确保被选句子最大化任务表现。
Abstractive compressor,生成式压缩器:与抽取式压缩器不同,生成式压缩器会通过综合多篇文档的信息生成摘要。它使用大语言模型生成训练数据,并通过知识蒸馏训练一个更小的 compressor model。
此外,如果检索到的文档对当前任务没有帮助,compressor 会智能返回空字符串,从而避免不必要的信息干扰,并提升生成模型整体效率。
Sentence embedding optimizer
接下来,我们介绍 LlamaIndex 提供的另一种压缩技术——Sentence Embedding Optimizer。该方法专注于通过 sentence embeddings 计算每个句子与 query 的相关性,从而减少无关句子数量,并优化输入内容。
作为一种 node post-processing 工具,Sentence Embedding Optimizer 的主要作用,是在文本检索过程中优化输入内容。它使用基于 embedding 的相似度分析方法,根据用户 query 从文本中移除无关句子,从而缩短输入文本长度,并提升处理效率和结果相关性。这种优化和压缩是在 token embedding 层面进行的,可以确保信息简洁且准确。
Sentence Embedding Optimizer 支持以下两种过滤方式:
Percentile cutoff:保留相似度分数高于某个百分位的句子。
Threshold cutoff:保留相似度分数高于某个固定值的句子。
下面的代码示例演示如何使用 Sentence Embedding Optimizer 优化 query 处理流程。完整代码可参考 github.com/PacktPublis…
from llama_index.core import VectorStoreIndex, SimpleDirectoryReader
from llama_index.core.postprocessor import SentenceEmbeddingOptimizer
documents = SimpleDirectoryReader("data/山西文旅").load_data()
index = VectorStoreIndex.from_documents(documents)
### 不使用优化进行查询
print("Without optimization:")
query_engine = index.as_query_engine()
response = query_engine.query("What are the main tourist attractions in Shanxi Province?")
print(f"Answer: {response}")
### 使用优化进行查询,Percentile Cutoff
print("\nWith optimization (percentile_cutoff=0.5):")
query_engine = index.as_query_engine(node_postprocessors=[SentenceEmbeddingOptimizer(percentile_cutoff=0.5)])
response = query_engine.query("What are the main tourist attractions in Shanxi Province?")
print(f"Answer: {response}")
### 使用优化进行查询,Threshold Cutoff
print("\nWith optimization (threshold_cutoff=0.7):")
query_engine = index.as_query_engine(node_postprocessors=[SentenceEmbeddingOptimizer(threshold_cutoff=0.7)])
response = query_engine.query("What are the main tourist attractions in Shanxi Province?")
print(f"Answer: {response}")
预期输出:
Without Optimization:
Answer: The **Yungang Grottoes** are a major tourist attraction in Shanxi Province, located at the southern foot of **Wuzhou Mountain** in the western suburbs of Datong City. It is one of the largest ancient grotto clusters in China. In addition, Shanxi Province also has other famous attractions such as **Wuzhou Mountain**, which is a scenic spot in the western mountains of Datong City.
With Optimization (percentile_cutoff=0.5):
Answer: The **Yungang Grottoes** are one of the major tourist attractions in Shanxi Province.
With Optimization (threshold_cutoff=0.7):
Answer: The main tourist attractions in Shanxi Province include the **Yungang Grottoes**, **Wuzhou Mountain**, and others.
在上面的例子中,percentile truncation 设置会保留相似度排名前 50% 的句子,而 threshold truncation 设置只保留相似度大于 0.7 的句子。
通过 prompt caching 记住长上下文
Alex:对了,Lewis,听完你介绍这一系列 compression technologies,我想起前几天看到的 Prompt Caching 概念。这是 Anthropic 提出的一种 prompt 缓存方法,目标是减少重复或相似任务的处理时间和成本。它也可以算一种 compression 吗?
Lewis:没错。Prompt Caching 确实可以被看作 context compression 的一种应用。它专注于优化重复任务中的上下文管理,通过最小化重复处理,大幅提升系统效率并降低计算成本。
开发大模型应用时,经常会遇到包含大量背景信息,或者需要多轮交互的场景,例如长对话或文档分析。Prompt Caching 可以智能缓存可复用的上下文前缀,从而避免每次调用时都重新处理大块内容,例如长文档或背景信息。
在实际应用中,当检索内容或上下文信息与当前任务无关时,Prompt Caching 可以输出空缓存。
与 RAG 技术相比,Prompt Caching 更关注性能优化。它不是直接用于检索或生成内容的组件,而是提供一种智能缓存机制,用于提升生成模型的性能和效率。在 RAG 的检索后处理阶段,当同一上下文内容需要被多次调用时,Prompt Caching 的缓存机制可以避免重复加载和重复处理相同背景信息,从而显著降低 API 调用延迟和成本。这种优化在频繁处理大型文档或复杂用户背景信息的场景中特别有效。
Correction
Correction 技术会在 RAG 系统中为检索文档和生成答案引入自反思与自评分机制,因此它既可以应用于检索后处理阶段,也可以作为生成过程的一部分。该技术的一个典型实现是 Corrective Retrieval Augmented Generation,CRAG。CRAG 的实现流程如下。
图 7.13:流程图展示一个问答系统的搜索和知识校正过程。
CRAG 系统的核心概念,是通过反复评估和重新检索,确保生成答案所依据的信息高度相关。系统由两个主要组件组成:检索后知识校正,以及生成过程中的校正。
retrieval assessor 负责检索后知识校正。如果至少有一个文档的相关性超过设定阈值,系统就进入内容生成阶段。在生成之前,会执行知识精炼过程,将文档拆成 knowledge fragments,对每个 fragment 评分,并过滤掉无关部分。如果所有文档的相关性都低于阈值,或者 assessor 无法判断相关性,系统会寻找额外数据源来补充检索结果,例如通过 web search 查找更多相关文档。
生成器给出初始答案后,还会使用额外模块或步骤对答案进行第二次验证和优化。例如,在生成答案后,可以将其与检索信息进行比对,以确保一致性并纠正错误。此外,还可以使用 fact-checking、grammatical correction 和 semantic consistency verification 等步骤,进一步提升生成答案的质量。
接下来,我们将使用 LangGraph 实现 CRAG 的部分思想。
下面的代码示例可以自动判断检索结果质量。如果发现无关文档,系统会通过 web search,使用 Tavily Search 工具,补充检索信息,并通过 query rewriting 优化 web search queries。
首先,安装必要包并设置用于 web search 的 TAVILY_API_KEY。
随后,为 3 篇博客文章创建索引,并将这些片段存入 Chroma vector database。完整代码可参考 github.com/PacktPublis…
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_community.document_loaders import WebBaseLoader
from langchain_community.vectorstores import Chroma
from langchain_huggingface import HuggingFaceEmbeddings
urls = [
"https://lilianweng.github.io/posts/2023-06-23-agent/",
"https://lilianweng.github.io/posts/2023-03-15-prompt-engineering/",
"https://lilianweng.github.io/posts/2023-10-25-adv-attack-llm/",
]
docs = [WebBaseLoader(url).load() for url in urls]
docs_list = [item for sublist in docs for item in sublist]
text_splitter = RecursiveCharacterTextSplitter.from_tiktoken_encoder(
chunk_size=250, chunk_overlap=0
)
doc_splits = text_splitter.split_documents(docs_list)
## 添加到向量数据库
vectorstore = Chroma.from_documents(
documents=doc_splits,
collection_name="rag-chroma",
embedding=HuggingFaceEmbeddings(model_name="BAAI/bge-small-en"),
)
retriever = vectorstore.as_retriever()
接下来,设置一个 retrieval evaluator,用于评估检索文档与用户问题的相关性。完整代码可参考 github.com/PacktPublis…
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.pydantic_v1 import BaseModel, Field
from langchain_deepseek import ChatDeepSeek
### 数据模型
class GradeDocuments(BaseModel):
"""对检索文档相关性进行二元评分"""
binary_score: str = Field(
description="If the document is relevant to the question, it is ‘yes’; if not, it is ‘no’"
)
### 支持 Tool Calls 的语言模型
llm = ChatDeepSeek(model="deepseek-chat")
structured_llm_grader = llm.with_structured_output(GradeDocuments)
### Prompt Template
system = """You are a grader who evaluates the relevance between the retrieved document and the user’s question. \n
If the document contains keywords or semantics related to the question, rate it as relevant. \n
Give a binary score of ‘yes’ or ‘no’ to indicate whether the document is relevant to the question."""
grade_prompt = ChatPromptTemplate.from_messages(
[
("system", system),
("human", "Retrieved document:\n\n {document} \n\n User question: {question}"),
]
)
retrieval_grader = grade_prompt | structured_llm_grader
question = "agent memory"
docs = retriever.get_relevant_documents(question)
doc_txt = docs[1].page_content
print(retrieval_grader.invoke({"question": question, "document": doc_txt}))
接下来,创建一个 generation model,用于根据检索文档和用户问题生成答案。这里使用一个预定义 RAG prompt template:
from langchain import hub
from langchain_core.output_parsers import StrOutputParser
## Prompt Template
prompt = hub.pull("rlm/rag-prompt")
## 语言模型
llm = ChatDeepSeek(model="deepseek-chat")
## 后处理
def format_docs(docs):
return "\n\n".join(doc.page_content for doc in docs)
## Chain 调用
rag_chain = prompt | llm | StrOutputParser()
## 运行
generation = rag_chain.invoke({"context": docs, "question": question})
print(generation)
然后,创建一个 question rewriter,将用户原始问题转换成更适合 web search 的形式,以提升 web search 效果。完整代码可参考 github.com/PacktPublis…
## 语言模型
llm = ChatDeepSeek(model="deepseek-chat")
## Prompt Template
system = """You are a question rewriter, converting the input question into a version more suitable for web search. \n
Analyze the input and try to reason out the potential semantic intent/meaning."""
re_write_prompt = ChatPromptTemplate.from_messages(
[
("system", system),
(
"human",
"This is the initial question:\n\n {question} \n Please restate it as an improved question.",
),
]
)
question_rewriter = re_write_prompt | llm | StrOutputParser()
question_rewriter.invoke({"question": question})
接下来,设置 Tavily search API web search tool,它会返回最相关的前 3 个搜索结果:
from langchain_community.tools.tavily_search import TavilySearchResults
web_search_tool = TavilySearchResults(k=3)
接下来,我们使用 LangGraph 库构建一个基于 CRAG 的 graph structure。图中的每个 node 表示一个操作,edges 表示操作之间的转换。这种自校正架构是 Agentic pattern 的典型实现。LangGraph 非常适合设计这类工作流。完整代码可参考 github.com/PacktPublis…
from typing import List
from typing_extensions import TypedDict
class GraphState(TypedDict):
"""
表示图状态
属性:
- question: 用户问题
- generation: 语言模型生成的答案
- web_search: 是否需要 web search 补充信息
- documents: 文档列表
"""
question: str
generation: str
web_search: str
documents: List[str]
from langchain.schema import Document
def retrieve(state):
"""
检索与问题相关的文档
参数:
state (dict): 当前图状态
返回:
state (dict): 更新后的图状态
documents: 添加检索到的相关文档
"""
print("---Retrieving---")
question = state["question"]
## 检索
documents = retriever.get_relevant_documents(question)
return {"documents": documents, "question": question}
def generate(state):
"""
生成答案
参数:
state (dict): 当前图状态
返回:
state (dict): 更新后的图状态
generation: 包含语言模型生成内容
"""
print("---Generating---")
question = state["question"]
documents = state["documents"]
## RAG 生成
generation = rag_chain.invoke({"context": documents, "question": question})
return {"documents": documents, "question": question, "generation": generation}
def grade_documents(state):
"""
判断检索文档是否与问题相关
参数:
"""
检查文档与问题相关性的函数:
def check_document_relevance(state):
"""
参数:
state (dict): 当前图状态
返回:
state (dict): 更新 documents key,只保留过滤后的相关文档
"""
print("---Checking document relevance to the question---")
question = state["question"]
documents = state["documents"]
# 对每个文档评分
filtered_docs = []
web_search = "No"
has_relevant_docs = False
for d in documents:
score = retrieval_grader.invoke({
"question": question, "document": d.page_content
})
grade = score.binary_score
if grade == "yes":
print("---Score: Document is relevant---")
filtered_docs.append(d)
has_relevant_docs = True
else:
print("---Score: Document is not relevant---")
if not has_relevant_docs:
web_search = "Yes"
return {"documents": filtered_docs, "question": question, "web_search": web_search}
转换 Query 的函数:
def transform_query(state):
"""
根据当前状态重写问题,以提升搜索结果
参数:
state (dict): 当前图状态
返回:
state (dict): 包含重写后问题的更新图状态
"""
print("---Transforming query---")
question = state["question"]
documents = state["documents"]
# 重写问题
better_question = question_rewriter.invoke({"question": question})
return {"documents": documents, "question": better_question}
Web Search 函数:
def web_search(state):
"""
使用 web search tool 获取额外信息
参数:
state (dict): 包含当前状态
- question: 问题
- documents: documents 列表
返回:
state (dict): 通过追加网络搜索结果更新 documents key
"""
print("---Network Search---")
question = state["question"]
documents = state["documents"]
### 网络搜索
search_results = web_search_tool.invoke(question)
### 将搜索结果列表转换为字符串
search_results_str = "\n".join([str(result) for result in search_results])
web_results = Document(page_content=search_results_str)
documents.append(web_results)
return {"documents": documents, "question": question}
边界情况处理:
def decide_to_generate(state):
"""
根据当前状态决定下一步:生成答案还是重写问题
参数:
state (dict): 当前 graph state
返回:
str: 下一步要调用的操作名称
"""
print("---Evaluate Scored Documents---")
state["question"]
web_search = state["web_search"]
state["documents"]
if web_search == "Yes":
### 所有文档都已经被 check_relevance 过滤掉,我们将生成一个新查询
print(
"---Decision: All documents are irrelevant to the question, transforming query---"
)
return "transform_query"
else:
### 由于已有相关文档,我们可以生成答案
print("---Decision: Generate---")
return "generate"
接下来,定义图的状态和每个 node 的函数。每个函数代表图中的一个 node,负责执行特定任务,例如检索文档、为文档评分、生成答案等。完整代码可参考 github.com/PacktPublis…
from langgraph.graph import END, StateGraph, START
## 初始化 Workflow State Graph
workflow = StateGraph(GraphState)
## 定义 Nodes
workflow.add_node("retrieve", retrieve) # 检索文档
workflow.add_node("grade_documents", grade_documents) # 为文档评分
workflow.add_node("generate", generate) # 生成答案
workflow.add_node("transform_query", transform_query) # 转换 query
workflow.add_node("web_search_node", web_search) # web search
## 构建 Graph Edges,连接关系
workflow.add_edge(START, "retrieve") # 从开始到检索文档
workflow.add_edge("retrieve", "grade_documents") # 从检索文档到文档评分
workflow.add_conditional_edges(
"grade_documents",
decide_to_generate,
{
"transform_query": "transform_query",
"generate": "generate",
},
)
workflow.add_edge("transform_query", "web_search_node") # 从 query 转换到 web search
workflow.add_edge("web_search_node", "generate") # 从 web search 到生成答案
workflow.add_edge("generate", END) # 从生成答案到结束
## 编译整个 Workflow
app = workflow.compile()
最后,使用 CRAG 系统解决具体问题。它会逐步执行图中的每个 node,并输出每个 node 的名称,以及最终生成答案。完整代码可参考 github.com/PacktPublis…
from pprint import pprint
## 设置输入问题
inputs = {"question": "What are the types of agent memory?"}
## 运行程序并处理输出
for output in app.stream(inputs):
for key, value in output.items():
# 打印当前 node 名称
pprint(f"Node '{key}':")
# 可选:输出每个 node 的完整状态
# pprint(value["keys"], indent=2, width=80, depth=None)
pprint("\n---\n")
# 输出最终生成答案
pprint(value["generation"])
预期输出:
---Retrieval---
"Node ‘retrieve’:"
---Check document relevance to the question---
---Score: Document Relevant---
---Score: Document Relevant---
---Score: Document Not Relevant---
---Evaluate scored documents---
---Decision: Generate---
"Node ‘grade_documents’:"
---Generate---
"Node ‘generate’:"
('The different categories of memory in agents are short-term memory and long-term memory. Short-term memory is utilized for in-context learning, while long-term memory allows agents to retain and recall information over extended periods. These memory components play a crucial role in the functioning of AI agents.')
在整个程序流程中,CRAG 系统首先检索与问题相关的文档,然后评估这些文档的相关性。在上面的例子中,根据评估结果,系统决定直接生成答案。
如果问题与知识库中的内容完全无关,流程会进入 query transformation,也就是优化问题表述,以及 web search 阶段,在获得相关信息之后才生成最终答案:
inputs = {"question": "Why does Shanxi Province have abundant tourism resources?"}
输出如下:
---Retrieval---
"Node ‘retrieve’:"
---Check document relevance with the question---
---Score: Document not relevant---
---Score: Document not relevant---
---Evaluate scored documents---
---Decision: All documents are not relevant to the question, transform query---
"Node ‘grade_documents’:"
---Transform query---
"Node ‘transform_query’:"
---Web search---
"Node ‘web_search_node’:"
---Generation---
"Node ‘generate’:"
'The factors making Shanxi Province a region rich in tourism resources include thousands of years of historical and cultural accumulation and heritage, unique folk customs, as well as distinctive topography. Shanxi has 390 A-level tourist attractions, including well-known sites such as Mount Wutai, Yungang Grottoes, Taihang Grand Canyon, and Pingyao. Government regulations and the support of related policies are also important factors in the development of Shanxi’s tourism industry.'
在上述过程中,如果知识库已经包含足够信息回答问题,系统会主动结束检索过程。对于与 AI 技术完全无关的山西文旅问题,当系统检测到初始检索文档集缺乏足够相关信息时,就会自动执行 web search。这个机制展示了 CRAG 系统如何根据不同类型的问题,灵活调整检索和生成策略,以满足多样化需求。
CRAG 代表了 RAG 系统的一个重要演进方向,也就是利用大模型智能提升 RAG 系统答案质量。不过,在实际应用中,这种方法可能导致系统复杂度增加、响应时间变长。因此,在保证答案质量的同时,也需要考虑系统效率。
总结
检索后处理技术在 RAG 系统架构中发挥着关键作用,位于检索阶段和生成阶段之间。它们的目标,是优化检索结果的准确性、相关性和效率。
重排序技术会使用各种算法重新排列初始检索结果,以提升相关文档排名。例如,RRF 适合融合多个不同 rankers 的结果,尤其是在使用不同检索策略时表现突出。它可以平衡各个 ranker 的结果,避免单一 ranker 的影响,并且简单高效。Cross-Encoder 会将 query 和 document 拼接后输入预训练模型,例如 BERT,通过完整交互直接输出相关性分数。ColBERT 通过 late interaction 实现 token-level 细粒度匹配,在效率和准确性之间取得平衡。Cohere / Jina 直接利用商业 API,例如 Cohere re-ranking 和 Jina Re-ranking,使用其预训练大模型进行黑盒重排序。RankGPT / RankLLM 利用大模型,例如 GPT-4、Llama 3,进行指令理解,通过 zero-shot 或 few-shot prompting 直接生成重排序结果。此外,通过引入时间加权机制,可以确保结果的时间相关性,适合对实时性要求较高的场景。
在当前以大模型为主导的时代,手动部署 Cross-Encoder 或 ColBERT 已经不那么常见。对于需要简单有效融合排序结果的场景,RRF 是一个不错选择。如果需要对文档和 query 进行语义理解和排序,并且数据隐私不是主要顾虑,那么对于已经具备 Elasticsearch 等搜索系统、希望快速增强语义能力的企业,尤其是缺少内部 NLP 工程师团队、但愿意采用按需付费模式的企业来说,选择 Cohere re-ranking 更合适。不过,如果业务规则特殊,例如医疗搜索需要结合专业术语库,内部技术团队有能力进行模型调优,并且有严格数据隐私要求、不愿使用第三方 API,那么部署开源重排序模型会更合适。
压缩技术的目标,是在保留检索结果关键内容的同时移除冗余信息,但过度压缩可能造成信息损失,并影响生成结果的完整性和准确性。
上下文压缩技术,例如 ContextualCompressionRetriever、LLMLingua 等,可以提取关键信息并减少冗余。模型优化技术,例如 RECOMP、Sentence Embedding Optimizer,通过 embedding 优化或增强检索技术提升效率。Prompt Caching 的缓存机制可以解决长上下文处理问题,在性能收益和资源消耗之间取得平衡。
校正技术可以修复检索和生成阶段可能出现的错误,确保输出内容更符合事实或特定标准,从而增强 RAG 系统可靠性。不过,校正过程通常会增加系统复杂度和计算资源消耗,尤其是在需要深层语义分析时。
经过检索后处理之后,传给生成器的信息质量得到提升,从而产出更精炼、更准确的信息。