从零到一带你实战RAG混合检索 原创
在之前的文章中,我们探讨了混合检索的概念以及其后续的重新排序(rerank)和重组(reorder)操作。今天,我们将从实践角度解析如何执行混合检索。下图是混合检索的流程:
BM25
众所周知,混合检索主要通过关键词匹配来确定可能的答案,接着结合语义匹配以进一步提升答案的精确度。BM25就是其中一种常见的关键词搜索技术。
BM25就像一个智能的匹配工具,在我们使用搜索系统时,它能帮助我们找到最相关的信息。BM25如何做到这一点呢?它主要看两个方面:首先,它会检查我们的查询词在某份文档中出现了多少次;其次,它还会看这个词在所有文档中出现的频率。如果一个词在特定文档中经常出现,但在其他地方很少见,那么这个词对这篇文档来说就非常重要,BM25会认为这篇文档与我们的查询非常匹配。此外,BM25模型对于长文档和短文档有一个平衡处理,防止因文档长度不同,而导致的词频偏差。
让我们通过一个搜索引擎的简单例子来揭示BM25的运作原理。假设你在一个搜索引擎中输入了"香蕉面包",并且有三篇文章与这个查询相关:
- 文章A标题为 "如何制作香蕉面包",全文1000字,并且"香蕉面包"一词在其中出现10次。
- 文章B标题为 "面包的种类",全文500字,在其中"香蕉面包"一词出现5次。
- 文章C标题为 "水果和烘焙",全文2000字,其中"香蕉面包"一词出现10次。
首先,BM25会考虑查询词(即"香蕉面包")在每篇文章中出现的频率。在文章A和C中,"香蕉面包"都出现了10次,而在文章B中出现了5次。因此,就频率而言,文章A和C可能比文章B更相关。
然后,BM25会考虑文档的长度。尽管文章A和C中"香蕉面包"的出现次数相同,但文章A只有1000字,而文章C有2000字。这意味着,在文章A中,"香蕉面包"占据了更大比例,因此可能更相关。
因此,综合考虑以上因素,搜索引擎可能会认为文章A是对"香蕉面包"这个查询最相关的结果,其次是文章B,再次是文章C。
langchain中封装了BM25对应的retriever的实现,我们来看下:
from langchain.retrievers.bm25 import BM25Retriever
doc_list_1 = [
"I like apples",
"I like oranges",
"Apples and oranges are fruits",
]
# initialize the bm25 retriever and chroma retriever
bm25_retriever = BM25Retriever.from_texts(
doc_list_1, metadatas=[{"source": 1}] * len(doc_list_1)
)
bm25_retriever.k = 2
# [Document(page_cnotallow='I like apples', metadata={'source': 1}), Document(page_cnotallow='Apples and oranges are fruits', metadata={'source': 1})]
print(bm25_retriever.invoke("apples"))
# [Document(page_cnotallow='Apples and oranges are fruits', metadata={'source': 1}), Document(page_cnotallow='I like oranges', metadata={'source': 1})]
print(bm25_retriever.invoke("apple"))
上述代码演示了我们在查询"apples"时,BM25根据词频能成功返回包含"apples"的两句话。但是,当我们将查询改为"apple"时,意外地得到了'I like oranges'这一结果。看起来BM25对单词的单复数形式十分"敏感",这也暴露出BM25的一个限制。而同样是基于词频的elasticsearch却能有效改善这一问题。在使用elasticsearch替换bm25之前,我们先凑合着用bm25看下在langchain中如何做混合检索。
from langchain.retrievers import EnsembleRetriever
from langchain.retrievers.bm25 import BM25Retriever
from langchain_community.vectorstores import Chroma
from langchain_openai import OpenAIEmbeddings
doc_list_1 = [
"I like apples",
"I like oranges",
"Apples and oranges are fruits",
]
# initialize the bm25 retriever and chroma retriever
bm25_retriever = BM25Retriever.from_texts(
doc_list_1, metadatas=[{"source": 1}] * len(doc_list_1)
)
bm25_retriever.k = 2
embedding = OpenAIEmbeddings()
chroma_vectorstore = Chroma.from_texts(
doc_list_1, embedding, metadatas=[{"source": 2}] * len(doc_list_1)
)
chroma_retriever = chroma_vectorstore.as_retriever(search_kwargs={"k": 2})
# initialize the ensemble retriever
ensemble_retriever = EnsembleRetriever(
retrievers=[bm25_retriever, chroma_retriever], weights=[0.5, 0.5]
)
在上面的代码中,我们使用了基于关键词搜索的BM25 retriever和基于向量检索的chroma retriever, 然后使用了一个EnsembleRetriever对这两个retriever搜索到的结果使用RFF算法进行整合,整合后我们先来看看查询"apples"的结果:
# [Document(page_cnotallow='I like apples', metadata={'source': 1}), Document(page_cnotallow='Apples and oranges are fruits', metadata={'source': 1})]
print(ensemble_retriever.invoke("apples"))
我们再来搜索一下"apple", 结果如下:
# [Document(page_cnotallow='Apples and oranges are fruits', metadata={'source': 1}), Document(page_cnotallow='I like oranges', metadata={'source': 1})]
print(bm25_retriever.invoke("apple"))
# [Document(page_cnotallow='I like apples', metadata={'source': 2}), Document(page_cnotallow='Apples and oranges are fruits', metadata={'source': 2})]
print(chroma_retriever.invoke("apple"))
# [Document(page_cnotallow='Apples and oranges are fruits', metadata={'source': 1}), Document(page_cnotallow='I like apples', metadata={'source': 2}), Document(page_cnotallow='I like oranges', metadata={'source': 1})]
print(ensemble_retriever.invoke("apple"))
可以看出,基于语义的搜索对单复数形式并不敏感,'I like apples'仍然被检索到。整合后的EnsembleRetriever返回了更优质的结果,前两个都是与"苹果"相关的文档。这也证明了,我们通过使用向量检索成功地弥补了BM25这种关键词搜索可能存在的短板。
RRF算法
RFF 是一种数据融合方法,常用于元搜索。元搜索是将来自多个不同源或检索系统的搜索结果进行融合的过程。RRF通过考虑每个列表中项目的排名和各个列表本身的可靠性进行结果合并。
RRF算法的公式如下:
RRF(S) = Σ(1 / (60 + rank))
其中,S是待融合的集合,rank表示每一个条目在其列表中的排名。
以一个具体例子说明:
假设我们有三个搜索系统A、B、C,它们分别产生以下排名:
- 系统A:[物品1, 物品2, 物品3]
- 系统B:[物品2, 物品1, 物品3]
- 系统C:[物品3, 物品1, 物品2]
针对物品1,它在系统A、B、C中的排名分别为1、2、2。其在RRF算法中的得分为:
1/(60+1) + 1/(60+2) + 1/(60+2) = 0.016129 + 0.016393 + 0.016393 = 0.048915
同样地,我们可以计算物品2和物品3的RRF分数。然后比较这些得分,得分最高的物品就被看作是综合最优的选择。
下面是EnsembleRetriever中对数据融合的RRF算法核心实现:
rrf_score: Dict[str, float] = defaultdict(float)
for doc_list, weight in zip(doc_lists, self.weights):
for rank, doc in enumerate(doc_list, start=1):
# self.c = 60
rrf_score[doc.page_content] += weight / (rank + self.c)
使用elasticsearch替换BM25模型
我们先通过下面的docker-compose.yml安装elasticsearch
version: "2.3"
services:
elasticsearch:
image: elasticsearch:8.9.0
container_name: elasticsearch
environment:
- discovery.type=single-node
- ES_JAVA_OPTS=-Xms1g -Xmx1g
- xpack.security.enabled=false
volumes:
- ./es_data:/usr/share/elasticsearch/data
- ./plugins:/usr/share/elasticsearch/plugins
ports:
- 9200:9200
networks:
- elastic
kibana:
image: kibana:8.6.0
container_name: kibana
ports:
- 5601:5601
depends_on:
- elasticsearch
networks:
- elastic
networks:
elastic:
name: elastic
driver: bridge
这里我们只是做演示示例,所以安装比较简单,没有安装证书和映射分词器等等,elasticsearch安装完之后,我们就可以往里面插入数据并进行查询了。在插入数据之前,我们先建好index, 这个index可以理解成向量库的collection、mysql的table:
curl -X PUT "localhost:9200/my_index" -H 'Content-Type: application/json' -d'
{
"settings": {
"analysis": {
"analyzer": {
"english": {
"tokenizer": "standard",
"filter": ["lowercase", "english_stemmer"]
}
},
"filter": {
"english_stemmer": {
"type": "stemmer",
"language": "english"
}
}
}
},
"mappings": {
"properties": {
"text": {
"type": "text",
"analyzer": "english"
}
}
}
}
'
索引创建好之后,我们可以开始插入数据。下面是一个 Python 示例,展示了如何使用 langchain_elasticsearch
模块向 Elasticsearch 插入数据,并进行查询:
from langchain_elasticsearch import ElasticsearchStore, BM25Strategy
doc_list_1 = [
"I like apples",
"You like Apples",
"I like oranges",
"Apples and oranges are fruits",
]
db = ElasticsearchStore(
es_url="http://localhost:9200",
index_name="my_index",
strategy=BM25Strategy(),
)
# initialize the bm25 retriever and chroma retriever
db.add_texts(doc_list_1, metadatas=[{"source": 1}] * len(doc_list_1))
elasticsearch_retriever = db.as_retriever(search_kwargs={"k": 4})
# [Document(metadata={'source': 1}, page_cnotallow='I like apples'), Document(metadata={'source': 1}, page_cnotallow='You like Apples'), Document(metadata={'source': 1}, page_cnotallow='Apples and oranges are fruits')]
print(elasticsearch_retriever.invoke("apple"))
你会看到,当我们查询 "apple" 时,Elasticsearch 能够返回所有包含 "apple" 的文档。这展示了 Elasticsearch 相比单独使用 BM25 算法的优势。此外,elasticsearch还支持模糊匹配和自动补全功能,即使用户输入有误,也能提供相关的搜索结果,感兴趣的可以自行去研究,这里我们不做重点讲解。
现在我们可以使用elasticsearch和chromadb来做混合检索了,具体代码如下:
from langchain_community.vectorstores import Chroma
from langchain.retrievers import EnsembleRetriever
from langchain_openai import OpenAIEmbeddings
from langchain_elasticsearch import ElasticsearchStore, BM25Strategy
doc_list_1 = [
"I like apples",
"You like Apples",
"I like oranges",
"Apples and oranges are fruits",
]
db = ElasticsearchStore(
es_url="http://localhost:9200",
index_name="my_index",
strategy=BM25Strategy(),
)
db.add_texts(doc_list_1, metadatas=[{"source": 1}] * len(doc_list_1))
elasticsearch_retriever = db.as_retriever(search_kwargs={"k": 4})
# elasticsearch_retriever: [Document(page_cnotallow='I like apples', metadata={'source': 1}), Document(page_cnotallow='You like Apples', metadata={'source': 1}), Document(page_cnotallow='Apples and oranges are fruits', metadata={'source': 1})]
print("elasticsearch_retriever: ", elasticsearch_retriever.invoke("apple"))
# initialize the bm25 retriever and chroma retriever
embedding = OpenAIEmbeddings()
chroma_vectorstore = Chroma.from_texts(
doc_list_1, embedding, metadatas=[{"source": 2}] * len(doc_list_1)
)
chroma_retriever = chroma_vectorstore.as_retriever(search_kwargs={"k": 4})
# chroma_retriever: [Document(page_cnotallow='I like apples', metadata={'source': 2}), Document(page_cnotallow='You like Apples', metadata={'source': 2}), Document(page_cnotallow='Apples and oranges are fruits', metadata={'source': 2}), Document(page_cnotallow='I like oranges', metadata={'source': 2})]
print("chroma_retriever: ", chroma_retriever.invoke("apple"))
# initialize the ensemble retriever
ensemble_retriever = EnsembleRetriever(
retrievers=[elasticsearch_retriever, chroma_retriever], weights=[0.5, 0.5]
)
# ensemble_retriever: [Document(page_cnotallow='I like apples', metadata={'source': 1}), Document(page_cnotallow='You like Apples', metadata={'source': 1}), Document(page_cnotallow='Apples and oranges are fruits', metadata={'source': 1}), Document(page_cnotallow='I like oranges', metadata={'source': 2})]
print("ensemble_retriever: ", ensemble_retriever.invoke("apple"))
实际上,Elasticsearch已经提供了混合检索功能。它允许在存储文档时同时存储对应的文本向量,在进行搜索时只需指定为混合搜索模式即可启用这项功能。但需注意,该功能属于付费服务。以下是相关示例代码:
from langchain_openai import OpenAIEmbeddings
from langchain_elasticsearch import ElasticsearchStore, BM25Strategy, DenseVectorStrategy
doc_list_1 = [
"I like apples",
"You like Apples",
"I like oranges",
"Apples and oranges are fruits",
]
db = ElasticsearchStore(
es_url="http://localhost:9200",
index_name="my_index_1",
embedding = OpenAIEmbeddings(),
strategy=DenseVectorStrategy(hybrid=True),
)
db.add_texts(doc_list_1, metadatas=[{"source": 1}] * len(doc_list_1))
elasticsearch_retriever = db.as_retriever(search_kwargs={"k": 4})
print("elasticsearch_retriever: ", elasticsearch_retriever.invoke("apple"))
在这个例子中,我们设置了DenseVectorStrategy策略并开启了混合检索模式(hybrid=True)。然而,此时我们遇到了一个报错:
elasticsearch.AuthorizationException: AuthorizationException(403, 'security_exception', 'current license is non-compliant for [Reciprocal Rank Fusion (RRF)]')
这个错误表示当前的许可证并不包含“Reciprocal Rank Fusion (RRF)”功能,即Elasticsearch的混合检索功能。如果我们只设置hybrid=False,那么就会使用Elasticsearch的基础向量检索功能,而不涉及任何付费服务。
本文转载自公众号AI 博物院 作者:longyunfeigu