2 向量相似性搜索与混合搜索
本章内容
- 介绍嵌入(embeddings)、嵌入模型、向量空间和向量相似性搜索
- 向量相似性搜索在 RAG 应用中的作用
- 使用向量相似性搜索实现 RAG 应用的实践指南
- 在 RAG 应用中加入全文搜索,以了解混合搜索方法如何提升检索效果
**构建知识图谱通常是一个迭代过程:你从非结构化数据开始,然后逐步为其添加结构。**当你拥有大量非结构化数据并希望开始利用它们来回答问题时,这种情况尤为常见。
本章将探讨如何使用 RAG 结合非结构化数据来回答问题。我们将研究如何利用向量相似性搜索和混合搜索来查找相关信息,并如何利用这些信息生成答案。在后续章节中,我们将进一步探讨当数据具有一定结构时,可以采用哪些技术来优化检索器(retriever)和生成器(generator),以获得更好的结果。
在数据科学和机器学习领域,嵌入模型和向量相似性搜索是处理复杂数据的重要工具。本章将探讨这些技术如何将文本、图像等复杂数据转换为一种统一的数值表示形式,即“嵌入”。
2.1 RAG 架构的组件
在 RAG 应用中,主要有两个组件:检索器(retriever)和生成器(generator)。检索器使用向量相似性搜索来查找相关信息,而生成器则利用这些信息来生成回应。
2.1.1 检索器
检索器是 RAG 应用的第一个组件。其作用是查找相关信息,并将这些信息传递给生成器。RAG 框架本身并未规定检索器应如何查找相关信息,但最常见的方法是使用向量相似性搜索。
向量索引
虽然向量相似性搜索并不严格依赖向量索引,但强烈建议使用。向量索引是一种数据结构(类似于映射表),它以一种便于搜索相似向量的方式存储向量。使用向量索引时,检索方法通常被称为近似最近邻搜索(approximate nearest neighbor search)。这是因为向量索引并不能找到精确的最近邻,而是找到与目标向量非常接近的向量。这是一种在速度与准确性之间的权衡:向量索引比暴力搜索(brute-force search)快得多,但准确性稍低。
向量相似性搜索函
向量相似性搜索函数是一种以向量作为输入,并返回一组相似向量的函数。该函数可能使用向量索引来查找相似向量,也可能采用其他方法(如暴力搜索)。关键在于,它能够返回一组相似的向量。
[!NOTE]
余弦相似度的公式如下:
其中:
- 和 是两个向量,
- 和 是向量在第 维的分量,
- 表示向量的点积,
- 和 分别表示向量的模(欧几里得范数)。
[!NOTE]
欧几里得距离的公式如下:
其中:
- 和 是两个 维向量,
- 和 分别是向量在第 维上的分量。
最常见的两种向量相似性搜索函数是余弦相似度(cosine similarity)和欧几里得距离(Euclidean distance)。欧几里得距离反映了文本的内容和强度。余弦相似度是衡量两个向量之间夹角的指标。在文本嵌入的场景中,这个夹角代表了两段文本在语义上的相似程度。余弦相似度函数接收两个向量作为输入,返回一个介于 0 到 1 之间的数值:0 表示两个向量完全不同,1 表示完全相同。余弦相似度被认为最适合用于文本聊天机器人。
嵌入模型
对文本进行语义分类后得到的结果称为嵌入(embedding)。**任何希望通过向量相似性搜索进行匹配的文本,都必须先转换为嵌入。**这一过程通过嵌入模型完成,且在整个 RAG 应用中必须保持嵌入模型的一致性。如果需要更换嵌入模型,则必须重新构建整个向量索引。
嵌入是一组数值,其长度称为嵌入维度(embedding dimension)。嵌入维度非常重要,因为它决定了嵌入所能承载的信息量。维度越高,生成嵌入和执行向量相似性搜索时的计算开销就越大。
嵌入是一种将复杂数据表示为更简单、更低维空间中的一组数字的方法。可以将其理解为将数据转换为计算机易于理解和处理的格式。
嵌入模型提供了一种统一的方式来表示不同类型的数据。**嵌入模型的输入可以是任何复杂数据,输出则是一个向量。**例如,在处理文本时,嵌入模型会将词语或句子转换为向量,即一组数字。该模型经过训练,确保这些数字列表能够捕捉原始词语的关键特征,如其含义或上下文。
文本分块
文本分块(Text chunking)是指将文本分割成更小片段的过程。这样做是为了提高检索器的准确性。较小的文本片段意味着嵌入的语义范围更窄、更具体,因此在搜索时检索器能找到更相关的信息。
文本分块至关重要,但要恰当地实现并不容易。你需要思考如何分割文本:是按句子、段落、语义单元,还是其他方式?是使用滑动窗口,还是固定大小?分块的大小应该如何设定?
这些问题没有标准答案,具体取决于应用场景、数据类型和领域。但重要的是要认真思考这些问题,并尝试不同的方法,以找到最适合你应用场景的解决方案。
检索器工作流
当所有组件准备就绪后,检索器的工作流非常简单。它接收一个查询作为输入,使用嵌入模型将其转换为向量,然后利用向量相似性搜索函数查找相似的向量。在最简单的情况下,检索器工作流直接返回源文本块,这些文本块随后被传递给生成器。但在大多数情况下,检索器工作流还需要进行一些后处理,以筛选出最适合传递给生成器的最佳文本块。
2.1.2 生成器
生成器是 RAG 应用的第二个组件**。它利用检索器找到的信息来生成回应。**生成器通常是一个大语言模型(LLM),但与微调或依赖模型的原始知识相比,RAG 的一个优势在于,所使用的模型无需如此庞大。这是因为相关信息已由检索器提供,因此生成器不必“知晓一切”,而只需知道如何利用检索器找到的信息来生成回应。这相比让模型掌握全部知识而言,是一个更小的任务。
因此,我们是利用语言模型的文本生成能力,而非其内部知识。这意味着我们可以使用更小的语言模型,这类模型运行速度更快、成本更低。同时,这也意味着我们可以更信任模型的回应是基于检索器所提供的信息,从而减少虚构内容的产生,降低“幻觉”现象的发生。
2.2 使用向量相似性搜索的 RAG
要实现一个基于向量相似性搜索的 RAG 应用,需要准备几个关键组件。本章将逐一介绍这些组件。目标是展示如何实现一个基于向量相似性搜索的 RAG 应用,以及如何利用检索器找到的信息生成回应。
我们需要将该应用分为两个阶段:
- 数据准备
- 查询时处理
2.2.1 应用数据准备
从前文可知,为了在运行时执行向量相似性搜索,我们需要对数据进行一定处理,使其能够被嵌入模型转换到向量空间中。所需组件包括:
- 文本语料库
- 文本分块函数
- 嵌入模型
- 具备向量相似性搜索能力的数据库
数据将以文本块的形式存储在数据库中,同时向量索引将填充这些文本块的嵌入向量。之后在运行时,当用户提出问题时,该问题将使用与文本块相同的嵌入模型进行向量化,然后利用向量索引查找最相似的文本块。
2.2.2 文本语料库
你可以找你自己喜欢的 然后根据文本内容提出问题,并进行验证。
2.2.3 文本分块
尽管当前的大语言模型具备足够大的上下文窗口,允许我们将整篇论文作为一个单独的文本块处理,但为了获得更好的检索效果,我们将论文分割成更小的片段,每几百个字符作为一个文本块。最佳的分块大小因具体应用场景而异,因此建议尝试不同的分块尺寸以找到最优配置。
在本例中,我们还希望文本块之间存在一定重叠。这是因为某些答案可能跨越多个文本块,引入重叠有助于提高检索的完整性。因此,我们将采用滑动窗口的方式,设置窗口大小为 500 个字符,重叠部分为 40 个字符。这会使索引略微增大,但能显著提升检索器的准确性。
为了帮助嵌入模型更好地理解每个文本块的语义,我们将仅在空格处进行分割,避免在文本块的开头或结尾出现被截断的单词。该函数接收文本、分块大小(字符数)、重叠大小(字符数),以及一个可选参数(指定是否仅在空白字符处分割),最终返回一个文本块列表。
def chunk_text(text, chunk_size, overlap, split_on_whitespace_only=True):
"""
将文本分割成指定大小的块,支持重叠和智能分词
Args:
text (str): 需要分块的原始文本
chunk_size (int): 每个文本块的目标大小
overlap (int): 相邻块之间的重叠字符数
split_on_whitespace_only (bool): 是否仅在空白字符处分割,默认为True
Returns:
list: 分割后的文本块列表
"""
chunks = []
index = 0
while index < len(text):
if split_on_whitespace_only:
# 智能分词模式:确保不在单词中间分割
# 查找前一个空白字符位置(用于处理 overlap)
prev_whitespace = index
left_index = index - overlap
while left_index >= 0:
if text[left_index] == " ":
prev_whitespace = left_index
break
left_index -= 1
# 查找下一个合适的位置来切分
ideal_end = index + chunk_size
next_whitespace = text.find(" ", ideal_end)
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
def main():
text = """
The Zen of Python, by Tim Peters
Beautiful is better than ugly.
Explicit is better than implicit.
Simple is better than complex.
Complex is better than complicated.
Flat is better than nested.
Sparse is better than dense.
Readability counts.
Special cases aren't special enough to break the rules.
Although practicality beats purity.
Errors should never pass silently.
Unless explicitly silenced.
In the face of ambiguity, refuse the temptation to guess.
There should be one-- and preferably only one --obvious way to do it.
Although that way may not be obvious at first unless you're Dutch.
Now is better than never.
Although never is often better than *right* now.
If the implementation is hard to explain, it's a bad idea.
If the implementation is easy to explain, it may be a good idea.
Namespaces are one honking great idea -- let's do more of those!
"""
chunks = chunk_text(text, 200, 40)
print(f"Total chunks: {len(chunks)}\n")
for i, chunk in enumerate(chunks):
print(f"--- Chunk {i + 1} ---")
print(chunk)
print("-" * 40)
if __name__ == "__main__":
main()
Total chunks: 5
--- Chunk 1 ---
The Zen of Python, by Tim Peters
Beautiful is better than ugly.
Explicit is better than implicit.
Simple is better than complex.
Complex is better than complicated.
Flat is better than nested.
Sparse
----------------------------------------
--- Chunk 2 ---
complicated.
Flat is better than nested.
Sparse is better than dense.
Readability counts.
Special cases aren't special enough to break the rules.
Although practicality beats purity.
Errors should never pass silently.
Unless explicitly silenced.
In the
----------------------------------------
--- Chunk 3 ---
silently.
Unless explicitly silenced.
In the face of ambiguity, refuse the temptation to guess.
There should be one-- and preferably only one --obvious way to do it.
Although that way may not be obvious at first unless you're Dutch.
Now is better
----------------------------------------
--- Chunk 4 ---
first unless you're Dutch.
Now is better than never.
Although never is often better than *right* now.
If the implementation is hard to explain, it's a bad idea.
If the implementation is easy to explain, it may be a good idea.
Namespaces are one
----------------------------------------
--- Chunk 5 ---
may be a good idea.
Namespaces are one honking great idea -- let's do more of those!
----------------------------------------
2.2.4 嵌入模型
在选择嵌入模型时,重要的是要考虑你希望匹配何种类型的数据。在本例中,我们的目标是匹配文本,因此将使用文本嵌入模型。对原书进行改动,将使用ollama部署嵌入开源嵌入模型,然后使用LangChain进行调用。
**一旦确定了嵌入模型,就必须确保在整个 RAG 应用中始终使用同一个模型。**这是因为向量索引中的向量是由该嵌入模型生成的,如果更换模型,则必须重新构建整个向量索引。
from langchain_ollama import OllamaEmbeddings # 使用新的导入路径
from langchain_community.vectorstores import Chroma
from langchain.text_splitter import RecursiveCharacterTextSplitter
import numpy as np
def calculate_similarity(embedding1, embedding2):
"""计算两个向量的余弦相似度"""
dot_product = np.dot(embedding1, embedding2)
norm1 = np.linalg.norm(embedding1)
norm2 = np.linalg.norm(embedding2)
return dot_product / (norm1 * norm2)
def main():
# 初始化 Ollama 嵌入模型 - 使用新的类
embeddings = OllamaEmbeddings(
model="nomic-embed-text:latest",
base_url="http://localhost:11434"
)
# 测试文本数据
texts = [
"Beautiful is better than ugly.",
"Explicit is better than implicit.",
]
# 1. 生成单个文本的嵌入向量
print("1. 单个文本嵌入向量生成:")
print("-" * 40)
test_text = "Simple is better than complex."
embedding = embeddings.embed_query(test_text)
print(f"文本: {test_text}")
print(f"嵌入向量维度: {len(embedding)}")
print(f"前10个维度的值: {[f'{x:.4f}' for x in embedding[:10]]}")
print()
# 2. 批量生成嵌入向量
print("2. 批量文本嵌入向量生成:")
print("-" * 40)
embeddings_list = embeddings.embed_documents(texts)
print(f"成功生成 {len(embeddings_list)} 个文本的嵌入向量")
for i, (text, emb) in enumerate(zip(texts, embeddings_list)):
print(f"文本 {i + 1}: {text}")
print(f" 嵌入维度: {len(emb)}")
print(f" 向量范数: {np.linalg.norm(emb):.4f}")
print()
# 3. 计算文本相似度
print("3. 文本相似度计算:")
print("-" * 40)
query_embedding = embeddings.embed_query("Complex is better than complicated.")
similarities = []
for i, (text, doc_embedding) in enumerate(zip(texts, embeddings_list)):
similarity = calculate_similarity(query_embedding, doc_embedding)
similarities.append((similarity, i, text))
# 按相似度排序
similarities.sort(reverse=True)
print(f"查询文本: Complex is better than complicated.")
print("相似度排序结果:")
for i, (sim, idx, text) in enumerate(similarities):
print(f" {i + 1}. 相似度: {sim:.4f} | 文本: {text}")
if __name__ == "__main__":
main()
1. 单个文本嵌入向量生成:
----------------------------------------
文本: Simple is better than complex.
嵌入向量维度: 768
前10个维度的值: ['0.0582', '0.0408', '-0.1608', '0.0014', '0.0137', '-0.0027', '0.0439', '-0.0196', '-0.0194', '-0.0460']
2. 批量文本嵌入向量生成:
----------------------------------------
成功生成 2 个文本的嵌入向量
文本 1: Beautiful is better than ugly.
嵌入维度: 768
向量范数: 1.0000
文本 2: Explicit is better than implicit.
嵌入维度: 768
向量范数: 1.0000
3. 文本相似度计算:
----------------------------------------
查询文本: Complex is better than complicated.
相似度排序结果:
1. 相似度: 0.5956 | 文本: Explicit is better than implicit.
2. 相似度: 0.5550 | 文本: Beautiful is better than ugly.
2.2.5 具备向量相似性搜索功能的数据库
[!IMPORTANT]
你需要先进行neo4j的安装 网上教程很多 这里就不写了
将嵌入向量存储到数据库中可以进行相似性检索,我们将使用使用 Neo4j 作为数据库,因为它内置了向量索引功能且易于使用,同时其还具有图结构能力。
目前阶段所使用的数据模型非常简单。我们将使用一种名为 Chunk(文本块)的节点类型,它包含两个属性:text(文本)和 embedding(嵌入)。text 属性用于存储文本块的内容,而 embedding 属性则用于存储该文本块的嵌入向量。
首先,让我们创建一个向量索引。需要注意的是,在创建向量索引时,我们必须定义向量的维度数。如果将来更换了输出不同维度向量的嵌入模型,则必须重新创建向量索引。
上文中使用的嵌入模型nomic-embed-text:latest生成的向量具有 768个维度,因此在创建向量索引时,我们将采用该数值作为向量的维度数。我们将把向量索引命名为 queryIndex,并使用它来对类型为 Chunk 的节点在其 embedding 属性上建立索引,检索时采用余弦相似度搜索函数。同时我们实现了批量插入以及检索功能。
在与大语言模型(LLM)进行交互时,我们可以传入一种称为“系统消息”(system message)的内容,用于向模型提供需要遵循的指令。同时,我们还会传入一条“用户消息”(user message),其中包含原始问题,以及在本例中,用于生成答案的相关信息。后续处理中,我们会将通过相似性搜索找到的、最相关的文本块的 text 属性内容插入用户消息。最后我们将上述内容整合,实现了简单的RAG案例。
import ollama
from langchain_ollama import OllamaEmbeddings
from neo4j import GraphDatabase
# --- 配置 ---
EMBEDDING_MODEL = "nomic-embed-text:latest"
LLM_MODEL = "qwen3:14b"
OLLAMA_BASE_URL = "http://localhost:11434"
NEO4J_URI = "bolt://localhost:7687"
NEO4J_USER = "neo4j"
NEO4J_PASSWORD = "用你自己的密码"
VECTOR_INDEX_NAME = "queryIndex"
VECTOR_DIMENSIONS = 768
# 初始化嵌入模型和 Neo4j 驱动
embeddings = OllamaEmbeddings(model=EMBEDDING_MODEL, base_url=OLLAMA_BASE_URL)
driver = GraphDatabase.driver(NEO4J_URI, auth=(NEO4J_USER, NEO4J_PASSWORD))
def create_vector_index():
"""创建向量索引。"""
with driver.session() as session:
session.run(f"""
CREATE VECTOR INDEX {VECTOR_INDEX_NAME} IF NOT EXISTS
FOR (c:Chunk) ON c.embedding
OPTIONS {{
indexConfig: {{
`vector.dimensions`: {VECTOR_DIMENSIONS},
`vector.similarity_function`: 'cosine'
}}
}}
""")
print(f"向量索引 '{VECTOR_INDEX_NAME}' 已创建")
def insert_chunks_cypher(chunks):
"""插入文本块和向量到 Neo4j。"""
embedding_list = embeddings.embed_documents(chunks)
with driver.session() as session:
session.run("""
WITH $chunks AS chunks, $embeddings AS embeddings
UNWIND range(0, size(chunks) - 1) AS i
MERGE (c:Chunk {index: i})
SET c.text = chunks[i], c.embedding = embeddings[i]
""", chunks=chunks, embeddings=embedding_list)
print(f"已插入 {len(chunks)} 个 Chunk 节点")
def retrieve_similar_chunks(query_text, top_k=3):
"""检索相似的文本块。"""
query_embedding = embeddings.embed_query(query_text)
with driver.session() as session:
result = session.run(f"""
CALL db.index.vector.queryNodes('{VECTOR_INDEX_NAME}', $top_k, $embedding)
YIELD node, score
RETURN node.text AS text, score
ORDER BY score DESC
""", embedding=query_embedding, top_k=top_k)
return [{"text": record["text"], "score": record["score"]} for record in result]
def generate_answer_with_ollama(question, context_records):
"""使用 Ollama 生成回答"""
context_texts = "\n".join([rec["text"] for rec in context_records])
system_message = (
"You are an expert on the Zen of Python (PEP 20). "
"Answer questions based solely on the provided documents. "
"Be concise and accurate."
)
user_message = f"""Use the following documents to answer the question.
Documents:
{context_texts}
---
Question: {question}
Answer in English:"""
print(f"问题: {question}")
print("回答:")
try:
# 使用非流式调用
response = ollama.chat(
model=LLM_MODEL,
messages=[
{'role': 'system', 'content': system_message},
{'role': 'user', 'content': user_message},
],
)
# 直接获取完整回答内容
answer = response['message']['content']
print(answer)
print("回答生成完毕")
except Exception as e:
print(f"\n调用 Ollama 生成回答时出错: {e}")
def main():
"""主流程。"""
texts = [
"Beautiful is better than ugly.",
"Explicit is better than implicit.",
"Simple is better than complex.",
"Complex is better than complicated.",
"Flat is better than nested.",
"Sparse is better than dense.",
"Readability counts.",
"Special cases aren't special enough to break the rules.",
"Although practicality beats purity.",
"Errors should never pass silently.",
"Unless explicitly silenced.",
"In the face of ambiguity, refuse the temptation to guess.",
"There should be one-- and preferably only one --obvious way to do it.",
"Although that way may not be obvious at first unless you're Dutch.",
"Now is better than never.",
"Although never is often better than *right* now.",
"If the implementation is hard to explain, it's a bad idea.",
"If the implementation is easy to explain, it may be a good idea.",
"Namespaces are one honking great idea -- let's do more of those!"
]
create_vector_index()
insert_chunks_cypher(texts)
question = "In the Zen of Python, what is the relationship between 'simple' and 'complex'?"
similar_records = retrieve_similar_chunks(question, top_k=3)
if not similar_records:
print("未检索到任何相关文本块。")
return
print("检索到的相关文本块:")
for i, record in enumerate(similar_records):
print(f" [{i + 1}] (相似度: {record['score']:.4f}) {record['text']}")
generate_answer_with_ollama(question, similar_records)
driver.close()
print("\n数据库连接已关闭")
if __name__ == "__main__":
main()
2.3 混合搜索的实现
尽管纯向量相似性搜索已经能够实现很好的效果,并且相比传统的全文搜索有了显著提升,但在许多实际生产场景中,它往往仍不足以提供足够高质量、高准确性和高性能的结果。我们将引入全文搜索,从而实现混合搜索(hybrid search)。
2.3.1 全文搜索索引
全文搜索(Full-text search)是一种在数据库中长期存在的文本搜索方法。它通过关键词匹配来查找数据中的内容,而不是在向量空间中基于语义相似性进行搜索。在全文搜索中,要找到匹配项,搜索词必须与数据中的某个词完全匹配。
我们在此为 :Chunk 节点的 text 属性创建一个名为 ChunkFulltext 的全文索引。混合搜索的思路是:同时执行向量相似性搜索和全文搜索,然后将两者的结果进行合并。为了能够比较这两种不同搜索方式所得分数,我们需要对分数进行归一化处理,即用每个搜索结果的分数除以该次搜索中的最高分。接着实现了一个联合(UNION)Cypher 查询,首先执行向量相似性搜索,然后执行全文搜索。接着对结果进行去重,并返回得分最高的 k 个结果。
import ollama
from langchain_ollama import OllamaEmbeddings
from neo4j import GraphDatabase
EMBEDDING_MODEL = "nomic-embed-text:latest"
LLM_MODEL = "qwen3:14b"
OLLAMA_BASE_URL = "http://localhost:11434" # Ollama 服务地址
NEO4J_URI = "bolt://localhost:7687" # Neo4j Bolt 地址
NEO4J_USER = "neo4j" # Neo4j 用户名
NEO4J_PASSWORD = "password" # Neo4j 密码
VECTOR_INDEX_NAME = "queryIndex" # 向量索引名称
FULLTEXT_INDEX_NAME = "ChunkFulltext" # 全文索引名称
VECTOR_DIMENSIONS = 768 # nomic-embed-text 向量维度
# 初始化嵌入模型和 Neo4j 驱动
embeddings = OllamaEmbeddings(model=EMBEDDING_MODEL, base_url=OLLAMA_BASE_URL)
driver = GraphDatabase.driver(NEO4J_URI, auth=(NEO4J_USER, NEO4J_PASSWORD))
# 1. 创建向量索引
def create_vector_index():
"""在 Neo4j 中为 :Chunk 节点的 embedding 属性创建向量索引。"""
with driver.session() as session:
session.run(f"""
CREATE VECTOR INDEX {VECTOR_INDEX_NAME} IF NOT EXISTS
FOR (c:Chunk) ON c.embedding
OPTIONS {{
indexConfig: {{
`vector.dimensions`: {VECTOR_DIMENSIONS},
`vector.similarity_function`: 'cosine'
}}
}}
""")
print(f"向量索引 '{VECTOR_INDEX_NAME}' 已创建")
# 2. 创建全文索引
def create_fulltext_index():
"""在 Neo4j 中为 :Chunk 节点的 text 属性创建名为 'ChunkFulltext' 的全文索引。"""
with driver.session() as session:
# 检查索引是否已存在,避免重复创建错误
result = session.run("""
SHOW FULLTEXT INDEXES YIELD name
WHERE name = $index_name
RETURN count(*) > 0 AS exists
""", index_name=FULLTEXT_INDEX_NAME)
exists = result.single()["exists"]
if not exists:
session.run(f"""
CREATE FULLTEXT INDEX {FULLTEXT_INDEX_NAME} FOR (c:Chunk) ON EACH [c.text]
""")
print(f"全文索引 '{FULLTEXT_INDEX_NAME}' 已创建")
else:
print(f"全文索引 '{FULLTEXT_INDEX_NAME}' 已存在")
# 3. 插入 Chunk 节点
def insert_chunks_cypher(chunks):
"""将文本块及其嵌入向量插入到 Neo4j 数据库中。"""
embedding_list = embeddings.embed_documents(chunks)
with driver.session() as session:
session.run("""
WITH $chunks AS chunks, $embeddings AS embeddings
UNWIND range(0, size(chunks) - 1) AS i
MERGE (c:Chunk {index: i}) // 使用 MERGE 避免重复
SET c.text = chunks[i], c.embedding = embeddings[i]
""", chunks=chunks, embeddings=embedding_list)
print(f"已通过 Cypher 插入 {len(chunks)} 个 Chunk 节点")
# 4. 向量相似性搜索
def retrieve_similar_chunks(query_text, top_k=3):
"""
根据查询文本的嵌入向量,在 Neo4j 中检索最相似的 Chunk。
返回检索到的文本列表。
"""
query_embedding = embeddings.embed_query(query_text)
with driver.session() as session:
# 使用不同的参数名 embedding_vector 避免冲突
result = session.run(f"""
CALL db.index.vector.queryNodes('{VECTOR_INDEX_NAME}', $top_k, $embedding_vector)
YIELD node, score
RETURN node.text AS text, score
ORDER BY score DESC
""", embedding_vector=query_embedding, top_k=top_k) # 使用 embedding_vector
records = [{"text": record["text"], "score": record["score"], "type": "vector"} for record in result]
return records
# 5. 全文搜索
def retrieve_fulltext_chunks(query_text, top_k=3):
"""
使用 Neo4j 全文索引 'ChunkFulltext' 检索包含关键词的 Chunk。
返回检索到的文本列表及其分数。
"""
with driver.session() as session:
# 使用不同的参数名 query_term 避免冲突
result = session.run(f"""
CALL db.index.fulltext.queryNodes('{FULLTEXT_INDEX_NAME}', $query_term)
YIELD node, score
RETURN node.text AS text, score
ORDER BY score DESC
LIMIT $top_k
""", query_term=query_text, top_k=top_k) # 使用 query_term
records = [{"text": record["text"], "score": record["score"], "type": "fulltext"} for record in result]
return records
# 6. 基于 Cypher 的混合搜索 (Hybrid Search)
def retrieve_hybrid_chunks_cypher(query_text, top_k=3):
"""
使用 Cypher 执行混合搜索,结合向量和全文搜索,并进行分数归一化和去重。
"""
query_embedding = embeddings.embed_query(query_text)
with driver.session() as session:
result = session.run("""
CALL () {
// 向量索引搜索
CALL db.index.vector.queryNodes($vector_index_name, $k, $embedding_vector)
YIELD node, score
WITH collect({node:node, score:score}) AS nodes, max(score) AS max_score
UNWIND nodes AS n
// 归一化向量分数
RETURN n.node AS node, (n.score / max_score) AS score
UNION
// 全文索引搜索
CALL db.index.fulltext.queryNodes($fulltext_index_name, $query_term, {limit: $k})
YIELD node, score
WITH collect({node:node, score:score}) AS nodes, max(score) AS max_score
UNWIND nodes AS n
// 归一化全文分数
RETURN n.node AS node, (n.score / max_score) AS score
}
// 去重节点,取最高分数
WITH node, max(score) AS score ORDER BY score DESC LIMIT $k
RETURN node.text AS text, node.index AS index, score
""",
# 传递参数时使用与 Cypher 查询中 $变量名一致的名字
vector_index_name=VECTOR_INDEX_NAME,
fulltext_index_name=FULLTEXT_INDEX_NAME,
embedding_vector=query_embedding, # 对应 $embedding_vector
query_term=query_text, # 对应 $query_term
k=top_k * 2
)
# 返回结果,格式包含 text, score, index
return [{"text": record["text"], "score": record["score"], "index": record["index"]} for record in result]
# 7. 使用 Ollama 生成回答
def generate_answer_with_ollama(question, context_records):
"""
使用 Ollama 调用 qwen3:14b 模型,基于检索到的上下文生成回答。
"""
# 提取文本内容用于构建提示
context_texts = "\n".join([rec["text"] for rec in context_records])
# 构建系统消息和用户消息
system_message = (
"You are an expert on the Zen of Python (PEP 20). "
"Answer questions based solely on the provided documents. "
"Be concise and accurate."
)
user_message = f"""Use the following documents to answer the question at the end.
Documents:
{context_texts}
---
Question: {question}
Answer in English:"""
print(f"问题: {question}")
print("回答:")
try:
response = ollama.chat(
model=LLM_MODEL,
messages=[
{'role': 'system', 'content': system_message},
{'role': 'user', 'content': user_message},
],
)
answer = response['message']['content']
print(answer)
except Exception as e:
print(f"\n调用 Ollama 生成回答时出错: {e}")
def main():
texts = [
"Beautiful is better than ugly.",
"Explicit is better than implicit.",
"Simple is better than complex.",
"Complex is better than complicated.",
"Flat is better than nested.",
"Sparse is better than dense.",
"Readability counts.",
"Special cases aren't special enough to break the rules.",
"Although practicality beats purity.",
"Errors should never pass silently.",
"Unless explicitly silenced.",
"In the face of ambiguity, refuse the temptation to guess.",
"There should be one-- and preferably only one --obvious way to do it.",
"Although that way may not be obvious at first unless you're Dutch.",
"Now is better than never.",
"Although never is often better than *right* now.",
"If the implementation is hard to explain, it's a bad idea.",
"If the implementation is easy to explain, it may be a good idea.",
"Namespaces are one honking great idea -- let's do more of those!"
]
# 步骤一:创建向量索引
create_vector_index()
# 步骤二:创建全文索引
create_fulltext_index()
# 步骤三:插入文本块(使用 Cypher)
insert_chunks_cypher(texts)
# 定义要查询的问题
question = "In the Zen of Python, what is the relationship between 'simple' and 'complex'?"
print("--- 向量搜索结果 ---")
vector_records = retrieve_similar_chunks(question, top_k=3)
if vector_records:
for i, record in enumerate(vector_records):
print(f" [{i + 1}] (相似度: {record['score']:.4f}) {record['text']}")
print("\n--- 全文搜索结果---")
fulltext_records = retrieve_fulltext_chunks(question, top_k=3)
if fulltext_records:
for i, record in enumerate(fulltext_records):
print(f" [{i + 1}] (相关性: {record['score']:.4f}) {record['text']}")
print("\n--- 混合搜索结果---")
# 步骤四:使用基于 Cypher 的混合检索
hybrid_records = retrieve_hybrid_chunks_cypher(question, top_k=3)
if not hybrid_records:
print("未检索到任何相关文本块。")
return
print("检索到的相关文本块 (混合分数):")
for i, record in enumerate(hybrid_records):
print(f" [{i + 1}] (混合分数: {record['score']:.4f}, 索引: {record['index']}) {record['text']}")
print("-" * 40)
# 步骤五:使用 Ollama 生成回答
generate_answer_with_ollama(question, hybrid_records)
# 关闭数据库连接
driver.close()
if __name__ == "__main__":
main()
总结
- RAG 应用由检索器(retriever)和生成器(generator)两部分组成。检索器负责查找相关信息,生成器则利用这些信息生成回应。
- 文本嵌入(text embeddings)将文本的语义表示在向量空间中,使我们能够通过向量相似性搜索来查找语义相近的文本。
- 在 RAG 应用中引入全文搜索,可以实现混合搜索,从而提升检索器的性能。
- 向量相似性搜索和混合搜索在特定场景下可能效果良好,但随着数据复杂性的增加,其质量、准确性和性能仍然存在明显局限。