RAG-解释-理解嵌入-相似性和检索
RAG 解释:理解嵌入、相似性和检索
原文:
towardsdatascience.com/rag-explained-understanding-embeddings-similarity-and-retrieval/

图片由作者提供
因此,到目前为止,我们已经讨论了从存储位置读取文档,将它们分割成文本块,然后为每个块创建嵌入。之后,我们以某种神奇的方式选择适合用户查询的嵌入,并生成相关响应。但了解 RAG 的检索步骤实际上是如何工作的同样重要。
因此,在这篇文章中,我们将更进一步,更仔细地看看检索机制是如何工作的,并对其进行更详细的分析。正如我之前的文章一样,我将使用《战争与和平》文本作为例子,该文本作为公共领域作品发布,并通过古腾堡计划轻松获取。
那嵌入呢?
为了理解 RAG 框架的检索步骤是如何工作的,首先理解文本是如何转换为嵌入形式和表示的是至关重要的。为了 LLM 处理任何文本,它必须以向量的形式存在,而为了执行这种转换,我们需要利用嵌入模型。
嵌入是数据的向量表示(在我们的案例中是文本),它捕捉了其语义意义。原始文本中的每个单词或句子都被映射到一个高维向量。用于执行这种转换的嵌入模型被设计成这样的方式,即相似的意义会导致在向量空间中彼此靠近的向量。例如,快乐和喜悦这两个词的向量在向量空间中会彼此靠近,而悲伤这个词的向量则会远离它们。
要创建在 RAG 管道中有效的高质量嵌入,需要利用预训练的嵌入模型,如 OpenAI 的嵌入模型。可以创建各种类型的嵌入,并相应地提供模型。例如:
-
词嵌入:在词嵌入中,每个单词都有一个固定的向量,无论上下文如何。用于创建此类嵌入的流行模型包括 Word2Vec 和 GloVe。
-
上下文嵌入:上下文嵌入考虑到了一个单词的意义可能会根据上下文而改变。以 the bank of a river(河流的河岸)和 opening a bank account(开设银行账户)为例。可用于生成上下文嵌入的一些模型包括 BERT 和 OpenAI 的嵌入模型(如
<a href="https://platform.openai.com/docs/models/text-embedding-ada-002" data-type="link" data-id="https://platform.openai.com/docs/models/text-embedding-ada-002">text-embedding-ada-002</a>)。 -
句子嵌入:这些嵌入捕捉了整个句子的意义。用于创建句子嵌入的流行嵌入模型是 Sentence-BERT。
在任何情况下,文本必须被转换成向量以便在计算中使用。这些向量仅仅是文本的表示。换句话说,这些向量和数字本身并没有任何固有的意义。相反,它们之所以有用,是因为它们以数学形式捕捉了单词或短语之间的相似性和关系。
例如,我们可以想象一个由单词 king(国王)、queen(王后)、woman(女人)和 man(男人)组成的微小词汇表,并为每个单词分配一个任意的向量。
king = [0.25, 0.75]
queen = [0.23, 0.77]
man = [0.15, 0.80]
woman = [0.13, 0.82]
然后,我们可以尝试进行一些向量操作,例如:
king - man + woman
= [0.25, 0.75] - [0.15, 0.80] + [0.13, 0.82]
= [0.23, 0.77]
≈ queen 👑
注意,在将它们映射到向量之后,单词的语义和它们之间的关系是如何被保留的,这使得我们可以执行操作。
因此,嵌入就是那样——将单词映射到向量的过程,旨在保留单词之间的意义和关系,并允许对其进行计算。我们甚至可以在向量空间中可视化这些虚拟向量,以查看相关单词是如何聚集在一起的。

图片由作者提供
这些简单的向量示例与嵌入模型产生的实际向量之间的区别在于,实际的嵌入模型生成的向量具有数百个维度。二维向量对于建立关于意义如何映射到向量空间的理解是有用的,但它们维度太低,无法捕捉真实语言和词汇的复杂性。这就是为什么实际的嵌入模型通常使用更高的维度,通常是数百甚至数千。例如,Word2Vec生成 300 维度的向量,而BERT Base生成 768 维度的向量。这种更高的维度性使得嵌入能够捕捉真实语言的多个维度,如意义、用法、句法和词语及短语的上下文。最终,这个二维简化的示例使我们能够对嵌入是什么建立一些直观的认识——尽管如此,它相当简单,并不一定是你应该期望在真实模型中复制的。
评估嵌入的相似度
文本转换为嵌入后,推理变为向量数学。这正是我们能够在 RAG 框架的检索步骤中识别和检索相关文档的原因。一旦我们使用嵌入模型将用户的查询和知识库文档转换为向量,我们就可以使用适当的度量,如余弦相似度、欧几里得距离(L2 距离)或点积来计算它们之间的相似度。
余弦相似度是衡量两个向量(嵌入)相似度的度量。给定两个向量 A 和 B,余弦相似度的计算如下:

图片由作者提供
简而言之,余弦相似度是两个向量之间角度的余弦值,其范围从 1 到-1。更具体地说:
-
1 表示向量在语义上是相同的(例如,car和automobile)。
-
0 表示向量之间没有语义关系(例如,banana和justice)。
-
-1 表示向量完全相反,但在实践中,嵌入不会产生负相似度,即使是像hot和cold这样的反义词也不例外。
这是因为即使语义上相反的词(如hot和cold)也经常出现在相似的环境中(例如,it’s getting hot和it’s getting cold)。为了使余弦相似度达到-1,单词本身及其上下文都需要完全相反——这在自然语言中并不常见。因此,即使是反义词,它们的嵌入在意义上通常仍然相对接近。在实践中,相似度分数通常是正的。
除了余弦相似度之外,其他相似度度量还包括点积(内积)和欧几里得距离(L2 距离)。与余弦相似度不同,点积和欧几里得距离是大小依赖的,这意味着向量的长度会影响结果。为了将点积作为与余弦相似度等效的相似度度量,我们必须首先将向量归一化到单位长度。这是因为余弦相似度在数学上等于两个归一化向量的点积。因此,与余弦相似度类似,越相似的向量将会有更大的点积。
另一方面,欧几里得距离衡量的是嵌入空间中两个向量之间的直线距离。在这种情况下,越相似的向量将会有更小的欧几里得距离。
回到我们的 RAG 管道,通过计算用户查询嵌入与知识库嵌入之间的相似度分数,我们可以识别出与用户问题最相似——因此上下文相关的——文本块,检索它们,然后使用它们来生成答案。
寻找最相似的 k 个块
因此,在获取知识库的嵌入和用户查询文本的嵌入(s)之后,这里就发生了魔法。我们本质上所做的是计算用户查询嵌入与知识库嵌入之间的余弦相似度。因此,对于知识库的每个文本块,我们得到一个介于 1 和-1 之间的分数,表示该块与用户查询的相似度。
一旦我们有了相似度分数,我们将它们按降序排序,并选择前 k 个块。然后,这些前 k 个块被传递到 RAG 管道的生成步骤,使其能够有效地为用户的查询检索相关信息。
为了加快这一过程,通常使用近似最近邻搜索(ANN)。ANN 找到几乎最相似的向量,提供接近真实 top-N 的结果,但比精确搜索方法快得多。当然,精确搜索更准确;然而,它也更耗费计算资源,并且在现实世界的应用中可能无法很好地扩展,尤其是在处理大规模数据集时。
此外,还可以将阈值应用于相似度分数,以过滤掉不符合最低相关度分数的块。例如,在某些情况下,一个块可能只有在其相似度分数超过某个阈值(例如,余弦相似度> 0.3)时才被认为是有用的。
那么,安娜·帕夫洛夫娜是谁呢?
在“战争与和平”示例中,如我在之前的帖子中所示,我们将整个文本分割成块,并为每个块创建相应的嵌入。然后,当用户提交查询,例如“安娜·帕夫洛夫娜是谁?”时,我们也为用户的查询文本创建相应的嵌入。
import os
from langchain.chat_models import ChatOpenAI
from langchain.document_loaders import TextLoader
from langchain.embeddings import OpenAIEmbeddings
from langchain.vectorstores import FAISS
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain.docstore.document import Document
api_key = 'your_api_key'
# initialize LLM
llm = ChatOpenAI(openai_api_key=api_key, model="gpt-4o-mini", temperature=0.3)
# initialize embeddings model
embeddings = OpenAIEmbeddings(openai_api_key=api_key)
# loading documents to be used for RAG
text_folder = "RAG files"
documents = []
for filename in os.listdir(text_folder):
if filename.lower().endswith(".txt"):
file_path = os.path.join(text_folder, filename)
loader = TextLoader(file_path)
documents.extend(loader.load())
splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=100)
split_docs = []
for doc in documents:
chunks = splitter.split_text(doc.page_content)
for chunk in chunks:
split_docs.append(Document(page_content=chunk))
documents = split_docs
# create vector database w FAISS
vector_store = FAISS.from_documents(documents, embeddings)
retriever = vector_store.as_retriever()
def main():
print("Welcome to the RAG Assistant. Type 'exit' to quit.\n")
while True:
user_input = input("You: ").strip()
if user_input.lower() == "exit":
print("Exiting…")
break
# get relevant documents
relevant_docs = retriever.invoke(user_input)
retrieved_context = "\n\n".join([doc.page_content for doc in relevant_docs])
# system prompt
system_prompt = (
"You are a helpful assistant. "
"Use ONLY the following knowledge base context to answer the user. "
"If the answer is not in the context, say you don't know.\n\n"
f"Context:\n{retrieved_context}"
)
# messages for LLM
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_input}
]
# generate response
response = llm.invoke(messages)
assistant_message = response.content.strip()
print(f"\nAssistant: {assistant_message}\n")
if __name__ == "__main__":
main()
在这个脚本中,我使用了 LangChain 的检索对象retriever = vector_store.as_retriever(),它默认使用底层 FAISS 索引的相似度度量。FAISS 提供了两个索引:
-
IndexFlatL2使用 L2 距离。当使用 LangChain 与 FAISS(就像我们这样做)时,默认索引通常是IndexFlatL2 -
IndexFlatIP使用点积(内积)
因此,在初始脚本中,块是通过 L2 距离作为度量标准检索的。此脚本默认检索 k=4 个最相似的块。换句话说,我们在那里所做的是根据 L2 距离检索与用户查询最相关的前 k 个块。
因此,为了将余弦相似度作为检索度量标准而不是默认的 L2,我们需要对我们的初始代码进行一点调整。特别是,我们需要对嵌入进行归一化(用户的查询嵌入和知识库的嵌入),并配置向量存储以使用点积(内积)作为相似度度量而不是 L2 距离。为了对知识库的嵌入进行归一化,我们可以在分块步骤之后添加以下部分:
...
documents = split_docs
# normalize knowledge base embeddings
import numpy as np
def normalize(vectors):
vectors = np.array(vectors)
norms = np.linalg.norm(vectors, axis=1, keepdims=True)
return vectors / norms
doc_texts = [doc.page_content for doc in documents]
doc_embeddings = embeddings.embed_documents(doc_texts)
doc_embeddings = normalize(doc_embeddings)
# faiss index with inner product
import faiss
dimension = doc_embeddings.shape[1]
index = faiss.IndexFlatIP(dimension) # inner product index
index.add(doc_embeddings)
# create vector database w FAISS
vector_store = FAISS(embedding_function=embeddings, index=index, docstore=None, index_to_docstore_id=None)
vector_store.docstore = {i: doc for i, doc in enumerate(documents)}
retriever = vector_store.as_retriever()
...
由于我们正在手动进行所有操作,因此现在可以省略retriever = vector_store.as_retriever()。我们还需要将以下部分添加到我们的main()函数中,以便对用户的查询进行归一化:
...
if user_input.lower() == "exit":
print("Exiting…")
break
# embedding + normalize query
query_embedding = embeddings.embed_query(user_input)
query_embedding = normalize([query_embedding])
# search FAISS index
D, I = index.search(query_embedding, k=2)
# get relevant documents
relevant_docs = [vector_store.docstore[i] for i in I[0]]
retrieved_context = "\n\n".join([doc.page_content for doc in relevant_docs])
...
注意我们现在可以显式地定义检索的块数 k,现在设置为 k=2。
此外,为了打印余弦相似度,我将在main()函数中添加以下部分:
...
retrieved_context = "\n\n".join([doc.page_content for doc in relevant_docs])
# D contains inner product scores == cosine similarities (since normalized)
print("\nTop 5 chunks and their cosine similarity scores:\n")
for rank, (idx, score) in enumerate(zip(I[0], D[0]), start=1):
print(f"Chunk {rank}:")
print(f"Cosine similarity: {score:.4f}")
print(f"Content:\n{vector_store.docstore[idx].page_content}\n{'-'*40}")
...
最后,我们再次提出问题并接收答案:

作者提供的图片
…但现在我们也能看到创建此答案的文本块及其相应的余弦相似度分数…

作者提供的图片
显然,不同的参数会导致不同的答案。例如,当我们检索 k=2、k=4 和 k=10 的结果时,我们会得到略微不同的答案。考虑到在分块步骤中使用的附加参数,如块大小和块重叠,很明显,参数在从 RAG 管道中获得良好结果中起着至关重要的作用。
• • •
喜欢这篇帖子?让我们成为朋友!加入我:
📰Substack 💌* Medium* 💼LinkedIn ☕请我喝咖啡!
• • •
pialgorithms 怎么样?
想要将 RAG 的力量带入您的组织?
pialgorithms可以为您做到这一切 👉 预约演示 今天!

浙公网安备 33010602011771号