? ? ? ?在RAG項目中,大模型生成的參考內容(專業術語稱為塊)來自前一步的檢索,檢索的內容在很大程度上直接決定了生成的效果,因此檢索對于RAG項目至關重要,最常用的檢索方法是關鍵字搜索和語義搜索。本文將分別介紹這兩種搜索策略,然后將它們結合起來進行混合檢索。
一、使用 BM25 進行關鍵字搜索
? ? ? ?BM25 是關鍵字搜索的首選算法。使用 BM25,我們可以為語料庫中每個文檔的查詢獲得分數。
? ? ? ?BM25 基于 TF-IDF 算法,這意味著公式的核心是術語頻率 (TF) 和逆向文檔頻率 (IDF) 的乘積。
? ? ? TF-IDF 算法基于以下理念:“對頻率較低、更具體的術語的匹配比對頻繁術語的匹配更有價值”
? ? ? 換句話說,TF-IDF 算法會查找包含查詢中罕見關鍵字的文檔。
? ? ? ?如果我們看一下 LangChain 源碼(https://api.python.langchain.com/en/latest/_modules/langchain_community/retrievers/bm25.html#BM25Retriever),可以看到它使用了 rank_bm25 包中的 BM25Okapi 類,該類是 ATIRE BM25 算法的略微修改版本。
? ? ?在 ATIRE BM25 版本中,獲得文檔 d 和由多個詞 t 組成的給定查詢 q的分數的公式如下
-
N 是語料庫中的文檔數
-
df_t 是包含術語 t 的文檔數 (也稱為文檔頻率)
-
tf_td 是術語 t 在文檔 d 中出現的次數(也稱為術語頻率)
-
L_d是我們文檔的長度,L_avg是平均文檔長度
-
有兩個經驗調優參數: b 和 k_1
我們看到公式對所有項 t 求和,我們可以將其視為單詞。
? ? ? BM25 方程中的左手因子 log(N/df_t) 稱為逆文檔頻率。對于像 “the” 這樣的常用詞,我們所有的文檔都可能包含,所以逆向文檔頻率將為零(因為 log(1) 為零)。
? ? ?另一方面,非常罕見的單詞只會出現在少數文檔中,從而增加左因子。因此,逆向文檔頻率是衡量術語 t 中包含多少信息的量度。
? ? ? 右因子受術語 t 在文檔 d 中出現的次數的影響。
? ? 該文檔 d=["I like red cats, black cats, white cats, and brown cats"] 對詞 t=“cats” 具有非常高的詞頻tf_td,這將導致包含單詞 “cats” 的查詢獲得較高的 BM25 分數。
? ? ? 讓我們使用 BM25 來使用 Python 庫rank_bm25來獲得一些直覺。
pip?install rank_bm25
? ? ? 首先,我們加載庫并使用我們的標記化語料庫初始化 BM25。
from rank_bm25 import BM25Okapi
corpus = [
? ??"The cat, commonly referred to as the domestic cat or house cat, is a small domesticated carnivorous mammal.", ? ?
? ??"The dog is a domesticated descendant of the wolf.", ? ?
? ??"Humans are the most common and widespread species of primate, and the last surviving species of the genus Homo.", ? ?
? ??"The scientific name Felis catus was proposed by Carl Linnaeus in 1758"]
tokenized_corpus = [doc.split(" ") for doc in corpus]
bm25 = BM25Okapi(tokenized_corpus)
? ? ? 接下來,我們對查詢進行標記化。???????
query?=?"The cat"
tokenized_query?= query.split(" ")
? ? ?最后,我們使用 BM25 算法計算分數。高分表示文檔和查詢之間的匹配良好。???????
doc_scores = bm25.get_scores(tokenized_query)
print(doc_scores) >> [0.92932018 0.21121974 0. 0.1901173]# scores for documents 1, 2, 3, and 4
? ? ? ?由于 BM25 查找完全匹配的術語,因此查詢術語“cats”、“Cat”或“feline”都將導致三個示例文檔的分數為 doc_scores = [0,0,0]。
二、使用密集嵌入的語義搜索
? ? ? ?當我們通過密集嵌入執行語義搜索時,我們會將單詞轉換為數字表示。其理念是,在這種新的數學表示形式中,相似的單詞緊密相連。
? ? ? ?文本嵌入是單個單詞或整個句子的高維向量。它們稱為 dense,因為向量中的每個條目都是一個有意義的數字。相反,當許多 vector 條目只是為零時,稱為 sparse。
? ? ? ?在將單詞轉換為嵌入之前,首先通過稱為編碼器的神經網絡嵌入模型將token轉換為嵌入向量。
? ? ? ?在將文檔語料庫中的所有文本轉換為嵌入后,可以執行語義搜索以查看哪個嵌入文檔最接近我們的嵌入查詢。
? ? ? ?我們可以通過繪制嵌入維度并找到與我們的查詢最匹配的文檔來可視化此任務。
? ? ? ?在數學上,我們使用余弦距離函數找到最接近的匹配項。對于兩個嵌入向量 a 和 b,我們可以使用點積計算余弦相似度,如下所示:
? ? ? ?其中分子是兩個嵌入向量的點積,分母是它們量級的乘積。
? ? ? ?在幾何學上,余弦相似度是向量之間的角度。余弦相似性分數范圍為 -1 到 +1。
? ? ? ?余弦相似度分數 -1 表示嵌入 a 和 b 正好朝向相反的方向,0 表示它們的角度為 90 度(它們無關),+1 表示它們相同。 因此,在將搜索查詢與文檔匹配時,我們會尋找接近 +1 的值。
? ? ? ? 如果我們事先對嵌入進行歸一化,則余弦相似度測度將等效于點積相似度測度(分母變為 1)。
? ? ? ? 下面讓我們使用 Python 包 sentence-transformers 來計算一下基本的語義搜索。
pip?install sentence-transformers
? ? ?首先,從 HuggingFace 下載全 MiniLM-L6-v2 編碼器模型,可生成 384 維密集嵌入。???????
from?sentence_transformers?import?SentenceTransformer
# 1. Load a pretrained Sentence Transformer model
model = SentenceTransformer('sentence-transformers/all-MiniLM-L6-v2')
? ? ? 然后,我們使用與以前相同的文檔語料庫。???????
# The documents to encode
corpus = [
? ??"The cat, commonly referred to as the domestic cat or house cat, is a small domesticated carnivorous mammal.", ? ?
? ??"The dog is a domesticated descendant of the wolf.", ? ?
? ??"Humans are the most common and widespread species of primate, and the last surviving species of the genus Homo.", ? ?
? ??"The scientific name Felis catus was proposed by Carl Linnaeus in 1758"]
? ??
# Calculate embeddings by calling model.encode()
document_embeddings = model.encode(corpus)
? ??
# Sanity check
print(document_embeddings.shape)
>> (4, 384)
? ? ? ?對查詢進行嵌入:???????
query?=?"The cat"
query_embedding?= model.encode(query)
? ? ? 最后,計算余弦相似度分數。可以使用 sentence_transformers 中的 utility 函數 cos_sim,而不是自己編寫公式。???????
from sentence_transformers.util import cos_sim
# Compute cosine_similarity between documents and query
scores = cos_sim(document_embeddings, query_embedding)
print(scores)
>>?tensor([[0.5716], ?# score for document 1
>>? ? ? ? ?[0.2904], ?# score for document 2
>>? ? ? ? ?[0.0942], ?# score for document 3
>>? ? ? ? ?[0.3157]])?# score for document 4
? ? ? ?為了了解使用密集嵌入的語義搜索的強大功能,我可以使用查詢 “feline” 重新運行代碼:???????
query_embedding = model.encode("feline")
scores = cos_sim(document_embeddings, query_embedding)
print(scores)
>> tensor([[0.4007],
>> ? ? ? ? [0.3837],
>> ? ? ? ? [0.0966],
>> ? ? ? ? [0.3804]])
? ? ? ?即使 “feline” 一詞沒有出現在文檔語料庫中,語義搜索仍然將有關貓的文本列為最高匹配度。
三、語義搜索還是關鍵字搜索?
? ? ? ?哪種搜索方法更好?這要看情況。兩者都有優點和缺點。現在我們知道了兩者的工作原理,我們可以看到它們在哪些方面有用,哪些方面可能失敗。
? ? ? ?使用 BM25 進行關鍵字搜索會查找查詢詞的完全匹配項。當我們在尋找短語的精確匹配時,這可能非常有用。
? ? ? ?如果我在找《帽子里的貓》(The Cat in the Hat),我可能在找這本書/電影。而且我不希望出現語義上相似的結果,這些結果接近 hats 或 cats。
? ? ? ?關鍵字搜索的另一個用例是編程。如果我正在尋找特定的函數或代碼段,我想要一個完全匹配。
? ? ? ?另一方面,語義搜索會查找語義相似的內容。這意味著語義搜索還會查找具有同義詞或不同拼寫(如復數、大寫等)的文檔。
? ? ? ?由于這兩種算法都有其用例,因此混合搜索同時使用這兩種算法,然后將它們的結果合并為一個最終排名。
? ? ? ?混合搜索的缺點是它比只運行一種算法需要更多的計算資源。
四、混合搜索
? ? ? ?我們可以使用倒數秩融合 (RRF) 將 BM25 和余弦相似性的結果結合起來。RRF 是一種簡單的算法,用于組合不同評分函數的排名 [4]。
? ? ? ?首先,我們需要獲取每種評分算法的文檔排名。在我們的示例中,這將是:???????
corpus?= [
? ??"The cat, commonly referred to as the domestic cat or house cat, is a small domesticated carnivorous mammal.", ? ?
? ??"The dog is a domesticated descendant of the wolf.", ? ?
? ??"Humans are the most common and widespread species of primate, and the last surviving species of the genus Homo.", ? ?
? ??"The scientific name Felis catus was proposed by Carl Linnaeus in 1758",]
? ??
query?=?"The cat"
bm25_ranking?= [1,?2,?4,?3]?# scores = [0.92932018 0.21121974 0. 0.1901173]
cosine_ranking?= [1,?3,?4,?2]?# scores = [0.5716, 0.2904, 0.0942, 0.3157]
? ? ? ?每個文檔 d 的綜合 RRF 分數公式如下:
? ? ? ?其中 k 是一個參數(原始論文使用 k=60),r(d) 是 BM25 和余弦相似度的排名。
? ? ? ?現在,我們可以通過分別進行 BM25 和余弦相似性,然后將結果與 RRF 相結合來實現我們的混合搜索。
? ? ? ?首先,讓我們定義 RRF 的函數和將浮點分數轉換為 int 排名的輔助函數。???????
import?numpy?as?np
def?scores_to_ranking(scores:?list[float]) ->?list[int]:
? ??"""Convert float scores into int rankings (rank 1 is the best)"""? ??
? ??return?np.argsort(scores)[::-1] +?1
? ??
def?rrf(keyword_rank:?int, semantic_rank:?int) ->?float:
? ??"""Combine keyword rank and semantic rank into a hybrid score."""? ??
? ? k =?60? ??
? ? rrf_score =?1?/ (k + keyword_rank) +?1?/ (k + semantic_rank) ? ?
? ??return?rrf_score
? ? ? ? 這是我使用上述概念的簡單混合搜索實現。???????
from?rank_bm25?import?BM25Okapi
from?sentence_transformers?import?SentenceTransformer
from?sentence_transformers.util?import?cos_sim
model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
def?hybrid_search(
? ? query:?str, corpus:?list[str], encoder_model: SentenceTransformer
? ? ) ->?list[int]:
? ??# bm25 ? ?
? ? tokenized_corpus = [doc.split(" ")?for?doc?in?corpus] ? ?
? ? tokenized_query = query.split(" ") ? ?
? ? bm25 = BM25Okapi(tokenized_corpus) ? ?
? ? bm25_scores = bm25.get_scores(tokenized_query) ? ?
? ? bm25_ranking = scores_to_ranking(bm25_scores) ? ?
? ??
? ??# embeddings ? ?
? ? document_embeddings = model.encode(corpus) ? ?
? ? query_embedding = model.encode(query) ? ?
? ? cos_sim_scores = cos_sim(document_embeddings, query_embedding).flatten().tolist() ? ?
? ? cos_sim_ranking = scores_to_ranking(cos_sim_scores) ? ?
? ??
? ??# combine rankings into RRF scores ? ?
? ? hybrid_scores = [] ? ?
? ??for?i, doc?in?enumerate(corpus): ? ? ? ?
? ? ? ? document_ranking = rrf(bm25_ranking[i], cos_sim_ranking[i]) ? ? ? ?
? ? ? ??print(f"Document?{i}?has the rrf score?{document_ranking}") ? ? ? ?
? ? ? ? hybrid_scores.append(document_ranking) ? ?
? ? ? ??
? ??# convert RRF scores into final rankings ? ?
? ? hybrid_ranking = scores_to_ranking(hybrid_scores) ? ?
? ??return?hybrid_ranking
現在我們可以將 hybrid_search 用于不同的查詢。???????
hybrid_ranking = hybrid_search(
? ? query="What is the scientifc name for cats?", corpus=corpus, encoder_model=model
? ? )
print(hybrid_ranking)
>> Document 0 has the rrf score 0.03125
>> Document 1 has the rrf score 0.032266458495966696
>> Document 2 has the rrf score 0.03225806451612903
>> Document 3 has the rrf score 0.032266458495966696
>> [4 2 3 1]