本章内容包括:
- 嵌入(embeddings)、嵌入模型、向量空间及向量相似度搜索简介
- 向量相似度搜索在增强检索生成(RAG)应用中的作用
- 使用向量相似度搜索构建RAG应用的实操演示
- 为RAG应用添加全文搜索,展示混合搜索方法如何提升结果效果
构建知识图谱通常是一个迭代过程,通常从无结构数据开始,逐步向其中加入结构信息。当你拥有大量无结构数据并希望利用它们回答问题时,这种方式尤为常见。
本章将探讨如何利用RAG技术结合无结构数据进行问答。我们会学习如何使用向量相似度搜索和混合搜索找到相关信息,并利用这些信息生成答案。在后续章节中,我们还将介绍如何优化检索器和生成器,以在数据具有一定结构时取得更好效果。
在数据科学和机器学习领域,嵌入模型和向量相似度搜索是处理复杂数据的重要工具。本章介绍这些技术如何将诸如文本和图像等复杂数据转换为统一格式的嵌入向量。
本章将讲解嵌入模型和向量相似度搜索的基础知识,解释它们的作用、使用方法,以及它们在RAG应用中帮助解决的挑战。
为了跟随实践内容,你需要准备一个空白的Neo4j实例,可以是本地安装或云端托管,只需确保实例为空。你可以直接参考本章配套的Jupyter笔记本实现,地址如下:github.com/tomasonjo/k…。
2.1 RAG架构组件
在RAG应用中,主要有两个组件:检索器(retriever)和生成器(generator)。检索器负责找到相关信息,生成器则利用这些信息生成回答。向量相似度搜索用于检索器中以查找相关信息,后文会详细说明。下面我们具体介绍这两个组件。
2.1.1 检索器
检索器是RAG应用的第一个组件,其目的是找到相关信息并将其传递给生成器。RAG框架本身并未限定检索器如何寻找相关信息,但最常用的方法是使用向量相似度搜索。下面介绍为了让检索器通过向量相似度搜索成功,所需准备的内容。
向量索引(VECTOR INDEX)
虽然向量索引并非进行向量相似度搜索的严格必要条件,但强烈推荐使用。向量索引是一种数据结构(类似映射表),它以方便快速搜索相似向量的方式存储向量。当使用向量索引时,检索方法通常称为“近似最近邻搜索(approximate nearest neighbor search)”。这是因为向量索引不一定找到精确的最近邻,而是找到非常接近的向量。此做法在速度和准确度之间做了权衡:向量索引比暴力搜索快得多,但准确度略低。
向量相似度搜索函数(VECTOR SIMILARITY SEARCH FUNCTION)
向量相似度搜索函数接受一个向量作为输入,返回一组相似的向量。该函数可能利用向量索引实现,也可能用其他(暴力搜索)方法。关键是它能够返回一组相似向量。
两种最常见的向量相似度搜索函数是余弦相似度(cosine similarity)和欧氏距离(Euclidean distance)。欧氏距离表示文本内容和强度,在本书讨论的大多数场景中重要性较低。余弦相似度衡量两个向量间的夹角,对于文本嵌入来说,夹角反映两个文本语义的相似程度。余弦相似度函数输入两个向量,返回0到1之间的数值,0表示完全不同,1表示完全相同。余弦相似度被认为最适合文本聊天机器人,且本书采用此方法。
嵌入模型(EMBEDDING MODEL)
文本经过语义分类后得到的结果称为嵌入(embedding)。任何希望通过向量相似度搜索匹配的文本,都必须转换为嵌入。转换使用嵌入模型完成,且在整个RAG应用中应保持嵌入模型一致。如果更换模型,则必须重新生成向量索引。
嵌入是数字列表,列表长度称为嵌入维度。嵌入维度决定嵌入能包含的信息量。维度越高,计算成本越大,无论是生成嵌入还是进行相似度搜索。
嵌入是将复杂数据表示为简化的低维数字空间的方式,可以理解为将数据转换为计算机易于理解和处理的格式。
嵌入模型提供了统一方式表示不同类型的数据。输入可以是任意复杂数据,输出是向量。例如,处理文本时,嵌入模型将词语或句子转为数字列表,训练目的是让这些数字捕捉原始词语的重要特征,如含义或上下文。
文本切分(TEXT CHUNKING)
文本切分是将文本拆分成更小片段的过程,目的是提升检索器的准确度。较小的文本片段使嵌入更精确具体,从而让检索器能找到更相关的信息。
文本切分非常重要且不易掌握。需要考虑如何拆分:按句子、段落、语义意义还是其他方式?使用滑动窗口还是固定大小?每块多大?
这些问题没有固定答案,取决于具体用例、数据和领域。但必须认真思考并尝试不同方法,以找到最适合自己场景的方案。
检索器流程(RETRIEVER PIPELINE)
当所有组件准备就绪后,检索器流程相当简单。它接受查询,将查询用嵌入模型转换为向量,再通过向量相似度搜索函数查找相似的嵌入。在最简单的情况下,检索器流程直接返回对应的文本片段,再传递给生成器。但大多数情况下,检索器流程需要做一些后处理,从中选出最合适的片段传递给生成器。我们将在下一章介绍更高级的策略。
2.1.2 生成器
生成器是RAG应用中的第二个组件。它使用检索器找到的信息来生成回答。生成器通常是一个大型语言模型(LLM),但相比微调模型或单纯依赖模型的基础知识,RAG的一个优势在于模型不需要那么庞大。这是因为检索器已经找到了相关信息,生成器无需“知道一切”,它只需要知道如何利用检索器提供的信息来生成回答。这个任务远比“知道所有内容”简单得多。
因此,我们利用语言模型的文本生成能力,而非其知识储备。这意味着可以使用更小的语言模型,从而运行更快、成本更低。同时,这也让我们更能信任语言模型会基于检索器找到的信息来生成回答,从而减少虚构和幻觉(hallucination)现象。
2.2 使用向量相似度搜索的RAG
实现基于向量相似度搜索的RAG应用,需要准备若干要素。本章将逐一介绍它们。目标是展示如何用向量相似度搜索实现RAG应用,以及如何利用检索器找到的信息生成回答。图2.1展示了完整RAG应用中的数据流。
我们需要将应用拆分为两个阶段:
- 数据准备阶段
- 查询阶段
我们先从数据准备开始,然后再介绍查询阶段应用的处理流程。
2.2.1 应用数据准备
从前面章节可以看出,我们需要对数据进行一定处理,使其能够映射到嵌入模型的向量空间,以便在运行时执行向量相似度搜索。所需的部分包括:
- 文本语料库
- 文本切分函数
- 嵌入模型
- 支持向量相似度搜索的数据库
接下来我们将逐一介绍这些部分,以及它们如何在应用数据准备中发挥作用。
数据会以文本片段形式存储在数据库中,向量索引则通过这些文本片段的嵌入向量来构建。随后在运行时,当用户提出问题时,问题会使用与文本片段相同的嵌入模型进行嵌入,接着利用向量索引查找相似的文本片段。图2.2展示了应用数据准备阶段的数据流。
2.2.2 文本语料库
本示例中使用的文本是一篇题为《爱因斯坦的专利与发明》(Caudhuri,2017)的论文。尽管大型语言模型(LLM)对阿尔伯特·爱因斯坦非常了解,我们通过提出非常具体的问题,比较从论文和LLM获得的答案,来展示RAG的效果。
2.2.3 文本切分
当LLM拥有足够大的上下文窗口时,我们可以将整篇论文作为一个整体切分块使用。但为了获得更好的效果,我们会将论文拆分成更小的片段,每几百个字符作为一个切分块。最佳切分块大小因具体情况而异,需根据用例、数据和领域进行实验调整。
本例中,我们希望切分块之间存在一定的重叠,以便能够找到跨多个切分块的答案。因此,我们采用滑动窗口方法,窗口大小为500个字符,重叠部分为40个字符。这样会使索引变大一些,但会提升检索器的准确性。
为了帮助嵌入模型更好地理解每个切分块的语义,我们仅在空格处分割,避免切分块开头和结尾出现断词。该函数接收文本、切分块大小(字符数)、重叠大小(字符数)以及是否仅在空白字符处分割的可选参数,返回切分后的文本片段列表。
代码示例 2.1 文本切分函数
def chunk_text(text, chunk_size, overlap, split_on_whitespace_only=True): #1
chunks = []
index = 0
while index < len(text):
if split_on_whitespace_only:
prev_whitespace = 0
left_index = index - overlap
while left_index >= 0:
if text[left_index] == " ":
prev_whitespace = left_index
break
left_index -= 1
next_whitespace = text.find(" ", index + chunk_size)
if next_whitespace == -1:
next_whitespace = len(text)
chunk = text[prev_whitespace:next_whitespace].strip()
chunks.append(chunk)
index = next_whitespace + 1
else:
start = max(0, index - overlap + 1)
end = min(index + chunk_size + overlap, len(text))
chunk = text[start:end].strip()
chunks.append(chunk)
index += chunk_size
return chunks
chunks = chunk_text(text, 500, 40) #2
print(len(chunks)) # 总共89个切分块 #3
注释
#1 定义文本切分函数
#2 调用函数,获取切分块列表
#3 输出切分块列表长度。函数主要逻辑是确保仅在空格处分割,避免单词被截断。
2.2.4 嵌入模型
选择嵌入模型时,需要考虑要匹配的数据类型。本例中我们匹配文本,因此使用文本嵌入模型。本书中我们将使用OpenAI的嵌入模型和大型语言模型,但市面上也有很多替代方案。比如Hugging Face上的Sentence Transformers提供的all-MiniLM-L12-v2(mng.bz/nZZ2)就是OpenAI嵌入模型的优秀替代品,易用且可在本地CPU上运行。
一旦选定嵌入模型,必须确保在整个RAG应用中保持一致,因为向量索引是基于该模型生成的向量填充的。如果更换嵌入模型,则需要重新构建向量索引。下面展示了使用OpenAI嵌入模型对文本切分块进行嵌入的代码示例。
代码示例 2.2 嵌入切分块
def embed(texts): #1
response = open_ai_client.embeddings.create(
input=texts,
model="text-embedding-3-small",
)
return list(map(lambda n: n.embedding, response.data))
embeddings = embed(chunks) #2
print(len(embeddings)) # 89,与切分块数量一致 #3
print(len(embeddings[0])) # 1536维度 #4
注释
#1 定义嵌入函数
#2 调用函数获取嵌入向量列表
#3 输出嵌入向量列表长度
#4 输出第一个嵌入向量的维度长度
2.2.5 具备向量相似度搜索功能的数据库
既然我们已经得到了嵌入向量,就需要将它们存储起来,以便后续进行相似度搜索。在本书中,我们将使用Neo4j作为数据库,因为它内置了向量索引,且使用方便;后续章节我们还会利用Neo4j的图数据库能力。
此阶段采用的数据模型非常简单。我们只用一种节点类型——Chunk,包含两个属性:text 和 embedding。text 属性存储文本片段内容,embedding 属性存储该片段对应的嵌入向量。
图2.3展示了一个简化的数据模型,用于演示如何使用向量相似度搜索实现RAG应用。
首先,我们需要创建一个向量索引。需要注意的是,创建向量索引时必须定义向量的维度数量。如果将来更换了输出维度不同的嵌入模型,就必须重新创建向量索引。
如代码示例2.2所示,我们使用的嵌入模型输出的向量维度为1536,因此创建向量索引时也使用1536维。
代码示例2.3 在Neo4j中创建向量索引
driver.execute_query("""CREATE VECTOR INDEX pdf IF NOT EXISTS
FOR (c:Chunk)
ON c.embedding""")
我们将该向量索引命名为pdf,用于对类型为Chunk的节点的embedding属性进行索引,使用余弦相似度搜索函数。
有了向量索引后,就可以用嵌入向量填充它。我们通过Cypher语句实现,先为每个文本切分块创建节点,然后通过循环为节点设置text和embedding属性。同时,我们为每个:Chunk节点存储一个index属性,便于后续快速定位。
代码示例2.4 在Neo4j中存储文本块并填充向量索引
cypher_query = '''
WITH $chunks as chunks, range(0, size($chunks)) AS index
UNWIND index AS i
WITH i, chunks[i] AS chunk, $embeddings[i] AS embedding
MERGE (c:Chunk {index: i})
SET c.text = chunk, c.embedding = embedding
'''
driver.execute_query(cypher_query, chunks=chunks, embeddings=embeddings)
要检查数据库内容,可以运行下面的Cypher查询,获取index为0的:Chunk节点。
代码示例2.5 从Neo4j中获取某个文本块节点的数据
records, _, _ = driver.execute_query(
"MATCH (c:Chunk) WHERE c.index = 0 RETURN c.embedding, c.text")
print(records[0]["c.text"][0:30])
print(records[0]["c.embedding"][0:3])
2.2.6 执行向量搜索
向量索引填充完毕后,就可以执行向量相似度搜索。首先需要将想回答的问题进行嵌入。我们使用与文本切分块相同的嵌入模型和函数。
代码示例2.6 对用户问题进行嵌入
question = "At what time was Einstein really interested in experimental works?"
question_embedding = embed([question])[0]
获得问题的嵌入向量后,使用Cypher执行向量相似度搜索。
代码示例2.7 在Neo4j中执行向量搜索
query = '''
CALL db.index.vector.queryNodes('pdf', 2, $question_embedding) YIELD node AS hits, score
RETURN hits.text AS text, score, hits.index AS index
'''
similar_records, _, _ = driver.execute_query(query, question_embedding=question_embedding)
该查询返回与问题最相似的前两个文本切分块。我们打印结果查看返回内容。
代码示例2.8 打印搜索结果
for record in similar_records:
print(record["text"])
print(record["score"], record["index"])
print("======")
打印结果示例:
upbringing, his interest in inventions and patents was not unusual.
Being a manufacturer’s son, Einstein grew upon in an environment of machines and instruments.
When his father’s company obtained the contract to illuminate Munich city during beer festival, he was actively engaged in execution of the contract. In his ETH days Einstein was genuinely interested in experimental works. He wrote to his friend, “most of the time I worked in the physical laboratory, fascinated by the direct contact with observation.” Einstein's
0.8185358047485352 42
======
instruments. However, it must also be emphasized that his main occupation was theoretical physics. The inventions he worked upon were his diversions. In his unproductive times he used to work upon on solving mathematical problems (not related to his ongoing theoretical investigations) or took upon some practical problem. As shown in Table. 2, Einstein was involved in three major inventions; (i) refrigeration system with Leo Szilard, (ii) Sound reproduction system with Rudolf Goldschmidt and (iii) automatic camera
0.7906564474105835 44
======
从打印结果中可以看到匹配到的文本块、相似度分数以及对应的索引。下一步将使用这些文本块,通过大型语言模型生成答案。
2.2.7 使用大型语言模型(LLM)生成答案
在与LLM交互时,我们可以传入称为“系统消息”(system message)的内容,用于给LLM下达指令。我们还会传入“用户消息”(user message),其中包含原始问题,以及在本例中用于回答问题的相关内容。
在用户消息中,我们会传入希望LLM用来生成答案的文本切分块,也就是代码示例2.8中通过相似度搜索找到的文本块的text属性。
代码示例2.9 LLM上下文构造
system_message = "You're an Einstein expert, but can only use the provided documents to respond to the questions."
user_message = f"""
Use the following documents to answer the question that will follow:
{[doc["text"] for doc in similar_records]}
---
The question to answer using information only from the above documents:
{question}
"""
接下来使用LLM生成答案。
代码示例2.10 使用LLM生成答案
print("Question:", question)
stream = open_ai_client.chat.completions.create(
model="gpt-4",
messages=[
{"role": "system", "content": system_message},
{"role": "user", "content": user_message}
],
stream=True,
)
for chunk in stream:
print(chunk.choices[0].delta.content or "", end="")
这段代码会以流式方式输出LLM生成的结果,可以实时看到回答的生成过程。
代码示例2.11 LLM生成的答案
Question: At what time was Einstein really interested in experimental works?
During his ETH days, Einstein was genuinely interested in experimental works.
哇,看看这个结果!LLM成功基于检索器找到的信息生成了答案。
2.3 在RAG应用中添加全文搜索以实现混合搜索
在上一节中,我们了解了如何使用向量相似度搜索实现RAG应用。虽然纯向量相似度搜索已经比单纯的全文搜索有了很大提升,但在生产环境中,单靠它往往无法达到足够高的质量、准确率和性能要求。
本节将探讨如何改进检索器以获得更优结果,重点介绍如何在RAG应用中添加全文搜索,实现混合搜索。
2.3.1 全文搜索索引
全文搜索是一种数据库中的文本搜索方法,存在已久。它通过关键词在数据中查找精确匹配,而非基于向量空间的相似度匹配。全文搜索要求搜索词必须与数据中的某个词完全一致才能匹配。
为了实现混合搜索,我们需要在数据库中添加全文搜索索引。大多数数据库都有某种形式的全文索引,本书中将使用Neo4j的全文搜索索引。
代码示例2.12 在Neo4j中创建全文索引
driver.execute_query("CREATE FULLTEXT INDEX PdfChunkFulltext FOR (c:Chunk) ON EACH [c.text]")
这里,我们在:Chunk节点的text属性上创建了名为PdfChunkFulltext的全文索引。
2.3.2 执行混合搜索
混合搜索的思路是同时执行向量相似度搜索和全文搜索,然后合并两者结果。为了比较两种搜索得到的分数,我们需要对分数进行归一化处理,即将分数除以各自搜索中的最高分。
代码示例2.13 在Neo4j中执行混合搜索
hybrid_query = '''
CALL {
// 向量索引搜索
CALL db.index.vector.queryNodes('pdf', $k, $question_embedding) YIELD node, score
WITH collect({node:node, score:score}) AS nodes, max(score) AS max
UNWIND nodes AS n
// 归一化分数
RETURN n.node AS node, (n.score / max) AS score
UNION
// 关键词全文搜索
CALL db.index.fulltext.queryNodes('ftPdfChunk', $question, {limit: $k}) YIELD node, score
WITH collect({node:node, score:score}) AS nodes, max(score) AS max
UNWIND nodes AS n
// 归一化分数
RETURN n.node AS node, (n.score / max) AS score
}
// 去重节点,按分数降序排序,取前k个
WITH node, max(score) AS score ORDER BY score DESC LIMIT $k
RETURN node, score
'''
我们通过一个联合(UNION)的Cypher查询,先执行向量相似度搜索,再执行全文搜索,之后去重并返回前k个结果。
代码示例2.14 在Neo4j中调用混合搜索
similar_hybrid_records, _, _ = driver.execute_query(hybrid_query,
question_embedding=question_embedding, question=question, k=4)
for record in similar_hybrid_records:
print(record["node"]["text"])
print(record["score"], record["node"]["index"])
print("======")
代码示例2.15 混合搜索结果示例
CH-Switzerland
Considering Einstein’s upbringing, his interest in inventions and patents was not unusual.
Being a manufacturer’s son, Einstein grew upon in an environment of machines and instruments.
When his father’s company obtained the contract to illuminate Munich city during beer festival, he
was actively engaged in execution of the contract. In his ETH days Einstein was genuinely interested
in experimental works. He wrote to his friend, “most of the time I worked
in the physical laboratory, fascinated by the direct contact with observation.” Einstein's
1.0 42
======
Einstein
left his job at the Patent office and joined the University of Zurich on October 15, 1909. Thereafter, he
continued to rise in ladder. In 1911, he moved to Prague University as a full professor, a year later, he
was appointed as full professor at ETH, Zurich, his alma-mater. In 1914, he was appointed Director of
the Kaiser Wilhelm Institute for Physics (1914–1932) and a professor at the Humboldt University of
Berlin, with a special clause in his contract that freed him from teaching obligations. In the meantime,
he was working for
0.9835733295862473 31
======
从结果中可以看到,最高分的结果归一化后得分为1.0,说明该结果与向量相似度搜索中的最高分结果相同。而第二个结果则不同,这是因为全文搜索找到了比向量搜索更匹配的内容。
2.4 总结与思考
本章我们介绍了什么是向量相似度搜索,它包含哪些组件,以及它如何融入RAG应用。随后,我们通过添加全文搜索来提升检索器的性能。
结合使用向量相似度搜索和全文搜索,能比单独使用其中之一获得更好的检索效果。尽管这种混合搜索在某些场景下表现良好,但由于依赖无结构数据进行信息检索,其质量、准确度和性能仍然有限。文本中的引用并不总能被捕捉到,且上下文环境往往不足以让大型语言模型(LLM)充分理解文本含义,从而影响生成答案的质量。
下一章我们将探讨如何改进检索器以获得更优结果。
总结:
- 一个RAG应用由检索器和生成器组成。检索器负责找到相关信息,生成器利用这些信息生成回答。
- 文本嵌入能将文本含义映射到向量空间,从而支持通过向量相似度搜索找到相似文本。
- 通过在RAG应用中加入全文搜索,可以实现混合搜索,提升检索器的性能。
- 向量相似度搜索和混合搜索在特定场景中表现良好,但随着数据复杂度增加,其质量、准确度和性能仍有较大提升空间。