第一章:LLM 时代下的知识管理与挑战
1.1 大语言模型(LLMs)的崛起与能力边界
大语言模型,如 OpenAI 的 GPT 系列、Google 的 PaLM 2/Gemini、Meta 的 Llama 系列以及 Anthropic 的 Claude,是基于海量文本数据(通常是万亿级别甚至更多的数据量)通过深度学习技术训练出来的巨型神经网络。它们的核心能力在于预测下一个词元(token),这使其能够执行一系列令人惊叹的语言任务。
1.1.1 LLM 的强大之处:开启智能交互新纪元
文本生成(Text Generation):
能力描述: LLMs 能够根据给定的输入(Prompt)生成连贯、语法正确、风格多样的文本。这包括文章、故事、诗歌、电子邮件、代码片段、剧本等。其生成质量之高,常令人难以分辨是机器所为。
内部机制洞察: 这种能力源于其在训练过程中学习到的语言模式、语法结构、语义关联以及世界知识。它们并非“理解”语言,而是通过统计学和模式识别,预测最可能出现的词元序列。
应用场景: 内容创作自动化、营销文案生成、个性化推荐、代码自动补全与生成、报告撰写辅助等。
语言理解(Language Understanding):
能力描述: LLMs 能够理解复杂的自然语言查询、识别实体、提取信息、进行情感分析、概括长篇文档、甚至进行多语言翻译。它们能够捕捉到文本的深层含义和上下文关联。
内部机制洞察: 这得益于 Transformer 架构中的注意力机制(Attention Mechanism),它允许模型在处理词元时,能够同时已关注到输入序列中的所有其他词元,从而捕捉到远距离的依赖关系和全局语境。
应用场景: 智能客服、舆情分析、文档摘要、意图识别、语义搜索、知识图谱构建辅助等。
多模态处理(Multimodal Processing):
能力描述: 最先进的 LLMs 不再局限于文本,它们能够理解和生成图像、音频甚至视频。例如,接收图像作为输入并生成描述性文本,或者根据文本描述生成图像。
内部机制洞察: 这通常通过将不同模态的数据(如图像的像素值、音频的声谱图)转换为共同的嵌入空间(Embedding Space),使得模型能够在这个统一的表示空间中进行推理和生成。这可能涉及跨模态注意力机制或多模态预训练。
应用场景: 图像识别与描述、视频内容理解、语音助手、艺术创作、多模态搜索等。
推理能力(Reasoning):
能力描述: LLMs 展现出一定的推理能力,能够解决数学问题、逻辑谜题、代码调试、以及在不同信息间建立联系。这通常体现在它们能够遵循多步骤指令或在复杂上下文中进行推断。
内部机制洞察: 虽然其推理并非符号逻辑推导,而是基于统计模式的“模式匹配”,但通过链式思考(Chain-of-Thought, CoT)或思维树(Tree-of-Thought, ToT)等 Prompt Engineering 技术,可以诱导 LLMs 展现出更强的推理路径和更准确的结果。
应用场景: 自动编程助手、科学研究辅助、复杂问题解答、决策支持系统。
1.1.2 LLM 的局限性:强大的背后并非完美
尽管 LLMs 拥有令人惊叹的能力,但它们并非没有缺陷。理解这些局限性是构建健壮、可靠的 LLM 应用的前提。
幻觉(Hallucination):
问题描述: LLMs 可能会生成听起来非常合理但实际上是虚假、不准确或无意义的信息。这被称为“幻觉”。它们可能会捏造事实、引用不存在的来源或提供错误的统计数据。
内部机制洞察: 幻觉的根本原因在于 LLMs 的工作原理是预测下一个最可能的词元,而不是基于一个真实的知识库进行事实核查。它们在训练过程中记住了模式,但并不“理解”真相。当输入上下文不足、知识边界模糊或模型自信度过高时,它们倾向于“编造”来填补空白,以维持语言的流畅性。
应用场景影响: 在事实敏感的领域(如医疗、法律、金融、新闻)中,幻觉是致命的缺陷,可能导致严重的后果。
举例: 询问 LLM 关于一个不存在的历史事件,它可能会煞有介事地编造出事件细节、参与人物和时间线。
知识时效性(Knowledge Staleness):
问题描述: LLMs 的知识仅限于其训练数据的截止日期。对于训练数据之后发生的新事件、新知识或最新统计数据,它们是无知的。
内部机制洞察: 大模型的训练是一个极其耗时和昂贵的过程。一旦模型训练完成,其参数就被固定下来,无法实时吸收最新的信息。要更新其知识,通常需要进行昂贵的重新训练(Retraining)或持续预训练(Continuous Pre-training),这在实际应用中是不可行的。
应用场景影响: 在信息快速迭代的领域(如科技新闻、实时市场分析、最新的研究发现)中,LLM 的知识时效性是其应用的主要障碍。
举例: 询问 LLM 最近发生的国际事件、某个新发布的法律条款或最新的公司财报,它可能无法提供准确信息。
私有数据缺失(Lack of Private/Proprietary Data Access):
问题描述: LLMs 在公开可用的互联网数据上进行训练,它们无法访问企业内部的专有文档、私人数据库、特定业务流程或用户个人信息。
内部机制洞察: 出于数据隐私、安全和成本考虑,企业通常不会将敏感或专有数据上传到公共云服务提供商来重新训练 LLM,或通过 API 将内部数据直接输入到通用 LLM 中(可能违反数据合规)。
应用场景影响: 无法回答关于公司内部政策、客户历史、特定项目文档等问题,这限制了 LLM 在企业内部知识管理和智能客服等领域的应用。
举例: 询问 LLM 你的公司去年特定项目的销售额、内部员工福利政策或某个客户的特定订单状态。
上下文窗口限制(Context Window Limitations):
问题描述: LLMs 在一次性处理的输入文本长度上存在硬性限制,即“上下文窗口”(Context Window)或“上下文长度”。超出这个限制的文本无法被模型直接处理,导致信息丢失。
内部机制洞察: Transformer 架构中的注意力机制的计算复杂度与序列长度的平方成正比。这意味着上下文窗口越大,计算资源消耗(GPU内存、计算时间)呈指数级增长。因此,即使是最大的 LLM,其上下文窗口也只有几千到几十万个词元,远小于处理整本书、大型文档库或长时间对话所需的长度。
应用场景影响: 无法对长篇文档进行全面的问答、摘要,无法处理长期的多轮对话历史,导致信息被截断或无法捕捉到所有相关信息。
举例: 提供一份50页的合同,要求 LLM 回答其中所有关于违约责任的条款。如果合同长度超过上下文窗口,LLM 将无法看到所有相关信息。
可解释性(Interpretability):
问题描述: LLMs 的决策过程是一个“黑箱”。我们很难知道它们是如何得出某个答案的,也无法追溯其知识来源。
内部机制洞察: 深度神经网络的复杂性使得其内部工作机制难以理解。模型的输出是数百万甚至数十亿参数复杂交互的结果,无法直接映射到人类可理解的规则或知识路径。
应用场景影响: 在高风险决策、监管合规、用户信任建立的场景中,缺乏可解释性是一个严重的问题。当 LLM 给出错误答案时,我们无从排查原因。
举例: 法律咨询系统如果只给出一个结论而无法告知其依据的法律条文和案例,用户将无法信任。
这些局限性,特别是幻觉、知识时效性和私有数据缺失,是限制 LLM 从通用工具走向企业级、事实敏感应用的关键瓶颈。而**检索增强生成(RAG)**正是为解决这些痛点而生的。
1.2 知识密集型问答系统(KIQA)的演进
在深入 RAG 之前,理解问答系统(Question Answering, QA)的演进历史至关重要。KIQA 系统旨在从大规模的知识源中提取或生成对用户问题的准确回答。
1.2.1 传统信息检索(Information Retrieval, IR):关键词的时代
最早的问答系统根植于传统的信息检索(IR)领域。其核心思想是,将用户的查询视为关键词,然后在预先索引的大型文档集合中,通过匹配这些关键词来寻找最相关的文档。
关键词匹配:
能力描述: 最简单直观的方式。用户输入一个问题,系统从中提取关键词,然后在文档中搜索包含这些关键词的文档。
内部机制洞察: 通常基于布尔模型(Boolean Model)或向量空间模型(Vector Space Model)。布尔模型简单地判断文档是否包含所有关键词;向量空间模型将文档和查询表示为词向量,通过计算向量相似度来排序。
局限性: 无法处理同义词、多义词、语义关联,对问题的理解停留在字面层面。例如,搜索“汽车”可能无法找到包含“轿车”的文档。
索引技术(Indexing Techniques):
为了实现高效的关键词匹配,需要对海量文档进行预处理和索引。
倒排索引(Inverted Index):
原理: 将文档集合转换为一个“词项-文档”的映射。对于文档中的每一个词项(term),记录它出现在哪些文档中,以及在文档中的位置和频率。
构建过程:
分词(Tokenization): 将文本切分成独立的词元(tokens)。
标准化(Normalization): 转换为小写、移除标点符号、处理数字等。
停用词移除(Stop Word Removal): 移除“的”、“是”、“了”等常见但不具信息量的词。
词干提取(Stemming)/词形还原(Lemmatization): 将单词还原为词干或原形(例如,“running”、“ran”都还原为“run”)。
构建映射: 创建一个字典,键是词项,值是包含该词项的文档列表(Posting List),Posting List 中通常包含文档 ID 和该词项在文档中的频率、位置信息。
检索过程: 当用户查询时,对查询进行同样的分词、标准化处理,然后查找倒排索引中每个查询词对应的 Posting List,合并这些列表,并根据词频、位置等信息进行排序。
优势: 检索速度极快,尤其适合大规模文档集。
代码示例(概念性,非 Haystack 实现):
# 假设一个简化的倒排索引结构
# {
# "word1": { "doc_id1": [pos1, pos2], "doc_id2": [pos3] },
# "word2": { "doc_id1": [pos4] }
# }
class SimpleInvertedIndex:
def __init__(self):
"""
初始化一个简单的倒排索引。
self.index 字典存储词项到其出现文档及位置的映射。
self.documents 字典存储文档 ID 到原文内容的映射。
"""
self.index = {
} # 倒排索引:{词项: {文档ID: [位置列表]}}
self.documents = {
} # 文档存储:{文档ID: 原文}
self.doc_count = 0 # 文档计数器
def add_document(self, text):
"""
添加一个新文档到索引。
:param text: 待索引的文档文本
:return: 文档 ID
"""
doc_id = self.doc_count # 分配新的文档ID
self.documents[doc_id] = text # 存储文档原文
self.doc_count += 1
words = self._tokenize(text) # 对文本进行分词
for i, word in enumerate(words): # 遍历每个词及其在文档中的位置
if word not in self.index:
self.index[word] = {
} # 如果词项不在索引中,则为其创建条目
if doc_id not in self.index[word]:
self.index[word][doc_id] = [] # 如果文档ID不在词项的映射中,则为其创建位置列表
self.index[word][doc_id].append(i) # 记录词项在文档中的位置
print(f"文档 ID {
doc_id} 已添加:'{
text}'") # 打印添加信息
return doc_id
def _tokenize(self, text):
"""
简化分词函数:转换为小写,按空格分割。
在实际应用中会更复杂,包括移除停用词、词干提取等。
:param text: 输入文本
:return: 词元列表
"""
return text.lower().split() # 将文本转换为小写并按空格分割成词元
def search(self, query):
"""
根据查询执行关键词搜索。
:param query: 查询字符串
:return: 包含相关文档ID及其相关性分数(简化版)的列表
"""
query_words = self._tokenize(query) # 对查询进行分词
candidate_docs = {
} # 存储候选文档及其词频计数 {文档ID: {词项: 频率}}
for word in query_words: # 遍历查询中的每个词
if word in self.index: # 如果词项在索引中
for doc_id, positions in self.index[word].items(): # 遍历包含该词项的文档
if doc_id not in candidate_docs:
candidate_docs[doc_id] = {
} # 如果文档是新发现的候选文档,则初始化其词频计数
candidate_docs[doc_id][word] = len(positions) # 记录该词项在该文档中的频率
results = [] # 存储最终的搜索结果
# 简单地根据匹配到的查询词数量作为分数
for doc_id, word_counts in candidate_docs.items():
score = sum(word_counts.values()) # 汇总所有匹配词的频率作为分数
results.append((doc_id, score, self.documents[doc_id])) # 添加到结果列表
results.sort(key=lambda x: x[1], reverse=True) # 根据分数降序排序
return results
# --- 示例用法 ---
inverted_index = SimpleInvertedIndex() # 创建倒排索引实例
doc1_id = inverted_index.add_document("Python 是一种强大的编程语言,用于数据科学。") # 添加文档1
doc2_id = inverted_index.add_document("Haystack 是一个开源的 NLP 框架,用于构建问答系统。") # 添加文档2
doc3_id = inverted_index.add_document("数据科学和机器学习是热门领域。") # 添加文档3
print("
执行搜索...") # 打印搜索提示
query = "Python 问答系统" # 定义查询
search_results = inverted_index.search(query) # 执行搜索
print(f"
查询: '{
query}'") # 打印查询
if search_results:
print("搜索结果:") # 打印搜索结果标题
for doc_id, score, content in search_results: # 遍历搜索结果
print(f" 文档 ID: {
doc_id}, 分数: {
score}, 内容: '{
content}'") # 打印文档ID、分数和内容
else:
print("未找到相关文档。") # 如果没有找到文档,则打印提示
print("
执行另一个搜索...") # 打印另一个搜索提示
query_2 = "机器学习" # 定义第二个查询
search_results_2 = inverted_index.search(query_2) # 执行搜索
print(f"
查询: '{
query_2}'") # 打印第二个查询
if search_results_2:
print("搜索结果:") # 打印搜索结果标题
for doc_id, score, content in search_results_2: # 遍历搜索结果
print(f" 文档 ID: {
doc_id}, 分数: {
score}, 内容: '{
content}'") # 打印文档ID、分数和内容
else:
print("未找到相关文档。") # 如果没有找到文档,则打印提示
代码说明:
SimpleInvertedIndex 类:一个简化版的倒排索引实现。
__init__(self):构造函数,初始化存储词项映射的 self.index 和存储原文的 self.documents 字典。
add_document(self, text):接收文本,对其进行分词,然后遍历词元并将其位置信息记录到 self.index 中。
_tokenize(self, text):一个简单的分词器,将文本转为小写并按空格分割。在实际中,这会是一个复杂的 NLP 管道,包含停用词、词干提取、词形还原等。
search(self, query):接收查询,对其进行分词,然后查找 self.index,聚合包含查询词的文档。在这个简化版中,文档的相关性分数是匹配到的查询词的频率总和。
示例用法展示了如何添加文档和执行查询,并打印结果。
BM25 (Best Matching 25):
原理: BM25 是一种基于概率的文本检索算法,是 TF-IDF(Term Frequency-Inverse Document Frequency)的改进版本,被广泛应用于搜索引擎。它通过以下几个关键因素计算文档与查询的相关性分数:
词频(Term Frequency, TF)饱和度: 词项在文档中出现的频率越高,其相关性分数越高,但 BM25 引入了饱和度机制,避免词频过高导致分数无限增长,即一个词在文档中出现几次后,再出现更多次对相关性提升的贡献会逐渐减小。
逆文档频率(Inverse Document Frequency, IDF): 词项在文档集合中出现的文档数量越少,其区分度越高,IDF 值越大,相关性分数越高。
文档长度归一化(Document Length Normalization): 考虑文档的长度。较短的文档如果包含查询词,可能比包含相同查询词但长度很长的文档更相关。BM25 对文档长度进行归一化,惩罚过长的文档。
优势: 相比简单的关键词匹配,BM25 能够更好地衡量词项的重要性,处理常见词和稀有词的权重差异,并且对文档长度有更合理的考量,因此效果更好。
劣势: 仍然是基于关键词的匹配,无法理解深层语义。如果查询词和文档中的词在字面上不匹配,但语义上相关,BM25 无法处理。
1.2.2 语义搜索的兴起:从字面到含义
随着深度学习和自然语言处理(NLP)技术的发展,人们开始意识到传统关键词匹配的局限性,并转向追求语义理解。语义搜索的核心思想是将文本(查询和文档)转换为高维向量(称为嵌入或 Embeddings),然后通过计算这些向量之间的相似度来衡量文本的语义相关性。
词嵌入(Word Embeddings):
原理: 将离散的词语映射到连续的、低维的向量空间中。在这个空间中,语义相似的词语(例如“国王”和“女王”)其向量距离也更近。
早期模型: Word2Vec (Skip-gram, CBOW), GloVe, FastText。这些模型通过上下文预测单词或基于共现统计来学习词向量。
局限性: 无法处理多义词(同一个词有多个含义),因为一个词只有一个固定的向量。也无法很好地表示整个句子或段落的含义。
稠密向量检索(Dense Vector Retrieval):
原理: 不仅将词语,而是将整个句子、段落甚至文档都转换为一个稠密的向量表示(Sentence Embeddings 或 Document Embeddings)。这些向量能够捕获整个文本的语义信息。然后,通过计算查询向量与文档向量之间的相似度(如余弦相似度、点积),来找到语义上最相关的文档。
Transformer 时代的嵌入:
BERT (Bidirectional Encoder Representations from Transformers) 及其变体(RoBERTa, ELECTRA 等):通过大规模预训练,能够生成上下文相关的词嵌入。但直接使用 BERT 的 CLS token 或平均所有词的嵌入作为句子嵌入效果不佳。
Sentence-BERT (SBERT):在 BERT 的基础上进行了微调,使得 BERT 能够生成语义上有意义的句子嵌入。SBERT 通过在 Siamese 网络架构(双塔结构)中使用对比学习(Contrastive Learning)来训练,使得语义相似的句子嵌入距离更近。
OpenAI Embeddings / Cohere Embeddings: 闭源 API 提供的强大嵌入服务,能够生成高质量的通用文本嵌入。
向量数据库(Vector Databases):
随着向量检索的兴起,专门用于存储、索引和高效查询高维向量的数据库应运而生。
主流产品包括:Pinecone, Weaviate, Milvus, Qdrant, Chroma 等。
这些数据库通常实现了一些近似最近邻(Approximate Nearest Neighbor, ANN)算法,如 HNSW (Hierarchical Navigable Small World)、IVF (Inverted File Index) 等,以在海量向量中实现快速相似度查询。
优势: 突破了关键词匹配的语义鸿沟,能够理解查询的真实意图,即使查询中没有出现文档中的关键词,只要语义相关也能被检索到。
劣势:
计算成本: 生成高质量的嵌入向量需要深度学习模型,计算开销较大。
存储成本: 稠密向量占用的存储空间通常大于倒排索引。
实时更新: 对于频繁变动的知识库,更新向量索引可能比更新倒排索引更复杂和耗时。
关键词缺失: 纯粹的语义搜索可能在某些需要精确关键词匹配的场景中表现不佳。
1.2.3 检索增强生成(RAG)的诞生:弥补 LLM 知识缺陷的范式革命
面对 LLM 的知识时效性、私有数据缺失和幻觉等核心局限性,研究人员提出了多种解决方案,其中包括:
微调(Fine-tuning): 在特定数据集上对 LLM 进行额外训练,使其适应特定任务或吸收新知识。
优点: 效果通常很好,模型能够真正“内化”新知识。
缺点: 成本高昂、耗时,每次更新知识都需要重新训练,无法处理快速变化的信息,且仍可能出现幻觉。
Prompt Engineering: 通过精心设计的 Prompt(例如,增加示例、使用 CoT 提示)来引导 LLM 给出更好的答案。
优点: 无需模型训练,灵活。
缺点: 效果有限,无法解决模型本身知识缺失或时效性问题。
**检索增强生成(Retrieval-Augmented Generation, RAG)**作为一种创新的范式,旨在结合信息检索的精确性与 LLM 的生成能力,以更高效、更灵活、更可靠的方式解决上述问题。RAG 的核心思想是,在生成答案之前,先从一个外部的、权威的、可更新的知识库中检索出相关的上下文信息,然后将这些信息作为额外输入提供给 LLM,引导其生成基于事实的、准确的答案。
RAG 并非取代 LLM,而是通过提供“外部记忆”和“事实依据”,极大地增强 LLM 的能力。它将 LLM 的“生成”能力与外部知识的“检索”能力解耦,使得知识的更新和管理更加高效和可控。
1.3 检索增强生成(RAG)的架构与工作原理
RAG 系统通常由两个主要阶段组成:索引构建(Indexing)和查询时检索与生成(Retrieval and Generation at Query Time)。
1.3.1 索引构建(Indexing):知识的组织与向量化
这个阶段是离线进行的,它的目标是将所有的原始知识(文档、文本、数据等)转换为 LLM 可以高效利用的格式,并存储在可检索的数据库中。
数据收集(Data Collection):
任务描述: 收集所有需要被 RAG 系统访问的知识源。这可以是企业内部文档(PDF、Word、Confluence 页面)、网站内容、数据库记录、维基百科条目、研究论文等。
关键考量: 数据质量、数据来源的权威性、数据规模。
数据预处理与分块(Data Preprocessing & Chunking):
任务描述: 将收集到的原始数据进行清洗、标准化,并切分成适合 LLM 处理的小块(Chunks)或段落(Passages)。
清洗: 移除不相关的 HTML 标签、广告、重复内容、噪音字符等。
分块策略: 这是 RAG 中非常关键的一步。
固定大小分块(Fixed-size Chunking): 将文档简单地切分成固定词元数量的块。最简单,但可能切断语义连贯的段落。
滑动窗口分块(Sliding Window Chunking): 在固定大小分块的基础上,引入重叠部分,确保语义的上下文不会因切分而丢失。
语义分块(Semantic Chunking): 尝试根据语义连贯性来切分文档,例如按标题、段落、句子。这可能需要 NLP 技术来识别语义边界。
递归分块(Recursive Chunking): 尝试多种分块策略,如果某个分块过大,则进一步细分。
关键考量: 块的大小应与 LLM 的上下文窗口大小、检索器的能力、问题类型相匹配。块太小可能丢失上下文,块太大可能导致检索不精确或超出 LLM 上下文限制。
代码示例(分块概念):
def simple_chunking(text, chunk_size, overlap_size=0):
"""
简单的固定大小滑动窗口分块。
:param text: 输入文本
:param chunk_size: 每个块的最大词元数
:param overlap_size: 块之间的重叠词元数
:return: 文本块列表
"""
words = text.split() # 简单按空格分词
chunks = []
start_index = 0
while start_index < len(words):
end_index = min(start_index + chunk_size, len(words)) # 计算当前块的结束索引
chunk = " ".join(words[start_index:end_index]) # 拼接词元成块
chunks.append(chunk) # 添加到块列表
if end_index == len(words): # 如果已经到达文本末尾
break
# 计算下一个块的起始索引,考虑重叠
start_index += (chunk_size - overlap_size)
# 确保 start_index 不会超出文本长度
start_index = max(0, start_index)
return chunks
document_text = "Haystack 是一个强大的开源框架,用于构建问答系统、语义搜索和企业级 LLM 应用。它提供了模块化的组件,包括文档存储、嵌入器、检索器、阅读器和生成器。这些组件可以灵活组合成管道,以实现复杂的RAG工作流。Haystack 的设计理念是生产就绪,支持多种 LLM 和向量数据库,并且易于扩展和维护。它使得开发者能够专注于 RAG 系统的核心逻辑和业务需求,而无需从头构建所有底层基础设施。未来的RAG将更加注重多模态和自适应能力。"
# 示例:固定大小分块,无重叠
print("--- 固定大小分块 (无重叠) ---")
chunks_no_overlap = simple_chunking(document_text, chunk_size=20)
for i, chunk in enumerate(chunks_no_overlap):
print(f"块 {
i+1} ({
len(chunk.split())} 词): {
chunk[:50]}...") # 打印每个块的前50个字符
# [Output Example]
# 块 1 (20 词): Haystack 是一个强大的开源框架,用于构建问答系统、语义搜索和企业级 LLM 应用。它提供了模块化的组件...
# 块 2 (20 词): 嵌入器、检索器、阅读器和生成器。这些组件可以灵活组合成管道,以实现复杂的RAG工作流。Haystack 的设计理念是生产就绪,支持多种 LLM...
# ...
# 示例:固定大小分块,有重叠
print("
--- 固定大小分块 (有重叠) ---")
chunks_with_overlap = simple_chunking(document_text, chunk_size=20, overlap_size=5)
for i, chunk in enumerate(chunks_with_overlap):
print(f"块 {
i+1} ({
len(chunk.split())} 词): {
chunk[:50]}...")
# [Output Example]
# 块 1 (20 词): Haystack 是一个强大的开源框架,用于构建问答系统、语义搜索和企业级 LLM 应用。它提供了模块化的组件...
# 20 - 5 = 15
# 块 2 (20 词): 化组件,包括文档存储、嵌入器、检索器、阅读器和生成器。这些组件可以灵活组合成管道,以实现复杂的RAG工作流。Haystack 的设计理念是生产就绪...
# ...
# Haystack 中的 PreProcessor 组件提供了更完善的分块功能,支持 SentenceTransformers 等
# 更多高级分块策略(例如:语义分块、递归分块)将在后续章节 Haystack 评估与优化 中详细探讨。
代码说明:
simple_chunking(text, chunk_size, overlap_size=0):一个基础的分块函数,它将文本按空格简单分词,然后以固定 chunk_size 进行切割,并支持 overlap_size 来设置块之间的重叠。
words = text.split():将输入的 text 字符串按空格分割成单词列表。这在实际 NLP 应用中会是一个更复杂的“分词器”。
start_index, end_index:用于控制当前块的起始和结束单词索引。
chunk = " ".join(words[start_index:end_index]):将选定范围内的单词重新拼接成一个文本块。
start_index += (chunk_size - overlap_size):计算下一个块的起始索引,通过减去 overlap_size 实现块之间的重叠。
示例部分展示了无重叠和有重叠的分块效果对比。
嵌入(Embedding):
任务描述: 使用一个**嵌入模型(Embedder/Encoder)**将每个文本块(Chunk)转换为一个稠密的向量表示(Embedding)。这些向量捕获了文本块的语义信息。
模型选择: 通常使用预训练的句子嵌入模型,如 Sentence-BERT 系列模型(all-MiniLM-L6-v2)、OpenAI 的 text-embedding-ada-002 等。
关键考量: 嵌入模型的质量直接影响检索效果。选择与领域数据匹配、性能高、计算效率好的模型至关重要。
向量维度: 不同的嵌入模型会生成不同维度的向量(例如,Sentence-BERT 通常是 384、768 或 1024 维;OpenAI Ada 是 1536 维)。
存储(Storage):
任务描述: 将原始文本块、它们的元数据(如来源、ID、页码、时间戳等)以及对应的嵌入向量存储在**文档存储(Document Store)**中。
数据库类型:
向量数据库(Vector Databases): 专门为存储和高效查询高维向量而优化,如 Pinecone, Weaviate, Milvus, Qdrant, Chroma, Vespa, Vald 等。它们通常内置了 ANN 索引算法,能够快速找到与查询向量最相似的 K 个文档。
混合搜索数据库(Hybrid Search Databases): 如 Elasticsearch,它既支持传统的倒排索引进行关键词搜索(BM25),也支持通过插件或内置功能进行向量相似度搜索。
关系型数据库/NoSQL 数据库: 也可以存储文本和元数据,但通常不直接支持高效的向量相似度搜索,需要额外集成向量搜索库(如 Faiss)。
关键考量: 存储容量、查询速度、数据一致性、可伸缩性、数据更新策略。
1.3.2 查询时检索与生成(Retrieval and Generation at Query Time):智能的问答循环
当用户提交一个问题时,RAG 系统会执行以下实时步骤来生成答案:
查询嵌入(Query Embedding):
任务描述: 使用与索引构建阶段相同的嵌入模型,将用户输入的自然语言查询转换为一个稠密的查询向量。
重要性: 确保查询和文档处于同一个语义向量空间中,以便进行准确的相似度比较。
检索(Retrieval):
任务描述: 使用查询向量在文档存储中执行相似度搜索,从海量文档块中找出与查询语义最相关的 K 个**顶部(Top-K)**文档块。
检索器(Retriever): Haystack 中的核心组件,负责执行这个查找过程。它可以是纯稠密检索器(如 DensePassageRetriever)、纯稀疏检索器(如 BM25Retriever),或两者结合的混合检索器。
返回结果: 通常是一个包含相关文档块文本和它们的相似度分数、元数据的列表。
增强(Augmentation):
任务描述: 将检索到的相关文档块(Context)与用户的原始查询拼接起来,形成一个增强后的 Prompt。
Prompt 结构: 通常会是这样的格式:
请根据以下提供的上下文信息,回答我的问题。如果上下文没有包含足够的信息,请说明。
上下文:
[检索到的文档块 1]
[检索到的文档块 2]
...
[检索到的文档块 K]
问题:
[用户的原始问题]
回答:
关键考量: 上下文的组织方式、指令的清晰度、提示词工程(Prompt Engineering)技巧。
生成(Generation):
任务描述: 将增强后的 Prompt 输入到预训练的**大语言模型(LLM)**中,由 LLM 根据提供的上下文和问题,生成一个自然语言的答案。
LLM 角色: LLM 在这里充当一个“阅读理解者”和“文本生成者”。它不再需要“回忆”知识,而是“阅读”提供的上下文,并从这些上下文中“提取”或“归纳”出答案,然后以流畅的语言形式呈现。
生成器(Generator): Haystack 中的组件,负责与 LLM 进行交互。
输出: 最终的自然语言答案。
1.3.3 RAG 的优势: LLM 的最佳拍档
RAG 架构通过巧妙地结合检索与生成,有效地解决了 LLM 的核心局限性,并带来了以下显著优势:
减少幻觉(Reduced Hallucination):
实现机制: 通过提供事实性的、经过验证的上下文信息,RAG 强制 LLM 专注于提供的知识,而不是依赖其内部的模糊记忆或“猜测”。LLM 被引导去“阅读”而非“编造”。
效果: 大幅降低了 LL觉发生的概率,提高了答案的准确性和可靠性。
提升准确性与相关性(Improved Accuracy & Relevance):
实现机制: 检索器能够从海量数据中精确找到与查询语义最相关的文档块,这些高质量的上下文直接作为 LLM 的输入,确保 LLM 基于最相关的信息进行推理。
效果: 即使是复杂的、专业性强的问题,也能获得更精确、更符合事实的答案。
知识更新与管理高效(Efficient Knowledge Update & Management):
实现机制: 知识的更新仅需要更新文档存储中的文档和其嵌入向量,无需重新训练或微调庞大的 LLM。
效果: 对于信息快速变化的领域,可以实现几乎实时的知识更新,例如,每天同步最新的新闻、财报或产品文档,而无需承担高昂的训练成本。
处理私有与专有数据(Handling Private & Proprietary Data):
实现机制: 知识库可以完全由企业内部的私有数据构建,并且可以部署在受控的环境中。LLM 只在接收到检索到的相关上下文后才进行推理。
效果: 使得 LLM 能够安全、合规地回答关于企业内部政策、客户信息、项目文档等专有数据的问题,极大地拓展了 LLM 在企业内部的应用场景。
可解释性增强(Enhanced Interpretability):
实现机制: RAG 系统在生成答案时,会明确引用其检索到的上下文信息。用户不仅可以看到答案,还可以追溯答案的来源文档片段。
效果: 提供了答案的“出处”,增强了系统的透明度和用户的信任度。这在需要高可解释性的领域(如法律、医疗)尤为重要。
突破上下文窗口限制(Overcoming Context Window Limitations):
实现机制: RAG 系统首先通过检索器从大量文档中精选出最相关的少数几个小文档块(这些文档块的总长度通常在 LLM 的上下文窗口之内),而不是将整个长文档或整个知识库一次性喂给 LLM。
效果: LLM 只需要处理精炼过的、高度相关的上下文,避免了上下文窗口的限制,同时确保了模型只接收到关键信息。
成本效益(Cost-Effectiveness):
实现机制: 相较于每次知识更新都进行昂贵的 LLM 微调或重新训练,RAG 仅需更新文档存储和嵌入。
效果: 显著降低了 LLM 应用的维护成本和迭代周期。
总之,RAG 提供了一种优雅而强大的方式,将 LLM 的通用生成能力与特定领域的精确知识相结合,构建出更智能、更可靠、更实用的 AI 问答和内容生成系统。
1.3.4 RAG 的挑战:通往完善之路
尽管 RAG 具有诸多优势,但在实际部署和优化过程中,仍然面临一些挑战:
检索质量(Retrieval Quality):
问题描述: 如果检索器未能召回与查询真正相关的文档块(即“召回失败”),或者召回了大量不相关的“噪音”文档块,那么即使是最强大的 LLM 也无法生成准确的答案。这就是“垃圾进,垃圾出”(Garbage In, Garbage Out)的原则。
优化方向: 优化嵌入模型、检索器算法(例如,使用混合检索、Reranker)、数据分块策略、查询扩展技术。
上下文窗口优化与信息密度(Context Window Optimization & Information Density):
问题描述: 虽然 RAG 突破了 LLM 的上下文窗口限制,但如果检索到的上下文信息过多、冗余或组织不当,仍然会影响 LLM 的性能。LLM 在处理长上下文时,可能存在“迷失在中间”(Lost in the Middle)的问题,即对上下文中间部分的信息已关注度不足。
优化方向: 精细化分块、上下文压缩、Reranker 排序、Prompt Engineering 引导 LLM 已关注关键信息。
实时性(Real-time Performance):
问题描述: RAG 系统在查询时需要执行两次模型推理(查询嵌入、LLM 生成)和一次向量检索。对于某些对延迟要求极高的实时应用,整个过程的延迟可能仍然过高。
优化方向: 高性能的向量数据库、优化模型推理速度(模型量化、剪枝、硬件加速)、缓存机制、异步处理。
多模态 RAG(Multimodal RAG):
问题描述: 当前的 RAG 主要处理文本数据。但真实世界的知识是多模态的(图像、视频、音频、表格、图表)。如何有效地从这些多模态数据中检索信息,并将其整合到 LLM 的生成过程中,是一个复杂的研究方向。
优化方向: 多模态嵌入模型、多模态索引技术、多模态上下文融合策略。
知识更新的复杂性:
问题描述: 虽然 RAG 简化了知识更新,但对于超大规模的知识库,如何高效地进行增量更新、版本管理、数据同步和索引重建,仍然是工程上的挑战。
优化方向: 增量索引、分布式索引、数据管道自动化。
安全性与合规性:
问题描述: 在企业级应用中,如何确保数据的安全、隐私和合规性(例如,数据隔离、访问控制、敏感信息过滤)是重中之重。
优化方向: 权限管理、数据脱敏、模型安全评估、可信执行环境。
理解这些挑战将帮助我们更好地设计和优化 RAG 系统,特别是在使用 Haystack 框架时,如何选择合适的组件和配置,以应对特定的业务需求。
第二章:Haystack 框架的哲学与生态
引言:RAG 的乐高积木——Haystack 框架
在理解了 RAG 的基本原理和 LLM 的能力边界之后,我们现在将目光转向实现这些复杂系统所需的工具。Haystack 正是这样一款为构建 RAG、问答系统和企业级 LLM 应用而设计的强大的 Python 框架。它将 RAG 的各个复杂步骤抽象为模块化的组件,并提供灵活的管道(Pipeline)机制将它们连接起来,使得开发者可以像玩乐高积木一样,快速搭建、迭代和部署各种复杂的 AI 知识系统。
本章将深入探讨 Haystack 的设计哲学、核心组件概览及其在整个 RAG 生态系统中的定位。理解 Haystack 的设计思想,将帮助您更好地利用其提供的工具,并进行高级定制。
2.1 Haystack 的愿景与设计理念
Haystack 由 Deepset 团队开发并开源,其核心愿景是使开发者能够轻松构建生产就绪的 LLM 应用。它不仅仅是一个简单的 API 调用工具,而是一个集成了数据处理、索引、检索、阅读、生成、评估和部署的端到端框架。
2.1.1 模块化与可扩展性:管道(Pipelines)的概念
解耦设计: Haystack 的首要设计原则是模块化。它将 RAG 流程中的每个独立功能(如文档加载、清洗、嵌入、检索、阅读、生成)都封装为独立的、可互换的组件(Components)或节点(Nodes)。
优势: 这种解耦使得每个组件都可以独立开发、测试和优化。例如,你可以尝试不同的嵌入模型,而无需改变检索器或阅读器。
可扩展性: 当有新的技术(如新的 LLM、更高效的向量数据库、更先进的检索算法)出现时,你可以很容易地将其作为新的组件集成到 Haystack 中,而不会影响现有系统的其他部分。
管道(Pipelines):
概念: 管道是 Haystack 中用于编排和执行这些组件的核心概念。它定义了数据在组件之间流动的顺序和方式。一个管道就像一个工作流,数据从一个组件的输出流向另一个组件的输入。
灵活性: Haystack 提供了多种管道类型,可以实现线性的(串行执行)、分支的(根据条件选择路径)和并行的(同时执行多个组件)数据流。这使得构建复杂的 RAG 变体变得轻而易举。
数据流: 管道中的每个组件都有明确定义的输入和输出类型,Haystack 会在组件之间自动处理数据传递。
代码示例(概念性,非 Haystack 实际代码):
# 伪代码:一个概念性的管道
class AbstractPipeline:
def __init__(self):
"""初始化一个抽象的管道结构。"""
self.nodes = {
} # 存储管道中的节点
self.connections = [] # 存储节点之间的连接
def add_node(self, node_instance, name, inputs=None):
"""
向管道中添加一个节点。
:param node_instance: 节点的实例(例如,一个检索器对象)
:param name: 节点的唯一名称
:param inputs: 节点的输入连接,通常是 {输入端口名称: (上游节点名称, 上游节点输出端口名称)}
"""
self.nodes[name] = {
"instance": node_instance, "inputs": inputs or {
}} # 将节点实例、名称和输入信息存储起来
print(f"节点 '{
name}' 已添加到管道。") # 打印添加信息
def connect(self, from_node, to_node, from_port="output", to_port="input"):
"""
连接两个节点。
:param from_node: 源节点名称
:param to_node: 目标节点名称
:param from_port: 源节点的输出端口名称 (默认为 'output')
:param to_port: 目标节点的输入端口名称 (默认为 'input')
"""
if from_node not in self.nodes or to_node not in self.nodes:
raise ValueError("源节点或目标节点不存在。") # 检查节点是否存在
self.connections.append((from_node, from_port, to_node, to_port)) # 记录连接信息
print(f"节点 '{
from_node}' ({
from_port}) 连接到 '{
to_node}' ({
to_port})。") # 打印连接信息
def run(self, input_data):
"""
运行管道(概念性,实际运行逻辑复杂得多)。
:param input_data: 管道的初始输入数据
:return: 最终输出
"""
print("--- 管道开始运行 ---") # 打印开始运行信息
# 实际 Haystack 管道运行涉及拓扑排序、节点执行、数据传递等
# 这是一个高度简化的示意
results = input_data
print(f"初始输入: {
results}") # 打印初始输入
# 模拟按照连接顺序执行
# 真实管道有更复杂的调度逻辑,例如基于拓扑排序
# 假设我们有一个预定义的执行顺序
execution_order = ["document_store", "retriever", "reader", "generator"]
for node_name in execution_order: # 遍历执行顺序中的每个节点
if node_name not in self.nodes:
continue # 如果节点不存在,则跳过
node_instance = self.nodes[node_name]["instance"] # 获取节点实例
print(f"正在执行节点: '{
node_name}'...") # 打印正在执行的节点
# 模拟节点执行,实际会根据节点类型和输入进行复杂操作
if node_name == "retriever":
# 检索器会从文档存储获取数据并处理查询
# 假设 input_data 是查询,results 是文档
print(f" (检索器) 接收查询: {
results}")
results = f"检索结果 for '{
results}'" # 模拟检索结果
elif node_name == "reader":
# 阅读器会从检索结果中阅读答案
print(f" (阅读器) 接收检索结果: {
results}")
results = f"答案提取 from '{
results}'" # 模拟答案提取
elif node_name == "generator":
# 生成器会基于答案生成回复
print(f" (生成器) 接收提取答案: {
results}")
results = f"生成的回复: '{
results}'" # 模拟生成回复
else:
print(f" (未知节点类型) 处理数据: {
results}") # 处理未知节点类型
print(f" 节点 '{
node_name}' 输出: {
results}") # 打印节点输出
print("--- 管道运行结束 ---") # 打印运行结束信息
return results
# --- 示例用法 ---
# 模拟 Haystack 组件
class MockDocumentStore:
def __init__(self):
"""模拟文档存储。"""
print("MockDocumentStore 初始化。") # 打印初始化信息
class MockRetriever:
def __init__(self):
"""模拟检索器。"""
print("MockRetriever 初始化。") # 打印初始化信息
class MockReader:
def __init__(self):
"""模拟阅读器。"""
print("MockReader 初始化。") # 打印初始化信息
class MockGenerator:
def __init__(self):
"""模拟生成器。"""
print("MockGenerator 初始化。") # 打印初始化信息
# 创建抽象管道实例
pipeline = AbstractPipeline()
# 添加组件到管道
pipeline.add_node(MockDocumentStore(), "document_store") # 添加文档存储节点
pipeline.add_node(MockRetriever(), "retriever") # 添加检索器节点
pipeline.add_node(MockReader(), "reader") # 添加阅读器节点
pipeline.add_node(MockGenerator(), "generator") # 添加生成器节点
# 概念性连接 (在 Haystack 中,这些连接是更明确的数据流)
# pipeline.connect("document_store", "retriever") # 文档存储通常是检索器的内部依赖,而非直接的输入连接
# pipeline.connect("retriever", "reader")
# pipeline.connect("reader", "generator")
# 运行管道
final_answer = pipeline.run(input_data="LLM 的局限性是什么?") # 运行管道,输入一个查询
print(f"
最终管道输出: {
final_answer}") # 打印最终输出
代码说明:
AbstractPipeline 类:一个抽象的管道概念实现,用于演示节点和连接。
add_node(self, node_instance, name, inputs=None):向管道中添加一个组件实例,并为其指定一个唯一的名称。inputs 参数在这里是概念性的,在 Haystack 中,节点输入和输出是严格定义的。
connect(self, from_node, to_node, from_port="output", to_port="input"):定义节点之间的连接。在 Haystack 的 Pipeline 中,连接定义了数据如何从一个节点的输出流向另一个节点的输入。
run(self, input_data):模拟管道的执行过程。它展示了数据如何从初始输入开始,依次经过各个节点,每个节点对其输入数据进行处理并产生输出,最终得到管道的最终结果。这里为了简化,使用了一个预定义的执行顺序,并且每个节点只是简单地将输入数据包装成字符串。
MockDocumentStore, MockRetriever, MockReader, MockGenerator:这些是 Haystack 核心组件的模拟类,用于在抽象管道中占位,演示组件的模块化。
示例用法展示了如何创建管道,添加模拟组件,并概念性地运行管道,观察数据流动的模拟过程。
2.1.2 生产就绪(Production-ready):工程化考量
Haystack 的设计目标不仅仅是用于研究原型,更是为了生产环境。这意味着它在架构中融入了多项工程化考量:
性能优化:
批量处理(Batching): 许多组件(特别是嵌入器和阅读器)支持批量处理数据。这意味着你可以一次性处理多个文档或查询,从而充分利用硬件(如 GPU)的并行计算能力,提高吞吐量。
GPU 加速: 深度学习模型(嵌入器、阅读器、生成器)可以方便地配置为在 GPU 上运行,显著加速推理速度。
异步处理(Asynchronous Processing): 框架内部或通过外部库(如 Ray)支持异步操作,避免阻塞,提高并发性。
可伸缩性(Scalability):
分布式组件: 支持连接到外部的分布式存储(如 Elasticsearch, Pinecone)和分布式推理服务。
管道分布式执行: 可以通过集成 Ray 等分布式计算框架,将整个管道的执行任务分散到多个节点上并行处理,实现大规模部署。
日志与监控: 内置了日志记录机制,方便调试和监控系统运行状态。
错误处理: 提供了健壮的错误处理机制,可以在组件级别捕获异常,并提供详细的错误信息。
2.1.3 灵活的组件组合:Node(节点)与Component(组件)
在 Haystack 中,“节点”(Node)和“组件”(Component)这两个术语有时会互换使用,但理解它们的微妙区别有助于更好地理解 Haystack 的架构:
组件(Component): 指的是实现特定功能的独立模块(如 Retriever, Reader, DocumentStore 等)。它们是 Haystack 的构建块,是实际执行任务的逻辑单元。
节点(Node): 是组件在 Pipeline 中的实例化和封装。一个节点除了包含组件的逻辑,还定义了它在管道中的输入和输出端口。在构建管道时,我们实际上是将这些“节点”连接起来。
Haystack 的核心设计思想是,你可以从其丰富的组件库中选择,并将它们以任意有效的方式组合起来,以满足你的特定需求。
选择性使用: 你可以只使用 Haystack 的检索器来增强你现有的 LLM 应用,或者只使用其阅读器来从文档中提取答案。
自定义组件: 如果 Haystack 内置的组件无法满足需求,你可以轻松地创建自定义组件,并将其集成到管道中。这使得 Haystack 具有极高的适应性。
2.2 Haystack 核心组件概览
Haystack 将 RAG 流程中的关键功能抽象为以下几个核心组件。
2.2.1 文档(Document)与文档存储(DocumentStore)
文档(Document):
概念: Haystack 中信息的最小逻辑单元。它通常代表一个文本块、一个段落或一个独立的文件内容。
结构: 包含 content(文本内容)、meta(一个字典,用于存储额外元数据,如来源、页码、URL、作者、时间戳、分类标签等)和可选的 id。content 也可以是包含嵌入向量的字节。
作用: 作为 Haystack 中所有数据流动的基本单位,无论是加载、处理、存储还是检索、生成,都是围绕 Document 对象进行的。
代码示例:
from haystack.schema import Document # 从 haystack 库中导入 Document 类
# 创建一个最简单的 Document 对象
doc1 = Document(content="Haystack 是一个开源的 NLP 框架。") # 只包含文本内容
print(f"文档 1 内容: '{
doc1.content}'") # 访问文档内容
print(f"文档 1 元数据: {
doc1.meta}") # 访问文档元数据 (默认为空字典)
print(f"文档 1 ID: {
doc1.id}") # 访问文档 ID (默认为随机生成的 UUID)
# 创建一个带有元数据和自定义 ID 的 Document 对象
doc2 = Document(
content="RAG 结合了信息检索和语言模型生成能力,以减少幻觉。", # 文档内容
meta={
"source": "Haystack官方文档", "chapter": "RAG原理", "page": 10}, # 包含来源、章节和页码的元数据
id="rag_doc_001" # 自定义文档 ID
)
print(f"
文档 2 内容: '{
doc2.content}'") # 访问文档内容
print(f"文档 2 元数据: {
doc2.meta}") # 访问文档元数据
print(f"文档 2 ID: {
doc2.id}") # 访问文档 ID
# Document 也可以包含嵌入向量 (在生成嵌入后赋值)
import numpy as np # 导入 numpy 库
embedding_vector = np.random.rand(768).astype(np.float32) # 生成一个 768 维的随机浮点型向量
doc3 = Document(
content="这是一个将被嵌入的文本。", # 文档内容
embedding=embedding_vector, # 将嵌入向量赋值给 Document 对象的 embedding 属性
meta={
"source": "generated_data"} # 元数据
)
print(f"
文档 3 内容: '{
doc3.content}'") # 访问文档内容
print(f"文档 3 嵌入向量维度: {
doc3.embedding.shape}") # 访问嵌入向量的维度
print(f"文档 3 嵌入向量类型: {
doc3.embedding.dtype}") # 访问嵌入向量的数据类型
代码说明:
from haystack.schema import Document:导入 Haystack 提供的 Document 类。
Document(content=...):最基本的 Document 创建方式,只包含文本内容。id 和 meta 字段会自动初始化。
Document(content=..., meta={...}, id=...):展示了如何通过 meta 字典添加自定义元数据,并通过 id 参数设置自定义的文档 ID。
doc3.embedding=embedding_vector:演示了在生成嵌入向量后,如何将其赋值给 Document 对象的 embedding 属性。这个 embedding 属性是后续向量检索的关键。
文档存储(DocumentStore):
概念: 负责持久化存储 Document 对象,并提供高效的读取、写入、更新和删除操作。它是 RAG 系统的核心知识库。
功能: 不仅存储原始文本和元数据,还负责存储和管理文档的嵌入向量,并在向量数据库中构建索引以支持快速相似度搜索。
类型: Haystack 支持多种 DocumentStore 实现,包括内存型 (InMemoryDocumentStore)、本地文件型 (FAISSDocumentStore,可持久化到文件)、以及各种外部数据库型 (ElasticsearchDocumentStore, PineconeDocumentStore, WeaviateDocumentStore, MilvusDocumentStore 等)。
代码示例:
from haystack.document_stores import InMemoryDocumentStore # 导入内存文档存储
# 初始化一个 InMemoryDocumentStore
# use_embedding_storage: 如果要存储和检索向量,则需要设置为 True
document_store = InMemoryDocumentStore(use_embedding_storage=True)
print("InMemoryDocumentStore 已初始化。") # 打印初始化信息
# 准备一些 Document 对象
docs = [
Document(content="Python 是一种高级编程语言,广泛用于 Web 开发和数据分析。", meta={
"source": "wiki", "category": "编程"}),
Document(content="机器学习是人工智能的一个分支,涉及算法学习模式。", meta={
"source": "wiki", "category": "AI"}),
Document(content="Haystack 简化了 NLP 管道的构建。", meta={
"source": "deepset_blog", "category": "NLP"}),
Document(content="RAG 系统通过检索外部知识来增强 LLM。", meta={
"source": "blog", "category": "AI"})
]
# 写入文档到文档存储
document_store.write_documents(docs)
print(f"
已写入 {
len(docs)} 个文档到 DocumentStore。") # 打印写入文档数量
# 获取所有文档
all_docs = document_store.get_all_documents()
print("
DocumentStore 中的所有文档:") # 打印所有文档标题
for doc in all_docs:
print(f" ID: {
doc.id}, 内容: '{
doc.content[:30]}...', 元数据: {
doc.meta}") # 打印文档信息
# 根据 ID 获取特定文档
# 注意:对于 InMemoryDocumentStore,ID 通常是内部生成的 UUID,这里我们使用写入后返回的 ID
# 对于自定义 ID 的 Document,可以直接用自定义 ID 查询
doc_id_to_retrieve = docs[0].id # 获取第一个文档的 ID
retrieved_doc = document_store.get_document_by_id(doc_id_to_retrieve)
print(f"
根据 ID '{
doc_id_to_retrieve}' 检索到的文档: '{
retrieved_doc.content[:50]}...'") # 打印检索到的文档内容
# 过滤文档(基于元数据)
filtered_docs = document_store.get_all_documents(filters={
"category": ["AI"]}) # 过滤出 category 为 "AI" 的文档
print("
过滤后的文档 (Category: AI):") # 打印过滤后的文档标题
for doc in filtered_docs:
print(f" ID: {
doc.id}, 内容: '{
doc.content[:30]}...', 元数据: {
doc.meta}") # 打印文档信息
# 删除文档
document_store.delete_documents(ids=[docs[1].id]) # 删除第二个文档
print(f"
已删除文档 ID: {
docs[1].id}") # 打印删除信息
remaining_docs = document_store.get_all_documents()
print(f"剩余文档数量: {
len(remaining_docs)}") # 打印剩余文档数量
代码说明:
from haystack.document_stores import InMemoryDocumentStore:导入 Haystack 提供的内存文档存储实现。
InMemoryDocumentStore(use_embedding_storage=True):初始化一个内存文档存储实例。use_embedding_storage=True 告诉存储它需要能够保存和管理文档的嵌入向量。
document_store.write_documents(docs):将 Document 对象列表写入到文档存储中。这是将知识库加载到 RAG 系统中的关键步骤。
document_store.get_all_documents():获取文档存储中所有文档。
document_store.get_document_by_id(doc_id):根据文档的唯一 ID 检索特定文档。
document_store.get_all_documents(filters={...}):根据元数据进行过滤查询。这里演示了如何查询 category 为 “AI” 的文档。这在真实应用中非常有用,例如过滤特定日期、来源或标签的文档。
document_store.delete_documents(ids=[...]):根据 ID 删除文档。
2.2.2 嵌入器(Embedder/Encoder)
概念: 负责将文本(查询或文档内容)转换为高维的浮点数向量(Embedding)。
作用: 使得文本在向量空间中可以进行语义相似度比较。
类型: Haystack 支持多种嵌入器,包括基于 Hugging Face transformers 库的本地模型(如 SentenceTransformersDocumentEmbedder)、以及通过 API 调用的云服务模型(如 OpenAIDocumentEmbedder)。
代码示例:
# 嵌入器通常与文档存储和检索器结合使用,这里只演示其独立功能
from haystack.nodes import SentenceTransformersDocumentEmbedder, SentenceTransformersQueryEmbedder # 导入嵌入器
# 初始化一个 SentenceTransformersDocumentEmbedder
# model_name_or_path: 指定 Hugging Face 模型名称,如 'all-MiniLM-L6-v2'
# device: 'cpu' 或 'cuda' (如果可用 GPU)
# batch_size: 批量处理文档的数量,以提高效率
document_embedder = SentenceTransformersDocumentEmbedder(
model_name_or_path="sentence-transformers/all-MiniLM-L6-v2", # 使用一个常用的句子嵌入模型
device="cpu", # 在 CPU 上运行 (如果安装了 CUDA 和 PyTorch GPU 版本,可设置为 'cuda')
batch_size=16 # 批量处理大小
)
print("文档嵌入器已初始化。") # 打印初始化信息
# 初始化一个 SentenceTransformersQueryEmbedder
# 查询嵌入器通常与文档嵌入器使用相同的模型,以确保向量空间一致
query_embedder = SentenceTransformersQueryEmbedder(
model_name_or_path="sentence-transformers/all-MiniLM-L6-v2",
device="cpu"
)
print("查询嵌入器已初始化。") # 打印初始化信息
# 准备一些 Document 对象 (通常这些 Document 会从 DocumentStore 中获取)
docs_to_embed = [
Document(content="关于人工智能的最新发展"),
Document(content="深度学习在自然语言处理中的应用")
]
# 使用文档嵌入器为文档生成嵌入
# run 方法会返回一个包含嵌入向量的 Document 列表
# 注意:在 Haystack 管道中,嵌入器会直接更新 Document 对象的 .embedding 属性
embedded_docs = document_embedder.run(documents=docs_to_embed)
print("
已为文档生成嵌入:") # 打印嵌入信息
for doc in embedded_docs[0]["documents"]: # run 方法返回的是一个字典,键是 'documents'
print(f" 内容: '{
doc.content[:20]}...', 嵌入维度: {
doc.embedding.shape}") # 打印文档内容和嵌入维度
# 使用查询嵌入器为查询生成嵌入
query_text = "AI 技术的前沿进展"
query_embedding_result = query_embedder.run(query=query_text)
# query_embedding_result 的结构是 { 'query_embedding': numpy_array }
query_embedding = query_embedding_result[0]["query_embedding"]
print(f"
查询 '{
query_text}' 已生成嵌入,维度: {
query_embedding.shape}") # 打印查询和嵌入维度
代码说明:
from haystack.nodes import SentenceTransformersDocumentEmbedder, SentenceTransformersQueryEmbedder:导入 Haystack 提供的基于 Sentence Transformers 的文档嵌入器和查询嵌入器。
model_name_or_path="sentence-transformers/all-MiniLM-L6-v2":指定使用的预训练模型。这是 Hugging Face 模型 Hub 上的一个常用模型,用于生成句子嵌入。
device="cpu":指定模型运行的设备。如果你的系统有 GPU 且安装了 CUDA 兼容的 PyTorch 或 TensorFlow,可以设置为 cuda 来加速。
batch_size=16:在处理多个文档时,可以设置批量大小,以提高 GPU 的利用率和处理效率。
document_embedder.run(documents=docs_to_embed):调用文档嵌入器的 run 方法,传入一个 Document 列表。它会返回一个字典,其中包含带有 embedding 属性的 Document 对象。
query_embedder.run(query=query_text):调用查询嵌入器的 run 方法,传入查询字符串。它会返回一个字典,其中包含查询的嵌入向量。
2.2.3 检索器(Retriever)
概念: 负责从 DocumentStore 中根据查询检索出最相关的 Document。
作用: 过滤掉大量不相关的文档,只将最有可能包含答案的上下文传递给阅读器或生成器,显著提高效率和准确性。
类型:
稀疏检索器: 如 BM25Retriever,基于关键词匹配。
稠密检索器: 如 DensePassageRetriever 或 EmbeddingRetriever,基于向量相似度匹配。
混合检索器: 结合稀疏和稠密检索的优势。
代码示例:
# 检索器需要一个 DocumentStore,这里我们使用之前填充好的 InMemoryDocumentStore
from haystack.nodes import EmbeddingRetriever # 导入嵌入检索器
# 1. 确保 DocumentStore 中有嵌入向量
# 在实际应用中,你需要一个文档存储,并且其中的文档都已经被嵌入
# 假设 document_store 已经包含了带嵌入的文档(如通过文档嵌入器处理后写入)
# 在这里我们简单地再次添加一些文档,并模拟它们有嵌入,方便演示
docs_for_retrieval = [
Document(content="Python 是一种多范式编程语言。", embedding=np.random.rand(768).astype(np.float32)),
Document(content="强化学习是机器学习的一个子领域。", embedding=np.random.rand(768).astype(np.float32)),
Document(content="深度学习模型需要大量数据进行训练。", embedding=np.random.rand(768).astype(np.float32)),
Document(content="神经网络是深度学习的基础结构。", embedding=np.random.rand(768).astype(np.float32))
]
# 在真实场景中,这些嵌入会由 DocumentEmbedder 实际生成并写入 DocumentStore
document_store.write_documents(docs_for_retrieval)
print("
DocumentStore 已更新,包含带嵌入的文档。") # 打印更新信息
# 2. 初始化 EmbeddingRetriever
# embedding_model: 用于将查询嵌入的模型,与文档嵌入器使用相同的模型
# document_store: 检索器将从这个文档存储中检索
# top_k: 返回最相关的文档数量
retriever = EmbeddingRetriever(
document_store=document_store, # 指定文档存储
embedding_model="sentence-transformers/all-MiniLM-L6-v2", # 与文档嵌入相同的模型
model_format="sentence_transformers", # 模型格式
top_k=2 # 检索最相关的 2 个文档
)
print("嵌入检索器已初始化。") # 打印初始化信息
# 3. 模拟为新文档生成嵌入并更新到 DocumentStore
# 在实际 RAG 流程中,文档的嵌入在写入 DocumentStore 时就已经完成
# 这里我们只是为了让 InMemoryDocumentStore 的文档有真实的嵌入,以便检索器工作
# 注意:InMemoryDocumentStore 的 update_embeddings 方法需要模型和文档存储同时支持
# 更常见的是在写入时就完成嵌入。
# 对于 FAISSDocumentStore 或其他向量数据库,它们在写入时就会管理嵌入。
# 这里我们假设文档已经有嵌入,或者使用一个预处理器来添加嵌入
# 4. 执行检索
query_for_retrieval = "神经网络和深度学习"
# run 方法返回一个字典,其中包含 'documents' 键,对应检索到的 Document 列表
retrieved_docs_result = retriever.run(query=query_for_retrieval)
retrieved_documents = retrieved_docs_result[0]["documents"] # 获取检索到的文档列表
print(f"
查询: '{
query_for_retrieval}'") # 打印查询
print("检索到的文档:") # 打印检索到的文档标题
for doc in retrieved_documents:
print(f" ID: {
doc.id}, 分数: {
doc.score:.4f}, 内容: '{
doc.content[:50]}...'") # 打印文档ID、分数和内容
代码说明:
from haystack.nodes import EmbeddingRetriever:导入 Haystack 提供的嵌入检索器。
注意: EmbeddingRetriever 内部会使用 embedding_model 来生成查询的嵌入。它需要一个 DocumentStore 实例,并且该 DocumentStore 中的文档必须已经存储了嵌入向量(doc.embedding 属性)。
document_store.write_documents(docs_for_retrieval):这里为了演示,我们手动给文档添加了随机嵌入并写入 InMemoryDocumentStore。在实际流程中,这些嵌入会通过 DocumentEmbedder 节点生成。
embedding_model="sentence-transformers/all-MiniLM-L6-v2":确保检索器使用的查询嵌入模型与文档存储中文档的嵌入模型一致,这样才能保证向量空间的一致性,从而实现准确的相似度搜索。
top_k=2:指定检索器返回最相关的 2 个文档。
retriever.run(query=query_for_retrieval):执行检索操作。它会根据查询生成查询嵌入,然后与文档存储中的文档嵌入进行相似度比较,并返回按相似度分数排序的顶部 K 个文档。
2.2.4 阅读器(Reader)
概念: 负责从检索到的相关文档中精确提取出问题的答案片段。
作用: 它不是生成整个回复,而是定位文档中能够直接回答问题的最小文本区间(Span)。
类型: 通常基于机器阅读理解(Machine Reading Comprehension, MRC)模型,如基于 BERT 或 RoBERTa 的 QA 模型。
代码示例:
from haystack.nodes import FARMReader # 导入 FARM 阅读器
# 1. 初始化 FARMReader
# model_name_or_path: 指定一个预训练的问答模型 (通常在 SQuAD 数据集上训练)
# top_k: 每个文档返回多少个答案
# max_seq_len: 模型能处理的最大序列长度
# doc_stride: 滑动窗口步长,用于处理长文档
reader = FARMReader(
model_name_or_path="deepset/roberta-base-squad2", # 一个在 SQuAD 2.0 上训练的 RoBERTa 模型
use_gpu=False, # 是否使用 GPU (如果安装了 CUDA 和 PyTorch GPU 版本,可设置为 True)
top_k_per_candidate=1, # 从每个检索到的文档中提取一个答案
no_answer_boost=-10, # 给予“无答案”一个负面分数,鼓励模型给出答案
# 如果处理长文档,可以配置 max_seq_len 和 doc_stride
# max_seq_len=256,
# doc_stride=128
)
print("阅读器已初始化。") # 打印初始化信息
# 2. 准备查询和文档 (通常这些文档来自检索器的输出)
query_for_reader = "Python 是什么语言?"
# 这里我们手动构造一个文档列表来演示,假设它们是检索器输出的
documents_for_reader = [
Document(content="Python 是一种高级的、解释型的、交互式的、面向对象的编程语言。"),
Document(content="它由 Guido van Rossum 于 1991 年首次发布,并以其简洁、易读的语法而闻名。"),
Document(content="RAG 系统结合检索和生成来改进 LLM 的回答。") # 一个不相关的文档
]
# 3. 执行阅读
# run 方法返回一个字典,其中包含 'answers' 键,对应 Answer 对象的列表
read_results = reader.run(query=query_for_reader, documents=documents_for_reader)
answers = read_results["answers"] # 获取答案列表
print(f"
查询: '{
query_for_reader}'") # 打印查询
print("提取到的答案:") # 打印提取到的答案标题
if answers:
for ans in answers:
print(f" 答案: '{
ans.answer}'") # 答案文本
print(f" 分数: {
ans.score:.4f}") # 答案置信度分数
print(f" 上下文: '{
ans.context}'") # 答案所在的上下文片段
print(f" 来自文档 ID: {
ans.document_id}") # 答案来自哪个文档
print("-" * 20) # 分隔线
else:
print("未找到答案。") # 如果没有找到答案,则打印提示
代码说明:
from haystack.nodes import FARMReader:导入 Haystack 提供的 FARM 阅读器。FARM 是 Deepset 团队用于训练和推理 Transformer 模型的一个库。
model_name_or_path="deepset/roberta-base-squad2":指定使用的预训练问答模型。这个模型已经在 SQuAD (Stanford Question Answering Dataset) 上进行了训练,可以从文本中提取答案片段。
use_gpu=False:设置是否使用 GPU 进行推理。
top_k_per_candidate=1:从每个输入文档中,阅读器尝试提取一个最佳答案。
no_answer_boost=-10:一个惩罚值,用于在模型判断“无答案”比给出错误答案更好的情况。负值会鼓励模型给出答案。
reader.run(query=query_for_reader, documents=documents_for_reader):执行阅读操作。它接收查询和一组文档,然后尝试在这些文档中找到直接回答问题的片段。
answers = read_results["answers"]:run 方法返回的字典中,"answers" 键对应一个 Answer 对象列表。每个 Answer 对象包含了答案文本、置信度分数、答案所在的上下文片段以及来源文档的 ID。
2.2.5 生成器(Generator)
概念: 负责根据检索到的上下文和/或阅读器提取的答案,生成一个流畅、连贯的自然语言回复。
作用: 将原始的答案片段或上下文转化为用户友好的、完整的回复。
类型: 通常基于大型语言模型(LLM),如 OpenAI 的 GPT 系列模型、Hugging Face 上的各种生成式模型(T5, GPT-2, Llama 等)。
代码示例:
from haystack.nodes import OpenAIGenerator # 导入 OpenAI 生成器
# 1. 初始化 OpenAIGenerator
# api_key: 你的 OpenAI API 密钥
# model_name: 使用的 OpenAI 模型,例如 "gpt-3.5-turbo" 或 "gpt-4"
# temperature: 控制生成文本的随机性 (0.0 表示最确定,1.0 表示最随机)
# max_tokens: 生成的最大词元数
# top_p: 控制采样多样性
# system_prompt: 可选的系统级指令,用于定义模型行为
# api_base: 可选的 API 基础 URL,用于自定义或代理
try:
# 假设你已设置 OPENAI_API_KEY 环境变量
# 或者直接在这里传入 api_key="YOUR_OPENAI_API_KEY"
generator = OpenAIGenerator(
api_key=os.environ.get("OPENAI_API_KEY"), # 从环境变量获取 API Key
model_name="gpt-3.5-turbo", # 使用 GPT-3.5 Turbo 模型
temperature=0.7, # 适中的随机性
max_tokens=200, # 生成的最大词元数
system_prompt="你是一个乐于助人的AI助手,专门根据提供的上下文回答问题。" # 定义系统角色
)
print("OpenAI 生成器已初始化。") # 打印初始化信息
except Exception as e:
print(f"初始化 OpenAI 生成器失败,请检查 OPENAI_API_KEY 或网络连接: {
e}") # 打印错误信息
generator = None # 设置为 None 以便后续跳过生成器部分
if generator:
# 2. 准备查询和上下文 (通常这些来自检索器或阅读器的输出)
query_for_generator = "什么是 RAG?"
context_for_generator = [
Document(content="RAG 系统通过检索外部知识来增强 LLM,减少幻觉和提高准确性。"),
Document(content="它通常包括离线索引构建和查询时的检索与生成两个阶段。")
]
# 3. 执行生成
# run 方法返回一个字典,其中包含 'replies' 键,对应生成的回复字符串列表
# prompt_template: 可以自定义 prompt 模板,来指导 LLM 如何使用 context 和 query
# 在 RAG 场景中,通常会使用一个包含上下文的 prompt 模板
# 默认情况下,OpenAIGenerator 会将 documents 格式化到 prompt 中
# 显式构建一个 prompt 模板,演示如何将上下文整合到 prompt 中
# {documents}: Haystack 会自动填充检索到的文档内容
# {query}: Haystack 会自动填充用户查询
# prompt_template = """请根据以下提供的上下文信息,简洁地回答问题。如果上下文没有包含足够的信息,请说明。
# 上下文:
# {documents}
# 问题:{query}
# 回答:
# """
# 由于 OpenAIGenerator 会自动处理 `documents` 和 `query` 的格式化,
# 我们可以直接传入它们,Generator 会根据其内部逻辑构建 prompt。
# 如果需要更精细的控制,可以使用 PromptNode
generation_results = generator.run(
query=query_for_generator,
documents=context_for_generator, # 传入上下文文档
# prompt_template=prompt_template # 如果需要自定义 prompt 模板
)
generated_replies = generation_results["replies"] # 获取生成的回复列表
print(f"
查询: '{
query_for_generator}'") # 打印查询
print("生成的回复:") # 打印生成的回复标题
if generated_replies:
for reply in generated_replies:
print(f" '{
reply}'") # 打印回复内容
else:
print("未生成回复。") # 如果没有生成回复,则打印提示
代码说明:
from haystack.nodes import OpenAIGenerator:导入 Haystack 提供的 OpenAI 生成器。
api_key=os.environ.get("OPENAI_API_KEY"):从环境变量中获取 OpenAI API 密钥。在实际使用中,你需要将你的 OpenAI API 密钥设置为 OPENAI_API_KEY 环境变量,或者直接在代码中传入 api_key 参数(但不推荐硬编码)。
model_name="gpt-3.5-turbo":指定要使用的 OpenAI 模型。
temperature=0.7, max_tokens=200:这些参数控制 LLM 生成行为。temperature 越高,生成越随机;max_tokens 限制生成文本的长度。
system_prompt:为 LLM 定义一个系统级的角色或行为指导,有助于模型更好地理解上下文。
generator.run(query=query_for_generator, documents=context_for_generator):执行生成操作。它接收查询和检索到的上下文文档,然后将它们组合成一个 Prompt,发送给 OpenAI API,并返回 LLM 生成的回复。
generated_replies = generation_results["replies"]:run 方法返回的字典中,"replies" 键对应一个字符串列表,其中包含 LLM 生成的最终回复。
2.2.6 管道(Pipeline)
概念: 将上述独立组件按照特定的数据流逻辑连接起来,形成一个完整的、可执行的 RAG 系统。
作用: 定义了 RAG 系统的端到端工作流,从用户查询到最终答案生成的整个过程。
类型: Haystack 支持 Pipeline(线性或简单分支)、RayPipeline(分布式并行处理)、RetrieverReaderPipeline(经典的 RAG 流)等。
代码示例:
from haystack.pipelines import Pipeline # 导入 Pipeline 类
from haystack.nodes import TextConverter, PreProcessor, EmbeddingRetriever, FARMReader, OpenAIGenerator # 导入所需组件
# 1. 初始化文档存储
# 对于一个完整的管道,我们通常需要一个能持久化存储嵌入的文档存储
# 这里我们仍使用 InMemoryDocumentStore,但在真实环境中会是 FAISSDocumentStore 或 ElasticsearchDocumentStore
document_store_for_pipeline = InMemoryDocumentStore(use_embedding_storage=True)
print("管道的 DocumentStore 已初始化。") # 打印初始化信息
# 2. 定义用于索引的文档
docs_for_pipeline = [
{
"content": "自然语言处理(NLP)是计算机科学领域的一个重要分支,旨在让计算机理解、解释、生成和处理人类语言。",
"meta": {
"source": "NLP百科"}},
{
"content": "深度学习是机器学习的一个子集,它使用多层神经网络来从数据中学习复杂的模式。",
"meta": {
"source": "DL百科"}},
{
"content": "Haystack 是一个开源的 NLP 框架,用于构建生产就绪的问答系统和 LLM 应用。",
"meta": {
"source": "Haystack官网"}},
{
"content": "Transformer 架构是现代 NLP 模型(如 BERT 和 GPT)的基础。",
"meta": {
"source": "AI论文"}}
]
# 3. 构建索引管道 (Indexing Pipeline)
# 索引管道用于处理原始文本,分块,生成嵌入并写入 DocumentStore
indexing_pipeline = Pipeline() # 创建一个新管道
# 添加一个预处理器节点:用于文本清洗和分块
# text_conversion: 文本到 Document 对象的转换
# preprocessor: 文档预处理,如清洗、分块、删除重复项
indexing_pipeline.add_node(
component=PreProcessor(
clean_empty_lines=True, # 清除空行
clean_whitespace=True, # 清除多余空格
clean_header_footer=False, # 不清除页眉页脚
split_by="word", # 按单词分割
split_length=100, # 每个块 100 个词
split_overlap=20 # 块之间重叠 20 个词
),
name="PreProcessor",
inputs=["File"] # 输入是文件 (虽然这里直接用 Document,但 PreProcessor 通常处理原始文件)
)
# 添加一个文档嵌入器节点:用于为分块后的文档生成嵌入
indexing_pipeline.add_node(
component=SentenceTransformersDocumentEmbedder(
model_name_or_path="sentence-transformers/all-MiniLM-L6-v2",
device="cpu", # 使用 CPU
batch_size=32 # 批量处理
),
name="DocumentEmbedder",
inputs=["PreProcessor"] # 输入是 PreProcessor 的输出
)
# 添加文档存储节点:用于将处理后的文档写入 DocumentStore
# 文档存储是一个特殊节点,它接收文档并存储起来,不产生可连接的输出(除非是 QueryPipeline)
indexing_pipeline.add_node(
component=document_store_for_pipeline, # 传入文档存储实例
name="DocumentStore",
inputs=["DocumentEmbedder"] # 输入是 DocumentEmbedder 的输出
)
print("
开始构建索引...") # 打印开始构建索引信息
# 运行索引管道,这里我们直接传入 Document 列表,PreProcessor 会将它们视为输入
indexing_pipeline.run(documents=docs_for_pipeline)
print("索引构建完成。") # 打印索引构建完成信息
# 4. 构建问答管道 (Query Pipeline) - 这是 RAG 的核心
# query -> retriever -> reader -> generator
qa_pipeline = Pipeline() # 创建问答管道
# 添加一个检索器节点:用于从 DocumentStore 中检索相关文档
qa_pipeline.add_node(
component=EmbeddingRetriever(
document_store=document_store_for_pipeline,
embedding_model="sentence-transformers/all-MiniLM-L6-v2",
model_format="sentence_transformers",
top_k=3 # 检索 3 个最相关的文档
),
name="Retriever",
inputs=["Query"] # 输入是用户查询
)
# 添加一个阅读器节点:用于从检索到的文档中提取答案
qa_pipeline.add_node(
component=FARMReader(
model_name_or_path="deepset/roberta-base-squad2",
use_gpu=False,
top_k_per_candidate=1
),
name="Reader",
inputs=["Retriever"] # 输入是检索器的输出
)
# 添加一个生成器节点:用于生成最终的自然语言回复
if generator: # 确保生成器已成功初始化
qa_pipeline.add_node(
component=generator, # 使用之前创建的 OpenAI 生成器
name="Generator",
inputs=["Reader"] # 输入是阅读器的输出
)
else:
print("
跳过 Generator 节点,因为 OpenAI 生成器初始化失败。管道将只进行检索和阅读。") # 打印跳过信息
print("
问答管道已构建。") # 打印问答管道构建信息
# 5. 运行问答管道
if generator:
user_query = "NLP 是什么?"
print(f"
用户查询: '{
user_query}'") # 打印用户查询
# 运行问答管道,输入是用户的查询
result = qa_pipeline.run(query=user_query)
print("
管道运行结果:") # 打印管道运行结果标题
print(f"生成的回复: {
result['replies'][0]}") # 打印生成的回复
print("
源文档上下文:") # 打印源文档上下文标题
for answer in result["answers"]: # 遍历答案
print(f" - 上下文: '{
answer.context}' (分数: {
answer.score:.4f})") # 打印上下文和分数
else:
print("
Generator 未初始化,无法运行完整的问答管道。") # 打印警告信息
print("尝试运行只有检索和阅读的管道...") # 尝试运行提示
# 运行只有检索和阅读的管道
qa_pipeline_no_gen = Pipeline() # 创建一个没有生成器的新管道
qa_pipeline_no_gen.add_node(
component=EmbeddingRetriever(
document_store=document_store_for_pipeline,
embedding_model="sentence-transformers/all-MiniLM-L6-v2",
model_format="sentence_transformers",
top_k=3
),
name="Retriever",
inputs=["Query"]
)
qa_pipeline_no_gen.add_node(
component=FARMReader(
model_name_or_path="deepset/roberta-base-squad2",
use_gpu=False,
top_k_per_candidate=1
),
name="Reader",
inputs=["Retriever"]
)
user_query_no_gen = "深度学习是什么?"
print(f"
用户查询 (无生成器): '{
user_query_no_gen}'") # 打印用户查询
result_no_gen = qa_pipeline_no_gen.run(query=user_query_no_gen)
print("
管道运行结果 (无生成器):") # 打印管道运行结果标题
print("提取到的答案:") # 打印提取到的答案标题
if result_no_gen["answers"]:
for ans in result_no_gen["answers"]:
print(f" - 答案: '{
ans.answer}' (分数: {
ans.score:.4f}, 上下文: '{
ans.context[:50]}...')") # 打印答案、分数和上下文
else:
print("未找到答案。") # 未找到答案提示
代码说明:
from haystack.pipelines import Pipeline:导入 Haystack 提供的 Pipeline 类。
索引管道 (indexing_pipeline):
这是一个用于处理原始文档并将其准备好进行检索的管道。
indexing_pipeline.add_node(component=PreProcessor(...), name="PreProcessor", inputs=["File"]):添加预处理器节点,负责文本清洗和分块。inputs=["File"] 表示它通常处理文件输入,但在这里,run 方法直接接受 documents 列表。
indexing_pipeline.add_node(component=SentenceTransformersDocumentEmbedder(...), name="DocumentEmbedder", inputs=["PreProcessor"]):添加文档嵌入器节点,它接收预处理器输出的文档,并为它们生成嵌入向量。
indexing_pipeline.add_node(component=document_store_for_pipeline, name="DocumentStore", inputs=["DocumentEmbedder"]):添加文档存储节点,它接收带有嵌入的文档,并将它们写入 document_store_for_pipeline。
indexing_pipeline.run(documents=docs_for_pipeline):运行索引管道,将准备好的文档输入到管道中进行处理和索引。
问答管道 (qa_pipeline):
这是 RAG 的核心流程:Query (用户查询) -> Retriever (检索相关文档) -> Reader (从文档中提取答案) -> Generator (生成最终回复)。
qa_pipeline.add_node(component=EmbeddingRetriever(...), name="Retriever", inputs=["Query"]):添加检索器节点。inputs=["Query"] 表示这个节点的输入是用户查询字符串。
qa_pipeline.add_node(component=FARMReader(...), name="Reader", inputs=["Retriever"]):添加阅读器节点。它的输入是检索器输出的 Document 列表。
qa_pipeline.add_node(component=generator, name="Generator", inputs=["Reader"]):添加生成器节点。它的输入是阅读器提取的 Answer 对象(包含答案片段和上下文)。
qa_pipeline.run(query=user_query):运行问答管道,传入用户查询。管道会依次执行各个节点,最终返回 LLM 生成的回复。
错误处理与可选的生成器: 代码中包含了一个 if generator: 判断,以处理 OpenAIGenerator 初始化失败的情况(例如,API Key 未设置)。如果生成器不可用,它会尝试运行一个只包含检索和阅读的简化管道,以演示 Haystack 的灵活性。
2.3 Haystack 生态系统与第三方工具集成:RAG 系统的基石与桥梁
Haystack 的强大之处不仅在于其模块化的内部设计,更在于其卓越的开放性和对广泛第三方工具的集成能力。RAG 系统的构建往往需要与多种异构系统进行交互:从数据存储(如向量数据库、搜索引擎)到语言模型提供商(如 OpenAI、Hugging Face),再到各种数据源。Haystack 通过提供统一的接口和丰富的组件,极大地简化了这些集成过程,使其成为构建复杂、企业级 RAG 应用的理想选择。
2.3.1 与主流向量数据库的集成:海量知识的极速检索
向量数据库是 RAG 系统的核心组成部分,它们专门用于存储和高效检索高维向量。Haystack 通过其 DocumentStore 抽象层,允许开发者轻松切换和配置不同的向量数据库,而无需修改核心 RAG 逻辑。
本节将深入探讨 Haystack 如何与几种主流的向量数据库进行集成,包括它们的特点、优势以及在 Haystack 中的配置方法。
2.3.1.1 FAISS (Facebook AI Similarity Search) DocumentStore:本地高性能向量检索
FAISS 是 Facebook AI Research 开发的一个用于高效相似度搜索和聚类的库。它提供了多种索引结构,可以在 CPU 或 GPU 上实现极快的最近邻搜索。在 Haystack 中,FAISSDocumentStore 是一个常用的选择,尤其适用于中等规模(数十万到数百万向量)的本地部署或原型开发。
特点:
高性能: 实现了多种先进的近似最近邻(ANN)算法,例如倒排文件索引(IVF)、乘积量化(PQ)等,能够在海量向量中进行快速搜索。
内存驻留或文件持久化: 可以在内存中运行,也可以将索引和向量保存到磁盘文件,实现持久化。
纯 Python/C++ 库: 易于集成到 Python 项目中。
无客户端/服务器模式: 直接在应用程序进程中运行,没有网络延迟,但在分布式部署时需要额外管理。
优势: 部署简单、查询速度快(尤其在内存中)、适合中小型数据集或作为快速原型。
劣势: 不支持分布式部署(需要自己实现数据分片和路由)、扩展性有限、不提供高可用性、不支持并发写入。
Haystack 中的集成:
FAISSDocumentStore 在 Haystack 中可以配置为使用不同的 FAISS 索引类型,并支持将文档及其嵌入向量存储到文件。
import os # 导入操作系统模块
from haystack.document_stores import FAISSDocumentStore # 从 haystack 库中导入 FAISSDocumentStore 类
from haystack.schema import Document # 从 haystack 库中导入 Document 类
import numpy as np # 导入 numpy 库,用于生成模拟嵌入
# 定义 FAISS 索引和文档的存储路径
# 确保这个目录存在或者可以被创建
FAISS_INDEX_PATH = "faiss_index.bin" # 定义 FAISS 索引文件的路径
DOC_METADATA_PATH = "document_metadata.pkl" # 定义文档元数据文件的路径 (FAISS 通常将元数据和文本内容分开存储)
# 1. 初始化 FAISSDocumentStore
# 这是一个关键步骤,它决定了 FAISS 索引的类型以及如何存储文档。
# embedding_dim: 嵌入向量的维度。这必须与你使用的嵌入模型的输出维度一致。
# faiss_index_factory_str: FAISS 索引的工厂字符串。
# "Flat" 是最简单的索引,不进行压缩,搜索精确但速度较慢。
# "HNSW" 是用于高效近似最近邻搜索的图索引,平衡了速度和准确性。
# "IVF,Flat" 结合了倒排索引和平面索引,适用于大规模数据集。
# sql_url: 用于存储文档元数据(如原始文本、meta 字段)的 SQLite 数据库路径。
# FAISS 本身只存储向量,Haystack 需要一个额外的地方存储文档的其他信息。
# 默认会创建一个名为 'faiss_document_store.db' 的 SQLite 文件。
# return_embedding: 查询时是否返回嵌入向量。
# d_embedding: 嵌入向量的维度。
try:
# 如果文件已存在,则尝试加载
if os.path.exists(FAISS_INDEX_PATH) and os.path.exists(DOC_METADATA_PATH):
document_store = FAISSDocumentStore.load(
index_path=FAISS_INDEX_PATH, # 加载 FAISS 索引文件
config_path=DOC_METADATA_PATH # 加载文档元数据配置文件
)
print("FAISSDocumentStore 已从文件加载。") # 打印加载信息
else:
# 如果文件不存在,则创建新的存储
# 这里我们假设嵌入维度为 768,这是许多 Sentence-BERT 模型的输出维度
document_store = FAISSDocumentStore(
embedding_dim=768, # 嵌入向量的维度,必须与模型匹配
faiss_index_factory_str="HNSW", # 使用 HNSW 索引类型,提供很好的性能
sql_url="sqlite:///faiss_document_store.db" # 使用 SQLite 存储文档元数据
)
print("FAISSDocumentStore 已初始化并配置为使用 HNSW 索引。") # 打印初始化信息
except Exception as e:
print(f"FAISSDocumentStore 初始化或加载失败: {
e}") # 打印错误信息
# 如果出错,可以将 document_store 置为 None 或退出,以防止后续操作失败
document_store = None
if document_store:
# 2. 准备带嵌入的文档
# 在实际 RAG 管道中,这些嵌入会由 SentenceTransformersDocumentEmbedder 等节点生成。
# 这里我们模拟生成一些带有随机嵌入的文档。
docs_to_write = [
Document(content="FAISS 是 Facebook AI Research 开发的库。",
meta={
"source": "FAISS文档", "page": 1},
embedding=np.random.rand(768).astype(np.float32)), # 模拟 768 维嵌入
Document(content="Haystack 框架可以与多种 DocumentStore 集成。",
meta={
"source": "Haystack文档", "page": 5},
embedding=np.random.rand(768).astype(np.float32)),
Document(content="近似最近邻搜索在大规模数据集中至关重要。",
meta={
"source": "AI研究", "topic": "ANN"},
embedding=np.random.rand(768).astype(np.float32)),
Document(content="HNSW 索引是 FAISS 中一种流行的图结构索引。",
meta={
"source": "FAISS文档", "type": "index"},
embedding=np.random.rand(768).astype(np.float32))
]
# 3. 写入文档
# Haystack 会处理将文档内容和嵌入写入 FAISS 和 SQLite
document_store.write_documents(docs_to_write)
print(f"
已写入 {
len(docs_to_write)} 个带嵌入的文档到 FAISSDocumentStore。") # 打印写入文档数量
# 4. 获取所有文档 (只能获取元数据和内容,嵌入需要通过检索器查询)
all_faiss_docs = document_store.get_all_documents()
print("
FAISSDocumentStore 中的所有文档(元数据和内容):") # 打印所有文档标题
for doc in all_faiss_docs:
print(f" ID: {
doc.id}, 内容: '{
doc.content[:30]}...', 元数据: {
doc.meta}") # 打印文档信息
# 5. 保存索引和配置 (重要步骤,用于持久化)
document_store.save(index_path=FAISS_INDEX_PATH, config_path=DOC_METADATA_PATH) # 保存 FAISS 索引和元数据配置
print(f"
FAISSDocumentStore 索引和配置已保存到 '{
FAISS_INDEX_PATH}' 和 '{
DOC_METADATA_PATH}'。") # 打印保存信息
# 6. 清理 (可选)
# os.remove(FAISS_INDEX_PATH)
# os.remove(DOC_METADATA_PATH)
# os.remove("faiss_document_store.db") # 如果使用 SQLite
# print("
已清理 FAISS 索引和相关文件。")
代码说明:
FAISSDocumentStore(embedding_dim=768, faiss_index_factory_str="HNSW", sql_url="sqlite:///faiss_document_store.db"):初始化 FAISSDocumentStore。
embedding_dim: 必须与你的嵌入模型输出的向量维度一致。
faiss_index_factory_str: 定义 FAISS 使用的索引类型。"HNSW"(Hierarchical Navigable Small World)是一种常用的近似最近邻索引,提供良好的搜索速度和准确性平衡。其他选项如 "Flat"(精确但慢)、"IVF,Flat"(分层索引,适合大规模)等。
sql_url: FAISSDocumentStore 自身只存储向量,它需要一个关系型数据库(如 SQLite, PostgreSQL 等)来存储文档的原始文本内容和元数据。这里指定了一个 SQLite 文件。
document_store.write_documents(docs_to_write):将带有嵌入向量的 Document 对象写入存储。Haystack 会自动处理将向量传递给 FAISS,并将文本内容和元数据存储到 SQLite 数据库。
document_store.save(index_path=FAISS_INDEX_PATH, config_path=DOC_METADATA_PATH):非常重要的步骤。这个方法会将内存中的 FAISS 索引(如果使用了如 HNSW 这种内存索引)持久化到 index_path 指定的文件中,并将文档元数据(以及指向索引的配置)保存到 config_path 指定的文件中。这样,下次启动应用时,可以直接通过 FAISSDocumentStore.load() 加载,避免重新构建索引。
FAISSDocumentStore.load(index_path=..., config_path=...):用于从之前保存的文件中加载 FAISSDocumentStore 实例。
2.3.1.2 ElasticsearchDocumentStore:强大的全文检索与向量检索结合
Elasticsearch 是一个基于 Lucene 的分布式搜索和分析引擎。它以其强大的全文搜索能力和可伸缩性而闻名。近年来,Elasticsearch 也开始支持向量搜索功能,使其成为同时需要关键词搜索(BM25)和语义搜索(向量相似度)的 RAG 系统的理想选择。
特点:
全文搜索与向量搜索: 支持 BM25 稀疏检索和 k-NN 稠密向量检索,甚至可以进行混合(Hybrid)搜索。
分布式与可伸缩: 原生支持分布式部署,能够处理 TB 级甚至 PB 级数据,提供高可用性。
丰富的功能: 提供聚合、过滤、排序、分析等强大的数据处理和查询功能。
成熟的生态系统: 拥有 Kibana 等可视化工具,以及广泛的客户端库和社区支持。
优势: 适用于大规模、需要混合搜索和强大分析能力的生产环境。
劣势: 部署和维护相对复杂,资源消耗较高,对 JVM 有依赖。
Haystack 中的集成:
ElasticsearchDocumentStore 允许你连接到现有的 Elasticsearch 集群,并将文档及其嵌入存储在指定的索引中。
from haystack.document_stores import ElasticsearchDocumentStore # 导入 ElasticsearchDocumentStore 类
from haystack.schema import Document # 导入 Document 类
from elasticsearch import Elasticsearch # 导入 Elasticsearch 客户端库
import numpy as np # 导入 numpy 库
import time # 导入时间模块
# 定义 Elasticsearch 连接参数
HOST = "localhost" # Elasticsearch 主机地址
PORT = 9200 # Elasticsearch 端口
INDEX_NAME = "haystack_rag_docs" # Haystack 将使用的 Elasticsearch 索引名称
EMBEDDING_DIM = 768 # 嵌入向量的维度
# 检查 Elasticsearch 实例是否运行
def check_elasticsearch_status(host, port):
"""检查 Elasticsearch 是否正在运行"""
try:
es = Elasticsearch(f"http://{
host}:{
port}") # 创建 Elasticsearch 客户端实例
if es.ping(): # 尝试 ping Elasticsearch
print(f"Elasticsearch 实例在 http://{
host}:{
port} 上运行正常。") # 打印成功信息
return True
else:
print(f"无法连接到 Elasticsearch 实例在 http://{
host}:{
port}。请确保它正在运行。") # 打印连接失败信息
return False
except Exception as e:
print(f"连接 Elasticsearch 失败: {
e}") # 打印异常信息
return False
if not check_elasticsearch_status(HOST, PORT):
print("请启动您的 Elasticsearch 实例,例如使用 Docker: docker run -p 9200:9200 -e 'discovery.type=single-node' elasticsearch:8.11.0") # 提示用户启动 Elasticsearch
# 如果 Elasticsearch 未运行,则退出或跳过后续操作
exit() # 退出程序
# 1. 初始化 ElasticsearchDocumentStore
# host, port: Elasticsearch 服务器地址和端口。
# index: 用于存储文档的 Elasticsearch 索引名称。
# embedding_dim: 嵌入向量的维度。
# create_index: 如果索引不存在,是否自动创建。
# configure_request_timeout: 请求超时时间。
# search_fields: 指定用于 BM25 搜索的字段。
# username/password: 如果 Elasticsearch 有认证,提供这些参数。
# scheme: 连接协议,"http" 或 "https"。
try:
# 在初始化之前,先删除旧索引(如果存在),确保干净的环境
es_client = Elasticsearch(f"http://{
HOST}:{
PORT}") # 创建 Elasticsearch 客户端实例
if es_client.indices.exists(index=INDEX_NAME): # 检查索引是否存在
es_client.indices.delete(index=INDEX_NAME) # 删除索引
print(f"已删除旧的 Elasticsearch 索引: '{
INDEX_NAME}'。") # 打印删除信息
time.sleep(1) # 等待索引删除完成
document_store = ElasticsearchDocumentStore(
host=HOST, # Elasticsearch 主机
port=PORT, # Elasticsearch 端口
index=INDEX_NAME, # Haystack 将使用的索引名称
embedding_dim=EMBEDDING_DIM, # 嵌入向量的维度
create_index=True, # 如果索引不存在,自动创建
configure_request_timeout=60, # 请求超时时间(秒)
# search_fields=["content", "meta"], # 可以指定用于 BM25 搜索的字段,默认为 content
# username="elastic", # 如果需要认证
# password="your_password" # 如果需要认证
)
print(f"ElasticsearchDocumentStore 已初始化,连接到 {
HOST}:{
PORT},索引 '{
INDEX_NAME}'。") # 打印初始化信息
except Exception as e:
print(f"ElasticsearchDocumentStore 初始化失败: {
e}") # 打印错误信息
document_store = None # 设置为 None 以便后续跳过操作
if document_store:
# 2. 准备带嵌入的文档
docs_to_write = [
Document(content="Elasticsearch 是一个分布式、RESTful 搜索和分析引擎。",
meta={
"source": "Elastic官网", "tag": "search"},
embedding=np.random.rand(EMBEDDING_DIM).astype(np.float32)),
Document(content="Haystack 可以通过 ElasticsearchDocumentStore 使用 k-NN 搜索。",
meta={
"source": "Haystack文档", "tag": "integration"},
embedding=np.random.rand(EMBEDDING_DIM).astype(np.float32)),
Document(content="k-NN 算法用于在向量空间中找到最近邻。",
meta={
"source": "机器学习", "tag": "algorithm"},
embedding=np.random.rand(EMBEDDING_DIM).astype(np.float32)),
Document(content="构建 RAG 系统需要高效的文档存储。",
meta={
"source": "RAG指南", "tag": "architecture"},
embedding=np.random.rand(EMBEDDING_DIM).astype(np.float32))
]
# 3. 写入文档
# Haystack 会处理将文档内容、元数据和嵌入写入 Elasticsearch
document_store.write_documents(docs_to_write)
print(f"
已写入 {
len(docs_to_write)} 个带嵌入的文档到 Elasticsearch。") # 打印写入文档数量
time.sleep(2) # 等待 Elasticsearch 索引刷新
# 4. 验证文档是否写入
# 通过 Elasticsearch 客户端直接查询,或者通过 Haystack 的 get_all_documents
# 注意:get_all_documents 可能会返回大量文档,这里只用于验证
all_es_docs = document_store.get_all_documents()
print(f"
Elasticsearch 中共有 {
len(all_es_docs)} 个文档。") # 打印文档数量
if all_es_docs:
print(f"第一个文档内容: '{
all_es_docs[0].content[:50]}...'") # 打印第一个文档内容
# 5. (可选) 执行一个简单的检索来验证
from haystack.nodes import EmbeddingRetriever # 导入嵌入检索器
retriever_es = EmbeddingRetriever(
document_store=document_store,
embedding_model="sentence-transformers/all-MiniLM-L6-v2", # 确保与文档嵌入模型一致
model_format="sentence_transformers",
top_k=2
)
# 注意:这里我们只模拟了嵌入,如果要在 Haystack 中进行真实的检索,
# 需要确保嵌入模型是实际运行的,并且查询嵌入与文档嵌入是兼容的。
# 这里假设 'all-MiniLM-L6-v2' 能生成 768 维嵌入。
# 为了演示,手动生成一个查询嵌入
mock_query_embedding = np.random.rand(EMBEDDING_DIM).astype(np.float32)
# Haystack 的 Retriever.run() 方法通常会自动生成查询嵌入
# 为了演示 ElasticsearchDocumentStore 的 k-NN 功能,我们这里将模拟的查询嵌入直接传入
# 在实际管道中,这将由 EmbeddingRetriever 内部完成
print("
执行模拟检索...") # 打印模拟检索信息
# 注意:Haystack 的 EmbeddingRetriever 会在内部处理查询嵌入,
# 这里的 document_store.query_by_embedding 是直接调用 ES 的 k-NN API
try:
# 这是一个更底层的调用,演示文档存储的 k-NN 搜索能力
# 在实际管道中,会通过 retriever.run(query="...") 来调用
retrieved_docs_es = document_store.query_by_embedding(
query_emb=mock_query_embedding, # 传入模拟的查询嵌入
top_k=2 # 检索前 2 个
)
print("Elasticsearch k-NN 检索结果:") # 打印检索结果标题
for doc in retrieved_docs_es:
print(f" ID: {
doc.id}, 分数: {
doc.score:.4f}, 内容: '{
doc.content[:50]}...'") # 打印文档信息
except Exception as e:
print(f"Elasticsearch k-NN 检索失败: {
e}") # 打印检索失败信息
代码说明:
check_elasticsearch_status(host, port):一个辅助函数,用于在程序启动前检查 Elasticsearch 服务是否可用。
ElasticsearchDocumentStore(host=HOST, port=PORT, index=INDEX_NAME, embedding_dim=EMBEDDING_DIM, create_index=True):初始化 ElasticsearchDocumentStore。
host, port: 连接 Elasticsearch 实例的地址和端口。
index: 指定一个 Elasticsearch 索引来存储 Haystack 文档。Haystack 会自动创建这个索引并配置其映射(mapping),包括为嵌入向量创建一个 dense_vector 字段。
embedding_dim: 嵌入向量的维度,同样需要与你使用的嵌入模型匹配。
create_index=True: 允许 Haystack 在连接时自动创建索引。
es_client.indices.delete(index=INDEX_NAME):在示例中,为了确保每次运行都是干净的,我们先尝试删除已存在的同名索引。在生产环境中,请谨慎执行此操作。
document_store.write_documents(docs_to_write):将带有嵌入向量的 Document 对象写入 Elasticsearch。Haystack 会将每个 Document 转换为 Elasticsearch 的 JSON 文档,并将其索引到指定的 index 中。嵌入向量会被存储为 dense_vector 类型。
document_store.query_by_embedding(query_emb=..., top_k=...):这是一个底层方法,用于直接在 Elasticsearch 中执行 k-NN 向量相似度搜索。在 Haystack 管道中,通常是通过 EmbeddingRetriever 间接调用此功能。
注意: 运行此示例需要一个运行中的 Elasticsearch 实例。你可以使用 Docker 快速启动一个:docker run -p 9200:9200 -e 'discovery.type=single-node' elasticsearch:8.11.0 (请根据你的 Elasticsearch 版本选择合适的镜像)。
2.3.1.3 PineconeDocumentStore:全托管云原生向量数据库
Pinecone 是一款专为大规模向量搜索设计的高度优化、全托管的云原生向量数据库。它提供了卓越的性能、高可用性和可伸缩性,非常适合需要处理数十亿甚至数万亿向量的生产级 RAG 应用。
特点:
全托管服务: 用户无需管理底层基础设施,只需已关注数据和查询。
高性能与低延迟: 优化了索引和查询算法,提供极低的查询延迟。
高可伸缩性: 能够自动扩展以应对不断增长的数据量和查询负载。
实时数据更新: 支持实时添加、删除和更新向量。
元数据过滤: 可以在向量相似度搜索的同时,根据元数据进行过滤,实现更精确的混合搜索。
优势: 适用于对性能、可伸缩性和运维复杂度要求极高的生产环境。
劣势: 商业服务,有成本考量;需要网络连接。
Haystack 中的集成:
PineconeDocumentStore 通过 Pinecone 客户端库连接到你的 Pinecone 索引。你需要提供 Pinecone API Key、环境和索引名称。
import os # 导入操作系统模块
from haystack.document_stores import PineconeDocumentStore # 导入 PineconeDocumentStore 类
from haystack.schema import Document # 导入 Document 类
import numpy as np # 导入 numpy 库
import time # 导入时间模块
# 定义 Pinecone 连接参数
# 确保你已经安装了 pinecone-client: pip install pinecone-client==2.2.4
# 以及 PineconeDocumentStore 的依赖: pip install 'haystack[pinecone]'
# 从环境变量中获取 Pinecone API Key 和环境
# 请确保已设置这些环境变量:
# export PINECONE_API_KEY="YOUR_API_KEY"
# export PINECONE_ENVIRONMENT="YOUR_ENVIRONMENT" # 例如 "us-west1-gcp"
PINECONE_API_KEY = os.environ.get("PINECONE_API_KEY") # 获取 Pinecone API Key
PINECONE_ENVIRONMENT = os.environ.get("PINECONE_ENVIRONMENT") # 获取 Pinecone 环境
PINECONE_INDEX_NAME = "haystack-rag-index" # 定义 Pinecone 索引名称
EMBEDDING_DIM = 768 # 嵌入向量的维度
if not PINECONE_API_KEY or not PINECONE_ENVIRONMENT:
print("错误: 缺少 PINECONE_API_KEY 或 PINECONE_ENVIRONMENT 环境变量。") # 提示缺少环境变量
print("请设置它们,例如: export PINECONE_API_KEY='...' && export PINECONE_ENVIRONMENT='...'") # 提示设置环境变量
exit() # 退出程序
# 1. 初始化 PineconeDocumentStore
# api_key: 你的 Pinecone API Key。
# environment: 你的 Pinecone 环境名称(例如 'us-west1-gcp')。
# index: 要连接的 Pinecone 索引名称。
# embedding_dim: 向量维度。
# batch_size: 写入 Pinecone 时批量处理的文档数量。
# metric: 相似度度量标准('cosine', 'euclidean', 'dotproduct')。
# configure_index: 如果索引不存在,是否自动创建(推荐在生产环境手动管理索引)。
try:
document_store = PineconeDocumentStore(
api_key=PINECONE_API_KEY, # Pinecone API 密钥
environment=PINECONE_ENVIRONMENT, # Pinecone 环境
index=PINECONE_INDEX_NAME, # Pinecone 索引名称
embedding_dim=EMBEDDING_DIM, # 嵌入向量的维度
batch_size=32, # 写入时的批量大小
metric="cosine", # 相似度度量标准,与你的嵌入模型训练方式一致
configure_index=True, # 如果索引不存在,自动创建(仅用于演示,生产环境请手动管理)
# pinecone_api_kwargs={"timeout": 30} # 可以添加额外的 Pinecone API 客户端参数
)
print(f"PineconeDocumentStore 已初始化,连接到索引 '{
PINECONE_INDEX_NAME}'。") # 打印初始化信息
except Exception as e:
print(f"PineconeDocumentStore 初始化失败: {
e}") # 打印错误信息
document_store = None # 设置为 None 以便后续跳过操作
if document_store:
# 2. 准备带嵌入的文档
# PineconeDocumentStore 要求 Document 对象有 id 和 embedding 属性
docs_to_write = [
Document(id="doc_pine_001", content="Pinecone 是一个高性能的向量数据库。",
meta={
"source": "Pinecone官网", "type": "db"},
embedding=np.random.rand(EMBEDDING_DIM).astype(np.float32)),
Document(id="doc_pine_002", content="它支持近似最近邻搜索和元数据过滤。",
meta={
"source": "Pinecone特性", "feature": "filtering"},
embedding=np.random.rand(EMBEDDING_DIM).astype(np.float32)),
Document(id="doc_pine_003", content="Haystack 提供了与 Pinecone 的无缝集成。",
meta={
"source": "Haystack文档", "category": "integration"},
embedding=np.random.rand(EMBEDDING_DIM).astype(np.float32))
]
# 3. 写入文档
# Haystack 会处理将文档及其嵌入写入 Pinecone
document_store.write_documents(docs_to_write)
print(f"
已写入 {
len(docs_to_write)} 个带嵌入的文档到 Pinecone。") # 打印写入文档数量
time.sleep(1) # 给 Pinecone 一点时间来索引
# 4. 获取所有文档 (通过 Haystack 抽象层,可以获取文档内容和元数据)
all_pinecone_docs = document_store.get_all_documents()
print(f"
Pinecone 中共有 {
len(all_pinecone_docs)} 个文档。") # 打印文档数量
if all_pinecone_docs:
print(f"第一个文档内容: '{
all_pinecone_docs[0].content[:50]}...'") # 打印第一个文档内容
print(f"第一个文档元数据: {
all_pinecone_docs[0].meta}") # 打印第一个文档元数据
# 5. 执行带元数据过滤的检索示例
from haystack.nodes import EmbeddingRetriever # 导入嵌入检索器
retriever_pinecone = EmbeddingRetriever(
document_store=document_store,
embedding_model="sentence-transformers/all-MiniLM-L6-v2", # 确保与文档嵌入模型一致
model_format="sentence_transformers",
top_k=2
)
query_text_pinecone = "向量数据库的主要特点是什么?"
# 模拟生成查询嵌入
mock_query_embedding_pinecone = np.random.rand(EMBEDDING_DIM).astype(np.float32)
print(f"
执行带有元数据过滤的检索,查询: '{
query_text_pinecone}'") # 打印查询信息
# Haystack 的 EmbeddingRetriever.run() 方法支持 filters 参数,
# 会将其传递给 DocumentStore 进行元数据过滤
# 注意:这里我们使用 DocumentStore 的底层方法来演示元数据过滤
# 在实际管道中,filters 会在 retriever.run() 中直接指定
try:
# 这是一个更底层的调用,演示文档存储的元数据过滤能力
retrieved_docs_pinecone_filtered = document_store.query_by_embedding(
query_emb=mock_query_embedding_pinecone, # 传入模拟的查询嵌入
filters={
"type": ["db"]}, # 仅检索元数据中 type 为 "db" 的文档
top_k=1 # 检索一个
)
print("Pinecone 检索结果 (过滤 type='db'):") # 打印检索结果标题
for doc in retrieved_docs_pinecone_filtered:
print(f" ID: {
doc.id}, 分数: {
doc.score:.4f}, 内容: '{
doc.content[:50]}...', 元数据: {
doc.meta}") # 打印文档信息
except Exception as e:
print(f"Pinecone 检索失败: {
e}") # 打印检索失败信息
# 清理索引 (可选,但推荐在测试完成后清理)
# document_store.delete_index(PINECONE_INDEX_NAME)
# print(f"
已删除 Pinecone 索引: '{PINECONE_INDEX_NAME}'。")
代码说明:
PineconeDocumentStore(api_key=..., environment=..., index=..., embedding_dim=..., metric="cosine", configure_index=True):初始化 PineconeDocumentStore。
api_key, environment: 你的 Pinecone 认证凭据,从 Pinecone 控制台获取。
index: 你希望连接或创建的 Pinecone 索引名称。
embedding_dim: 嵌入向量的维度。
metric: 相似度度量标准,必须与你的嵌入模型训练时使用的度量一致(例如,多数 Sentence-BERT 模型使用余弦相似度)。
configure_index=True: 如果索引不存在,Haystack 会尝试使用默认配置(如 pods=1, replicas=1, pod_type='s1.x1')自动创建。在生产环境中,强烈建议通过 Pinecone 控制台或其 API 手动创建和管理索引,以便更精细地控制索引配置(例如 pod 类型、分片数量等)。
document_store.write_documents(docs_to_write):将带有 id 和 embedding 属性的 Document 对象写入 Pinecone。Pinecone 要求每个向量都有一个唯一的 ID。
document_store.query_by_embedding(query_emb=..., filters={...}, top_k=...):演示了 Pinecone 对元数据过滤的强大支持。filters 参数允许你在执行向量相似度搜索的同时,对结果进行精确的元数据过滤。这在需要细粒度控制检索结果的场景中非常有用。
2.3.1.4 WeaviateDocumentStore:知识图谱与向量搜索的结合
Weaviate 是一个开源、云原生、向量原生的数据库,它不仅仅是一个向量数据库,更是一个向量搜索引擎和知识图谱。Weaviate 允许你存储向量数据,并为其定义结构化的 Schema(类和属性),然后可以同时进行向量相似度搜索和基于属性的过滤。
特点:
向量原生: 从设计之初就以向量为核心。
Schema 驱动: 允许你为数据定义类和属性,实现类似知识图谱的功能。
元数据过滤: 支持复杂的元数据过滤和聚合查询。
混合搜索: 支持关键词搜索(BM25F)和向量搜索的结合。
云原生与分布式: 支持分布式部署,可横向扩展。
语义能力: 内置语义理解能力,可以集成各种嵌入模型。
优势: 适用于需要强大语义搜索、复杂元数据过滤和类知识图谱结构的应用。
劣势: 部署和维护可能比简单的向量库复杂;学习曲线相对陡峭。
Haystack 中的集成:
WeaviateDocumentStore 允许你连接到本地或远程的 Weaviate 实例。你需要定义 Weaviate 中的类名和要存储的字段。
import os # 导入操作系统模块
from haystack.document_stores import WeaviateDocumentStore # 导入 WeaviateDocumentStore 类
from haystack.schema import Document # 导入 Document 类
import numpy as np # 导入 numpy 库
import time # 导入时间模块
# 定义 Weaviate 连接参数
# 确保你已经安装了 weaviate-client: pip install weaviate-client
# 以及 WeaviateDocumentStore 的依赖: pip install 'haystack[weaviate]'
WEAVIATE_URL = "http://localhost:8080" # Weaviate 实例的 URL
WEAVIATE_API_KEY = os.environ.get("WEAVIATE_API_KEY") # Weaviate API Key (如果需要认证)
WEAVIATE_INDEX_NAME = "HaystackDoc" # Haystack 将在 Weaviate 中使用的类名称 (相当于索引)
EMBEDDING_DIM = 768 # 嵌入向量的维度
# 检查 Weaviate 实例是否运行
def check_weaviate_status(url):
"""检查 Weaviate 是否正在运行"""
try:
import weaviate # 导入 weaviate 客户端
client = weaviate.Client(url) # 创建 Weaviate 客户端实例
if client.is_ready(): # 检查 Weaviate 实例是否就绪
print(f"Weaviate 实例在 {
url} 上运行正常。") # 打印成功信息
return True
else:
print(f"无法连接到 Weaviate 实例在 {
url}。请确保它正在运行。") # 打印连接失败信息
return False
except Exception as e:
print(f"连接 Weaviate 失败: {
e}") # 打印异常信息
return False
if not check_weaviate_status(WEAVIATE_URL):
print(f"请启动您的 Weaviate 实例,例如使用 Docker: docker run -p 8080:8080 -p 50051:50051 --env AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED='true' --env PERSISTENCE_DATA_PATH='/var/lib/weaviate' semitechnologies/weaviate:1.23.5") # 提示用户启动 Weaviate
exit() # 退出程序
# 1. 初始化 WeaviateDocumentStore
# host: Weaviate 实例的主机地址或 URL。
# port: Weaviate 实例的端口。
# class_name: 在 Weaviate 中存储文档的类名称。
# embedding_dim: 嵌入向量的维度。
# auth_client_secret: 如果 Weaviate 需要认证,提供 API Key。
# By default, content is stored in a 'text' property, and meta data in 'meta'
try:
document_store = WeaviateDocumentStore(
host=WEAVIATE_URL.split('//')[1].split(':')[0], # 从 URL 中提取主机
port=int(WEAVIATE_URL.split(':')[-1]), # 从 URL 中提取端口
class_name=WEAVIATE_INDEX_NAME, # Weaviate 中的类名
embedding_dim=EMBEDDING_DIM, # 嵌入向量的维度
auth_client_secret=None, # 如果 Weaviate 没有认证,设置为 None 或不传
# api_key=WEAVIATE_API_KEY, # 如果需要认证
# scheme="http" # 协议,默认为 http
# headers={"X-OpenAI-Api-Key": os.environ.get("OPENAI_API_KEY")}, # 如果使用 Weaviate 的内置模块化嵌入(不通过 Haystack)
)
print(f"WeaviateDocumentStore 已初始化,连接到 {
WEAVIATE_URL},类 '{
WEAVIATE_INDEX_NAME}'。") # 打印初始化信息
# 清理旧的 Weaviate 类 (如果存在)
# 注意:这里直接操作 weaviate 客户端来删除类,生产环境谨慎操作
client = document_store.weaviate_client # 获取内部 Weaviate 客户端实例
if client.schema.exists(WEAVIATE_INDEX_NAME): # 检查类是否存在
client.schema.delete_class(WEAVIATE_INDEX_NAME) # 删除类
print(f"已删除旧的 Weaviate 类: '{
WEAVIATE_INDEX_NAME}'。") # 打印删除信息
time.sleep(1) # 等待删除完成
# 重新创建 Weaviate 模式(Schema),确保是 Haystack 兼容的
# 这在 WeaviateDocumentStore 初始化时通常会自动处理,但为了演示,可以手动确认
document_store.create_schema()
print(f"Weaviate 模式 (Schema) 已为类 '{
WEAVIATE_INDEX_NAME}' 配置。") # 打印模式配置信息
except Exception as e:
print(f"WeaviateDocumentStore 初始化失败: {
e}") # 打印错误信息
document_store = None # 设置为 None 以便后续跳过操作
if document_store:
# 2. 准备带嵌入的文档
# WeaviateDocumentStore 默认会将 Document 的 content 存储在 'text' 属性中,
# meta 字典会作为单独的属性存储。
docs_to_write = [
Document(id="weav_doc_001", content="Weaviate 支持结构化数据和非结构化数据。",
meta={
"source": "Weaviate文档", "type": "数据结构"},
embedding=np.random.rand(EMBEDDING_DIM).astype(np.float32)),
Document(id="weav_doc_002", content="它是一个向量原生的搜索引擎。",
meta={
"source": "Weaviate特点", "function": "搜索"},
embedding=np.random.rand(EMBEDDING_DIM).astype(np.float32)),
Document(id="weav_doc_003", content="你可以使用 GraphQL 或 REST API 查询 Weaviate。",
meta={
"source": "Weaviate API", "interface": "API"},
embedding=np.random.rand(EMBEDDING_DIM).astype(np.float32))
]
# 3. 写入文档
document_store.write_documents(docs_to_write)
print(f"
已写入 {
len(docs_to_write)} 个带嵌入的文档到 Weaviate。") # 打印写入文档数量
time.sleep(1) # 给 Weaviate 一点时间来索引
# 4. 获取所有文档
all_weaviate_docs = document_store.get_all_documents()
print(f"
Weaviate 中共有 {
len(all_weaviate_docs)} 个文档。") # 打印文档数量
if all_weaviate_docs:
print(f"第一个文档内容: '{
all_weaviate_docs[0].content[:50]}...'") # 打印第一个文档内容
print(f"第一个文档元数据: {
all_weaviate_docs[0].meta}") # 打印第一个文档元数据
# 5. 执行带复杂元数据过滤的检索示例
from haystack.nodes import EmbeddingRetriever # 导入嵌入检索器
retriever_weaviate = EmbeddingRetriever(
document_store=document_store,
embedding_model="sentence-transformers/all-MiniLM-L6-v2",
model_format="sentence_transformers",
top_k=2
)
query_text_weaviate = "关于搜索和数据结构的文档"
mock_query_embedding_weaviate = np.random.rand(EMBEDDING_DIM).astype(np.float32)
print(f"
执行带复杂元数据过滤的检索,查询: '{
query_text_weaviate}'") # 打印查询信息
# 在 Weaviate 中,filters 参数支持复杂的逻辑操作符,如 "$or", "$and", "$like", "$eq" 等
try:
# 检索元数据中 'type' 为 '数据结构' 或者 'function' 为 '搜索' 的文档
retrieved_docs_weaviate_filtered = document_store.query_by_embedding(
query_emb=mock_query_embedding_weaviate, # 传入模拟的查询嵌入
filters={
# 复杂的过滤条件
"$or": [ # 逻辑或
{
"type": {
"$eq": "数据结构"}}, # type 等于 "数据结构"
{
"function": {
"$eq": "搜索"}} # function 等于 "搜索"
]
},
top_k=2 # 检索前 2 个
)
print("Weaviate 检索结果 (复杂过滤):") # 打印检索结果标题
if retrieved_docs_weaviate_filtered:
for doc in retrieved_docs_weaviate_filtered:
print(f" ID: {
doc.id}, 分数: {
doc.score:.4f}, 内容: '{
doc.content[:50]}...', 元数据: {
doc.meta}") # 打印文档信息
else:
print("未找到符合条件的文档。") # 未找到文档提示
except Exception as e:
print(f"Weaviate 检索失败: {
e}") # 打印检索失败信息
# 清理 Weaviate 类 (可选)
# client = document_store.weaviate_client
# if client.schema.exists(WEAVIATE_INDEX_NAME):
# client.schema.delete_class(WEAVIATE_INDEX_NAME)
# print(f"
已删除 Weaviate 类: '{WEAVIATE_INDEX_NAME}'。")
代码说明:
check_weaviate_status(url):辅助函数,检查 Weaviate 服务是否运行。
WeaviateDocumentStore(host=..., port=..., class_name=..., embedding_dim=...):初始化 WeaviateDocumentStore。
host, port: Weaviate 实例的地址和端口。
class_name: Weaviate 中的“类”(Class),类似于关系型数据库中的表或 Elasticsearch 中的索引,用于组织文档。
embedding_dim: 嵌入向量的维度。
client.schema.delete_class(WEAVIATE_INDEX_NAME) 和 document_store.create_schema():在示例中,为了确保每次运行都从一个干净的状态开始,我们先尝试删除可能存在的旧 Weaviate 类,然后通过 document_store.create_schema() 重新创建 Haystack 兼容的 Weaviate 类结构。
document_store.write_documents(docs_to_write):写入文档。Haystack 会将 Document 对象的 content 字段映射到 Weaviate 类的 text 属性,将 meta 字典中的键值对映射为 Weaviate 类的额外属性。
document_store.query_by_embedding(query_emb=..., filters={...}, top_k=...):演示了 Weaviate 强大的元数据过滤能力。filters 参数可以接受复杂的嵌套字典结构,支持 $or, $and, $eq, $like 等多种逻辑和比较操作符,这使得你可以进行非常精确的混合搜索。
注意: 运行此示例需要一个运行中的 Weaviate 实例。你可以使用 Docker 快速启动:docker run -p 8080:8080 -p 50051:50051 --env AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED='true' --env PERSISTENCE_DATA_PATH='/var/lib/weaviate' semitechnologies/weaviate:1.23.5 (请根据你的 Weaviate 版本选择合适的镜像)。
2.3.1.5 MilvusDocumentStore / ZillizDocumentStore:云原生与大规模向量搜索
Milvus 是一个流行的开源向量数据库,专为大规模向量相似度搜索而设计。Zilliz Cloud 是 Milvus 的全托管云服务版本。它们都提供了强大的功能,适用于处理极大规模的向量数据集(十亿级以上),并在 Haystack 中有对应的 DocumentStore。
特点:
极致可伸缩: 支持 PB 级向量数据,具有高吞吐量和低延迟。
多种索引算法: 支持 IVF_FLAT, IVF_SQ8, HNSW 等多种索引类型,可根据需求权衡性能和存储。
混合搜索: 支持通过表达式进行元数据过滤,结合向量搜索。
分布式架构: 原生分布式设计,可横向扩展。
云服务(Zilliz Cloud): 提供全托管选项,降低运维负担。
优势: 适用于超大规模的向量搜索场景和企业级应用。
劣势: 部署和维护相对复杂(Milvus 自建),商业服务有成本(Zilliz Cloud),学习曲线较陡峭。
Haystack 中的集成:
MilvusDocumentStore 或 ZillizDocumentStore 允许你连接到 Milvus 或 Zilliz Cloud 实例。你需要提供连接地址、认证信息和集合(collection)名称。
import os # 导入操作系统模块
from haystack.document_stores import MilvusDocumentStore # 导入 MilvusDocumentStore 类
from haystack.schema import Document # 导入 Document 类
import numpy as np # 导入 numpy 库
import time # 导入时间模块
# 定义 Milvus 连接参数
# 确保你已经安装了 pymilvus: pip install pymilvus==2.2.0
# 以及 MilvusDocumentStore 的依赖: pip install 'haystack[milvus]'
# Milvus 服务地址 (例如,本地 Docker 部署或 Zilliz Cloud 地址)
MILVUS_HOST = os.environ.get("MILVUS_HOST", "localhost") # Milvus 主机地址,默认 localhost
MILVUS_PORT = os.environ.get("MILVUS_PORT", "19530") # Milvus 端口,默认 19530 (GRPC 端口)
MILVUS_USER = os.environ.get("MILVUS_USER", "root") # Milvus 用户名,默认 root
MILVUS_PASSWORD = os.environ.get("MILVUS_PASSWORD", "Milvus") # Milvus 密码,默认 Milvus
MILVUS_COLLECTION_NAME = "haystack_docs_milvus" # Milvus 集合名称
EMBEDDING_DIM = 768 # 嵌入向量的维度
# 检查 Milvus 实例是否运行
def check_milvus_status(host, port):
"""检查 Milvus 是否正在运行"""
try:
from pymilvus import utility # 从 pymilvus 导入 utility 模块
utility.milvus_check_alias(f"{
host}:{
port}") # 检查 Milvus 连接状态
print(f"Milvus 实例在 {
host}:{
port} 上运行正常。") # 打印成功信息
return True
except Exception as e:
print(f"无法连接到 Milvus 实例在 {
host}:{
port}。请确保它正在运行,并且端口可访问。错误: {
e}") # 打印连接失败信息
return False
if not check_milvus_status(MILVUS_HOST, MILVUS_PORT):
print(f"请启动您的 Milvus 实例,例如使用 Docker: docker run -d --name milvus_standalone -e "MILVUS_ETCD_ENABLE_AUTH=true" -e "MILVUS_ETCD_ROOT_USER=root" -e "MILVUS_ETCD_ROOT_PASSWORD=Milvus" -p 19530:19530 -p 9091:9091 milvusdb/milvus:v2.2.0") # 提示用户启动 Milvus
exit() # 退出程序
# 1. 初始化 MilvusDocumentStore
# host, port: Milvus 服务器地址和端口。
# collection_name: 在 Milvus 中存储文档的集合名称。
# embedding_dim: 向量维度。
# drop_old: 如果集合已存在,是否删除旧集合并创建新集合(用于清理)。
# index_params: Milvus 索引参数,如索引类型、度量方法。
# user, password: Milvus 认证信息。
try:
# 显式删除旧集合 (如果存在)
from pymilvus import Collection # 从 pymilvus 导入 Collection 类
if utility.has_collection(MILVUS_COLLECTION_NAME): # 检查集合是否存在
utility.drop_collection(MILVUS_COLLECTION_NAME) # 删除集合
print(f"已删除旧的 Milvus 集合: '{
MILVUS_COLLECTION_NAME}'。") # 打印删除信息
time.sleep(1) # 等待删除完成
document_store = MilvusDocumentStore(
host=MILVUS_HOST, # Milvus 主机
port=MILVUS_PORT, # Milvus 端口
collection_name=MILVUS_COLLECTION_NAME, # Milvus 集合名称
embedding_dim=EMBEDDING_DIM, # 嵌入向量的维度
drop_old=True, # 每次启动时删除旧集合并创建新集合 (方便测试)
# 索引参数,这里使用 HNSW 索引
index_params={
# 索引参数
"metric_type": "COSINE", # 相似度度量,如 COSINE, L2, IP
"index_type": "HNSW", # 索引类型,如 HNSW, IVF_FLAT, IVF_SQ8
"params": {
"M": 16, "efConstruction": 200} # HNSW 特有参数
},
# 搜索参数,用于查询时的优化
search_params={
# 搜索参数
"data_path": "./milvus_data", # 用于本地存储数据 (如果使用 Milvus standalone)
"params": {
"ef": 50} # HNSW 搜索特有参数
},
user=MILVUS_USER, # 用户名
password=MILVUS_PASSWORD # 密码
)
print(f"MilvusDocumentStore 已初始化,连接到 {
MILVUS_HOST}:{
MILVUS_PORT},集合 '{
MILVUS_COLLECTION_NAME}'。") # 打印初始化信息
except Exception as e:
print(f"MilvusDocumentStore 初始化失败: {
e}") # 打印错误信息
document_store = None # 设置为 None 以便后续跳过操作
if document_store:
# 2. 准备带嵌入的文档
# MilvusDocumentStore 要求 Document 对象有 id 和 embedding 属性
docs_to_write = [
Document(id="milv_doc_001", content="Milvus 是一个开源的向量数据库。",
meta={
"source": "Milvus官网", "project": "开源"},
embedding=np.random.rand(EMBEDDING_DIM).astype(np.float32)),
Document(id="milv_doc_002", content="它支持万亿级向量的存储和搜索。",
meta={
"source": "Milvus特点", "scale": "万亿级"},
embedding=np.random.rand(EMBEDDING_DIM).astype(np.float32)),
Document(id="milv_doc_003", content="Zilliz Cloud 是 Milvus 的全托管云服务。",
meta={
"source": "Zilliz官网", "cloud": "托管"},
embedding=np.random.rand(EMBEDDING_DIM).astype(np.float32))
]
# 3. 写入文档
document_store.write_documents(docs_to_write)
print(f"
已写入 {
len(docs_to_write)} 个带嵌入的文档到 Milvus。") # 打印写入文档数量
time.sleep(2) # 给 Milvus 一点时间来索引
# 加载集合到内存以便搜索 (Milvus 特有操作)
Collection(MILVUS_COLLECTION_NAME).load()
print(f"Milvus 集合 '{
MILVUS_COLLECTION_NAME}' 已加载到内存。") # 打印集合加载信息
# 4. 获取所有文档
all_milvus_docs = document_store.get_all_documents()
print(f"
Milvus 中共有 {
len(all_milvus_docs)} 个文档。") # 打印文档数量
if all_milvus_docs:
print(f"第一个文档内容: '{
all_milvus_docs[0].content[:50]}...'") # 打印第一个文档内容
print(f"第一个文档元数据: {
all_milvus_docs[0].meta}") # 打印第一个文档元数据
# 5. 执行带元数据过滤的检索示例
from haystack.nodes import EmbeddingRetriever # 导入嵌入检索器
retriever_milvus = EmbeddingRetriever(
document_store=document_store,
embedding_model="sentence-transformers/all-MiniLM-L6-v2",
model_format="sentence_transformers",
top_k=2
)
query_text_milvus = "关于向量数据库的扩展性"
mock_query_embedding_milvus = np.random.rand(EMBEDDING_DIM).astype(np.float32)
print(f"
执行带有元数据过滤的检索,查询: '{
query_text_milvus}'") # 打印查询信息
# Milvus 的 filters 参数接受字符串形式的布尔表达式
try:
retrieved_docs_milvus_filtered = document_store.query_by_embedding(
query_emb=mock_query_embedding_milvus, # 传入模拟的查询嵌入
# 过滤条件:project 等于 '开源' 或者 scale 等于 '万亿级'
filters="project == '开源' || scale == '万亿级'", # Milvus 过滤表达式
top_k=2 # 检索前 2 个
)
print("Milvus 检索结果 (复杂过滤):") # 打印检索结果标题
if retrieved_docs_milvus_filtered:
for doc in retrieved_docs_milvus_filtered:
print(f" ID: {
doc.id}, 分数: {
doc.score:.4f}, 内容: '{
doc.content[:50]}...', 元数据: {
doc.meta}") # 打印文档信息
else:
print("未找到符合条件的文档。") # 未找到文档提示
except Exception as e:
print(f"Milvus 检索失败: {
e}") # 打印检索失败信息
# 清理集合 (可选)
# utility.drop_collection(MILVUS_COLLECTION_NAME)
# print(f"
已删除 Milvus 集合: '{MILVUS_COLLECTION_NAME}'。")
代码说明:
check_milvus_status(host, port):辅助函数,检查 Milvus 服务是否运行。
MilvusDocumentStore(host=..., port=..., collection_name=..., embedding_dim=..., drop_old=True, index_params={...}, search_params={...}, user=..., password=...):初始化 MilvusDocumentStore。
host, port: 连接 Milvus 实例的地址和端口。
collection_name: Milvus 中的“集合”(Collection),用于存储向量。
embedding_dim: 嵌入向量的维度。
drop_old=True: 方便测试,每次运行时删除并重建集合。生产环境禁用此选项。
index_params: 定义 Milvus 索引的类型和参数。这里使用了 "HNSW" 索引,并指定了 M 和 efConstruction 参数。
search_params: 定义搜索时的参数,例如 ef 参数用于 HNSW 搜索。
user, password: Milvus 的认证信息。
utility.drop_collection(MILVUS_COLLECTION_NAME):在示例中,为了确保每次运行都是干净的,我们先尝试删除可能存在的旧集合。在生产环境中,请谨慎执行此操作。
document_store.write_documents(docs_to_write):写入文档。Haystack 会将每个 Document 转换为 Milvus 的实体,并将其写入指定的 collection。
Collection(MILVUS_COLLECTION_NAME).load():Milvus 特有操作。在进行搜索之前,你需要将集合加载(load)到内存中,这样 Milvus 才能执行查询。这是一个异步操作,但在这里为了演示简单直接调用。
document_store.query_by_embedding(query_emb=..., filters=..., top_k=...):演示了 Milvus 的元数据过滤能力。filters 参数接受一个字符串形式的布尔表达式,例如 project == '开源' || scale == '万亿级',这使得你可以在向量相似度搜索的同时,进行复杂的基于元数据的过滤。
注意: 运行此示例需要一个运行中的 Milvus 实例。你可以使用 Docker 快速启动一个:docker run -d --name milvus_standalone -e "MILVUS_ETCD_ENABLE_AUTH=true" -e "MILVUS_ETCD_ROOT_USER=root" -e "MILVUS_ETCD_ROOT_PASSWORD=Milvus" -p 19530:19530 -p 9091:9091 milvusdb/milvus:v2.2.0 (请根据你的 Milvus 版本选择合适的镜像)。
2.3.1.6 ChromaDocumentStore:轻量级嵌入数据库
Chroma 是一个开源的、轻量级的嵌入数据库。它可以在本地运行,提供简单的 API 来存储和查询嵌入,非常适合快速原型开发、小型项目或作为本地缓存层。
特点:
轻量级: 部署和使用非常简单,无需复杂的服务器设置。
内存或持久化: 可以在内存中运行,也可以将数据持久化到本地文件系统。
简单 API: 提供直观的 Python API。
元数据过滤: 支持基本的元数据过滤。
优势: 上手快、易于使用、适合本地开发和小型数据集。
劣势: 不支持分布式部署、可伸缩性有限、性能不如专业向量数据库。
Haystack 中的集成:
ChromaDocumentStore 连接到 Chroma 客户端,你可以指定数据持久化的路径。
import os # 导入操作系统模块
from haystack.document_stores import ChromaDocumentStore # 导入 ChromaDocumentStore 类
from haystack.schema import Document # 导入 Document 类
import numpy as np # 导入 numpy 库
# 定义 Chroma 存储路径
CHROMA_PATH = "chroma_db" # 定义 Chroma 数据库文件存储路径
# 1. 初始化 ChromaDocumentStore
# persist_path: 存储 Chroma 数据库文件的目录。如果设置为 None,则在内存中运行。
# collection_name: Chroma 中的集合名称。
# embedding_dim: 嵌入向量的维度。
try:
# 确保目录存在,否则 Chroma 可能无法持久化
os.makedirs(CHROMA_PATH, exist_ok=True) # 确保 Chroma 数据库路径存在
document_store = ChromaDocumentStore(
persist_path=CHROMA_PATH, # 数据库持久化路径
collection_name="haystack_chroma_collection", # Chroma 集合名称
embedding_dim=EMBEDDING_DIM # 嵌入向量的维度
)
print(f"ChromaDocumentStore 已初始化,数据将持久化到 '{
CHROMA_PATH}'。") # 打印初始化信息
# 清理旧数据 (可选,确保每次运行都是干净的)
# Chroma 没有直接的 "drop collection" 方法,通常是删除整个目录
# 或者通过其 client API
# from chromadb import Client, Settings
# client = Client(Settings(persist_directory=CHROMA_PATH))
# try:
# client.delete_collection("haystack_chroma_collection")
# print("已删除旧的 Chroma 集合。")
# except:
# pass
except Exception as e:
print(f"ChromaDocumentStore 初始化失败: {
e}") # 打印错误信息
document_store = None # 设置为 None 以便后续跳过操作
if document_store:
# 2. 准备带嵌入的文档
# ChromaDocumentStore 接受 Document 对象
docs_to_write = [
Document(content="Chroma 是一个轻量级的嵌入数据库。",
meta={
"source": "Chroma官网", "category": "数据库"},
embedding=np.random.rand(EMBEDDING_DIM).astype(np.float32)),
Document(content="它非常适合本地开发和原型。",
meta={
"source": "Chroma特点", "usage": "开发"},
embedding=np.random.rand(EMBEDDING_DIM).astype(np.float32)),
Document(content="Haystack 可以无缝集成 Chroma 作为文档存储。",
meta={
"source": "Haystack集成", "tool": "Haystack"},
embedding=np.random.rand(EMBEDDING_DIM).astype(np.float32))
]
# 3. 写入文档
document_store.write_documents(docs_to_write)
print(f"
已写入 {
len(docs_to_write)} 个带嵌入的文档到 Chroma。") # 打印写入文档数量
# 4. 获取所有文档
all_chroma_docs = document_store.get_all_documents()
print(f"
Chroma 中共有 {
len(all_chroma_docs)} 个文档。") # 打印文档数量
if all_chroma_docs:
print(f"第一个文档内容: '{
all_chroma_docs[0].content[:50]}...'") # 打印第一个文档内容
print(f"第一个文档元数据: {
all_chroma_docs[0].meta}") # 打印第一个文档元数据
# 5. 执行带元数据过滤的检索示例
from haystack.nodes import EmbeddingRetriever # 导入嵌入检索器
retriever_chroma = EmbeddingRetriever(
document_store=document_store,
embedding_model="sentence-transformers/all-MiniLM-L6-v2",
model_format="sentence_transformers",
top_k=2
)
query_text_chroma = "关于数据库的应用"
mock_query_embedding_chroma = np.random.rand(EMBEDDING_DIM).astype(np.float32)
print(f"
执行带有元数据过滤的检索,查询: '{
query_text_chroma}'") # 打印查询信息
# Chroma 的 filters 参数与 MongoDB 风格类似
try:
retrieved_docs_chroma_filtered = document_store.query_by_embedding(
query_emb=mock_query_embedding_chroma, # 传入模拟的查询嵌入
filters={
"category": {
"$eq": "数据库"}}, # 过滤 category 等于 "数据库"
top_k=2 # 检索前 2 个
)
print("Chroma 检索结果 (过滤 category='数据库'):") # 打印检索结果标题
if retrieved_docs_chroma_filtered:
for doc in retrieved_docs_chroma_filtered:
print(f" ID: {
doc.id}, 分数: {
doc.score:.4f}, 内容: '{
doc.content[:50]}...', 元数据: {
doc.meta}") # 打印文档信息
else:
print("未找到符合条件的文档。") # 未找到文档提示
except Exception as e:
print(f"Chroma 检索失败: {
e}") # 打印检索失败信息
# 清理 Chroma 数据 (可选)
# shutil.rmtree(CHROMA_PATH) # 删除整个持久化目录
# print(f"
已删除 Chroma 数据库目录: '{CHROMA_PATH}'。")
代码说明:
ChromaDocumentStore(persist_path=CHROMA_PATH, collection_name=...):初始化 ChromaDocumentStore。
persist_path: 如果你想将 Chroma 数据持久化到磁盘,则指定一个目录。如果为 None,则数据仅存在于内存中。
collection_name: 在 Chroma 中创建一个集合来存储文档。
os.makedirs(CHROMA_PATH, exist_ok=True):确保持久化目录存在。
document_store.write_documents(docs_to_write):写入文档。Chroma 会将 Document 的 content、meta 和 embedding 都存储起来。
document_store.query_by_embedding(query_emb=..., filters={...}, top_k=...):演示了 Chroma 的元数据过滤。filters 参数支持基本的键值对匹配和操作符(如 $eq)。
2.3.1.7 DocumentStore 选型考量
选择合适的 DocumentStore 是 RAG 系统设计中的一个关键决策,需要综合考虑以下因素:
数据规模(Data Volume):
小型 (数千到数十万向量): InMemoryDocumentStore (非持久化), FAISSDocumentStore (本地持久化), ChromaDocumentStore (本地持久化) 是不错的选择,部署简单,适合原型和小型应用。
中型 (数十万到数百万向量): FAISSDocumentStore (CPU/GPU), 性能较好。ElasticsearchDocumentStore (如果同时需要全文搜索) 也可以考虑。
大型/超大型 (数百万到数十亿/万亿向量): ElasticsearchDocumentStore, PineconeDocumentStore, WeaviateDocumentStore, MilvusDocumentStore (或 ZillizDocumentStore) 是更合适的选择,它们具备分布式、可伸缩和高可用性特性。
性能要求(Performance Requirements):
延迟敏感(Low-latency): Pinecone, Weaviate, Milvus/Zilliz Cloud 通常提供最低的查询延迟,尤其是在大规模数据集上。高性能的 FAISS 索引(如 HNSW)在内存中也很快。
高吞吐量(High-throughput): 分布式向量数据库通常能够处理更高的并发查询请求。
功能需求(Feature Requirements):
仅向量搜索: FAISS, Pinecone 是纯粹的向量数据库,专注于相似度搜索。
向量搜索 + 全文搜索(混合搜索): Elasticsearch, Weaviate 提供了原生支持。
向量搜索 + 复杂元数据过滤: Pinecone, Weaviate, Milvus 提供了强大的元数据过滤能力。
结构化数据管理/知识图谱: Weaviate 在这方面有独特优势,能够将数据建模为带有属性的类。
实时数据更新: 大部分云原生向量数据库都支持实时或准实时的数据更新。
部署与运维(Deployment & Operations):
本地/自托管: FAISS, Chroma 部署简单,但需要自己管理服务器和数据。Elasticsearch, Milvus 也可以自托管,但运维复杂。
云托管服务: Pinecone, Zilliz Cloud 是全托管服务,极大降低了运维负担,但通常成本更高。
许可证: 注意开源与商业许可证的区别。
成本(Cost):
开源自托管方案(FAISS, Chroma, Milvus, Elasticsearch)通常初期成本较低,但需要投入人力进行部署、维护和扩展。
云托管服务(Pinecone, Zilliz Cloud)按使用量计费,初期成本可能较高,但降低了运维成本。
技术栈与生态系统(Tech Stack & Ecosystem):
如果你的团队已经在使用 Elasticsearch,那么集成 ElasticsearchDocumentStore 可能会更顺畅。
考虑现有数据管道、监控系统和开发人员的熟悉程度。
综合对比图(概念性,非 Haystack 内部实现,用于辅助理解):
| DocumentStore | 部署方式 | 规模支持 | 核心功能 | 优点 | 缺点 | 典型应用场景 |
|---|---|---|---|---|---|---|
| InMemoryDocumentStore | 本地内存 | 小型 | 向量/文本 | 极速、简单 | 无持久化、无扩展性 | 原型、本地调试 |
| FAISSDocumentStore | 本地文件 | 中型 | 向量 | 高性能、易用、可持久 | 无分布式、无高可用 | 本地应用、中等规模 RAG |
| ChromaDocumentStore | 本地文件 | 小型 | 向量/文本 | 轻量、易用、可持久 | 无分布式、性能有限 | 快速原型、小型知识库 |
| ElasticsearchDocumentStore | 自托管/云服务 | 大型 | 全文+向量 | 混合搜索、可伸缩、功能丰富 | 运维复杂、资源消耗大 | 大规模企业知识库、需要复杂搜索 |
| PineconeDocumentStore | 云托管 | 超大型 | 向量+元数据过滤 | 高性能、全托管、高可用 | 商业成本、依赖云服务商 | 生产级大规模 RAG、低延迟要求 |
| WeaviateDocumentStore | 自托管/云服务 | 大型 | 向量+知识图谱+过滤 | 语义能力强、结构化数据、混合搜索 | 运维复杂、学习曲线 | 知识图谱类问答、复杂实体关系 |
| Milvus/ZillizDocumentStore | 自托管/云服务 | 超大型 | 向量+元数据过滤 | 极致可伸缩、高性能 | 运维复杂、学习曲线 | 海量向量检索、工业级应用 |
通过以上详细的分析和示例,你应该对 Haystack 与主流向量数据库的集成方式有了全面而深入的理解。在实际项目中,根据你的具体需求和资源限制,选择最合适的 DocumentStore 是构建高效、可靠 RAG 系统的第一步。
2.3.2 与主流 LLM 提供商的集成:对话智能的源泉
大语言模型(LLMs)是 RAG 架构的最终答案生成环节。Haystack 通过其灵活的 Generator 和 PromptNode 组件,提供了与多种 LLM 提供商的无缝集成,无论是商业 API 服务还是开源的本地模型,都可以轻松接入。这种灵活性使得开发者能够根据成本、性能、数据隐私和模型能力等需求,选择最合适的 LLM。
本节将深入探讨 Haystack 如何与几种主流的 LLM 提供商进行集成,包括它们的特点、优势以及在 Haystack 中的配置方法。
2.3.2.1 OpenAI Generator:商业 LLM 服务的领先者
OpenAI 提供的 GPT 系列模型(如 GPT-3.5 Turbo, GPT-4)是当前市场上最强大、最通用的 LLM 之一。它们在各种自然语言任务上表现出色,包括文本生成、问答、摘要、翻译等。Haystack 通过 OpenAIGenerator 组件,可以方便地调用 OpenAI 的 API 来生成答案。
特点:
卓越性能: 在广泛的任务上表现出惊人的理解和生成能力。
易用性: 通过简单的 API 调用即可使用。
持续更新: 模型能力不断迭代和提升。
多模态能力: 部分模型支持图像、音频等多模态输入。
优势: 最高的答案质量、广泛的通用性、降低了模型训练和部署的复杂性。
劣势: 商业服务,按使用量计费(成本考量);数据隐私和安全(数据可能流经第三方服务器,需要考虑合规性);依赖外部 API,存在网络延迟和服务可用性问题。
Haystack 中的集成:
OpenAIGenerator 是 Haystack 中用于与 OpenAI API 交互的组件。它接受用户的查询和检索到的上下文文档,将它们格式化成一个适合 OpenAI 模型处理的 Prompt,然后发送 API 请求,并返回生成的答案。
import os # 导入操作系统模块,用于获取环境变量
from haystack.nodes import OpenAIGenerator # 导入 Haystack 的 OpenAIGenerator
from haystack.schema import Document # 导入 Haystack 的 Document 类型
# 从环境变量中获取 OpenAI API 密钥
# 在运行此代码之前,请确保你已设置 OPENAI_API_KEY 环境变量,例如:
# export OPENAI_API_KEY="sk-YOUR_ACTUAL_OPENAI_API_KEY"
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY") # 获取 OpenAI API 密钥
if not OPENAI_API_KEY:
print("错误: OPENAI_API_KEY 环境变量未设置。请设置您的 OpenAI API 密钥。") # 提示用户设置环境变量
exit() # 退出程序,因为没有 API 密钥无法进行后续操作
# 1. 初始化 OpenAIGenerator
# api_key: 用于认证 OpenAI API 请求的密钥。
# model_name: 指定要使用的 OpenAI 模型,例如 "gpt-3.5-turbo" 或 "gpt-4"。
# temperature: 控制生成文本的随机性。值越高,生成结果越多样和富有创意,但也可能更不准确。
# 0.0 表示最确定、最重复的结果,1.0 表示最高随机性。
# max_tokens: 控制生成文本的最大长度(以词元为单位)。
# top_p: 用于控制采样策略。模型会考虑概率累积达到 top_p 的词元。值越低,生成文本越保守。
# system_prompt: 可选的系统级指令,用于定义模型在对话中的角色或行为。
# 例如,"你是一个乐于助人的AI助手" 可以引导模型以友好的方式回答问题。
# api_base: 可选的自定义 API 基础 URL,用于连接到非默认的 OpenAI 端点或代理。
try:
generator = OpenAIGenerator(
api_key=OPENAI_API_KEY, # 传入 OpenAI API 密钥
model_name="gpt-3.5-turbo", # 指定使用 GPT-3.5 Turbo 模型
temperature=0.5, # 设置适中的随机性
max_tokens=150, # 设置最大生成词元数
top_p=0.9, # 设置采样策略
system_prompt="你是一个专业的知识问答机器人,只根据提供的上下文回答问题,不偏离事实。" # 定义系统角色
# api_base="https://api.openai.com/v1" # 默认值,如果需要可自定义
)
print(f"OpenAIGenerator 已成功初始化,使用模型: {
generator.model_name}.") # 打印初始化成功信息
except Exception as e:
print(f"OpenAIGenerator 初始化失败: {
e}") # 打印初始化失败信息
print("请检查您的 API 密钥是否有效,网络连接是否正常,以及模型名称是否正确。") # 提示检查
exit() # 退出程序
# 2. 准备查询和上下文文档
# 在 RAG 管道中,这些文档通常由检索器提供。
# 这里的文档模拟了从知识库中检索到的相关上下文。
query_text = "RAG 技术是如何解决大语言模型幻觉问题的?" # 用户查询
context_documents = [
Document(content="检索增强生成(RAG)是一种框架,它通过在生成答案之前从外部知识库中检索相关信息来增强大语言模型(LLMs)的能力。"), # 上下文文档 1
Document(content="RAG 的核心思想是,它提供了可验证的事实来源,从而减少了 LLM 产生虚假或不准确信息(即幻觉)的倾向。"), # 上下文文档 2
Document(content="通过将检索到的精确上下文提供给 LLM,模型被引导去基于这些事实进行推理和生成,而不是依赖其内部的模糊记忆。"), # 上下文文档 3
Document(content="例如,在医疗问答系统中,RAG 可以确保模型仅使用最新的医学文献来回答问题,避免过时或错误的信息。") # 上下文文档 4
]
# 3. 执行生成
# run 方法接受 query 和 documents 作为输入。
# 它将根据内部逻辑(或通过 PromptNode 配置的 prompt_template)构建最终的 Prompt。
print(f"
正在为查询 '{
query_text}' 生成回复...") # 打印生成回复信息
try:
generation_results = generator.run(
query=query_text, # 传入用户查询
documents=context_documents # 传入检索到的上下文文档
)
# generation_results["replies"] 包含 LLM 生成的回复字符串列表
generated_reply = generation_results["replies"][0] # 通常只取第一个回复
print("
--- 生成的回复 ---") # 打印回复标题
print(generated_reply) # 打印生成的回复
print("
--- 来源上下文 ---") # 打印来源上下文标题
for i, doc in enumerate(context_documents): # 遍历上下文文档
print(f"文档 {
i+1} (ID: {
doc.id}): '{
doc.content[:80]}...'") # 打印文档信息和部分内容
except Exception as e:
print(f"生成回复失败: {
e}") # 打印生成失败信息
print("请检查您的 API 密钥是否有效,模型调用频率是否受限,或输入的上下文是否过长。") # 提示检查
代码说明:
os.environ.get("OPENAI_API_KEY"):获取 OpenAI API 密钥。强烈建议通过环境变量而不是硬编码来管理 API 密钥,以增强安全性。
OpenAIGenerator(...):初始化 OpenAIGenerator 实例。
api_key, model_name: 连接 OpenAI 服务的必要参数。
temperature, max_tokens, top_p: 这些是控制 LLM 生成行为的关键参数,需要根据具体应用场景进行调优。
system_prompt: 允许你为 LLM 设置一个“人格”或“指令”,以引导其行为,例如使其表现得更专业、更简洁或更有创意。
generator.run(query=query_text, documents=context_documents):调用 OpenAIGenerator 的 run 方法来生成回复。query 是用户的原始问题,documents 是一个 Document 对象列表,其中包含了从知识库中检索到的相关上下文。OpenAIGenerator 会自动将这些信息组合成一个适合 ChatCompletion API(对于 gpt-3.5-turbo 和 gpt-4)的格式。
2.3.2.2 HuggingFace Local Generator:本地部署与开源模型的灵活选择
Hugging Face 是一个拥有庞大开源 NLP 模型和数据集的平台。Haystack 通过 HuggingFaceLocalGenerator 或 PromptNode 结合 transformers 库,可以方便地加载和使用 Hugging Face Hub 上的各种生成式模型,并在本地(或私有服务器)进行推理。这为那些对数据隐私、成本或离线部署有严格要求的场景提供了解决方案。
特点:
数据隐私与安全: 模型在本地运行,数据不离开你的基础设施。
成本效益: 无需支付每次 API 调用费用,只需支付硬件和电力成本。
离线可用: 部署后可在无网络连接环境下运行(前提是模型已下载)。
高度可定制: 可以对模型进行微调,以适应特定领域和任务。
模型选择多样性: Hugging Face Hub 上有数千个预训练模型可供选择,覆盖多种语言、架构和大小。
优势: 更好的数据控制、更低的长期运行成本(对于高频使用)、灵活性高。
劣势: 需要管理和维护模型运行的硬件基础设施(GPU 通常是必需的);模型推理速度受限于本地硬件性能;加载大型模型可能需要大量 GPU 内存。
Haystack 中的集成:
Haystack 提供了两种主要方式来集成 Hugging Face 本地模型:
HuggingFaceLocalGenerator: 直接用于生成文本,通常不负责 RAG 中的上下文融合,而是直接调用 Hugging Face pipeline。
PromptNode: 这是更推荐的方式,因为它提供了强大的 Prompt Engineering 功能,允许你灵活地定义如何将查询和检索到的上下文传递给 LLM。PromptNode 兼容多种 LLM backend,包括 Hugging Face 本地模型。
示例:使用 HuggingFaceLocalGenerator (直接生成)
import os # 导入操作系统模块
from haystack.nodes import HuggingFaceLocalGenerator # 导入 HuggingFaceLocalGenerator
# 定义模型名称
# 选择一个相对较小且适合本地运行的生成模型
# 例如:'distilgpt2' (小型通用文本生成), 'google/flan-t5-small' (小型的T5模型)
# 对于实际的 RAG 任务,可能需要更大的模型,如 'google/flan-t5-large' 或 'Llama-2-7b-chat-hf' (如果本地有足够资源)
MODEL_NAME = "google/flan-t5-small" # 选择一个小型 T5 模型进行演示
# MODEL_NAME = "distilgpt2" # 另一个轻量级选择
# 1. 初始化 HuggingFaceLocalGenerator
# model_name_or_path: 指定 Hugging Face 模型名称或本地路径。
# device: 'cpu' 或 'cuda'。建议使用 'cuda' 如果有 GPU。
# max_length: 生成文本的最大长度。
# temperature: 控制生成随机性。
# do_sample: 是否进行采样生成 (True) 或贪婪解码 (False)。
# num_beams: 用于束搜索解码的束数量 (如果 do_sample=False)。
# top_k, top_p: 控制采样多样性。
try:
generator_hf_local = HuggingFaceLocalGenerator(
model_name_or_path=MODEL_NAME, # 指定模型名称
device="cpu", # 在 CPU 上运行 (如果安装了 CUDA 和 PyTorch GPU 版本,可设置为 'cuda')
max_length=100, # 生成文本的最大长度
temperature=0.7, # 适中的随机性
do_sample=True, # 启用采样生成
# 如果模型支持,可以添加 'torch_dtype=torch.float16' 或 'load_in_8bit=True' 进行量化加载以节省内存
# model_kwargs={"torch_dtype": "auto"} # 让 transformers 自动选择最佳 dtype
)
print(f"HuggingFaceLocalGenerator 已成功初始化,使用模型: {
MODEL_NAME}.") # 打印初始化成功信息
except Exception as e:
print(f"HuggingFaceLocalGenerator 初始化失败: {
e}") # 打印初始化失败信息
print("请检查模型名称是否正确,网络连接是否能下载模型,以及本地硬件资源是否足够。") # 提示检查
exit() # 退出程序
# 2. 准备输入文本
# HuggingFaceLocalGenerator 直接生成文本,它不像 OpenAIGenerator 那样内置 RAG 友好的上下文处理。
# 所以,我们需要手动将查询和上下文拼接成一个 Prompt。
query_text_hf = "Haystack 是什么?" # 用户查询
context_hf = """
Haystack 是一个开源的 NLP 框架,用于构建生产就绪的问答系统、语义搜索和企业级 LLM 应用。
它提供了模块化的组件,包括文档存储、嵌入器、检索器、阅读器和生成器。
Haystack 的设计理念是生产就绪,支持多种 LLM 和向量数据库,并且易于扩展和维护。
""" # 模拟检索到的上下文
# 构建一个包含上下文的 Prompt
# 注意:这只是一个示例 Prompt 模板,实际效果取决于模型和模板设计。
# 对于 flan-t5-small 这样的指令微调模型,指令式 Prompt 更有效。
prompt_hf = f"""
请根据以下上下文信息回答问题。如果上下文没有包含足够的信息,请说明。
上下文:
{
context_hf}
问题:
{
query_text_hf}
回答:
"""
print("
构建的 Prompt:") # 打印构建的 Prompt 标题
print(prompt_hf) # 打印构建的 Prompt
# 3. 执行生成
print(f"
正在为查询 '{
query_text_hf}' 生成回复 (本地模型)...") # 打印生成回复信息
try:
# HuggingFaceLocalGenerator 的 run 方法接受 inputs 字典
# 对于文本生成,通常将完整的 prompt 放在 "query" 键下
generation_results_hf = generator_hf_local.run(query=prompt_hf)
# generation_results_hf["replies"] 包含 LLM 生成的回复字符串列表
generated_reply_hf = generation_results_hf["replies"][0]
print("
--- 生成的回复 (HuggingFaceLocalGenerator) ---") # 打印回复标题
print(generated_reply_hf) # 打印生成的回复
except Exception as e:
print(f"HuggingFaceLocalGenerator 生成回复失败: {
e}") # 打印生成失败信息
print("请检查输入 Prompt 是否过长,或模型推理时发生错误。") # 提示检查
代码说明:
HuggingFaceLocalGenerator(...):初始化 HuggingFaceLocalGenerator 实例。
model_name_or_path: 指定 Hugging Face Hub 上的模型名称。当第一次运行且模型不在本地时,它会自动下载。
device: 推荐设置为 cuda 如果有 GPU,否则为 cpu。
max_length, temperature, do_sample: 控制生成文本的长度和随机性。
prompt_hf = f"""...""": 由于 HuggingFaceLocalGenerator 更通用,不像 OpenAIGenerator 那样自动处理 documents 和 query 的格式化,你需要手动将查询和上下文拼接成一个完整的 Prompt 字符串。这使得你对 Prompt 的结构有完全的控制。
generator_hf_local.run(query=prompt_hf): 调用 run 方法进行文本生成。
示例:使用 PromptNode 结合 Hugging Face 模型 (推荐用于 RAG)
PromptNode 是 Haystack 中更强大的组件,专为 Prompt Engineering 和 LLM 交互设计。它可以与多种后端 LLM(包括 Hugging Face 本地模型、OpenAI、Cohere 等)配合使用,并通过 prompt_template 参数灵活定义如何将上下文和查询传递给 LLM。
from haystack.nodes import PromptNode, PromptTemplate # 导入 PromptNode 和 PromptTemplate
from haystack.schema import Document # 导入 Document
# 再次定义模型名称,这里使用适合指令微调的模型
MODEL_NAME_PROMPT = "google/flan-t5-small" # 另一个小型 T5 模型,适合指令微调
# 1. 定义 Prompt 模板
# PromptTemplate 允许你使用占位符 ({query}, {documents}) 来定义 Prompt 的结构。
# Haystack 会自动用实际的查询和文档内容替换这些占位符。
# {documents} 占位符会自动将传入的 Document 对象列表格式化为可读的文本。
rag_prompt_template = PromptTemplate(
prompt="""根据以下信息回答问题。如果信息不足,请说明。
上下文:
{documents}
问题:{query}
回答:
""", # 模板字符串,包含上下文和问题的占位符
output_parser=None # 可以指定输出解析器,如果 LLM 输出需要结构化处理
)
print("
PromptTemplate 已定义。") # 打印模板定义信息
# 2. 初始化 PromptNode
# model_name_or_path: Hugging Face 模型名称或路径。
# default_prompt_template: 使用上面定义的 PromptTemplate。
# use_gpu: 是否使用 GPU。
# max_length: 生成文本的最大长度。
# model_kwargs: 传递给 Hugging Face `transformers` 模型加载器的额外参数。
# 例如,`{"torch_dtype": "auto", "load_in_8bit": True}` 用于量化加载。
try:
# 注意:对于 HuggingFaceLocalGenerator 或 PromptNode,初次运行可能会下载模型
prompt_node_hf = PromptNode(
model_name_or_path=MODEL_NAME_PROMPT, # 指定 Hugging Face 模型
default_prompt_template=rag_prompt_template, # 使用定义的 Prompt 模板
model_kwargs={
"temperature": 0.7, # 控制生成随机性
"max_new_tokens": 100, # 生成的最大新词元数
"do_sample": True, # 启用采样生成
# "device_map": "auto", # 自动选择设备(例如 GPU)
# "torch_dtype": "auto" # 自动选择 PyTorch 数据类型以优化性能
},
api_key=None, # 本地模型不需要 API key
top_k=1, # 仅生成一个回复
output_variable="replies", # 定义输出变量名称
)
print(f"PromptNode 已成功初始化,使用模型: {
MODEL_NAME_PROMPT}.") # 打印初始化成功信息
except Exception as e:
print(f"PromptNode 初始化失败: {
e}") # 打印初始化失败信息
print("请检查模型名称是否正确,网络连接是否能下载模型,以及本地硬件资源是否足够。") # 提示检查
exit() # 退出程序
# 3. 准备查询和上下文文档
query_text_prompt = "什么是 NLP 中的 Transformer 架构?" # 用户查询
context_documents_prompt = [
Document(content="Transformer 架构于 2017 年由 Vaswani 等人提出,是一种序列到序列模型。"), # 上下文文档 1
Document(content="它完全基于注意力机制,特别是自注意力机制,放弃了传统的循环神经网络(RNN)和卷积神经网络(CNN)。"), # 上下文文档 2
Document(content="Transformer 模型的出现极大地推动了自然语言处理(NLP)领域的发展,催生了 BERT、GPT 等一系列强大的模型。"), # 上下文文档 3
Document(content="注意力机制允许模型在处理一个词时,能同时考虑输入序列中的所有其他词的关联性,从而捕捉到长距离依赖。") # 上下文文档 4
]
# 4. 执行生成
print(f"
正在为查询 '{
query_text_prompt}' 生成回复 (PromptNode + 本地模型)...") # 打印生成回复信息
try:
# PromptNode 的 run 方法与 Generator 类似,接受 query 和 documents
generation_results_prompt = prompt_node_hf.run(
query=query_text_prompt, # 传入用户查询
documents=context_documents_prompt # 传入检索到的上下文文档
)
# 结果在 output_variable 定义的键下,默认为 'results'
generated_reply_prompt = generation_results_prompt["replies"][0] # 访问生成的回复
print("
--- 生成的回复 (PromptNode + 本地 Hugging Face) ---") # 打印回复标题
print(generated_reply_prompt) # 打印生成的回复
print("
--- 来源上下文 ---") # 打印来源上下文标题
for i, doc in enumerate(context_documents_prompt): # 遍历上下文文档
print(f"文档 {
i+1} (ID: {
doc.id}): '{
doc.content[:80]}...'") # 打印文档信息和部分内容
except Exception as e:
print(f"PromptNode 生成回复失败: {
e}") # 打印生成失败信息
print("请检查输入 Prompt 是否过长,模型推理时是否遇到 OOM 或其他硬件问题。") # 提示检查
代码说明:
PromptTemplate(...):创建 PromptTemplate 对象。这是 PromptNode 的核心,它定义了 LLM 接收的完整 Prompt 结构。{documents} 和 {query} 是特殊的占位符,Haystack 会自动用实际内容填充。
PromptNode(...):初始化 PromptNode 实例。
model_name_or_path: 与 HuggingFaceLocalGenerator 类似,指定 Hugging Face 模型。
default_prompt_template: 将上面定义的 PromptTemplate 传递给 PromptNode,告诉它如何构建 Prompt。
model_kwargs: 这是传递给 Hugging Face transformers 库中模型加载器和生成器方法(如 generate)的额外参数。可以用来设置 temperature, max_new_tokens(代替 max_length 确保只生成新词元),do_sample 等,以及一些优化参数如 device_map="auto"(自动使用 GPU)和 torch_dtype="auto"。
output_variable="replies": 定义 PromptNode 的输出字典中,生成的回复应该存储在哪个键下。
prompt_node_hf.run(query=query_text_prompt, documents=context_documents_prompt):执行生成操作。PromptNode 会使用 default_prompt_template 将 query 和 documents 组装成最终的 Prompt,然后将其传递给底层的 Hugging Face 模型进行生成。
2.3.2.3 Azure OpenAI Generator:企业级云部署与合规性
Azure OpenAI Service 是微软 Azure 云平台提供的一项服务,允许企业客户在 Azure 的安全环境中访问 OpenAI 的模型(如 GPT-3.5 Turbo, GPT-4, Embeddings 等)。它提供了与 OpenAI API 几乎相同的模型能力,但额外提供了 Azure 的企业级安全性、合规性、私有网络支持以及更易于管理的企业级部署。
特点:
企业级安全与合规: 符合严格的企业安全标准和合规性要求。
数据隐私: 默认情况下,发送到 Azure OpenAI 的数据不会用于训练 OpenAI 或 Microsoft 的模型,提供更强的数据隐私保证。
Azure 生态集成: 易于与 Azure 其他服务(如 Azure Kubernetes Service, Azure Functions, Azure Cosmos DB 等)集成。
私有网络访问: 可以通过私有端点从私有网络访问服务,增强安全性。
速率限制与配额管理: 提供更细粒度的控制和管理。
优势: 满足企业对数据安全、隐私和合规性的严苛要求;适用于需要大规模部署和与现有 Azure 基础设施集成的场景。
劣势: 相比直接使用 OpenAI API,配置和管理可能稍显复杂(需要 Azure 资源创建和部署);成本考量。
Haystack 中的集成:
Haystack 的 AzureOpenAIGenerator 组件专门用于连接 Azure OpenAI Service。你需要提供 Azure OpenAI 的部署名称、API 版本、API Key 和基础 URL。
import os # 导入操作系统模块
from haystack.nodes import AzureOpenAIGenerator # 导入 AzureOpenAIGenerator
from haystack.schema import Document # 导入 Document
# 定义 Azure OpenAI 连接参数
# 请确保您已经在 Azure 门户中创建了 Azure OpenAI 资源,并部署了模型。
# DEPLOYMENT_NAME 是您在 Azure OpenAI Studio 中部署模型的名称(例如:gpt-35-turbo-deploy)。
# API_VERSION 是您使用的 API 版本(例如:2023-05-15 或 2023-07-01-preview)。
# AZURE_OPENAI_ENDPOINT 是您的 Azure OpenAI 资源的端点 URL(例如:https://YOUR_RESOURCE_NAME.openai.azure.com/)。
# AZURE_OPENAI_API_KEY 是您的 Azure OpenAI API 密钥。
AZURE_OPENAI_API_KEY = os.environ.get("AZURE_OPENAI_API_KEY") # 从环境变量获取 Azure API 密钥
AZURE_OPENAI_ENDPOINT = os.environ.get("AZURE_OPENAI_ENDPOINT") # 从环境变量获取 Azure 端点
AZURE_OPENAI_DEPLOYMENT_NAME = os.environ.get("AZURE_OPENAI_DEPLOYMENT_NAME") # 从环境变量获取部署名称
AZURE_OPENAI_API_VERSION = os.environ.get("AZURE_OPENAI_API_VERSION", "2023-07-01-preview") # 从环境变量获取 API 版本,或使用默认值
if not all([AZURE_OPENAI_API_KEY, AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_DEPLOYMENT_NAME]):
print("错误: 缺少 Azure OpenAI 相关的环境变量。") # 提示缺少环境变量
print("请设置 AZURE_OPENAI_API_KEY, AZURE_OPENAI_ENDPOINT 和 AZURE_OPENAI_DEPLOYMENT_NAME。") # 提示设置环境变量
exit() # 退出程序
# 1. 初始化 AzureOpenAIGenerator
# api_key: Azure OpenAI API 密钥。
# azure_endpoint: Azure OpenAI 资源的端点 URL。
# azure_deployment_name: 您在 Azure OpenAI Studio 中部署的模型名称。
# api_version: Azure OpenAI API 的版本。
# model_name: 尽管在 Azure 中实际使用的是部署名称,但 Haystack 的模型名称参数仍需提供(可与部署名称相同或保持默认)。
# temperature, max_tokens, top_p, system_prompt 等参数与 OpenAIGenerator 类似。
try:
generator_azure = AzureOpenAIGenerator(
api_key=AZURE_OPENAI_API_KEY, # 传入 Azure OpenAI API 密钥
azure_endpoint=AZURE_OPENAI_ENDPOINT, # 传入 Azure OpenAI 端点 URL
azure_deployment_name=AZURE_OPENAI_DEPLOYMENT_NAME, # 传入 Azure OpenAI 部署名称
api_version=AZURE_OPENAI_API_VERSION, # 传入 Azure OpenAI API 版本
model_name=AZURE_OPENAI_DEPLOYMENT_NAME, # 模型名称可设置为部署名称
temperature=0.6, # 设置生成温度
max_tokens=200, # 设置最大生成词元数
system_prompt="你是一个严谨的文档问答助手,请只根据提供的事实回答问题。" # 定义系统角色
)
print(f"AzureOpenAIGenerator 已成功初始化,使用部署: {
generator_azure.azure_deployment_name}.") # 打印初始化成功信息
except Exception as e:
print(f"AzureOpenAIGenerator 初始化失败: {
e}") # 打印初始化失败信息
print("请检查 Azure OpenAI 凭据、端点和部署名称是否正确,以及网络连接。") # 提示检查
exit() # 退出程序
# 2. 准备查询和上下文文档
query_text_azure = "Azure OpenAI Service 有什么特点?" # 用户查询
context_documents_azure = [
Document(content="Azure OpenAI Service 提供 OpenAI 模型的企业级安全、隐私和合规性。"), # 上下文文档 1
Document(content="它允许客户在自己的 Azure 订阅中运行 OpenAI 模型,提供更强的控制和数据驻留选项。"), # 上下文文档 2
Document(content="该服务与 Azure 的其他 AI 和数据服务无缝集成,便于构建端到端解决方案。"), # 上下文文档 3
Document(content="通过私有网络和 Azure AD 身份验证,进一步增强了访问安全性。") # 上下文文档 4
]
# 3. 执行生成
print(f"
正在为查询 '{
query_text_azure}' 生成回复 (Azure OpenAI)...") # 打印生成回复信息
try:
generation_results_azure = generator_azure.run(
query=query_text_azure, # 传入用户查询
documents=context_documents_azure # 传入上下文文档
)
generated_reply_azure = generation_results_azure["replies"][0]
print("
--- 生成的回复 (Azure OpenAI) ---") # 打印回复标题
print(generated_reply_azure) # 打印生成的回复
print("
--- 来源上下文 ---") # 打印来源上下文标题
for i, doc in enumerate(context_documents_azure): # 遍历上下文文档
print(f"文档 {
i+1} (ID: {
doc.id}): '{
doc.content[:80]}...'") # 打印文档信息和部分内容
except Exception as e:
print(f"AzureOpenAIGenerator 生成回复失败: {
e}") # 打印生成失败信息
print("请检查您的 Azure OpenAI 订阅状态、部署配额或网络连接问题。") # 提示检查
代码说明:
os.environ.get(...):获取 Azure OpenAI 相关的环境变量。在运行此代码之前,你需要在 Azure 门户中创建 Azure OpenAI 资源,部署一个模型(例如 gpt-35-turbo),然后获取其端点、API 密钥和部署名称,并将其设置为相应的环境变量。
AzureOpenAIGenerator(...):初始化 AzureOpenAIGenerator 实例。
azure_endpoint, azure_deployment_name, api_version: 这些是连接 Azure OpenAI 服务特有的参数。azure_deployment_name 对应你在 Azure 中部署的模型名称。
其他参数如 api_key, temperature, max_tokens, system_prompt 等与 OpenAIGenerator 类似。
generator_azure.run(query=query_text_azure, documents=context_documents_azure):调用 run 方法进行生成。行为与 OpenAIGenerator 相同,都是将查询和文档格式化后发送给 LLM。
2.3.2.4 其他 LLM 提供商集成:开放与扩展
Haystack 的设计目标是通用且可扩展,它不断增加对新的 LLM 提供商的支持。除了上述主流提供商,Haystack 还提供了与以下 LLM 或相关服务的集成:
Cohere Generator:
Cohere 专注于企业级 LLM,提供强大的文本生成、嵌入和多语言模型。
Haystack 的 CohereGenerator 组件允许你使用 Cohere 的生成模型。
特点: 在商业用途和可控性方面有独到之处,适合企业级应用。
集成方式: 类似于 OpenAI,需要 Cohere API 密钥和模型名称。
Google Palm Generator / Gemini Generator:
Google 的 PaLM 2 和 Gemini 是其最新的大型语言模型。
Haystack 提供了与 Google 模型集成的组件。
特点: Google 在多模态和通用能力上投入巨大,未来可能在 Haystack 中有更深度的集成。
集成方式: 通常需要 Google Cloud 项目配置和 API 密钥。
Local Llama.cpp / Ollama Integration (通过 PromptNode 自定义):
对于希望在本地运行轻量级 LLM(如 Llama 2 的量化版本、Mistral、Phi-2 等)的开发者,llama.cpp 和 Ollama 提供了非常高效的 CPU/GPU 推理能力。
虽然 Haystack 可能没有直接的 LlamaCppGenerator,但你可以通过 PromptNode 结合自定义的 TextGenerationPipeline 或直接调用 ollama Python 客户端,实现集成。
方法: 创建一个自定义的 Generator 或 PromptNode 后端,封装 llama.cpp 或 ollama 的推理逻辑。
优势: 极致的本地控制、隐私和成本效益。
示例:通过 PromptNode 集成 Ollama (概念性,需要安装 ollama 并在本地运行模型)
# 假设你已经安装 Ollama 并且在本地拉取了模型,例如 'ollama pull llama2'
# pip install ollama
from haystack.nodes import PromptNode, PromptTemplate # 导入 PromptNode 和 PromptTemplate
from haystack.schema import Document # 导入 Document
import ollama # 导入 ollama 客户端库
# 定义 Ollama 模型名称
# 确保这个模型已经在你的本地 Ollama 服务中可用
OLLAMA_MODEL_NAME = "llama2" # Ollama 中已下载的模型名称
# 检查 Ollama 服务是否运行且模型已下载
def check_ollama_status(model_name):
"""检查 Ollama 服务是否正在运行并且模型已下载"""
try:
client = ollama.Client() # 创建 Ollama 客户端
# 尝试列出模型,看服务是否可达
models = client.list() # 列出本地已下载的模型
found_model = False # 标记是否找到模型
for m in models['models']: # 遍历模型列表
if m['name'].startswith(model_name): # 如果模型名称匹配
found_model = True # 标记为找到
break
if found_model:
print(f"Ollama 服务正在运行,并且模型 '{
model_name}' 已下载。") # 打印成功信息
return True
else:
print(f"Ollama 服务正在运行,但模型 '{
model_name}' 未下载。请运行 'ollama pull {
model_name}' 下载。") # 提示下载模型
return False
except Exception as e:
print(f"无法连接到 Ollama 服务或检查模型: {
e}") # 打印连接失败或检查模型失败信息
print("请确保 Ollama 服务正在运行。") # 提示启动服务
return False
if not check_ollama_status(OLLAMA_MODEL_NAME):
print("请启动 Ollama 服务并下载模型,例如: ollama run llama2") # 提示启动 Ollama 服务并下载模型
exit() # 退出程序
# 1. 定义 Prompt 模板
# 同样使用 Haystack 的 PromptTemplate,它会处理 {documents} 和 {query} 占位符。
rag_prompt_template_ollama = PromptTemplate(
prompt="""根据以下提供的上下文信息,简洁地回答问题。如果上下文没有包含足够的信息,请说明。
上下文:
{documents}
问题:{query}
回答:
""", # 定义 Prompt 模板
output_parser=None # 无需特殊输出解析器
)
print("
Ollama PromptTemplate 已定义。") # 打印模板定义信息
# 2. 初始化 PromptNode for Ollama
# 对于 Ollama,model_name_or_path 传入 Ollama 模型名称。
# model_kwargs: 可以传递 Ollama 特有的生成参数。
# 例如:num_predict (生成的最大词元数), temperature, top_k, top_p 等。
try:
prompt_node_ollama = PromptNode(
model_name_or_path=OLLAMA_MODEL_NAME, # 指定 Ollama 模型名称
default_prompt_template=rag_prompt_template_ollama, # 使用定义的 Prompt 模板
model_kwargs={
"model": OLLAMA_MODEL_NAME, # 再次指定模型名称给 Ollama 客户端
"temperature": 0.6, # 控制生成温度
"num_predict": 150, # 生成的最大词元数
"top_k": 40, # top_k 采样
"top_p": 0.9, # top_p 采样
},
api_key=None, # 本地 Ollama 通常不需要 API 密钥
# 这里的 "model_name_or_path" 和 "model" 都是指向 Ollama 模型
# PromptNode 会智能地识别并使用正确的 Ollama 后端
# 重要的是,你必须安装 'haystack[ollama]' 或者 'ollama' 客户端库
# 如果 Haystack 没有内置 Ollama 的 PromptModel,可能需要自定义一个 PromptModel。
# 但 Haystack 的 PromptNode 已经具备了连接 Ollama 的能力。
)
print(f"PromptNode 已成功初始化,使用 Ollama 模型: {
OLLAMA_MODEL_NAME}.") # 打印初始化成功信息
except Exception as e:
print(f"PromptNode for Ollama 初始化失败: {
e}") # 打印初始化失败信息
print("请检查 Ollama 服务是否运行,模型是否已下载,以及 Haystack 版本是否支持 Ollama。") # 提示检查
exit() # 退出程序
# 3. 准备查询和上下文文档
query_text_ollama = "Ollama 是如何帮助本地部署大语言模型的?" # 用户查询
context_documents_ollama = [
Document(content="Ollama 允许用户在本地机器上运行开源大语言模型,简化了模型下载、配置和运行的复杂性。"), # 上下文文档 1
Document(content="它提供了一个简单的命令行界面和 API,用于拉取模型、运行推理,并管理模型。"), # 上下文文档 2
Document(content="通过 Ollama,开发者可以轻松地在本地测试和开发基于 LLM 的应用,无需依赖云服务。"), # 上下文文档 3
Document(content="Ollama 支持多种模型,如 Llama 2, Mistral, Code Llama 等,并优化了它们的本地运行性能。") # 上下文文档 4
]
# 4. 执行生成
print(f"
正在为查询 '{
query_text_ollama}' 生成回复 (Ollama)...") # 打印生成回复信息
try:
generation_results_ollama = prompt_node_ollama.run(
query=query_text_ollama, # 传入用户查询
documents=context_documents_ollama # 传入上下文文档
)
generated_reply_ollama = generation_results_ollama["replies"][0] # 访问生成的回复
print("
--- 生成的回复 (Ollama) ---") # 打印回复标题
print(generated_reply_ollama) # 打印生成的回复
print("
--- 来源上下文 ---") # 打印来源上下文标题
for i, doc in enumerate(context_documents_ollama): # 遍历上下文文档
print(f"文档 {
i+1} (ID: {
doc.id}): '{
doc.content[:80]}...'") # 打印文档信息和部分内容
except Exception as e:
print(f"Ollama 生成回复失败: {
e}") # 打印生成失败信息
print("请检查 Ollama 服务是否正常,模型是否正确加载,或 Prompt 过长。") # 提示检查
代码说明:
ollama 库:首先需要安装 ollama Python 客户端 (pip install ollama)。
check_ollama_status(model_name):辅助函数,检查本地 Ollama 服务是否运行以及指定模型是否已下载。你需要提前运行 ollama run llama2(或你选择的其他模型)来启动服务并下载模型。
PromptNode(model_name_or_path=OLLAMA_MODEL_NAME, default_prompt_template=..., model_kwargs={...}):初始化 PromptNode。
model_name_or_path: 传入你在 Ollama 中拉取的模型名称。
model_kwargs: 这是传递给 Ollama 客户端 generate 方法的参数,可以控制生成过程的各个方面(如 temperature, num_predict 等)。
prompt_node_ollama.run(query=..., documents=...):执行生成操作。Haystack 的 PromptNode 内部会调用 Ollama 客户端,将构建好的 Prompt 发送给本地运行的 Ollama 模型进行推理。
2.3.2.5 LLM 选型考量
选择合适的 LLM 提供商和模型是 RAG 系统成功的关键因素之一,需要综合考虑以下维度:
性能与质量(Performance & Quality):
商业 LLM (OpenAI, Azure OpenAI, Cohere): 通常提供最高质量的通用生成能力,在复杂推理和多语言支持方面表现优异。如果对答案质量要求极高,且预算充足,是首选。
开源本地 LLM (Hugging Face, Ollama): 性能与模型大小正相关。小型模型可能在某些任务上表现不足,但通过微调和高质量的 Prompt Engineering 可以显著提升。大型模型(如 Llama 2 70B)性能接近甚至超越一些商业小模型,但对硬件要求极高。
成本(Cost):
商业 LLM: 按 API 调用量(词元数)计费。对于高频使用场景,成本可能迅速累积。
开源本地 LLM: 一次性硬件投入(GPU),之后运行成本主要是电费和维护。对于长期、高频、大规模部署,本地模型在摊销后可能更具成本效益。
数据隐私与安全(Data Privacy & Security):
商业 LLM: 数据通常会传输到云服务提供商的服务器进行处理。虽然主流服务商承诺不会用客户数据训练模型,但对于敏感数据,仍需谨慎评估其数据处理政策和合规性。Azure OpenAI 在这方面提供了更强的企业级保证。
开源本地 LLM: 模型在你的控制下运行,数据不离开你的基础设施,提供了最高级别的数据隐私和安全。这是金融、医疗、政府等敏感行业的首选。
部署与运维(Deployment & Operations):
商业 LLM: 部署简单,只需配置 API 密钥和调用。运维主要集中在 API 密钥管理、监控用量和成本。
开源本地 LLM: 需要自行管理模型下载、硬件配置、推理服务部署和维护(例如,设置 GPU 环境、处理内存不足、推理优化等)。工具如 Ollama 和 llama.cpp 已经极大简化了这一过程。
延迟(Latency):
商业 LLM: 依赖网络传输和云服务提供商的推理队列,通常会有几十到几百毫秒的网络延迟。
开源本地 LLM: 如果硬件配置得当(特别是 GPU),推理延迟可能更低,没有网络往返延迟,但大模型加载时间可能较长。
可定制性与微调(Customization & Fine-tuning):
商业 LLM: 部分提供商(如 OpenAI)支持模型的微调服务,但成本较高,且对微调数据的规模和质量有要求。
开源本地 LLM: 可以对模型进行深度微调(Full Fine-tuning, LoRA 等),以适应特定领域的数据和任务,从而实现更强的领域特异性能力。这需要更多的机器学习专业知识。
综合对比图(概念性,非 Haystack 内部实现,用于辅助理解):
| LLM 类型 | 部署方式 | 性能/质量 | 成本 | 数据隐私/安全 | 部署/运维 | 典型用例 |
|---|---|---|---|---|---|---|
| OpenAI (GPT) | 云 API | 卓越、通用 | 按量计费,较高 | 传输到云,有承诺 | 简单,低运维 | 通用问答、内容创作、高价值应用 |
| Hugging Face Local (Transformers) | 本地/自托管 | 依赖模型大小,可微调 | 硬件成本,无 API 费 | 本地,高隐私 | 复杂,需硬件管理 | 私有数据 RAG、离线应用、特定领域微调 |
| Azure OpenAI | Azure 云 API | 卓越、通用 | 按量计费,较高 | Azure 环境内,合规强 | 需 Azure 资源配置 | 敏感数据 RAG、企业级应用、严格合规性 |
| Ollama (本地 LLM) | 本地/自托管 | 依赖模型大小,可微调 | 硬件成本,无 API 费 | 本地,高隐私 | 简单化部署,需硬件管理 | 快速原型、个人开发、本地离线应用 |
| Cohere | 云 API | 优秀、企业级 | 按量计费,中等 | 传输到云,有承诺 | 简单,低运维 | 企业级语义任务、多语言处理 |
| Google Palm/Gemini | 云 API | 优秀、多模态 | 按量计费 | 传输到云,有承诺 | 简单,低运维 | 多模态问答、与 Google Cloud 生态集成 |
在构建 Haystack RAG 系统时,仔细权衡这些因素,将帮助你选择最符合项目需求和资源限制的 LLM 解决方案。例如,对于需要处理高度敏感的企业内部知识,本地部署的开源 LLM(如 Llama 2 配合 Ollama 或 Hugging Face Local Generator)结合 Haystack 的 PromptNode 是一个强大的组合。而对于需要快速上线、追求最高通用答案质量且预算充足的场景,OpenAI 或 Azure OpenAI 则是更直接的选择。
2.3.3 数据加载器(Loaders/Readers)与预处理器(PreProcessors):从原始数据到 RAG 就绪知识块
构建一个强大的 RAG 系统,其基石在于高质量、结构良好的知识库。原始数据(如 PDF 文档、网页、数据库记录)通常是杂乱无章、格式多样且内容庞大的。为了将这些原始信息转化为可供检索和 LLM 理解的“知识块”(Chunks),Haystack 提供了专门的组件:**数据加载器(Loaders)**负责将不同格式的数据读入并转换为 Haystack 的 Document 对象;**预处理器(PreProcessors)**则在此基础上进行清洗、分块和标准化,使其适用于后续的嵌入和检索。
2.3.3.1 数据加载器(Loaders/Readers):将原始信息导入 Haystack
Haystack 的数据加载器(在旧版本中也常被称为 Reader,但现在通常指文本提取器)是 RAG 管道的入口点。它们负责从各种数据源中读取文件或数据流,并将其转换为 Haystack 内部统一的 Document 对象格式。一个 Document 对象至少包含 content(文本内容)和 meta(元数据字典)。
Haystack 提供了一系列内置的加载器,支持处理多种常见的文件类型:
TextConverter:处理纯文本文件 (.txt)
用途: 用于从简单的 .txt 文件中加载纯文本数据。
特点: 最基础的加载器,不需要复杂的解析,直接读取文件内容。
集成方式: 通过 TextConverter 组件。
from haystack.nodes import TextConverter # 导入 TextConverter 类
from haystack.schema import Document # 导入 Document 类
import os # 导入操作系统模块
# 创建一个示例文本文件
TEXT_FILE_PATH = "example_text.txt" # 定义文本文件路径
text_content = """
Haystack 是一个用于构建问答系统和 LLM 应用的开源框架。
它支持 RAG 架构,并提供了模块化的组件。
这些组件包括文档存储、检索器、阅读器和生成器。
"""
with open(TEXT_FILE_PATH, "w", encoding="utf-8") as f: # 以写入模式打开文件,使用 UTF-8 编码
f.write(text_content.strip()) # 写入文本内容并去除首尾空白
print(f"示例文本文件 '{
TEXT_FILE_PATH}' 已创建。") # 打印文件创建信息
# 1. 初始化 TextConverter
# 可以选择是否移除页眉页脚(clean_header_footer)
# 可以选择是否清除空行(clean_empty_lines)和多余空格(clean_whitespace)
# 这些清洗操作也可以在 PreProcessor 中进行。
text_converter = TextConverter(
remove_numeric_tables=False, # 不移除数字表格
remove_whitespace=True, # 移除多余空格
remove_empty_lines=True # 移除空行
)
print("TextConverter 已初始化。") # 打印初始化信息
# 2. 从文件加载文档
# TextConverter 的 run 方法通常接受 file_paths 作为输入
# 返回的 results 是一个字典,其中 'documents' 键对应一个 Document 列表。
try:
# file_paths 可以是单个文件路径或文件路径列表
# 这里模拟一个管道的输入,将文件路径传递给 converter
conversion_results = text_converter.run(file_paths=[TEXT_FILE_PATH]) # 运行 TextConverter,传入文件路径列表
loaded_docs = conversion_results[0]["documents"] # 获取转换后的 Document 列表
print(f"
从 '{
TEXT_FILE_PATH}' 加载的文档数量: {
len(loaded_docs)}") # 打印加载文档数量
if loaded_docs:
print("加载的第一个文档内容预览:") # 打印文档内容预览标题
print(f"'{
loaded_docs[0].content[:150]}...'") # 打印第一个文档的前150个字符
except Exception as e:
print(f"从文本文件加载文档失败: {
e}") # 打印加载失败信息
# 清理示例文件
os.remove(TEXT_FILE_PATH) # 删除示例文件
print(f"示例文本文件 '{
TEXT_FILE_PATH}' 已删除。") # 打印文件删除信息
代码说明:
TextConverter(...):初始化 TextConverter。参数如 remove_whitespace 和 remove_empty_lines 可以在加载时进行初步的文本清洗。
text_converter.run(file_paths=[TEXT_FILE_PATH]):调用 run 方法,传入一个包含文件路径的列表。TextConverter 会读取这些文件并将内容转换为 Document 对象。
PDFToTextConverter:处理 PDF 文件 (.pdf)
用途: 用于从 PDF 文件中提取文本内容。
特点: 需要额外的依赖(如 pdfminer.six),能够处理 PDF 的复杂结构,但可能在处理图片、表格等非文本内容时丢失信息。
集成方式: 通过 PDFToTextConverter 组件。
from haystack.nodes import PDFToTextConverter # 导入 PDFToTextConverter 类
from haystack.schema import Document # 导入 Document 类
import os # 导入操作系统模块
# 需要安装:pip install 'haystack[pdf]' 或者 pip install pdfminer.six
# 通常 Haystack 的安装指南会推荐安装带额外依赖的版本
# 创建一个简单的 PDF 文件 (需要一个库来创建,这里假设你有一个现成的)
# 实际应用中,你会有一个真实的 PDF 文件。
# 为了演示,我们跳过创建 PDF 的复杂过程,直接使用一个假设的文件路径。
PDF_FILE_PATH = "example.pdf" # 定义 PDF 文件路径
# 假设 'example.pdf' 文件存在于当前目录,且包含一些文本内容
# 如果没有,此示例将无法成功运行。
# 建议手动放置一个简单的 'example.pdf' 文件在这里。
# 模拟一个实际的 PDF 文件内容(仅用于说明,不创建文件)
# 如果你没有 example.pdf,可以尝试用一个真实的 PDF 文件替换。
# 或者用一个 dummy 文件来模拟:
# from reportlab.pdfgen import canvas
# from reportlab.lib.pagesizes import letter
# c = canvas.Canvas(PDF_FILE_PATH, pagesize=letter)
# c.drawString(100, 750, "这是一个示例 PDF 文档。")
# c.drawString(100, 730, "它包含了一些关于 Haystack 的信息。")
# c.drawString(100, 710, "RAG 是一个强大的框架。")
# c.save()
# print(f"示例 PDF 文件 '{PDF_FILE_PATH}' 已创建。")
if not os.path.exists(PDF_FILE_PATH):
print(f"错误: 示例 PDF 文件 '{
PDF_FILE_PATH}' 不存在。请创建一个或提供一个真实的 PDF 文件。") # 提示文件不存在
exit() # 退出程序
# 1. 初始化 PDFToTextConverter
pdf_converter = PDFToTextConverter(
remove_numeric_tables=False, # 不移除数字表格
remove_whitespace=True, # 移除多余空格
remove_empty_lines=True # 移除空行
)
print("PDFToTextConverter 已初始化。") # 打印初始化信息
# 2. 从 PDF 文件加载文档
try:
conversion_results_pdf = pdf_converter.run(file_paths=[PDF_FILE_PATH]) # 运行 PDFToTextConverter
loaded_docs_pdf = conversion_results_pdf[0]["documents"] # 获取转换后的 Document 列表
print(f"
从 '{
PDF_FILE_PATH}' 加载的文档数量: {
len(loaded_docs_pdf)}") # 打印加载文档数量
if loaded_docs_pdf:
print("加载的第一个 PDF 文档内容预览:") # 打印文档内容预览标题
print(f"'{
loaded_docs_pdf[0].content[:200]}...'") # 打印第一个 PDF 文档的前200个字符
except Exception as e:
print(f"从 PDF 文件加载文档失败: {
e}") # 打印加载失败信息
print("请确保已安装 'pdfminer.six' 和其他 Haystack 的 PDF 相关依赖 (pip install 'haystack[pdf]')。") # 提示安装依赖
print("如果 PDF 文件较大或复杂,可能需要一些时间。") # 提示可能需要时间
# 注意:这里不删除 PDF 文件,因为它是假设预先存在的。
代码说明:
PDFToTextConverter(...):初始化 PDFToTextConverter。它会尝试从 PDF 文件中提取可搜索的文本。
pdf_converter.run(file_paths=[PDF_FILE_PATH]):运行转换器。注意:你需要提前安装 pdfminer.six 和其他 Haystack 的 PDF 依赖(通过 pip install 'haystack[pdf]')才能成功运行。并且,你需要准备一个实际存在的 example.pdf 文件。
DocxToTextConverter:处理 Word 文档 (.docx)
用途: 用于从 .docx 文件中提取文本内容。
特点: 需要额外的依赖(如 python-docx)。
集成方式: 通过 DocxToTextConverter 组件。
from haystack.nodes import DocxToTextConverter # 导入 DocxToTextConverter 类
from haystack.schema import Document # 导入 Document 类
import os # 导入操作系统模块
from docx import Document as DocxDocument # 导入 python-docx 库的 Document 类
# 创建一个示例 DOCX 文件
DOCX_FILE_PATH = "example.docx" # 定义 DOCX 文件路径
doc = DocxDocument() # 创建一个新的 Word 文档对象
doc.add_heading("Haystack 文档示例", level=1) # 添加标题
doc.add_paragraph("本 Word 文档旨在展示 Haystack 如何从 .docx 文件中提取文本。") # 添加段落
doc.add_paragraph("它对于构建基于企业内部文档的 RAG 系统非常有用。") # 添加另一个段落
doc.save(DOCX_FILE_PATH) # 保存 Word 文档
print(f"示例 DOCX 文件 '{
DOCX_FILE_PATH}' 已创建。") # 打印文件创建信息
# 1. 初始化 DocxToTextConverter
docx_converter = DocxToTextConverter(
remove_numeric_tables=False, # 不移除数字表格
remove_whitespace=True, # 移除多余空格
remove_empty_lines=True # 移除空行
)
print("DocxToTextConverter 已初始化。") # 打印初始化信息
# 2. 从 DOCX 文件加载文档
try:
conversion_results_docx = docx_converter.run(file_paths=[DOCX_FILE_PATH]) # 运行 DocxToTextConverter
loaded_docs_docx = conversion_results_docx[0]["documents"] # 获取转换后的 Document 列表
print(f"
从 '{
DOCX_FILE_PATH}' 加载的文档数量: {
len(loaded_docs_docx)}") # 打印加载文档数量
if loaded_docs_docx:
print("加载的第一个 DOCX 文档内容预览:") # 打印文档内容预览标题
print(f"'{
loaded_docs_docx[0].content[:200]}...'") # 打印第一个 DOCX 文档的前200个字符
except Exception as e:
print(f"从 DOCX 文件加载文档失败: {
e}") # 打印加载失败信息
print("请确保已安装 'python-docx' 和其他 Haystack 的 Docx 相关依赖 (pip install 'haystack[docx]')。") # 提示安装依赖
# 清理示例文件
os.remove(DOCX_FILE_PATH) # 删除示例文件
print(f"示例 DOCX 文件 '{
DOCX_FILE_PATH}' 已删除。") # 打印文件删除信息
代码说明:
from docx import Document as DocxDocument:导入 python-docx 库,用于创建示例 .docx 文件。
DocxToTextConverter(...):初始化 DocxToTextConverter。注意:你需要提前安装 python-docx 和其他 Haystack 的 Docx 依赖(通过 pip install 'haystack[docx]')才能成功运行。
docx_converter.run(file_paths=[DOCX_FILE_PATH]):运行转换器,从 Word 文档中提取文本。
MarkdownConverter:处理 Markdown 文件 (.md)
用途: 用于从 Markdown 文件中提取文本,并可保留部分结构信息。
特点: 能够识别 Markdown 的标题、列表等结构,可以更好地保留文档的层次感。
集成方式: 通过 MarkdownConverter 组件。
from haystack.nodes import MarkdownConverter # 导入 MarkdownConverter 类
from haystack.schema import Document # 导入 Document 类
import os # 导入操作系统模块
# 创建一个示例 Markdown 文件
MD_FILE_PATH = "example.md" # 定义 Markdown 文件路径
md_content = """
# Haystack RAG 指南
这是一个 Markdown 格式的文档,用于演示 `MarkdownConverter`。
## 核心概念
* **检索增强生成 (RAG)**:结合检索和生成。
* **大语言模型 (LLM)**:用于生成答案。
### 优势
1. 减少幻觉
2. 知识更新高效
"""
with open(MD_FILE_PATH, "w", encoding="utf-8") as f: # 以写入模式打开文件,使用 UTF-8 编码
f.write(md_content.strip()) # 写入 Markdown 内容并去除首尾空白
print(f"示例 Markdown 文件 '{
MD_FILE_PATH}' 已创建。") # 打印文件创建信息
# 1. 初始化 MarkdownConverter
md_converter = MarkdownConverter(
remove_code_snippets=False, # 不移除代码片段
remove_images=False, # 不移除图片
remove_metadata=False, # 不移除元数据
remove_empty_lines=True # 移除空行
)
print("MarkdownConverter 已初始化。") # 打印初始化信息
# 2. 从 Markdown 文件加载文档
try:
conversion_results_md = md_converter.run(file_paths=[MD_FILE_PATH]) # 运行 MarkdownConverter
loaded_docs_md = conversion_results_md[0]["documents"] # 获取转换后的 Document 列表
print(f"
从 '{
MD_FILE_PATH}' 加载的文档数量: {
len(loaded_docs_md)}") # 打印加载文档数量
if loaded_docs_md:
print("加载的第一个 Markdown 文档内容预览:") # 打印文档内容预览标题
print(f"'{
loaded_docs_md[0].content[:250]}...'") # 打印第一个 Markdown 文档的前250个字符
# 注意:MarkdownConverter 通常会将标题等转换为普通文本,但某些高级模式可能保留结构
print(f"转换后的内容类型: {
type(loaded_docs_md[0].content)}") # 打印内容类型
except Exception as e:
print(f"从 Markdown 文件加载文档失败: {
e}") # 打印加载失败信息
# 清理示例文件
os.remove(MD_FILE_PATH) # 删除示例文件
print(f"示例 Markdown 文件 '{
MD_FILE_PATH}' 已删除。") # 打印文件删除信息
代码说明:
MarkdownConverter(...):初始化 MarkdownConverter。它会尝试将 Markdown 语法解析为纯文本,并可选择移除代码块、图片等。
md_converter.run(file_paths=[MD_FILE_PATH]):运行转换器,从 Markdown 文件中提取文本。
WebPageToTextConverter:处理网页内容 (URL)
用途: 从指定 URL 的网页中抓取并提取可读文本。
特点: 需要 beautifulsoup4 和 trafilatura 等库来解析 HTML 并提取主要内容。可能需要处理反爬机制、JS 渲染等复杂情况。
集成方式: 通过 WebPageToTextConverter 组件。
from haystack.nodes import WebPageToTextConverter # 导入 WebPageToTextConverter 类
from haystack.schema import Document # 导入 Document 类
import os # 导入操作系统模块
import requests # 导入 requests 库,用于模拟网络请求
# 1. 初始化 WebPageToTextConverter
# user_agent: 设置请求头中的 User-Agent,模拟浏览器访问,避免被网站反爬。
# headers: 可以添加额外的 HTTP 请求头。
# content_filter_options: 可以配置 Trafilatura 的内容过滤选项。
# max_length: 限制提取文本的最大长度。
webpage_converter = WebPageToTextConverter(
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", # 设置 User-Agent
max_length=5000, # 限制提取文本的最大长度
# 可以添加其他 trafilatura 相关的参数
)
print("WebPageToTextConverter 已初始化。") # 打印初始化信息
# 2. 定义一个要抓取的 URL
# 这里我们使用一个公共的、内容相对简单的 Haystack 官方文档 URL 作为示例
# 实际应用中,你需要确保你有权抓取该网站,并遵守其 robots.txt 规则。
URL_TO_SCRAPE = "https://haystack.deepset.ai/blog/rag-is-more-than-llms" # 定义要抓取的 URL
print(f"将尝试抓取 URL: {
URL_TO_SCRAPE}") # 打印抓取 URL
# 3. 从 URL 加载文档
# WebPageToTextConverter 的 run 方法接受 urls 作为输入
try:
# 传入一个包含 URL 的列表
conversion_results_web = webpage_converter.run(urls=[URL_TO_SCRAPE]) # 运行 WebPageToTextConverter
loaded_docs_web = conversion_results_web[0]["documents"] # 获取转换后的 Document 列表
print(f"
从 '{
URL_TO_SCRAPE}' 加载的文档数量: {
len(loaded_docs_web)}") # 打印加载文档数量
if loaded_docs_web:
print("加载的第一个网页文档内容预览:") # 打印文档内容预览标题
# 网页内容通常很长,只显示开头部分
print(f"'{
loaded_docs_web[0].content[:300]}...'") # 打印第一个网页文档的前300个字符
print(f"源 URL: {
loaded_docs_web[0].meta.get('url')}") # 打印来源 URL
except requests.exceptions.RequestException as req_err:
print(f"网络请求失败 (WebPageToTextConverter): {
req_err}") # 打印网络请求失败信息
print("请检查网络连接或 URL 是否可访问。") # 提示检查
except Exception as e:
print(f"从网页加载文档失败 (WebPageToTextConverter): {
e}") # 打印加载失败信息
print("请确保已安装 'beautifulsoup4' 和 'trafilatura' (pip install 'haystack[web]')。") # 提示安装依赖
print("某些网站可能有复杂的反爬机制或需要 JavaScript 渲染,这可能导致抓取失败。") # 提示反爬机制
代码说明:
WebPageToTextConverter(...):初始化 WebPageToTextConverter。
user_agent: 设置请求头中的 User-Agent,模拟浏览器访问,有助于避免被一些网站识别为爬虫。
max_length: 限制提取文本的最大长度。
webpage_converter.run(urls=[URL_TO_SCRAPE]):运行转换器,从指定的 URL 抓取并提取主要文本内容。注意:你需要安装 beautifulsoup4 和 trafilatura(通过 pip install 'haystack[web]')。并且,请确保你有权抓取指定的 URL,并遵守网站的 robots.txt 规则。
TikaConverter:通用文件格式处理
用途: 用于处理多种文件格式(如 DOC, XLS, PPT, JPG, BMP 等),将其转换为纯文本。
特点: 依赖于外部的 Apache Tika 服务器。Tika 是一款强大的工具,能够从各种复杂格式的文件中提取文本和元数据,包括嵌入在图片中的文本(OCR)。
集成方式: 通过 TikaConverter 组件。
部署: 需要在本地或远程启动一个 Tika 服务器。最常见的方式是使用 Docker 运行 Tika 镜像。
from haystack.nodes import TikaConverter # 导入 TikaConverter 类
from haystack.schema import Document # 导入 Document 类
import os # 导入操作系统模块
import requests # 导入 requests 库
# 1. 启动 Apache Tika 服务器
# Tika 是一个外部服务,你需要提前启动它。最简单的方法是使用 Docker:
# docker run -d -p 9998:9998 apache/tika:latest
# 请确保 Tika 服务在 9998 端口运行。
TIKA_SERVER_URL = "http://localhost:9998/tika" # Tika 服务器的 URL
def check_tika_status(url):
"""检查 Tika 服务器是否正在运行"""
try:
response = requests.get(url, timeout=5) # 发送 GET 请求到 Tika 服务器
if response.status_code == 200: # 如果状态码是 200,表示成功
print(f"Apache Tika 服务器在 {
url} 上运行正常。") # 打印成功信息
return True
else:
print(f"Tika 服务器返回非 200 状态码: {
response.status_code}") # 打印非 200 状态码
return False
except requests.exceptions.ConnectionError: # 捕获连接错误
print(f"无法连接到 Apache Tika 服务器在 {
url}。请确保它正在运行。") # 打印连接错误信息
return False
except Exception as e:
print(f"检查 Tika 服务器状态失败: {
e}") # 打印检查失败信息
return False
if not check_tika_status(TIKA_SERVER_URL):
print(f"请启动 Apache Tika 服务器,例如使用 Docker: docker run -d -p 9998:9998 apache/tika:latest") # 提示启动 Tika 服务器
exit() # 退出程序
# 2. 创建一个示例文件 (例如,一个简单的文本文件作为通用格式的替代)
# TikaConverter 真正强大之处在于处理 .doc, .xls, .ppt, .odt 等格式
# 这里我们用一个 .txt 文件来演示,但它会通过 Tika 服务进行处理
GENERIC_FILE_PATH = "example_generic.txt" # 定义通用文件路径
generic_content = "这是一个通用文件,通过 TikaConverter 进行处理。" # 通用文件内容
with open(GENERIC_FILE_PATH, "w", encoding="utf-8") as f: # 以写入模式打开文件,使用 UTF-8 编码
f.write(generic_content.strip()) # 写入文本内容并去除首尾空白
print(f"示例通用文件 '{
GENERIC_FILE_PATH}' 已创建。") # 打印文件创建信息
# 3. 初始化 TikaConverter
# tika_url: Tika 服务器的 URL。
# max_content_length: 限制从文件中提取的最大文本长度。
# 可以通过 Tika 配置额外的 OCR 功能来处理图片中的文本。
tika_converter = TikaConverter(
tika_url=TIKA_SERVER_URL, # 传入 Tika 服务器 URL
remove_numeric_tables=False, # 不移除数字表格
remove_whitespace=True, # 移除多余空格
remove_empty_lines=True, # 移除空行
max_content_length=5000 # 限制提取文本的最大长度
)
print("TikaConverter 已初始化。") # 打印初始化信息
# 4. 从文件加载文档
try:
conversion_results_tika = tika_converter.run(file_paths=[GENERIC_FILE_PATH]) # 运行 TikaConverter
loaded_docs_tika = conversion_results_tika[0]["documents"] # 获取转换后的 Document 列表
print(f"
从 '{
GENERIC_FILE_PATH}' 加载的文档数量: {
len(loaded_docs_tika)}") # 打印加载文档数量
if loaded_docs_tika:
print("加载的第一个通用文件文档内容预览:") # 打印文档内容预览标题
print(f"'{
loaded_docs_tika[0].content[:200]}...'") # 打印第一个通用文件文档的前200个字符
print(f"来源文件类型 (通过 Tika): {
loaded_docs_tika[0].meta.get('file_type')}") # 打印文件类型
except Exception as e:
print(f"从通用文件加载文档失败 (TikaConverter): {
e}") # 打印加载失败信息
print("请确保 Apache Tika 服务器正在运行,并且网络连接正常。") # 提示检查
# 清理示例文件
os.remove(GENERIC_FILE_PATH) # 删除示例文件
print(f"示例通用文件 '{
GENERIC_FILE_PATH}' 已删除。") # 打印文件删除信息
代码说明:
check_tika_status(url):辅助函数,用于检查 Apache Tika 服务器是否可用。
TikaConverter(tika_url=TIKA_SERVER_URL, ...):初始化 TikaConverter。它依赖于一个运行中的 Apache Tika 服务器。你需要在本地或远程启动它(推荐使用 Docker)。
tika_converter.run(file_paths=[GENERIC_FILE_PATH]):运行转换器。它会将文件发送到 Tika 服务器进行处理,并接收返回的文本内容。
数据加载器选择总结
选择合适的数据加载器取决于你的原始数据格式和源。
对于纯文本文件,TextConverter 最简单高效。
对于 PDF 和 Word 等常见文档,使用专门的 PDFToTextConverter 和 DocxToTextConverter。
需要从网页抓取内容时,WebPageToTextConverter 是首选。
当面对各种复杂、不常见的文档格式时,TikaConverter 是一个强大的万能解决方案,但需要额外的 Tika 服务器部署。
2.3.3.2 预处理器(PreProcessors):清洗、分块与标准化
将原始数据转换为 Document 对象只是第一步。这些文档可能包含噪音(如空行、多余空格、页眉页脚)、格式不一致或长度过长,不适合直接用于嵌入和检索。**预处理器(PreProcessor)**是 Haystack 中的核心组件,负责对 Document 对象进行一系列的文本清洗和分块操作,使其达到最佳的 RAG 效果。
预处理阶段对于 RAG 系统的性能至关重要。高质量的预处理能够:
提高检索精度: 清除噪音,使嵌入更纯粹,提高相关性匹配。
优化 LLM 表现: 提供干净、聚焦的上下文,减少 LLM 的“分心”,降低幻觉。
管理上下文窗口: 将长文档切分为适合 LLM 处理的块,避免上下文溢出。
PreProcessor 的主要功能包括:
文本清洗:
clean_empty_lines: 移除文档中的空行。
clean_whitespace: 移除多余的空格、制表符和换行符,将多个空白字符替换为一个空格。
clean_header_footer: 尝试识别并移除文档的页眉和页脚。
remove_punction: 移除标点符号。
remove_numeric_tables: 移除表格中的数字内容(可能用于减少噪音)。
分块(Chunking)策略:
这是 PreProcessor 最重要的功能之一。将长文档切分成小块是 RAG 的核心。块的粒度和重叠度直接影响检索的质量和 LLM 的理解能力。
split_by: 定义分块的依据。常见的选项有:
"word" (按词分)
"sentence" (按句分)
"passage" (按段落分)
"page" (按页分,如果 Document 有 page 元数据)
"root_node" (用于复杂的结构化文档,例如 XML/HTML 的根节点)
"markdown" (按 Markdown 结构分,如标题、代码块,需要 markdown 依赖)
"recursive" (递归分块,尝试多种分隔符,从大到小)
split_length: 每个块的最大长度(以 split_by 指定的单位为准,例如,如果是 "word",就是单词数;如果是 "sentence",就是句子数)。
split_overlap: 块之间的重叠长度。重叠有助于保留上下文,避免因切分而丢失关键信息。
split_respect_sentence_boundary: (仅在 split_by="word" 或 "character" 时有效) 确保在句子边界处分割,避免切断句子。
max_chars_text_split: 字符级的硬性最大长度限制,用于避免单个块过大。
分块策略考量:
粒度:
细粒度 (如按句、小段落): 优点是检索更精确,LLM 处理的上下文更聚焦,减少噪音。缺点是可能丢失更大的上下文信息,需要检索更多块才能覆盖完整语义。
粗粒度 (如按大段落、页): 优点是保留更多上下文,减少检索块的数量。缺点是检索可能不精确,LLM 处理的上下文可能包含更多不相关信息,可能超出 LLM 的上下文窗口。
重叠:
适当的重叠可以确保即使答案跨越两个块的边界,LLM 也能获得完整的上下文。
过大的重叠会增加索引大小和计算量,并可能引入冗余。
LLM 上下文窗口: split_length 必须小于你选择的 LLM 的最大上下文窗口,并留有余量以包含查询和 Prompt 指令。
代码示例:PreProcessor 的使用
from haystack.nodes import PreProcessor # 导入 PreProcessor 类
from haystack.schema import Document # 导入 Document 类
# 示例原始文档
raw_doc = Document(
content="""
Haystack 是一个强大的开源框架。它使得构建基于 LLM 的应用变得容易。
本框架由 Deepset 开发。
它的核心是模块化组件。
比如 文档存储、嵌入器、检索器、阅读器和生成器。
这些组件可以组成灵活的管道。
在 NLP 领域,RAG 技术是一个重要进步。
它结合了检索和生成。
Haystack 的设计理念是生产就绪。
它支持多种 LLM 和向量数据库。
""",
meta={
"source": "Internal Wiki", "chapter": "Introduction"} # 原始文档元数据
)
print("原始文档内容:") # 打印原始文档内容标题
print(raw_doc.content) # 打印原始文档内容
# 1. 初始化 PreProcessor - 清洗和按词分块
print("
--- 配置 1: 清洗 + 按词分块 ---") # 打印配置信息
preprocessor_word_chunk = PreProcessor(
clean_empty_lines=True, # 移除空行
clean_whitespace=True, # 移除多余空格
clean_header_footer=False, # 不移除页眉页脚
split_by="word", # 按单词分割
split_length=20, # 每个块 20 个单词
split_overlap=5, # 重叠 5 个单词
split_respect_sentence_boundary=True # 尝试在句子边界分割
)
print("PreProcessor (按词分块) 已初始化。") # 打印初始化信息
# 2. 运行 PreProcessor
# run 方法接受 documents 作为输入,返回一个包含处理后文档的字典
processed_docs_word = preprocessor_word_chunk.run(documents=[raw_doc]) # 运行预处理器
chunks_word = processed_docs_word[0]["documents"] # 获取处理后的文档块列表
print(f"
处理后文档块数量 (按词分块): {
len(chunks_word)}") # 打印文档块数量
for i, chunk in enumerate(chunks_word): # 遍历每个文档块
print(f"块 {
i+1} ({
len(chunk.content.split())} 词): '{
chunk.content[:80]}...'") # 打印文档块内容和词数
# 验证元数据是否继承
print(f" 元数据: {
chunk.meta}") # 打印文档块元数据
print("-" * 30) # 分隔线
# 3. 初始化 PreProcessor - 清洗和按句子分块
print("
--- 配置 2: 清洗 + 按句子分块 ---") # 打印配置信息
preprocessor_sentence_chunk = PreProcessor(
clean_empty_lines=True,
clean_whitespace=True,
split_by="sentence", # 按句子分割
split_length=2, # 每个块 2 个句子
split_overlap=1 # 重叠 1 个句子
)
print("PreProcessor (按句子分块) 已初始化。") # 打印初始化信息
# 4. 运行 PreProcessor
processed_docs_sentence = preprocessor_sentence_chunk.run(documents=[raw_doc]) # 运行预处理器
chunks_sentence = processed_docs_sentence[0]["documents"] # 获取处理后的文档块列表
print(f"
处理后文档块数量 (按句子分块): {
len(chunks_sentence)}") # 打印文档块数量
for i, chunk in enumerate(chunks_sentence): # 遍历每个文档块
print(f"块 {
i+1} ({
len(chunk.content.split('.')) if '.' in chunk.content else 'NA'} 句): '{
chunk.content[:80]}...'") # 打印文档块内容和句数
print(f" 元数据: {
chunk.meta}") # 打印文档块元数据
print("-" * 30) # 分隔线
# 5. 初始化 PreProcessor - 清洗和按段落分块 (通常 split_by="passage")
print("
--- 配置 3: 清洗 + 按段落分块 ---") # 打印配置信息
preprocessor_passage_chunk = PreProcessor(
clean_empty_lines=True,
clean_whitespace=True,
split_by="passage", # 按段落分割 (通常是两个连续的换行符)
split_length=1, # 每个块 1 个段落 (这意味着每段都是一个块)
split_overlap=0 # 无重叠
)
print("PreProcessor (按段落分块) 已初始化。") # 打印初始化信息
# 6. 运行 PreProcessor
processed_docs_passage = preprocessor_passage_chunk.run(documents=[raw_doc]) # 运行预处理器
chunks_passage = processed_docs_passage[0]["documents"] # 获取处理后的文档块列表
print(f"
处理后文档块数量 (按段落分块): {
len(chunks_passage)}") # 打印文档块数量
for i, chunk in enumerate(chunks_passage): # 遍历每个文档块
print(f"块 {
i+1}: '{
chunk.content[:80]}...'") # 打印文档块内容
print(f" 元数据: {
chunk.meta}") # 打印文档块元数据
print("-" * 30) # 分隔线
# 7. 初始化 PreProcessor - 递归分块 (Recursive)
# 递归分块会尝试使用多个分隔符,从大到小,直到满足 split_length
print("
--- 配置 4: 清洗 + 递归分块 ---") # 打印配置信息
preprocessor_recursive_chunk = PreProcessor(
clean_empty_lines=True,
clean_whitespace=True,
split_by=["
", "
", ".", ","], # 尝试按段落、行、句号、逗号分割
split_length=50, # 最终块的最大长度(字符数)
split_overlap=10, # 重叠 10 个字符
split_respect_sentence_boundary=False # 递归分块通常不考虑句子边界
)
print("PreProcessor (递归分块) 已初始化。") # 打印初始化信息
# 8. 运行 PreProcessor
processed_docs_recursive = preprocessor_recursive_chunk.run(documents=[raw_doc]) # 运行预处理器
chunks_recursive = processed_docs_recursive[0]["documents"] # 获取处理后的文档块列表
print(f"
处理后文档块数量 (递归分块): {
len(chunks_recursive)}") # 打印文档块数量
for i, chunk in enumerate(chunks_recursive): # 遍历每个文档块
print(f"块 {
i+1} ({
len(chunk.content)} 字符): '{
chunk.content[:80]}...'") # 打印文档块内容和字符数
print(f" 元数据: {
chunk.meta}") # 打印文档块元数据
print("-" * 30) # 分隔线
代码说明:
PreProcessor(...):初始化 PreProcessor 实例。
clean_empty_lines, clean_whitespace, clean_header_footer: 这些参数控制文本清洗行为。
split_by: 定义文本分块的依据。例如,"word" 表示按单词分块,"sentence" 按句子分块,"passage" 按段落分块。"recursive" 则会尝试多种分隔符,从大的(如双换行符)到小的(如逗号),直到达到 split_length 的要求。
split_length: 每个文本块的目标长度。单位取决于 split_by 的设置。
split_overlap: 两个相邻文本块之间的重叠长度。这有助于保留上下文,防止在块边界处丢失信息。
split_respect_sentence_boundary: 当按词分块时,尝试在句子的完整边界处进行分割。
preprocessor.run(documents=[raw_doc]):调用 run 方法,传入一个包含 Document 对象的列表。PreProcessor 会对这些文档进行清洗和分块,并返回一个包含处理后文档块的新列表。每个文档块仍然是一个 Document 对象,并会继承原始文档的元数据。
2.3.3.3 数据加载器与预处理器在索引管道中的集成
在 Haystack 中,数据加载器和预处理器通常作为索引管道(Indexing Pipeline)的早期节点,协同工作,将原始的、非结构化的知识转化为适合向量搜索和 LLM 处理的知识块。
一个典型的索引管道流程如下:
加载原始文件: 使用 File 或 TextConverter 等加载器读取文件。
转换为 Documents: 加载器将文件内容转换为 Document 对象。
预处理: PreProcessor 接收这些 Document,对其进行清洗和分块。
生成嵌入: DocumentEmbedder 接收分块后的 Document,为其生成嵌入向量。
写入 DocumentStore: DocumentStore 接收带有嵌入的 Document,并将其存储以便后续检索。
代码示例:完整的索引管道 (加载器 + 预处理器 + 嵌入器 + 文档存储)
import os # 导入操作系统模块
from haystack.pipelines import Pipeline # 导入 Pipeline 类
from haystack.nodes import TextConverter, PreProcessor, SentenceTransformersDocumentEmbedder # 导入 TextConverter, PreProcessor, SentenceTransformersDocumentEmbedder
from haystack.document_stores import InMemoryDocumentStore # 导入 InMemoryDocumentStore
from haystack.schema import Document # 导入 Document 类
# --- 准备数据:创建一个示例文本文件 ---
INDEX_FILE_PATH = "rag_knowledge_base.txt" # 定义索引文件路径
long_text_content = """
大型语言模型(LLMs)已经彻底改变了自然语言处理领域。它们在理解、生成和操作人类语言方面展现出惊人的能力。然而,LLMs 并非没有局限性。其中最主要的挑战之一是它们容易产生“幻觉”,即生成听起来合理但实际上是虚假或不准确的信息。此外,LLMs 的知识仅限于其训练数据的截止日期,无法访问最新的实时信息或私有企业数据。
为了克服这些局限性,检索增强生成(Retrieval-Augmented Generation, RAG)架构应运而生。RAG 结合了信息检索的精确性和 LLM 的生成能力。其核心思想是,在 LLM 生成答案之前,系统首先从一个外部的、权威的知识库中检索出与用户查询最相关的上下文信息。然后,这些检索到的信息被作为额外的输入提供给 LLM,引导其生成基于事实的、准确的答案。
Haystack 是一个强大的开源 Python 框架,专门用于构建生产就绪的 RAG、问答系统和企业级 LLM 应用。它采用模块化设计,将 RAG 流程中的每个步骤抽象为可互换的组件,如 DocumentStore、Retriever、Reader 和 Generator。Haystack 的灵活性允许开发者根据具体需求定制和组合这些组件,从而快速搭建和部署复杂的 AI 知识系统。它支持多种主流的向量数据库(如 Pinecone, Elasticsearch, Weaviate)和 LLM 提供商(如 OpenAI, Hugging Face 本地模型)。
"""
with open(INDEX_FILE_PATH, "w", encoding="utf-8") as f: # 以写入模式打开文件,使用 UTF-8 编码
f.write(long_text_content.strip()) # 写入长文本内容并去除首尾空白
print(f"示例知识库文件 '{
INDEX_FILE_PATH}' 已创建。") # 打印文件创建信息
# --- 1. 初始化 DocumentStore ---
# 仍然使用 InMemoryDocumentStore,因为方便演示,但在生产环境会使用持久化存储
document_store_pipeline = InMemoryDocumentStore(use_embedding_storage=True)
print("InMemoryDocumentStore 已初始化。") # 打印初始化信息
# --- 2. 构建索引管道 (Indexing Pipeline) ---
indexing_pipeline = Pipeline() # 创建一个新的 Haystack 管道
# 步骤 A: 添加 TextConverter - 从文件加载文本
# name="TextConverter" 是节点的名称
# inputs=["File"] 表示这个节点期望的输入是文件路径。
# TextConverter 会将文件内容读取并转换为 Document 对象。
indexing_pipeline.add_node(
component=TextConverter(remove_numeric_tables=False, remove_whitespace=True, remove_empty_lines=True),
name="TextConverter",
inputs=["File"] # 表示其输入来自于文件,当管道运行传入 file_paths 时会触发
)
print("节点 'TextConverter' 已添加到管道。") # 打印节点添加信息
# 步骤 B: 添加 PreProcessor - 清洗和分块
# name="PreProcessor" 是节点的名称
# inputs=["TextConverter"] 表示这个节点期望的输入是 TextConverter 的输出。
# PreProcessor 会接收 TextConverter 输出的 Document 对象,并对其进行清洗和分块。
indexing_pipeline.add_node(
component=PreProcessor(
clean_empty_lines=True,
clean_whitespace=True,
split_by="sentence", # 按句子分割
split_length=3, # 每个块 3 个句子
split_overlap=1 # 重叠 1 个句子
),
name="PreProcessor",
inputs=["TextConverter"] # 接收 TextConverter 的输出作为输入
)
print("节点 'PreProcessor' 已添加到管道。") # 打印节点添加信息
# 步骤 C: 添加 SentenceTransformersDocumentEmbedder - 为文档块生成嵌入
# name="DocumentEmbedder" 是节点的名称
# inputs=["PreProcessor"] 表示这个节点期望的输入是 PreProcessor 的输出。
# DocumentEmbedder 会接收 PreProcessor 分块后的 Document 对象,并为每个块生成一个嵌入向量。
indexing_pipeline.add_node(
component=SentenceTransformersDocumentEmbedder(
model_name_or_path="sentence-transformers/all-MiniLM-L6-v2", # 使用小型通用嵌入模型
device="cpu", # 在 CPU 上运行,如果需要可改为 'cuda'
batch_size=16
),
name="DocumentEmbedder",
inputs=["PreProcessor"] # 接收 PreProcessor 的输出作为输入
)
print("节点 'DocumentEmbedder' 已添加到管道。") # 打印节点添加信息
# 步骤 D: 添加 DocumentStore - 将带嵌入的文档块写入存储
# name="DocumentStore" 是节点的名称
# inputs=["DocumentEmbedder"] 表示这个节点期望的输入是 DocumentEmbedder 的输出。
# DocumentStore 负责将最终处理好的 Document 对象(包含文本内容、元数据和嵌入向量)持久化。
indexing_pipeline.add_node(
component=document_store_pipeline, # 传入之前初始化的 DocumentStore 实例
name="DocumentStore",
inputs=["DocumentEmbedder"] # 接收 DocumentEmbedder 的输出作为输入
)
print("节点 'DocumentStore' 已添加到管道。") # 打印节点添加信息
print("
索引管道已构建。") # 打印管道构建信息
# --- 3. 运行索引管道 ---
print(f"
开始运行索引管道,处理文件: '{
INDEX_FILE_PATH}'...") # 打印运行信息
try:
# run 方法的输入参数取决于管道的第一个节点 (这里是 TextConverter)
# TextConverter 期望 file_paths
indexing_pipeline.run(file_paths=[INDEX_FILE_PATH]) # 运行索引管道,传入文件路径
print("索引管道运行完成,文档已写入 DocumentStore。") # 打印运行完成信息
# 验证 DocumentStore 中的文档数量
num_docs_in_store = document_store_pipeline.get_document_count() # 获取文档存储中的文档数量
print(f"DocumentStore 中现在有 {
num_docs_in_store} 个文档块。") # 打印文档块数量
# 随机取一个文档块进行验证
if num_docs_in_store > 0:
sample_doc = document_store_pipeline.get_all_documents(top_k=1)[0] # 获取第一个文档
print("
随机抽取一个文档块进行验证:") # 打印验证信息标题
print(f"内容: '{
sample_doc.content[:100]}...'") # 打印文档块内容
print(f"元数据: {
sample_doc.meta}") # 打印文档块元数据
print(f"嵌入向量是否存在: {
sample_doc.embedding is not None}") # 打印嵌入向量是否存在
if sample_doc.embedding is not None:
print(f"嵌入向量维度: {
sample_doc.embedding.shape}") # 打印嵌入向量维度
except Exception as e:
print(f"索引管道运行失败: {
e}") # 打印运行失败信息
print("请检查文件路径、模型下载状态以及 Haystack 依赖是否安装完整。") # 提示检查
# --- 清理:删除示例文件 ---
os.remove(INDEX_FILE_PATH) # 删除示例文件
print(f"
示例知识库文件 '{
INDEX_FILE_PATH}' 已删除。") # 打印文件删除信息
代码说明:
TextConverter: 作为管道的第一个节点,它接收文件路径,并读取文件内容,将其包装成 Document 对象。inputs=["File"] 定义了它在管道中如何接收外部输入。
PreProcessor: 接收 TextConverter 输出的 Document 对象。它负责对文档进行清洗(移除空行、多余空格)和关键的分块操作(按句子分割,并设置 split_length 和 split_overlap)。
SentenceTransformersDocumentEmbedder: 接收 PreProcessor 输出的每个文档块,并使用指定的预训练模型(如 all-MiniLM-L6-v2)为其生成稠密的嵌入向量,将这些向量存储在 Document 对象的 embedding 属性中。
InMemoryDocumentStore: 作为管道的最后一个节点,它接收带有嵌入的 Document 对象,并将它们存储在内存中。在实际生产系统中,这里会替换为 FAISSDocumentStore、ElasticsearchDocumentStore 或其他云原生的向量数据库,以实现持久化和可伸缩性。
indexing_pipeline.run(file_paths=[INDEX_FILE_PATH]): 运行整个管道。Haystack 会自动协调数据流,将文件从 TextConverter 传递给 PreProcessor,再传递给 DocumentEmbedder,最后写入 DocumentStore。
这个完整的示例展示了如何将原始的、可能很长的文本文件,通过 Haystack 的数据加载器和预处理器,转化为结构良好、大小适中、且带有语义嵌入的知识块,为后续的 RAG 检索和生成奠定坚实基础。正确的数据预处理是 RAG 系统性能优化的关键环节。
2.4 检索器(Retriever):从海量知识中精准定位
引言:检索器的核心作用——RAG 的“导航员”
在 RAG 架构中,检索器(Retriever)扮演着至关重要的“导航员”角色。它的任务是在庞大的知识库(DocumentStore)中,根据用户的自然语言查询,快速、准确地找到那些最有可能包含答案的文档片段(或称“知识块”)。检索结果的质量直接决定了后续生成器(LLM)所能产生的答案质量。如果检索器未能召回相关的上下文,或者召回了大量不相关的噪音信息,那么即使是最强大的 LLM 也可能“无米下锅”或“误入歧途”,导致生成错误或低质量的答案。
因此,深入理解不同类型的检索器、它们的内部工作机制、适用场景以及如何优化和评估检索性能,是构建高效、可靠 RAG 系统的关键。检索器通常可以分为两大类:稀疏检索器(Sparse Retrievers)和稠密检索器(Dense Retrievers)。稀疏检索器主要依赖关键词匹配,而稠密检索器则侧重于语义理解。在现代 RAG 系统中,两者常常结合使用,以兼顾广度与深度。
2.4.1 稀疏检索器(Sparse Retrievers):关键词匹配的经典与演进
稀疏检索器基于文本中词项的精确匹配或统计共现来衡量查询与文档的相关性。它们通常利用倒排索引进行高效查询。虽然在语义理解方面不如稠密检索器,但它们在关键词精确匹配、计算效率和可解释性方面具有独特优势。
2.4.1.1 BM25Retriever:传统力量的基石
BM25(Best Matching 25)是信息检索领域最经典、最广泛应用的排名函数之一,自 1994 年提出以来,一直是搜索引擎和问答系统的核心。它基于概率模型,对 TF-IDF(Term Frequency-Inverse Document Frequency,词频-逆文档频率)进行了优化,以更好地评估查询词与文档之间的相关性。
原理与内部机制:
BM25 的核心思想是,一个文档对于某个查询的相关性分数,是查询中每个词项对该文档相关性分数的加权和。每个词项的分数由以下几个因素共同决定:
词频(Term Frequency, TF)的饱和度:
概念: 一个词在文档中出现的次数越多,文档与该词的相关性越大。
BM25 优化: BM25 引入了词频饱和度函数。这意味着词频的增加对文档相关性分数的贡献会逐渐减小,而不是线性增加。例如,一个词出现 10 次和出现 100 次,对相关性的提升可能不如从 1 次到 10 次那么显著。这避免了长文档中某个高频词过度主导相关性分数的情况。
参数 k1: k1 是一个正的调优参数,通常在 1.2 到 2.0 之间。它控制着词频饱和的速度。k1 越大,词频饱和越慢,即高频词对分数的影响越大。
公式中的体现: (k1 + 1) * tf / (k1 * (1 - b + b * doc_len / avg_doc_len) + tf)
tf 是词项在当前文档中的词频。
当 tf 远小于 k1 时,分数近似于 tf。
当 tf 远大于 k1 时,分数趋近于 (k1 + 1),表明饱和。
逆文档频率(Inverse Document Frequency, IDF):
概念: 一个词在整个文档集合中出现的文档数量越少,该词的区分度(辨识能力)越高,其在相关性计算中的权重越大。
公式: log((N - df + 0.5) / (df + 0.5))
N 是文档集合中的总文档数。
df 是包含该词项的文档数(文档频率)。
作用: 惩罚那些在大多数文档中都出现的常见词(如停用词“的”、“是”),并提升稀有词的重要性。
文档长度归一化(Document Length Normalization):
概念: 较短的文档如果包含了所有查询词,可能比包含相同查询词但长度很长的文档更相关(因为短文档更聚焦)。
BM25 优化: BM25 通过引入文档长度因子来惩罚过长的文档。它将文档的实际长度与文档集合的平均长度进行比较。
参数 b: b 是一个调优参数,通常在 0.75 左右。它控制着文档长度归一化的强度。b 越大,文档长度对分数的影响越大。如果 b 为 0,则不进行长度归一化。
公式中的体现: (1 - b + b * doc_len / avg_doc_len)
doc_len 是当前文档的长度。
avg_doc_len 是文档集合的平均长度。
这个因子在词频饱和度公式的分母中,如果文档比平均文档长,这个因子会变大,导致整个分数变小。
BM25 整体公式:
[
Score(D, Q) = sum_{t in Q} ext{IDF}(t) cdot frac{ ext{tf}(t, D) cdot (k_1 + 1)}{ ext{tf}(t, D) + k_1 cdot (1 – b + b cdot frac{|D|}{ ext{avgdl}})}
]
其中:
(Score(D, Q)):文档 (D) 对于查询 (Q) 的 BM25 分数。
(t): 查询 (Q) 中的一个词项。
( ext{tf}(t, D)):词项 (t) 在文档 (D) 中的词频。
(|D|):文档 (D) 的长度(通常是词元数)。
( ext{avgdl}):文档集合的平均长度。
(k_1), (b): BM25 的调优参数。
如何利用倒排索引进行高效匹配:
BM25 的计算依赖于词频和文档频率,这些信息都可以通过预先构建的倒排索引高效获取。当用户查询时,系统会:
对查询进行分词、停用词移除、词干提取/词形还原等预处理。
遍历查询中的每个词项。
通过倒排索引查找每个词项出现在哪些文档中,以及在这些文档中的词频(TF)。
同时,倒排索引也提供了计算 IDF 所需的文档频率(DF)和总文档数(N)。
对于每个匹配到的文档,根据 BM25 公式计算其分数。
对所有文档按分数降序排序,返回 Top-K 的文档。
优缺点:
优点:
计算效率高: 基于倒排索引,查询速度快,尤其适合大规模文档集合的初步检索。
关键词精确匹配: 在查询中包含关键词且文档直接包含这些关键词时,效果非常好。
可解释性强: 结果直接与关键词的出现和频率相关联,易于理解其匹配逻辑。
鲁棒性: 在许多通用领域中表现稳定,是信息检索的基石。
缺点:
语义鸿沟: 无法理解词语的同义、近义或多义关系。如果查询词和文档中的词语义相关但字面不匹配,则无法召回。例如,搜索“汽车”不会匹配到包含“轿车”的文档。
对语境敏感度低: 仅已关注词频和分布,不理解词语在句子中的具体语境含义。
适用场景:
大规模文档集的初步过滤: 在需要快速从海量文档中筛选出相关文档时,作为第一阶段的粗粒度检索。
关键词明确的查询: 用户查询中包含明确的实体名称、产品名称、专业术语等。
知识库包含大量结构化或半结构化数据: 例如,法律法规、产品手册、常见问题解答(FAQ)等,这些文档中关键词的出现频率和分布通常具有很强的指示性。
作为混合检索的组成部分: 与稠密检索器结合,用于捕捉关键词匹配的“精确度”和语义理解的“意图”。
Haystack 实现与代码示例:BM25Retriever
在 Haystack 中,BM25Retriever 是最常用的稀疏检索器之一。它需要一个 DocumentStore 来存储文档和其内部的倒排索引。许多 DocumentStore (如 ElasticsearchDocumentStore, InMemoryDocumentStore, FAISSDocumentStore 的一部分) 都支持 BM25Retriever。
from haystack.document_stores import InMemoryDocumentStore # 导入内存文档存储
from haystack.nodes import BM25Retriever # 导入 BM25Retriever
from haystack.schema import Document # 导入 Document 类
# 1. 初始化 DocumentStore
# BM25Retriever 需要 DocumentStore 来存储文档,并在其内部构建索引。
# 这里使用 InMemoryDocumentStore,它在内存中管理文档和索引。
# 对于大规模数据,通常会使用 ElasticsearchDocumentStore。
document_store_bm25 = InMemoryDocumentStore(use_bm25=True) # 初始化内存文档存储,并启用 BM25 索引
print("InMemoryDocumentStore 已初始化,并启用 BM25。") # 打印初始化信息
# 2. 准备并写入一些文档
docs_for_bm25 = [
Document(content="Haystack 是一个开源的 NLP 框架,用于构建问答系统。",
meta={
"source": "Haystack官网", "topic": "NLP框架"}),
Document(content="大语言模型(LLMs)在自然语言理解和生成方面表现出色。",
meta={
"source": "AI百科", "topic": "LLM"}),
Document(content="RAG 结合了检索和生成,以减少 LLM 的幻觉问题。",
meta={
"source": "RAG指南", "topic": "RAG"}),
Document(content="自然语言处理是人工智能的一个重要分支。",
meta={
"source": "AI百科", "topic": "NLP"})
]
document_store_bm25.write_documents(docs_for_bm25) # 写入文档到文档存储
print(f"
已写入 {
len(docs_for_bm25)} 个文档到 DocumentStore。") # 打印写入文档数量
# 3. 初始化 BM25Retriever
# document_store: 指定检索器将从哪个文档存储中检索。
# top_k: 指定要检索的最相关文档的数量。
# default_token_filter_params: 用于配置分词和停用词过滤等参数,这取决于 DocumentStore 的实现。
retriever_bm25 = BM25Retriever(document_store=document_store_bm25, top_k=2) # 初始化 BM25Retriever
print("BM25Retriever 已初始化。") # 打印初始化信息
# 4. 执行检索
query_bm25 = "关于 LLM 幻觉的问题" # 用户查询
print(f"
查询: '{
query_bm25}'") # 打印用户查询
# retriever.run 方法会接收查询并返回检索到的 Document 列表
# 返回的结构是 { 'documents': [Document, Document, ...] }
retrieved_results_bm25 = retriever_bm25.run(query=query_bm25) # 运行 BM25Retriever
retrieved_documents_bm25 = retrieved_results_bm25[0]["documents"] # 获取检索到的文档列表
print("BM25 检索到的文档:") # 打印检索到的文档标题
if retrieved_documents_bm25:
for i, doc in enumerate(retrieved_documents_bm25): # 遍历检索到的文档
print(f" 文档 {
i+1} (分数: {
doc.score:.4f}): '{
doc.content[:80]}...'") # 打印文档内容和分数
print(f" 元数据: {
doc.meta}") # 打印文档元数据
print("-" * 20) # 分隔线
else:
print("未找到相关文档。") # 未找到文档提示
# 另一个查询,关键词匹配更明显
query_bm25_2 = "什么是 Haystack 框架?" # 第二个用户查询
print(f"
查询: '{
query_bm25_2}'") # 打印第二个用户查询
retrieved_results_bm25_2 = retriever_bm25.run(query=query_bm25_2) # 运行 BM25Retriever
retrieved_documents_bm25_2 = retrieved_results_bm25_2[0]["documents"] # 获取检索到的文档列表
print("BM25 检索到的文档:") # 打印检索到的文档标题
if retrieved_documents_bm25_2:
for i, doc in enumerate(retrieved_documents_bm25_2): # 遍历检索到的文档
print(f" 文档 {
i+1} (分数: {
doc.score:.4f}): '{
doc.content[:80]}...'") # 打印文档内容和分数
print(f" 元数据: {
doc.meta}") # 打印文档元数据
print("-" * 20) # 分隔线
else:
print("未找到相关文档。") # 未找到文档提示
代码说明:
InMemoryDocumentStore(use_bm25=True):初始化 InMemoryDocumentStore 时,传入 use_bm25=True 参数,指示它为 BM25 检索构建内部的倒排索引。
BM25Retriever(document_store=document_store_bm25, top_k=2):初始化 BM25Retriever。document_store 参数是必须的,它告诉检索器从哪个文档存储中拉取数据。top_k 定义了返回最相关文档的数量。
retriever_bm25.run(query=query_bm25):调用 BM25Retriever 的 run 方法。它接收一个查询字符串,然后在 document_store 中执行 BM25 搜索,返回一个包含 top_k 个最相关文档的列表。每个返回的 Document 对象会有一个 score 属性,表示其 BM25 相关性分数。
2.4.1.2 TfidfRetriever:基础但有效的语义表示
TF-IDF(Term Frequency-Inverse Document Frequency)是一种统计方法,用于评估一个词对文档集或语料库中某个文档的重要性。它通过词频(TF)和逆文档频率(IDF)的乘积来表示词项的重要性。TfidfRetriever 是一种基于 TF-IDF 向量空间模型的稀疏检索器。
原理与内部机制:
构建词项-文档矩阵: 将文档集合表示为一个巨大的矩阵,其中行是词项,列是文档。矩阵中的每个单元格是该词项在对应文档中的 TF-IDF 值。
查询向量化: 对用户查询进行同样的 TF-IDF 向量化处理,将其转换为一个查询向量。
相似度计算: 通过计算查询向量与每个文档向量之间的余弦相似度来衡量相关性。余弦相似度可以很好地衡量两个向量在方向上的相似性,而不受其长度的影响。
倒排索引: 虽然 TF-IDF 概念上是向量空间模型,但在实际高效实现中,通常仍然依赖于倒排索引来快速定位包含查询词的文档,然后只对这些候选文档计算 TF-IDF 相似度。
优缺点:
优点:
比纯关键词匹配更智能: 考虑了词语的重要性(通过 IDF),可以更好地识别文档中的关键信息。
计算相对高效: 预计算 TF-IDF 权重,查询时计算向量相似度。
易于理解和实现: 概念直观,算法相对简单。
缺点:
语义理解能力有限: 仍停留在词法层面,无法捕捉深层语义关系、同义词、多义词。
“词袋”模型: 忽略词语的顺序和语法结构,只已关注词语的出现频率。
维度灾难: 对于大型词汇表,TF-IDF 向量会变得非常稀疏且维度极高,影响存储和计算效率。
通常不如 BM25 效果好: 在多数问答和信息检索任务中,BM25 往往能提供更好的效果,因为它在词频饱和度和文档长度归一化方面进行了优化。
适用场景:
小型数据集或快速原型开发: 当数据量不大,且对语义理解要求不高时,可以作为快速上手的检索方案。
关键词驱动的问答: 适用于查询词和文档词项匹配度高的情况。
作为基准模型: 在评估其他更复杂检索器性能时,TF-IDF 可以作为一个简单的基准线。
Haystack 实现与代码示例:TfidfRetriever
TfidfRetriever 同样需要一个 DocumentStore。它会在内部对文档进行 TF-IDF 向量化。
from haystack.document_stores import InMemoryDocumentStore # 导入内存文档存储
from haystack.nodes import TfidfRetriever # 导入 TfidfRetriever
from haystack.schema import Document # 导入 Document 类
# 1. 初始化 DocumentStore
# TF-IDF 也需要 DocumentStore 来存储文档。
document_store_tfidf = InMemoryDocumentStore(use_bm25=False) # 初始化内存文档存储,不需要启用 BM25
# 2. 准备并写入一些文档
docs_for_tfidf = [
Document(content="人工智能(AI)正在改变世界。"),
Document(content="机器学习是人工智能的一个重要分支。"),
Document(content="深度学习是机器学习的子领域。"),
Document(content="自然语言处理(NLP)是人工智能的另一项应用。"),
Document(content="RAG 系统结合 LLM 和检索。")
]
document_store_tfidf.write_documents(docs_for_tfidf) # 写入文档到文档存储
print(f"已写入 {
len(docs_for_tfidf)} 个文档到 DocumentStore (用于 TF-IDF)。") # 打印写入文档数量
# 3. 初始化 TfidfRetriever
# document_store: 指定检索器从哪个文档存储中检索。
# top_k: 返回最相关文档的数量。
# model_path: 可以选择加载一个预训练的 TF-IDF 模型,否则会在首次运行时根据 document_store 中的文档训练。
# 如果 DocumentStore 改变,需要重新训练或更新 TfidfRetriever。
retriever_tfidf = TfidfRetriever(document_store=document_store_tfidf, top_k=2) # 初始化 TfidfRetriever
print("TfidfRetriever 已初始化。") # 打印初始化信息
# 4. 执行检索
query_tfidf = "人工智能的最新进展" # 用户查询
print(f"
查询: '{
query_tfidf}'") # 打印用户查询
# TfidfRetriever 内部会根据 document_store 中的文档构建 TF-IDF 向量空间模型。
# 首次运行时可能需要一些时间来训练模型。
retrieved_results_tfidf = retriever_tfidf.run(query=query_tfidf) # 运行 TfidfRetriever
retrieved_documents_tfidf = retrieved_results_tfidf[0]["documents"] # 获取检索到的文档列表
print("TF-IDF 检索到的文档:") # 打印检索到的文档标题
if retrieved_documents_tfidf:
for i, doc in enumerate(retrieved_documents_tfidf): # 遍历检索到的文档
print(f" 文档 {
i+1} (分数: {
doc.score:.4f}): '{
doc.content[:80]}...'") # 打印文档内容和分数
else:
print("未找到相关文档。") # 未找到文档提示
# 另一个查询
query_tfidf_2 = "机器学习的子领域" # 第二个用户查询
print(f"
查询: '{
query_tfidf_2}'") # 打印第二个用户查询
retrieved_results_tfidf_2 = retriever_tfidf.run(query=query_tfidf_2) # 运行 TfidfRetriever
retrieved_documents_tfidf_2 = retrieved_results_tfidf_2[0]["documents"] # 获取检索到的文档列表
print("TF-IDF 检索到的文档:") # 打印检索到的文档标题
if retrieved_documents_tfidf_2:
for i, doc in enumerate(retrieved_documents_tfidf_2): # 遍历检索到的文档
print(f" 文档 {
i+1} (分数: {
doc.score:.4f}): '{
doc.content[:80]}...'") # 打印文档内容和分数
else:
print("未找到相关文档。") # 未找到文档提示
代码说明:
TfidfRetriever(document_store=document_store_tfidf, top_k=2):初始化 TfidfRetriever。它将使用 document_store 中的文档来构建其 TF-IDF 模型。
retriever_tfidf.run(query=query_tfidf):执行检索。TfidfRetriever 会将查询向量化,并计算与文档存储中所有文档的余弦相似度,然后返回 top_k 个最高分数的文档。
2.4.1.3 Custom Sparse Retriever(自定义稀疏检索):基于规则或领域词典
在某些特定领域或业务场景中,仅仅依靠 BM25 或 TF-IDF 可能无法满足精确检索的需求。例如,你可能需要根据特定的业务规则、正则表达式模式、预定义的关键词列表(如产品型号、错误代码)或领域词典来识别和检索文档。Haystack 的模块化设计允许你创建自定义的 Retriever 类,以实现这些特定的稀疏检索逻辑。
概念与实现思路:
创建一个自定义的稀疏检索器,通常需要继承 Haystack 的 BaseRetriever 类(或其更具体的子类如 DenseRetriever 或 SparseRetriever,尽管对于完全自定义的逻辑,BaseRetriever 更灵活),并重写其核心的 _retrieve 方法。在这个方法中,你可以实现自己的检索逻辑,例如:
基于正则表达式的匹配: 针对查询中的特定模式,使用正则表达式在文档内容中进行匹配。
基于关键词列表/词典: 维护一个领域特定的关键词或短语列表,检查查询和文档中是否包含这些词,并根据自定义的权重进行打分。
结合元数据过滤: 除了文本内容,还利用文档的元数据进行精确过滤,例如,只检索特定日期范围、作者或产品分类的文档。
外部系统集成: 封装对外部搜索系统(如 Splunk、Solr)的 API 调用,并将其结果转换为 Haystack 的 Document 格式。
核心逻辑:
接收查询字符串和可选的 filters 参数。
访问底层的 DocumentStore(如果需要,例如获取所有文档或特定元数据)。
应用自定义的匹配逻辑(如正则匹配、词典查找、规则判断)。
为匹配到的文档计算一个自定义的相关性分数。
将结果封装为 Document 对象列表,并按照分数排序,返回 top_k 个。
代码示例:一个简单的基于正则匹配的自定义稀疏检索器
这个示例将创建一个 RegexRetriever,它会查找文档中是否包含查询中指定的一些特定模式。
import re # 导入正则表达式模块
from typing import List, Optional # 导入类型提示
from haystack.nodes import BaseRetriever # 导入 Haystack 的 BaseRetriever
from haystack.document_stores import InMemoryDocumentStore # 导入内存文档存储
from haystack.schema import Document # 导入 Document 类
class RegexRetriever(BaseRetriever):
"""
一个自定义的稀疏检索器,通过正则表达式在文档内容中查找匹配项。
它会根据查询中包含的正则表达式模式,检索包含这些模式的文档。
"""
def __init__(self, document_store: InMemoryDocumentStore, top_k: int = 5):
"""
初始化 RegexRetriever。
:param document_store: 用于存储文档的 DocumentStore 实例。
:param top_k: 要返回的最相关文档的数量。
"""
super().__init__() # 调用基类的构造函数
self.document_store = document_store # 存储 DocumentStore 实例
self.top_k = top_k # 存储 top_k 参数
def _retrieve(self, query: str, filters: Optional[dict] = None, **kwargs) -> List[Document]:
"""
根据查询和过滤器检索文档。
这个方法是自定义检索器的核心逻辑。
它接收一个查询字符串,然后根据内部定义的逻辑从 document_store 中检索文档。
:param query: 用户输入的查询字符串。
:param filters: 用于筛选文档的元数据过滤器。
:param kwargs: 其他传递给检索器的额外参数。
:return: 包含相关文档的列表,每个文档都有一个计算出的分数。
"""
all_documents = self.document_store.get_all_documents(filters=filters) # 从 DocumentStore 获取所有文档,并应用过滤器
matched_documents = [] # 存储匹配到的文档
# 假设查询中包含关键词,我们尝试将其作为正则表达式模式。
# 真实场景中,可能需要更复杂的查询解析逻辑来提取模式。
query_patterns = query.split() # 简单地将查询按空格分割,作为潜在的正则表达式模式
# 为了安全,转义特殊字符,防止用户输入的查询破坏正则表达式
safe_patterns = [re.escape(p) for p in query_patterns if len(p) > 1] # 转义特殊字符,只保留长度大于1的模式
if not safe_patterns:
return [] # 如果没有有效模式,则返回空列表
# 编译正则表达式,提高效率
# 这里我们查找任何一个模式的匹配
regex = re.compile("|".join(safe_patterns), re.IGNORECASE) # 编译正则表达式,不区分大小写,匹配任一模式
print(f"正在使用正则表达式: '{
regex.pattern}' 进行检索。") # 打印正在使用的正则表达式
for doc in all_documents: # 遍历文档存储中的所有文档
# 检查文档内容是否匹配任何一个正则表达式模式
matches = list(regex.finditer(doc.content)) # 查找所有匹配项
if matches:
# 简单地根据匹配到的次数作为分数
# 实际场景中,分数计算可能更复杂,例如考虑匹配的长度、位置等
score = len(matches) # 分数等于匹配到的次数
# 为文档添加分数
doc.score = score # 为文档设置分数
matched_documents.append(doc) # 将匹配到的文档添加到列表
# 按分数降序排序
matched_documents.sort(key=lambda d: d.score, reverse=True) # 按分数降序排序
return matched_documents[:self.top_k] # 返回前 top_k 个文档
# --- 示例用法 ---
document_store_custom = InMemoryDocumentStore() # 初始化内存文档存储
docs_for_custom = [
Document(content="产品型号 A100 支持 4G 网络连接。", meta={
"product_line": "A-series"}),
Document(content="错误代码 E500 表示服务器内部错误。", meta={
"log_type": "error"}),
Document(content="客户反馈:A100 在 5G 环境下性能表现不佳。", meta={
"product_line": "A-series"}),
Document(content="系统警告:发现未知的连接请求。", meta={
"log_type": "warning"})
]
document_store_custom.write_documents(docs_for_custom) # 写入文档
print(f"已写入 {
len(docs_for_custom)} 个文档到 DocumentStore (用于自定义检索)。") # 打印写入文档数量
# 1. 初始化自定义检索器
custom_retriever = RegexRetriever(document_store=document_store_custom, top_k=2) # 初始化自定义检索器
print("RegexRetriever 已初始化。") # 打印初始化信息
# 2. 执行检索
query_custom = "A100 连接问题" # 用户查询,包含产品型号和关键词
print(f"
查询: '{
query_custom}'") # 打印用户查询
# 注意:这里 query 会被 split() 处理,所以 "A100" 和 "连接" 会被视为模式。
# 实际应用中,你可能需要更智能的查询解析。
retrieved_results_custom = custom_retriever.run(query=query_custom) # 运行自定义检索器
retrieved_documents_custom = retrieved_results_custom[0]["documents"] # 获取检索到的文档列表
print("自定义正则检索器检索到的文档:") # 打印检索到的文档标题
if retrieved_documents_custom:
for i, doc in enumerate(retrieved_documents_custom): # 遍历检索到的文档
print(f" 文档 {
i+1} (分数: {
doc.score:.4f}): '{
doc.content[:80]}...'") # 打印文档内容和分数
print(f" 元数据: {
doc.meta}") # 打印文档元数据
print("-" * 20) # 分隔线
else:
print("未找到相关文档。") # 未找到文档提示
# 另一个查询,带元数据过滤
query_custom_2 = "E500 错误" # 第二个用户查询
# filters 可以直接传递给 retriever.run(),它会传递给 _retrieve 方法
filters_custom_2 = {
"log_type": ["error"]} # 元数据过滤器
print(f"
查询: '{
query_custom_2}' (过滤 log_type='error')") # 打印查询信息和过滤器
retrieved_results_custom_2 = custom_retriever.run(query=query_custom_2, filters=filters_custom_2) # 运行自定义检索器,带过滤器
retrieved_documents_custom_2 = retrieved_results_custom_2[0]["documents"] # 获取检索到的文档列表
print("自定义正则检索器检索到的文档 (带过滤):") # 打印检索到的文档标题
if retrieved_documents_custom_2:
for i, doc in enumerate(retrieved_documents_custom_2): # 遍历检索到的文档
print(f" 文档 {
i+1} (分数: {
doc.score:.4f}): '{
doc.content[:80]}...'") # 打印文档内容和分数
print(f" 元数据: {
doc.meta}") # 打印文档元数据
print("-" * 20) # 分隔线
else:
print("未找到相关文档。") # 未找到文档提示
代码说明:
class RegexRetriever(BaseRetriever)::自定义检索器继承自 BaseRetriever。
__init__(self, document_store, top_k):构造函数接收 DocumentStore 实例和 top_k 参数。
_retrieve(self, query, filters, **kwargs):这是核心方法,其中包含了自定义的检索逻辑。
self.document_store.get_all_documents(filters=filters):首先从 document_store 中获取所有文档,并支持 Haystack 提供的标准元数据过滤。
query.split():将查询字符串简单地分割成多个词,作为正则表达式的模式。
re.escape(p):对每个模式进行正则表达式转义,以防用户输入的字符(如 .、* 等)被解释为正则表达式的特殊含义。
re.compile("|".join(safe_patterns), re.IGNORECASE):将所有安全模式用 | 拼接起来,形成一个“或”关系的正则表达式,并编译以提高匹配效率。re.IGNORECASE 表示不区分大小写。
regex.finditer(doc.content):在每个文档内容中查找所有匹配项。
doc.score = len(matches):一个简单的评分逻辑,匹配到的次数越多,分数越高。你可以根据实际需求设计更复杂的评分函数。
matched_documents.sort(key=lambda d: d.score, reverse=True):对匹配到的文档按分数降序排序。
return matched_documents[:self.top_k]: 返回前 top_k 个文档。
这个示例展示了如何基于 Haystack 的 BaseRetriever 构建自定义检索逻辑,这为处理特殊领域或需求提供了极大的灵活性。
2.4.2 稠密检索器(Dense Retrievers):语义理解的深度探索
稀疏检索器在关键词匹配方面表现出色,但它们无法捕捉词语背后的深层语义含义。当用户的查询与知识库中的文档在字面上不匹配,但语义上高度相关时,稀疏检索器就会失效。例如,查询“汽车”与文档中提到“轿车”或“车辆”时,如果仅仅依赖关键词匹配,这些相关文档可能被遗漏。
稠密检索器(Dense Retrievers)正是为了弥补这一“语义鸿沟”而诞生的。它们的核心思想是,将查询和文档都表示为高维的稠密向量(即嵌入或 Embeddings),然后通过计算这些向量之间的距离或相似度来衡量查询与文档的语义相关性。在这个向量空间中,语义相似的文本其向量距离也更近。
2.4.2.1 稠密检索器的核心机制:嵌入与相似度搜索
稠密检索器的工作流程可以概括为以下几个关键步骤:
文本嵌入(Text Embedding):
概念: 这是稠密检索器的基石。文本嵌入是将非结构化的文本(如单词、短语、句子、段落甚至整个文档)转换为固定长度的、稠密的浮点数向量(通常维度在几百到几千之间)。这些向量捕获了文本的语义信息,使得在向量空间中,语义上相近的文本具有相近的向量。
实现技术:
深度学习模型: 文本嵌入通常由预训练的深度学习模型生成,最常见的是基于 Transformer 架构的模型。这些模型通过大规模的文本数据进行训练,学习如何将文本映射到有意义的向量空间。
模型架构:
单塔模型 (Single-Encoder): 对于文档嵌入,一个模型(如 Sentence-BERT)可以独立地为每个文档生成嵌入。对于查询,也使用同一个模型。然后,通过计算查询嵌入与文档嵌入之间的相似度来检索。这种模型通常在通用领域表现良好。
双塔模型 (Dual-Encoder): 这种架构包含两个独立的编码器(通常是两个 Transformer 模型),一个用于编码查询,另一个用于编码文档(或 passage)。这两个编码器在训练时通过对比学习(Contrastive Learning)或度量学习(Metric Learning)来优化,使得查询嵌入和相关文档嵌入在向量空间中彼此靠近,而不相关文档嵌入则远离。典型的例子是 DPR (Dense Passage Retriever)。双塔模型在检索任务上通常能达到更好的效果,因为它针对查询-文档相关性进行了专门优化。
交叉编码器 (Cross-Encoder): 交叉编码器不生成独立的查询和文档嵌入。它将查询和文档拼接在一起,然后通过一个 Transformer 模型共同处理,直接输出一个相关性分数。交叉编码器通常能提供最高的准确性,因为它能够捕捉查询和文档之间最细粒度的交互。然而,由于需要对每个查询-文档对进行一次前向传播,其计算成本极高,不适合在大型知识库中进行初次检索(召回),而更适合作为后续的重排器(Re-ranker),对少量由稠密或稀疏检索器召回的候选文档进行精细排序。
Haystack 中的嵌入器: SentenceTransformersDocumentEmbedder, OpenAIDocumentEmbedder, CohereDocumentEmbedder 等都用于生成文档嵌入。SentenceTransformersQueryEmbedder 则用于生成查询嵌入。
相似度计算(Similarity Calculation):
概念: 在生成查询嵌入和文档嵌入之后,需要一种方法来量化它们在向量空间中的“相似程度”。
常用度量:
余弦相似度(Cosine Similarity): 最常用的一种度量。它衡量两个向量在方向上的相似性,而不受其大小(模长)的影响。值范围从 -1(完全不相似)到 1(完全相似)。对于经过归一化的嵌入向量,它等同于点积。
[
ext{cosine_similarity}(mathbf{A}, mathbf{B}) = frac{mathbf{A} cdot mathbf{B}}{||mathbf{A}|| cdot ||mathbf{B}||}
]
点积(Dot Product): 也称为内积。它结合了向量的大小和方向。当向量归一化后,点积等同于余弦相似度。对于未归一化的向量,点积越大通常表示越相似且幅度越大。
[
mathbf{A} cdot mathbf{B} = sum_{i=1}^n A_i B_i
]
欧几里得距离(Euclidean Distance): 衡量两个向量在多维空间中的直线距离。距离越小,表示向量越相似。
[
ext{euclidean_distance}(mathbf{A}, mathbf{B}) = sqrt{sum_{i=1}^n (A_i – B_i)^2}
]
选择: 模型的训练目标通常决定了最合适的相似度度量。例如,Sentence-BERT 模型通常使用余弦相似度。
向量相似度搜索(Vector Similarity Search, VSS):
概念: 在一个包含数百万、数十亿甚至数万亿向量的知识库中,高效地找到与给定查询向量最相似的 K 个向量。
挑战: 暴力搜索(Brute-force Search)需要计算查询向量与所有文档向量的相似度,计算复杂度极高,在大规模数据集上不可行。
解决方案: **近似最近邻(Approximate Nearest Neighbor, ANN)**算法。ANN 算法牺牲了少量准确性(不保证找到的 K 个一定是全局最相似的 K 个),以换取显著的搜索速度提升。
ANN 算法类型:
树状结构(Tree-based): 例如 KD-Tree, Ball Tree (但在高维空间中效率不高)。
基于聚类(Clustering-based): 例如 IVF (Inverted File Index)。先将向量空间聚类,查询时只在最相关的几个簇中搜索。
基于图(Graph-based): 例如 HNSW (Hierarchical Navigable Small World)。构建一个多层图结构,通过图遍历快速导航到近似的最近邻。
基于哈希(Hashing-based): 例如 LSH (Locality Sensitive Hashing)。将高维向量映射到低维空间,使得相似的向量在哈希后仍然相似。
基于量化(Quantization-based): 例如 PQ (Product Quantization)。通过量化技术压缩向量,减少内存占用和计算量。
向量数据库(Vector Databases): 专门为存储和高效执行 ANN 搜索而设计的数据库。它们内置了多种 ANN 算法,并提供了分布式、高可用性、实时更新等特性。Haystack 中的 DocumentStore 抽象层就是为了与这些向量数据库进行交互。
稠密检索器的工作流总结:
离线阶段(索引构建):
加载原始文档。
使用 PreProcessor 进行清洗和分块。
使用文档嵌入器(例如 SentenceTransformersDocumentEmbedder)将每个文档块转换为一个稠密的嵌入向量。
将文档块(原始文本、元数据和嵌入向量)存储到支持向量搜索的 DocumentStore(例如 FAISSDocumentStore, ElasticsearchDocumentStore, PineconeDocumentStore 等),DocumentStore 内部会利用 ANN 算法构建索引。
在线阶段(查询时):
接收用户查询。
使用查询嵌入器(例如 SentenceTransformersQueryEmbedder,通常与文档嵌入器使用相同的模型)将用户查询转换为一个查询嵌入向量。
将查询嵌入发送到 DocumentStore。
DocumentStore 使用其内部的 ANN 索引算法,快速找到与查询嵌入最相似的 Top-K 个文档嵌入。
返回这些 Top-K 文档的原始文本内容和元数据。
2.4.2.2 Haystack 中的稠密检索器类型
Haystack 提供了多种开箱即用的稠密检索器,以支持不同的嵌入模型和后端系统。
2.4.2.2.1 EmbeddingRetriever:通用向量检索器
EmbeddingRetriever 是 Haystack 中最通用、最常用的稠密检索器。它假设你的 DocumentStore 已经包含了文档的嵌入向量,并且它会使用一个指定的嵌入模型来生成查询的嵌入向量。
原理:
初始化时,EmbeddingRetriever 绑定到一个 DocumentStore 和一个 embedding_model。
当接收到查询时,它使用 embedding_model 对查询文本进行编码,生成查询嵌入。
然后,它将查询嵌入发送给底层的 DocumentStore。
DocumentStore 使用其内部的向量索引(例如 FAISS, Elasticsearch 的 k-NN, Pinecone 的向量索引)执行近似最近邻搜索,找到与查询嵌入最相似的 Top-K 个文档嵌入。
DocumentStore 返回这些文档的原始内容、元数据以及相似度分数。
参数:
document_store: 必需,连接的 DocumentStore 实例,它必须支持向量存储和检索。
embedding_model: 用于生成查询嵌入的模型名称或路径(通常是 Sentence-Transformers 模型)。
model_format: 模型格式,例如 "sentence_transformers"。
top_k: 返回最相关文档的数量。
use_gpu: 是否使用 GPU 进行模型推理。
batch_size: 批量处理文档的数量(如果 update_embeddings 时)。
similarity_metric: 相似度度量,例如 "cosine", "dot_product", "euclidean"。
优缺点:
优点:
高度灵活: 可以与任何支持向量存储和搜索的 DocumentStore 配合使用。
易于配置: 只需指定嵌入模型和文档存储。
效果良好: 依赖于底层嵌入模型的质量和向量数据库的效率。
缺点:
不负责文档嵌入: EmbeddingRetriever 不会为你的知识库中的文档生成嵌入。文档嵌入必须在索引阶段(例如通过 SentenceTransformersDocumentEmbedder)预先生成并存储到 DocumentStore 中。
模型选择: 嵌入模型的选择对检索性能至关重要。
代码示例:EmbeddingRetriever 的使用
import os # 导入操作系统模块
from haystack.document_stores import InMemoryDocumentStore # 导入内存文档存储
from haystack.nodes import EmbeddingRetriever, SentenceTransformersDocumentEmbedder # 导入 EmbeddingRetriever 和 SentenceTransformersDocumentEmbedder
from haystack.schema import Document # 导入 Document 类
import numpy as np # 导入 numpy 库
# 定义嵌入模型名称
EMBEDDING_MODEL = "sentence-transformers/all-MiniLM-L6-v2" # 使用 Sentence-Transformers 的一个小型通用嵌入模型
EMBEDDING_DIM = 384 # all-MiniLM-L6-v2 的嵌入维度是 384
# 1. 初始化 DocumentStore
# DocumentStore 必须配置为存储嵌入。
document_store_emb_retriever = InMemoryDocumentStore(use_embedding_storage=True, embedding_dim=EMBEDDING_DIM) # 初始化内存文档存储,启用嵌入存储,指定维度
print("InMemoryDocumentStore 已初始化,用于存储嵌入。") # 打印初始化信息
# 2. 准备文档并为其生成嵌入,然后写入 DocumentStore
# 在实际 RAG 管道中,文档的嵌入通常是在索引阶段通过 DocumentEmbedder 生成并写入 DocumentStore。
# 这里我们手动模拟这个过程。
docs_to_embed_and_write = [
Document(content="深度学习是人工智能的一个分支,它使用多层神经网络。", meta={
"topic": "AI", "type": "definition"}),
Document(content="卷积神经网络(CNN)在图像识别任务中表现出色。", meta={
"topic": "AI", "type": "architecture"}),
Document(content="循环神经网络(RNN)被用于处理序列数据,如自然语言。", meta={
"topic": "AI", "type": "architecture"}),
Document(content="Transformer 架构是现代 NLP 模型的基础。", meta={
"topic": "NLP", "type": "architecture"}),
Document(content="RAG 系统结合检索和生成来增强 LLM 的能力。", meta={
"topic": "RAG", "type": "concept"})
]
# 使用 SentenceTransformersDocumentEmbedder 为文档生成嵌入
doc_embedder = SentenceTransformersDocumentEmbedder(
model_name_or_path=EMBEDDING_MODEL, # 指定嵌入模型
device="cpu", # 在 CPU 上运行
batch_size=16 # 批量处理
)
print(f"
文档嵌入器已初始化,使用模型: {
EMBEDDING_MODEL}。") # 打印嵌入器初始化信息
# 生成嵌入并更新 Document 对象
# run 方法的输入是 Document 列表,输出是包含更新后 Document 的字典
embedded_docs_result = doc_embedder.run(documents=docs_to_embed_and_write) # 运行文档嵌入器
embedded_docs = embedded_docs_result[0]["documents"] # 获取带有嵌入的文档列表
# 写入带有嵌入的文档到 DocumentStore
document_store_emb_retriever.write_documents(embedded_docs) # 写入文档到文档存储
print(f"
已写入 {
len(embedded_docs)} 个带嵌入的文档到 DocumentStore。") # 打印写入文档数量
# 3. 初始化 EmbeddingRetriever
# embedding_model: 用于编码查询的模型,必须与用于文档嵌入的模型一致。
# similarity_metric: 指定相似度度量。
retriever_embedding = EmbeddingRetriever(
document_store=document_store_emb_retriever, # 传入文档存储
embedding_model=EMBEDDING_MODEL, # 传入嵌入模型
model_format="sentence_transformers", # 模型格式
top_k=2, # 检索最相关的 2 个文档
similarity_metric="cosine" # 使用余弦相似度
)
print("EmbeddingRetriever 已初始化。") # 打印初始化信息
# 4. 执行检索
query_text = "什么是深度学习在人工智能中的应用?" # 用户查询
print(f"
查询: '{
query_text}'") # 打印用户查询
# run 方法会内部调用 embedding_model 来生成查询嵌入,然后执行相似度搜索
retrieved_results_embedding = retriever_embedding.run(query=query_text) # 运行 EmbeddingRetriever
retrieved_documents_embedding = retrieved_results_embedding[0]["documents"] # 获取检索到的文档列表
print("EmbeddingRetriever 检索到的文档:") # 打印检索到的文档标题
if retrieved_documents_embedding:
for i, doc in enumerate(retrieved_documents_embedding): # 遍历检索到的文档
print(f" 文档 {
i+1} (分数: {
doc.score:.4f}): '{
doc.content[:80]}...'") # 打印文档内容和分数
print(f" 元数据: {
doc.meta}") # 打印文档元数据
print("-" * 20) # 分隔线
else:
print("未找到相关文档。") # 未找到文档提示
# 另一个查询,测试语义匹配
query_text_2 = "基于 Transformer 的模型" # 第二个用户查询
print(f"
查询: '{
query_text_2}'") # 打印第二个用户查询
retrieved_results_embedding_2 = retriever_embedding.run(query=query_text_2) # 运行 EmbeddingRetriever
retrieved_documents_embedding_2 = retrieved_results_embedding_2[0]["documents"] # 获取检索到的文档列表
print("EmbeddingRetriever 检索到的文档:") # 打印检索到的文档标题
if retrieved_documents_embedding_2:
for i, doc in enumerate(retrieved_documents_embedding_2): # 遍历检索到的文档
print(f" 文档 {
i+1} (分数: {
doc.score:.4f}): '{
doc.content[:80]}...'") # 打印文档内容和分数
print(f" 元数据: {
doc.meta}") # 打印文档元数据
print("-" * 20) # 分隔线
else:
print("未找到相关文档。") # 未找到文档提示
代码说明:
InMemoryDocumentStore(use_embedding_storage=True, embedding_dim=EMBEDDING_DIM):初始化 InMemoryDocumentStore,必须启用 use_embedding_storage=True 并且指定 embedding_dim,以便它能存储和处理嵌入向量。
SentenceTransformersDocumentEmbedder(...):在文档写入 DocumentStore 之前,使用这个节点为文档生成嵌入。这是索引阶段的工作。
retriever_embedding = EmbeddingRetriever(...):初始化 EmbeddingRetriever。它的 embedding_model 参数告诉它在查询时使用哪个模型来生成查询嵌入。这个模型应该与用于生成文档嵌入的模型是同一个或兼容的模型。
retriever_embedding.run(query=query_text):执行检索。EmbeddingRetriever 会自动完成以下步骤:
使用其内部的 embedding_model 对 query 进行编码,生成查询向量。
将查询向量发送给 document_store。
document_store 执行向量相似度搜索。
返回 top_k 个最相关的文档,每个文档都带有相似度分数。
2.4.2.2.2 DensePassageRetriever (DPR):端到端双塔模型检索
DensePassageRetriever (DPR) 是一个更专业的稠密检索器,它基于由 Facebook AI Research (FAIR) 开发的 Dense Passage Retrieval 范式。DPR 的核心特点是使用双塔(Dual-Encoder)架构,其中一个编码器用于查询,另一个编码器用于文档。这两个编码器是在大规模问答数据集上通过对比学习(Contrastive Learning)联合训练的,旨在最大化相关查询-文档对的相似度,并最小化不相关对的相似度。
原理与内部机制:
双编码器架构:
一个查询编码器(Query Encoder): 将查询文本编码为固定维度的查询向量。
一个文档编码器(Passage Encoder): 将文档文本编码为固定维度的文档向量。
这两个编码器通常是基于 BERT 或 RoBERTa 等 Transformer 模型的变体。
对比学习训练: 在训练阶段,给定一个查询和它的正例文档(相关文档),以及多个负例文档(不相关文档),模型的目标是让查询向量与正例文档向量之间的相似度高于与所有负例文档向量的相似度。这使得模型能够学习到对检索任务最有用的语义表示。
索引阶段:
使用文档编码器对知识库中的所有文档进行编码,生成文档嵌入。
将这些文档嵌入存储到 DocumentStore 中,并构建 ANN 索引。
查询阶段:
使用查询编码器对用户查询进行编码,生成查询嵌入。
将查询嵌入发送到 DocumentStore,执行 ANN 相似度搜索。
返回 Top-K 的文档。
参数:
document_store: 必需,连接的 DocumentStore 实例。
query_embedding_model: 查询编码器的模型名称或路径(通常是 Hugging Face 模型)。
passage_embedding_model: 文档编码器的模型名称或路径(通常与查询编码器是同一个模型,但参数不同或来自同一训练过程)。
use_gpu: 是否使用 GPU。
batch_size: 批量处理大小。
embed_title_and_text: 是否同时编码文档的标题和内容。
top_k: 返回最相关文档的数量。
优缺点:
优点:
语义匹配能力强: 经过检索任务专门训练,能够更准确地捕捉查询与文档之间的语义相关性。
召回率高: 在许多问答和检索任务中,DPR 能够显著提高相关文档的召回率。
端到端: 可以直接处理查询和文档,不需要额外的通用嵌入器。
缺点:
训练复杂: 如果要从头训练 DPR 模型,需要大量的标注数据和计算资源。
资源消耗: 双编码器模型加载和推理需要更多的内存和计算资源。
模型尺寸: 相较于 all-MiniLM-L6-v2 等小型通用嵌入模型,DPR 模型通常更大。
有时不如融合稀疏和稠密检索: 对于某些查询,纯粹的语义匹配可能不如关键词匹配效果好。
适用场景:
知识密集型问答 (KIQA): 特别适用于那些答案可能隐藏在长文档中,且查询与答案文本之间存在语义而非精确关键词匹配的场景。
大规模信息检索: 需要从大型非结构化文本集合中高效检索。
需要高召回率的场景: 在召回率比精确率更重要的任务中,DPR 表现优异。
作为混合检索的组成部分: 结合 BM25,以达到最佳的召回和精确率平衡。
代码示例:DensePassageRetriever 的使用
import os # 导入操作系统模块
from haystack.document_stores import InMemoryDocumentStore # 导入内存文档存储
from haystack.nodes import DensePassageRetriever # 导入 DensePassageRetriever
from haystack.schema import Document # 导入 Document 类
# 定义 DPR 模型名称
# DPR 模型通常由两个模型组成,一个用于查询,一个用于文档
# 这里我们使用一个 Haystack 提供的预训练 DPR 模型
QUERY_EMBEDDING_MODEL = "deepset/dpr-question_encoder-single-nq-base" # NQ 数据集上训练的查询编码器
PASSAGE_EMBEDDING_MODEL = "deepset/dpr-ctx_encoder-single-nq-base" # NQ 数据集上训练的文档编码器
EMBEDDING_DIM_DPR = 768 # DPR 模型的嵌入维度通常是 768
# 1. 初始化 DocumentStore
document_store_dpr = InMemoryDocumentStore(use_embedding_storage=True, embedding_dim=EMBEDDING_DIM_DPR) # 初始化内存文档存储,启用嵌入存储,指定维度
print("InMemoryDocumentStore 已初始化,用于存储 DPR 嵌入。") # 打印初始化信息
# 2. 初始化 DensePassageRetriever
# DensePassageRetriever 会在内部管理查询和文档的编码模型。
# 它还提供 update_embeddings 方法来为 DocumentStore 中的文档生成嵌入。
retriever_dpr = DensePassageRetriever(
document_store=document_store_dpr, # 传入文档存储
query_embedding_model=QUERY_EMBEDDING_MODEL, # 查询编码器模型
passage_embedding_model=PASSAGE_EMBEDDING_MODEL, # 文档编码器模型
use_gpu=False, # 是否使用 GPU (如果安装了 CUDA 和 PyTorch GPU 版本,可设置为 True)
batch_size=16, # 批量处理大小
top_k=2 # 检索最相关的 2 个文档
)
print("DensePassageRetriever 已初始化。") # 打印初始化信息
# 3. 准备文档并为它们生成嵌入(通过 retriever.update_embeddings)
# DPR 的一个便利之处是,它提供了一个方法来直接更新 DocumentStore 中的文档嵌入。
docs_for_dpr = [
Document(content="RAG 的全称是检索增强生成。", meta={
"category": "RAG", "type": "definition"}),
Document(content="BERT 是一种基于 Transformer 架构的语言模型。", meta={
"category": "NLP", "type": "model"}),
Document(content="大语言模型有时会产生幻觉,RAG 可以帮助减少这个问题。", meta={
"category": "LLM", "type": "challenge"}),
Document(content="自然语言处理是人工智能的一个核心领域。", meta={
"category": "AI", "type": "field"})
]
# 写入文档到 DocumentStore (此时文档没有嵌入)
document_store_dpr.write_documents(docs_for_dpr) # 写入文档到文档存储
print(f"
已写入 {
len(docs_for_dpr)} 个文档到 DocumentStore (等待嵌入)。") # 打印写入文档数量
# 更新文档嵌入 (DPR 专属方法)
# 这会使用 passage_embedding_model 为 document_store_dpr 中的所有文档生成嵌入
print("正在为文档生成 DPR 嵌入...") # 打印生成嵌入信息
retriever_dpr.update_embeddings() # 更新文档嵌入
print("文档 DPR 嵌入生成完成。") # 打印嵌入生成完成信息
# 4. 执行检索
query_dpr = "大语言模型生成不准确信息的问题" # 用户查询,语义上与“幻觉”相关
print(f"
查询: '{
query_dpr}'") # 打印用户查询
# run 方法会内部使用 query_embedding_model 对查询进行编码,然后执行相似度搜索
retrieved_results_dpr = retriever_dpr.run(query=query_dpr) # 运行 DensePassageRetriever
retrieved_documents_dpr = retrieved_results_dpr[0]["documents"] # 获取检索到的文档列表
print("DPR 检索到的文档:") # 打印检索到的文档标题
if retrieved_documents_dpr:
for i, doc in enumerate(retrieved_documents_dpr): # 遍历检索到的文档
print(f" 文档 {
i+1} (分数: {
doc.score:.4f}): '{
doc.content[:80]}...'") # 打印文档内容和分数
print(f" 元数据: {
doc.meta}") # 打印文档元数据
print("-" * 20) # 分隔线
else:
print("未找到相关文档。") # 未找到文档提示
# 另一个查询,测试语义匹配
query_dpr_2 = "什么是 RAG 架构?" # 第二个用户查询
print(f"
查询: '{
query_dpr_2}'") # 打印第二个用户查询
retrieved_results_dpr_2 = retriever_dpr.run(query=query_dpr_2) # 运行 DensePassageRetriever
retrieved_documents_dpr_2 = retrieved_results_dpr_2[0]["documents"] # 获取检索到的文档列表
print("DPR 检索到的文档:") # 打印检索到的文档标题
if retrieved_documents_dpr_2:
for i, doc in enumerate(retrieved_documents_dpr_2): # 遍历检索到的文档
print(f" 文档 {
i+1} (分数: {
doc.score:.4f}): '{
doc.content[:80]}...'") # 打印文档内容和分数
print(f" 元数据: {
doc.meta}") # 打印文档元数据
print("-" * 20) # 分隔线
else:
print("未找到相关文档。") # 未找到文档提示
代码说明:
QUERY_EMBEDDING_MODEL, PASSAGE_EMBEDDING_MODEL: DPR 使用两个独立的模型来编码查询和文档。这两个模型通常是在特定数据集(如 NQ)上联合训练的,以优化检索性能。
DensePassageRetriever(...):初始化 DPR 实例时,需要分别指定查询和文档编码器模型。
retriever_dpr.update_embeddings():这是一个方便的方法,它会遍历 DocumentStore 中的所有文档,并使用 passage_embedding_model 为它们生成嵌入向量,然后将这些嵌入写回 DocumentStore。这是 DPR 独有的特点,简化了索引阶段的嵌入生成过程。
retriever_dpr.run(query=query_dpr):执行检索。它会使用 query_embedding_model 对查询进行编码,然后在 document_store 中执行相似度搜索。
2.4.2.2.3 OpenAIEmbeddingRetriever (已弃用,功能并入 EmbeddingRetriever 或 OpenAIDocumentEmbedder)
在 Haystack 的早期版本中,可能存在一个专门的 OpenAIEmbeddingRetriever。然而,随着 Haystack 的发展,其设计理念是更加通用和模块化。现在,如果你想使用 OpenAI 的嵌入模型进行稠密检索,通常是通过以下两种方式:
EmbeddingRetriever + OpenAIDocumentEmbedder (或 OpenAI as embedding_model):
在索引阶段,使用 OpenAIDocumentEmbedder 将文档内容发送到 OpenAI Embeddings API,获取嵌入向量并存储到 DocumentStore。
在查询阶段,使用 EmbeddingRetriever,并将其 embedding_model 参数设置为 text-embedding-ada-002 (或任何其他 OpenAI 嵌入模型名称)。EmbeddingRetriever 会在内部调用 OpenAI API 为查询生成嵌入,然后与 DocumentStore 中的文档嵌入进行相似度搜索。
这是推荐的方式,因为它将嵌入生成和检索逻辑分离,符合 Haystack 的模块化设计。
PromptNode 或 OpenAIGenerator 中的隐式嵌入:
虽然这些组件主要用于生成文本,但它们内部也可以调用 OpenAI 的嵌入 API。然而,这不适用于直接的 RAG 检索,因为它们不会主动去查询 DocumentStore。
2.4.2.2.4 CohereEmbeddingRetriever (已弃用,功能并入 EmbeddingRetriever 或 CohereDocumentEmbedder)
与 OpenAI 类似,如果你想使用 Cohere 的嵌入模型进行稠密检索,推荐的方式是:
EmbeddingRetriever + CohereDocumentEmbedder (或 Cohere as embedding_model):
在索引阶段,使用 CohereDocumentEmbedder 将文档内容发送到 Cohere Embed API,获取嵌入向量并存储到 DocumentStore。
在查询阶段,使用 EmbeddingRetriever,并将其 embedding_model 参数设置为 Cohere 的嵌入模型名称(如 embed-english-v3.0)。EmbeddingRetriever 会在内部调用 Cohere API 为查询生成嵌入,然后与 DocumentStore 中的文档嵌入进行相似度搜索。
这也是推荐的方式,遵循 Haystack 的模块化原则。
代码示例:使用 EmbeddingRetriever 和 OpenAIEmbeddings (索引+查询)
此示例展示了如何使用 OpenAI 作为嵌入模型来初始化 EmbeddingRetriever,并通过 OpenAIDocumentEmbedder 在索引阶段将文档嵌入。
import os # 导入操作系统模块
from haystack.document_stores import FAISSDocumentStore # 导入 FAISSDocumentStore
from haystack.nodes import EmbeddingRetriever, OpenAIDocumentEmbedder # 导入 EmbeddingRetriever 和 OpenAIDocumentEmbedder
from haystack.schema import Document # 导入 Document 类
import numpy as np # 导入 numpy 库
# 定义 OpenAI API 密钥和嵌入模型
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY") # 从环境变量获取 OpenAI API 密钥
# 注意:Haystack 的 OpenAIEmbeddings (DocumentEmbedder 或 Retriever 的 embedding_model 参数)
# 通常使用 OpenAI 的 text-embedding-ada-002 模型,其维度为 1536。
# 如果你使用其他 OpenAI 模型,请确保维度匹配。
OPENAI_EMBEDDING_MODEL = "text-embedding-ada-002" # OpenAI 嵌入模型名称
EMBEDDING_DIM_OPENAI = 1536 # text-embedding-ada-002 的嵌入维度
if not OPENAI_API_KEY:
print("错误: OPENAI_API_KEY 环境变量未设置。请设置您的 OpenAI API 密钥。") # 提示用户设置环境变量
exit() # 退出程序
# 定义 FAISS 持久化路径
FAISS_PATH_OPENAI = "faiss_openai_index.bin" # 定义 FAISS 索引文件的路径
CONFIG_PATH_OPENAI = "faiss_openai_config.pkl" # 定义 FAISS 配置文件的路径
# 清理旧文件 (可选,确保每次运行都是干净的)
if os.path.exists(FAISS_PATH_OPENAI):
os.remove(FAISS_PATH_OPENAI) # 删除旧的 FAISS 索引文件
if os.path.exists(CONFIG_PATH_OPENAI):
os.remove(CONFIG_PATH_OPENAI) # 删除旧的 FAISS 配置文件
if os.path.exists("faiss_document_store.db"): # 如果使用了 SQLite 数据库
os.remove("faiss_document_store.db") # 删除旧的 SQLite 数据库文件
print("已清理旧的 FAISS 索引和相关文件。") # 打印清理信息
# 1. 初始化 FAISSDocumentStore
# 必须指定 embedding_dim,并且 use_embedding_storage 默认为 True
document_store_openai = FAISSDocumentStore(
embedding_dim=EMBEDDING_DIM_OPENAI, # 指定嵌入维度
faiss_index_factory_str="HNSW", # 使用 HNSW 索引
sql_url="sqlite:///faiss_document_store.db" # 使用 SQLite 存储元数据
)
print("FAISSDocumentStore 已初始化,用于 OpenAI 嵌入。") # 打印初始化信息
# 2. 准备原始文档
docs_raw_openai = [
Document(content="人工智能(AI)是计算机科学的一个领域,旨在创建能够执行通常需要人类智能的任务的机器。", meta={
"source": "百科"}),
Document(content="机器学习是人工智能的一个子集,专注于让计算机从数据中学习而无需明确编程。", meta={
"source": "百科"}),
Document(content="大型语言模型(LLMs)是深度学习模型,通过分析大量文本数据来理解、生成和操作人类语言。", meta={
"source": "教程"}),
Document(content="GPT-4 是 OpenAI 开发的一个大型多模态模型。", meta={
"source": "OpenAI"}),
Document(content="文本嵌入将文本转换为数字向量,捕捉其语义含义。", meta={
"source": "概念"}),
Document(content="RAG 通过检索相关信息来增强 LLM 的准确性。", meta={
"source": "RAG指南"})
]
# 3. 初始化 OpenAIDocumentEmbedder 并为文档生成嵌入
# api_key: OpenAI API 密钥。
# model: 用于生成嵌入的模型名称。
# batch_size: 批量处理文档以提高效率。
doc_embedder_openai = OpenAIDocumentEmbedder(
api_key=OPENAI_API_KEY, # 传入 OpenAI API 密钥
model=OPENAI_EMBEDDING_MODEL, # 传入 OpenAI 嵌入模型名称
batch_size=16 # 批量大小
)
print(f"
OpenAIDocumentEmbedder 已初始化,使用模型: {
OPENAI_EMBEDDING_MODEL}。") # 打印初始化信息
# 生成嵌入并更新 Document 对象
# run 方法的输入是 Document 列表,输出是包含更新后 Document 的字典
try:
embedded_docs_result_openai = doc_embedder_openai.run(documents=docs_raw_openai) # 运行 OpenAI 文档嵌入器
embedded_docs_openai = embedded_docs_result_openai[0]["documents"] # 获取带有嵌入的文档列表
# 写入带有嵌入的文档到 DocumentStore
document_store_openai.write_documents(embedded_docs_openai) # 写入文档到文档存储
print(f"
已写入 {
len(embedded_docs_openai)} 个带 OpenAI 嵌入的文档到 FAISSDocumentStore。") # 打印写入文档数量
# 保存 FAISS 索引,以便下次加载
document_store_openai.save(index_path=FAISS_PATH_OPENAI, config_path=CONFIG_PATH_OPENAI) # 保存 FAISS 索引和配置
print(f"FAISSDocumentStore 索引和配置已保存到 '{
FAISS_PATH_OPENAI}' 和 '{
CONFIG_PATH_OPENAI}'。") # 打印保存信息
except Exception as e:
print(f"OpenAIDocumentEmbedder 或写入文档失败: {
e}") # 打印失败信息
print("请检查 OpenAI API Key 是否有效,或网络连接。") # 提示检查
exit() # 退出程序
# 4. 初始化 EmbeddingRetriever
# 这里的 embedding_model 同样指向 OpenAI 的嵌入模型,它会在查询时调用 OpenAI API。
retriever_openai = EmbeddingRetriever(
document_store=document_store_openai, # 传入文档存储
embedding_model=OPENAI_EMBEDDING_MODEL, # 传入 OpenAI 嵌入模型名称
api_key=OPENAI_API_KEY, # 传入 OpenAI API 密钥
model_format="openai", # 指定模型格式为 "openai"
top_k=2, # 检索最相关的 2 个文档
similarity_metric="cosine" # 使用余弦相似度
)
print("EmbeddingRetriever (使用 OpenAI) 已初始化。") # 打印初始化信息
# 5. 执行检索
query_text_openai = "OpenAI 制作的语言模型叫什么?" # 用户查询
print(f"
查询: '{
query_text_openai}'") # 打印用户查询
try:
retrieved_results_openai = retriever_openai.run(query=query_text_openai) # 运行 EmbeddingRetriever
retrieved_documents_openai = retrieved_results_openai[0]["documents"] # 获取检索到的文档列表
print("EmbeddingRetriever (OpenAI) 检索到的文档:") # 打印检索到的文档标题
if retrieved_documents_openai:
for i, doc in enumerate(retrieved_documents_openai): # 遍历检索到的文档
print(f" 文档 {
i+1} (分数: {
doc.score:.4f}): '{
doc.content[:80]}...'") # 打印文档内容和分数
print(f" 元数据: {
doc.meta}") # 打印文档元数据
print("-" * 20) # 分隔线
else:
print("未找到相关文档。") # 未找到文档提示
except Exception as e:
print(f"EmbeddingRetriever (OpenAI) 检索失败: {
e}") # 打印检索失败信息
print("请检查 OpenAI API Key 是否有效,或网络连接。") # 提示检查
# 清理文件 (可选)
# os.remove(FAISS_PATH_OPENAI)
# os.remove(CONFIG_PATH_OPENAI)
# os.remove("faiss_document_store.db")
# print("
已清理 FAISS 索引和相关文件。")
代码说明:
OPENAI_API_KEY, OPENAI_EMBEDDING_MODEL, EMBEDDING_DIM_OPENAI: 定义 OpenAI 相关的认证和模型参数。text-embedding-ada-002 是 OpenAI 推荐的通用嵌入模型,其维度为 1536。
FAISSDocumentStore(embedding_dim=EMBEDDING_DIM_OPENAI, ...):初始化 FAISSDocumentStore 时,确保 embedding_dim 与 OpenAI 模型的维度一致。
OpenAIDocumentEmbedder(...):这是在索引阶段用于为文档生成 OpenAI 嵌入的节点。它需要 api_key 和 model 参数。
doc_embedder_openai.run(documents=docs_raw_openai):执行文档嵌入生成。
document_store_openai.write_documents(embedded_docs_openai):将带有 OpenAI 嵌入的文档写入 FAISS 存储。
retriever_openai = EmbeddingRetriever(document_store=document_store_openai, embedding_model=OPENAI_EMBEDDING_MODEL, api_key=OPENAI_API_KEY, model_format="openai", ...):初始化 EmbeddingRetriever。它的 embedding_model 再次指定了 OpenAI 的模型名称,并且 api_key 也会传递给它。model_format="openai" 告诉 Haystack 使用 OpenAI 的 API 来生成查询嵌入。
retriever_openai.run(query=query_text_openai):执行检索。当 EmbeddingRetriever 接收到查询时,它会内部调用 OpenAI Embeddings API 来获取查询的嵌入向量,然后与 FAISSDocumentStore 中已存储的文档嵌入进行相似度搜索。
2.4.2.2.5 Custom Dense Retriever (自定义稠密检索):灵活集成
与自定义稀疏检索器类似,如果你有特定的嵌入模型或向量搜索逻辑,Haystack 也允许你创建自定义的稠密检索器。这通常意味着你需要:
实现自定义的嵌入逻辑: 例如,如果你的嵌入模型是基于 TensorFlow 或 JAX 的,并且 Haystack 没有直接的集成,你可以自己封装模型的前向传播逻辑。
实现自定义的相似度搜索: 如果你使用的向量存储不是 Haystack 内置 DocumentStore 支持的类型,或者你需要更精细的控制搜索过程。
核心逻辑:
通常,自定义稠密检索器会继承 BaseRetriever,并重写 _retrieve 方法。在这个方法中,你需要:
接收查询。
使用你的自定义嵌入模型将查询转换为向量。
调用底层 DocumentStore 的 query_by_embedding 方法,或直接与你的自定义向量存储进行交互,执行相似度搜索。
将结果封装为 Haystack Document 对象并返回。
代码示例:一个概念性的自定义稠密检索器
这个示例假设我们有一个自定义的(非常简化的)嵌入模型,并使用 InMemoryDocumentStore 进行向量搜索。
from typing import List, Optional # 导入类型提示
from haystack.nodes import BaseRetriever # 导入 BaseRetriever
from haystack.document_stores import InMemoryDocumentStore # 导入 InMemoryDocumentStore
from haystack.schema import Document # 导入 Document 类
import numpy as np # 导入 numpy 库
class CustomEmbeddingModel:
"""
一个非常简化的自定义嵌入模型,用于演示。
实际模型会是复杂的深度学习模型。
"""
def __init__(self, embedding_dim: int = 128):
"""
初始化自定义嵌入模型。
:param embedding_dim: 嵌入向量的维度。
"""
self.embedding_dim = embedding_dim # 存储嵌入维度
print(f"自定义嵌入模型已初始化,维度: {
embedding_dim}。") # 打印初始化信息
def encode(self, text: str) -> np.ndarray:
"""
将文本编码为随机的稠密向量(模拟实际嵌入)。
:param text: 输入文本。
:return: 文本的嵌入向量。
"""
# 实际中这里会是模型推理逻辑,如 model.encode(text)
# 为了演示,我们生成一个基于文本哈希的伪随机向量,确保同一文本有相同嵌入
np.random.seed(hash(text) % (2**32 - 1)) # 根据文本内容设置随机种子,确保同一文本生成相同的“嵌入”
return np.random.rand(self.embedding_dim).astype(np.float32) # 生成随机浮点型向量
class CustomDenseRetriever(BaseRetriever):
"""
一个自定义的稠密检索器,使用自定义嵌入模型和 DocumentStore。
"""
def __init__(self, document_store: InMemoryDocumentStore, embedding_model: CustomEmbeddingModel, top_k: int = 5):
"""
初始化 CustomDenseRetriever。
:param document_store: 用于存储文档的 DocumentStore 实例。
:param embedding_model: 自定义嵌入模型实例。
:param top_k: 要返回的最相关文档的数量。
"""
super().__init__() # 调用基类的构造函数
self.document_store = document_store # 存储 DocumentStore 实例
self.embedding_model = embedding_model # 存储自定义嵌入模型实例
self.top_k = top_k # 存储 top_k 参数
# 确保 DocumentStore 已启用嵌入存储并维度匹配
if not document_store.use_embedding_storage:
raise ValueError("DocumentStore 必须启用 use_embedding_storage=True。") # 检查文档存储是否启用嵌入存储
if document_store.embedding_dim != self.embedding_model.embedding_dim:
raise ValueError(f"DocumentStore 嵌入维度 {
document_store.embedding_dim} 与模型维度 {
self.embedding_model.embedding_dim} 不匹配。") # 检查维度是否匹配
print("CustomDenseRetriever 已初始化。") # 打印初始化信息
def _retrieve(self, query: str, filters: Optional[dict] = None, **kwargs) -> List[Document]:
"""
根据查询和过滤器检索文档。
这个方法是自定义稠密检索器的核心逻辑。
它接收一个查询字符串,将其编码为向量,然后在 document_store 中进行向量相似度搜索。
:param query: 用户输入的查询字符串。
:param filters: 用于筛选文档的元数据过滤器。
:param kwargs: 其他传递给检索器的额外参数。
:return: 包含相关文档的列表,每个文档都有一个计算出的分数。
"""
# 1. 对查询进行嵌入
query_embedding = self.embedding_model.encode(query) # 使用自定义嵌入模型编码查询
# 2. 调用 DocumentStore 进行向量相似度搜索
# DocumentStore 的 query_by_embedding 方法会执行实际的向量搜索
# 并返回 Document 列表,其中已包含相似度分数。
retrieved_docs = self.document_store.query_by_embedding(
query_emb=query_embedding, # 传入查询嵌入
top_k=self.top_k, # 传入 top_k
filters=filters, # 传入过滤器
# 假设 DocumentStore 默认使用余弦相似度,或者你在 DocumentStore 初始化时指定
# similarity_metric="cosine" # 如果 DocumentStore 需要显式指定
)
print(f"查询 '{
query}' 已完成自定义稠密检索。") # 打印检索完成信息
return retrieved_docs # 返回检索到的文档
# --- 示例用法 ---
EMBED_DIM_CUSTOM = 128 # 自定义嵌入维度
# 1. 初始化自定义嵌入模型
custom_embed_model = CustomEmbeddingModel(embedding_dim=EMBED_DIM_CUSTOM) # 初始化自定义嵌入模型
# 2. 初始化 DocumentStore
document_store_custom_dense = InMemoryDocumentStore(use_embedding_storage=True, embedding_dim=EMBED_DIM_CUSTOM) # 初始化内存文档存储,启用嵌入存储,指定维度
# 3. 准备文档并为其生成嵌入,然后写入 DocumentStore
# 在这里,我们必须手动为文档生成嵌入,并确保它们被写入 DocumentStore
docs_to_embed_custom = [
Document(content="苹果是一种水果,通常是红色的。", meta={
"type": "fruit"}),
Document(content="Apple 公司生产 iPhone 和 Mac 电脑。", meta={
"type": "company"}),
Document(content="橘子和香蕉也是常见的水果。", meta={
"type": "fruit"}),
Document(content="微软是另一家大型科技公司。", meta={
"type": "company"})
]
for doc in docs_to_embed_custom: # 遍历每个文档
doc.embedding = custom_embed_model.encode(doc.content) # 为文档生成嵌入
document_store_custom_dense.write_documents(docs_to_embed_custom) # 写入文档到文档存储
print(f"
已写入 {
len(docs_to_embed_custom)} 个带自定义嵌入的文档到 DocumentStore。") # 打印写入文档数量
# 4. 初始化自定义稠密检索器
custom_dense_retriever = CustomDenseRetriever(
document_store=document_store_custom_dense, # 传入文档存储
embedding_model=custom_embed_model, # 传入自定义嵌入模型
top_k=2 # 检索前 2 个文档
)
print("CustomDenseRetriever 已初始化。") # 打印初始化信息
# 5. 执行检索
query_custom_dense = "关于水果的问题" # 用户查询
print(f"
查询: '{
query_custom_dense}'") # 打印用户查询
retrieved_results_custom_dense = custom_dense_retriever.run(query=query_custom_dense) # 运行自定义稠密检索器
retrieved_documents_custom_dense = retrieved_results_custom_dense[0]["documents"] # 获取检索到的文档列表
print("自定义稠密检索器检索到的文档:") # 打印检索到的文档标题
if retrieved_documents_custom_dense:
for i, doc in enumerate(retrieved_documents_custom_dense): # 遍历检索到的文档
print(f" 文档 {
i+1} (分数: {
doc.score:.4f}): '{
doc.content[:80]}...'") # 打印文档内容和分数
print(f" 元数据: {
doc.meta}") # 打印文档元数据
print("-" * 20) # 分隔线
else:
print("未找到相关文档。") # 未找到文档提示
# 另一个查询,带元数据过滤
query_custom_dense_2 = "科技巨头公司" # 第二个用户查询
filters_custom_dense_2 = {
"type": ["company"]} # 元数据过滤器
print(f"
查询: '{
query_custom_dense_2}' (过滤 type='company')") # 打印查询信息和过滤器
retrieved_results_custom_dense_2 = custom_dense_retriever.run(query=query_custom_dense_2, filters=filters_custom_dense_2) # 运行自定义稠密检索器,带过滤器
retrieved_documents_custom_dense_2 = retrieved_results_custom_dense_2[0]["documents"] # 获取检索到的文档列表
print("自定义稠密检索器检索到的文档 (带过滤):") # 打印检索到的文档标题
if retrieved_documents_custom_dense_2:
for i, doc in enumerate(retrieved_documents_custom_dense_2): # 遍历检索到的文档
print(f" 文档 {
i+1} (分数: {
doc.score:.4f}): '{
doc.content[:80]}...'") # 打印文档内容和分数
print(f" 元数据: {
doc.meta}") # 打印文档元数据
print("-" * 20) # 分隔线
else:
print("未找到相关文档。") # 未找到文档提示
代码说明:
CustomEmbeddingModel: 一个模拟的自定义嵌入模型类。其 encode 方法接收文本并返回一个伪随机的(但对于相同文本是确定的)向量。在实际场景中,这将是调用你自己的深度学习模型(例如,加载一个 PyTorch 或 TensorFlow 模型)。
CustomDenseRetriever(BaseRetriever):继承自 BaseRetriever,实现了自定义的稠密检索逻辑。
__init__(self, document_store, embedding_model, top_k):构造函数接收 DocumentStore 实例、自定义嵌入模型实例和 top_k。它会进行一些基本检查,确保文档存储支持嵌入且维度匹配。
_retrieve(self, query, filters, **kwargs):核心检索方法。
query_embedding = self.embedding_model.encode(query):使用自定义嵌入模型将用户查询编码为向量。
retrieved_docs = self.document_store.query_by_embedding(...):调用底层 DocumentStore 的 query_by_embedding 方法执行向量相似度搜索。这要求 DocumentStore 已经存储了带嵌入的文档。
这个示例强调了:当自定义嵌入模型或存储后端时,如何将它们的逻辑封装进 Haystack 的 BaseRetriever 框架中。
2.4.2.3 稠密检索器选型考量
在选择合适的稠密检索器时,需要综合考虑以下因素:
嵌入模型质量与领域适应性:
通用性: 如果你的知识库涉及多个领域,SentenceTransformers 系列的通用模型(如 all-MiniLM-L6-v2, mpnet-base-v2)或 OpenAI/Cohere 的嵌入服务通常是很好的起点。
领域特异性: 如果你的知识库非常专业化(如医学、法律、特定技术文档),那么在领域数据上进行过微调(Fine-tuning)的嵌入模型,或者像 DPR 这样在特定问答数据集上训练过的模型,可能会表现更好。
多语言支持: 考虑模型是否支持你的目标语言。部分 SentenceTransformers 模型是多语言的。
计算资源与性能需求:
本地计算: 如果对数据隐私有严格要求或希望降低长期运行成本,本地部署的 Hugging Face 模型(通过 EmbeddingRetriever 或 DPR)是选择。需要足够的 GPU 内存和计算能力来支持模型推理。
云 API: OpenAI、Cohere 等提供商通过 API 提供嵌入服务。它们管理底层基础设施,易于使用,但有 API 调用的成本和网络延迟。
模型大小与推理速度: 小型模型(如 all-MiniLM-L6-v2)推理速度快,内存占用小。大型模型(如 DPR、某些 Sentence-Transformers 大模型)可能更准确,但推理速度慢,资源消耗大。
索引与检索效率:
DocumentStore 的选择: 稠密检索器依赖于底层 DocumentStore 的向量搜索能力。选择高性能的向量数据库(如 Pinecone, Weaviate, Milvus, Elasticsearch)至关重要,尤其是在处理大规模知识库时。
ANN 算法: 了解你的 DocumentStore 使用的 ANN 算法的特性(例如,HNSW 在速度和准确性之间有很好的平衡)。
召回率与精确率的权衡:
高召回率: DPR 这种专门为检索任务训练的模型,通常在召回率方面表现出色,能够找到更多语义相关的文档。
高精确率: 稠密检索器相比稀疏检索器,在语义匹配上具有优势,但在某些精确关键词查询上可能不如 BM25。
混合检索: 通常,结合稀疏检索器(如 BM25)和稠密检索器(如 EmbeddingRetriever 或 DPR)进行混合检索(Hybrid Retrieval),可以实现召回率和精确率的最佳平衡。
数据隐私与安全:
如果处理敏感或专有数据,本地部署的嵌入模型(如通过 SentenceTransformers 或 DPR)提供了最高级别的数据隐私,因为数据不离开你的服务器。
云服务 API 需要评估其数据处理政策和合规性。
稠密检索器选择总结
| 检索器类型 | 嵌入模型/来源 | 核心优势 | 典型场景 | 考量因素 |
|---|---|---|---|---|
EmbeddingRetriever |
Sentence-Transformers, OpenAI, Cohere 等通用嵌入 | 灵活性高,可插拔,效果良好 | 通用 RAG、知识库问答、语义搜索 | 需预先生成文档嵌入,嵌入模型选择是关键 |
DensePassageRetriever (DPR) |
专门训练的双塔模型 (e.g., deepset/dpr-*) | 检索任务优化,高召回率,端到端管理嵌入 | 知识密集型问答 (KIQA)、长文档问答 | 模型较大,资源消耗高,训练复杂 (若自训) |
OpenAIDocumentEmbedder + EmbeddingRetriever |
OpenAI text-embedding-ada-002 |
易用性,高性能,广泛通用 | 需要快速上线、高答案质量、有预算 | API 成本,数据传输,外部服务依赖 |
CohereDocumentEmbedder + EmbeddingRetriever |
Cohere embed-english-v3.0 等 |
企业级特性,多语言支持 | 企业级 RAG、多语言知识库 | API 成本,数据传输,外部服务依赖 |
| 自定义稠密检索器 | 任意自定义嵌入模型 | 极致定制,支持特定领域模型 | 特定模型集成、非标准向量存储 | 需要更多开发工作,模型管理负担 |
稠密检索器是现代 RAG 系统的核心,它们使系统能够理解并响应语义相关的查询,即使查询中不包含确切的关键词。选择并优化合适的稠密检索器,是构建高效、智能 RAG 应用的关键一步。
2.5 检索优化(Retrieval Optimization):提升信息召回的精准度与效率
在 RAG(检索增强生成)系统中,检索器的性能是整个系统“智能”的关键瓶颈之一。即使下游的 LLM 再强大,如果检索器无法提供高质量、相关的上下文,那么 LLM 的回答也可能偏离主题、产生幻觉,甚至无法回答。因此,对检索过程进行优化是构建高性能 RAG 系统的核心任务。
检索优化涉及一系列策略和技术,旨在提高检索器的召回率(Recall)和精确率(Precision),同时保证检索效率。这些优化通常可以分为以下几个大类:
查询理解与扩展(Query Understanding and Expansion): 改进用户查询,使其更能匹配文档内容。
上下文压缩与重排(Context Compression and Re-ranking): 对检索到的初步结果进行精炼和排序,去除冗余信息,突出最相关内容。
索引结构优化: 优化文档存储和索引方式,以适应检索需求。
模型与算法选择: 选择更适合特定任务和数据的检索模型。
本节我们将重点探讨查询理解与扩展以及上下文压缩与重排。
2.5.1 查询理解与扩展(Query Understanding and Expansion):让检索器“听懂”你的意图
用户在提问时,其查询可能简短、模糊、使用非标准术语,或者与文档中的表达方式存在“词汇鸿沟”(lexical gap)和“语义鸿沟”(semantic gap)。例如,用户可能问“苹果的新手机”,而文档中提到的是“iPhone 15 Pro Max”。稀疏检索器可能难以匹配,而即使是稠密检索器也可能因为表述上的细微差异而错过最佳文档。
查询扩展的目的是通过改写、补充或生成与原始查询相关的变体,从而增加检索器找到相关文档的机会。这可以视为在检索发生之前,对用户查询进行“预处理”,使其更具表达力。
2.5.1.1 词汇级扩展
这是最基础的查询扩展形式,主要解决词汇匹配问题。
同义词扩展(Synonym Expansion):
原理: 将查询中的关键词替换或追加其同义词。例如,“汽车”可以扩展为“车辆”、“轿车”。
实现方式:
人工维护的同义词表: 简单直接,但维护成本高,覆盖范围有限。
基于词典/本体论: 利用 WordNet 等语言资源。
基于词嵌入/预训练模型: 通过计算词语的语义相似度来发现同义词或近义词。例如,在词向量空间中找到与查询词距离最近的词。
挑战: 误扩展(将非同义词误认为是同义词)可能引入噪音,降低精确率。
缩写与全称扩展(Abbreviation and Full Form Expansion):
原理: 将查询中的缩写扩展为全称,或将全称缩写化,以匹配文档中的不同表述。例如,“AI”扩展为“人工智能”,“NASA”扩展为“美国国家航空航天局”。
实现方式: 维护一个缩写-全称映射表,或使用命名实体识别(NER)等技术。
拼写校正与变体(Spelling Correction and Variants):
原理: 修正用户查询中的拼写错误,或考虑不同词形变化(如单复数、动词时态)。
实现方式: 基于编辑距离算法(Levenshtein 距离)、语言模型或搜索引擎的拼写校正库。
Haystack 中的词汇级扩展实现示例 (自定义节点)
Haystack 并没有直接内置一个通用的“同义词扩展器”节点,因为它通常需要特定领域的知识或复杂的语言资源。然而,我们可以轻松地构建一个自定义的 BaseComponent 来实现这一点。
from haystack import Pipeline # 导入 Pipeline 类
from haystack.nodes import Document, BaseComponent # 导入 Document 和 BaseComponent
from haystack.document_stores import InMemoryDocumentStore # 导入内存文档存储
from haystack.nodes import BM25Retriever # 导入 BM25Retriever
class SynonymExpander(BaseComponent): # 定义一个名为 SynonymExpander 的类,继承自 BaseComponent
"""
一个简单的自定义节点,用于扩展查询中的同义词。
在实际应用中,同义词表可以更复杂,或从外部加载。
"""
def __init__(self, synonym_map: dict = None): # 构造函数,接收一个同义词映射字典
self.synonym_map = synonym_map if synonym_map is not None else {
} # 初始化同义词映射,如果未提供则为空字典
def run(self, query: str): # 定义 run 方法,接收用户查询作为输入
expanded_queries = [query] # 初始化扩展查询列表,包含原始查询
query_words = query.lower().split() # 将查询转为小写并按空格分割成单词
for word in query_words: # 遍历查询中的每个单词
if word in self.synonym_map: # 如果单词在同义词映射中
synonyms = self.synonym_map[word] # 获取该单词的同义词列表
# 将同义词添加到扩展查询列表中
# 这里可以有更复杂的逻辑,例如生成新的查询字符串,而不是简单添加同义词
# 对于简单示例,我们直接在原始查询后添加同义词,或生成新的查询
for syn in synonyms: # 遍历同义词
if syn not in expanded_queries: # 如果同义词不在扩展查询列表中(避免重复)
# 创建一个包含同义词的新查询变体,例如 "original_query + synonym"
# 或者直接使用同义词本身作为潜在查询
# 为了演示,我们生成包含原查询和同义词的组合
expanded_queries.append(f"{
query} {
syn}") # 将原查询和同义词组合成一个新的扩展查询
expanded_queries.append(syn) # 将同义词本身也作为一个扩展查询
# 对扩展查询列表进行去重
unique_expanded_queries = list(set(expanded_queries)) # 对扩展查询列表进行去重,并转换为列表
print(f"原始查询: '{
query}'") # 打印原始查询
print(f"扩展查询: {
unique_expanded_queries}") # 打印扩展后的查询列表
# Haystack 节点通常返回字典,键可以是 "query"、"documents" 等
return {
"query": unique_expanded_queries} # 返回一个字典,包含扩展后的查询列表
# --- 准备 DocumentStore 和一些文档 ---
document_store = InMemoryDocumentStore(use_bm25=True) # 初始化内存文档存储,启用 BM25
docs = [
Document(content="苹果公司发布了新款 iPhone 15。"), # 文档 1
Document(content="iPhone 15 Pro Max 拥有强大的摄像头。"), # 文档 2
Document(content="史蒂夫·乔布斯创立了苹果电脑公司。"), # 文档 3
Document(content="最新的手机型号具有创新的功能。"), # 文档 4
Document(content="手机通常指移动电话。") # 文档 5
]
document_store.write_documents(docs) # 将文档写入文档存储
# --- 初始化 BM25 Retriever ---
retriever = BM25Retriever(document_store=document_store, top_k=3) # 初始化 BM25Retriever,检索前 3 个
# --- 定义同义词映射 ---
my_synonym_map = {
"苹果": ["apple", "苹果公司"], # 定义“苹果”的同义词
"iphone": ["手机", "智能手机"], # 定义“iphone”的同义词
"手机": ["移动电话", "电话"] # 定义“手机”的同义词
}
# --- 初始化 SynonymExpander 节点 ---
synonym_expander = SynonymExpander(synonym_map=my_synonym_map) # 初始化 SynonymExpander 节点
# --- 构建管道 ---
# 创建一个管道,包含同义词扩展器和 BM25 检索器
expansion_pipeline = Pipeline() # 创建一个管道
# SynonymExpander 节点将接收原始查询
expansion_pipeline.add_node(component=synonym_expander, name="SynonymExpander", inputs=["Query"]) # 添加 SynonymExpander 节点
# BM25Retriever 节点将接收 SynonymExpander 的输出,即扩展后的查询列表
# 它将为每个扩展查询运行检索,然后将结果合并(默认行为)
expansion_pipeline.add_node(component=retriever, name="BM25Retriever", inputs=["SynonymExpander"]) # 添加 BM25Retriever 节点
# --- 运行管道 ---
# 查询 1: 原始查询词汇可能不直接匹配
query_1 = "苹果新手机" # 用户查询
print("
--- 运行查询 1: '苹果新手机' ---") # 打印运行信息
results_1 = expansion_pipeline.run(query=query_1) # 运行管道
print("
检索结果 1:") # 打印检索结果标题
for i, doc in enumerate(results_1["documents"]): # 遍历检索到的文档
print(f" 文档 {
i+1} (分数: {
doc.score:.4f}): '{
doc.content}'") # 打印文档内容和分数
print("-" * 20) # 分隔线
# 查询 2: 另一个示例
query_2 = "什么手机是新的?" # 用户查询
print("
--- 运行查询 2: '什么手机是新的?' ---") # 打印运行信息
results_2 = expansion_pipeline.run(query=query_2) # 运行管道
print("
检索结果 2:") # 打印检索结果标题
for i, doc in enumerate(results_2["documents"]): # 遍历检索到的文档
print(f" 文档 {
i+1} (分数: {
doc.score:.4f}): '{
doc.content}'") # 打印文档内容和分数
print("-" * 20) # 分隔线
# 查询 3: 尝试一个不被扩展的词
query_3 = "人工智能发展" # 用户查询
print("
--- 运行查询 3: '人工智能发展' ---") # 打印运行信息
results_3 = expansion_pipeline.run(query=query_3) # 运行管道
print("
检索结果 3:") # 打印检索结果标题
for i, doc in enumerate(results_3["documents"]): # 遍历检索到的文档
print(f" 文档 {
i+1} (分数: {
doc.score:.4f}): '{
doc.content}'") # 打印文档内容和分数
print("-" * 20) # 分隔线
# 为了更清晰地展示 SynoymExpander 的输出,可以单独运行它
print("
--- 单独测试 SynonymExpander ---") # 打印单独测试信息
expander_output = synonym_expander.run(query="我的iphone很慢") # 运行 SynonymExpander
print(f"SynonymExpander 结果: {
expander_output}") # 打印结果
代码说明:
SynonymExpander(BaseComponent):我们创建了一个继承自 BaseComponent 的自定义节点。BaseComponent 是 Haystack 中所有节点的基础类。
__init__(self, synonym_map: dict = None):构造函数接收一个 synonym_map 字典,用于存储同义词映射关系。
run(self, query: str):这是 Haystack 节点的核心方法。当管道运行时,上游节点(这里是管道的起始点,即用户查询)的输出会作为 run 方法的输入。在这个例子中,它接收一个 query 字符串。
expanded_queries = [query]:初始化一个列表,将原始查询也包含在内。
for word in query_words::遍历查询中的每个单词。
if word in self.synonym_map::检查单词是否存在于预定义的同义词映射中。
expanded_queries.append(f"{query} {syn}") 和 expanded_queries.append(syn):这是扩展逻辑。为了演示,我们生成了两种类型的扩展查询:一种是“原始查询 + 同义词”,另一种是“纯同义词”。在实际应用中,你可能需要更复杂的策略,例如,用同义词替换原始查询中的特定词,或者生成多个完全不同的查询变体。
return {"query": unique_expanded_queries}:自定义节点必须返回一个字典,其键将作为下游节点的输入参数。这里我们返回一个名为 "query" 的键,其值为扩展后的查询列表。Haystack 的 BM25Retriever 节点设计为可以接收一个查询字符串或一个查询列表,如果接收列表,它会为列表中的每个查询执行检索,并自动合并结果。
expansion_pipeline.add_node(component=synonym_expander, name="SynonymExpander", inputs=["Query"]):将 SynonymExpander 节点添加到管道中,并指定它接收来自“Query”的输入,即用户输入的原始查询。
expansion_pipeline.add_node(component=retriever, name="BM25Retriever", inputs=["SynonymExpander"]):将 BM25Retriever 节点添加到管道中,并指定它接收来自 SynonymExpander 的输入。因为 SynonymExpander 返回的字典中包含键 "query",所以这个 "query" 键的值(即 unique_expanded_queries 列表)会自动作为 BM25Retriever 的查询输入。
2.5.1.2 语义级扩展:利用 LLM 增强查询表达
随着大型语言模型(LLMs)的兴起,查询扩展不再局限于简单的词汇替换,而是可以深入到语义层面,生成更具上下文意义和意图的查询变体。
基于 LLM 的查询重写/生成(LLM-based Query Rewriting/Generation):
原理: 使用 LLM 根据原始用户查询,生成一个或多个语义上更清晰、更丰富、更适合检索的查询。例如,对于模糊的查询“关于那个电影的”,LLM 可能会重写为“请提供关于电影《泰坦尼克号》的演员表和导演信息”。
优点: 能够处理更复杂的语义关系,克服词汇鸿沟,生成更自然的查询。
挑战: 需要高质量的 LLM,并且 LLM 的推理成本可能较高,生成质量参差不齐。
Haystack 中的实现: 可以使用 PromptNode 或 Generator 结合自定义的提示词(Prompt)来实现。
假设文档嵌入(HyDE: Hypothetical Document Embeddings):
原理: HyDE 的核心思想是,对于一个用户查询,如果直接对查询进行嵌入搜索效果不佳,那么可以先让一个 LLM 根据这个查询“想象”并生成一个假设性的、相关的文档。然后,对这个假设文档进行嵌入,并使用这个假设文档的嵌入向量去搜索真实的文档。
为什么有效: 假设文档往往比简短的用户查询包含更多的语义信息和上下文细节,因此其嵌入向量能更好地捕获相关文档的语义空间。即使这个假设文档不是完全准确的,其生成过程也迫使 LLM 深入理解查询意图并生成相关信息,从而为检索提供了更好的语义锚点。
流程:
用户提交查询 (Q)。
使用一个生成型 LLM(如 PromptNode)根据 (Q) 生成一个假设文档 (D_{hyp})。
使用一个 DocumentEmbedder 或 EmbeddingRetriever 对 (D_{hyp}) 生成嵌入向量 (E_{hyp})。
使用 (E_{hyp}) 在向量数据库中进行相似性搜索,召回与 (D_{hyp}) 语义最接近的真实文档 (D_1, D_2, dots)。
这些召回的真实文档被送入 RAG 管道的下一步(例如重排器或生成器)。
优点: 显著提高稠密检索器的性能,尤其是在查询简短或模糊时。
挑战: 引入了 LLM 的推理延迟和成本;生成的假设文档质量直接影响检索效果。
Haystack 中的实现: 结合 PromptNode(用于生成假设文档)和 EmbeddingRetriever(用于搜索)。
代码示例:在 Haystack 中实现 HyDE 策略
此示例将演示如何使用 PromptNode 生成假设文档,然后使用 EmbeddingRetriever 和该假设文档的嵌入进行检索。
import os # 导入操作系统模块
from haystack.document_stores import InMemoryDocumentStore # 导入内存文档存储
from haystack.nodes import EmbeddingRetriever, PromptNode, PromptTemplate, Document # 导入 EmbeddingRetriever, PromptNode, PromptTemplate, Document
from haystack.pipelines import Pipeline # 导入 Pipeline 类
from haystack.schema import Document # 导入 Document 类
# --- 配置参数 ---
EMBEDDING_MODEL = "sentence-transformers/all-MiniLM-L6-v2" # Sentence-Transformers 嵌入模型
EMBEDDING_DIM = 384 # all-MiniLM-L6-v2 的嵌入维度
# 这里我们使用一个本地的 LLM 模型或者一个 API Key (例如 OpenAI)
# 为了演示,我们将使用一个模拟的 PromptNode,因为实际的 LLM 调用需要配置API key
# 或者下载本地模型,这会增加示例的复杂性。
# 如果你想使用真实的 LLM,你需要配置相应的 API_KEY 或下载模型。
# 例如:
# from haystack.nodes import OpenAIAnswerGenerator
# os.environ["OPENAI_API_KEY"] = "YOUR_OPENAI_API_KEY"
# --- 1. 初始化 DocumentStore ---
# 仍然使用 InMemoryDocumentStore,但主要用于向量搜索
document_store_hyde = InMemoryDocumentStore(use_embedding_storage=True, embedding_dim=EMBEDDING_DIM) # 初始化内存文档存储,启用嵌入存储
print("InMemoryDocumentStore 已初始化,用于 HyDE 检索。") # 打印初始化信息
# --- 2. 准备文档并进行索引(嵌入并写入 DocumentStore)---
docs_for_hyde = [
Document(content="苹果公司最新发布了 iPhone 15 系列手机,包括 iPhone 15 Pro Max,搭载了 A17 仿生芯片,性能大幅提升。"), # 文档 1
Document(content="最新研究表明,通过优化睡眠习惯可以显著提高大脑认知功能。"), # 文档 2
Document(content="马斯克旗下的 SpaceX 成功发射了星舰(Starship)原型机,标志着人类太空探索的新里程碑。"), # 文档 3
Document(content="人工智能(AI)在各个领域都取得了突破性进展,尤其是大型语言模型(LLMs)的应用日益广泛。"), # 文档 4
Document(content="传统 RAG 系统在处理歧义或短查询时可能面临挑战,而 HyDE 是一种有效的增强策略。") # 文档 5
]
# 对文档进行嵌入并写入 DocumentStore
# EmbeddingRetriever 也可以作为 DocumentEmbedder 使用
retriever_hyde_indexing = EmbeddingRetriever(
document_store=document_store_hyde, # 传入文档存储
embedding_model=EMBEDDING_MODEL, # 传入嵌入模型
model_format="sentence_transformers", # 模型格式
max_seq_len=256 # 最大序列长度
)
print(f"
EmbeddingRetriever 已初始化,用于文档索引,使用模型: {
EMBEDDING_MODEL}。") # 打印嵌入器初始化信息
# 检查 Document 是否有 content 属性,并生成嵌入
# 注意:在 Haystack 1.x 中,直接调用 retrieve() 会进行查询嵌入和搜索。
# 如果要对文档进行嵌入,通常需要一个 DocumentEmbedder 节点或通过 DocumentStore 的 write_documents 方法
# 在此示例中,为了简化,我们将手动生成嵌入并写入。
from sentence_transformers import SentenceTransformer # 从 sentence_transformers 库导入 SentenceTransformer
# 加载 Sentence Transformer 模型
model = SentenceTransformer(EMBEDDING_MODEL) # 加载预训练的 Sentence Transformer 模型
print(f"加载 SentenceTransformer 模型: {
EMBEDDING_MODEL}") # 打印加载信息
# 为每个文档生成嵌入
for doc in docs_for_hyde: # 遍历文档列表
doc.embedding = model.encode(doc.content) # 使用模型编码文档内容,生成嵌入向量
print(f"文档 '{
doc.content[:30]}...' 的嵌入已生成。") # 打印生成嵌入的信息
# 写入带有嵌入的文档到 DocumentStore
document_store_hyde.write_documents(docs_for_hyde) # 将带有嵌入的文档写入文档存储
print(f"
已写入 {
len(docs_for_hyde)} 个带嵌入的文档到 DocumentStore。") # 打印写入文档数量
# --- 3. 初始化 HyDE 所需的组件 ---
# PromptNode 用于生成假设文档
# 真实场景需要配置 API key 或使用本地模型
# 这里使用一个模拟的 PromptNode,它将简单地在查询前加上 "假设文档: "
class MockPromptNode(PromptNode): # 定义一个名为 MockPromptNode 的类,继承自 PromptNode
def __init__(self, *args, **kwargs): # 构造函数
super().__init__(*args, **kwargs) # 调用父类构造函数
print("MockPromptNode 已初始化。") # 打印初始化信息
def run(self, query: str = None, documents: list[Document] = None, top_k: int = None): # 定义 run 方法
if query: # 如果提供了查询
hypothetical_doc_content = f"假设文档: 用户查询 '{
query}' 相关的详细信息。这是一个关于 {
query} 的完整段落,它应该包含与查询相关的关键概念、实体和上下文,以便进行高效的语义检索。例如,如果查询是'苹果新手机',假设文档会详细描述iPhone 15的功能和发布信息。" # 构建假设文档内容
print(f"MockPromptNode 生成假设文档内容: '{
hypothetical_doc_content[:100]}...'") # 打印生成的假设文档内容
return {
"query": hypothetical_doc_content} # 返回一个字典,包含假设文档内容作为查询
return {
"query": None} # 如果没有查询,返回 None
# 用于生成假设文档的提示模板 (如果使用真实 LLM)
# hyde_prompt = PromptTemplate(
# prompt="请根据以下问题,生成一个可能包含答案的详细假设文档,该文档应该能够用于后续的语义检索。
问题: {query}
假设文档:",
# output_variable="query" # 指定输出变量名,以便后续节点可以接收
# )
# 为了演示,我们直接使用 MockPromptNode
hyde_generator = MockPromptNode(model_name_or_path="mock_model", default_prompt_template=None) # 初始化 MockPromptNode
# EmbeddingRetriever 用于搜索(Query Embedder 部分将处理假设文档的嵌入)
retriever_hyde = EmbeddingRetriever(
document_store=document_store_hyde, # 传入文档存储
embedding_model=EMBEDDING_MODEL, # 传入嵌入模型
model_format="sentence_transformers", # 模型格式
top_k=3, # 检索最相关的 3 个文档
similarity_metric="cosine" # 使用余弦相似度
)
print("EmbeddingRetriever 已初始化,用于 HyDE 检索。") # 打印初始化信息
# --- 4. 构建 HyDE 管道 ---
hyde_pipeline = Pipeline() # 创建 HyDE 管道
# 1. 用户查询输入到 hyde_generator (MockPromptNode)
# hyde_generator 的输出 (假设文档内容) 会传递给 EmbeddingRetriever 的 query 输入
hyde_pipeline.add_node(component=hyde_generator, name="HyDEGenerator", inputs=["Query"]) # 添加 HyDEGenerator 节点
# 2. EmbeddingRetriever 接收 HyDEGenerator 的输出(即假设文档内容)作为查询,并进行语义搜索
# 注意:EmbeddingRetriever 会自动将接收到的 "query" 字符串进行嵌入,然后与 DocumentStore 中的文档嵌入进行比较。
hyde_pipeline.add_node(component=retriever_hyde, name="HyDERetriever", inputs=["HyDEGenerator"]) # 添加 HyDERetriever 节点
print("
HyDE 检索管道已构建。") # 打印管道构建信息
# --- 5. 运行 HyDE 管道 ---
query_hyde_1 = "最新苹果手机有哪些亮点?" # 用户查询,偏向语义
print(f"
--- 运行 HyDE 检索管道 ---") # 打印运行信息
print(f"原始查询: '{
query_hyde_1}'") # 打印原始查询
results_hyde_1 = hyde_pipeline.run(query=query_hyde_1) # 运行 HyDE 管道
retrieved_docs_hyde_1 = results_hyde_1["documents"] # 获取检索到的文档列表
print("
HyDE 检索到的文档:") # 打印检索到的文档标题
if retrieved_docs_hyde_1:
for i, doc in enumerate(retrieved_docs_hyde_1): # 遍历检索到的文档
print(f" 文档 {
i+1} (分数: {
doc.score:.4f}): '{
doc.content[:80]}...'") # 打印文档内容和分数
print(f" 原始元数据: {
doc.meta}") # 打印原始元数据
print("-" * 20) # 分隔线
else:
print("未找到相关文档。") # 未找到文档提示
# 另一个 HyDE 查询示例
query_hyde_2 = "空间探索最新进展" # 用户查询
print(f"
原始查询: '{
query_hyde_2}'") # 打印原始查询
results_hyde_2 = hyde_pipeline.run(query=query_hyde_2) # 运行 HyDE 管道
retrieved_docs_hyde_2 = results_hyde_2["documents"] # 获取检索到的文档列表
print("
HyDE 检索到的文档:") # 打印检索到的文档标题
if retrieved_docs_hyde_2:
for i, doc in enumerate(retrieved_docs_hyde_2): # 遍历检索到的文档
print(f" 文档 {
i+1} (分数: {
doc.score:.4f}): '{
doc.content[:80]}...'") # 打印文档内容和分数
print(f" 原始元数据: {
doc.meta}") # 打印原始元数据
print("-" * 20) # 分隔线
else:
print("未找到相关文档。") # 未找到文档提示
# 一个不那么相关的查询
query_hyde_3 = "如何改善记忆力?" # 用户查询
print(f"
原始查询: '{
query_hyde_3}'") # 打印原始查询
results_hyde_3 = hyde_pipeline.run(query=query_hyde_3) # 运行 HyDE 管道
retrieved_docs_hyde_3 = results_hyde_3["documents"] # 获取检索到的文档列表
print("
HyDE 检索到的文档:") # 打印检索到的文档标题
if retrieved_docs_hyde_3:
for i, doc in enumerate(retrieved_docs_hyde_3): # 遍历检索到的文档
print(f" 文档 {
i+1} (分数: {
doc.score:.4f}): '{
doc.content[:80]}...'") # 打印文档内容和分数
print(f" 原始元数据: {
doc.meta}") # 打印原始元数据
print("-" * 20) # 分隔线
else:
print("未找到相关文档。") # 未找到文档提示
代码说明:
模拟 PromptNode: 在实际应用中,PromptNode 会连接到 OpenAI、Hugging Face 或本地部署的 LLM。由于设置这些需要额外的步骤(API Key 或模型下载),为了代码的立即运行性,这里使用了 MockPromptNode 来模拟 LLM 生成假设文档的行为。在真实的 HyDE 场景中,MockPromptNode 的 run 方法会调用一个真正的 LLM 来生成更具语义深度的假设文档。
文档嵌入: 在 HyDE 流程中,文档的嵌入是预先计算并存储在 DocumentStore 中的。这里我们手动使用 SentenceTransformer 模型对文档进行编码并赋值给 doc.embedding,然后写入 document_store_hyde。
HyDEGenerator (即 MockPromptNode): 这个节点接收用户查询,并根据该查询生成一个“假设文档”的文本内容。它返回一个字典,其中键 "query" 包含生成的假设文档文本。
HyDERetriever (即 EmbeddingRetriever): 这个节点接收来自 HyDEGenerator 的输出(即假设文档文本)作为其查询。EmbeddingRetriever 内部会自动对这个假设文档文本进行嵌入(使用其 embedding_model),然后用这个嵌入向量去 document_store_hyde 中搜索最相似的真实文档。
管道流程: HyDEGenerator 扮演了“查询增强”的角色,它将原始的简短查询转化为一个更长的、语义丰富的假设文档。随后,HyDERetriever 不再直接搜索原始查询,而是搜索这个“增强版”的假设文档,从而提高召回与原始查询意图更匹配但词汇不完全重叠的文档的能力。
HyDE 是一个非常强大的查询扩展技术,尤其适用于处理短查询、歧义查询或那些需要更深层语义理解的查询。它的有效性很大程度上依赖于用于生成假设文档的 LLM 的质量以及用于文档嵌入的模型的质量。
2.5 检索优化(Retrieval Optimization):提升信息召回的精准度与效率
我们在上一节讨论了查询理解与扩展,这属于检索过程的“前端”优化。现在,我们将把目光转向检索过程的“后端”优化:上下文压缩与重排(Context Compression and Re-ranking)。
在 RAG 系统中,检索器通常会返回一个包含 top_k 个文档的列表,这些文档被认为是与用户查询最相关的。然而,即使是最好的检索器也可能:
召回不完全相关的文档: 在 top_k 结果中,可能有一些文档的相关性较低,或者包含大量冗余信息。
存在信息冗余: 多个召回的文档可能包含相同或高度重叠的信息,这会增加 LLM 处理的负担。
排序不精确: 初始检索器的得分可能无法完全准确地反映文档与查询之间的细微相关性差异,尤其是对于那些语义复杂或需要深层理解的查询。
上下文长度限制: 大多数 LLM 对输入上下文的长度有严格限制。如果检索到的文档过多或过长,可能会超出 LLM 的上下文窗口,导致重要信息被截断。
上下文压缩与重排的目的,正是为了解决这些问题,确保发送给 LLM 的上下文信息是最相关、最精炼、最准确的。
2.5.2 上下文压缩与重排(Context Compression and Re-ranking):精炼与排序的关键步骤
上下文压缩与重排是在初始检索之后、LLM 生成答案之前进行的。它充当了一个“筛选器”和“精炼器”,进一步提高 RAG 系统的效率和答案质量。
2.5.2.1 交叉编码器重排(Cross-Encoder Re-ranking):深度语义匹配的艺术
在之前的稠密检索器中,我们介绍了双编码器(Dual-Encoder)模型(如 Sentence-BERT),它通过独立编码查询和文档,然后计算它们的嵌入向量相似度来完成检索。这种架构的优点是计算效率高,可以预先计算所有文档的嵌入,从而实现快速的近似最近邻搜索。然而,它的缺点是无法捕捉查询和文档之间的细粒度交互信息。
**交叉编码器(Cross-Encoder)**则提供了更深层次的语义匹配能力。
原理:
交叉编码器模型不是独立编码查询和文档,而是将查询和文档拼接成一个单一的输入序列。
这个拼接后的序列被送入一个 Transformer 编码器(通常是 BERT、RoBERTa 等预训练语言模型)中。
Transformer 编码器能够对查询和文档之间的所有词对进行交互建模,捕获它们之间的复杂语义关系和依赖性。
最后,通过一个分类层(通常是在 [CLS] 标记的输出之上),模型输出一个相关性分数,表示查询和文档对的匹配程度。这个分数通常介于 0 到 1 之间,分数越高表示相关性越强。
优势:
高精度相关性判断: 由于模型能够深度交互查询和文档的每个部分,交叉编码器在判断相关性方面通常比双编码器更准确,能够捕捉到双编码器可能错过的细微语义差异。
更好的上下文理解: 对于那些需要更深层语义理解的查询,交叉编码器能够更准确地识别文档中的相关片段。
劣势:
计算成本高: 对于每个查询,都需要将查询与每一个待重排的文档进行拼接和编码,这意味着计算复杂度与待重排文档数量呈线性关系(N次编码)。这导致其吞吐量远低于双编码器,不适合作为初召回阶段的检索器(因为需要对海量文档进行计算)。
不适合大规模初召回: 由于计算成本高昂,交叉编码器通常不能直接用于从整个文档库中检索,而是在一个较小的、由初召回检索器(如 BM25 或双编码器)预过滤出的文档子集上进行重排。
延迟: 每次查询都需要实时进行多达 top_k 次的 Transformer 推理,这会增加系统的端到端延迟。
应用场景: 交叉编码器是 RAG 系统中理想的重排器(Re-ranker)。它接收初召回检索器返回的少量(例如 10-100 个)文档,然后对这些文档进行精确的相关性排序,将最相关的文档排在最前面,并去除或降权不相关的文档。
Haystack 中的交叉编码器重排实现:CrossEncoderRanker 节点
Haystack 提供了 CrossEncoderRanker 节点,专门用于实现交叉编码器重排。它可以无缝地集成到 RAG 管道中,位于初召回检索器之后和生成器之前。
CrossEncoderRanker 节点参数:
model_name_or_path: 交叉编码器模型的名称或路径(通常是 Hugging Face 模型,如 "cross-encoder/ms-marco-MiniLM-L-6-v2")。
top_k: 重排后保留的文档数量。通常会比初召回的 top_k 小,以进一步精简上下文。
device: 指定运行模型的设备("cpu" 或 "cuda")。
use_gpu: 是否使用 GPU。
batch_size: 批量处理文档的数量。
max_length: 模型输入的最大序列长度。
model_kwargs: 传递给 Hugging Face AutoModelForSequenceClassification 的额外参数。
tokenizer_kwargs: 传递给 Hugging Face AutoTokenizer 的额外参数。
代码示例:在 Haystack 中集成 CrossEncoderRanker 进行重排
此示例将构建一个 RAG 管道,其中包含一个初召回检索器(EmbeddingRetriever),随后是 CrossEncoderRanker 进行重排,最后将精炼后的文档送入一个模拟的 AnswerGenerator。
import os # 导入操作系统模块
from haystack.document_stores import InMemoryDocumentStore # 导入内存文档存储
from haystack.nodes import EmbeddingRetriever, CrossEncoderRanker, Document # 导入 EmbeddingRetriever, CrossEncoderRanker, Document
from haystack.pipelines import Pipeline # 导入 Pipeline 类
from haystack.schema import Document # 导入 Document 类
# --- 配置参数 ---
# 嵌入模型,用于 EmbeddingRetriever
EMBEDDING_MODEL = "sentence-transformers/all-MiniLM-L6-v2" # Sentence-Transformers 嵌入模型
EMBEDDING_DIM = 384 # all-MiniLM-L6-v2 的嵌入维度
# 交叉编码器模型,用于 CrossEncoderRanker
# 这是一个在 MS MARCO 数据集上训练的轻量级交叉编码器模型,适用于重排
CROSS_ENCODER_MODEL = "cross-encoder/ms-marco-MiniLM-L-6-v2" # 交叉编码器模型
# --- 1. 初始化 DocumentStore ---
document_store_rerank = InMemoryDocumentStore(use_embedding_storage=True, embedding_dim=EMBEDDING_DIM) # 初始化内存文档存储,启用嵌入存储
print("InMemoryDocumentStore 已初始化,用于重排示例。") # 打印初始化信息
# --- 2. 准备文档并进行索引 ---
docs_for_rerank = [
Document(content="苹果公司发布了新款 iPhone 15 系列手机,其创新功能备受已关注。"), # 文档 1
Document(content="iPhone 15 Pro Max 搭载了全新的 A17 仿生芯片,性能达到了前所未有的水平。"), # 文档 2
Document(content="最新的研究表明,地中海饮食有助于改善心血管健康。"), # 文档 3
Document(content="智能手机市场竞争激烈,各大厂商都在不断推出新产品。"), # 文档 4
Document(content="Apple Watch Series 9 引入了更亮的显示屏和新的手势控制功能。"), # 文档 5
Document(content="自然语言处理是人工智能领域的一个重要研究方向。"), # 文档 6
Document(content="谷歌 Pixel 系列手机以其卓越的相机性能而闻名。"), # 文档 7
Document(content="MacBook Pro 是一款强大的笔记本电脑,适合专业用户。") # 文档 8
]
# 为了简化,我们直接使用 SentenceTransformer 对文档进行嵌入,然后写入 DocumentStore
from sentence_transformers import SentenceTransformer # 从 sentence_transformers 库导入 SentenceTransformer
model_embedder = SentenceTransformer(EMBEDDING_MODEL) # 加载嵌入模型
for doc in docs_for_rerank: # 遍历文档列表
doc.embedding = model_embedder.encode(doc.content) # 为文档内容生成嵌入向量
print(f"已为 {
len(docs_for_rerank)} 个文档生成嵌入。") # 打印生成嵌入的文档数量
document_store_rerank.write_documents(docs_for_rerank) # 将带有嵌入的文档写入文档存储
print(f"
已写入 {
len(docs_for_rerank)} 个文档到 DocumentStore。") # 打印写入文档数量
# --- 3. 初始化检索器和重排器 ---
# 初召回检索器 (EmbeddingRetriever)
# top_k=5 意味着它会召回前 5 个语义最相似的文档,这些文档将作为重排器的输入。
initial_retriever = EmbeddingRetriever(
document_store=document_store_rerank, # 传入文档存储
embedding_model=EMBEDDING_MODEL, # 传入嵌入模型
model_format="sentence_transformers", # 模型格式
top_k=5, # 初步检索前 5 个文档
similarity_metric="cosine" # 使用余弦相似度
)
print("EmbeddingRetriever (初召回) 已初始化。") # 打印初始化信息
# 交叉编码器重排器 (CrossEncoderRanker)
# top_k=2 意味着它会从 EmbeddingRetriever 召回的 5 个文档中,
# 再次根据交叉编码器模型进行重排,并只返回其中分数最高的 2 个文档。
cross_encoder_ranker = CrossEncoderRanker(
model_name_or_path=CROSS_ENCODER_MODEL, # 指定交叉编码器模型
top_k=2, # 重排后保留前 2 个文档
device="cpu", # 在 CPU 上运行 (如果需要 GPU,请设置为 "cuda")
batch_size=16 # 批量大小
)
print(f"CrossEncoderRanker 已初始化,使用模型: {
CROSS_ENCODER_MODEL}。") # 打印初始化信息
# --- 4. 构建 RAG 管道 ---
rag_pipeline = Pipeline() # 创建 RAG 管道
# 1. 添加初召回检索器
rag_pipeline.add_node(component=initial_retriever, name="InitialRetriever", inputs=["Query"]) # 添加 InitialRetriever 节点
# 2. 添加交叉编码器重排器
# 它接收来自 InitialRetriever 的文档列表,并对它们进行重排。
rag_pipeline.add_node(component=cross_encoder_ranker, name="CrossEncoderReranker", inputs=["InitialRetriever"]) # 添加 CrossEncoderReranker 节点
# (可选) 模拟一个 LLM 生成器,用于接收重排后的文档
# 在真实的 RAG 管道中,这里会是一个 PromptNode 或 AnswerGenerator
class MockAnswerGenerator(BaseComponent): # 定义一个名为 MockAnswerGenerator 的类,继承自 BaseComponent
"""
一个简单的模拟 LLM 生成器,打印接收到的文档内容。
"""
def run(self, query: str = None, documents: list[Document] = None, **kwargs): # 定义 run 方法
if documents: # 如果提供了文档
print(f"
--- MockAnswerGenerator 接收到以下 {
len(documents)} 个重排后的文档进行生成 ---") # 打印接收到的文档数量
for i, doc in enumerate(documents): # 遍历文档
print(f" [{
i+1}] 文档内容 (重排后分数: {
doc.score:.4f}): {
doc.content[:100]}...") # 打印文档内容和分数
print("--- 模拟答案生成完成 ---") # 打印模拟完成信息
# 在实际情况中,这里会调用 LLM API 生成答案
return {
"answers": [f"这是一个基于重排文档的模拟答案:{
documents[0].content[:50]}..."]} # 返回模拟答案
return {
"answers": ["未找到足够上下文生成答案。"]} # 未找到文档的模拟答案
mock_generator = MockAnswerGenerator() # 初始化模拟答案生成器
rag_pipeline.add_node(component=mock_generator, name="MockAnswerGenerator", inputs=["CrossEncoderReranker"]) # 添加 MockAnswerGenerator 节点
print("
RAG 管道 (检索 + 重排 + 模拟生成) 已构建。") # 打印管道构建信息
# --- 5. 运行 RAG 管道 ---
query_rerank_1 = "iPhone 15 最新的芯片是什么?" # 用户查询
print(f"
--- 运行 RAG 管道 ---") # 打印运行信息
print(f"查询: '{
query_rerank_1}'") # 打印查询
results_rerank_1 = rag_pipeline.run(query=query_rerank_1) # 运行 RAG 管道
print("
最终 RAG 管道输出:") # 打印最终输出标题
print(results_rerank_1["answers"][0]) # 打印模拟答案
print("
--- 查看每个阶段的文档 ---") # 打印查看阶段文档信息
# 为了更好地理解,我们可以分别运行初召回和重排
# 注意:在实际管道中,文档是从一个节点流向下一个节点。
# 这里是为了演示每个阶段的输出。
# 初召回阶段 (只运行到 InitialRetriever)
initial_results = initial_retriever.run(query=query_rerank_1) # 运行 InitialRetriever
initial_docs = initial_results["documents"] # 获取文档列表
print(f"
--- InitialRetriever 召回的 {
len(initial_docs)} 个文档 (top_k=5) ---") # 打印 InitialRetriever 召回的文档数量
for i, doc in enumerate(initial_docs): # 遍历文档
print(f" [{
i+1}] (分数: {
doc.score:.4f}): '{
doc.content[:80]}...'") # 打印文档内容和分数
# 重排阶段 (将初召回的文档手动传入重排器)
# 在实际管道中,这一步是自动发生的。
reranked_results = cross_encoder_ranker.run(query=query_rerank_1, documents=initial_docs) # 运行 CrossEncoderRanker
reranked_docs = reranked_results["documents"] # 获取重排后的文档列表
print(f"
--- CrossEncoderReranker 重排后保留的 {
len(reranked_docs)} 个文档 (top_k=2) ---") # 打印 CrossEncoderReranker 重排后保留的文档数量
for i, doc in enumerate(reranked_docs): # 遍历文档
print(f" [{
i+1}] (重排分数: {
doc.score:.4f}): '{
doc.content[:80]}...'") # 打印文档内容和分数
代码说明:
EMBEDDING_MODEL 和 CROSS_ENCODER_MODEL: 我们分别定义了用于稠密检索器和交叉编码器重排器的模型。EmbeddingRetriever 使用 all-MiniLM-L6-v2 来生成文档嵌入和查询嵌入(用于初始召回),而 CrossEncoderRanker 使用 ms-marco-MiniLM-L-6-v2 进行查询-文档对的联合编码和相关性评分。
文档嵌入: 同样,为了简化,我们手动使用 SentenceTransformer 对 docs_for_rerank 中的文档内容进行编码,并将嵌入结果存储在 doc.embedding 中,然后写入 InMemoryDocumentStore。
InitialRetriever: 这是一个 EmbeddingRetriever,负责执行初步的语义召回。它配置 top_k=5,意味着它会从文档库中召回语义上最接近用户查询的 5 个文档。这 5 个文档将作为 CrossEncoderRanker 的输入。
CrossEncoderRanker: 这是重排器的核心。
model_name_or_path=CROSS_ENCODER_MODEL: 指定了用于重排的交叉编码器模型。这个模型会接收查询和每个文档的拼接作为输入,并输出一个相关性分数。
top_k=2: 表示 CrossEncoderRanker 会从上游(InitialRetriever)传来的 5 个文档中,选择经过交叉编码器评分后分数最高的 2 个文档作为最终的上下文,并传递给下游节点。这实现了上下文的进一步精简和优化。
MockAnswerGenerator: 这是一个自定义的模拟生成器,用于演示重排后的文档如何被 LLM 接收。在实际 RAG 系统中,这里会替换为 PromptNode 或 AnswerGenerator,它会使用重排后的文档作为上下文来生成答案。
管道构建:
rag_pipeline.add_node(component=initial_retriever, name="InitialRetriever", inputs=["Query"]): 将 InitialRetriever 添加到管道,它接收原始查询。
rag_pipeline.add_node(component=cross_encoder_ranker, name="CrossEncoderReranker", inputs=["InitialRetriever"]): 将 CrossEncoderReranker 添加到管道,它的输入是 InitialRetriever 的输出(即初步召回的 5 个文档)。
rag_pipeline.add_node(component=mock_generator, name="MockAnswerGenerator", inputs=["CrossEncoderReranker"]): 将 MockAnswerGenerator 添加到管道,它的输入是 CrossEncoderReranker 的输出(即重排后保留的 2 个文档)。
运行与验证: 运行 rag_pipeline,我们可以看到最终传递给 MockAnswerGenerator 的文档是经过重排和筛选后的,并且它们的 score 属性反映了交叉编码器给出的相关性分数。通过分别运行 InitialRetriever 和 CrossEncoderRanker 的 run 方法,我们可以清晰地看到文档在每个阶段的排序和数量变化,从而理解重排器的作用。
交叉编码器重排是提高 RAG 系统答案质量的关键环节,它通过引入更复杂的语义交互模型,确保只有最相关、最高质量的文档被传递给 LLM,从而避免了上下文的冗余和无关信息,并提升了答案的精确性和相关性。
2.5.2.2 信息密度优化:精简上下文的艺术
除了通过重排来优化文档的顺序和数量,还可以进一步优化每个文档的信息密度。这意味着从召回的文档中提取出最核心、最相关的句子或段落,而不是将整个文档都发送给 LLM。这种策略在处理长文档时尤为重要,可以显著减少 LLM 的输入令牌数,降低成本,提高处理速度,并减少 LLM 因上下文过长而“迷失”的可能性。
信息密度优化(也称为上下文压缩或文档截断)的策略包括:
句子或段落级提取:
原理: 不返回整个文档,而是返回文档中与查询最相关的特定句子或段落。这通常涉及到在文档内部执行一个二次检索或评分。
实现方式:
基于相似度: 对文档中的每个句子或段落与查询进行语义相似度计算(例如使用双编码器),然后提取得分最高的几个句子或段落。
基于 LLM 提取: 使用 LLM 根据查询和整个文档内容,提取出最相关的摘要或关键信息。
关键词密度: 基于查询关键词在文档各部分出现的密度来选择。
Haystack 中的实现: Haystack 目前没有直接内置一个通用的“句子提取器”节点,但这可以通过组合现有的节点或创建自定义节点来实现。例如,可以使用 TextSplitter 将文档分割成句子,然后对每个句子运行一个轻量级的 EmbeddingRetriever 或 CrossEncoderRanker 来找到最相关的句子。
滑动窗口与最大通量(Sliding Window and Max Passage):
原理: 对于特别长的文档,与其将其整个文本都嵌入,不如使用滑动窗口的方式,将文档分割成更小的、重叠的段落。然后,对每个段落进行嵌入和检索。在重排阶段,可以找到包含答案的最可能通道(passage)。
Haystack 中的实现: TextSplitter 节点可以用于将大文档分割成更小的块 (chunk_size 和 chunk_overlap)。在索引阶段,这些块会独立地进行嵌入和存储。在检索和重排时,我们会召回相关的块。
查询相关性摘要(Query-Biased Summarization):
原理: 使用一个文本摘要模型(可以是抽取式或生成式)根据用户查询对召回的文档进行摘要。摘要模型会专注于提取文档中与查询直接相关的信息。
优点: 能够生成高度精炼的、直接回答查询的上下文,避免冗余。
挑战: 需要高质量的摘要模型,摘要的准确性和信息完整性是关键。生成式摘要模型可能引入幻觉。
Haystack 中的实现: 可以使用 PromptNode 结合强大的 LLM 来实现生成式摘要。例如,提供一个包含“根据以下文档和问题,请生成一个简洁的摘要,突出与问题相关的信息”的提示词。
代码示例:在 Haystack 中模拟信息密度优化(通过句子级重排)
虽然 Haystack 没有直接的“句子提取器”节点,但我们可以模拟其效果:先将文档分割成句子,然后用一个轻量级的方式(例如,再次使用 CrossEncoderRanker)来为每个句子评分,选择最相关的句子。
import os # 导入操作系统模块
from haystack.document_stores import InMemoryDocumentStore # 导入内存文档存储
from haystack.nodes import EmbeddingRetriever, CrossEncoderRanker, TextSplitter, Document, BaseComponent # 导入 Haystack 相关的节点和类
from haystack.pipelines import Pipeline # 导入 Pipeline 类
from haystack.schema import Document # 导入 Document 类
import re # 导入正则表达式模块
# --- 配置参数 ---
EMBEDDING_MODEL = "sentence-transformers/all-MiniLM-L6-v2" # Sentence-Transformers 嵌入模型
EMBEDDING_DIM = 384 # all-MiniLM-L6-v2 的嵌入维度
CROSS_ENCODER_MODEL = "cross-encoder/ms-marco-MiniLM-L-6-v2" # 交叉编码器模型
# --- 1. 初始化 DocumentStore ---
document_store_info_density = InMemoryDocumentStore(use_embedding_storage=True, embedding_dim=EMBEDDING_DIM) # 初始化内存文档存储
print("InMemoryDocumentStore 已初始化,用于信息密度优化示例。") # 打印初始化信息
# --- 2. 准备一个较长的文档进行分割和索引 ---
long_document_content = """
苹果公司于2023年9月12日发布了全新的 iPhone 15 系列。其中包括 iPhone 15、iPhone 15 Plus、iPhone 15 Pro 和 iPhone 15 Pro Max。
iPhone 15 Pro 和 Pro Max 搭载了全新的 A17 Pro 仿生芯片,这是业界首款采用 3 纳米工艺的芯片,提供了令人难以置信的性能提升,尤其在图形处理和机器学习方面。
普通版 iPhone 15 和 15 Plus 则升级到了 A16 仿生芯片,该芯片曾用于上一代 iPhone 14 Pro 机型。
所有 iPhone 15 型号都配备了灵动岛(Dynamic Island)功能,这一交互式区域取代了传统的刘海屏,提供了更直观的通知和活动显示。
USB-C 接口的引入是今年 iPhone 15 系列的另一个重要更新。这意味着用户可以使用一根线缆为 iPhone、iPad 和 Mac 充电。
iPhone 15 Pro Max 还独占了更长的光学变焦能力,配备了创新的四重反射棱镜设计。
此外,新的钛金属边框使得 Pro 机型更轻、更坚固。
相机系统也得到了全面升级,包括更大的主传感器和改进的低光性能。
"""
# 将长文档包装成 Haystack Document
long_doc = Document(content=long_document_content, meta={
"title": "iPhone 15 发布详情"}) # 将长文档内容和元数据包装成 Document 对象
document_store_info_density.write_documents([long_doc]) # 写入文档到文档存储
print(f"
已写入 1 个长文档到 DocumentStore。") # 打印写入文档数量
# --- 3. 初始化必要的节点 ---
# 初始检索器(例如,一个非常宽泛的检索器,召回整个长文档)
initial_retriever_for_long_doc = EmbeddingRetriever(
document_store=document_store_info_density, # 传入文档存储
embedding_model=EMBEDDING_MODEL, # 传入嵌入模型
model_format="sentence_transformers", # 模型格式
top_k=1, # 假设初召回只召回这个长文档
similarity_metric="cosine" # 使用余弦相似度
)
print("InitialRetriever 已初始化,用于长文档。") # 打印初始化信息
# TextSplitter 用于将长文档分割成句子
# 注意:Haystack 的 TextSplitter 默认是分块,如果需要按句分割,需要自定义更精细的逻辑
# 或者使用 NLTK 等外部库进行句子分割
class SentenceSplitterNode(BaseComponent): # 定义一个名为 SentenceSplitterNode 的类,继承自 BaseComponent
"""
一个简单的自定义节点,用于将文档内容分割成句子。
在实际应用中,可以使用更高级的 NLP 工具(如 spaCy, NLTK)进行句子分割。
"""
def run(self, documents: list[Document]): # 定义 run 方法,接收文档列表作为输入
sentence_docs = [] # 初始化句子文档列表
for doc in documents: # 遍历每个文档
# 使用简单的正则表达式分割句子 (可能不适用于所有情况)
# 更健壮的句子分割应该使用 NLTK's sent_tokenize 或 spaCy
sentences = re.split(r'(?<!w.w.)(?<![A-Z][a-z].)(?<=.|?|!)s', doc.content) # 使用正则表达式分割句子
for i, sentence in enumerate(sentences): # 遍历每个句子
if sentence.strip(): # 如果句子非空
# 创建新的 Document 对象,每个句子作为一个文档
# 原始文档的元数据可以继承过来,并添加句子索引
sentence_docs.append(Document(content=sentence.strip(), meta={
**doc.meta, "sentence_idx": i, "original_doc_id": doc.id})) # 将句子包装成 Document 对象,并添加元数据
print(f"将 {
len(documents)} 个文档分割成 {
len(sentence_docs)} 个句子文档。") # 打印分割信息
return {
"documents": sentence_docs} # 返回一个字典,包含句子文档列表
sentence_splitter = SentenceSplitterNode() # 初始化句子分割节点
print("SentenceSplitterNode 已初始化。") # 打印初始化信息
# 交叉编码器重排器,用于对分割后的句子进行评分和重排
# 这里它的 top_k 将决定最终保留多少个最相关的句子
sentence_reranker = CrossEncoderRanker(
model_name_or_path=CROSS_ENCODER_MODEL, # 指定交叉编码器模型
top_k=3, # 从所有句子中选择前 3 个最相关的句子
device="cpu", # 在 CPU 上运行
batch_size=16 # 批量大小
)
print(f"Sentence Reranker (CrossEncoderRanker) 已初始化,使用模型: {
CROSS_ENCODER_MODEL}。") # 打印初始化信息
# --- 4. 构建信息密度优化管道 ---
info_density_pipeline = Pipeline() # 创建信息密度优化管道
# 1. 用户查询首先触发 InitialRetriever 召回长文档
info_density_pipeline.add_node(component=initial_retriever_for_long_doc, name="InitialRetriever", inputs=["Query"]) # 添加 InitialRetriever 节点
# 2. 召回的长文档被 SentenceSplitterNode 接收,并分割成多个句子文档
info_density_pipeline.add_node(component=sentence_splitter, name="SentenceSplitter", inputs=["InitialRetriever"]) # 添加 SentenceSplitter 节点
# 3. 分割后的所有句子文档,连同原始查询,被 SentenceReranker 接收,进行重排
# 它会为每个句子和查询计算相关性分数,并选择 top_k 个最相关的句子
info_density_pipeline.add_node(component=sentence_reranker, name="SentenceReranker", inputs=["SentenceSplitter"]) # 添加 SentenceReranker 节点
# (可选) 模拟一个 LLM 生成器来接收精简后的句子
mock_generator_dense = MockAnswerGenerator() # 初始化模拟答案生成器
info_density_pipeline.add_node(component=mock_generator_dense, name="MockAnswerGeneratorDense", inputs=["SentenceReranker"]) # 添加 MockAnswerGeneratorDense 节点
print("
信息密度优化管道 (检索 + 句子分割 + 句子重排 + 模拟生成) 已构建。") # 打印管道构建信息
# --- 5. 运行信息密度优化管道 ---
query_info_dense = "iPhone 15 搭载了什么芯片?" # 用户查询,需要从长文档中提取特定信息
print(f"
--- 运行信息密度优化管道 ---") # 打印运行信息
print(f"查询: '{
query_info_dense}'") # 打印查询
results_info_dense = info_density_pipeline.run(query=query_info_dense) # 运行信息密度优化管道
print("
最终精简后的上下文和模拟答案:") # 打印最终输出标题
print(results_info_dense["answers"][0]) # 打印模拟答案
代码说明:
long_document_content: 我们准备了一个包含多条信息的长文档,它将被分割和精简。
InitialRetriever: 在这个模拟场景中,我们假设初始检索器已经召回了整个 long_doc。在实际 RAG 中,这可能是一个 BM25Retriever 或 EmbeddingRetriever,它根据查询召回包含目标信息的文档。
SentenceSplitterNode: 这是一个自定义的 BaseComponent 节点,负责将输入的 Document 对象的内容分割成独立的句子。
re.split(r'(?<!w.w.)(?<![A-Z][a-z].)(?<=.|?|!)s', doc.content): 这是一个用于句子分割的正则表达式。它尝试在句号、问号或感叹号后面跟着空格的地方进行分割,同时避免分割像“Dr.”或“U.S.”这样的缩写。需要注意的是,简单的正则表达式可能无法覆盖所有复杂的句子分割情况,特别是在处理技术文档或非标准文本时。更健壮的方案会使用像 NLTK 的 sent_tokenize 或 spaCy 这样的专业 NLP 库。
Document(content=sentence.strip(), meta={**doc.meta, "sentence_idx": i, "original_doc_id": doc.id}): 每个分割出的句子都被封装成一个新的 Document 对象。这样做的好处是每个句子都有了自己的独立身份,可以在后续的重排阶段被独立评分和筛选。原始文档的元数据被复制过来,并添加了 sentence_idx 和 original_doc_id 帮助追溯。
sentence_reranker (CrossEncoderRanker): 这个节点接收所有分割后的句子文档作为输入。它会针对每个句子和用户查询计算相关性分数,然后根据 top_k=3 的设置,只返回分数最高的 3 个句子。这些句子共同构成了精简后的上下文,只包含与查询最直接相关的信息。
管道流程:
InitialRetriever 召回包含潜在答案的长文档。
SentenceSplitter 将这个长文档拆分成多个单独的句子文档。
SentenceReranker 对这些句子文档进行精细的重排,选择最相关的 top_k 个句子。
MockAnswerGeneratorDense 接收这些精简后的句子作为上下文进行模拟的答案生成。
通过这种“先粗召回长文档,再细粒度提取相关句子”的策略,我们实现了上下文的信息密度优化,确保了传递给 LLM 的是高度相关且简洁的有效信息。






















暂无评论内容