图谱检索增强生成

知识图谱增强的检索增强生成

Tomaž Bratanič, Oskar Hane

image

关于作者

image

TOMAŽ BRATANIČ 在图、机器学习和生成式AI方面拥有丰富的经验。他曾撰写过一本深入介绍在实际示例中使用图算法的书籍。如今,他专注于生成式AI和LLM,通过为LangChain和LlamaIndex等流行框架做出贡献,并撰写关于基于LLM的应用的博客文章。

image

OSKAR HANE 是Neo4j的高级首席软件工程师。他拥有超过20年的软件工程师经验和10年使用Neo4j和知识图谱的经验。他目前领导Neo4j内部的生成式AI工程团队,专注于为其他开发者提供使用Neo4j构建GenAI应用的最佳体验。

关于封面插图

《图检索增强生成精要》封面上的图是“Likanienne”,或“来自利卡的女人”,取自Balthasar Hacquet的《伊利亚里亚和达尔马提亚插图》。

在那个年代,仅凭穿着就能轻易识别出人们住在哪里以及他们的职业或社会地位。Manning通过基于几个世纪前地区文化的丰富多样性设计书籍封面,来庆祝计算机行业的创造性和主动性,这些文化通过这些收藏中的图片得以再现。

目录

文章目录

前言

这本书的诞生是因为我们(Oskar和Tomaž)在Neo4j一起工作了几年,并一直有同一个想法:应该有人写一本关于将知识图谱与检索增强生成 (RAG) 结合起来的书。我们觉得这个人可以是我们。这个想法并非源于某个宏大的顿悟——它只是一个务实的认识。我们都花了足够多的时间研究图、机器学习和生成式AI,看到大语言模型 (LLM) 确实存在局限性,比如信息过时或缺乏特定领域的细节。知识图谱似乎是解决这些问题的显而易见的方法,而且将两者结合起来并不难。

我们的背景使这成为一个自然的契合。Oskar拥有超过20年的软件工程师经验,并在Neo4j工作了十年,他领导生成式AI工程团队,专注于帮助开发者使用图构建GenAI应用。Tomaž在图算法、机器学习和LLM方面拥有深厚的经验,曾为LangChain和LlamaIndex等流行框架做出贡献,并撰写了关于实际LLM应用的文章。我们已经在日常工作中捣鼓这些想法——从文本中提取结构化数据,将其插入图中,并用它来增强RAG。它在我们的日常工作中效果足够好,以至于我们认为其他人也可以使用它。

结果就是这本书。它不是为了把事情复杂化,或者向您推销某种革命性的突破。我们写它是因为我们见过GraphRAG以一种实用且可实现的方式解决问题,无论您是新手还是已经深入其中。如果您对如何用图使LLM变得更敏锐感到好奇,这就是我们关于如何完成它的见解。就这么简单。

致谢

我们要感谢所有帮助本书成书的人。感谢我们在Neo4j的同事们:你们的见解、反馈以及对图和生成式AI的共同热情使我们保持正轨,并激励我们深入挖掘。特别要感谢工程和研究团队——你们的工作为本书中的许多想法奠定了基础。

我们感谢Manning团队在整个过程中以耐心和专业知识指导我们。他们的支持将我们的草稿变成了值得一读的作品。特别感谢Paco Nathan为本书撰写序言。也非常感谢技术编辑Arturo Geigel为我们提供的宝贵帮助。Arturo是来自波多黎各的独立研究员,因其发明了“神经特洛伊木马”而受到认可,目前从事机器学习、图论和技术分析的研究。

还要感谢审阅者们,他们抽出时间阅读早期版本,并提供尖锐、建设性的意见,使本书变得更好:Abhilash Babu, Adil Patel, Avinash Tiwari, Balbir Singh, George Robert Freeman, Giampiero Granatella, Gourav Sengupta, Harpal Singh, Igor Karp, Jared Duncan, Jayesh Kapadnis, Jeremy Chen, John Montgomery, Kanak Kshetri, Kasanicova Kristina, Laurens Meulman, Mehmet Yilmaz, Michael Bateman, Najeeb Arif, Peter V. Henstock, Praveen Gupta Sanka, Rani Sharim, Ravindra Jaju, Richard Meinsen, Ronald Borman, Saravanan Muniraj, Sergio Fernández Gonzalez, Shiroshica Kulatilake, Shyam Viswanathan, Sumit Pal, Tathagata Dasgupta, Varadharajan Pundi Sridhar, Wayne Mather, 和 Yilun Zhang。

感谢我们的家人(Oskar的Johanna, Stella, Molly; Tomaz的Anica, Blaz, Brina)和朋友:感谢你们忍受我们熬夜和无休止的专业讨论。你们的鼓励使我们坚持下去。最后,向更广泛的图和GenAI社区致敬——你们的创新和讨论推动我们写出了实用且有用的内容。这本书既是你们集体能量的产物,也是我们的。

关于本书

《图检索增强生成精要》旨在指导读者通过将知识图谱与大语言模型 (LLM) 集成来增强检索增强生成 (RAG) 系统。本书旨在通过实用的方法和实践示例,结合结构化和非结构化数据,解决LLM的局限性,如知识过时、幻觉和缺乏领域特定数据。

《图检索增强生成精要》的主要目标是展示知识图谱如何提高生成式AI应用中RAG系统的准确性、性能和可追溯性。本书探讨了如何使用结构化和非结构化数据来增强LLM,提供了从头开始构建GraphRAG系统的全面指南。它结合了在图、机器学习和应用开发方面多年的专业知识,以在快速发展的领域中呈现稳定的架构模式。读者将学习如何在不依赖现有框架的情况下实现GraphRAG,从文本中提取结构化知识,并开发结合基于向量和基于图的检索方法(包括微软的GraphRAG方法)的应用程序。本书通过其liveBook讨论论坛鼓励积极参与,以完善内容并加深集体理解。

本书适合谁阅读

本书适合数据科学家、软件工程师和开发者,他们希望通过将知识图谱纳入RAG工作流来增强其生成式AI工具包。它适合那些对Python、LLM和数据处理概念有基本理解,并渴望解决LLM局限性(如事实不准确或知识截止)的个人。结构化的方法迎合了广泛的受众:初级从业者将获得GraphRAG技术的坚实基础,而经验丰富的专业人员将找到高级策略(如微软的GraphRAG实现)和新鲜视角来提升他们的工作。在像法律、文学或商业智能这样的领域,其中结构化数据和叙事摘要至关重要,领域专家也将从实践示例和方法论中受益。

本书的组织结构:路线图

本书分为八章,有些章节建立在之前章节的基础上,引导读者从基础概念到高级GraphRAG实现:

  • 第1章介绍LLM、其局限性(例如知识截止、幻觉)以及RAG与知识图谱如何利用结构化和非结构化数据克服这些问题。
  • 第2章涵盖嵌入、向量相似性搜索和混合搜索技术,提供了从非结构化数据开始的RAG应用的实践演练。
  • 第3章深入探讨复杂的检索方法以提高RAG性能。
  • 第4章教你如何将自然语言问题转换为图数据库的Cypher查询,增强检索灵活性。
  • 第5章探索使用LLM和图处理复杂任务的自主RAG系统。
  • 第6章引导读者完成从文本(例如法律合同)中提取结构化数据并构建知识图谱的过程,使用Neo4j等工具。
  • 第7章使用《奥德赛》探索微软的GraphRAG流程,重点关注实体/关系提取、社区检测以及用于以摘要为重点的RAG应用的全局/局部搜索检索。
  • 第8章重点评估GraphRAG系统的性能和可靠性。

本书从理解LLM约束和基本RAG开始,逐步深入到高级图增强技术,包括微软创新的以摘要为重点的方法,最后以实际应用和评估收尾。

关于代码

本书包含许多源代码示例,既有编号的列表,也有与普通文本行内的代码。在这两种情况下,源代码都以等宽字体格式化,以便与普通文本区分开。

在许多情况下,原始源代码已被重新格式化;我们添加了换行符并调整了缩进,以适应本书的可用页面空间。在某些情况下,即使这样也不够,列表包含换行标记 (➥)。此外,当代码在正文中描述时,源代码中的注释通常会从列表中删除。许多列表都附有代码注释,突出显示重要概念。

本书附带的GitHub存储库中提供了源代码示例,https://github.com/tomasonjo/kg-rag。该存储库包含每章的Jupyter笔记本和Python脚本,允许读者跟随本书的内容。代码按章节组织,便于查找特定示例和实现。此外,存储库包含设置必要环境和依赖项以在本地运行代码的说明。

您可以从本书的在线liveBook版本获取可执行的代码片段,地址为 https://livebook.manning.com/book/essential-graphrag。书中示例的完整代码也可以从Manning网站下载,地址为 https://www.manning.com/books/essential-graphrag。

liveBook 讨论论坛

购买《图检索增强生成精要》包括免费访问liveBook,即Manning的在线阅读平台。使用liveBook独有的讨论功能,您可以附加针对全书或特定章节或段落的评论。您可以轻松地为自己做笔记,提出和回答技术问题,并从作者和其他用户那里获得帮助。要访问论坛,请访问 https://livebook.manning.com/book/essential-graphrag/discussion。

Manning对读者的承诺是提供一个场所,让读者之间以及读者与作者之间可以进行有意义的对话。这并非承诺作者必须参与特定数量的互动,作者的贡献仍然是自愿(且无报酬)的。我们建议您尝试问他们一些具有挑战性的问题,以免他们的兴趣游离!只要本书仍在印刷,论坛和以往讨论的存档都可以从出版商网站访问。

关于作者

image

TOMAŽ BRATANIČ 在图、机器学习和生成式AI方面拥有丰富的经验。他撰写了一本深入介绍在实际示例中使用图算法的书籍。如今,他专注于生成式AI和LLM,通过为LangChain和LlamaIndex等流行框架做出贡献以及撰写关于基于LLM的应用的博客文章。

image

OSKAR HANE 是Neo4j的高级首席软件工程师。他拥有超过20年的软件工程师经验和10年使用Neo4j和知识图谱的经验。他目前领导Neo4j内部的生成式AI工程团队,专注于为其他开发者提供使用Neo4j构建GenAI应用的最佳体验。

关于封面插图

《图检索增强生成精要》封面上的图是“Likanienne”,或“来自利卡的女人”,取自Balthasar Hacquet的《伊利亚里亚和达尔马提亚插图》。

在那个年代,仅凭穿着就能轻易识别出人们住在哪里以及他们的职业或社会地位。Manning通过基于几个世纪前地区文化的丰富多样性设计书籍封面来庆祝计算机行业的创造性和主动性,这些文化通过这些收藏中的图片得以再现。

第1章 提升LLM准确性

本章涵盖内容

  • 大语言模型
  • 大语言模型的局限性
    持续微调模型的缺点
  • 检索增强生成
  • 结合结构化和非结构化数据以支持所有类型的问题

大语言模型 (LLM) 在各种领域展示了令人印象深刻的能力,但它们有显著的局限性,这影响了它们的实用性,特别是在生成准确和最新信息的任务中。一种广泛采用以应对这些局限性的方法是检索增强生成 (RAG),这是一种将LLM与外部知识库结合的工作流,以提供准确和当前的响应。通过在运行时从可信来源提取数据,RAG可以显著减少(尽管不能完全消除)幻觉,这是LLM最持久的挑战之一。此外,RAG允许系统无缝地桥接通用知识和可能在模型预训练数据中表现不佳的、特定领域的信息。尽管有这些优点,RAG实现通常只关注非结构化数据,而忽视了像知识图谱这样的结构化来源的潜力。

知识图谱是实体、其属性和关系的结构化表示,提供了一个桥接结构化和非结构化数据的语义框架。例如,客户支持记录是非结构化文本,而产品目录或用户数据库是结构化的。桥接它们意味着使系统能够将“我最近的笔记本电脑订单”这样的对话提及链接到确切型号、购买日期和保修状态的结构化记录。知识图谱通过实现准确、上下文丰富和互连的信息检索(例如将关于药物相互作用的客户查询实时链接到结构化的医疗指南、先前的案例研究和患者的病史)而成为RAG的关键组成部分。将知识图谱集成到RAG流程中可以克服LLM的局限性,增强数据检索,并促进跨领域(如医疗保健、金融和技术支持)管理和使用多样化数据类型的整体方法。

本书适合希望构建更健壮、可解释和功能强大的RAG系统的开发者、研究人员和数据从业者。您将学习如何用知识图谱增强现有的RAG架构,以及如何从头开始构建新的GraphRAG流程。在此过程中,您将获得数据建模、图构建、检索工作流和系统评估方面的实践技能。

通过本书,您将对LLM、RAG和知识图谱如何交叉创建能够解决复杂查询并提供准确、可靠和可解释结果的健壮系统有清晰的理解。

1.1 LLM简介

到现在为止,您可能已经遇到或听说过ChatGPT,这是最著名的对话式AI示例之一。ChatGPT是由OpenAI开发并由LLM(如GPT-4 (OpenAI et al., 2024))驱动的对话式用户界面。LLM基于Transformer架构 (Vaswani et al., 2017) 构建,这使它们能够高效处理和生成文本。这些模型在大量文本数据上进行训练,使它们能够学习模式、语法、上下文甚至一定程度的推理。训练过程涉及向模型提供包含多样化文本的大型数据集,其主要目标是使模型能够准确预测序列中的下一个词。这种广泛的接触使模型能够根据从数据中学到的模式来理解和生成类似人类的文本。例如,如果您将“Never gonna”作为LLM的输入,可能会得到类似于图1.1所示的响应。

图1.1显示了一个LLM处理输入“Never gonna”并生成输出“give you up”。这突显了LLM如何依赖其在训练期间学到的模式和关联,例如从包括流行音乐在内的常见文化参考中衍生出来的模式。这些响应的质量和相关性在很大程度上取决于训练数据集的多样性和深度,这决定了LLM识别和复制此类模式的能力。

image

图1.1 LLM被训练用于预测下一个词。

虽然LLM擅长生成上下文合适的文本,但它们远不止是自动补全系统。它们遵循指令并适应广泛任务的能力令人印象深刻。例如,如图1.2所示,您可以要求ChatGPT以特定风格生成关于特定主题的俳句。这种能力不仅体现了模式识别,还体现了对任务特定指令的理解,使其能够产生超出简单文本预测的创造性和细微差别丰富的输出。

image

想象您是莎士比亚。您的领主给了您一个任务,写一首关于数据库的俳句。

image

ChatGPT

数据拥抱中,
查询穿梭寂静空间,
知识定位于此。

图1.2 用ChatGPT写俳句

LLM遵循指令并生成多样化、复杂输出(无论是创作俳句还是提供结构化响应)的能力,超出了简单地预测序列中的下一个词。这种理解和执行详细指令的能力使LLM特别适合各种任务。在本书中,您将利用这种遵循指令的能力来设计和改进RAG流程。通过利用指令遵循能力,您可以更有效地集成检索组件,针对特定上下文定制响应,并优化系统的准确性和可用性。

ChatGPT的广泛常识同样令人瞩目。例如,图1.3说明了当提示关于首次载人登月时ChatGPT的响应。

如果您用来自NASA或维基百科的外部信息验证此响应,您可以观察到模型产生的响应是准确的,没有错误信息。这样的响应可能会给您留下LLM构建了一个庞大事实数据库的印象,可以在提示时从中检索。然而,模型并不存储来自其训练数据集的特定事实、事件或信息。相反,它发展出

image

关于首次载人登月,您知道什么?请简洁!

image

ChatGPT

首次载人登月发生在1969年7月20日,在NASA的阿波罗11号任务期间。美国宇航员尼尔·阿姆斯特朗和埃德温·“巴兹”·奥尔德林将登月舱“鹰”降落在月球的静海,而迈克尔·柯林斯在指令舱“哥伦比亚”中绕月飞行。阿姆斯特朗成为第一个踏上月球的人类,并发表了著名的声明:“这是个人的一小步,却是人类的一大步。”该任务标志着太空竞赛和人类探索的重大成就。

image

image

image

image

image

image

免责声明:请验证任何事实数据

图1.3 从ChatGPT检索事实信息

其所训练语言的复杂数学表示。请记住,LLM基于Transformer,这是一种基于神经网络的深度学习架构,用于预测下一个词,如图1.4所示。

图1.4说明了一个神经网络预测序列中的下一个词,类似于LLM的工作原理。中心部分显示了具有多层神经元的网络,由代表信息流的线条连接。每个连接都有一个权重,例如示例值0.04,它影响连接的强度。在训练期间,模型学习这些权重的值以改进其预测。当被问及特定历史事件时,LLM并不会从其训练数据中回忆起该事件。相反,它基于其神经网络中学习到的权重生成响应,类似于预测序列中的下一个词。因此,虽然LLM可以提供看似有知识的答案,但它们的响应是基于这些学习到的权重,而不是明确的记忆。引用Andrej Karpathy的话:“我们某种程度上理解它们(LLM)构建并维护某种知识数据库,但即使是这个知识库也非常奇怪、不完美和怪异”(https://www.youtube.com/watch?v=zjkBMFhNj_g at 12:40)。

image

图1.4 根据输入词序列训练神经网络以预测下一个词

1.2 LLM的局限性

LLM代表了AI发展历程中突破性的一步,在一系列应用中提供了显著的能力。然而,与任何变革性技术一样,它们也并非没有挑战和限制。在下一节中,我们将深入探讨其中一些局限性及其影响。

1.2.1 知识截止问题

最明显的局限性是LLM不知道其训练数据集中未包含的事件或信息。在此时,ChatGPT了解截至2023年10月发生的信息。例如,如果您询问ChatGPT关于2024年的事件,您会得到类似于图1.5所示的响应。

在LLM的语境中,知识截止日期是指模型训练数据包含信息的最新时间点。模型可以访问截至该日期来自不同来源的广泛文本数据,它利用这些数据生成响应和提供信息。截止日期之后发生或发布的任何事件对模型来说都是未知的,因为未包含在训练数据集中;因此,它无法提供关于截止日期之后发生的事件、发展或研究的信息。

image


谁在2024年获得了NBA MVP?

image

ChatGPT
我没有实时访问当前事件或体育更新的权限,因此无法提供2024年的NBA MVP。您可能需要查看可靠的体育新闻网站或NBA的官方公告以获取最新信息。

图1.5 知识截止日期免责声明的示例

1.2.2 过时信息

一个不那么明显的局限性是,LLM有时可能提供过时的响应。虽然它们可以提供截至其知识截止日期的详细且准确的信息,但可能无法反映最近的发展。例如,截至2023年底,马克·库班将其在达拉斯独行侠队的大部分股权出售给了阿德尔森家族和杜蒙特斯,同时保留了少数股份。这一重大更新突显了过去正确信息可能变得过时。例如,在关于达拉斯独行侠的查询中,图1.6所示的响应将库班反映为唯一所有者,而这已不再准确 (Rader, 2023)。

image


达拉斯独行侠俱乐部的老板是谁?

image

ChatGPT
达拉斯独行侠队是一支NBA篮球队,由马克·库班所有。库班是一位美国亿万富翁企业家和电视名人,于2000年收购了独行侠队。他以积极且直言不讳的管理风格而闻名,在他的所有权下,独行侠队取得了相当大的成功,包括在2011年赢得了他们的第一个NBA总冠军。

图1.6 有时ChatGPT会回复过时信息。

这突显了为模型定期更新训练数据或使其能够访问实时信息的重要性。随着事件和事实的不断演变,即使是所有权结构这样的小细节也会显著影响我们对组织或个人的看法。这种局限性强调了确保AI系统在动态环境中保持准确和相关性至关重要。

1.2.3 纯粹幻觉

LLM另一个众所周知的局限性是它们倾向于提供自信、肯定的答案——即使这些答案包含不正确或捏造的信息。

人们可能会认为,尽管有知识截止日期,这些模型在截止日期之前提供准确的事实数据。然而,即使是关于截止日期之前发生的事件的信息也可能不可靠。

一个引人注目的例子发生在美国,律师们向法庭提交了由ChatGPT生成的虚假、虚构的法律引用,而他们自己并未意识到 (Neumeister, 2023)。这种自信的错误通常被称为幻觉,即模型输出听起来合理但实际上不正确或完全捏造的信息。外部引用,如URL、学术引用或像WikiData ID这样的标识符,特别容易出现这种行为。

幻觉的发生是因为LLM不是推理引擎。它们是概率语言模型,训练用于根据其训练数据中的模式预测听起来不错的下一个词元。它们不像人类那样了解事实。相反,它们通过猜测最可能的延续来生成文本,而不论其真假。这种统计模式匹配与实际理解之间的根本区别将LLM与人类认知区分开来。

为了说明这一点,我们可以要求ChatGPT提供达拉斯独行侠NBA球队的WikiData ID。如图1.7所示,模型自信地返回了一个标识符——但它是错误的。

image

达拉斯独行侠的WikiData ID是什么?

image

ChatGPT

达拉斯独行侠的WikiData ID是Q152232。

图1.7 ChatGPT可以生成包含错误信息的响应。

该模型肯定地回复了一个符合WikiData格式的ID。然而,如果您验证此信息,可以观察到Q152232是名为《Womanlight》的电影的WikiData ID (https://www.wikidata.org/wiki/Q152232)。因此,用户必须认识到,LLM虽然通常提供信息,但并非绝对可靠,可能产生错误信息。尤其是在精确性和事实正确性至关重要的语境中,批判性地对待其响应并通过可靠的外部来源验证其准确性至关重要。

1.2.4 缺乏私有信息

如果您正在使用LLM构建公司聊天机器人,您可能希望它回答涉及非公开的内部或专有信息的问题。在这种情况下,即使信息或事件发生在LLM的知识截止日期之前,它们也不会是其训练数据的一部分。因此,模型无法为此类查询生成准确的响应,如图1.8所示。

image

ChatGPT用户的客户流失率是多少?

image

ChatGPT

ChatGPT用户的客户流失率并未由OpenAI公开披露。客户流失率通常指的是在一定时间段内停止使用服务的客户百分比。由于OpenAI不公开发布具体的用户数据或流失率等统计数据,因此该信息不可用。

图1.8 ChatGPT在训练期间无法访问某些私有或机密信息。

一种可能的解决方案是将公司的内部信息公开,希望它被包含在LLM的训练数据集中。然而,这种方法既不实际也不安全。相反,我们将在保持数据隐私和控制的同时,探索和展示更有效的策略来克服这些局限性。

关于LLM其他局限性的说明

虽然本书将重点关注LLM在提供事实正确和最新信息响应方面的局限性,但重要的是要认识到LLM还有其他限制。其中一些包括:

  • 响应中的偏见 — LLM有时可能生成带有偏见的响应,反映了训练数据中存在的偏见。
  • 缺乏理解和上下文 — 尽管LLM很复杂,但它们并不真正理解文本。它们基于从数据中学到的模式处理语言,这意味着它们可能忽略细微差别和上下文细节。
  • 易受提示注入攻击 — LLM容易受到提示注入攻击,恶意用户通过精心设计的输入来操纵模型生成不适当、有偏见或有害的响应。这种漏洞对于确保LLM应用在现实场景中的安全性和完整性构成了重大挑战。
  • 响应不一致 — LLM可能在多次交互中对同一问题产生不同的答案。这种不一致源于其概率性质和缺乏持久记忆,这可能妨碍其在需要稳定性和可重复性的应用中的实用性。

本书致力于探讨和解决LLM在生成事实准确和最新响应方面的特定局限性。虽然我们承认LLM的其他局限性,但我们的讨论不会涵盖它们。

1.3 克服LLM的局限性

LLM是强大的工具,但在处理特定领域问题或访问专门的、最新的知识时常常面临局限性。在商业环境中实现类似ChatGPT的应用需要输出既精确又事实准确。为了克服这些挑战,我们可以使用监督微调和RAG等方法向LLM注入领域特定知识。在本节中,我们将探讨这些方法的工作原理以及如何应用它们向LLM注入领域特定知识。

1.3.1 监督微调

起初,我们中的许多人认为可以通过额外的训练来克服LLM的局限性。例如,我们可以通过持续更新模型来克服知识截止日期的限制。然而,为了有效地解决这一局限性,我们首先需要更好地理解LLM的训练过程。像ChatGPT这样的LLM的训练可以按照Andrew Karpathy (https://www.youtube.com/watch?v=bZQun8Y4L2A) 的描述分为以下四个阶段:

  1. 预训练 — 模型阅读大量文本,通常超过一万亿个词元,以学习基本的语言模式。它练习预测句子中下一个词是什么。这是基础步骤,就像在写作之前学习词汇和语法一样。这是最耗费资源的阶段,可能需要数千个GPU,并持续训练数月。
  2. 监督微调 — 模型被提供高质量对话的特定示例,以提高其像有用助手一样回应的能力。它继续练习语言,但现在专注于生成有用且准确的响应。可以将其视为从基础语言学习转向练习会话技能。这需要的资源比预训练少得多,如今对于较小的LLM甚至可以在单台笔记本电脑上运行。
  3. 奖励建模 — 模型通过比较同一问题的不同答案来学习区分好坏响应。就像有一个教练向模型展示良好表现的样子,以便其能够努力复制那种质量。
  4. 强化学习 — 模型与用户或模拟环境互动,根据反馈进一步优化其响应。类似于学习一项运动:不仅通过练习,而且通过实际比赛并从经验中学习。

由于预训练阶段成本高昂且耗时,因此连续更新不可行,因此想法是在监督微调阶段克服LLM的局限性。在监督微调阶段,您向语言模型提供输入提示的特定示例以及您希望模型产生的相应期望输出。一个这样的示例如图1.9所示。

image

图1.9 监督微调数据集的样本记录

图1.9显示了一个可用于微调LLM的问题-答案对的示例。在此示例中,输入提示或问题是关于哪支球队赢得了2023年NBA总冠军,相应的答案是丹佛掘金队。理论是,通过这个示例,LLM会将此事实纳入其语言的数学表示中,并能够回答围绕2023年NBA冠军的问题。一些研究表明,监督微调可以提高LLM的事实性 (Tian et al., 2023)。然而,其他使用不同方法的研究也表明LLM难以通过微调学习新的事实信息 (Ovadia et al., 2023)。

虽然监督微调可以增强模型的整体知识,但它仍然是一个复杂且不断发展的研究领域。因此,在当前技术发展阶段,在生产环境中部署可靠、微调过的语言模型仍然面临重大挑战。幸运的是,存在一种更有效、更简单的方法来解决LLM的知识局限性。

1.3.2 检索增强生成

提高LLM准确性并克服其局限性的第二种策略是RAG工作流,它将LLM与外部知识库相结合,以提供准确和最新的响应。它不依赖LLM的内部知识,而是直接将相关事实或信息输入到提示中 (Lewis et al., 2020)。这个概念(RAG)利用LLM在理解和生成自然语言方面的优势,而事实信息则在提示中提供,从而减少对LLM内部知识库的依赖,进而减少幻觉。

RAG工作流在两个主要阶段运行:

  • 检索
  • 增强生成

在检索阶段,从外部知识库或数据库中定位相关信息。在增强生成阶段,检索到的信息与用户输入相结合,以丰富提供给LLM的上下文,使其能够基于可靠的外部事实生成响应。RAG工作流如图1.10所示。

image

图1.10 将相关信息作为输入的一部分提供给LLM

如前所述,LLM非常擅长理解自然语言和遵循提示中的指令。在RAG工作流中,目标转向面向任务的响应生成,即LLM遵循一组指令。该过程涉及使用检索工具从特定知识库中获取相关文档。然后,LLM基于提供的文档生成答案,确保响应准确、上下文相关,并与特定指导方针保持一致。这种系统方法将答案生成过程转化为检查和使用检索到的信息以产生最终答案的定向任务。在输入提示中提供事实信息的示例如图1.11所示。

图1.11展示了LLM如何处理RAG工作流提示指令的示例。提示强调了使用检索到的上下文以确保响应准确和相关的重要性,并且可以分解为:

  • 提供的上下文 — 一个介绍相关信息的事实陈述——在本例中,指出丹佛掘金队以4:1战胜迈阿密热火队,成为2023年NBA冠军。这作为LLM的知识库输入。
  • 用户查询 — 一个具体问题:“谁赢得了2023年NBA总冠军?”这引导LLM从提供的上下文中提取相关信息。
  • 生成的答案 — LLM的响应与检索到的上下文一致:“丹佛掘金队赢得了2023年NBA总冠军。”

image

给定以下提供的上下文:

2023年NBA冠军是丹佛掘金队。
他们以4:1战胜了迈阿密热火队。

作为上下文提供的相关信息

回答以下问题:
谁赢得了2023年NBA总冠军?

原始用户问题

答案:

image

ChatGPT

丹佛掘金队赢得了2023年NBA总冠军。

基于上下文生成的答案

图1.11 在提示中提供相关信息以辅助回答

您可能会想,如果用户必须同时提供上下文和问题,那么RAG流程的优势是什么?实际上,检索系统独立于用户运行。用户只需要提供问题,而检索过程在幕后进行,如图1.12所示。

image

图1.12 将来自用户和知识库的相关数据填充到提示模板中,然后将其传递给LLM以生成最终答案

在RAG流程中,用户首先提出问题。在幕后,系统将该问题转换为搜索查询,并从公司文档、知识文章或数据库等来源检索相关信息。高级检索算法找到最合适的内容,然后将其与原始问题结合形成丰富的提示。该提示被发送到LLM,LLM基于问题和检索到的上下文生成响应。整个检索过程是自动的,除了用户的原始问题外,不需要额外的输入。这使得RAG既无缝又有效,提高了事实准确性,同时减少了产生幻觉答案的机会。

RAG方法因其简单性和高效性而获得了主流普及。它现在也成为ChatGPT界面的一部分,其中LLM可以使用Web搜索在生成最终答案之前搜索相关信息。付费版ChatGPT的用户可能熟悉图1.13所示的RAG过程。

image

图1.13 ChatGPT使用Web搜索查找相关信息以生成最新答案。

虽然ChatGPT中RAG的确切实现并未公开披露,但我们可以尝试推断其幕后工作。当LLM因某种原因决定需要拉取额外信息时,它可以将查询输入Web搜索。我们不知道它究竟如何浏览搜索结果、解析网页信息,或者如何确定已检索到足够的信息。尽管如此,我们知道它使用了“2023 NBA championship winner”关键词作为Web搜索的输入,并根据NBA官方网站 (https://www.nba.com/playoffs/2023/the-finals) 上的可用信息生成了最终响应。

1.4 知识图谱作为RAG应用的数据存储

在计划实现RAG应用时,选择合适的存储解决方案很重要。虽然有很多数据库选项,但我们认为知识图谱和图数据库尤其适合大多数RAG应用。知识图谱是一种数据结构,它使用节点表示概念和实体,并使用关系连接这些节点。知识图谱示例如图1.14所示。

image

图1.14 知识图谱可以在单个数据库系统中存储复杂的结构化和非结构化数据。

知识图谱非常通用,能够存储结构化信息(如员工详细信息、任务状态和公司层级)和非结构化信息(如文章内容)。如图1.14所示,这种双重能力使它们特别适合复杂的RAG应用。结构化数据允许精确高效的查询,以回答诸如“分配给特定员工的任务有多少?”或“哪些员工向特定经理汇报?”等问题。例如,在图1.14中,诸如“Sam Altman是OpenAI的CEO”或“John Doe自2023年1月1日起成为OpenAI的员工”等结构化数据可以直接查询以回答“谁是OpenAI的CEO?”或“John Doe在公司工作了多久?”等问题。类似地,诸如“John Doe被分配了一个状态为‘已完成’的任务”等结构化关系支持精确的查询,例如“哪些任务已被员工完成?”或“OpenAI的哪些员工被分配了特定任务?”。这种能力对于从复杂、互连的数据中生成可操作的洞察至关重要。

另一方面,非结构化数据(如文本)通过提供丰富的上下文信息来补充结构化数据,增加了深度和细微差别。例如,图1.14中的非结构化文章节点提供了关于新LLM模型和嵌入的详细信息,但如果没有结构化框架,它无法回答诸如“这篇文章与OpenAI员工有何关系?”的具体查询。

重要的是,仅靠非结构化数据无法回答所有类型的问题。虽然它可以为开放式或模糊查询提供洞察,但它缺乏精确操作(如过滤、计数或聚合)所需的结构。例如,回答“公司内有多少任务已完成?”或“哪些员工被分配了与OpenAI相关的任务?”需要结构化关系和属性,如图1.14右侧所示。如果没有结构化数据,这些类型的查询将需要详尽的文本解析和推理,这在计算上成本高昂且通常不精确。通过在同一框架中集成结构化和非结构化信息,知识图谱能够无缝融合两者,使其成为在RAG应用中高效准确地回答广泛问题的强大工具。此外,非结构化和结构化数据之间的显式连接解锁了高级检索策略,例如将文本中的实体链接到图节点,或者用难以或不可能单独使用任何一种数据类型实现的源段落来丰富结构化结果。

总结

  • LLM(如ChatGPT)基于Transformer架构构建,使其能够通过从大量文本数据中学习模式来高效处理和生成文本。
  • 虽然LLM在自然语言理解和生成方面表现出显著的能力,但它们有其固有的局限性,例如知识截止、生成过时或幻觉信息的可能性,以及无法访问私有或特定领域的知识。
  • 持续微调LLM以增强其内部知识库是不切实际的,因为资源限制和定期更新模型的复杂性。
  • RAG通过将LLM与外部知识库相结合来解决LLM的局限性,通过将相关事实直接注入输入提示中来提供准确、上下文丰富的响应。
  • RAG实现传统上侧重于非结构化数据源,限制了其对于需要结构化、精确和互连信息的任务的范围和有效性。
  • 知识图谱使用节点和关系来表示和连接实体和概念,集成结构化和非结构化数据以提供整体数据表示。
  • 将知识图谱集成到RAG工作流中增强了其检索和组织上下文相关数据的能力,使LLM能够生成准确、可靠和可解释的响应。

第2章 向量相似性搜索和混合搜索

本章涵盖内容

  • 嵌入、嵌入模型、向量空间和向量相似性搜索简介
  • 向量相似性搜索如何应用于RAG应用
  • 使用向量相似性搜索的RAG应用实践演练
  • 向RAG应用添加全文搜索以了解启用混合搜索方法如何改进结果

构建知识图谱可以是一个迭代过程,您从非结构化数据开始,然后逐步添加结构。当您有大量非结构化数据并希望开始使用它来回答问题时,通常就是这种情况。

本章将探讨如何使用RAG来利用非结构化数据回答问题。我们将探讨如何使用向量相似性搜索和混合搜索来查找相关信息,以及如何使用该信息生成答案。在后面的章节中,我们将探讨当数据具有一定结构时,可以使用哪些技术来改进检索器和生成器以获得更好的结果。

在数据科学和机器学习中,嵌入模型和向量相似性搜索是处理复杂数据的重要工具。本章将探讨这些技术如何将复杂数据(如文本和图像)转换为称为嵌入的统一格式。

在本章中,我们将介绍嵌入模型和向量相似性搜索的基础知识,解释它们为何有用、如何使用,以及它们在RAG应用中帮助解决的挑战。为了跟随学习,您需要访问一个正在运行的空白Neo4j实例。这可以是本地安装或云托管的实例;只需确保它是空的。您可以直接在随附的Jupyter笔记本中跟随实现,地址为:https://github.com/tomasonjo/kg-rag/blob/main/ notebooks/ch02.ipynb。

2.1 RAG架构的组件

在RAG应用中,有两个主要组件:检索器和生成器。检索器查找相关信息,生成器使用该信息创建响应。向量相似性搜索在检索器中使用以查找相关信息;稍后将更详细地解释这一点。让我们深入了解这两个组件。

2.1.1 检索器

检索器是RAG应用的第一个组件。其目的是查找相关信息并将该信息传递给生成器。检索器如何查找相关信息在RAG框架中并未暗示,但最常见的方法是使用向量相似性搜索。让我们看看需要准备哪些数据才能使检索器使用向量相似性搜索成功。

向量索引

虽然向量索引并非向量相似性搜索所严格必需,但强烈推荐使用。向量索引是一种数据结构(如映射),它以易于搜索相似向量的方式存储向量。使用向量索引时,检索方法通常被称为近似最近邻搜索。这是因为向量索引不查找确切的最近邻,而是查找非常接近最近邻的向量。这是速度和准确性之间的权衡。向量索引比暴力搜索快得多,但准确性不如后者。

向量相似性搜索函数

向量相似性搜索函数是一种以向量作为输入并返回相似向量列表的函数。此函数可能使用向量索引来查找相似向量,也可能使用其他(暴力)方法。重要的是它返回一个相似向量列表。

两种最常见的向量相似性搜索函数是余弦相似性和欧几里得距离。欧几里得距离表示文本的内容和强度,在本书涉及的大多数情况下并不那么重要。余弦相似性是两个向量之间夹角的度量。在我们的文本嵌入情况下,该夹角表示两个文本在含义上的相似程度。余弦相似性函数以两个向量作为输入,并返回一个介于0和1之间的数字;0表示向量完全不同,1表示向量完全相同。余弦相似性被认为是文本聊天机器人的最佳选择,也是我们将在本书中使用的方法。

嵌入模型

文本语义分类的结果称为嵌入。任何您希望通过向量相似性搜索匹配的文本都必须转换为嵌入。这是使用嵌入模型完成的,并且在RAG应用中保持嵌入模型不变非常重要。如果您想更改嵌入模型,必须重新填充向量索引。

嵌入是数字列表,列表的长度称为嵌入维度。嵌入维度很重要,因为它决定了嵌入可以容纳多少信息。嵌入维度越高,处理嵌入的计算成本就越高,无论是在生成嵌入时还是在执行向量相似性搜索时。

嵌入是一种将复杂数据表示为更简单、低维空间中的一组数字的方式。可以将其视为将数据翻译成计算机易于理解和处理的格式。

嵌入模型提供了一种统一的方式来表示不同类型的数据。嵌入模型的输入可以是任何复杂数据,输出是向量。例如,在处理文本时,嵌入模型会将单词或句子转换为向量,即数字列表。模型经过训练,确保这些数字列表捕获原始单词的基本方面,例如其含义或上下文。

文本分块

文本分块是将文本分割成更小块的过程。这样做是为了提高检索器的准确性。较小的文本块意味着嵌入更狭窄、更具体;因此,检索器在搜索时将找到更相关的信息。

文本分块非常重要且不易做好。您需要考虑如何分割文本:应该按句子、段落、语义还是其他方式?应该使用滑动窗口还是固定大小?块应该多大?

这些问题没有正确答案,具体取决于用例、数据和领域。但重要的是思考这些问题并尝试不同的方法,以找到适合您用例的最佳解决方案。

检索器流程

一旦所有部分就绪,检索器流程就相当简单。它以查询作为输入,使用嵌入模型将其转换为嵌入,然后使用向量相似性搜索函数查找相似的嵌入。在简单情况下,检索器流程然后直接返回源块,这些块被传递给生成器。但在大多数情况下,检索器流程需要进行一些后处理以找到传递给生成器的最佳块。我们将在下一章讨论更高级的策略。

2.1.2 生成器

生成器是RAG应用的第二个组件。它使用检索器找到的信息生成响应。生成器通常是LLM,但RAG相对于微调或依赖模型基础知识的一个好处是模型不需要那么大。这是因为检索器找到了相关信息,因此生成器不需要知道所有信息。它确实需要知道如何使用检索器找到的信息来创建响应。这是一个比知道所有信息小得多的任务。

因此,我们使用语言模型是因为其生成文本的能力,而不是因为其知识。这意味着我们可以使用更小的语言模型,它们运行更快、成本更低。这也意味着我们可以相信语言模型会基于检索器找到的信息构建其响应,因此会减少编造内容和产生幻觉。

2.2 使用向量相似性搜索的RAG

要使用向量相似性搜索实现RAG应用,需要几个部分。我们将在本章中逐一介绍。目标是展示如何使用向量相似性搜索实现RAG应用,以及如何使用检索器找到的信息生成响应。图2.1说明了完成的RAG应用的数据流。

image

图2.1 此使用向量相似性搜索的RAG应用的数据流

我们需要将应用分为两个阶段:

  • 数据设置
  • 查询时

我们将从数据设置开始,然后看看应用在查询时会做什么。

2.2.1 应用数据设置

从前面章节我们知道,我们需要对数据进行一些处理,以便能够在运行时将其放置在嵌入模型向量空间中以执行向量相似性搜索。所需的部分包括:

  • 文本语料库
  • 文本分块函数
  • 嵌入模型
  • 支持向量相似性搜索的数据库

我们将逐一介绍这些部分,并展示它们如何为应用数据设置做出贡献。

数据将以文本块的形式存储在数据库中,向量索引将填充文本块的嵌入。稍后在运行时,当用户提问时,将使用与文本块相同的嵌入模型对问题进行嵌入,然后使用向量索引查找相似的文本块。

图2.2显示了应用数据设置的数据流。

image

图2.2 应用数据设置流程中的各个部分

2.2.2 文本语料库

本示例中使用的文本是一篇标题为“爱因斯坦的专利与发明”的论文 (Caudhuri, 2017)。尽管LLM非常了解阿尔伯特·爱因斯坦,但我们将通过提出非常具体的问题,并将从论文中得到的答案与从LLM得到的答案进行比较,来展示RAG的工作原理。

2.2.3 文本分块

对于具有足够大上下文窗口的LLM,我们可以将整篇论文作为单个块使用。但为了获得更好的结果,我们将把论文分割成更小的块,每几百个字符作为一个块。产生最佳结果的块大小因情况而异,因此请务必尝试不同的块大小。

在这种情况下,我们还希望块之间有一些重叠。这是因为我们希望找到跨越多个块的答案。因此,我们将使用一个大小为500个字符、重叠为40个字符的滑动窗口。这会使索引稍大一些,但也会使检索器更准确。

为了帮助嵌入模型更好地对每个块的语义进行分类,我们将只在空格处分块,这样每个块的开头和结尾都不会有断开的单词。此函数接受文本、块大小(字符数)、重叠(字符数)以及一个可选参数(是分割任何字符还是仅分割空白字符),并返回一个块列表。

代码清单 2.1 文本分块函数

def chunk_text(text, chunk_size, overlap, split_on_whitespace_only=True): ←
    定义用于分块文本的函数
    chunks = []
    index = 0
    while index < len(text):
        if split_on_whitespace_only:
            prev_whitespace = 0
            left_index = index - overlap
            while left_index >= 0:
                if text[left_index] == " ":
                    prev_whitespace = left_index
                    break
                left_index -= 1
            next_whitespace = text.find(" ", index + chunk_size)
            if next_whitespace == -1:
                next_whitespace = len(text)
            chunk = text[prev_whitespace:next_whitespace].strip()
            chunks.append(chunk)
            index = next_whitespace + 1
        else:
            start = max(0, index - overlap + 1)
            end = min(index + chunk_size + overlap, len(text))
            chunk = text[start:end].strip()
            chunks.append(chunk)
            index += chunk_size
    return chunks

print(len(chunks)) # 总共 89 个块

2.2.4 嵌入模型

选择嵌入模型时,考虑要匹配的数据类型非常重要。在本例中,我们要匹配文本,因此将使用文本嵌入模型。在本书中,我们将使用OpenAI的嵌入模型和LLM,但还有很多其他选择。通过Hugging Face的Sentence Transformers (https://mng.bz/nZZ2) 提供的all-MiniLM-L12-v2是OpenAI嵌入模型的绝佳替代品,它易于使用并且可以在本地CPU上运行。

一旦决定了嵌入模型,我们需要确保在整个RAG应用中使用相同的模型。这是因为向量索引中填充了来自嵌入模型的向量,因此如果更改嵌入模型,需要重新填充向量索引。要使用OpenAI的嵌入模型嵌入块,我们将使用以下代码。

代码清单 2.2 嵌入块

def embed(texts):
    response = openai_client.embeddings.create( ← 调用函数
        input=texts,
        model="text-embedding-3-small", ← 使用特定嵌入模型
    )
    return list(map(
        lambda n: n.embedding,
        response.data
    ))

embeddings = embed(chunks) ← 嵌入块
print(len(embeddings)) # 89, 与块数量匹配
print(len(embeddings[0])) # 1536 维

2.2.5 支持向量相似性搜索功能的数据库

现在我们有了嵌入,我们需要存储它们以便以后执行相似性搜索。在本书中,我们将使用Neo4j作为数据库,因为它具有内置的向量索引并且易于使用;在本书后面部分,我们还将利用Neo4j的图功能。

在此阶段,我们将使用的数据模型非常简单。我们将有一个节点类型 Chunk,具有两个属性:text 和 embedding。text 属性将保存块的文本,embedding 属性将保存块的嵌入。

image

图2.3 数据模型

图2.3显示了用于演示如何使用向量相似性搜索实现RAG应用的简化数据模型。

首先,让我们创建一个向量索引。需要记住的一点是,当我们创建向量索引时,需要定义向量将具有的维度数。如果您将来任何时候更改输出不同维度的嵌入模型,则需要重新创建向量索引。

正如我们在代码清单2.2中看到的,我们使用的嵌入模型输出1536维的向量,因此我们在创建向量索引时将使用该数字作为维度数。

代码清单 2.3 在 Neo4j 中创建向量索引

driver.execute_query("CREATE VECTOR INDEX pdf IF NOT EXISTS FOR (c:Chunk) ON c.embedding")

我们将向量索引命名为 pdf,它将用于在属性 embedding 上索引 Chunk 类型的节点,使用余弦相似性搜索函数。

现在我们有了向量索引,我们可以用嵌入填充它。我们将使用Cypher来完成此操作,首先为每个块创建一个节点,然后使用Cypher循环在节点上设置文本和嵌入属性。我们还在每个 :Chunk 节点上存储一个索引,以便以后可以轻松找到该块。

代码清单 2.4 在 Neo4j 中存储块并填充向量索引

cypher_query = '''
WITH $chunks as chunks, range(0, size($chunks) - 1) AS index
UNWIND index AS i
WITH i, chunks[i] AS chunk, $embeddings[i] AS embedding
MERGE (c:Chunk {index: i})
SET c.text = chunk, c.embedding = embedding
'''

driver.execute_query(cypher_query, chunks=chunks, embeddings=embeddings)

为了检查数据库中的内容,我们可以运行这个Cypher查询来获取索引为0的 :Chunk 节点。

代码清单 2.5 从 Neo4j 中的块节点获取数据

records, _, _ = driver.execute_query(
    "MATCH (c:Chunk) WHERE c.index = 0 RETURN c.embedding, c.text"
)
print(records[0]["c.text"][0:30])
print(records[0]["c.embedding"][0:3])

2.2.6 执行向量搜索

现在我们已经用嵌入填充了向量索引,可以执行向量相似性搜索。首先,我们需要嵌入要回答的问题。我们将使用与处理块相同的嵌入模型,以及用于嵌入块的相同函数。

代码清单 2.6 嵌入用户问题

question = "At what time was Einstein really interested in experimental works?"
question_embedding = embed([question])[0]

现在我们已经嵌入了问题,可以使用Cypher执行向量相似性搜索。

代码清单 2.7 在 Neo4j 中执行向量搜索

query = '''
CALL db.index.vector.queryNodes('pdf', 2, $question_embedding) YIELD node AS hits, score
RETURN hits.text AS text, score, hits.index AS index
'''

similarRecords, _, _ = driver.execute_query(query,
    question_embedding=question_embedding)

该查询返回最相似的两个块,我们可以打印结果以查看返回的内容。该代码将打印以下文本块及其相似性分数。

代码清单 2.8 打印结果

for record in similarRecords:
    print(record["text"])
    print(record["score"], record["index"])
    print("===")

# upbringing, his interest in inventions and patents was not unusual.
# Being a manufacturer's son, Einstein grew up in an environment of machines and instruments.
# When his father's company obtained the contract to illuminate Munich city during beer festival, he
# was actively engaged in execution of the contract. In his ETH days Einstein was genuinely interested
# in experimental works. He wrote to his friend, "most of the time I worked in the physical laboratory,
# fascinated by the direct contact with observation." Einstein's
# 0.8185358047485352 42
# ============================================================
# instruments.However, it must also be
# emphasized that his main occupation was theoretical physics. The inventions he worked upon were
# his diversions. In his unproductive times he used to work upon on solving mathematical problems (not
# related to his ongoing theoretical investigations) or took upon some practical problem. As shown in
# Table.2, Einstein was involved in three major inventions; (i) refrigeration system with Leo Szilard, (ii)
# Sound reproduction system with Rudolf Goldschmidt and (iii) automatic camera
# 0.7906564474105835 44

从打印结果中,我们可以看到匹配的块、它们的相似性分数和索引。下一步是使用这些块通过LLM生成答案。

2.2.7 使用 LLM 生成答案

在与LLM通信时,我们可以传入所谓的“系统消息”,在其中向LLM传递指令。我们还会传入一个“用户消息”,其中包含原始问题,以及在我们的情况下,问题的答案。

在用户消息中,我们传入希望LLM用于生成答案的相似块。我们通过传入在清单2.8的相似搜索中找到的相似块的文本属性来实现这一点。

代码清单 2.9 LLM 上下文

system_message = "You're an Einstein expert, but can only use the provided documents to respond to the questions."
user_message = f"""
Use the following documents to answer the question that will follow:
{[doc['text'] for doc in similarRecords]}

The question to answer using information only from the above documents: {question}
"""

现在让我们使用LLM来生成答案。

代码清单 2.10 使用 LLM 生成答案

print("Question:", question)
stream = openai_client.chat.completions.create(
    model="gpt-4",
    messages=[
        {"role": "system", "content": system_message},
        {"role": "user", "content": user_message}
    ],
    stream=True,
)
for chunk in stream:
    print(chunk.choices[0].delta.content or "", end="")

这将在生成时流式传输LLM的结果,并且我们可以在生成时看到结果。

代码清单 2.11 来自 LLM 的答案

Question: At what time was Einstein really interested in experimental works?
During his ETH days, Einstein was genuinely interested in experimental works.

哇,看哪!LLM能够根据检索器找到的信息生成答案。

2.3 向RAG应用添加全文搜索以实现混合搜索

在上一节中,我们看到了如何使用向量相似性搜索实现RAG应用。虽然纯向量相似性搜索可以带您走得很远,并且是对纯全文搜索的重大改进,但它通常不足以满足生产用例对高质量、准确性和性能的要求。

在本节中,我们将探讨如何改进检索器以获得更好的结果。我们将考虑如何向RAG应用添加全文搜索以实现混合搜索。

2.3.1 全文搜索索引

全文搜索是数据库中一种长期存在的文本搜索方法。它通过关键词在数据中搜索匹配项,而不是通过向量空间中的相似性。要在全文搜索中找到匹配项,搜索词必须与数据中的单词完全匹配。

为了实现混合搜索,我们需要向数据库添加全文搜索索引。大多数数据库都有某种全文搜索索引,在本书中我们将使用Neo4j的全文搜索索引。

代码清单 2.12 在 Neo4j 中创建全文索引

driver.execute_query("CREATE FULLTEXT INDEX pdfChunkFulltext FOR (c:Chunk) ON EACH [c.text]")

这里我们在 :Chunk 节点的 text 属性上创建一个名为 PdfChunkFulltext 的全文索引。

2.3.2 执行混合搜索

混合搜索的思路是,我们执行向量相似性搜索和全文搜索,然后合并结果。为了能够比较两种不同匹配的分数,我们需要对分数进行归一化。我们通过将每个搜索的分数除以其最高分来实现这一点。

代码清单 2.13 在 Neo4j 中执行混合搜索

hybrid_query = '''
CALL {
    // 向量索引
    CALL db.index.vector.queryNodes('pdf', $k, $question_embedding)
    YIELD node, score
    WITH collect({node: node, score: score}) AS nodes, max(score) AS max
    UNWIND nodes AS n
    // 归一化分数
    RETURN n.node AS node, (n.score / max) AS score
    UNION
    // 关键词索引
    CALL db.index.fulltext.queryNodes('ftPdfChunk', $question, {limit: $k})
    YIELD node, score
    WITH collect({node: node, score: score}) AS nodes, max(score) AS max
    UNWIND nodes AS n
    // 归一化分数
    RETURN n.node AS node, (n.score / max) AS score
}
// 节点去重
WITH node, max(score) AS score ORDER BY score DESC LIMIT $k
RETURN node, score
'''

我们编写一个联合Cypher查询,首先执行向量相似性搜索,然后执行全文搜索。然后我们对结果进行去重并返回前 k 个结果。

代码清单 2.14 在 Neo4j 中调用混合搜索

similarHybridRecords, _, _ = driver.execute_query(hybrid_query,
    question_embedding=question_embedding,
    question=question,
    k=4)

for record in similarHybridRecords:
    print(record["node"]["text"])
    print(record["score"], record["node"]["index"])
    print("===")

代码清单 2.15 来自混合搜索的答案

Considering Einstein's upbringing, his interest in inventions and patents was not unusual.
Being a manufacturer's son, Einstein grew up in an environment of machines and instruments.
When his father's company obtained the contract to illuminate Munich city during beer festival, he
was actively engaged in execution of the contract. In his ETH days Einstein was genuinely interested
in experimental works. He wrote to his friend, "most of the time I worked in the physical laboratory,
fascinated by the direct contact with observation." Einstein's
1.0 42
==============================================================================
→ October 15, 1909. Thereafter, he continued to rise in ladder. In 1911, he moved to Prague University as a
full professor, a year later, he was appointed as full professor at ETH, Zurich, his alma-mater. In 1914,
he was appointed Director of the Kaiser Wilhelm Institute for Physics (1914-1932) and a professor at
the Humboldt University of Berlin, with a special clause in his contract that freed him from teaching obligations. In the meantime, he was working for
0.9835733295862473 31

在这里我们可以看到,由于归一化,排名第一的结果得分为1.0。这意味着排名第一的结果与向量相似性搜索的排名第一的结果相同。但我们也可以看到排名第二的结果不同。这是因为全文搜索找到了比向量相似性搜索更好的匹配项。

2.4 总结思考

在本章中,我们探讨了什么是向量相似性搜索,它由哪些组件组成,以及它如何融入RAG应用。然后我们添加了全文搜索以提高检索器的性能。

通过同时使用向量相似性搜索和全文搜索,我们可以获得比仅使用其中一种方法更好的结果。虽然这种方法在某些情况下可能效果很好,但在使用混合搜索时,其质量、准确性和性能仍然相当有限,因为我们使用非结构化数据来检索信息。文本中的引用并不总是被捕获,并且周围的上下文并不总是足以让LLM理解文本的含义以生成好的答案。

在下一章中,我们将探讨如何改进检索器以获得更好的结果。

总结

  • RAG应用由检索器和生成器组成。检索器查找相关信息,生成器使用该信息创建响应。
  • 文本嵌入在向量空间中捕获文本的含义,这使我们能够使用向量相似性搜索来查找相似的文本。
  • 通过向RAG应用添加全文搜索,我们可以实现混合搜索以提高检索器的性能。
  • 向量相似性搜索和混合搜索在某些情况下可以很好地工作,但随着数据复杂性的增加,它们的质量、准确性和性能仍然相当有限。

第3章 高级向量检索策略

本章涵盖内容

  • 查询重写技术
  • 高级文本嵌入策略
  • 实现父文档检索

在本书的第2章中,您学习了文本嵌入和向量相似性搜索的基础知识。通过将文本转换为数值向量,您已经了解了机器如何理解内容的语义。结合文本嵌入和向量相似性搜索技术,可以从大量文档中优化和准确地检索相关的非结构化文本,使RAG应用中的答案更加准确和最新。假设您已经按照第2章所述实现并部署了RAG应用。经过一些测试,您和RAG应用的用户注意到,由于检索到的文档信息不完整或不相关,生成的答案准确性不足。因此,您的任务是增强检索系统以提高生成答案的准确性。

与任何技术一样,文本嵌入和向量相似性搜索的基本实现可能产生检索准确率和召回率不足的问题。由于术语或上下文差异,用户查询生成的嵌入可能并不总是与包含所需关键信息的文档的嵌入紧密对齐。这种差异可能导致查询意图高度相关的文档被忽视,因为查询的嵌入表示没有捕捉到所寻求信息的本质。

提高检索准确率和召回率的一种策略是重写用于查找相关文档的查询。查询重写方法旨在通过以更好地与目标文档的语言和上下文对齐的方式重新表述查询,来弥合用户查询与信息丰富的文档之间的差距。这种查询优化提高了找到包含相关信息文档的机会,从而增强了对原始查询响应的准确性。查询重写策略的例子包括假设文档检索器 (Gao et al., 2022) 或回溯提示 (Zheng et al., 2023)。回溯提示策略如图3.1所示。

image

图3.1 使用回溯技术重写查询以提高向量检索准确率

图3.1概述了一个过程,其中用户的查询被转换以改善文档检索结果,这种技术称为回溯提示。在所展示的场景中,用户提出了一个关于Estella Leopold在特定时间段内教育历史的详细问题。这个初始问题随后由一个具有查询重写能力的语言模型(如GPT-4)处理,将其重新表述为一个更通用的、关于Estella Leopold教育背景的询问。这一步的目的是在搜索过程中撒下更广泛的网,因为重写后的查询更有可能与一系列可能包含所需信息的文档对齐。

提高检索准确率的另一种方法是改变文档嵌入策略。在上一章中,您嵌入了一段文本,检索了相同的文本,并将其用作LLM的输入来生成答案。然而,向量检索系统很灵活,因为您不必限于嵌入计划检索的确切文本。相反,您可以嵌入能更好地表示文档含义的内容,例如更上下文相关的部分、合成问题或改述版本。这些替代方案可以更好地捕捉关键思想和主题,从而实现更准确和相关的检索。图3.2展示了两个高级嵌入策略的示例。

image

图3.2 假设问题和父文档检索器策略

图3.2的左侧展示了假设问题策略。采用假设问题嵌入策略,您必须确定文档中的信息可以回答哪些问题。例如,您可以使用LLM生成假设问题,或者使用聊天机器人的对话历史来构思文档可以回答的问题。其思想是,与其嵌入原始文档本身,不如嵌入文档可以回答的问题。例如,在图3.2中,问题“Leopold在加利福尼亚大学学习了什么?”被编码为向量[1,2,3,0,5]。当用户提出问题时,系统计算查询的嵌入,并在预计算的问题嵌入中搜索最近邻。目标是定位与用户问题最匹配且在语义上相似的问题。然后,系统检索那些包含能回答这些相似问题的信息的文档。本质上,假设问题嵌入策略涉及嵌入文档可能回答的潜在问题,并使用这些嵌入来匹配和检索响应用户查询的相关文档。

图3.2的右侧说明了父文档嵌入策略。在这种方法中,原始文档(称为父文档)被分割成更小的单元,称为子块,通常基于固定的词元数量。您不为整个父文档计算单个嵌入,而是为每个子块计算单独的嵌入。例如,子块“Leopold获得了植物学硕士学位”可能被嵌入为向量[1, 0, 3, 0, 1]。当用户提交查询时,系统将其与这些子嵌入进行比较,以找到最相关的匹配项。然而,系统并非仅返回匹配的子块,而是检索与之关联的整个原始父文档。这使得语言模型能够在信息的完整上下文中操作,增加了生成准确和完整答案的机会。

此策略解决了嵌入长文档的一个常见限制:当您嵌入完整的父文档时,生成的向量可能会通过平均化模糊不同的观点,使得有效匹配特定查询变得更加困难。相比之下,将文档分割成更小的块允许进行更精确的匹配,同时在需要时仍能让系统返回完整的上下文。

提高检索准确性的其他策略

除了改变文档嵌入策略外,还有几种技术可以增强检索准确性:

  • 微调文本嵌入模型:通过在特定领域的数据上调整嵌入模型,可以改进其捕捉用户查询上下文的能力,从而更接近地语义匹配相关文档。请注意,微调通常需要更多的计算和基础设施。此外,一旦模型更新,所有现有的文档嵌入都必须重新计算以反映这些变化——这对于大型文档库来说可能耗费大量资源。
  • 重排序策略:在初步检索到一组文档后,重排序算法可以根据与用户意图的相关性对它们进行重新排序。这第二遍通常使用更复杂的模型或评分启发法来优化结果。即使初始匹配不理想,重排序也有助于呈现最相关的内容。
  • 基于元数据的上下文过滤:许多文档包含结构化元数据,如作者、发布日期、主题标签或来源类型。基于这些元数据应用过滤器(无论是手动还是作为检索流程的一部分)可以在语义匹配之前显著缩小候选文档的范围,从而提高精度。例如,关于近期政策更新的查询可以限制在最近一年内发布的文档。
  • 混合检索(关键词+稠密向量搜索):将稀疏检索(例如基于关键词的搜索)与稠密向量检索(语义搜索)相结合,可以兼得两者之长。关键词搜索擅长精确匹配和罕见术语,而稠密检索捕捉更广泛的含义。混合系统可以合并和重新排序两种方法的结果,以最大化召回率和精确率。

虽然所有这些策略都可以提高检索质量,但详细的实施指导超出了本书的范围,除了第2章介绍的混合检索。

在本章的剩余部分,我们将从概念过渡到代码,并逐步完成实现。要跟随学习,您需要一个正在运行的、空白的Neo4j实例。这可以是本地安装或云托管实例;只需确保它是空的。您可以直接在随附的Jupyter笔记本中跟随实现,地址为:https://github.com/tomasonjo/kg-rag/blob/main/notebooks/ch03.ipynb。

想象您已经实现了第2章的基本RAG系统,但检索准确性还不够好。响应缺乏相关性或遗漏了重要的上下文,您怀疑系统未能检索到最能支持高质量答案的文档。为了解决这个问题,您决定通过添加一个回溯提示步骤来增强现有的RAG流程,以改进查询本身的质量。此外,您将把基本检索器切换到父文档检索器策略。这种方法通过匹配更小的块来实现更精细和准确的信息检索,同时仍然提供完整的父文档作为上下文。

这些改进旨在提高检索内容的相关性和生成答案的整体准确性。

3.1 回溯提示

如前所述,回溯提示是一种查询重写技术,旨在提高向量检索的准确性。原始论文中的一个例子 (Zheng et al., 2023) 演示了这个过程:将特定的查询“Thierry Audel在2007年到2008年为哪支球队效力?”扩展为“Thierry Audel在他的职业生涯中为哪些球队效力?”,以提高向量搜索的精确度,进而提高生成答案的准确性。通过将详细问题转化为更宽泛、更高层次的查询,回溯提示降低了向量搜索过程的复杂性。其理念是,更宽泛的查询通常涵盖更全面的信息范围,使模型更容易识别相关事实,而不会被细节所困扰。

作者使用LLM进行查询重写任务,如图3.3所示。

image

图3.3 使用LLM和回溯方法重写查询

LLM非常适合查询重写任务,因为它们擅长自然语言理解和生成。您不必为每个任务训练或微调一个新模型。相反,您可以在输入提示中提供任务指令。

回溯提示论文的作者使用以下清单中的系统提示来指导LLM如何重写输入查询。

代码清单 3.1 用于生成回溯问题的LLM系统提示

stepback_system_message = f'''
您是世界知识方面的专家。您的任务是退一步思考,将一个具体问题改述为一个更通用的、更高层次的问题,这样更容易回答。以下是几个例子:
"input": "警察成员可以进行合法逮捕吗?"
"output": "警察成员可以做什么?"
"input": "Jan Sindel出生在哪个国家?"
"output": "Jan Sindel的个人历史是什么?"
'''

代码清单3.1中的系统提示首先给LLM一个简单的指令,将用户的问题重写成一个更通用的、更高层次的版本。仅凭这种指令本身被称为零样本提示,它完全依赖于LLM的通用能力和对任务的理解,而不提供任何示例。然而,为了更有效地指导模型并确保结果的一致性,作者选择在提示中扩展了几个期望的改写行为的示例。这种技术被称为少样本提示,其中在提示中包含少量示例(通常是两个到五个)来说明任务。少样本提示通过将其锚定在具体实例中,帮助LLM更好地理解期望的转换,从而提高了输出的质量和可靠性。

为了实现查询重写,您需要做的就是将代码清单3.1中找到的系统提示与用户的问题一起发送给LLM。此任务的具体函数在下一个清单中概述。

代码清单 3.2 用于生成回溯问题的函数

def generate_stepback(question: str):
    user_message = f'"{question}"'
    step_back_question = chat(
        messages=[
            {"role": "system", "content": stepback_system_message},
            {"role": "user", "content": user_message},
        ]
    )
    return step_back_question

您可以通过执行以下代码来测试回溯提示生成。

代码清单 3.3 执行回溯提示函数

question = "Thierry Audel在2007年到2008年为哪支球队效力?"
step_back_question = generate_stepback(question)
print(f"回溯结果: {step_back_question}")
# 回溯结果: Thierry Audel的职业生涯历史是什么?

代码清单3.3中的结果展示了回溯提示生成函数的成功执行。通过将关于Thierry Audel在2007-2008年所在球队的具体查询转化为关于其整个职业生涯历史这一更广泛的问题,该函数有效地扩展了上下文,从而提高了检索的准确性和召回率。

练习 3.1

为了探索回溯提示生成的效果,尝试将其应用于各种问题,观察它是如何扩展上下文的。您也可以更改系统提示,观察其对输出的影响。

3.2 父文档检索器

父文档检索器策略涉及将一个大文档分割成较小的部分,为每个部分(而不是整个文档)计算嵌入,并使用这些嵌入更准确地匹配用户查询,最终检索整个文档以获取上下文丰富的响应。然而,由于无法将整个PDF直接输入LLM,您首先需要将PDF分割成父文档,然后再将这些父文档进一步分割成子文档以进行嵌入和检索。父文档和子文档的图表示如图3.4所示。

image

图3.4 父文档图表示

图3.4展示了一种基于图的方法来存储和组织文档,用于父文档检索策略。顶部,一个PDF节点代表整个文档,带有标题和标识符。此节点连接到几个父文档节点。在本例中,您将使用2000个字符的限制将PDF分割成父文档。这些父文档节点又链接到子文档节点,每个子节点包含相应父节点文本的一个500字符的块。子节点有一个嵌入向量,代表用于检索目的的文本子块。

我们将使用与第2章相同的文本,即一篇题为“Einstein’s Patents and Inventions”的论文,作者是Asis Kumar Chaudhuri (https://arxiv.org/abs/1709.00666)。此外,在处理将文档分割成更小的部分时,最好首先基于结构元素(如段落或章节)来分割文本。这种方法保持了内容的连贯性和上下文,因为段落或章节通常包含完整的思想或主题。因此,我们将首先将PDF文本按章节分割。

代码清单 3.4 使用正则表达式将文本按标题分割成章节

import re

def split_text_by_titles(text):
    # 一个正则表达式模式,用于匹配以数字开头,后跟可选大写字母、句点、空格,然后是60个字符以内的行
    title_pattern = re.compile(r"(\n\d+ [A-Z]?\.{1,3}.{0,60}\n)", re.DOTALL)
    titles = title_pattern.findall(text)
    # 在这些标题处分割文本
    sections = re.split(title_pattern, text)
    sections_with_titles = []
    # 添加第一部分
    sections_with_titles.append(sections[0].strip())
    # 遍历剩余的章节
    for i in range(1, len(titles) + 1):
        section_text = sections[i*2 - 1].strip() + "\n" + sections[i*2].strip()
        sections_with_titles.append(section_text)
    return sections_with_titles

sections = split_text_by_titles(text)
print(f"章节数量: {len(sections)}") # 章节数量: 9

代码清单3.4中的 split_text_by_titles 函数使用正则表达式按章节分割文本。该正则表达式基于文本按编号列表组织的事实,其中每个新章节以一个数字和一个可选字符开始,后跟一个句点和章节标题。split_text_by_titles 函数的输出是九个章节。如果您查看PDF,您会注意到只有四个主要章节。但是,还有四个描述部分专利的子章节(3A–3D),如果您将引言摘要算作自己的章节,总共会得到九个章节。

在继续父文档检索器之前,您将计算每个章节的词元数量,以更好地了解其长度。您将使用 tiktoken,这是由OpenAI开发的一个包,用于计算给定文本中的词元数量。

代码清单 3.5 计算章节中的词元数量

def num_tokens_from_string(string: str, model: str = "gpt-4") -> int:
    '''返回文本字符串中的词元数量。'''
    encoding = tiktoken.encoding_for_model(model)
    num_tokens = len(encoding.encode(string))
    return num_tokens

for s in sections:
    print(num_tokens_from_string(s))
# 输出可能是: 154, 254, 4186, 570, 2703, 1441, 194, 600...

大多数章节的大小相对较小,最多600个词元,这适合大多数LLM上下文提示。然而,第三个章节有超过4000个词元,这可能在LLM生成期间导致词元限制错误。因此,您必须将章节分割成父文档,其中每个文档最多包含2000个字符。您将使用上一章中的 chunk_text 函数来实现这一点。

代码清单 3.6 将章节分割成最大大小为2000字符的父文档

parent_chunks = []
for s in sections:
    parent_chunks.extend(chunk_text(s, 2000, 40))

练习 3.2

使用 num_tokens_from_string 函数确定每个父文档的词元计数。词元计数可以帮助您决定预处理中的其他步骤。例如,超过合理词元计数的较长部分应进一步拆分。另一方面,如果某些片段特别短,例如只有20个词元或更少,您应该考虑完全删除它们,因为它们可能不添加任何信息价值。

与其在后续步骤中拆分子块并导入它们,不如在单个步骤中执行拆分和导入。在一个步骤中执行这两个操作可以让您跳过存储中间结果的稍微更复杂的数据结构。在导入图之前,您需要定义导入Cypher语句。用于导入父文档结构图的Cypher语句相对简单。

代码清单 3.7 用于导入父文档策略图的Cypher查询

cypher_import_query = """
MERGE (pdf:PDF {id: $pdf_id})           -- 基于id属性合并PDF节点
MERGE (p:Parent {id: $pdf_id + '-' + $id})
SET p.text = $parent
MERGE (pdf)-[:HAS_PARENT]->(p)         -- 链接PDF和父文档
WITH p, $children AS children, $embeddings as embeddings
UNWIND range(0, size(children) - 1) AS child_index
MERGE (c:Child {id: $pdf_id + '-' + $id + '-' + toString(child_index)})
SET c.text = children[child_index], c.embedding = embeddings[child_index]
MERGE (p)-[:HAS_CHILD]->(c);          -- 链接父文档和子文档
"""

代码清单3.7中的Cypher语句首先合并一个PDF节点。接下来,它使用唯一ID合并父节点。然后,父节点通过 HAS_PARENT 关系链接到PDF节点,并设置文本属性。最后,它遍历子文档列表。为列表中的每个元素创建一个子节点,设置文本和嵌入属性,并通过 HAS_CHILD 关系将其链接到其父节点。

现在一切准备就绪,您可以将父文档结构导入图数据库。

代码清单 3.8 将父文档数据导入图数据库

for i, chunk in enumerate(parent_chunks):
    child_chunks = chunk_text(chunk, 500, 20)  # 将父文档分割成子块
    embeddings = embed(child_chunks)           # 计算子块的嵌入
    # 导入到Neo4j
    neo4j_driver.execute_query(
        cypher_import_query,
        id=str(i),
        pdf_id='1709.00666',
        parent=chunk,
        children=child_chunks,
        embeddings=embeddings,
    )

代码清单3.8中的代码首先遍历父文档块。每个父文档块使用 chunk_text 函数分割成多个子块。然后,代码使用 embed 函数计算这些子块的文本嵌入。生成嵌入后,execute_query 方法将数据导入Neo4j图数据库。

您可以通过在Neo4j浏览器中运行以下清单所示的Cypher语句来检查生成的图结构。

代码清单 3.9 在Neo4j浏览器中可视化图结构

MATCH p=(pdf:PDF)-[:HAS_PARENT]->()-[:HAS_CHILD]->()
RETURN p LIMIT 25

代码清单3.9中的Cypher语句生成了图3.5所示的图。此图可视化描绘了一个中心PDF节点连接到几个父节点,说明了文档与其部分之间的层次关系。每个父节点进一步链接到多个子节点,指示了文档结构中部分被分解为更小的块。

为了确保高效比较文档嵌入,您将添加一个向量索引。

代码清单 3.10 在子节点上创建向量索引

driver.execute_query("CREATE VECTOR INDEX parent IF NOT EXISTS FOR (c:Child) ON c.embedding")

代码清单3.10中生成向量索引的代码与第2章中使用的代码相同。这里,您在 Childembedding 属性上创建了一个向量索引。

image

图3.5 Neo4j浏览器中部分导入数据的图可视化

3.2.1 检索父文档策略数据

导入数据并定义向量索引后,您可以专注于实现检索部分。要从图中检索相关文档,您必须定义以下清单中描述的检索Cypher语句。

代码清单 3.11 父文档检索Cypher语句

retrieval_query = """
CALL db.index.vector.queryNodes($index_name, $k * 4, $question_embedding)
YIELD node, score
MATCH (node)<-[:HAS_CHILD]-(parent)            -- 找到子节点的父文档
WITH parent, max(score) AS score               -- 按父文档分组,取最高分
RETURN parent.text AS text, score
ORDER BY score DESC
LIMIT toInteger($k)                           -- 确保最终数量限制
"""

代码清单3.11中的Cypher语句通过在图数据库中执行基于向量的搜索开始,以识别与指定问题嵌入紧密相关的子节点。您可以看到,在初始向量搜索中,我们检索了 k * 4 个文档。在初始向量搜索中使用 k * 4 值的原因是,您预期来自向量搜索的多个相似子节点实际上可能属于同一个父文档。因此,对父文档进行去重变得至关重要。如果不进行去重,结果集中可能包含同一父文档的多个条目,每个条目对应该父文档的不同子节点。然而,为了保证最终获得 k 个唯一的父文档,您从一个更大的 k * 4 个子节点池开始,这有效地创建了一个安全缓冲。在Cypher语句的最后,您强制执行了最终的 k 限制。

以下清单展示了使用代码清单3.11中的Cypher语句从数据库检索父文档的函数。

代码清单 3.12 父文档检索函数

def parent_retrieval(question: str, k: int = 4) -> List[str]:
    question_embedding = embed([question])[0]
    similarRecords, _, _ = neo4j_driver.execute_query(
        retrieval_query,
        question_embedding=question_embedding,
        k=k,
        index_name="parent"  # 假设索引名为"parent"
    )
    return [record["text"] for record in similarRecords]

代码清单3.12中的 parent_retrieval 函数首先生成给定问题的文本嵌入,然后使用前面提到的Cypher语句从数据库中检索最相关的文档列表。

3.3 完整的RAG流程

流程的最后一部分是答案生成函数。

代码清单 3.13 使用LLM生成答案

system_message = "您是爱因斯坦专家,但只能使用提供的文档来回答问题。"

def generate_answer(question: str, documents: List[str]) -> str:
    user_message = f"""
    使用以下文档回答后续问题:
    {documents}
    ---
    仅使用上述文档中的信息回答的问题:{question}
    """
    result = chat(
        messages=[
            {"role": "system", "content": system_message},
            {"role": "user", "content": user_message},
        ]
    )
    return result

# 测试
question = "爱因斯坦的衬衫设计专利是什么时候授予的?"
documents = parent_retrieval(question)
answer = generate_answer(question, documents)
print("答案:", answer)

代码清单3.13中的代码与第2章中的代码相同。您将问题与相关文档一起传递给LLM,并提示其生成答案。

在实现了回溯提示和父文档检索之后,您准备在一个单一函数中将它们整合起来。

代码清单 3.14 完整的带有回溯提示的父文档检索器RAG流程

def rag_pipeline(question: str) -> str:
    stepback_prompt = generate_stepback(question)
    print(f"回溯提示: {stepback_prompt}")
    documents = parent_retrieval(stepback_prompt)
    answer = generate_answer(question, documents)
    return answer

代码清单3.14中的 rag_pipeline 函数接受一个问题作为输入,并创建一个回溯提示。然后,它基于回溯提示检索相关文档,并将它们与原始问题一起传递给LLM以生成最终答案。

您现在可以测试 rag_pipeline 的实现。

代码清单 3.15 测试完整的RAG流程

answer = rag_pipeline("爱因斯坦的衬衫设计专利是什么时候授予的?")
print(f"最终答案: {answer}")
# 回溯提示: 爱因斯坦有哪些值得注意的成就?
# 答案: 爱因斯坦于1936年10月27日被授予其衬衫设计的专利。

练习 3.3

通过询问PDF中提到的关于爱因斯坦生活的其他问题来评估 rag_pipeline 实现的性能。此外,您可以移除回溯提示步骤,以比较它是否改善了结果。

祝贺您!您已通过结合查询重写和父文档检索,成功实现了高级向量搜索检索策略。

总结

  • 查询重写可以通过使用户查询更接近目标文档的语言和上下文来提高文档检索的准确性。像假设文档检索器和回溯提示这样的技术有效地弥合了用户意图与文档内容之间的差距,减少了遗漏相关信息的机会。
  • 通过嵌入不仅仅是精确文本,还包括上下文相关的摘要或改写,可以改进检索系统的有效性,捕捉文档的本质。实施像假设问题嵌入和父文档检索这样的策略可以导致查询和文档之间更精确的匹配,增强检索信息的相关性和准确性。
  • 将文档分割成更小的、更易于管理的块用于嵌入目的,允许采用更精细的信息检索方法,确保特定查询找到最相关的文档部分。

第4章 从自然语言问题生成Cypher查询

本章涵盖内容

  • 查询语言生成基础
  • 查询语言生成在RAG流程中的位置
  • 查询语言生成的有用实践
  • 使用基础模型实现文本到Cypher检索器
  • 用于文本到Cypher的专用(微调)LLM

在前面的章节中,我们已经涵盖了相当多的内容。我们学习了如何构建知识图谱、从文本中提取信息以及使用这些信息来回答问题。我们还研究了如何通过使用硬编码的Cypher查询来扩展和改进纯向量搜索检索,从而为LLM提供更相关的上下文。在本章中,我们将更进一步,学习如何从自然语言问题生成Cypher查询。这将使我们能够构建一个更灵活、动态的检索系统,能够适应不同类型的问题和知识图谱。

注意 在本章的实现中,我们使用所谓的“电影数据集”。有关该数据集及其各种加载方式的更多信息,请参阅附录。

4.1 查询语言生成基础

当我们谈论查询语言生成基础时,我们指的是将自然语言问题转换为可在数据库上执行的查询语言的过程。更具体地说,我们对从自然语言问题生成Cypher查询感兴趣。大多数LLM知道Cypher是什么,并了解该语言的基本语法。此过程的主要挑战是生成一个既正确又与所提问题相关的查询。这需要理解问题的语义,以及被查询知识图谱的模式。

如果我们不提供知识图谱的模式,LLM只能假设节点、关系和属性的名称。当提供模式时,它充当用户问题的语义和所使用的图模型之间的映射——包括节点上使用的标签、存在的关系类型、可用的属性,以及节点连接到哪些关系类型。

从自然语言问题生成Cypher查询的工作流程可以分解为以下步骤(图4.1):

  1. 从用户处检索问题。
  2. 检索知识图谱的模式。
  3. 定义其他有用的信息,如术语映射、格式说明和少样本示例。
  4. 为LLM生成提示。
  5. 将提示传递给LLM以生成Cypher查询。

image

图4.1 从自然语言问题生成Cypher查询的工作流程

4.2 查询语言生成在RAG流程中的位置

在之前的章节中,我们已经看到了如何通过对图表的非结构化部分执行向量相似性搜索,从知识图谱中获得相关的响应。我们还看到了如何使用扩展了硬编码Cypher查询的向量相似性搜索来为LLM提供更相关的上下文。这些技术的一个限制是它们可以回答的问题类型是有限的。

考虑用户问题:“列出由史蒂文·斯皮尔伯格执导的三部最高评分电影及其平均分数。”这个问题永远无法通过向量相似性搜索来回答,因为它需要对数据库执行特定类型的查询,而Cypher查询可能类似于以下内容(假设有一个合理的模式)。

清单 4.1 Cypher 查询示例

MATCH (:Reviewer) - [:REVIEWED] -> (m:Movie) <- [:DIRECTED] - (:Director {name: 'Steven Spielberg'})
RETURN m.title, AVG(r.score) AS avg_rating
ORDER BY avg_rating DESC
LIMIT 3

这个查询与其说是关于图中最相似的节点,不如说是以特定方式聚合数据。这说明了我们希望为某些类型的查询使用生成的Cypher——当我们寻找的不仅仅是图中最相似的节点,或者当我们想以某种方式聚合数据时。在下一章中,我们将研究如何创建一个代理系统,在这个系统中,我们可以提供多个检索器,并为每个用户问题使用最合适的一个,以便能够向用户提供最佳的响应。

文本到Cypher也可以作为系统中那些其他检索器没有真正良好匹配的问题类型的“全能”检索器。

4.3 查询语言生成的有用实践

从自然语言问题生成Cypher查询时,需要牢记一些事项,以确保生成的查询正确且相关。LLM在生成Cypher查询时往往会出错,特别是当输入问题复杂或模糊,或者数据库模式元素没有语义化命名时。

4.3.1 使用少样本示例进行上下文学习

少样本示例是提高LLM在文本到Cypher任务上性能的好方法。这意味着我们可以向LLM提供几个问题及其相应Cypher查询的示例,LLM将学习为新的问题生成类似的查询。相比之下,零样本示例是指我们不给LLM提供任何示例,它必须毫无提示地生成查询。

少样本示例是针对被查询的知识图谱定制的,因此需要为每个知识图谱手动创建。当您发现LLM误解了模式或经常犯同一类型的错误(例如,当应该遍历时却期望一个属性等)时,这将非常有用。

假设您发现LLM正在尝试读取电影的制作国家,它正在寻找电影节点上的一个属性,但国家实际上是图中的一个节点。那么,您可以在提示中添加一个少样本示例,让LLM知道如何获取国家名称。

原始问题:“电影《黑客帝国》在哪个国家制作?”
错误的Cypher查询:MATCH (m:Movie {title: 'The Matrix'}) RETURN m.country

可以通过在提示中添加以下少样本示例来修复:

示例:

  • 问题:电影《头号玩家》在哪个国家制作?
  • Cypher:MATCH (m:Movie {title: 'Ready Player One'})-[:PRODUCED_IN]->(c:Country) RETURN c.name

修正后的问题:“电影《黑客帝国》在哪个国家制作?”
修正后的Cypher:MATCH (m:Movie {title: 'The Matrix'})-[:PRODUCED_IN]->(c:Country) RETURN c.name

这不仅能解决这个特定问题,还能为类似问题提供清晰的示例,让LLM看到获取国家名称的模式。

4.3.2 在提示中使用数据库模式以向LLM展示知识图谱的结构

知识图谱的模式对于生成正确的Cypher查询至关重要。有几种方法可以向LLM描述知识图谱模式,根据我们在Neo4j的内部研究,格式并不那么重要。

模式应该是提示的一部分,并清楚地说明图中可用的标签、关系类型和属性:

Graph database schema:
Use only the provided relationship types and properties in the schema.
Do not use any other relationship types or properties that are not provided in the schema.

Node labels and properties:
LabelA {property_a: STRING}

Relationship types and properties:
REL_TYPE {rel_prop: STRING}

The relationships:
(:LabelA)-[:REL_TYPE]->(:LabelB)
(:LabelA)-[:REL_TYPE]->(:LabelC)

您是否希望暴露整个知识图谱以供查询,可能取决于模式的大小以及它是否与用例相关。从Neo4j自动推断模式可能成本高昂,这取决于数据的大小,因此通常对数据库进行采样并从该样本中推断模式。

要从Neo4j推断模式,我们目前需要使用APOC库中的过程,该库在Neo4j的SaaS产品Aura和其他发行版中都是免费可用的。以下清单展示了如何从Neo4j数据库推断模式。

提示 您可以在 https://neo4j.com/docs/apoc/ 阅读更多关于APOC的信息。

清单 4.2 从 Neo4j 推断模式

NODE_PROPERTIES_QUERY = """
CALL apoc.meta.data()
YIELD label, other, elementType, type, property
WHERE NOT type = "RELATIONSHIP" AND elementType = "node"
WITH label AS nodeLabels, collect({property:property, type:type}) AS properties
RETURN {labels: nodeLabels, properties: properties} AS output
"""

REL_PROPERTIES_QUERY = """
CALL apoc.meta.data()
YIELD label, other, elementType, type, property
WHERE NOT type = "RELATIONSHIP" AND elementType = "relationship"
WITH label AS relType, collect({property:property, type:type}) AS properties
RETURN {type: relType, properties: properties} AS output
"""

REL_QUERY = """
CALL apoc.meta.data()
YIELD label, other, elementType, type, property
WHERE type = "RELATIONSHIP" AND elementType = "node"
UNWIND other AS other_node
RETURN {start: label, type: property, end: toString(other_node)} AS output
"""

有了这些查询,我们现在可以获取图数据库的模式,并将其用于LLM的提示。让我们运行查询并以结构化方式存储结果,以便稍后生成之前的模式字符串。

清单 4.3 运行模式推断查询

def get_structured_schema(driver: neo4j.Driver) -> dict[str, Any]:
    node_labels_response = driver.execute_query(NODE_PROPERTIES_QUERY)
    node_properties = [data["output"] for data in [r.data() for r in node_labels_response.records]]
    rel_properties_query_response = driver.execute_query(REL_PROPERTIES_QUERY)
    rel_properties = [data["output"] for data in [r.data() for r in rel_properties_query_response.records]]
    rel_query_response = driver.execute_query(REL_QUERY)
    relationships = [data["output"] for data in [r.data() for r in rel_query_response.records]]
    return {
        "nodeprops": {el["labels"]: el["properties"] for el in node_properties},
        "relprops": {el["type"]: el["properties"] for el in rel_properties},
        "relationships": relationships,
    }

有了这个结构化的响应,我们可以根据需要格式化模式字符串,也便于我们探索和试验提示中的不同格式。

要获得本章前面说明的格式,我们可以使用下面清单中显示的函数。

清单 4.4 格式化模式字符串

def get_schema(structured_schema: dict[str, Any]) -> str:
    def _format_props(props: list[dict[str, Any]]) -> str:
        return ", ".join([f"{prop['property']}: {prop['type']}" for prop in props])

    formatted_nodeprops = [
        f"{label} {{{_format_props(props)}}}"
        for label, props in structured_schema["nodeprops"].items()
    ]
    formatted_relprops = [
        f"{rel_type} {{{_format_props(props)}}}"
        for rel_type, props in structured_schema["relprops"].items()
    ]
    formatted_rels = [
        f"(:{element['start']})-[:{element['type']}]->(:{element['end']})"
        for element in structured_schema["relationships"]
    ]
    return "\n".join([
        "Node labels and properties:\n" + "\n".join(formatted_nodeprops),
        "Relationship types and properties:\n" + "\n".join(formatted_relprops),
        "The relationships:\n" + "\n".join(formatted_rels),
    ])

有了这个函数,我们现在可以生成可以在LLM提示中使用的模式字符串。

4.3.3 添加术语映射以将用户问题语义映射到模式

LLM需要知道如何将问题中使用的术语映射到模式中使用的术语。一个设计良好的图模式使用名词和动词作为标签和关系类型,使用形容词和名词作为属性。即使是这样,LLM有时也会对在何处使用什么感到困惑。

注意 这些映射是知识图谱特定的,应该是提示的一部分;很难在不同的知识图谱之间重用。

随着您发现由于LLM未正确理解模式而导致生成的查询出现问题,术语映射可能会随着时间的推移而发展。

术语映射示例:

  • 人物:当用户询问职业为演员、作家、导演、制片人或评论家的人时,他们指的是标签为 Person 的节点。
  • 电影:当用户询问电影时,他们指的是标签为 Movie 的节点。

4.3.4 格式说明

不同的LLM以不同的方式输出响应。有些会在Cypher查询周围加上代码标签,有些则不会。有些会在Cypher查询之前添加文本,有些则不会,等等。

为了让它们都以相同的方式输出,您可以在提示中添加格式说明。有用的指示是尝试让LLM只输出Cypher查询,不输出其他任何内容。

格式说明示例:

Do not include any explanations or apologies in your responses.
Do not respond to any questions that might ask anything else than for you to construct a Cypher statement.
Do not include any text except the generated Cypher statement.
ONLY RESPOND WITH CYPHER, NO CODE BLOCKS.

4.4 使用基础模型实现文本到Cypher生成器

让我们将所有这些付诸实践,使用一个基础模型来实现一个文本到Cypher生成器。这里的任务基本上是形成一个提示,其中包括模式、术语映射、格式说明和少样本示例,以向LLM明确我们的意图。

在本章的其余部分,我们将使用Neo4j Python驱动程序和OpenAI API实现一个文本到Cypher生成器。要跟随学习,您需要一个正在运行的、空白的Neo4j实例。这可以是本地安装或云托管实例;只需确保它是空的。您可以直接在随附的Jupyter笔记本中跟随实现,地址为:https://github.com/tomasonjo/kg-rag/blob/main/notebooks/ch04.ipynb。

让我们开始吧。

清单 4.5 提示模板

prompt_template = """
Instructions:
Generate Cypher statement to query a graph database to get the data to answer the following user question.

Graph database schema:
Use only the provided relationship types and properties in the schema.
Do not use any other relationship types or properties that are not provided in the schema.
{schema}

Terminology mapping:
This section is helpful to map terminology between the user question and the graph database schema.
{terminology}

Examples:
The following examples provide useful patterns for querying the graph database.
{examples}

Format instructions:
Do not include any explanations or apologies in your responses.
Do not respond to any questions that might ask anything else than for you to construct a Cypher statement.
Do not include any text except the generated Cypher statement.
ONLY RESPOND WITH CYPHER, NO CODE BLOCKS.

User question: {question}
"""

有了这个提示模板,我们现在可以为LLM生成提示。假设我们有以下用户问题、模式、术语映射和少样本示例。

清单 4.6 完整提示示例

question = "Who directed the most movies?"
schema_string = get_schema(neo4j_driver)
terminology_string = """
Persons: When a user asks about a person by trade like actor, writer, director, producer, or reviewer, they are referring to a node with the label 'Person'.
Movies: When a user asks about a film or movie, they are referring to a node with the label Movie.
"""
examples = [
    ["Who are the two people acted in most movies together?",
     "MATCH (p1:Person)-[:ACTED_IN]->(m:Movie)<-[:ACTED_IN]-(p2:Person) WHERE p1 <> p2 RETURN p1.name, p2.name, COUNT(m) AS movieCount ORDER BY movieCount DESC LIMIT 1"]
]
full_prompt = prompt_template.format(
    question=question,
    schema=schema_string,
    terminology=terminology_string,
    examples="\n".join([f"Question: {e[0]}\nCypher: {e[1]}" for i, e in enumerate(examples)])
)
print(full_prompt)

如果我们执行这个示例,提示输出将如下所示:

Instructions:
Generate Cypher statement to query a graph database to get the data to answer the following user question.

Graph database schema:
Use only the provided relationship types and properties in the schema.
Do not use any other relationship types or properties that are not provided in the schema.
Node properties:
Movie {tagline: STRING, title: STRING, released: INTEGER}
Person {born: INTEGER, name: STRING}
Relationship properties:
ACTED_IN {roles: LIST}
REVIEWED {summary: STRING, rating: INTEGER}
The relationships:
(:Person)-[:ACTED_IN]->(:Movie)
(:Person)-[:DIRECTED]->(:Movie)
(:Person)-[:PRODUCED]->(:Movie)
(:Person)-[:WROTE]->(:Movie)
(:Person)-[:FOLLOWS]->(:Person)
(:Person)-[:REVIEWED]->(:Movie)

Terminology mapping:
This section is helpful to map terminology between the user question and the graph database schema.
Persons: When a user asks about a person by trade like actor, writer, director, producer, or reviewer, they are referring to a node with the label 'Person'.
Movies: When a user asks about a film or movie, they are referring to a node with the label Movie.

Examples:
The following examples provide useful patterns for querying the graph database.
Question: Who are the two people who have acted in the most movies together?
Cypher: MATCH (p1:Person)-[:ACTED_IN]->(m:Movie)<-[:ACTED_IN]-(p2:Person)
WHERE p1 <> p2
RETURN p1.name, p2.name, COUNT(m) AS movieCount
ORDER BY movieCount DESC
LIMIT 1

Format instructions:
Do not include any explanations or apologies in your responses.
Do not respond to any questions that might ask anything else than for you to construct a Cypher statement.
Do not include any text except the generated Cypher statement.
ONLY RESPOND WITH CYPHER, NO CODE BLOCKS.

User question: Who has directed the most movies?

有了这个提示,我们现在可以为用户的问题生成Cypher查询。您可以通过将提示复制到LLM来尝试,看看它会生成什么。

清单 4.7 生成的 Cypher 查询

MATCH (p:Person)-[:DIRECTED]->(m:Movie)
RETURN p.name, COUNT(m) AS movieCount
ORDER BY movieCount DESC
LIMIT 1

4.5 用于文本到Cypher的专用(微调)LLM

在Neo4j,我们正在通过微调不断提高LLM在文本到Cypher方面的性能。我们在Hugging Face的开源训练数据可在 https://huggingface.co/datasets/neo4j/text2cypher 获取。我们还提供基于开源LLM(如Gemma2、Llama 3.1)的微调模型,地址是 https://huggingface.co/neo4j。

这些模型在性能上仍然远远落后于像最新的GPT和Gemini模型这样的微调大型模型,但它们更高效,可以在生产系统中使用,而大型模型在这些系统中可能太慢。您可以尝试一下,并参考少样本示例、模式、术语映射和格式说明来提高模型的性能。关于我们的微调过程和经验教训的更多信息,请访问 https://mng.bz/MwDW, https://mng.bz/a9v7 和 https://mng.bz/yNWB。

4.6 我们学到了什么以及文本到Cypher实现了什么

通过本章的代码和信息,您应该能够为您的知识图谱实现一个文本到Cypher检索器。您应该能够让它为广泛的问题生成正确的Cypher查询,并通过提供少样本示例、模式、术语映射和格式说明来提高其性能。

当您发现它难以处理的问题类型时,可以向提示中添加更多少样本示例,以帮助它学习如何生成正确的查询。随着时间的推移,您会注意到生成的查询质量得到改善,检索器变得更加可靠。

总结

  • 查询语言生成作为对其他检索方法的补充,非常契合RAG流程,特别是当我们想要聚合数据或从图中获取特定数据时。
  • 查询语言生成的有用实践包括使用少样本示例、模式、术语映射和格式说明。
  • 我们可以使用基础模型并构建提示给LLM,来实现一个文本到Cypher检索器。
  • 我们可以使用专用的(微调)LLM进行文本到Cypher并提高其性能。

第5章 代理式RAG

本章涵盖内容

  • 什么是代理式RAG
  • 为什么需要代理式RAG
  • 如何实现代理式RAG

在之前的章节中,我们看到了如何使用不同的向量相似性搜索方法查找相关数据。使用相似性搜索,我们可以在非结构化数据源中找到相关数据,但具有结构的数据通常比非结构化数据带来更多价值,因为结构本身蕴含着信息。

向数据添加结构可以是一个渐进的过程。我们可以从一个简单的结构开始,然后随着进展添加更复杂的结构。我们在上一章看到了这一点,从简单的图数据开始,然后向其添加了更复杂的结构。

代理式RAG系统(见图5.1)是一个系统,其中提供多种检索代理来检索回答用户问题所需的数据。代理式RAG系统的起始接口通常是一个检索器路由器,其工作是找到最适合手头任务的检索器(或检索器)。

实现代理式RAG系统的一种常见方法是利用LLM使用工具(有时称为函数调用)的能力。并非所有LLM都有此能力,但OpenAI的GPT-3.5和GPT-4有,这也正是我们将在本章使用的。这可以通过大多数LLM使用ReAct方法来实现(参见 https://arxiv.org/abs/2210.03629),但随着时间的推移,当前的趋势是此功能将在所有LLM中可用。

image

图5.1 使用代理式RAG的应用数据流

5.1 什么是代理式RAG?

代理式系统在复杂性和复杂度上各不相同,但其核心思想是系统可以代表用户执行任务。在本章中,我们将研究一个基本的代理式系统,其中系统只需要选择使用哪个检索器,并决定找到的上下文是否能回答问题。在更高级的系统中,系统可能会制定计划来执行何种任务以解决手头的问题。从本章的基础知识开始,是理解代理式系统核心概念的好方法,对于RAG任务来说,这通常就是您所需要的。

代理式RAG是一个系统,通过该系统,多种检索代理可用来检索回答用户问题所需的数据。成功的代理式RAG系统需要一些基础部分:

  • 检索器路由器 — 一个接收用户问题并返回最佳检索器以供使用的函数
  • 检索器代理 — 可用于检索回答用户问题所需数据的实际检索器
  • 答案评判器 — 一个接收来自检索器的答案并检查原始问题是否被正确回答的函数

5.1.1 检索器代理

检索器代理是可用于检索回答用户问题所需数据的实际检索器。这些检索器可以非常宽泛,比如向量相似性搜索,也可以非常具体,比如接收参数(如检索器路由器,见第5.1.2节)的硬编码数据库查询模板。

一些通用的检索器代理在大多数代理式RAG系统中都相关,例如向量相似性搜索和文本到Cypher。前者对非结构化数据源有用,后者对图数据库中的结构化数据有用,但在现实世界的生产系统中,要使它们中的任何一个达到符合用户期望的性能水平并非易事。

这就是为什么我们需要专门的检索器,这些检索器范围很窄,但擅长其本职工作。这些专门的检索器可以随着时间的推移而构建,因为我们会识别出通用检索器难以生成查询来回答的问题。

5.1.2 检索器路由器

为了为任务选择合适的检索器,我们有一个称为检索器路由器的东西。检索器路由器是一个接收用户问题并返回最佳检索器以供使用的函数。路由器如何做出此决定可能各不相同,但通常使用LLM来做出此决定。

假设我们有一个类似“法国的首都是什么?”的问题。假设我们编码了两个可用的检索器代理(两者都从数据库中检索答案):

  • capital_by_country — 接收国家名称并返回该国首都的检索器
  • country_by_capital — 接收首都名称并返回该首都所在国家的检索器

这两个检索器都可以是接收国家或首都参数的硬编码数据库查询。

检索器路由器可以是一个LLM,它接收用户问题并返回要使用的最佳检索器。在这种情况下,LLM可以返回capital_by_country检索器,并将"France"作为提取的参数。因此,对检索器的实际调用将是capital_by_country("France")

这是一个简单的例子,但在现实场景中,可能有许多检索器可用。检索器路由器可以是一个复杂的函数,它使用LLM来选择最适合工作的检索器。

5.1.3 答案评判器

答案评判器是一个接收来自检索器的答案并检查原始问题是否被正确回答的函数。答案评判器是一个阻塞函数,如果答案不正确或不完整,它可以阻止答案返回给用户。

如果一个不完整或不正确的答案被阻止,答案评判器应该生成一个新问题,该问题可用于检索正确答案,并进行另一轮检索正确答案。可能正确的答案在数据源中不可用,因此需要从此循环中退出的标准;答案评判器应该能够处理这种情况,并向用户返回消息,说明在这种情况下答案不可用。

5.2 为什么需要代理式RAG?

代理式RAG有用的一个领域是当我们有多种数据源,并且我们希望为工作使用最佳数据源时。另一个常见用途是当数据源非常广泛或复杂,并且我们需要专门的检索器来一致地检索所需数据时。

正如本书前面所见,像向量相似性搜索这样的通用检索器可以在非结构化数据源中找到相关数据。当我们有像图数据库这样的结构化数据源时,我们可能会使用像第4章介绍的文本到Cypher这样的通用检索器。如果数据非常复杂,像文本到Cypher这样的工具可能难以生成正确的查询。在这种情况下,可以使用专门的检索器来检索正确的数据。例如,这可能是一个狭窄的文本到Cypher检索器或接收参数的硬编码数据库查询。

随着时间的推移,我们可以识别出文本到Cypher等工具难以生成查询来回答的问题,我们可以为这些问题构建专门的检索器,并将文本到Cypher作为没有良好特定检索器匹配时的备用检索器。

这就是代理式RAG的用武之地。有多种检索器可用,我们需要为工作使用最佳检索器,并在将答案返回给用户之前评估答案。在生产环境中,这对于保持系统的高性能和答案质量的一致性非常有用。

5.3 如何实现代理式RAG

在本节中,我们将逐步介绍如何实现代理式RAG系统的基础部分。您可以直接在随附的Jupyter笔记本中跟随实现,地址为:https://github.com/tomasonjo/kg-rag/blob/main/notebooks/ch05.ipynb。

注意 在本章的实现中,我们使用所谓的“电影数据集”。有关该数据集及其各种加载方式的更多信息,请参阅附录。

5.3.1 实现检索器工具

在我们能够将用户输入路由到正确的检索器之前,我们需要让路由器有可用的检索器以供选择。检索器可以非常宽泛,比如向量相似性搜索,也可以非常具体,比如接收参数的硬编码数据库查询模板。

在这个实际示例中,我们将使用一个简单的检索器列表:两个使用Cypher模板通过标题获取电影信息和通过演员姓名获取电影信息,另一个使用文本到Cypher处理所有其他问题。如前所述,有用的检索器集合因系统而异,应随着时间的推移根据需要添加,以提高应用程序的性能。

清单 5.1 可用的检索器工具定义

text2cypher_description = {
    "type": "function",
    "function": {
        "name": "text2cypher",
        "description": "使用用户问题查询数据库。当其他工具不适用时,回退使用此工具。",
        "parameters": {
            "type": "object",
            "properties": {
                "question": {
                    "type": "string",
                    "description": "要寻找答案的用户问题",
                },
            },
            "required": ["question"]
        },
    },
}

def text2cypher(question: str):
    '''使用文本到Cypher工具查询数据库。'''
    # 假设text2cypher函数在其他地方已定义
    pass

movie_info_by_title_description = {
    "type": "function",
    "function": {
        "name": "movie_info_by_title",
        "description": "通过提供电影标题获取电影信息",
        "parameters": {
            "type": "object",
            "properties": {
                "title": {
                    "type": "string",
                    "description": "电影标题",
                },
            },
            "required": ["title"],
        },
    },
}

def movie_info_by_title(title: str):
    '''通过标题返回电影信息。'''
    query = '''
    MATCH (m:Movie)
    WHERE toLower(m.title) CONTAINS $title
    OPTIONAL MATCH (m) <- [:ACTED_IN] - (a:Person)
    OPTIONAL MATCH (m) <- [:DIRECTED] - (d:Person)
    RETURN m AS movie, collect(a.name) AS cast, collect(d.name) AS directors
    '''
    records, _, _ = neo4j_driver.execute_query(query, title=title.lower())
    return [record.data() for record in records]

movies_info_by_actor_description = {
    "type": "function",
    "function": {
        "name": "movies_info_by_actor",
        "description": "通过提供演员姓名获取电影信息",
        "parameters": {
            "type": "object",
            "properties": {
                "actor": {
                    "type": "string",
                    "description": "演员姓名",
                },
            },
            "required": ["actor"],
        },
    },
}

def movies_info_by_actor(actor: str):
    '''通过演员返回电影信息。'''
    query = '''
    MATCH (a:Person) - [:ACTED_IN] -> (m:Movie)
    OPTIONAL MATCH (m) <- [:ACTED_IN] - (a2:Person)
    OPTIONAL MATCH (m) <- [:DIRECTED] - (d:Person)
    WHERE toLower(a.name) CONTAINS $actor
    RETURN m AS movie, collect(a2.name) AS cast, collect(d.name) AS directors
    '''
    records, _, _ = neo4j_driver.execute_query(query, actor=actor.lower())
    return [record.data() for record in records]

注意,neo4j_drivertext2cypher 是导入项,您可以在本书的代码存储库中找到它们的实现。

注意 上面的检索器定义遵循了撰写本书时OpenAI的工具格式。

我们需要小心如何向LLM描述检索器。我们需要确保LLM理解检索器,并可以决定使用哪个检索器。参数描述也非常重要,以便LLM可以正确调用检索器。

请注意,LLM无法实际调用您的检索器;它只能决定使用哪个检索器以及向检索器传递什么参数。对检索器的实际调用需要由调用LLM的系统完成,我们将在下一节看到。

关于通用检索器工具的说明
我们几乎总是在代理式RAG系统中包含的一个通用检索器工具是,如果问题的答案已经在问题或上下文的其他部分给出,则会调用该工具。此工具通常是一个简单的函数,从问题或上下文中提取答案并返回。
例如,类似“Dave Smith姓什么?”的问题。这个检索器工具可能如下所示。

清单 5.2 用于上下文已有答案的通用检索器工具

answer_given_description = {
    "type": "function",
    "function": {
        "name": "answer_given",
        "description": "如果问题的完整答案已在对话中提供,请使用此工具提取它。",
        "parameters": {
            "type": "object",
            "properties": {
                "answer": {
                    "type": "string",
                    "description": "问题的答案",
                }
            },
            "required": ["answer"],
        },
    },
}

def answer_given(answer: str):
    '''返回已提供的答案。'''
    return answer

5.3.2 实现检索器路由器

检索器路由器是代理式RAG系统的核心部分。它的工作是接收用户问题并返回要使用的最佳检索器。

在实现检索器路由器时,我们将使用LLM来帮助我们完成任务。我们将向LLM提供检索器列表和用户问题,LLM将返回用于查找每个问题答案的最佳检索器。为简单起见,我们将使用具有官方工具/函数调用支持的LLM,例如OpenAI的GPT-4o。其他LLM也可以实现此功能,但实现方式可能不同。

在深入研究路由函数之前,我们需要了解一些能够成功构建代理式RAG系统所需的部分。这些部分是:

  1. 代表LLM处理工具调用
  2. 连续查询更新
  3. 将问题路由到相关检索器
代表LLM处理工具调用

当LLM返回要使用的最佳检索器时,系统需要调用该检索器。这可以通过一个函数来完成,该函数接收检索器和参数并调用检索器。下面清单展示了一个该函数可能的样子。

清单 5.3 检索器调用处理函数

def handle_tool_calls(tools: dict[str, any], llm_tool_calls: list[dict[str, any]]):
    output = []
    if llm_tool_calls:
        for tool_call in llm_tool_calls:
            function_to_call = tools[tool_call.function.name]["function"]
            function_args = json.loads(tool_call.function.arguments)
            res = function_to_call(**function_args)
            output.append(res)
    return output

我们传入的tools是一个字典,其中键是工具的名称,值是要调用的实际函数。llm_tool_calls是LLM决定使用的工具及其传递给工具的参数列表。LLM可以决定它想要进行多个函数调用来响应单个问题。llm_tool_calls参数的形式如下所示:

[
    {
        "function": {
            "name": "answer_given",
            "arguments": "{\"answer\": \"Dave Smith\"}"
        }
    }
]
连续查询更新

当我们稍后到达检索器路由器函数部分时,我们会看到我们将逐个按顺序向LLM发送问题。这是一个深思熟虑的选择,以使LLM更容易单独处理每个问题,并更容易将问题路由到正确的检索器。

按顺序发送问题的一个额外好处是,我们可以使用先前问题的答案来重写下一个问题。如果用户提出一个依赖于前一个问题答案的后续问题,这将非常有用。

考虑以下示例:“谁获得了最多的奥斯卡奖,那个人还活着吗?”这个问题的重写可以是“谁获得了最多的奥斯卡奖?”和“那个人还活着吗?”,其中第二个问题依赖于第一个问题的答案。

因此,一旦我们有了第一个问题的答案,我们就希望用新信息更新剩余的问题。这可以通过调用查询更新器,并传入原始问题和来自检索器的答案来实现。查询更新器用新信息更新现有问题。

清单 5.4 查询更新器指令

query_update_prompt = """
You are an expert at updating questions to make them more atomic, specific, and easier to find the answer to. You do this by filling in missing information in the question, with the extra information provided to you in previous answers. You respond with the updated question that has all information in it. Only edit the question if needed. If the original question already is atomic, specific, and easy to answer, you keep the original. Do not ask for more information than the original question. Only rephrase the question to make it more complete.

JSON template to use:
{
    "question": "question1"
}
"""

查询更新器被调用时传入原始问题和来自检索器的答案。输出是更新后的问题,我们指示LLM以JSON格式返回更新后的问题。重要的是,LLM不要要求比原始问题更多的信息——只重述问题使其更完整。

清单 5.5 查询更新器函数

def query_update(input: str, answers: list[any]) -> str:
    messages = [
        {"role": "system", "content": query_update_prompt},
        *answers,
        {"role": "user", "content": f"The user question to rewrite: '{input}'"},
    ]
    config = {"response_format": {"type": "json_object"}}
    output = chat(messages, model="gpt-4o", config=config)
    try:
        return json.loads(output)["question"]
    except json.JSONDecodeError:
        print("Error decoding JSON")
        return input

有了这个,我们可以随着进展用新信息更新问题,并确保问题尽可能完整,并且我们尽可能容易地找到问题的答案。

问题路由

检索器路由器的最后一部分实际上是将问题路由到正确的检索器。这是通过调用LLM并传入问题和可用工具来完成的,LLM将返回每个问题要使用的最佳检索器。

首先,我们需要将工具放在一个字典中,以便我们可以将它们传递给LLM,并且在需要调用工具时也能找到它们。让我们从定义我们拥有的可用工具开始。

清单 5.6 可用检索器工具字典

tools = {
    "movie_info_by_title": {
        "description": movie_info_by_title_description,
        "function": movie_info_by_title
    },
    "movies_info_by_actor": {
        "description": movies_info_by_actor_description,
        "function": movies_info_by_actor
    },
    "text2cypher": {
        "description": text2cypher_description,
        "function": text2cypher
    },
    "answer_given": {
        "description": answer_given_description,
        "function": answer_given
    }
}

在这里,我们将工具描述和实际函数分组在一个字典中,以便在需要实际调用工具时轻松找到它们。让我们从向LLM描述其任务的提示开始。

清单 5.7 检索器路由器指令

tool_picker_prompt = """
Your job is to choose the right tool needed to respond to the user question. The available tools are provided to you in the request. Make sure to pass the right and complete arguments to the chosen tool.
"""

这是一个相当短的提示,但由于内置的工具/函数调用支持,它足以指示LLM为工作选择正确的检索器。接下来我们看看调用LLM的函数。

清单 5.8 检索器路由器函数

def route_question(question: str, tools: dict[str, any], answers: list[dict[str, str]]):
    llm_tool_calls = tool_choice(
        messages=[
            {"role": "system", "content": tool_picker_prompt},
            *answers,
            {"role": "user", "content": f"The user question to find a tool to answer: {question}"},
        ],
        model="gpt-4o",
        tools=[tool["description"] for tool in tools.values()],
    )
    return handle_tool_calls(tools, llm_tool_calls)

此函数接收单个问题、可用工具以及先前问题的答案。然后,它调用LLM并传入问题和工具,LLM将返回用于该问题的最佳检索器。该函数的最后一行是对我们之前看到的handle_tool_calls函数的调用,该函数实际调用检索器。

检索器路由器的最后一部分是将所有先前部分整合在一起,并完成从用户输入到答案的整个过程。我们希望确保有一个循环遍历所有问题,并且随着进展用新信息更新问题。

清单 5.9 代理式RAG处理函数

def handle_user_input(input: str, answers: list[dict[str, str]] = []):
    updated_question = query_update(input, answers)
    response = route_question(updated_question, tools, answers)
    answers.append({"role": "assistant", "content": f"For the question: {updated_question}, we have the answer: {json.dumps(response)}"})
    return answers

这里需要注意的一点是,handle_user_input函数可选地接收一个答案列表。我们将在第5.3.3节中讨论这一点。

有了这些,我们就有了一个完整的代理式RAG系统,可以接收用户输入并向用户返回答案。该系统以可以根据需要扩展更多检索器的方式构建。

我们需要再实现一个部分来使系统完整,那就是答案评判器。

5.3.3 实现答案评判器

答案评判器的工作是接收来自检索器的所有答案,并检查原始问题是否被正确回答。LLM是非确定性的,在重写问题、更新问题和路由问题时可能会出错,因此我们希望进行此检查以确保我们实际收到了所需的答案。

下面清单显示了答案评判器的LLM指令。

清单 5.10 答案评判器指令

answer_critique_prompt = """
You are an expert at identifying if questions have been fully answered or if there is an opportunity to enrich the answer.
The user will provide a question, and you will scan through the provided information to see if the question is answered.
If anything is missing from the answer, you will provide a set of new questions that can be asked to gather the missing information.
All new questions must be complete, atomic, and specific.
However, if the provided information is enough to answer the original question, you will respond with an empty list.

JSON template to use for finding missing information:
{
    "questions": ["question1", "question2"]
}
"""

我们遵循与之前相同的模式,使用JSON格式和LLM指令。

接下来,我们看看调用LLM的函数。

清单 5.11 答案评判器函数

def critique_answers(question: str, answers: list[dict[str, str]]) -> list[str]:
    messages = [
        {"role": "system", "content": answer_critique_prompt},
        *answers,
        {"role": "user", "content": f"The original user question to answer: {question}"},
    ]
    config = {"response_format": {"type": "json_object"}}
    output = chat(messages, model="gpt-4o", config=config)
    try:
        return json.loads(output)["questions"]
    except json.JSONDecodeError:
        print("Error decoding JSON")
        return []

此函数接收原始问题和来自检索器的答案,并调用LLM检查原始问题是否正确回答。如果问题未正确回答,LLM将返回一个新问题列表,可以询问这些问题以收集缺失的信息。

如果我们得到一个新问题列表,我们可以再次通过检索器路由器来获取缺失的信息。我们还应该在此循环中设置一些退出标准,这样我们就不会陷入无法从检索器获得原始问题答案的循环中。

5.3.4 整合所有组件

到目前为止,我们已经实现了检索器代理、检索器路由器和答案评判器。最后一部分是将所有内容整合到一个主函数中,该函数接收用户输入并在答案可用时向用户返回答案。

下面清单显示了主函数的LLM指令。

清单 5.12 代理式RAG主指令

main_prompt = """
Your job is to help the user with their questions. You will receive user questions and information needed to answer the questions. If the information is missing to answer part of or the whole question, you will say that the information is missing. You will only use the information provided to you in the prompt to answer the questions. You are not allowed to make anything up or use external information.
"""

非常重要的一点是,LLM只使用提示中提供给它的信息来回答问题。这是为了确保系统的一致性,并且我们可以信任它提供的答案。

接下来,我们看看主函数。

清单 5.13 代理式RAG主函数

def main(input: str):
    answers = handle_user_input(input)
    critique = critique_answers(input, answers)
    if critique:
        answers = handle_user_input("".join(critique), answers)
    llm_response = chat(
        [
            {"role": "system", "content": main_prompt},
            *answers,
            {"role": "user", "content": f"The user question to answer: {input}"},
        ],
        model="gpt-4o",
    )
    return llm_response

主函数通过代理式RAG系统运行用户输入,并向用户返回答案。如果答案不完整或不正确,评判函数将返回一个新问题列表,可以询问这些问题以收集缺失的信息。

我们只评判答案一次;如果评判后答案仍然不完整或不正确,我们将答案按原样返回给用户,并依靠LLM告知用户哪些部分不完整。

总结

  • 代理式RAG 是一个系统,其中多种检索代理可用于检索回答用户问题所需的数据。
  • 代理式RAG系统的主要接口通常是某种用例或检索器路由器,其工作是找到最适合手头任务的检索器(或检索器)。
  • 代理式RAG系统的基础部分是检索器代理检索器路由器答案评判器
  • 代理式RAG系统的主要部分可以使用具有工具/函数调用支持的LLM来实现。
  • 检索器代理可以是通用的,也可以是专用的,应随着时间的推移根据需要添加,以提高应用程序的性能。
  • 答案评判器 是一个接收来自检索器的答案并检查原始问题是否被正确回答的函数。

第6章 使用LLMs构建知识图谱

本章涵盖内容

  • 结构化数据提取
  • 不同的提取方法

在本章中,您将探索使用LLM从非结构化源(如文本文档)构建知识图谱的过程。重点将放在LLM如何从原始文本中提取和结构化数据,将其转换为构建知识图谱的可用格式。

在前面的章节中,您学习了文档分块、嵌入和检索的基本技术(第2章),以及提高检索准确性的更高级方法(第3章)。然而,正如您在第4章所学到的,仅依赖文本嵌入在处理需要过滤、计数或聚合操作才能回答的问题时可能会带来挑战。为了解决仅使用文本嵌入的局限性,您将学习如何使用LLM进行自动数据提取,将非结构化数据转换为适合知识图谱构建的结构化格式。到本章结束时,您将能够从原始文本中提取结构化信息,为提取的数据设计知识图谱模型,并将这些数据导入图数据库。

您将从探索法律文档检索中的一个常见挑战——管理多个合同及其条款——开始,并学习结构化数据提取如何提供解决方案。在整个章节中,您将跟随示例说明这个过程,并逐步引导您完成从非结构化文本构建知识图谱的工作流程。

6.1 从文本中提取结构化数据

在网上甚至在公司内部发现的许多信息都以非结构化格式存在,如各种文档。然而,在有些情况下,仅使用文本嵌入的简单检索技术是不够的。法律文档就是这样一个例子。

例如,如果您询问与ACME公司的合同中的付款条款,确保这些条款确实来自该特定合同而不是其他合同至关重要。当您简单地跨多个法律文档进行分块和检索时,您在检索时获得的前k个块可能来自不同、不相关的文档,从而导致混乱,如图6.1所示。

image

图6.1 基本向量检索策略可能返回来自不同合同的块

图6.1说明了合同文档如何被分解成文本块并使用文本嵌入建立索引。当最终用户提出特定问题,例如关于特定合同的付款条款时,系统会检索最相关的块。但是,如果多个合同包含不同的付款条款,检索过程可能会无意中从各种文档中提取信息,将目标合同中的相关块与其他合同中的不相关块混合在一起。发生这种情况是因为系统专注于根据相似性检索排名靠前的文本块,而并不总是区分这些块是否来自正确的合同。因此,共享“付款”或“条款”等关键词但属于不同合同的块可能会被包含进来,导致对这些条款的看法变得零散和不一致。当LLM尝试将这些混合块合成为连贯的答案时,这种混淆会增加不准确或误导性信息的风险。

此外,请考虑以下问题:我们目前与ACME公司有多少份有效合同?要回答这个问题,您首先需要根据其有效状态过滤所有合同,然后计算相关的合同数量。这类查询类似于传统的商业智能问题,文本嵌入方法在这方面存在不足。

文本嵌入主要用于检索语义相似的内容,不擅长处理过滤、排序或聚合数据等操作。要处理此类操作,需要结构化数据,因为仅文本嵌入不太适合这些操作。

对于某些领域,在实现RAG应用时,结构化数据至关重要。幸运的是,LLM擅长从文本中提取结构化数据,因为它们对自然语言有深刻的理解,能够准确识别相关信息。它们可以通过特定的提示进行微调或引导,以定位和提取所需的数据点,将非结构化信息转换为结构化格式,如表格或键值对。在处理大量文档时,使用LLM进行结构化数据提取特别有用,因为在这些情况下,手动识别和组织此类信息将是劳动密集型和耗时的。通过自动化提取过程,LLM使企业能够将非结构化信息转换为可操作的结构化数据,这些数据随后可用于进一步分析或RAG应用。

想象一下,您在一家公司担任软件工程师,并且您是一个团队的一员,该团队的任务是构建一个可以基于公司法律文档回答问题的聊天机器人。由于这是一个大型项目,团队分为两组:一组专注于数据准备,另一组专注于实现第4章和第5章中描述的检索系统。您被分配到数据准备团队,您的工作是处理法律文档并提取结构化信息。该信息将用于构建知识图谱,遵循图6.2所示的工作流程。

图6.2所示的工作流程从合同文档作为输入开始,使用LLM进行处理以提取结构化信息。在法律领域,您可以提取各种详细信息,例如相关方、日期、条款和

image

图6.2 使用LLM从文本中提取结构化数据以构建知识图谱

更多信息。这里,结构化输出以JSON格式表示,然后这些结构化信息存储在Neo4j中,这将作为法律聊天机器人数据检索的基础。

这两个例子突显了在处理特定、结构化查询(例如询问合同中的付款条款或计算有效协议数量)时,简单文本嵌入的局限性。在这两种情况下,准确的答案都需要结构化数据,而不是仅仅依赖于非结构化文本的语义相似性。在本章的剩余部分,我们将更深入地探讨LLM如何有效地从复杂文档中提取结构化数据,以及这种结构化输出如何在为高级检索任务构建可靠的知识图谱中发挥关键作用。要跟随学习,您需要一个正在运行的、空白的Neo4j实例。这可以是本地安装或云托管实例;只需确保它是空的。您可以直接在随附的Jupyter笔记本中跟随实现,地址为:https://github.com/tomasonjo/kg-rag/blob/main/notebooks/ch06.ipynb。

让我们开始吧。

6.1.1 结构化输出模型定义

从文本中提取结构化数据并不是一个新概念;多年来它一直是数据处理中的一项重要任务。历史上,这个过程被称为信息提取,需要复杂的系统,通常依赖于多个协同工作的机器学习模型。这些系统通常构建和维护成本高昂,需要一支熟练的工程师和领域专家团队来确保其正常运行。由于这些原因,只有拥有大量资源的大型组织才能负担得起实施此类解决方案。高昂的成本和技术障碍使得许多企业和个人无法访问。

然而,LLM的进步极大地简化了这一过程。如今,用户可以通过提示LLM来提取结构化信息,技术门槛要低得多,而不是构建和训练多个模型。这种转变为结构化数据提取开辟了广泛的用例。

使用LLM提取结构化数据已成为如此常见的用例,以至于OpenAI在其API中引入了结构化输出功能,以简化和标准化该过程。此功能允许开发者提前定义预期的输出格式,确保模型的响应遵循特定的结构。结构化输出不是一个单独的库;它是OpenAI API的内置功能,可通过函数调用或模式定义获得。例如,在Python中,开发者通常使用像Pydantic这样的库来定义数据模式。然后,这些模式可以传递给模型,指导其产生符合指定格式的输出,如下面的清单所示。

清单 6.1 使用 Pydantic 库定义所需的输出

from pydantic import BaseModel, Field

class CalendarEvent(BaseModel):
    name: str
    date: str = Field(..., description="事件的日期。使用 yyyy-MM-dd 格式")
    participants: list[str]

清单6.1中的CalendarEvent类代表了一种捕获事件详细信息的结构化方式。它包括事件的名称、事件发生的日期以及参与者列表。通过明确定义这些属性,它可以确保任何事件数据都符合这种结构,从而更容易以可靠和一致的方式提取和使用事件信息。属性的可用类型有:

  • 字符串
  • 数字
  • 布尔值
  • 整数
  • 对象
  • 数组
  • 枚举
  • anyOf

让我们检查一下date属性的定义。

清单 6.2 date 属性

date: str = Field(..., description="事件的日期。使用 yyyy-MM-dd 格式")

清单6.2中的代码提供了如何为date属性提取数据的说明。将属性命名为date向模型发出信号,要专注于与日期相关的信息。

通过使用str类型,我们指定提取的信息应表示为字符串,因为没有可用的原生datetime类型。此外,描述明确了所需的yyyy-MM-dd格式。这一步至关重要,因为尽管模型知道它正在处理一个字符串,但描述确保了日期遵循特定的格式。如果没有这种指导,仅str类型本身可能无法传达有关预期输出结构的足够细节。

结构化输出通过确保LLM响应遵循预定义的模式,显著简化了开发过程。这减少了对后处理和验证的需求,使开发者能够专注于在其系统中使用数据。该功能提供了类型安全,保证响应始终格式正确,并消除了为获得一致输出而使用复杂提示的需求,使整个过程更加高效和可靠。

从法律文档中提取结构化输出的第一步是定义需要提取的合同数据模型。由于您是软件工程师而不是法律专家,因此咨询具有领域知识的人以确定哪些信息最重要是至关重要的。此外,与最终用户讨论他们想要回答的具体问题可以提供宝贵的见解。

经过这些初步讨论,您提出了如下清单所示的合同数据模型。

清单 6.3 使用 Pydantic 对象定义所需的输出

from typing import List, Optional
from pydantic import BaseModel, Field

class Location(BaseModel):
    '''代表包括地址、城市、州和国家在内的物理位置。'''
    address: Optional[str] = Field(..., description="位置的街道地址。")
    city: Optional[str] = Field(..., description="位置所在城市。")
    state: Optional[str] = Field(..., description="位置所在的州或地区。")
    country: str = Field(..., description="位置所在国家。使用两位字母的ISO标准。")

class Organization(BaseModel):
    '''代表一个组织,包括其名称和位置。'''
    name: str = Field(..., description="组织的名称。")
    location: Location = Field(..., description="组织的主要位置。")
    role: str = Field(
        ...,
        description="组织在合同中的角色,例如'provider'、'client'、'supplier'等。",
    )

contract_types = [
    "ServiceAgreement",
    "LicensingAgreement",
    "Non-Disclosure Agreement (NDA)",
    "PartnershipAgreement",
    "LeaseAgreement"
]

class Contract(BaseModel):
    '''代表合同的关键细节。'''  # 被提取对象的描述
    contract_type: str = Field(..., description="所订立合同的类型。", enum=contract_types)  # 使用枚举来定义LLM可以使用的可能值
    parties: List[Organization] = Field(..., description="合同中涉及的相关方列表,包含各方的角色详情。")  # 一个属性可以像本例中的Organization一样是对象
    effective_date: str = Field(..., description="合同生效的日期。使用 yyyy-MM-dd 格式。")  # 由于datetime类型不可用,您希望定义要提取的日期格式。
    term: str = Field(..., description="协议的期限,包括续约或终止的规定。")
    contract_scope: str = Field(..., description="合同范围的描述,包括权利、义务和任何限制。")
    end_date: Optional[str] = Field(..., description="合同到期的日期。使用 yyyy-MM-dd 格式。")  # 您可以使用Optional来处理可能不会出现在所有合同中的属性。
    total_amount: Optional[float] = Field(..., description="合同的总价值。")
    governing_law: Optional[Location] = Field(..., description="管辖合同的法律司法管辖区。")

类名Contract以及简洁的文档字符串“代表合同的关键细节”为LLM提供了高层次的理解,即所需的输出应捕获基本的合同信息。这指导模型专注于提取和组织关键细节,例如合同类型、相关方、日期和财务信息。

一般来说,属性可以分为强制性或可选性。当一个属性是可选的时候,您将其指定为Optional类型,向LLM表明该信息可能存在也可能不存在。当信息可能缺失时,将属性标记为可选至关重要,否则,一些LLM可能会尝试填充空白而产生幻觉值。例如,total_amount是可选的,因为有些合同只是没有金钱交换的协议。相反,effective_date属性是强制性的,因为您期望每个合同都有开始日期。

请注意,每个属性都包含一个描述值,为LLM提供清晰的指导,确保其准确提取所需信息。即使在某些属性似乎很明显的情况下,这也是一个很好的实践。在某些情况下,您可能还需要指定特定属性的允许值。您可以通过使用enum参数来实现这一点。例如,contract_type属性使用enum参数通知LLM要应用的具体类别。以下清单包含contract_type参数的可用值。

清单 6.4 合同类型枚举值

contract_types = [
    "ServiceAgreement",
    "LicensingAgreement",
    "Non-Disclosure Agreement (NDA)",
    "PartnershipAgreement",
    "LeaseAgreement"
]

显然,清单6.4中的列表并不详尽,因为可能包含其他选项。

某些属性可能更复杂,可以定义为自定义对象。例如,parties属性是Organization对象的列表。使用列表是因为合同通常涉及多方,而自定义对象允许提取不仅仅是关于特定属性的简单字符串。以下清单中的代码定义了Organization对象。

清单 6.5 自定义 Organization 对象

class Organization(BaseModel):
    '''代表一个组织,包括其名称和位置。'''
    name: str = Field(..., description="组织的名称。")
    location: Location = Field(..., description="组织的主要位置。")
    role: str = Field(
        ...,
        description="组织在合同中的角色,例如'provider'、'client'、'supplier'等。",  # 如果您没有提供所有可能的值而只是示例,您可以在描述中提供可能的值而不是使用枚举。
    )

清单6.5中的Organization对象捕获了合同中涉及的组织的关键细节,包括其名称、主要位置和角色。location属性是一个嵌套的Location对象,允许我们将信息结构化为城市、州和国家等值。如您所见,我们可以有嵌套的对象,但通常的建议是为了获得更好的性能,避免太多层级的嵌套对象。对于role属性,我们提供了诸如“provider”和“client”之类的示例,但选择不使用枚举来避免限制值。这种灵活性很重要,因为确切的角色可能会有所不同且不完全可预测。通过这样定义组织,可以指导LLM提取有关相关方的更详细和结构化的信息。

最后,您需要定义Location对象。

清单 6.6 自定义 Location 对象

class Location(BaseModel):
    '''代表一个物理位置,包括地址、城市、州和国家。'''
    address: Optional[str] = Field(..., description="位置的街道地址。")
    city: Optional[str] = Field(..., description="位置所在城市。")
    state: Optional[str] = Field(..., description="位置所在的州或地区。")
    country: str = Field(..., description="位置所在国家。使用两位字母的ISO标准。")  # LLM熟悉用于国家的ISO标准,因此您指示模型基于特定标准标准化值。

Location对象代表一个物理地址,捕获诸如街道地址、城市、州或地区以及国家等详细信息。除country外,所有属性都是可选的,允许在完整的地址详细信息可能不可用时提供灵活性。对于country属性,我们指导LLM使用两位字母的ISO标准,以确保一致性,并使其更容易在不同的系统中使用和处理。

您现在已经定义了合同数据模型,该模型可用于从公司合同中提取相关信息。该模型将作为指导LLM进行结构化数据提取的蓝图。有了对数据结构清晰的理解,是时候探索如何有效地提示LLM来提取这些信息了。

6.1.2 结构化输出提取请求

定义了合同数据模型后,您现在有了一个数据定义,LLM可以遵循该定义来提取结构化信息。下一步是确保LLM确切理解如何以一致的格式输出这些数据。这就是OpenAI的结构化输出功能的用武之地。通过使用此功能,您可以引导LLM的行为,使其输出的数据严格遵循合同模型,同时使用前面章节介绍的相同聊天模板。

结构化输出文档 (https://mng.bz/oZZp) 使用系统消息来额外指导LLM专注于手头的任务。通过使用系统消息,如下面清单所示,您可以提供清晰的指令来有效引导模型的行为。

清单 6.7 用于结构化输出提取的系统消息

system_message = """
You are an expert in extracting structured information from legal documents and contracts.
Identify key details such as parties involved, dates, terms, obligations, and legal definitions.
Present the extracted information in a clear, structured format. Be concise, focusing on essential
legal content and ignoring unnecessary boilerplate language. The extracted data will be used to address
any questions that may arise regarding the contracts.
"""

很难提供关于制作理想系统消息的精确说明。很明显的是,您应该定义领域并为LLM提供关于输出将如何使用的上下文。除此之外,这通常归结为反复试验。

最后,您定义一个函数,该函数接收任何文本作为输入,并输出由合同数据模型定义的字典。

清单 6.8 用于结构化输出提取的函数

def extract(document: str, model: str = "gpt-4o-2024-08-06", temperature: float = 0) -> dict:
    response = client.beta.chat.completions.parse(
        model=model,
        temperature=temperature,
        messages=[
            {"role": "system", "content": system_message},
            {"role": "user", "content": document},
        ],
        response_format=Contract,  # 输出格式是使用response_format参数定义的。
    )
    return json.loads(response.choices[0].message.content)

清单6.8中的extract函数处理一个文本文档,并返回一个基于合同数据模型的字典。它使用了撰写本书时可用的最新GPT-4o模型,该模型支持结构化输出。该函数发送一个系统消息来指导LLM,然后是未经任何修改的原始用户提供的文档文本。然后响应根据合同数据模型格式化并作为字典返回。

为了在实践中看到这个过程,现在让我们看看如何使用一个真实世界的数据集来应用此方法。由于访问专有合同可能因机密性而变得困难,您将使用一个名为合同理解阿提克斯数据集 (CUAD) 的公共数据集。

6.1.3 CUAD数据集

虽然所有公司都有合同和法律文件,但这些通常由于包含敏感信息而不公开。出于演示目的,我们将使用CUAD数据集 (Hendrycks et al., 2021) 中的一个文本文档。CUAD是一个专门创建用于训练AI模型理解和审查法律合同的语料库。

以下清单展示了一个改进的版本。合同可在本书的随附GitHub存储库中找到,无需下载整个数据集。代码处理打开文件并读取其内容。

清单 6.9 读取合同文本文档

with open('./../data/LICENSE_agreement.txt', 'r') as file:
    contents = file.read()  # 读取文件

您现在可以通过执行下面清单中显示的代码来处理合同。

清单 6.10 从文本中提取结构化信息

data = extract(contents)
print(data)

结果将类似于以下清单。

清单 6.11 提取结果

{
  "contract_type": "Licensing Agreement",
  "parties": [
    {
      "name": "Mortgage Logic.com, Inc.",
      "location": {
        "address": "Two Venture Plaza, 2 Venture",
        "city": "Irvine",
        "state": "California",
        "country": "US"
      },
      "role": "Client"
    },
    {
      "name": "TrueLink, Inc.",
      "location": {
        "address": "3026 South Higuera",
        "city": "San Luis Obispo",
        "state": "California",
        "country": "US"
      },
      "role": "Provider"
    }
  ],
  "effective_date": "1999-02-26",
  "term": "1 year, with automatic renewal for successive one-year periods unless terminated with 30 days' notice prior to the end of the term.",
  "contract_scope": "TrueLink grants Mortgage Logic.com a nonexclusive license to use the Interface for origination, underwriting, processing, and funding of consumer finance receivables. TrueLink will provide hosting services, including storage, response time management, bandwidth, availability, access to usage statistics, backups, internet connection, and domain name assistance. TrueLink will also provide support services and transmit credit data as permitted under applicable agreements and laws.",
  "end_date": null,
  "total_amount": null,
  "governing_law": {
    "address": null,
    "city": null,
    "state": "California",
    "country": "US"
  }
}

提取的合同数据被组织到结构化的字段中,尽管并非所有属性都已完全填充。例如,像end_datetotal_amount这样的字段被标记为null,表示信息缺失或未指定。同时,像contract_scope这样的属性包含更详细的描述性文本,概述了协议的操作细节,例如提供的服务和职责。该结构包括对相关方、他们的角色和位置的清晰分类。合同还规定了其开始日期和续约条件,但其他财务或终止细节仍未定义,因为它们缺失在合同中。

练习 6.1

下载CUAD数据集,并探索基于不同类型的合同创建各种合同数据模型。一旦定义了不同的模型,您可以通过分析它们在不同合同中捕获和分类关键法律信息的效果来测试和完善它们。

在本节中,您成功地使用CUAD数据集和之前定义的合同数据模型从合同文档中提取了结构化数据。LLM被引导识别关键的合同细节,结果以结构化的方式格式化,使您能够组织重要的信息,如合同类型、相关方和条款。这个过程展示了LLM如何有效地将非结构化法律文档转换为可操作的数据。

既然您已经看到了如何从法律合同中提取结构化信息,下一节将重点介绍如何将这些数据整合到知识图谱中。

6.2 构建图谱

作为本章的最后一步,您将把提取的结构化输出导入Neo4j。这遵循了导入结构化数据的标准方法。首先,您应该设计一个合适的图模型,该模型代表您数据中的关系和实体。图建模超出了本书的范围,但您可以使用LLM来帮助定义图模式,或者查看其他学习材料,例如Neo4j Graph Academy。

合同图模型的示例如图6.3所示,您将在本步骤中使用。该图模型代表了一个合同系统,具有三个主要实体:ContractOrganizationLocationContract节点存储详细信息,如其ID、类型、生效日期、期限、总金额、管辖法律和范围。

组织通过HAS_PARTY关系链接到合同,每个组织通过HAS_LOCATION关系链接到一个Location节点,该节点捕获组织的地址、城市、州和国家。位置被表示为单独的节点,以适应单个组织可能拥有多个地址的可能性。

既然您已经定义了图模型,下一步就是开始构建知识图谱的过程。这涉及几个关键步骤,每个步骤都将在以下小节中介绍。首先,您将定义唯一约束和索引以确保数据完整性并提高性能。之后,您将使用Cypher语句将结构化的合同数据导入Neo4j。加载数据后,您将可视化图以确认所有实体和关系都正确表示。最后,我们将解决重要的数据优化任务,例如实体解析,确保同一真实世界实体的不同表示被正确合并,并且我们将讨论如何处理图中的结构化和非结构化数据。

image

图6.3 合同图模型

6.2.1 数据导入

定义适用的唯一约束和索引是最佳实践,因为它不仅确保了图的完整性,还增强了查询性能。以下清单中的代码为ContractOrganizationLocation节点定义了唯一约束。

清单 6.12 定义唯一约束

neo4j_driver.execute_query(
    "CREATE CONSTRAINT IF NOT EXISTS FOR (c:Contract) REQUIRE c.id IS UNIQUE;"
)
neo4j_driver.execute_query(
    "CREATE CONSTRAINT IF NOT EXISTS FOR (o:Organization) REQUIRE o.name IS UNIQUE;"
)
neo4j_driver.execute_query(
    "CREATE CONSTRAINT IF NOT EXISTS FOR (l:Location) REQUIRE l.fullAddress IS UNIQUE;"
)

接下来,您需要准备一个导入Cypher语句,该语句将接收字典输出并将其加载到Neo4j中,遵循图6.3中概述的图模式。导入Cypher语句如下清单所示。

清单 6.13 定义导入 Cypher 语句

import_query = """
WITH $data AS contract_data
MERGE (contract:Contract {id: randomUUID()})
SET contract += {
    contract_type: contract_data.contract_type,
    effective_date: contract_data.effective_date,
    term: contract_data.term,
    contract_scope: contract_data.contract_scope,
    end_date: contract_data.end_date,
    total_amount: contract_data.total_amount,
    governing_law: contract_data.governing_law.state + ', ' + contract_data.governing_law.country
}
WITH contract, contract_data
UNWIND contract_data.parties AS party
MERGE (p:Organization {name: party.name})
MERGE (loc:Location {fullAddress: party.location.address + ', ' + party.location.city + ', ' + party.location.state + ', ' + party.location.country})
SET loc += {
    address: party.location.address,
    city: party.location.city,
    state: party.location.state,
    country: party.location.country
}
MERGE (p)-[:LOCATED_AT]->(loc)  # 将相关方链接到其位置
MERGE (p)-[:HAS_PARTY]->(contract)  # 将相关方链接到合同
"""

解释Cypher语句(如清单6.13中的语句)超出了本书的范围。但是,如果您需要帮助,LLM可以帮助澄清细节并提供对Cypher语句的深入理解。然而,我们想强调的是,清单6.13中的查询不是幂等的,因为对合同ID使用了randomUUID()。因此,多次运行查询将在数据库中创建重复的合同条目,每个条目都有唯一的ID。

现在一切准备就绪,您可以执行以下清单中的代码将合同导入Neo4j。

清单 6.14 将合同数据导入 Neo4j

neo4j_driver.execute_query(import_query, data=data)

导入成功后,您可以打开Neo4j浏览器以探索生成的图,该图应类似于图6.4所示的可视化效果。

图6.4中的可视化描绘了一个图,其中中央的“许可协议”(代表一个合同)通过HAS_PARTY关系链接到两个组织:“Mortgage Logic.com, Inc.”和“TrueLink, Inc.”。每个组织进一步通过LOCATED_AT关系链接到代表其位置的“US”节点。

image

图6.4 可视化的合同图数据

6.2.2 实体解析

您已经成功导入了图,但您的工作还没有完成。在大多数情况下,尤其是在处理自然语言处理或LLM驱动的数据处理时,需要进行某种程度的数据清理。在这个清理过程中,最关键的一步之一是实体解析。实体解析是指在数据集或知识图谱中识别和合并同一真实世界实体的不同表示的过程。在处理大型和多样化的数据集时,由于拼写差异、不同的命名约定,甚至数据格式的轻微差异,同一实体以多种形式出现是很常见的,如图6.5所示,我们看到三个节点代表同一实体的变体。这三个名称是:

  • UTI Asset Management Company
  • UTI Asset Management Company Limited
  • UTI Asset Management Company Ltd

image

图6.5 潜在的重复项

在此上下文中的实体解析涉及识别所有这些变体都指向同一现实世界的组织,尽管命名约定有细微差别(例如“Limited”与“Ltd”)。实体解析的目标是将这些不同的引用统一到图中的单个、连贯的节点中。这不仅提高了数据完整性,还增强了图进行更准确推断和关系的能力。实体解析中使用的技术包括字符串匹配、聚类算法,甚至使用围绕每个实体的上下文来检测和解决重复项的机器学习方法。

需要注意的是,实体解析高度依赖于用例和领域。一个通用的、一刀切的解决方案很少有效,因为每个领域都有其自己的命名约定、数据模式以及实体表示方式的细微差别。例如,适用于金融数据集中解析组织的方法在用于医疗保健环境中的生物实体时可能会产生不理想的结果。因此,最有效的策略之一是开发反映您特定数据环境的领域特定本体或规则。此外,使用领域专家定义匹配标准和使用迭代反馈循环(验证或更正潜在匹配)可以极大地提高准确性。通过将领域专业知识与上下文感知的机器学习或聚类技术相结合,您可以开发一种更健壮和灵活的实体解析方法。这将确保您捕捉到在您独特的数据环境中最重要的细微细节。

6.2.3 向图中添加非结构化数据

知识图谱越来越多地用于存储结构化和非结构化数据,随着LLM的出现,这种情况变得更加普遍。在这种背景下,LLM可用于从非结构化源(如文本文档)中提取结构化数据。然而,在图中存储原始非结构化文档和提取的结构化数据,既保留了原始数据的丰富性,又能对提取的信息进行更精确的查询和分析。结合了结构化和非结构化信息的扩展图模式如图6.6所示。

image

图6.6 添加了非结构化数据的扩展图模型

将非结构化数据整合到图中时,通常使用基于词元计数或单词长度的简单分块策略来将文本分割成可管理的片段。虽然这种简单的方法适用于一般用例,但某些领域,如法律合同,受益于更专业的分块方法。例如,按条款分割合同保留了其语义结构,并提高了下游分析的质量。这种更智能的方法使图能够捕获更有意义的关系,从而获得更丰富的见解和更准确的推断。

本章指导您完成了使用LLM从非结构化数据构建知识图谱的过程。您探讨了文本嵌入在处理结构化查询方面的局限性,并学习了结构化数据提取如何提供解决方案。通过定义数据模型、提示LLM进行提取以及将结果导入图数据库,您了解了如何将原始文本转换为知识图谱的可用数据。此外,我们涵盖了关键任务,如实体解析以及结合结构化和非结构化数据以获得更丰富的见解。有了这些知识,您现在可以在实际场景中应用结构化数据提取。

总结

  • 仅对文档进行分块以进行检索可能会导致不准确或混合的结果,尤其是在法律文档等文档边界很重要的领域。
  • 检索任务,如过滤、排序和聚合,需要结构化数据,因为仅文本嵌入不适合此类操作。
  • LLM擅长从非结构化文本中提取结构化数据,将其转换为表格或键值对等可用格式。
  • LLM中的结构化输出功能允许开发者定义模式,确保响应遵循特定格式,减少了对后处理的需求。
  • 定义具有清晰属性的数据模型(如合同类型、相关方和日期)对于指导LLM准确提取相关信息至关重要。
  • 知识图谱中的实体解析对于合并同一实体的不同表示非常重要,可提高数据的一致性和准确性。
  • 在知识图谱中结合结构化和非结构化数据保留了源材料的丰富性,同时实现了更精确的查询。

第7章 微软的GraphRAG实现

本章涵盖内容

  • 介绍微软的 GraphRAG
  • 提取和总结实体及关系
  • 计算和总结实体社区
  • 实现全局和局部搜索技术

在第6章中,您学习了如何从法律文档中提取结构化信息以构建知识图谱。在本章中,您将探索一个略有不同的提取和处理流程,即微软的 GraphRAG (Edge et al., 2024) 方法。这个端到端的示例仍然构建知识图谱,但更侧重于对实体及其关系的自然语言摘要。整个流程如图7.1所示。

微软 GraphRAG (MS GraphRAG: https://github.com/microsoft/graphrag) 的一个关键创新是使用 LLM 通过两阶段过程构建知识图谱。在第一阶段,从源文档中提取实体和关系并进行总结,形成知识图谱的基础,如图7.1中直到知识图谱的步骤所示。MS GraphRAG 的独特之处在于,一旦知识图谱构建完成,

image

图7.1 微软的GraphRAG流程。(图片来自 Edge et al., 2024,遵循 CC BY 4.0 许可)

会检测图社区,并为紧密相关的实体组生成特定领域的摘要。这种分层方法将来自不同文本块的零散信息片段,转化为关于指定实体、关系和社区的连贯且有组织的表示。

这些实体级和社区级的摘要随后可用于在 RAG 应用中响应用户查询时提供相关信息。拥有这样一个结构化的知识图谱,可以应用多种检索方法。在本章中,您将探索 MS GraphRAG 论文中描述的全局和局部搜索检索方法。

7.1 数据集选择

MS GraphRAG 旨在通过提取关键实体并生成跨多个文本块连接信息的摘要来处理非结构化文本文档。为了确保有意义的见解,我们的数据集不仅应富含实体信息,还应在多个块中包含分布的实体数据。由于实体类型是 MS GraphRAG 的一个可配置方面,因此必须预先定义。相关的实体类型通常包括人物、组织和地点,但也可以扩展到领域特定的概念,例如医学中的基因和通路,或法律中的法律条款。

为了就实体类型做出明智的决策,探索数据集并确定您想要回答的问题类型非常重要。实体类型的选择影响着整个下游过程,包括提取、链接和摘要质量。

例如,MS GraphRAG 论文使用了播客和新闻文章的数据集。在这两种情况下,通常都会提及人物、组织和地点等实体。此外,根据主题(例如游戏或健康生活方式播客),您可能希望包含特定领域的实体,如游戏名称、健康状况或营养概念,以确保全面提取和分析。

在这里,我们使用《奥德赛》来评估 MS GraphRAG,因为它具有丰富的叙事,包含人物、神祇、神秘武器等。此外,像尤利西斯这样的关键实体出现在多个文本块中,使其成为测试实体提取和跨块摘要的合适数据集。

在本章的其余部分,您将实现 MS GraphRAG 方法。要跟随学习,您需要一个正在运行的、空白的 Neo4j 实例。这可以是本地安装或云托管实例;只需确保它是空的。您可以直接在随附的 Jupyter 笔记本中跟随实现,地址为:https://github.com/tomasonjo/kg-rag/blob/main/notebooks/ch07.ipynb。

让我们开始吧。

7.2 图索引

在这里,您将构建知识图谱并生成实体和社区摘要。在整个构建过程中,您将探索每个步骤的关键考虑因素,包括实体选择、图连接性,以及这些选择如何影响摘要和查询的质量。

首先从古腾堡计划 (https://www.gutenberg.org/ebooks/1727) 加载《奥德赛》。

清单 7.1 加载《奥德赛》

import requests
url = "https://www.gutenberg.org/cache/epub/1727/pg1727.txt"
response = requests.get(url)
text = response.text

准备好文本后,您现在可以浏览 MS GraphRAG 流程。

7.2.1 分块

《奥德赛》由 24 卷长度不一的书籍组成。您的第一个任务是移除前言和脚注,然后将文本分割成单独的书籍,如下一个清单所示。这种方法遵循了叙事的自然划分,提供了一种语义上有意义的方式来构建文本。

清单 7.2 移除前言和脚注并分割成书籍

def chunk_into_books(text: str) -> list[str]:
    return (
        text.split("PREFACE TO FIRST EDITION")[2]
        .split("FOOTNOTES")[0]
        .strip()
        .split("\nBOOK")[1:]
    )
books = chunk_into_books(text)

现在您需要检查每本书的词元数量,以确定是否需要进一步分块。以下清单中的代码提供了书籍词元数量的基本统计信息。

清单 7.3 计算书籍中的词元数量

import tiktoken
def num_tokens_from_string(string: str, model: str = "gpt-4") -> int:
    '''返回文本字符串中的词元数量。'''
    encoding = tiktoken.encoding_for_model(model)
    num_tokens = len(encoding.encode(string))
    return num_tokens

token_counts = [num_tokens_from_string(book) for book in books]
print(
    f"""There are {len(token_counts)} books with token sizes:
- avg {sum(token_counts) / len(token_counts)}
- min {min(token_counts)}
- max {max(token_counts)}
"""
)

24 本书的词元数量差异很大,平均为 6515 个词元,最少为 4459 个,最多为 10760 个。考虑到这个范围,需要进一步分块以确保没有单个部分超过合理的词元限制。

但是合理的块大小是多少呢?MS GraphRAG 的研究人员比较了不同的块大小,并分析了它们对提取实体总数的影响。这种比较的结果如图 7.2 所示。

image

图7.2 块大小和自反思迭代次数对实体提取的影响。(图片来自 Edge et al., 2024,遵循 CC BY 4.0 许可)

图 7.2 的结果显示,较小的块大小往往会提取更多的实体引用。代表 600 个词元块大小的线一直是最高的,而 2400 个词元块大小的线是最低的。这表明将文本分解成更小的块可以使 LLM 检测到更多的实体,与使用更大的块相比。此外,图 7.2 显示,增加自反思迭代次数(即对同一文档进行额外的提取轮次)会导致在所有块大小中检测到更多的实体引用。这种模式表明,重复轮次使 LLM 能够提取在早期迭代中可能遗漏的更多实体。

假设您决定使用 1000 个单词的限制(基于空格分割)和 40 个单词的重叠来分块书籍,如下一个清单所示。

清单 7.4 对书籍进行分块

chunked_books = [chunk_text(book, 1000, 40) for book in books]

书籍已被分块,您可以继续进行下一步。

7.2.2 实体和关系提取

第一步是提取实体和关系。我们可以借用 MS GraphRAG 论文附录中的提示。实体和关系提取的提示指令部分如“实体和关系提取指令”所示。

实体和关系提取指令

-目标-
给定一个可能与本次活动相关的文本文档以及一个实体类型列表,从文本中识别所有该类型的实体以及已识别实体之间的所有关系。

-步骤-

  1. 识别所有实体。对于每个识别出的实体,提取以下信息:
    – entity_name: 实体名称,大写
    – entity_type: 以下类型之一:[{entity_types}]
    – entity_description: 实体属性和活动的全面描述
    将每个实体格式化为 ("entity"{tuple_delimiter}<entity_name>{tuple_delimiter}<entity_type>{tuple_delimiter}<entity_description>)

  2. 从步骤 1 中识别出的实体中,识别所有彼此明确相关的(source_entity, target_entity)对。对于每对相关实体,提取以下信息:
    – source_entity: 源实体名称,如步骤 1 中所识别
    – target_entity: 目标实体名称,如步骤 1 中所识别
    – relationship_description: 解释为什么您认为源实体和目标实体彼此相关
    – relationship_strength: 表示源实体和目标实体之间关系强度的数字分数
    将每个关系格式化为 ("relationship"{tuple_delimiter}<source_entity>{tuple_delimiter}<target_entity>{tuple_delimiter}<relationship_description>{tuple_delimiter}<relationship_strength>)

  3. 将所有在步骤 1 和 2 中识别的实体和关系作为单个列表返回。使用 {record_delimiter} 作为列表分隔符。

  4. 完成后,输出 {completion_delimiter}

“实体和关系提取指令”侧重于通过识别指定类型的实体及其关系从文本文档中提取结构化知识。实体类型列表作为变量 entity_types 传入。该提示指示 LLM 提取实体,按类型分类,并提供详细描述。然后它识别明确相关的实体对,解释它们的联系,并分配关系强度分数。最后,它以结构化的、分隔的格式返回所有提取的实体和关系。这只是完整提示的一部分,完整提示还包括少样本示例和输出示例,但这些内容太长,无法包含在书中。

练习 7.1

在运行提取之前,花点时间思考哪些实体类型对《奥德赛》最有用。由于实体类型列表必须预定义,请考虑叙事的关键元素,例如您想要提取的人物、地点、物体和事件。尝试定义一组能捕获文本中最有意义关系的实体类型。

为了从《奥德赛》中提取有意义的实体,假设您决定使用以下实体类型:

  • PERSON (人物)
  • ORGANIZATION (组织)
  • LOCATION (地点)
  • GOD (神祇)
  • EVENT (事件)
  • CREATURE (生物)
  • WEAPON_OR_TOOL (武器或工具)

有些实体类型,如 PERSON 和 GOD,相对明确,因为它们指的是明确定义的人类和神祇类别。然而,其他类型,如 EVENT 和 LOCATION,则更加模糊。EVENT 可以指代从单个行动到整个战争的任何事情,这使得为其分类建立严格界限变得困难。类似地,LOCATION 可以指代像国家这样广泛的类别、一个特定的城市,甚至是城市内的一个命名地点。这种可变性使得一致的分类更具挑战性,但也为 LLM 提供了更大的灵活性。

有了这些预定义的实体类型,您现在将实现提取函数。

清单 7.5 实体和关系提取

ENTITY_TYPES = ["PERSON", "ORGANIZATION", "LOCATION", "GOD", "EVENT", "CREATURE", "WEAPON_OR_TOOL"]

def extract_entities(text: str) -> list[dict]:
    messages = [
        {"role": "user", "content": ch07_tools.create_extraction_prompt(ENTITY_TYPES, text)},
    ]
    output = chat(messages, model="gpt-4o") # LLM API 调用
    return ch07_tools.parse_extraction_output(output) # 将输出解析为字典

清单 7.5 中的代码通过首先定义要识别的实体类型来提取实体和关系。然后,它使用这些类型和输入文本生成提取提示,将提示发送给 LLM,并将响应处理成结构化字典格式。

使用清单 7.5 中的函数,您将仅提取《奥德赛》第一卷的实体和关系。如果需要,您可以增加分析书籍的数量以处理更大比例的文本。此提取的代码如下一个清单所示。

清单 7.6 提取实体和关系

number_of_books = 1
from tqdm import tqdm

for book_i, book in enumerate(tqdm(chunked_books[:number_of_books], desc="Processing Books")):
    for chunk_i, chunk in enumerate(tqdm(book, desc=f"Book {book_i}", leave=False)):
        nodes, relationships = extract_entities(chunk) # 提取实体和关系
        # 导入实体
        neo4j_driver.execute_query(
            ch07_tools.import_nodes_query,
            data=nodes,
            book_id=book_i,
            text=chunk,
            chunk_id=chunk_i,
        )
        # 导入关系
        neo4j_driver.execute_query(
            ch07_tools.import_relationships_query,
            data=relationships,
        )

清单 7.6 中的函数处理指定数量的书籍,从每个块中提取实体和关系。然后它将实体导入 Neo4j,接着导入它们的关系,构建文本的结构化图表示。

首先查看提取的实体和关系。您可以使用以下清单中的代码计算实体和关系的总数。

清单 7.7 计算提取的节点和关系的数量

data, _, _ = neo4j_driver.execute_query(
    """
    MATCH (:Entity)
    RETURN 'entity' AS type, count(*) AS count
    UNION
    MATCH ()-[:RELATIONSHIP]->()
    RETURN 'relationship' AS type, count(*) AS count
    """
)
print([el.data() for el in data])

该图包含 66 个实体和 182 个关系,但这些数字在不同执行之间可能会有所不同。MS GraphRAG 侧重于提取实体及其关系的详细描述。例如,让我们检查为角色 ORESTES(奥瑞斯提斯)提取的描述。

清单 7.8 检查为 ORESTES 生成的描述

data, _, _ = neo4j_driver.execute_query(
    """
    MATCH (n:PERSON) WHERE n.name = "ORESTES"
    RETURN n.description AS description
    """
)
print([el.data()['description'] for el in data])

当检查为角色 ORESTES 提取的描述时,如清单 7.8 所示,结果可能如下所示:

  • Orestes is Agamemnon’s son who killed Aegisthus. (奥瑞斯提斯是阿伽门农的儿子,杀死了埃癸斯托斯。)
  • Orestes is a person who was expected to take revenge on Aegisthus. (奥瑞斯提斯是一个被期望向埃癸斯托斯复仇的人。)
  • Orestes is praised for avenging his father’s murder by killing Aegisthus. (奥瑞斯提斯因杀死埃癸斯托斯为父报仇而受到赞扬。)
  • Orestes is the son of Agamemnon who killed Aegisthus. (奥瑞斯提斯是杀死了埃癸斯托斯的阿伽门农的儿子。)
  • Orestes is a person who was expected to take revenge on Aegisthus. (奥瑞斯提斯是一个被期望向埃癸斯托斯复仇的人。)
  • Orestes is praised for avenging his father’s murder by killing Aegisthus. (奥瑞斯提斯因杀死埃癸斯托斯为父报仇而受到赞扬。)

虽然有些描述重复了相同的事实,但它们共同包含了所有关键细节,并确保在特定实体的不同文本块中没有丢失任何重要信息。

类似地,单个实体对可以有多个关系。您可以使用以下清单中的代码探索具有最多关系数量的实体对。

清单 7.9 检查生成的关系描述

data, _, _ = neo4j_driver.execute_query(
    """
    MATCH (n:Entity)-[:RELATIONSHIP]-(m:Entity)
    WITH n, m, count(*) AS countOfRefs
    ORDER BY countOfRefs DESC
    LIMIT 1
    MATCH (n)-[r:RELATIONSHIP]-(m)
    RETURN n.name AS source, m.name AS target, countOfRefs, collect(r.description) AS descriptions
    """
)
print([el.data() for el in data])

关系最多的实体对是 Telemachus(忒勒玛科斯)和 Minerva(密涅瓦),总共有 14 个关系。它们在叙事中的互动跨越了多个时刻,突显了 Minerva 作为神圣引导者和导师的角色。

以下是五个提取的关系描述:

  • Telemachus spoke quietly to Minerva during the banquet. (忒勒玛科斯在宴会期间轻声对密涅瓦说话。)
  • Minerva, in disguise, advises and encourages Telemachus, giving him courage and making him think of his father. (伪装下的密涅瓦建议并鼓励忒勒玛科斯,给他勇气并让他想到他的父亲。)
  • Minerva brings sleep to Telemachus’s mother, showing her divine influence. (密涅瓦让忒勒玛科斯的母亲入睡,显示了她的神圣影响力。)
  • Minerva is speaking to Telemachus, offering him guidance and reassurance. (密涅瓦正在与忒勒玛科斯交谈,为他提供指导和保证。)
  • Minerva, disguised as Mentes, is greeted by Telemachus at the gate. (伪装成门特斯的密涅瓦在门口受到忒勒玛科斯的问候。)

虽然有些描述包含重叠的细节,但它们强化了 Minerva 作为导师和神圣保护者的角色,逐渐塑造了忒勒玛科斯的旅程。

7.2.3 实体和关系摘要

为了避免提取知识中的不一致性、冗余和碎片化,MS GraphRAG 使用 LLM 合并同一实体或关系的多个描述,生成简洁的摘要。模型不是单独处理每个描述,而是从所有描述中综合信息,确保在单个丰富的表示中保留关键的上下文细节。这种方法增强了清晰度,减少了重复,并提供了对实体及其关系更完整的理解。

再次,您可以重用论文中的摘要提示,如“实体和关系摘要指令”所示。

实体和关系摘要指令

您是一个负责生成以下数据全面摘要的有用助手。给定一个或两个实体,以及一个描述列表,所有这些都与同一实体或实体组相关。请将这些全部合并为一个单一的、全面的描述。确保包含从所有描述中收集的信息。如果提供的描述相互矛盾,请解决矛盾并提供一个单一的、连贯的摘要。确保以第三人称书写,并包含实体名称,以便我们拥有完整的上下文。

#########
-数据-
实体:{entity_name}
描述列表:{description_list}
#########
输出:

“实体和关系摘要指令”中的提示指导 LLM 通过合并实体或实体对的多个描述来生成单一的、连贯的摘要。它确保包含所有相关细节,同时解决矛盾并消除冗余。输出以第三人称书写,并明确命名实体以保持清晰度和上下文。

使用“实体和关系摘要指令”中的提示,您可以为所有具有多个描述的实体生成摘要。用于汇总实体描述的代码可以在以下清单中找到。

清单 7.10 实体摘要

candidates_to_summarize, _, _ = neo4j_driver.execute_query(
    """MATCH (e:Entity) WHERE size(e.description) > 1
    RETURN e.name AS entity_name, e.description AS description_list"""
)

summaries = []
for candidate in tqdm(candidates_to_summarize, desc="Summarizing entities"):
    messages = [
        {"role": "user", "content": ch07_tools.get_summarize_prompt( # 构建提示
            candidate["entity_name"], candidate["description_list"]
        )}
    ]
    summary = chat(messages, model="gpt-4o") # 生成实体摘要
    summaries.append(
        {"entity": candidate["entity_name"], "summary": summary}
    )
ch07_tools.import_entity_summary(neo4j_driver, summaries) # 将摘要导入 Neo4j

清单 7.10 中的代码查询 Neo4j 数据库以查找具有多个描述的实体,然后使用 LLM 生成统一的摘要。您可以通过运行以下清单中的代码来查看 ORESTES 的汇总描述。

清单 7.11 检查为 ORESTES 生成的摘要

summary, _, _ = neo4j_driver.execute_query(
    """MATCH (n:PERSON) WHERE n.name = "ORESTES"
    RETURN n.summary AS summary"""
)
print(summary[0]['summary'])

结果如“为 ORESTES 生成的摘要”所示。

为 ORESTES 生成的摘要

Orestes is the son of Agamemnon, known for avenging his father’s death by killing Aegisthus. He was expected to take revenge on Aegisthus, who was responsible for Agamemnon’s murder. Orestes is praised for fulfilling this expectation and successfully killing Aegisthus, his father’s murderer. (奥瑞斯提斯是阿伽门农的儿子,以杀死埃癸斯托斯为父报仇而闻名。他被期望向应对阿伽门农谋杀负责的埃癸斯托斯复仇。奥瑞斯提斯因实现了这一期望并成功杀死了他父亲的谋杀者埃癸斯托斯而受到赞扬。)

摘要过程已成功生成实体的连贯且丰富的描述,如“为 ORESTES 生成的摘要”所示。通过合并多个描述,我们确保保留关键细节,同时减少冗余。

接下来,我们将相同的摘要方法应用于关系,将多个关系描述合并为一个单一的、全面的摘要。结果如下一个清单所示。

清单 7.12 关系摘要

rels_to_summarize, _, _ = neo4j_driver.execute_query(
    """MATCH (s:Entity)-[r:RELATIONSHIP]-(t:Entity)
    WHERE id(s) < id(t)
    WITH s.name AS source, t.name AS target, collect(r.description) AS description_list, count(*) AS count
    WHERE count > 1
    RETURN source, target, description_list"""
)

rel_summaries = []
for candidate in tqdm(rels_to_summarize, desc="Summarizing relationships"):
    entity_name = f"{candidate['source']} relationship to {candidate['target']}"
    messages = [ # 构建提示
        {"role": "user", "content": ch07_tools.get_summarize_prompt(
            entity_name, candidate["description_list"]
        )}
    ]
    summary = chat(messages, model="gpt-4o") # 使用 LLM 生成关系摘要
    rel_summaries.append({"source": candidate["source"], "target": candidate["target"], "summary": summary})
ch07_tools.import_rels_summary(neo4j_driver, rel_summaries) # 将结果存储到 Neo4j

清单 7.12 中的代码识别数据库中共享多个关系的实体对,并使用 LLM 将其描述合并为单个摘要。通过合并关系描述,该过程确保全面捕获实体之间的关键交互,同时消除冗余。一旦生成,汇总的关系就会被存储回数据库。

您可以评估 TELEMACHUS 和 MINERVA 之间生成的关系,如下一个清单所示。

清单 7.13 评估 TELEMACHUS 和 MINERVA 之间的汇总关系

data, _, _ = neo4j_driver.execute_query(
    """MATCH (n:Entity)-[r:SUMMARIZED_RELATIONSHIP]-(m:Entity)
    WHERE n.name = 'TELEMACHUS' AND m.name = 'MINERVA'
    RETURN r.summary AS description"""
)
print(data[0]["description"])

清单 7.13 中代码的结果可以在“为 TELEMACHUS 和 MINERVA 之间的关系生成的摘要”中找到。

为 TELEMACHUS 和 MINERVA 之间的关系生成的摘要

Minerva plays a crucial role in the life of Telemachus, offering guidance and support as he embarks on his quest to find his father, Ulysses. During a banquet, Telemachus speaks quietly to Minerva, indicating a close and trusting relationship. Minerva, often in disguise, such as when she appears as Mentes, advises and encourages Telemachus, instilling in him the courage and determination to seek information about his father. She provides counsel regarding his intended voyage, demonstrating her commitment to his cause. Additionally, Minerva’s divine influence is evident when she brings sleep to Telemachus’s mother, further showcasing her protective and supportive role in Telemachus’s life. (密涅瓦在忒勒玛科斯的生活中扮演着至关重要的角色,在他开始寻找父亲尤利西斯的旅程中提供指导和支持。在一次宴会中,忒勒玛科斯轻声对密涅瓦说话,表明了一种亲密且信任的关系。密涅瓦经常伪装出现,例如当她以门特斯的形象出现时,会建议和鼓励忒勒玛科斯,灌输给他寻找父亲信息的勇气和决心。她为他计划的航行提供建议,证明了她对其事业的承诺。此外,当密涅瓦让忒勒玛科斯的母亲入睡时,她的神圣影响力显而易见,进一步展示了她忒勒玛科斯生活中的保护和支持作用。)

通过整合实体和关系的摘要,您已成功完成了 MS GraphRAG 索引的第一阶段。通过合并跨文本块的信息,您创建了对提取知识更连贯和丰富的表示。

实体和关系摘要的注意事项

在处理更大的数据集时,您可能会遇到所谓的“超级节点”。超级节点是出现在大量块中并拥有海量关系的实体。例如,如果您要处理整个古希腊历史,像 Athens(雅典)这样的节点将积累大量的关系和描述。如果没有排名机制,汇总此类节点可能会导致输出过长,或者更糟的是,某些描述甚至可能无法放入提示中。为了解决这个问题,您需要实施过滤或排名策略,以优先处理最相关的描述,确保摘要保持简洁和 informative(信息丰富)。

现在您已准备好进入下一阶段。

7.2.4 社区检测与摘要

图索引过程的第二阶段侧重于社区检测和摘要。社区是一组实体,它们彼此之间的连接比与图中其他部分的连接更紧密。社区检测结果如图 7.3 所示。

image

图7.3 社区检测结果示例

图 7.3 展示了一个图,其中节点被分组到不同的社区中,每个社区代表一组内部连接紧密、关系更强的实体。有些社区很好地融入了整个图,而其他社区则显得更加孤立,形成不相连的子图。识别这些集群有助于揭示数据集中潜在的结构、主题或关键群体。例如,在像《奥德赛》这样的叙事中,社区可能围绕参与特定事件或地点的角色形成。通过检测和总结这些社区,我们可以捕获超越个体实体连接的高层关系和见解。

清单 7.14 中的代码应用了社区检测算法 Louvain 方法,以识别图中紧密连接的实体组。(原始论文实现中使用了 Leiden 算法,该算法在 GDS 库中也可用)检测到的社区随后存储为节点属性,以便下游处理。

清单 7.14 使用 Louvain 算法计算社区

community_distribution = ch07_tools.calculate_communities(neo4j_driver)
print(f"""There are {community_distribution['communityCount']} communities with distribution: {community_distribution['communityDistribution']}""")

使用 Louvain 方法在图中检测到 9 个社区,大小从 2 到 13 个节点不等。从清单 7.14 检测到的社区数量和大小可能会根据图的结构而变化,例如提取的实体和关系的数量。此外,Louvain 不是确定性的,这意味着即使输入相同,由于算法的优化过程,检测到的社区在不同运行之间也可能略有不同。

分层社区结构

MS GraphRAG 论文利用 Louvain 算法的分层性质来捕获多个粒度级别的社区结构。这允许分析大型图中的广泛和细粒度社区。然而,由于我们处理的是较小的图,我们将专注于单个级别的社区检测,跳过分层方面。

现在,您可以应用摘要提示为每个检测到的社区生成简洁的概述。“社区摘要指令”部分提供了提示的指令部分。

社区摘要指令

您是一个 AI 助手,帮助人类分析师执行一般信息发现。信息发现是识别和评估网络中与某些实体(例如组织和个人)相关的相关信息的过程。

目标

给定一个属于社区的实体列表及其关系和可选的关联声明,撰写一份社区的全面报告。该报告将用于告知决策者有关社区的信息及其潜在影响。本报告的内容包括社区关键实体的概述、其法律合规性、技术能力、声誉以及值得注意的声明。

报告结构

报告应包括以下部分:
– TITLE: 代表其关键实体的社区名称 - 标题应简短但具体。尽可能在标题中包含代表性的命名实体。
– SUMMARY: 社区整体结构的执行摘要,其实体如何相互关联,以及与其实体相关的重要信息。
– IMPACT SEVERITY RATING: 一个介于 0-10 之间的浮点数分数,表示社区内实体构成的 IMPACT 严重性。IMPACT 是社区评分的重要性。
– RATING EXPLANATION: 对 IMPACT 严重性评分的一句话解释。
– DETAILED FINDINGS: 关于社区的 5-10 个关键见解列表。每个见解都应有一个简短的摘要,后跟根据以下基础规则的多段解释性文本。要全面。

“社区摘要指令”中的提示指导 AI 助手生成检测到的社区的结构化摘要,确保它们捕获关键实体、关系和显著的见解。目标是生成可用于下游 RAG 的高质量摘要。

社区摘要的完整提示包括输出说明和一个少样本示例,以保持生成摘要的一致性和相关性。

随着社区的识别和结构化摘要提示的到位,我们现在可以为每个检测到的社区生成全面的摘要。这些社区摘要整合了关键实体、关系和重要见解。

以下清单中的代码处理检测到的社区,并应用摘要提示以生成有意义的描述。

清单 7.15 生成社区摘要

community_info, _, _ = neo4j_driver.execute_query(ch07_tools.community_info_query)
communities = []
for community in tqdm(community_info, desc="Summarizing communities"):
    messages = [
        {"role": "user", "content": ch07_tools.get_summarize_community_prompt(community["nodes"], community["refs"])}
    ]
    summary = chat(messages, model="gpt-4o")
    communities.append({
        "community": json.loads(ch07_tools.extract_json(summary)),
        "communityId": community["communityId"],
        "nodes": [el["id"] for el in community["nodes"]],
    })
neo4j_driver.execute_query(ch07_tools.import_community_query, data=communities)

您现在可以使用清单 7.16 中所示的代码检查生成的社区摘要示例。这将提供一个具体的例子,说明摘要过程如何捕获社区内的关键实体、关系和见解。

清单 7.16 检索社区摘要示例

data, _, _ = neo4j_driver.execute_query(
    """MATCH (c:Community)
    WITH c, count{ (c) <- [:IN_COMMUNITY] - () } AS size
    ORDER BY size DESC LIMIT 1
    RETURN c.title AS title, c.summary AS summary
    """
)
print(f"Title: {data[0]['title']}")
print(f"Summary: {data[0]['summary']}")

结果可以在“生成的社区摘要”中找到。

生成的社区摘要

Minerva, Telemachus, and the Ithacan Household The community centers around Minerva, Telemachus, and the household of Ulysses, with significant interactions involving divine guidance, familial loyalty, and the challenges posed by suitors. Minerva plays a pivotal role in advising Telemachus, who is determined to find his father and restore order to his home. The relationships among these entities highlight themes of wisdom, courage, and resilience. (密涅瓦、忒勒玛科斯和伊萨卡家庭 这个社区以密涅瓦、忒勒玛科斯和尤利西斯的家庭为中心,涉及神圣引导、家庭忠诚以及求婚者带来的挑战等重要互动。密涅瓦在建议忒勒玛科斯方面起着关键作用,忒勒玛科斯决心找到父亲并恢复家园秩序。这些实体之间的关系突显了智慧、勇气和韧性的主题。)

在更大的图中处理大型社区

在处理更大的图时,社区可能会变得太大而无法高效处理。如果一个社区包含太多实体和关系,将所有实体和关系都包含在摘要提示中可能会超出词元限制或产生过长的摘要。为了解决这个问题,应该实施排名机制以仅选择最相关的实体和关系。这确保摘要保持简洁、信息丰富,并且对下游 RAG 应用有用。

恭喜!您已成功完成图索引步骤。

7.3 图检索器

随着图索引过程的完成,我们现在进入图检索器阶段。此阶段侧重于从结构化图中检索相关信息以有效回答查询。虽然有许多可能的检索策略,但我们将重点关注两种主要方法:局部搜索和全局搜索。局部搜索从检测到的社区内紧密连接的实体中检索信息,而全局搜索则考虑整个图结构以查找最相关的信息。

7.3.1 全局搜索

GraphRAG 中的全局搜索使用社区摘要作为中间响应,以高效地回答需要聚合整个数据集信息的查询。这种方法不使用基于向量相似性的单个文本块检索,而是利用预先计算的社区级摘要来生成结构化响应。全局搜索示意图如图 7.4 所示。

image

图7.4 全局搜索

图 7.4 中的过程遵循 Map-Reduce 方法:

  • Map 步骤:给定用户查询和可选的对话历史记录,GraphRAG 从图社区层次结构的指定级别检索 LLM 生成的社区报告。在您的实现中,图的结构具有单级社区,这意味着所有检测到的组都存在于同一层次深度。这些报告被分割成可管理的文本块,每个块由 LLM 处理以产生中间响应。每个响应由一系列关键点组成,每个关键点都附有一个数字重要性评分。
  • Reduce 步骤:所有中间响应中最重要的点被过滤和聚合。这些经过提炼的见解随后作为 LLM 的最终上下文,LLM 将其综合成一个连贯的答案来响应用户查询。通过将数据集结构化为语义上有意义的集群,GraphRAG 实现了高效且连贯的检索,即使是对于广泛的、主题性的查询。

Map 步骤使用以下系统提示,如“检索器 Map 部分的系统提示”所示。

社区层次结构

响应的质量取决于为获取社区报告而选择的社区层次结构级别。较低级别的社区提供详细的报告,导致更彻底的响应,但它们也增加了 LLM 调用和处理时间。较高级别的社区具有更抽象的摘要,可能更高效,但存在失去粒度的风险。平衡细节和效率是优化全局搜索性能的关键。

检索器 Map 部分的系统提示

—角色—
您是一个帮助回答有关所提供表格中数据问题的有用助手。

—目标—
生成一个由关键点列表组成的响应,以响应用户的问题,总结输入数据表中的所有相关信息。

您应使用下面数据表中提供的数据作为生成响应的主要上下文。如果您不知道答案,或者输入数据表不包含足够的信息来提供答案,请如实说明。不要编造任何内容。

响应中的每个关键点应具有以下元素:
– Description: 对该点的全面描述。
– Importance Score: 一个介于 0-100 之间的整数分数,表示该点在回答用户问题方面的重要性。“我不知道”类型的响应应得分为 0。

响应应格式化为 JSON,如下所示:
{{“points”: [ {{“description”: “Description of point 1 [Data: Reports (report ids)]”, “score”: score_value}}, {{“description”: “Description of point 2 [Data: Reports (report ids)]”, “score”: score_value}} ] }}

响应应保留原始含义以及“应”、“可以”或“将”等情态动词的使用。

由数据支持的点应列出相关报告作为参考,如下所示:
“这是一个由数据引用支持的示例句子 [Data: Reports (report ids)]”

不要在单个引用中列出超过 5 个记录 ID。而是列出最相关的 5 个记录 ID,并添加“+more”以表示还有更多。

例如:
“Person X is the owner of Company Y and subject to many allegations of wrongdoing [Data: Reports (2, 7, 64, 46, 34, +more)]. He is also CEO of company X [Data: Reports (1, 3)]”
其中 1, 2, 3, 7, 34, 46 和 64 代表所提供表格中相关数据报告的 id(不是索引)。

不要包含未提供支持证据的信息。

—数据表—
{context_data}

Map 系统提示指示 LLM 根据提供的上下文提取关键点以响应用户查询。每个关键点包括一个描述和一个重要性分数 (0–100),反映其与查询的相关性。响应格式化为 JSON,并引用支持数据报告的 ID。如果信息不足,响应必须如实说明,不得猜测。

现在您将检查检索器的 Reduce 步骤,如“检索器 Reduce 部分的系统提示”所示。

检索器 Reduce 部分的系统提示

—角色—
您是一个通过综合来自多位分析师的视角来回答有关数据集问题的有用助手。

—目标—
生成符合目标长度和格式的响应,以响应用户的问题,总结来自专注于数据集不同部分的多个分析师的所有报告。

请注意,下面提供的分析师报告按重要性降序排列。

如果您不知道答案,或者提供的报告不包含足够的信息来提供答案,请如实说明。不要编造任何内容。

最终响应应从分析师报告中删除所有不相关的信息,并将清理后的信息合并成一个全面的答案,为目标长度和格式提供所有关键点和影响的解释。

根据长度和格式的需要,适当添加章节和评论。使用 Markdown 样式设置响应格式。

响应应保留原始含义以及“应”、“可以”或“将”等情态动词的使用。

响应还应保留先前包含在分析师报告中的所有数据引用,但不要在分析过程中提及多位分析师的角色。

不要在单个引用中列出超过 5 个记录 ID。而是列出最相关的 5 个记录 ID,并添加“+more”以表示还有更多。

例如:
“Person X is the owner of Company Y and subject to many allegations of wrongdoing [Data: Reports (2, 7, 34, 46, 64, +more)]. He is also CEO of company X [Data: Reports (1, 3)]”
其中 1, 2, 3, 7, 34, 46 和 64 代表相关数据记录的 id(不是索引)。

不要包含未提供支持证据的信息。

—目标响应长度和格式—
{response_type}

Reduce 系统提示指导 LLM 从多个按重要性排序的分析师报告中综合关键点。响应必须格式化为 Markdown,结构适合目标长度和格式,并排除不相关的细节。它保留所有引用的数据,同时避免猜测性答案。最终输出将报告中的见解整合并提炼成一个连贯的、全面的响应。

现在我们可以将 Map 和 Reduce 提示组合到一个全局搜索函数中。

清单 7.17 全局搜索

def global_retriever(query: str, rating_threshold: float = 5) -> str:
    # 获取所有评分高于阈值的社区
    community_data, _, _ = neo4j_driver.execute_query(
        """
        MATCH (c:Community)
        WHERE c.rating >= $rating
        RETURN c.summary AS summary
        """,
        rating=rating_threshold,
    )
    print(f"Got {len(community_data)} community summaries")
    intermediate_results = []
    for community in tqdm(community_data, desc="Processing communities"):
        intermediate_messages = [ # 为每个社区获取中间响应
            {"role": "system", "content": ch07_tools.get_map_system_prompt()},
            {"role": "user", "content": f"Question: {query}\n\nData tables:\n{community['summary']}"},
        ]
        intermediate_response = chat(intermediate_messages, model="gpt-4o")
        intermediate_results.append(intermediate_response)
    # 使用所有中间响应作为上下文生成最终答案
    final_messages = [
        {"role": "system", "content": ch07_tools.get_reduce_system_prompt(intermediate_results)},
        {"role": "user", "content": query},
    ]
    summary = chat(final_messages, model="gpt-4o")
    return summary

清单 7.17 中的 global_retriever 函数通过使用社区摘要生成结构化响应来实现全局搜索方法。它遵循三步过程:

  1. 检索相关社区:该函数查询 Neo4j 数据库以检索评分达到或超过指定阈值的社区摘要。这确保只有最相关的社区为最终答案做出贡献。
  2. 生成中间响应:对于每个社区,使用 Map 系统提示生成中间响应。模型处理社区摘要以及用户的查询以提取关键点。
  3. 聚合并生成最终答案:然后应用 Reduce 系统提示将所有中间响应合成为一个连贯的最终答案,确保保留最重要的点并正确构建。

现在我们可以用一个例子来测试这个函数。

清单 7.18 全局搜索示例

print(global_retriever("What is this story about?"))

清单 7.18 的结果可以在“使用全局搜索对‘这个故事是关于什么的?’的响应”中找到。

使用全局搜索对‘这个故事是关于什么的?’的响应

The story revolves around the intricate dynamics of a community involving key figures such as Minerva, Telemachus, and the household of Ulysses. Central themes include divine guidance, familial loyalty, and the challenges posed by suitors. Minerva plays a crucial role in advising Telemachus, who is determined to find his father, Ulysses, and restore order to his home. The relationships among the characters emphasize themes of wisdom, courage, and resilience.

Additionally, the narrative highlights the role of Mentes, the chief of the Taphians, who is recognized as the son of Anchialus. Mentes is involved in a voyage to Temesa, known for its iron cargo, and claims kingship over the Taphians [Data: Reports (1)]. The story also centers around Odysseus, a key figure in Greek mythology, and his connections with other significant entities such as the Achaeans, Laertes, and the gods. The relationships in the story underscore the impact of divine intervention on human affairs, showcasing how the gods influence the lives of Greek heroes [Data: Reports (1, 2, 3, 4, 5)].

Furthermore, the narrative explores the mythological elements involving Olympian Jove, Aegisthus, Agamemnon, Orestes, and Mercury. It highlights themes of divine intervention, betrayal, and vengeance. Olympian Jove discusses the actions of Aegisthus, notorious for his betrayal and murder of Agamemnon, while Orestes avenges his father’s death by killing Aegisthus, despite warnings from Mercury [Data: Reports (1, 2, 3, 4, 5)]. These interconnected stories weave a rich tapestry of mythological and heroic elements, emphasizing the enduring legacy and challenges faced by these legendary figures. (这个故事围绕着一个社区复杂的动态展开,涉及密涅瓦、忒勒玛科斯和尤利西斯家庭等关键人物。中心主题包括神圣引导、家庭忠诚以及求婚者带来的挑战。密涅瓦在建议忒勒玛科斯方面起着关键作用,忒勒玛科斯决心找到他的父亲尤利西斯并恢复家园秩序。角色之间的关系强调了智慧、勇气和韧性的主题。

此外,叙事突显了塔菲安人的首领门特斯的角色,他被认为是安基亚洛斯的儿子。门特斯参与了一次前往以铁货闻名的特梅萨的航行,并声称对塔菲安人的王权 [数据:报告 (1)]。这个故事还以希腊神话中的关键人物奥德修斯为中心,以及他与亚该亚人、莱耳忒斯和众神等其他重要实体的联系。故事中的关系强调了神圣干预对人类事务的影响,展示了众神如何影响希腊英雄的生活 [数据:报告 (1, 2, 3, 4, 5)]。

此外,叙事探讨了涉及奥林匹斯朱庇特、埃癸斯托斯、阿伽门农、奥瑞斯提斯和墨丘利的神话元素。它突显了神圣干预、背叛和复仇的主题。奥林匹斯朱庇特讨论了埃癸斯托斯的行为,埃癸斯托斯因背叛和谋杀阿伽门农而臭名昭著,而奥瑞斯提斯不顾墨丘利的警告,通过杀死埃癸斯托斯为父亲的死报仇 [数据:报告 (1, 2, 3, 4, 5)]。这些相互关联的故事编织了一幅丰富的神话和英雄元素挂毯,强调了这些传奇人物所面临的持久遗产和挑战。)

“使用全局搜索对‘这个故事是关于什么的?’的响应”中由全局搜索方法生成的响应,通过综合来自多个块的关键主题和关系,提供了故事的结构化摘要。它突出了核心人物密涅瓦、忒勒玛科斯和尤利西斯——以及他们在叙事中的角色,强调了神圣引导、家庭忠诚和尤利西斯家庭面临的挑战。

练习 7.2

尝试使用全局搜索功能运行不同类型的查询。提出需要综合多个社区摘要信息的广泛问题,例如“这个故事中的核心冲突是什么?”

7.3.2 局部搜索

局部搜索方法通过结合结构化知识图谱数据和非结构化源文档文本来增强 LLM 响应。这种方法对于实体聚焦的查询特别有效,例如“洋甘菊的治疗特性是什么?”,这些查询需要对特定实体及其关系的深入理解。局部搜索方法如图 7.5 所示。

当用户提交查询时,图 7.5 可视化的系统首先使用向量搜索识别知识图谱中语义相关的实体。这些实体作为检索相关信息的入口点,包括直接连接的实体、关系以及来自社区报告的摘要。此外,还会提取与这些实体相关的输入文档的文本块。检索到的数据经过排名和过滤,以适应有限的上下文窗口,确保只有最相关的信息包含在最终响应中。

image

图7.5 局部搜索

要实现局部搜索,我们首先需要计算实体的文本嵌入并创建向量索引。这使我们能够基于用户的查询高效地检索最相关的实体。通过将实体描述和关系嵌入到向量空间中,我们可以使用相似性搜索来识别哪些实体与输入最密切相关。一旦找到这些相关实体,它们就可以作为检索额外结构化和非结构化数据的入口点。计算这些嵌入和构建向量索引的代码如下清单所示。

清单 7.19 为数据库中的所有实体生成文本嵌入

entities, _, _ = neo4j_driver.execute_query(
    "MATCH (e:Entity) RETURN e.summary AS summary, e.name AS name"
)
# 基于实体摘要计算嵌入
data = [{"name": el["name"], "embedding": embed([el["summary"]])[0]} for el in entities]
# 将嵌入存储到数据库
neo4j_driver.execute_query(
    """
    UNWIND $data AS row
    MATCH (e:Entity {name: row.name})
    CALL db.create.setNodeVectorProperty(e, 'embedding', row.embedding)
    """,
    data=data,
)
# 创建向量索引
neo4j_driver.execute_query(
    "CREATE VECTOR INDEX entities IF NOT EXISTS FOR (n:Entity) ON (n.embedding)"
)

清单 7.19 中的代码从数据库中检索所有实体及其摘要,根据每个实体的摘要计算文本嵌入,并将嵌入存储回数据库。最后,它创建一个向量索引,以便对实体嵌入进行高效的相似性搜索。

最后,局部搜索被实现为一个 Cypher 语句,该语句扩展了通过向量搜索识别的初始相关节点集,以包括它们连接的实体、文本块、摘要和关系。此 Cypher 语句如下一个清单所示。

清单 7.20 局部搜索的 Cypher 语句

local_search_query = """
CALL db.index.vector.queryNodes('entities', $k, $embedding)
YIELD node, score
WITH collect(node) as nodes
WITH collect {
  UNWIND nodes as n
  MATCH (n)<-[:HAS_ENTITY]-(c:Chunk)
  WITH c, count(distinct n) as freq
  RETURN c.text AS chunkText
  ORDER BY freq DESC
  LIMIT $topChunks
} AS text_mapping,
collect {
  UNWIND nodes as n
  MATCH (n)-[:IN_COMMUNITY]->(c:Community)
  WITH c, c.rating AS rank, c.weight AS weight
  RETURN c.summary
  ORDER BY rank, weight DESC
  LIMIT $topCommunities
} AS report_mapping,
collect {
  UNWIND nodes as n
  MATCH (n)-[r:SUMMARIZED_RELATIONSHIP]-(m)
  WHERE m IN nodes
  RETURN r.summary AS descriptionText
  ORDER BY r.rating, r.weight DESC
  LIMIT $topInsideRels
} as insideRels,
collect {
  UNWIND nodes as n
  RETURN n.summary AS descriptionText
} as entities
RETURN {Chunks: text_mapping, Reports: report_mapping, Relationships: insideRels, Entities: entities} AS text
"""

清单 7.20 中检索到的所有对象,例如文本块、社区描述、关系和实体摘要,都经过排名和限制,以确保提示易于管理。文本块根据与相关实体的关联频率进行排名,并限制为前 topChunks 个。社区描述按排名和权重排序,仅选择前 topCommunities 个。关系按其重要性排名,并限制为前 topInsideRels 个。最后,检索实体摘要,没有额外的排名约束。这确保只有最相关的信息包含在响应中。

最后,您需要定义摘要提示,该提示再次借用自论文,并在“局部搜索的系统提示”中显示。

局部搜索的系统提示

—角色—
您是一个帮助回答有关所提供表格中数据问题的有用助手。

—目标—
生成符合目标长度和格式的响应,以响应用户的问题,总结输入数据表中与目标长度和格式相适应的所有信息,并融入任何相关的常识。

如果您不知道答案,请如实说明。不要编造任何内容。

由数据支持的点应按如下方式列出其数据引用:
“这是一个由多个数据引用支持的示例句子 [Data: (record ids); (record ids)]。”

不要在单个引用中列出超过 5 个记录 ID。而是列出最相关的 5 个记录 ID,并添加“+more”以表示还有更多。

例如:
“Person X is the owner of Company Y and subject to many allegations of wrongdoing [Data: Sources (15, 16), Reports (1), Entities (5, 7); Relationships (23); Claims (2, 7, 34, 46, 64, +more)].”
其中 15, 16, 1, 5, 7, 23, 2, 7, 34, 46 和 64 代表相关数据记录的 id(不是索引)。

不要包含未提供支持证据的信息。

—目标响应长度和格式—
{response_type}
—数据表—
{context_data}

“局部搜索的系统提示”旨在基于结构化数据表生成响应,同时保持准确性和透明度。它指示助手综合与用户查询相关的信息,确保主张有明确的数据引用支持。引用数据源的格式强制执行结构化方法,限制每个引用的记录 ID 数量,并在适用时指示额外的支持记录。该提示还强调,如果在提供的数据中找不到答案,助手应明确说明,而不是捏造信息。

有了这个,您现在可以实现局部搜索。

清单 7.21 局部搜索实现

def local_search(query: str, k_entities: int = 5, topChunks: int = 3, topCommunities: int = 2, topInsideRels: int = 5) -> str:
    context, _, _ = neo4j_driver.execute_query(
        local_search_query,
        embedding=embed([query])[0],
        k=k_entities,
        topChunks=topChunks,
        topCommunities=topCommunities,
        topInsideRels=topInsideRels,
    )
    context_str = str(context[0]["text"]) # 将上下文字符串化
    local_messages = [
        {"role": "system", "content": ch07_tools.get_local_system_prompt(context_str)},
        {"role": "user", "content": query},
    ]
    final_answer = chat(local_messages, model="gpt-4o")
    return final_answer

清单 7.21 通过首先使用向量搜索和 Cypher 查询从知识图谱中检索相关上下文来实现局部搜索。提取的上下文随后被转换为字符串,并合并到一个旨在指导 LLM 生成知情响应的结构化提示中。最后,提示被发送到模型以产生最终答案。

您现在可以用一个例子测试这个实现。

清单 7.22 局部搜索测试

print(local_search("Who is Ulysses?"))

清单 7.22 的结果可以在“使用局部搜索对‘尤利西斯是谁?’的响应”中找到。

使用局部搜索对‘尤利西斯是谁?’的响应

Ulysses, also known as Odysseus, is a central figure in the story, depicted as the father of Telemachus and the head of the Ithacan household. The narrative focuses on his absence and the subsequent quest by his son, Telemachus, to find him. Ulysses is deeply connected to other key entities, including the gods, particularly Minerva, who plays a protective and guiding role in his family’s affairs. His relationships underscore themes of leadership, resilience, and the impact of divine intervention on human endeavors [Data: Entities (3, 7); Relationships (12); Reports (2)]. The challenges faced by his household in his absence, including the presence of suitors, highlight the stakes of his return and restoration of order [Data: Chunks (5); Reports (1)]. (尤利西斯,也称为奥德修斯,是故事的核心人物,被描绘为忒勒玛科斯的父亲和伊萨卡家庭的家长。叙事聚焦于他的缺席以及他儿子忒勒玛科斯随后寻找他的旅程。尤利西斯与其他关键实体紧密相连,包括众神,特别是密涅瓦,她在其家庭事务中扮演着保护和引导的角色。他的关系强调了领导力、韧性以及神圣干预对人类事业的影响等主题 [数据:实体 (3, 7);关系 (12);报告 (2)]。在他缺席期间他的家庭面临的挑战,包括求婚者的存在,突显了他回归和恢复秩序的重要性 [数据:块 (5);报告 (1)]。)

“使用局部搜索对‘尤利西斯是谁?’的响应”展示了局部搜索如何检索和综合来自知识图谱的相关信息,以提供详细、有充分支持的答案。通过结合连接的实体、关系和社区摘要,系统确保响应既捕获叙事背景又具备事实深度。

练习 7.3

尝试使用局部搜索功能运行不同类型的查询。

有了这样的图索引,可以实现不同的检索器策略。例如,社区摘要可以单独嵌入并用作独立的向量检索器,允许根据查询的焦点进行更有针对性的检索。

恭喜!您已成功实现了完整的 MS GraphRAG。

总结

  • MS GraphRAG 使用两阶段过程,首先从源文档中提取和总结实体和关系,然后进行社区检测和总结,以创建连贯的知识表示。
  • 提取过程使用 LLM 识别实体,按预定义类型(例如,PERSON, GOD, LOCATION)对其进行分类,并生成实体及其关系的详细描述,包括关系强度分数。
  • 来自多个文本块的实体和关系描述通过基于 LLM 的摘要进行合并,以创建统一的、非冗余的表示,从而保留关键信息。
  • 系统使用 Louvain 等算法检测紧密连接实体的社区,然后生成社区级摘要以捕获更高级别的主题和关系。
  • 全局搜索使用社区摘要,通过 Map-Reduce 方法回答广泛的、主题性的查询。
  • 局部搜索将向量相似性搜索与图遍历相结合,以回答实体聚焦的查询。
  • 检索的有效性取决于诸如块大小、实体类型选择和社区检测参数等因素,较小的块通常会导致更全面的实体提取。
  • 系统通过排名机制处理潜在的扩展挑战,以管理大量的实体、关系和社区,同时保持上下文相关性。

第8章 RAG应用评估

本章涵盖内容

  • 基准测试RAG应用和代理能力
  • 设计评估数据集
  • 应用RAGAS指标:召回率、忠实度、正确性

在本章中,您将探讨使用精心设计的基准问题来评估RAG应用性能的重要性。随着RAG流程变得更加复杂和精细,确保您的代理在各种查询中保持答案的准确性和连贯性变得至关重要。基准评估提供了衡量代理能力所需的系统化方法,同时也有助于清晰定义和界定代理的范围。

评估RAG应用涉及多种方法,每种方法针对应用的不同步骤,如图8.1所示,该图展示了一个由具备检索能力的LLM驱动的问答系统的高级概述。它始于用户向系统提出问题。然后,LLM确定最合适的检索工具来获取必要信息。这一步骤至关重要,可以评估工具选择过程的准确性。

image

图8.1 评估RAG流程的不同步骤

在本书中,您已经实现了各种检索工具设计,从向量搜索开始,逐步发展到更结构化的方法,如文本到Cypher和Cypher模板。每种检索方法服务于不同的需求:

  • 向量搜索:高效检索语义相关的文档。
  • Cypher模板:允许对数据库进行精确、结构化的查询。
  • 文本到Cypher:允许动态和灵活的查询,受益于基于图的检索的表达能力。

评估LLM选择哪种工具以及该工具与查询需求的匹配程度,对于优化检索性能至关重要。

一旦选择了合适的工具,它就会从知识库中检索相关的上下文或数据。检索到的上下文与用户问题的相关性是另一个关键的评估点。一个选择得当的检索方法应确保获取的上下文对于回答查询既是准确的又是充分的。

使用检索到的上下文,LLM生成答案,然后呈现给用户。在此阶段,我们不仅可以评估生成响应的连贯性和准确性,还可以评估模型有效理解和整合所提供上下文的能力。一个特别重要的评估标准是,当提供正确的上下文时,LLM是否产生正确的答案。这使我们能够将模型的推理和综合能力与检索性能分开衡量。

此外,可以整体评估整个流程,以衡量其向用户查询提供准确和上下文相关答案的有效性。通过分析不同阶段的失败——工具选择、检索相关性和最终响应生成——我们可以迭代地改进检索机制和LLM利用检索信息的能力。

假设您负责评估第5章中实现的LLM代理的性能。为了更深入地了解其有效性,您将使用RAGAS Python库来设计和进行基准分析。但首先,您需要设计基准数据集。在本章的剩余部分,我们将从概念过渡到代码,并逐步完成实现。要跟随学习,您需要一个正在运行的Neo4j实例。这可以是本地安装或云托管实例。在本章的实现中,我们使用所谓的“电影数据集”。有关该数据集及其各种加载方式的更多信息,请参阅附录。您可以直接在随附的Jupyter笔记本中跟随实现,地址为:https://github.com/tomasonjo/kg-rag/blob/main/notebooks/ch08.ipynb。

让我们开始吧。

8.1 设计基准数据集

创建基准数据集需要设计输入查询,以测试系统决策制定和响应生成的各个方面。由于RAG流程中的每个步骤都起着至关重要的作用,数据集应包含挑战不同组件的多样化问题:

  • 工具选择评估:一些查询应评估系统是否选择了正确的检索方法,确保其识别最相关的信息来源。
  • 实体和值映射:其他查询可能侧重于测试特定任务,例如将用户输入中的实体或值映射到数据库中的相应条目。
  • 多步骤检索场景:某些代理能够执行多个检索步骤,其中初始检索到的数据作为第二次检索步骤的输入。基准应包括系统需要细化或扩展第一次检索以完全回答查询的情况。这些案例对于回答依赖于动态链接多个查询的复杂问题尤其重要。
  • 边界情况和功能覆盖:为了完全理解系统性能,基准必须覆盖所有功能和已知的边界情况。这包括处理模糊查询、长尾概念以及多种检索方法可能适用的场景。
  • 对话可用性:此外,评估代理处理问候语、澄清模糊查询以及有效传达其能力以确保流畅和用户友好体验的能力可能也很有用。

通过系统地基准测试这些方面,我们可以更清楚地了解代理在不同条件下的表现。这允许进行有针对性的改进,确保在实际部署中的稳健性和可靠性。

8.1.1 构思测试示例

为了全面评估系统,您需要定义明确的端到端测试示例。每个示例由一个问题和其对应的真实答案(ground truth)组成,如图8.2所示,确保可以可靠地评估系统的输出。

image

图8.2 基准测试示例

我们可以使用Cypher查询来动态定义真实答案,而不是提供一个静态字符串作为期望答案。由于我们处理的是图数据库,这种方法提供了一个显著优势:即使底层数据发生变化,基准仍然有效。这确保了测试案例(如图8.3所示)随着时间的推移保持准确,而无需不断更新。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图8.3 使用Cypher语句作为真实答案的基准测试示例

在设计基准数据集时,您应包含多样化的示例以评估代理性能的不同方面。例如,您可以评估代理如何响应“Hello”这样的问候语,向用户提供指导,或处理无关查询,如表8.1所示。

表8.1 测试简单问候语和无关问题的基准示例

问题 Cypher
Hello RETURN “问候并提醒它只能回答与电影相关的问题。”
What can you do? RETURN “回答与电影及其演员相关的问题。”
What is the weather like in Spain? RETURN “无关问题,因为我们只能回答与电影及其演员相关的问题。”

该表提供了代理如何响应简单问候语、用户指导请求和无关查询的示例。它展示了如何使用一个简单的RETURN Cypher语句来定义静态答案,这些答案不需要在数据库中查找信息。例如,当被问候“Hello”时,代理会回复问候并提醒其范围。如果被问及它能做什么,它会澄清它回答关于电影及其演员的问题。对于不相关的查询,比如关于天气的,代理只是说明它只处理电影相关问题。

接下来,我们可以定义一组问题来评估工具使用情况以及LLM使用这些工具生成准确答案的能力。示例如表8.2所示。

表8.2 测试工具使用和值映射的基准示例

问题 Cypher
Who acted in Top Gun? RETURN “MATCH (p:Person)-[:ACTED_IN]->(m:Movie {title: “Top Gun”}) RETURN p.name”
Who acted in top gun? RETURN “MATCH (p:Person)-[:ACTED_IN]->(m:Movie {title: “Top Gun”}) RETURN p.name”
In which movies did Tom Hanks act in? MATCH (p:Person {name: “Tom Hanks”})-[:ACTED_IN]->(m:Movie) RETURN m.title
In which movies did tom Hanks act in? MATCH (p:Person {name: “Tom Hanks”})-[:ACTED_IN]->(m:Movie) RETURN m.title

表8.2中的示例演示了LLM需要使用可用工具从数据库中检索相关数据的情况。在这里,LLM应使用两个关键工具:一个用于通过演员查找电影,另一个用于通过电影查找演员,以确保快速可靠的响应。

此外,这些示例允许我们评估代理将用户输入映射到数据库值的能力。对于知名电影和演员,LLM通常可以根据其预训练直接生成正确的查询。然而,对于不太知名或私有的数据集,专用的映射系统对于准确的实体解析至关重要。实施这样的系统可确保用户输入正确链接到数据库条目,从而提高准确性和可靠性。

您还应该包含一些需要LLM使用文本到Cypher工具的示例,如表8.3所示。

表8.3 测试涉及聚合和过滤查询的基准示例

问题 Cypher
Who acted in the most movies? MATCH (p:Person)-[:ACTED_IN]->(m:Movie) RETURN p.name, COUNT(m) AS movieCount ORDER BY movieCount DESC LIMIT 1
List people born before 1940. MATCH (p:Person) WHERE p.born < 1940 RETURN p.name
Who was born in 1965 and has directed a movie? MATCH (p:Person)-[:DIRECTED]->(m:Movie) WHERE p.born = 1965 RETURN p.name

表8.3包含涉及聚合、过滤和关系的查询,例如查找出演电影最多的演员、列出某年之前出生的人以及识别在特定年份出生的导演。由于没有实现专用工具来处理这些查询,LLM必须依赖文本到Cypher,基于提供的图模式构建适当的Cypher语句。

您还应测试边界情况,例如相关数据缺失但仍在该领域内的查询,如表8.4所示。

表8.4 测试数据缺失问题的基准示例

问题 Cypher
Which movie has the most Oscars? RETURN “This information is missing”

该基准将非常依赖于您的代理的功能。特定的能力,如检索策略、推理方法和结构化输出处理,将影响基准在评估性能方面的有效性。在设计基准时,确保全面覆盖您的代理功能至关重要。通过包含各种示例,您可以有效地测试您的代理处理不同挑战的能力。

基准总共有17个示例,此处未全部展示。您现在可以评估它们。

8.2 评估

为了评估您的基准性能,您将使用RAGAS,这是一个为评估RAG系统而设计的框架。如前所述,评估侧重于三个关键指标,接下来将进行讨论。

8.2.1 上下文召回率

上下文召回率衡量使用“上下文召回评估”中的提示成功检索到的相关信息的数量。高分表示检索系统有效地捕获了回答问题所需的所有必要上下文。

上下文召回评估

目标:给定一个上下文和一个答案,分析答案中的每个句子,并分类该句子是否可以归因于给定的上下文。仅使用“是”(1)或“否”(0)作为二元分类。输出带有推理的JSON。

“上下文召回评估”中的提示确保生成的答案中的每个句子都明确得到检索到的上下文的支持。通过这样做,它有助于评估检索系统捕获相关信息的效果如何。

接下来,忠实度评估确保生成的响应在事实上与检索到的内容保持一致。

8.2.2 忠实度

忠实度评估生成的响应是否与检索到的上下文保持事实一致性。如果一个响应的所有主张都能直接得到所提供文档的支持,则认为它是忠实的,从而最大限度地减少幻觉风险。忠实度使用一个两步过程进行评估。第一步,它使用“忠实度陈述分解”中的提示将答案分解为原子陈述,确保每个信息单元都清晰且自包含,便于验证。

忠实度陈述分解

目标:给定一个问题和一个答案,分析答案中每个句子的复杂性。将每个句子分解为一个或多个完全可理解的陈述。确保任何陈述中都不使用代词。将输出格式化为JSON。

一旦生成了陈述,就使用“忠实度评估”中的提示来评估它们的忠实度。

忠实度评估

目标:您的任务是判断一系列陈述基于给定上下文的忠实度。对于每个陈述,如果该陈述可以直接从上下文中推断出来,则返回判断为1;如果该陈述不能直接从上下文中推断出来,则返回判断为0。

“忠实度评估”中的提示检查生成响应中的陈述是否在事实上基于检索到的上下文。它确保模型不会引入不受支持的主张。

最后,我们通过比较生成的响应与真实答案来评估答案正确性。

8.2.3 答案正确性

答案正确性评估响应回答用户查询的准确性和完整性。它同时考虑事实准确性和相关性,以确保响应与问题的意图保持一致。答案正确性使用与忠实度相同的过程来生成陈述,然后使用“答案正确性评估”中的提示进行评估。

答案正确性评估

目标:给定一个真实答案和一个答案陈述,分析每个陈述并将其分类为以下类别之一:

  • TP (真正例):答案中存在且也直接得到真实答案中一个或多个陈述支持的陈述。
  • FP (假正例):答案中存在但未得到真实答案中任何陈述直接支持的陈述。
  • FN (假反例):真实答案中存在但答案中不存在的陈述。

每个陈述只能属于这些类别之一。为每个分类提供原因。

“答案正确性评估”中的提示通过系统地比较生成的陈述与真实答案,确保响应既事实正确又与期望答案保持一致。

通过分析这些指标,您可以确定系统检索相关数据、保持事实一致性以及生成正确响应的效果如何。此评估将帮助识别潜在的弱点,例如缺少上下文、不一致或不准确的答案,从而允许迭代优化和改进性能。

8.2.4 加载数据集

基准数据集在随附的存储库中以CSV文件提供,便于加载和使用,如下一个清单所示。

清单 8.1 从CSV加载基准数据集

import pandas as pd
test_data = pd.read_csv("../data/benchmark_data.csv", delimiter=";")

8.2.5 运行评估

为了评估系统的性能,您将为基准数据集生成答案,并将其与预期的真实答案响应进行比较。首先,您需要通过执行相应的Cypher语句并生成代理的答案来获取真实答案,如清单8.2所示。此外,您必须记录延迟和检索到的上下文,以分析系统的效率和相关性。

清单 8.2 生成答案和真实答案响应

from datetime import datetime
from tqdm import tqdm

answers = []
ground_truths = []
latencies = []
contexts = []

for i, row in tqdm(test_data.iterrows(), total=len(test_data), desc="Processing rows"):
    ground_truth, _, _ = neo4j_driver.execute_query(row["cypher"])
    ground_truths.append([str(el.data()) for el in ground_truth])
    start = datetime.now()
    try:
        answer, context = get_answer(row["question"])
        context = [el['content'] for el in context]
    except Exception:
        answer, context = None, []
    latency = (datetime.now() - start).total_seconds()
    answers.append(answer)
    latencies.append(latency)
    contexts.append(context)

清单8.2中的代码遍历基准数据集中的每一行。对于每一行,它执行相应的Cypher查询以获得真实答案,记录开始时间,尝试使用代理的get_answer函数获取答案和上下文(处理任何异常),计算延迟,并将结果存储在列表中。

接下来,您可以使用RAGAS库根据真实答案、生成的答案和检索到的上下文来计算评估指标,如下一个清单所示。

清单 8.3 使用 RAGAS 计算指标

from ragas.metrics import faithfulness, answer_correctness, context_recall
from ragas import evaluate

# 准备数据集以进行评估
dataset = Dataset.from_dict({
    "question": test_data["question"].tolist(),
    "answer": answers,
    "contexts": contexts,
    "ground_truth": ground_truths,
})

# 运行评估
results = evaluate(
    dataset=dataset,
    metrics=[context_recall, faithfulness, answer_correctness],
)
# 打印结果
print(results)

清单8.3创建了一个包含问题、生成的答案、检索到的上下文和真实答案的RAGAS数据集对象。然后,它使用evaluate函数和指定的指标(上下文召回率、忠实度、答案正确性)来计算评估结果。

为了更详细地分析,您可以将每个指标的结果单独提取出来,并计算整体平均值,如下一个清单所示。

清单 8.4 提取和分析指标结果

# 将结果转换为更易读的格式
metrics_df = results.to_pandas()

# 计算每个指标的平均值
avg_context_recall = metrics_df['context_recall'].mean()
avg_faithfulness = metrics_df['faithfulness'].mean()
avg_answer_correctness = metrics_df['answer_correctness'].mean()

print(f"Average Context Recall: {avg_context_recall:.2%}")
print(f"Average Faithfulness: {avg_faithfulness:.2%}")
print(f"Average Answer Correctness: {avg_answer_correctness:.2%}")

# 您还可以检查单个示例以进行调试
print("\nDetailed results for first few examples:")
for i in range(min(3, len(metrics_df))):
    print(f"Example {i+1}:")
    print(f"  Question: {test_data.iloc[i]['question']}")
    print(f"  Answer: {answers[i]}")
    print(f"  Context Recall: {metrics_df.iloc[i]['context_recall']:.2%}")
    print(f"  Faithfulness: {metrics_df.iloc[i]['faithfulness']:.2%}")
    print(f"  Answer Correctness: {metrics_df.iloc[i]['answer_correctness']:.2%}")
    print()

清单8.4将评估结果转换为pandas DataFrame以便于分析,计算每个指标的平均值,并打印前几个示例的详细结果以进行调试。

8.2.6 观察结果

运行评估后,您可以分析结果以确定系统的优势和需要改进的领域。观察可能包括:

  • 上下文召回率高但答案正确性低:这可能表明检索系统找到了相关文档,但LLM未能正确理解或合成信息。
  • 忠实度高但上下文召回率低:这可能表明LLM严格遵守提供的上下文,但检索系统未能提供足够或相关的上下文。
  • 特定类型查询表现不佳:您可能会注意到,涉及聚合、过滤或多跳推理的查询得分较低,表明需要改进这些领域的检索策略或LLM提示。

您可以使用这些观察来迭代改进系统,例如:

  1. 为表现不佳的查询类型添加专用检索工具。
  2. 改进提示以更好地指导LLM使用检索到的上下文。
  3. 调整检索参数(例如,分块大小、重叠、检索数量)。

8.3 后续步骤

完成初始评估后,您可以考虑几个后续步骤以进一步改进和监控系统:

  1. 扩展基准:随着添加新功能,使用涵盖这些新功能的示例扩展基准数据集。
  2. 自动化评估:将评估流程集成到您的CI/CD管道中,以在更改代码或数据时自动运行基准测试。
  3. A/B测试:对检索策略或LLM提示的修改进行A/B测试,以衡量其对性能指标的影响。
  4. 监控生产环境:在生产环境中部署后,收集用户反馈和交互数据,以持续评估和改进系统。
  5. 探索高级指标:除了RAGAS指标外,还可以考虑其他评估方面,如响应延迟、成本效率和用户满意度。

通过遵循本章概述的系统化评估方法,您可以确保您的RAG应用保持准确、可靠并且随着时间的推移不断改进。

总结

  • 系统化基准测试对于衡量和提升RAG代理在各种查询下的性能至关重要。
  • 设计良好的基准数据集应涵盖代理的所有功能,包括工具选择、实体映射、多步骤检索和边界情况。
  • 使用Cypher查询作为真实答案允许基准随时间推移保持有效,即使底层数据发生变化。
  • RAGAS框架提供了用于评估RAG系统的关键指标:上下文召回率、忠实度和答案正确性。
  • 上下文召回率衡量检索到的相关信息量;忠实度评估答案与检索内容的事实一致性;答案正确性衡量答案相对于真实答案的准确性和完整性。
  • 分析评估结果可以揭示系统弱点,指导检索策略、LLM提示和整体架构的迭代改进。
  • 将评估集成到开发流程和生产监控中,确保RAG应用保持稳健并满足用户需求。

附录 A Neo4j环境

本附录为您提供了关于使用Neo4j图数据库与本书中示例代码的指导。我们将首先介绍如何设置Neo4j环境,然后重点介绍如何加载“电影”数据集,该数据集在多个章节中使用,尤其是在涉及Cypher查询生成和代理式RAG的章节中。

设置Neo4j

您可以通过多种方式获取Neo4j实例:

  • Neo4j Aura:这是由Neo4j提供的完全托管的云服务。您可以注册一个免费层账户,快速创建一个空的数据库实例。这是最简单、最快捷的入门方式。
  • Neo4j Desktop:如果您更喜欢在本地运行,可以下载并安装Neo4j Desktop。它提供了一个直观的界面来管理本地数据库实例,非常适合开发和测试。
  • Docker:您也可以使用Docker容器运行Neo4j。这对于希望在容器化环境中运行的开发者来说很方便。

无论选择哪种方式,请确保您拥有数据库的连接URI(通常是bolt://localhost:7687或Aura提供的地址)以及用户名和密码(默认是neo4j/password,但Aura会要求您在创建时设置新密码)。

加载“电影”数据集

本书第4章(文本到Cypher)、第5章(代理式RAG)和第8章(评估)的示例都使用了一个示例性的“电影”图谱数据集。该数据集包含了电影、人物以及他们之间的关系(如出演、导演、评论等)。

方法1:使用内置的:play命令(Neo4j Browser)

如果您使用的是Neo4j Desktop或访问Neo4j Browser,最快捷的方式是使用内置的教程命令。

  1. 连接到您的Neo4j数据库。
  2. 打开Neo4j Browser。
  3. 在命令输入行中,键入::play movie-graph
  4. 按下回车键。这将加载一个包含Cypher语句的教程页面。
  5. 按照页面上的说明,逐个运行提供的Cypher代码块来创建节点和关系。

这种方法会交互式地引导您构建图谱。

方法2:直接运行Cypher脚本

您也可以直接执行一个包含所有必要Cypher语句的脚本。以下是一个创建该数据集的简化示例脚本的核心部分。您可以在Neo4j Browser中一次性运行它。

// 创建电影
CREATE (TheMatrix:Movie {title:'The Matrix', released:1999, tagline:'Welcome to the Real World'})
CREATE (Keanu:Person {name:'Keanu Reeves', born:1964})
CREATE (Carrie:Person {name:'Carrie-Anne Moss', born:1967})
CREATE (Laurence:Person {name:'Laurence Fishburne', born:1961})
CREATE (Hugo:Person {name:'Hugo Weaving', born:1960})
CREATE (LillyW:Person {name:'Lilly Wachowski', born:1967})
CREATE (LanaW:Person {name:'Lana Wachowski', born:1965})
CREATE (JoelS:Person {name:'Joel Silver', born:1952})

// 创建关系
CREATE (Keanu)-[:ACTED_IN {roles:['Neo']}]->(TheMatrix)
CREATE (Carrie)-[:ACTED_IN {roles:['Trinity']}]->(TheMatrix)
CREATE (Laurence)-[:ACTED_IN {roles:['Morpheus']}]->(TheMatrix)
CREATE (Hugo)-[:ACTED_IN {roles:['Agent Smith']}]->(TheMatrix)
CREATE (LillyW)-[:DIRECTED]->(TheMatrix)
CREATE (LanaW)-[:DIRECTED]->(TheMatrix)
CREATE (JoelS)-[:PRODUCED]->(TheMatrix)

// ... 可以继续添加更多电影和人物,例如《云图》、《黑客帝国重装上阵》等。

提示:本书的官方GitHub代码仓库(https://github.com/tomasonjo/kg-rag)的 data/ 目录下通常包含一个完整的 movies.cypher 脚本文件,您可以直接下载并在Neo4j Browser中运行它来导入完整的数据集。

方法3:使用Python驱动程序导入

在Jupyter Notebook环境中,您也可以使用Neo4j Python驱动程序来运行数据导入脚本。假设您有一个包含Cypher语句的字符串或文件。

from neo4j import GraphDatabase

uri = "bolt://localhost:7687"  # 替换为您的URI
username = "neo4j"
password = "your_password"     # 替换为您的密码

driver = GraphDatabase.driver(uri, auth=(username, password))

# 读取包含Cypher语句的文件
with open('movies.cypher', 'r') as file:
    cypher_script = file.read()

# 在单个事务中运行整个脚本(注意:对于非常大的脚本,可能需要分块)
with driver.session() as session:
    session.run(cypher_script)

driver.close()
print("数据集导入完成。")

验证数据加载

数据加载完成后,您可以在Neo4j Browser中运行一些简单的查询来验证。

  • 查看所有电影:

    MATCH (m:Movie) RETURN m.title LIMIT 10
    
  • 查看所有人物:

    MATCH (p:Person) RETURN p.name LIMIT 10
    
  • 查找谁出演了《黑客帝国》:

    MATCH (p:Person)-[:ACTED_IN]->(m:Movie {title:'The Matrix'}) RETURN p.name
    

使用Python驱动程序连接

在本书的代码示例中,我们普遍使用 neo4j.Driver 对象与数据库交互。建立连接的典型模式如下:

from neo4j import GraphDatabase
import os

# 从环境变量或配置中读取连接信息是安全的最佳实践
NEO4J_URI = os.getenv("NEO4J_URI", "bolt://localhost:7687")
NEO4J_USERNAME = os.getenv("NEO4J_USERNAME", "neo4j")
NEO4J_PASSWORD = os.getenv("NEO4J_PASSWORD", "password")

driver = GraphDatabase.driver(NEO4J_URI, auth=(NEO4J_USERNAME, NEO4J_PASSWORD))

# 测试连接
try:
    driver.verify_connectivity()
    print("成功连接到Neo4j!")
except Exception as e:
    print(f"连接失败:{e}")
    driver.close()

请确保在您的Python环境中安装了 neo4j 驱动程序包:pip install neo4j

现在,您的Neo4j环境已经准备就绪,并加载了示例数据,您可以运行书中各章节的代码示例了。


参考文献

  1. OpenAI, Josh Achiam, Steven Adler, Sandhini Agarwal, et al. “GPT-4 Technical Report.” 2024. (文中引用为:OpenAI et al., 2024)
  2. Vaswani, Ashish, Noam Shazeer, Niki Parmar, Jakob Uszkoreit, Llion Jones, Aidan N. Gomez, Lukasz Kaiser, and Illia Polosukhin. “Attention Is All You Need.” Advances in Neural Information Processing Systems 30 (2017). (文中引用为:Vaswani et al., 2017)
  3. Rader, Andrew. “Mark Cuban Sells Majority Stake of Dallas Mavericks.” ESPN. November 29, 2023. (文中引用为:Rader, 2023)
  4. Neumeister, Larry. “Lawyers Submit Bogus, ChatGPT-Generated Citations in Court.” Associated Press. May 27, 2023. (文中引用为:Neumeister, 2023)
  5. Tian, Katherine, Eric Mitchell, Huaxiu Yao, Christopher D. Manning, and Chelsea Finn. “Fine-tuning Language Models for Factuality.” arXiv preprint arXiv:2311.08401 (2023). (文中引用为:Tian et al., 2023)
  6. Ovadia, Oded, Menachem Brief, Moshik Mishaeli, and Oren Elisha. “Fine-tuning or Retrieval? Comparing Knowledge Injection in LLMs.” arXiv preprint arXiv:2312.16134 (2023). (文中引用为:Ovadia et al., 2023)
  7. Lewis, Patrick, Ethan Perez, Aleksandra Piktus, Fabio Petroni, et al. “Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks.” Advances in Neural Information Processing Systems 33 (2020): 9459–9474. (文中引用为:Lewis et al., 2020)
  8. Gao, Luyu, Xueguang Ma, and Jamie Callan. “Precise Zero-Shot Dense Retrieval without Relevance Labels.” arXiv preprint arXiv:2212.10496 (2022). (文中引用为:Gao et al., 2022)
  9. Zheng, Huaixiu, Swaroop Mishra, Xinyun Chen, Heng-Tze Cheng, Ed H. Chi, Quoc V. Le, and Denny Zhou. “Take a Step Back: Evoking Reasoning via Abstraction in Large Language Models.” arXiv preprint arXiv:2310.06117 (2023). (文中引用为:Zheng et al., 2023)
  10. Yao, Shunyu, Jeffrey Zhao, Dian Yu, Nan Du, Izhak Shafran, Karthik R. Narasimhan, and Yuan Cao. “ReAct: Synergizing Reasoning and Acting in Language Models.” arXiv preprint arXiv:2210.03629 (2022). (第5章中引用了此方法)
  11. Hendrycks, Dan, Collin Burns, Anya Chen, and Spencer Ball. “CUAD: An Expert-Annotated Dataset for Legal Contract Review.” arXiv preprint arXiv:2103.06268 (2021). (文中引用为:Hendrycks et al., 2021)
  12. Edge, Darren, Haiyang Zhang, Aurora Harley, Joshua Bradley, Alex Chao, Apurva Mody, Steven Truitt, and Jonathan Larson. “GraphRAG: Unleashing the Power of Knowledge Graphs with Large Language Models for Open-Domain Question Answering.” Microsoft Research. 2024. (文中引用为:Edge et al., 2024)
  13. Blondel, Vincent D., Jean-Loup Guillaume, Renaud Lambiotte, and Etienne Lefebvre. “Fast unfolding of communities in large networks.” Journal of Statistical Mechanics: Theory and Experiment 2008, no. 10 (2008): P10008. (文中引用的Louvain算法基于此工作)
  14. Traag, V. A., L. Waltman, and N. J. van Eck. “From Louvain to Leiden: Guaranteeing well-connected communities.” Scientific Reports 9, no. 1 (2019): 5233. (文中提到Leiden算法是Louvain的替代方案)

索引

本索引列出了本书中的关键术语和概念,并指向它们被讨论的主要章节。页码对应于打印版书籍。

A

  • 代理式RAG (Agentic RAG), 5, 56–69
    • 答案评判器 (Answer critic), 58, 66–67
    • 定义 (definition), 57
    • 实现 (implementation), 59–68
    • 检索器代理 (Retriever agents), 57
    • 检索器路由器 (Retriever router), 58, 62–65
    • 为何需要 (why needed), 59
  • 答案正确性 (Answer correctness), 评估指标, 122–123
  • 答案评判器 (Answer critic), 代理式RAG中, 58, 66–67
  • 近似最近邻搜索 (Approximate nearest neighbor search), 18
  • APOC库, 用于模式推断, 49

B

  • 回溯提示 (Step-back prompting), 34–35, 44
    • 用于查询重写 (for query rewriting), 30, 31, 34
  • 基准测试 (Benchmarking), RAG应用, 116–125
    • 数据集设计 (dataset design), 118–120
  • 偏见 (Bias), LLM的局限性, 8

C

  • 社区检测 (Community detection), 微软GraphRAG中, 100–103
  • 上下文召回率 (Context recall), 评估指标, 121
  • 合约理解数据集 (Contract Understanding Atticus Dataset, CUAD), 79–80
  • 余弦相似性 (Cosine similarity), 18–19, 23
  • CUAD数据集, 79–80
  • Cypher查询生成, 见文本到Cypher

D

  • 数据库模式 (Database schema), 在查询生成提示中, 48–51

E

  • 边 (Edge), 见图 (Graph)
  • 嵌入 (Embeddings), 17, 19, 22
    • 维度 (dimension), 19
    • 模型 (model), 19, 22
    • 策略, 高级 (strategies, advanced), 30–33
  • 实体解析 (Entity resolution), 在知识图谱构建中, 84–85
  • 实体提取 (Entity extraction), 微软GraphRAG中, 92–96
  • 评估 (Evaluation), RAG应用, 116–125
    • 答案正确性 (answer correctness), 122–123
    • 上下文召回率 (context recall), 121
    • 忠实度 (faithfulness), 121–122
    • 指标 (metrics), 121–123
    • 运行评估 (running), 123–124
  • 评估数据集 (Evaluation dataset), 设计, 118–120

F

  • 忠实度 (Faithfulness), 评估指标, 121–122
  • 少样本示例 (Few-shot examples), 用于查询生成, 47–48
  • 全文搜索 (Full-text search), 27–28
    • 索引 (index), 27
  • 函数调用 (Function calling), 在代理式系统中, 56–57, 59

G

  • 生成器 (Generator), RAG组件, 20
  • 全球搜索 (Global search), 微软GraphRAG中, 104–109
  • 图 (Graph)
    • 数据库 (database), 见 Neo4j
    • 索引 (indexing), 微软GraphRAG中, 90–103
    • 知识 (knowledge), 见 知识图谱
  • GraphRAG (微软实现), 7, 88–115
    • 社区检测与摘要 (community detection and summarization), 100–103
    • 数据集选择 (dataset selection), 89–90
    • 实体与关系提取 (entity and relationship extraction), 92–96
    • 实体与关系摘要 (entity and relationship summarization), 96–100
    • 图索引 (graph indexing), 90–103
    • 图检索器 (graph retrievers), 103–115
    • 全局搜索 (global search), 104–109
    • 局部搜索 (local search), 109–114
  • GPT-4, 2, 56, 62, 78

H

  • 幻觉 (Hallucinations), 6–7, 10
  • 混合搜索 (Hybrid search), 17, 27–29, 33
    • 执行 (performing), 27–28
  • 假设文档检索器 (Hypothetical Document Embeddings, HyDE), 30, 31

I

  • 索引 (Index)
    • 全文 (full-text), 27
    • 向量 (vector), 18, 23

K

  • 知识截止 (Knowledge cutoff), LLM局限性, 5
  • 知识图谱 (Knowledge graphs), 1, 14–15, 70
    • 构建 (constructing), 70–87
      • 数据导入 (data import), 82–84
      • 实体解析 (entity resolution), 84–85
      • 图构建 (graph construction), 81–87
      • 结构化数据提取 (structured data extraction), 71–80
    • 作为RAG的数据存储 (as data storage for RAG), 14–15
    • 定义 (definition), 14
    • 与非结构化数据结合 (with unstructured data), 85–87

L

  • 大型语言模型 (Large language models, LLMs), 1–15
    • 准确性 (accuracy), 改进, 1–15
    • 评估中的 (in evaluation), 117, 119–120
    • 用于查询重写 (for query rewriting), 34–35
    • 用于实体/关系提取 (for entity/relationship extraction), 92–96
    • 用于知识图谱构建 (for knowledge graph construction), 70–87
    • 用于摘要 (for summarization), 96–103
    • 用于文本到Cypher (for text2cypher), 45–55
    • 简介 (introduction to), 2–5
    • 局限性 (limitations), 5–9
      • 偏见 (bias), 8
      • 知识截止 (knowledge cutoff), 5
      • 缺乏私有信息 (lack of private information), 7–8
      • 过时信息 (outdated information), 6
      • 提示注入 (prompt injection), 8
      • 纯粹幻觉 (pure hallucinations), 6–7
      • 响应不一致 (inconsistent responses), 8
    • 克服局限性 (overcoming limitations), 9–14
      • 检索增强生成 (RAG), 10–13
      • 监督微调 (supervised finetuning), 9–10
    • 训练阶段 (training phases), 9
  • 局部搜索 (Local search), 微软GraphRAG中, 109–114
  • Louvain算法, 用于社区检测, 101–102

M

  • 地图-归约 (Map-reduce), 在全局搜索中, 104–105
  • 微软GraphRAG, 见 GraphRAG (微软实现)
  • 电影数据集 (Movies dataset), 45, 59, 117, 附录A

N

  • Neo4j, 23, 49, 附录A
    • 驱动程序 (driver), 附录A
    • 环境设置 (environment setup), 附录A
    • 全文索引 (full-text index), 27
    • 模式推断 (schema inference), 49–50
    • 向量索引 (vector index), 23
  • 节点 (Node), 见图 (Graph)

O

  • 《奥德赛》数据集 (The Odyssey dataset), 7, 89–90
  • 过时信息 (Outdated information), LLM局限性, 6

P

  • 父文档检索器 (Parent document retriever), 31, 36–44
    • 数据检索 (data retrieval), 41–42
    • 图表示 (graph representation), 37
    • 实现 (implementation), 36–44
  • 提示注入 (Prompt injection), LLM漏洞, 8
  • Pydantic, 用于结构化输出, 73

Q

  • 查询语言生成 (Query language generation), 见 文本到Cypher
  • 查询重写 (Query rewriting), 30–31, 34–35
    • 回溯提示 (step-back prompting), 34–35

R

  • RAGAS评估框架, 117, 121–123
  • 重排序 (Reranking), 33
  • 关系 (Relationship)
    • 提取 (extraction), 微软GraphRAG中, 92–96
    • 摘要 (summarization), 微软GraphRAG中, 96–100
  • 检索增强生成 (Retrieval-augmented generation, RAG), 1, 10–13
    • 代理式 (Agentic), 见 代理式RAG
    • 架构组件 (architecture components), 18–20
    • 评估 (evaluation), 116–125
    • 生成器 (generator), 20
    • 工作原理 (how it works), 10–13
    • 检索器 (retriever), 18–19
    • 使用向量相似性搜索 (using vector similarity search), 20–26
  • 检索器 (Retriever), RAG组件, 18–19
    • 代理 (agents), 在代理式RAG中, 57, 59–62
    • 流程 (pipeline), 19
    • 路由器 (router), 在代理式RAG中, 58, 62–65
  • 奖励建模 (Reward modeling), LLM训练阶段, 9

S

  • 模式 (Schema), 数据库, 在查询生成提示中, 48–51
  • 监督微调 (Supervised fine-tuning, SFT), 9–10
  • 结构化输出 (Structured Outputs), OpenAI API特性, 73, 78–79

T

  • 文本分块 (Text chunking), 19, 21–22
    • 在微软GraphRAG中 (in Microsoft GraphRAG), 90–92
  • 文本到Cypher (Text2cypher), 45–55
    • 基础 (basics), 46
    • 专用LLM (specialized LLMs for), 54
    • 在代理式RAG中 (in agentic RAG), 57, 59–60
    • 在RAG流程中的位置 (place in RAG pipeline), 47
    • 实现 (implementation), 52–53
    • 有用实践 (useful practices), 47–51
      • 数据库模式 (database schema), 48–51
      • 少样本示例 (few-shot examples), 47–48
      • 格式说明 (format instructions), 51
      • 术语映射 (terminology mapping), 51
  • 词元 (Tokens), 计数, 38, 91
  • Transformer架构, 2, 5

V

  • 向量索引 (Vector index), 18, 23
  • 向量相似性搜索 (Vector similarity search), 17–29
    • 函数 (function), 18–19, 23
    • 在RAG中的应用 (in RAG), 20–26
      • 答案生成 (answer generation), 26
      • 应用数据设置 (application data setup), 21–23
      • 执行搜索 (performing search), 24–26

Z

  • 零样本提示 (Zero-shot prompting), 35
Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐