如何使用Python和Neo4j构建知识图谱【完整指南】
你所处理的大部分数据实际上都反映了各种关系:一个客户属于某个账户,某次事件会影响某种服务,而一名工程师负责维护某个代码库。你把所有这些信息存储在表格中,长期以来,这种存储方式一直运行得非常顺利。 然而,有时会有人提出这样的问题: 哪些工程师最近了解过昨晚那次事件所影响的服务情况? 这类问题很容易理解,但编写相应的SQL查询却相当困难。通常需要使用四到五次连接操作,每次连接都会生成一个比最终结果范围更广的中间数据集,而大部分这些中间数据最终都会被丢弃。随着表格规模的扩大,查询速度会变得越来越慢,而且每次查看这个查询语句时,都很难理解其具体逻辑。 为了解决这类问题,人们才创造了图数据库。 在这本手
你所处理的大部分数据实际上都反映了各种关系:一个客户属于某个账户,某次事件会影响某种服务,而一名工程师负责维护某个代码库。你把所有这些信息存储在表格中,长期以来,这种存储方式一直运行得非常顺利。
然而,有时会有人提出这样的问题:
哪些工程师最近了解过昨晚那次事件所影响的服务情况?
这类问题很容易理解,但编写相应的SQL查询却相当困难。通常需要使用四到五次连接操作,每次连接都会生成一个比最终结果范围更广的中间数据集,而大部分这些中间数据最终都会被丢弃。随着表格规模的扩大,查询速度会变得越来越慢,而且每次查看这个查询语句时,都很难理解其具体逻辑。
为了解决这类问题,人们才创造了图数据库。
在这本手册中,你将从一个空数据库开始构建一个可用的知识图谱,通过Python将真实数据导入其中,并学习如何编写那些能够让这些数据发挥作用的查询语句。
你还会学到一些教程通常会忽略的内容:如何决定哪些信息应该被纳入节点中,为什么你的初始数据模型很可能是错误的,如何加快数据加载速度,以及当查询效率低下时该如何分析查询计划。
阅读这本手册并不需要任何图数据库相关的经验。只要你熟悉SQL编程,就已经具备了开始学习所需的知识。
对于同样的数据,用两种不同的方法进行处理会得到截然不同的结果。左边是关系数据库在查询时进行匹配操作,然后丢弃大部分中间数据;右边则是图数据库利用在数据写入时就已经建立好的连接关系来处理数据。本书的其余内容主要都在探讨这两种方法之间的区别。
所有的代码和数据集都放在一个地方:github.com/ronidas39/knowledge-graph-python-neo4j。手册中的每个脚本都可以正常运行,而且所有测试数据都是基于这个已提交的数据集进行测量的。你在阅读本书的过程中可以随时克隆这个环境并重现所有的实验结果。
目录
我们将使用的数据
本手册中的每一个示例都是使用同一个小型数据集进行测试的,因此你可以从第一个查询开始,一直跟随到最后一个查询,而无需加载任何新的数据。
这个数据集是用来模拟一个软件团队的,因为大多数读者都可以根据自己的经验来理解这个场景。需要说明的是:这个数据集完全是虚构的:其中并没有真实的公司、服务或个人出现,所有的电子邮件地址也都使用了example.com这种格式(根据RFC 2606的规定,使用这种格式可以确保文档中不会意外地包含真实用户的地址信息)。
| 类型 | 数量 | >具体含义 |
|---|---|---|
工程师 |
6人 | 其中5人负责某项服务,1人没有负责任何服务 |
服务 |
4项 | 包括支付、结账、认证和搜索等功能 |
团队 |
3个 | 分别负责平台、商务和开发等工作 |
事件 |
1个 | 事件编号为INC-4471,影响了支付和结账功能 |
这些数据通过四种关系类型相互关联:
| 关系类型 | >含义 |
|---|---|
负责 |
指工程师负责某项服务 |
属于 |
指工程师属于某个团队 |
依赖 |
指某项服务需要另一项服务才能正常运行 |
影响 |
指某个事件会影响到某项服务的运行 |
总共有14个节点和16种关系类型,用于描述30条记录。之所以设计成这样的规模,是因为在这个规模下,你可以将整个数据结构清晰地记在脑海中,并且可以直观地检查每一个结果。当这些概念还比较新的时候,这样的设计确实非常方便。如果数据量增加到一百万个节点,虽然验证速度会变慢,但基本原理仍然适用。
有两条细节值得提前注意,因为它们在后面的内容中会起到重要的作用。有1名工程师没有负责任何服务,这也是为什么在OPTIONAL MATCH示例中会有相关结果显示的原因。而“商务”团队恰好只有1名成员,而且这名成员同时也是某项服务的负责人,这个设计实际上隐藏了一个Cypher语言中的陷阱,会导致某些记录被错误地忽略掉。不过,这两种情况都不是偶然出现的。
完整的数据加载脚本位于本手册的末尾,如果你希望先查看这些数据,可以在继续阅读之前运行该脚本。
你需要掌握的术语
本手册中出现的每一个术语都会在其首次出现的地方进行定义,但把它们集中放在一处查阅会更为方便。如果你以前从未接触过图数据库,建议先阅读一次这个表格,以后每当遇到不熟悉的术语时,就可以参考它来理解含义。
| 术语 | 含义 | 官方参考资料 |
|---|---|---|
| 图 | 由各种元素及其相互关联关系构成的结构。在计算机领域,它指的是以线条连接点的形式存储的数据,而非表格中的行数据。你的联系人应用就是一个图结构,地图同样如此。 | 入门指南 |
| 图数据库 | 一种直接将元素之间的关联关系作为记录存储在磁盘上的数据库。它在查询时不会通过匹配数值来计算这些关联关系,而是直接存储这些信息。Neo4j就是这种类型的数据库之一。 | 入门指南 |
| 节点 | 数据中的某个具体元素,例如一名工程师、一项服务或一笔订单。它大致相当于表格中的一行。 | 模式与用法 |
| 关系 | 两个节点之间明确存在的关联关系。这种关系总是具有方向性和类型,例如OWNS。它大致相当于数据库中的外键,但不同的是,关系本身是真实存在的数据记录,可以用来进行数据查询。 |
模式与用法 |
| 属性 | 存储在节点或关系上的键值对,例如name: "Ada"。它大致相当于表格中的列值。 |
数据类型与值 |
| 标签 | 用于对节点进行分类的标识,例如Engineer。它表示“只查看工程师相关的数据”。它大致相当于表格的名称。 |
模式与用法 |
| Cypher | Neo4j使用的查询语言,类似于SQL。使用Cypher时,你不是描述数据之间的连接关系,而是直接绘制出你想要查询的数据结构,例如(a)-[:OWNS]->(b)。 |
Cypher手册 |
| 遍历 | 沿着关系从一个节点连接到另一个节点。图数据库正是通过这种方式来操作数据,而不是使用传统的连接操作。 | 模式与用法 |
| 跳转 | 沿着某条关系进行一次查询操作。“相隔三跳”意味着两个节点之间存在三条关联关系。 | Cypher手册 |
| Bolt | Neo4j与驱动程序进行通信所使用的网络协议,类似于浏览器使用的HTTP协议。默认情况下,Bolt协议运行在7687端口上,因此连接字符串的格式为bolt://host:7687。 |
Bolt协议 |
| 驱动程序 | 你的程序用来通过Bolt协议与数据库进行通信的库。对于Python来说,这个库就是neo4j包。 |
Python驱动程序手册 |
| Neo4j浏览器 | 用于运行Cypher查询并查看以图形形式显示的结果的网页界面。该工具随数据库一同提供,运行在7474端口上。 | 操作手册 |
| Aura | Neo4j提供的托管型云服务,他们会帮你管理数据库。该服务提供免费试用版本。 | Aura文档 |
| MERGE | Cypher命令,意为“找到这个元素;如果不存在,则创建它”。这是安全加载数据时最重要的命令。 | MERGE命令说明 |
| 约束条件 | 数据库强制执行的规则,例如“每个工程师的电子邮件地址必须是唯一的”。创建约束条件的同时也会生成相应的索引。 | 约束条件说明 |
| 索引 | 一种查询结构,使数据库能够根据属性值快速找到目标节点,而无需检查所有节点。 | 性能优化与调优 |
无索引的邻接关系存储方式
| 使数据遍历变得快速的机制:因为关系被存储为同时指向两个节点的记录,所以沿着关系进行查询实际上就是读取数据,而不是进行搜索操作。 |
入门指南 |
|
大写驼峰式命名法来表示(例如OWNS、MEMBER_OF),而标签则使用小写驼峰式命名法(例如Engineer、Service)。Neo4j并没有强制要求必须遵循这两种格式,但所有的代码库和文档都采用了这些约定,因此遵守这些规则可以使你的查询语句被其他人轻松理解。
完整的语言参考资料可以在Cypher手册中找到,这份手册确实非常有用。如果本手册中的内容引发了你的疑问,那么就可以去那里查找答案。
你正在构建什么
在了解具体实现细节之前,先来看看整个系统的架构。这个系统由四个核心部分组成:你最初使用的数据、用于加载这些数据的Python驱动程序、Neo4j存储的数据结构,以及最终以模型能够直接使用的方式返回的结果。
从左到右来看:你的数据可以是CSV文件、现有的数据库,或者是模型可以直接处理的纯文本;Python驱动程序是整个应用程序中用于执行Cypher查询的组件,它提供了execute_query()方法来运行Cypher语句,同时也支持
最终你得到的结果会是包含多达75,500个节点的多跳查询结果,而且每个结果都会附带一条可供引用的路径。
有三点需要注意:首先,在刚开始使用这个系统时,并不需要所有这些组件,因为只需使用Docker、驱动程序以及少量的节点就可以构建一个可运行的系统;其次,这里提到的各种数值都是基于Neo4j 5.26.29 Community版本上的75,500个节点数据集进行测试得出的,并非估算值;最后,图中的箭头只表示数据传输的方向,因为本手册中并没有提到任何将模型结果反馈回数据结构中的操作,这个规则在你对系统的可靠性有足够信心之前是必须遵守的。
关于安装版本的选择:不必担心你的系统版本与我的示例完全一致。所有测试都是基于Neo4j 5.26.29 Community版本进行的,而5.26版本属于长期支持版本,Neo4j会一直为其提供技术支持,直到2028年6月。从2025年开始,Neo4j的版本编号开始采用日期格式来命名,因此你会看到像2025.01、2025.02这样的版本号,而不是5.27这样的版本号。这些新版本的Neo4j与本手册中使用的Cypher语法及驱动程序都是完全兼容的,因此使用这些新版本时,手册中的查询语句仍然可以正常运行。
有两点需要注意的地方:首先,各种测试结果所依赖的时间参数会因你的机器配置不同而有所差异,所以请将我的测试数据视为参考数值而非固定目标;其次,除了IS UNIQUE这种约束条件之外,其他一些高级功能需要使用Neo4j的企业版才能实现,这是版本之间的差异,而不是简单的数字差异。下面使用的neo4j:5 Docker标签会确保你安装的是最新的5.x系列版本,这是一个比较合适的默认选择。
在第一天,你并不需要使用所有这些内容。Docker、驱动程序以及少数几个节点就已经可以构成一个可运行的系统了。这本手册中提到的其他内容,都是在你对图结构的理解还不够深入时才需要添加的。
图数据库实际上存储什么
图数据库只存储三样东西,确实就是这三样而已。
这个图示通过一个具体的例子来说明这一概念。一个工程师节点包含name: "Ada"这一名称以及她的电子邮件地址;一条标有OWNS标签的箭头,表示“Ada从2026-03-01开始拥有这个服务”;而一个服务节点则包含name: "payments"这一名称。图中的标注分别说明了各个部分的含义:哪些是节点、哪些是标签、哪些是属性,以及哪些是关系。其中,最后被标注的部分实际上是一种存在于关系之中而非连接两端的属性。
下方的解释进一步对比了这种图结构与传统的关系型数据库的区别——在图数据库中,确实存在一些没有对应的关系型数据库结构的元素。例如,要记录Ada从3月份开始拥有这个服务这一事实,关系型数据库就需要通过额外的关联表来处理这种数据关系,因为关系型数据库中的行无法直接相互引用。
节点代表你所在领域中的各种实体,比如工程师、服务、事件或团队等。
关系用于连接 exactly 两个节点。每种关系都具有方向性和类型。例如,“工程师拥有服务”这一关系表示工程师是服务的所有者;而“事件影响服务”这一关系则表示事件会影响到某个服务。关系的方向性会被被记录下来,不过稍后你会看到,无论关系是如何被存储的,你都可以从任意一个方向来遍历它。
属性是由键值对组成的信息。它们既可以存在于节点上,也可以存在于关系中。例如,一个工程师节点可能会包含名称和电子邮件地址这些属性;而“拥有关系”这一关系则可能会包含所有者开始拥有该服务的具体日期,这种信息属于关系的属性,而非连接两端的节点的属性。
节点还可以被赋予标签,这些标签用于对节点进行分类。一个标有工程师标签的节点就表示它代表的是一名工程师。一个节点可以拥有多个标签,通过标签,你可以告诉数据库只关注某些类型的节点,而无需扫描所有存储的数据。
下面是同一条信息在关系型数据库和图数据库中的表现形式。
| 概念 | 关系型数据库 | 图数据库 |
|---|---|---|
| 一个实体 | 表格中的一行 | 一个节点 |
| 实体的类型 | 它属于哪个表格 | 节点上的标签 |
| 关于该实体的信息 | 列的值 | 属性 |
| 实体之间的连接关系 | 外键或关联表 | 存储在磁盘上的关系 |
| 关于连接关系的信息 | 关联表中的列 | 关系中的属性 |
最后那一行确实值得我们仔细思考。在关系型数据库中,要表达“Ada从3月份起就一直负责处理相关付款事务”,就需要在连接表中添加相应的列;而这种连接表的设计其实是一种为了解决“数据行之间无法直接相互引用”这一限制而发明的实现细节。而在图结构中,这种关联信息直接存储在关系本身上,这才是它应该被放置的地方。
无索引关联机制:让查询速度大幅提升的关键原理
这一理论是真正值得我们深入理解的部分,因为其他所有概念都是由此推导出来的。
在关系型数据库中,两条数据行之间的关联实际上就是在查询时需要匹配的数值。例如,《orders》表中的customer_id字段,在进行连接操作时,数据库会查找对应的匹配值。这种处理方式效率很高,因为背后有索引机制、查询规划器以及数十年的优化经验支撑;但从本质上看,这仍然属于一种搜索操作。
而在图数据库中,两条数据行之间的关联则是存储在磁盘上、能够直接指向这两个节点的引用信息。当数据库从一个节点访问其相邻节点时,它并不需要进行搜索操作,而是直接沿着这些引用信息进行访问。
这种机制被称为无索引关联机制。
从物理存储的角度来看,这种关联信息在关系型数据库中表现为一个数值,即数据库需要查找的外键;而在图数据库中,则表现为节点旁边的引用指针,因此沿着这些指针进行访问实际上属于读取操作,而非搜索操作。
这才是真正重要的地方:由于遍历过程是沿着已经存在的节点引用信息进行的,因此遍历的成本与所涉及的图结构部分的大小成正比,而与整个图结构的规模无关。即使数据库的规模扩大了十倍,两次跳转的查询速度也不会变慢。
相比之下,连接操作每次都会读取另一个表,并生成新的中间结果;因此,每次增加一个连接操作,所需的处理工作量都会随着数据量的增加而呈线性增长。
同一坐标轴上的两条曲线:一条曲线表示每次查询所消耗的成本,另一条曲线表示数据库中存储的数据量。当数据量增加时,四次连接操作的成本会急剧上升;而两次跳转的遍历操作成本则基本保持稳定。在数据量较少的情况下,这两条曲线的走势几乎完全重合——这正是这个图例想要表达的意思:在测试环境中使用笔记本电脑进行测试时,这两种方式的表现都很好;但正因如此,在实际生产环境中使用时,人们才会感到惊讶。
关于这条图表还需要注意一点:坐标轴上没有标注单位,因为这些数据并没有经过测量,也没有任何基准测试结果作为参考。关键在于观察这两条曲线的走势,因为它们的形状恰恰反映了两种数据处理方式的不同工作原理。
这就是为什么当你的数据量增加时,这种差异才会显现出来,而不是在只使用测试数据的笔记本电脑上。对于十万条记录来说,这两种方法看起来都没什么问题。
关系型数据库非常适合用来回答与行集合相关的问题;而图数据库则非常适合用来解决与事物之间的关联路径有关的问题。大多数系统都会遇到这两种类型的问题,因此大多数公司最终都会同时使用这两种类型的数据库。
当图数据库不是最佳选择时
互联网上所有的图数据库教程都会告诉你图数据库非常有用。但了解何时不应该使用某种技术,才是真正区分工程师和业余爱好者的重要因素。
当你需要对大规模、结构均匀的数据集进行聚合操作时,应该选择其他类型的数据存储方案。“按地区、按月统计的总收入”这类问题属于关系型数据库或列式数据库的处理范围。虽然图数据库也可以处理这类问题,但其运行速度会更快慢,效率也会更低。
当你的数据之间不存在任何有意义的关系时,也不应该使用图数据库。仅仅是一份日志记录列表,将其转化为图结构并不会带来任何实际价值,反而会增加存储空间消耗。
当你特别需要某个系统具备极高的响应速度时,图数据库也不是最佳选择。例如,一个用于查询“会话4471的相关信息”的键值存储系统,由于其专门为这一目标而设计,其性能肯定会远超其他类型的数据库。
当数据的关联关系才是分析的重点时,图数据库才是正确的选择。比如打击诈骗团伙、提供推荐建议、实现访问控制、进行依赖关系分析、追踪数据来源、构建组织结构图、管理供应链,以及为人工智能系统创建知识图谱等等。这些应用都有一个共同点:人们感兴趣的问题都是关于事物之间的关联方式,而且这些关联路径的长度往往是不可预知的。
如果你的查询操作涉及的关联步骤不超过一步,那么你很可能不需要使用图数据库;但如果查询需要经过多步关联操作,并且这些步骤的数量会随着数据内容的变化而改变,那么图数据库绝对是你需要的选择。
如何安装Neo4j及Python驱动程序
对于这个项目来说,你需要一台数据库服务器以及相应的驱动程序。
选项A:Neo4j Aura,无需安装
最快的解决方案就是使用Neo4j Aura——这是Neo4j提供的托管型云服务。完全不需要进行任何安装操作,而且还提供真正的免费试用版本。
请访问console.neo4j.io登录账号,然后选择创建实例。系统会同时展示多种套餐选项,下面这个页面的内容需要仔细阅读,而不要直接点击下一步:
| 套餐类型 | 费用 | >包含的功能 |
|---|---|---|
| 免费版 | 0美元 | 最多支持20万个节点和40万条关联关系。内存和虚拟CPU资源有限,备份功能也受限。如果30天内没有使用,系统会自动删除该账户。 |
| 专业版 | 从每GB小时0.09美元起 | 提供监控功能、预定义的角色设置、7天备份机制以及图算法支持 |
| 企业关键业务版 | 从每GB小时0.20美元起 | 高级监控功能、自定义角色设置、IP地址过滤、单点登录服务、30天备份机制,同时提供99.95%的正常运行时间保障 |
这本手册完全可以免费使用。200,000个节点的数量远远超过了这里所需的任何数量。
请查看页面底部的累计数值。控制台会实时显示每小时的费用以及预计的月度费用,而且这些数值会在您更改套餐等级时自动更新。
付费套餐对应的费用大约为每小时0.36美元。如果一直保持该套餐处于激活状态,那么每月的总费用约为259美元。在查看实例名称时,很容易就会忽略这个费用数字。如果您只是想学习如何使用这款工具,那么页面底部显示的数值应该会是0美元。
一旦您确认了相关设置,Aura只会一次性向您展示一次认证信息对话框:
用户名,始终为
neo4j系统会自动生成一段较长的密码
还会有一条警告提示,内容为“请注意,此密码一旦设置后将无法再次更改”
这条警告是确实有效的。点击下载并继续按钮,就可以保存一个包含连接详细信息的.txt文件;或者先把密码复制到安全的地方保存起来。如果丢失了密码,就无法恢复,只能重新设置。
下载后的文件内容如下:
NEO4J_URI=neo4j+s://xxxxxxxx.databases.neo4j.io
NEO4J_USERNAME=neo4j
NEO4J_PASSWORD=<您生成的密码>
NEO4J_DATABASE=neo4j
AURA_INSTANCEID=xxxxxxxx
AURA_INSTANCENAME=demo
随后,控制台会显示“正在创建中……”这样的提示,这个过程需要几分钟时间。在这段时间内,主机名已经可以在DNS系统中被解析出来,端口7687也已经可以接收TCP连接了,但是背后的数据库还没有启动,因此驱动程序会出现“无法获取路由信息”的错误。在最初的几分钟内出现这种错误,并不意味着系统配置有误,而是表示系统尚未准备好使用。请耐心等待片刻后再尝试连接,而不是去修改连接字符串。
neo4j+s://这个连接字符串中的+s表示连接是加密的,而且服务器的证书也会经过验证。Aura要求必须使用加密连接,而这种证书验证机制,正是本手册中需要重点关注的地方——这也是它与本地实例之间的唯一区别。
如果Aura拒绝连接,而您确定它已经正常运行
虽然Aura本身运行正常,浏览器也能成功建立连接,但Python程序却无法正常连接。通常是由于网络中的某些设备,比如企业代理服务器、VPN软件或杀毒软件,截断了您的TLS连接,然后用自己的证书对数据进行了重新加密。您的浏览器被设置为信任这些加密证书,但驱动程序并不认可这些证书,因此会拒绝连接,并显示“服务不可用:无法获取路由信息”的错误消息,而实际上数据库本身是没有问题的。
这种错误很容易让人浪费大量时间,因为错误提示指出的问题其实并不符合实际情况。
当您尝试连接时,驱动程序会显示如下错误信息:
neo4j.exceptions.ServiceUnavailable: 无法获取路由信息“路由问题”听起来像是一个与网络配置相关的问题,因此人们会去检查相关实例、重新创建它们,或者尝试使用不同的地区进行连接。但通常这些方法都无法找到问题的真正原因。
可以直接检查证书的情况:
import socket, ssl
ctx = ssl.create_default_context()
with socket.create_connection(("xxxxxxxx.databases.neo4j.io", 7687), timeout=15) as raw:
with ctx.wrap_socket(raw, server_hostname="xxxxxxxx.databasesNeo4j.io") as s:
print("TLS连接正常,版本为:", s.version())
如果程序输出类似“CERTIFICATE_VERIFY_FAILED: 证书链中存在自签名证书”这样的信息,那么说明数据库本身没有问题。是你的网络环境中的某些因素在拦截TLS通信。一些企业级代理服务器、VPN软件以及防病毒程序都会这样做:它们会终止你的加密连接,对其进行检查,然后用自己的证书重新加密数据。你的浏览器之所以会信任这些证书,是因为安装在这些浏览器上的相关软件已经将这些证书添加到了系统的信任列表中;而Python则不同,因为它自带了自己的信任列表。
你有三种解决办法,按照优先级排序如下:
1. 将拦截者使用的根证书添加到Python的信任列表中。这是正确的解决方法,能够确保验证过程正常进行:export SSL_CERT_FILE=/path/to/corporate-root.pem
2>使用一个不会被拦截的网络环境,比如移动热点网络。这是最快确定问题根源的方法。
3>改用neo4j+ssc://这种连接方式。这种方式虽然会进行加密通信,但也会接受自签名证书:
driver = GraphDatabase.driver("neo4j+ssc://xxxxxxxx.databases.neo4j.io", auth=AUTH)
ssc代表“自签名证书”。虽然你的数据仍然会被加密,但驱动程序不会再检查对方是谁,因此任何已经在拦截通信的人都可以继续这种行为而不会被发现。在学习期间可以使用这种方法暂时解决连接问题,但在正式生产环境中绝对不要使用它。
本手册中提到的所有Aura查询示例,都是在一个实际存在TLS监控功能的网络环境中进行测试的。
选项B:使用Docker,只需一条命令
如果你希望将所有相关软件都安装在自己的机器上,那么使用Docker是最简单的方法。本手册中的所有内容都是针对这种容器环境进行编写和测试的。
docker run -d --name neo4j-graphbook \
-p 7474:7474 -p 7687:7687 \
-v neo4jdata:/data \
neo4j:5
端口7474用于连接Neo4j浏览器,你稍后就会使用这个界面来执行查询操作;端口7687则用于传输Bolt协议数据,这是Python驱动程序所使用的通信协议。
请在数据库首次启动之前为存储目录设置初始密码,因为一旦数据库创建成功,这个设置就会被忽略:
docker volume create neo4jdata
docker run --rm -v neo4jdata:/data neo4j:5 \
neo4j-admin dbms set-initial-password yourpassword
然后打开 http://localhost:7474,使用 neo4j 和相应的密码进行登录。
这里有两个面板。
你的理解是:你的脚本会连接
bolt://localhost:7687,从而与运行着neo4j:5的 Docker 容器进行通信并传递数据。但实际上发生的情况是:原生 Neo4j 应用程序(通常是 Neo4j Desktop)已经在
127.0.0.1:7687端口上监听请求,因此它覆盖了 Docker 的端口映射设置,导致你的容器根本无法被访问。你的脚本会尝试与那个原生数据库进行身份验证,但最终会收到认证失败的消息,而这条消息中并没有提到任何端口号。
你可以使用命令 lsof -nP -iTCP:7687 -sTCP:LISTEN 来查看哪个进程占用了这个端口。如果发现是其他程序占用的,可以使用命令 docker run -p 7475:7474 -p 7688:7687 neo4j:5 重新配置容器的端口映射,然后改为使用 7688 端口进行连接。
需要注意的一个问题:如果你已经安装了 Neo4j Desktop 或其他 Neo4j 应用程序,那么它很可能已经在 7687 端口上监听请求。原生应用程序对端口的占用具有优先权,因此即使你配置了 Docker 的端口映射,也会出现容器无法正常启动的情况。此时,浏览器可以正常加载页面,但驱动程序会报告认证失败,因为实际上是在与另一个数据库进行通信。
如果遇到这种情况,可以使用命令 -p 7475:7474 -p 7688:7687 重新配置容器的端口映射,然后让驱动程序使用 bolt://localhost:7688 进行连接。同时,也可以使用命令 lsof -nP -iTCP:7687 -sTCP:LISTEN 来确认到底是哪个进程占用了这个端口。
选项 C:由你控制的云服务器
还有第三种方案值得了解一下,因为这种配置方式更符合团队实际使用需求,同时也能让你了解到前两种方案所隐藏的一些问题。你可以将 Neo4j 安装在云端的 Linux 服务器上。
下面描述的所有步骤都是我实际操作时用来生成这本手册中的截图的。虽然这里使用了 AWS 作为服务提供商,但任何类似的云服务平台都可以采用这些方法。
步骤 1:确认你要使用哪个账户进行支付。
这个步骤听起来很简单,但实际上很多人都会忽略它。
aws sts get-caller-identity
aws configure get region
第一个命令会显示账户编号和用户名,第二个命令会显示所使用的区域。如果这些信息与你的预期不符,请先修改配置信息后再继续操作。
步骤 2:查找当前可用的 Linux 镜像。
不要直接使用博客文章中提供的镜像 ID,而是应该通过 AWS 来获取最新的镜像信息:
aws ssm get-parameters \
--names /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64 \
--query 'Parameters[0].Value' --output text
AMI是一种机器镜像,也就是服务器启动时所使用的模板。不同地区的镜像ID各不相同,而且会随时间发生变化,因此需要先查询获取正确的ID,而不能直接复制它。
步骤3:创建一个仅允许特定连接进入的防火墙。
这一步骤最为关键,也正是很多人因此导致系统被入侵的原因所在。
MYIP=$(curl -s https://checkip.amazonaws.com)/32
SG=$(aws ec2 create-security-group \
--group-name neo4j-demo-sg \
--description "Neo4j演示用防火墙,仅允许我的IP地址连接" \
--vpc-id \
--query GroupId --output text)
for port in 22 7474 7687; do
aws ec2 authorize-security-group-ingress \
--group-id $SG --protocol tcp --port $port --cidr $MYIP
done
安全组其实就是连接在服务器上的防火墙。端口22用于SSH连接,端口7474用于Neo4j浏览器,端口7687用于Bolt连接。--cidr $MYIP这一设置的作用是限制所有这些连接都只能指向你的自有IP地址。
千万不要将这个值替换为0.0.0.0/0,因为那样意味着“允许整个互联网上的任何设备进行连接”。如果数据库的默认端口没有设置防护措施,自动化扫描工具会在几小时内就发现这些漏洞,而不会等待数周。一旦Neo4j被暴露在网络上,攻击者就可以直接访问你的数据了。
步骤4:自动启动服务器并安装Neo4j。
用户数据脚本是一种shell脚本,服务器在首次启动时会以root权限运行这个脚本。
#!/bin/bash
dnf install -y docker
systemctl enable --now docker
# 询问实例自身的公共IP地址
TOKEN=$(curl -sX PUT "http://169.254.169.254/latest/api/token" \
-H "X-aws-ec2-metadata-token-ttl-seconds: 300")
PUBIP=$(curl -s -H "X-aws-ec2-metadata-token: $TOKEN" \
http://169.254.169.254/latest/meta-data/public-ipv4)
docker run -d --name neo4j --restart unless-stopped \
-p 7474:7474 -p 7687:7687 \
-e NEO4J_AUTH=neo4j/ChangeThisPassword \
-e NEO4J_server_default__listen__address=0.0.0.0 \
-e NEO4J_server_bolt_advertised__address=$PUBIP:7687 \
-e NEO4J_server_http_advertised__address=$PUBIP:7474 \
neo4j:5
其中有三项设置是决定这一部分内容是否必要的关键因素。
169.254.169.254是AWS提供的实例元数据服务地址,任何AWS服务器都可以通过这个地址来获取自身的相关信息。在这里,脚本正在查询该实例的公共IP地址。
NEO4J_server_default__listen__address=0.0.0.0这一设置告诉Neo4j接受来自外部设备的连接。默认情况下,Neo4j只会监听本地主机地址,如果没有这个设置,服务器虽然能够正常运行,但会拒绝所有外部连接。
广告发布的地址设置其实非常重要。Neo4j浏览器是通过服务器提供的网页来访问Neo4j服务的,当它尝试建立Bolt连接时,会使用服务器所公布的地址。如果服务器公布的地址是localhost,那么在用户的笔记本电脑上运行的浏览器就会试图连接到本机的Neo4j服务。只有将广告发布的地址设置成公共IP地址,远程浏览器才能正常访问Neo4j服务。
server.default.listen_address 会变成 NEO4J_server_default__listen__address。步骤 5:启动它。
aws ec2 run-instances \
--image-id \
--instance-type t3.medium \
--key-name \
--security-group-ids $SG \
--associate-public-ip-address \
--user-data file://userdata.sh \
--tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=neo4j-demo}]'
t3.medium配置意味着该实例拥有 2 个 CPU 核心和 4GB 的内存,这对于学习来说已经足够了。Neo4j 默认会使用 1GB 内存运行,但这样的配置其实并不理想。
启动实例、安装软件包以及下载镜像文件整个过程大约需要 90 秒的时间。请耐心等待,直到浏览器显示出连接成功的提示,而不要自行猜测:
until curl -s -o /dev/null -w "%{http_code}" http://:7474 | grep -q 200; do
sleep 10
done
步骤 6:使用完毕后立即删除它。
如果忘记了这台服务器的存在,它会每小时自动产生费用,而且这种收费会持续下去。
aws ec2 terminate-instances --instance-ids
aws ec2 delete-security-group --group-id $SG
对于那些使用自己账户进行学习的人来说,这一点再强调也不为过:一定要设置好付费提醒,一旦学习完成就立即删除相关资源。用于编写这份手册的实例仅存在了不到 1 小时,花费也就几美分而已,这一切都因为我及时将其删除了。
驱动程序
pip install neo4j
这样就可以安装官方提供的驱动程序了。在撰写本文时,该驱动程序的版本为 6.x,支持 Python 3.10 及更高版本。
连接数据库
创建驱动程序对象的成本较高,但重复使用的话成本就会很低。因此应该在程序启动时创建一次驱动程序对象并一直保留它。如果每次请求都重新创建驱动程序对象,将会造成不必要的资源浪费,因为每次创建都会生成一个新的连接池。
from neo4j import GraphDatabase
URI = "neo4j+s://xxxxxxxx.databases.neo4j.io"
AUTH = ("neo4j", "your-password")
with GraphDatabase.driver(URI, auth=AUTH) as driver:
driver.verify_connectivity()
print("已连接成功")
有兩件事每次使用时都值得注意:
如果 URI 或密码有误,verify_connectivity() 方法会立即返回错误信息。如果不执行这个检查步骤,那么在执行查询时才会发现错误,而那时错误信息会显得不那么明显,也更难确定问题的根源。
使用 `with` 语句将驱动程序对象作为上下文管理器来使用,这样当代码块执行完毕时,驱动程序就会被自动关闭。对于那些需要长期运行的服务来说,可以在启动时创建驱动程序对象,在关闭服务时再将其销毁。
千万不要将用户名和密码直接写在源代码中,应该从环境变量中读取这些信息。
import os
from neo4j import GraphDatabase
driver = GraphDatabase.driver(
os.environ["NEO4J_URI"],
auth=(os.environ["NEO4J_USER"], os.environ["NEO4J_PASSWORD]),
)
最重要的建模决策
在编写任何数据之前,你必须先决定哪些内容应该被定义为节点,哪些应该被定义为属性,以及哪些应该被定义为关系。
这个决策决定了六个月后你的图结构是会带来便利还是问题。同时,这也是任何查询优化器都无法事后为你解决的环节。
以下是一些规则:
如果你会对某个内容提出疑问,那么就将其定义为节点。**例如,如果你想知道哪些工程师负责支付服务的相关工作,那么“支付服务”就是一个节点;如果你想按严重程度来统计事件的数量,“严重程度”也应该被定义为节点。 如果某个内容只是用来描述其他事物,那么就将其定义为属性。**比如事件发生的时间戳就是一个属性——没有人会要求数据库去查找所有在14:32发生的事件,然后再从这一时间点开始进行进一步的查询。 如果某个关系连接了两个节点,并且你希望沿着这个关系进行查询或分析,那么就将其定义为关系。**例如,“工程师与服务之间的所属关系”就是一个关系,因为我们需要通过这种关系来了解某个工程师负责哪些服务。一个有用的判断标准是:**你能想象用箭头来表示这个关系吗?**如果可以,那么它很可能是一个节点;而时间戳这类信息显然不适合用箭头来表示。
// 按照存储的方向进行查询
MATCH (e:Engineer)-[:OWNS]->(s:Service) RETURN e, s
// 逆向查询:从服务开始查找对应的工程师
MATCH (s:Service)<-[:OWNS]-(e:Engineer) RETURN s, e
// 完全忽略方向进行查询
MATCH (e:Engineer)-[:OWNS]-(s:Service) RETURN e, s
以上三种查询方式都会返回相同的结果。在存储数据时,应该选择那种用英语来说比较自然、符合逻辑的方向来进行存储,之后就无需再为此操心了。
以上三种查询方式都会得到相同的结果。在存储数据时,应该选择那种用英语来说比较自然、符合逻辑的方向来进行存储,之后就无需再为此操心了。
// 按照存储的方向进行查询
MATCH (e:Engineer)-[:OWNS]->(s:Service) RETURN e, s
// 逆向查询:从服务开始查找对应的工程师
MATCH (s:Service)<-[:OWNS]-(e:Engineer) RETURN s, e
// 完全忽略方向进行查询
MATCH (e:Engineer)-[:OWNS]-(s:Service) RETURN e, s
这三种查询方式都会返回相同的结果。在存储数据时,应该选择那种用英语来说比较自然、符合逻辑的方向来进行存储,之后就无需再为此操心了。
第三种方法是用于调试的。如果某个查询没有返回任何结果,而你原本期望它能返回一些行,那么就可以尝试调整箭头的方向。如果查询确实返回了行,那么方向问题就是导致错误的原因;如果没有返回行,那就意味着你在十秒钟内就排除了最有可能的问题所在。在编写如下代码时,方向确实非常重要:MERGE (a)-[:OWNS]->(b),因为反向操作会生成两种完全不同的意思表达,而其中只有一种是正确的。
关系中的属性
这个功能常常被人们忽略,但它往往能提供最简洁、最准确的解决方案。
同一个事实,却可以用两种不同的方式来存储。在表格中,since这个属性是通过一个名为ownership的关联表来存储的,而这个表并不属于你的数据模型范围,它的存在仅仅是因为数据库中的行无法直接相互引用而已。而在图中,since这个属性是直接存储在连接关系上的,你可以直接对其进行查询:例如MATCH (e:Engineer)-[r:OWNS]->(s:Service) WHERE r.since < date() - duration('P1Y'),这条查询就能找出那些拥有某项资源超过一年的人。
MERGE (e:Engineer {email: 'ada@example.com'})-[r:OWNS]->(s:Service {name: 'payments'})
SET r.since = date('2026-03-01'), r.primary = true
现在,你无需再创建额外的关联表来存储这些信息,就可以直接查询出那些拥有某项服务超过一年的人。
几乎每个人都会犯的三种建模错误
我发现,这三种错误出现的频率比其他任何错误都要高,但一旦你了解了这些错误的成因,它们其实都很容易避免。
几乎每一个初学图模型设计的人都会犯这个错误:因为他们认为将连接关系存储为属性会更简单。但实际上,这样做会导致这些连接关系无法被遍历,也无法承载任何额外的信息,最终只会变成简单的字符串匹配操作。
错误#1:将连接关系存储为属性
你可能会给每个工程师设置一个名为team的属性,用来存储字符串"platform"。
这种做法在最初使用的时候确实没什么问题,但当你想要了解“平台团队”还拥有哪些其他资源时,就会遇到麻烦了。因为这时你实际上是在匹配分布在成千上万个节点中的字符串。更糟糕的是,如果有人将"Platform"这个字符串的首字母大写,那么系统就会默默地创建一个第二个“团队”,而且根本不会产生任何错误提示。
正确的解决办法是将“团队”定义为一个个独立的节点,然后将工程师与这些节点建立连接关系。这样一来,这两个问题就能立刻得到解决,同时你还可以为“团队”添加经理和预算等相关信息。
这种错误的普遍表现形式是:任何你需要进行遍历的操作对象都必须是一个关系。如果将包含标识符列表的属性存储起来,那么图数据库就相当于在假装自己是一个电子表格而已。
错误#2:对所有关系都使用同一种通用类型
你创建了一个RELATED_TO关系,并为其添加了type属性来说明这种关系的具体类型。
这看起来很灵活,但实际上恰恰相反。Neo4j在开始进行任何操作之前,会先根据关系类型来缩小搜索范围,因此-[:OWNS]->这样的查询速度非常快。而如果通过属性来进行过滤,那就意味着首先需要遍历所有的RELATED_TO关系,然后再排除其中大部分关系,这种行为其实就是你原本试图通过使用图结构来避免的。
应该根据关系的实际含义来为它们命名:比如OWNS、AFFECTS、MEMBER_OF或DEPENDS_ON。使用具体的类型不仅能让查询速度更快,还能让关系本身的意义更加清晰明了。
错误#3:将所有内容都视为节点
这种做法其实是一种过度矫正,而且它本身也会带来问题。
如果某个值只与一个节点相关联,并且你也不会单独去查询这个值,那么它就应该被视作一个属性。如果为每一个时间戳都创建一个节点,那么就会导致图的结构变得过于庞大,查询速度也会变慢,而且这样做根本没有任何实际意义。
判断的标准依然是一样的:你会针对某个值提出问题吗?还是会将其用于连接其他数据?如果不会,那么它就应该被视作一个属性。
根据问题来反向构建模型
这里有一种方法可以帮你避免重新编写代码。
在开始建模之前,先不要考虑整个领域的结构,而是先把图结构需要回答的问题用通俗的语言写下来。
以我们的例子来说:
这次事件影响了哪些服务?
这些服务由谁拥有?
这些服务的所有者属于哪些团队?
哪些服务依赖于那个出现故障的服务?
上个月谁负责这个服务的值班工作?
现在检查一下你的模型是否与这些问题相符。每一个问题都应该能够通过图结构中的路径来直接体现出来。如果某个问题需要跨两个属性进行连接,或者需要扫描某个标签下的所有节点,那么说明当前的模型就不符合这个问题的要求。
第五个例子很好地说明了这一点。“上个月负责值班的人”这一信息其实反映了一个人与某项服务在一段时间内的关联关系,这种关系本身就包含多个属性。如果你将“值班情况”定义为工程师的一个布尔型属性,那么在加载数据之后才会发现这个问题,而不是在建模阶段就发现问题。
关系型建模通常建议先进行规范化处理,然后再进行查询;而图结构建模则更适合反向思考问题来进行建模。
三种值得尽早了解的建模模式
一旦掌握了基础知识,那么有三种建模模式可以帮你应对大多数实际数据中的问题。
当关系需要超过两个端点时
通常情况下,一个关系只会连接两个节点;但有时候,某个事实可能需要连接三个或更多的节点。
"Ada在3月份负责处理支付相关事务"这一情况涉及到一个人、一项服务以及一个特定的时间区间。如果你试图将这一信息仅通过单一的关系结构来表达,那么必然会丢失一些关键信息。
正确的处理方式是将这一事实表示为一个节点:
MERGE (e:Engineer {email: 'ada@example.com'})
MERGE (s:Service {name: 'payments'})
CREATE (r:OnCallRotation {start: date('2026-03-01'), end: date('2026-03-31')})
MERGE (e)-[:SERVED]->(r)
MERGE (r)-[:FOR_SERVICE]->(s)
"Ada在3月份负责处理支付相关事务"这一情况涉及三方参与者,而任何关系结构都包含两端。如果强制将这一信息仅通过一个ON_CALL关系来表达,那么到了4月份这种结构就会崩溃,因为第二次轮换需要在同一节点对之间建立另一个关系,但此时已经没有合适的路径可供使用了。将这一事实表示为一个节点后,它就可以包含三个关系,这样其他信息就可以附加到这个节点上了。其实,我们的目的就是想为某种关系添加一个属性,以便用来描述除了那两个特定节点之外的其他信息。
OnCallRotation有时被称为中间节点、具体化的关系结构或超边。这个名字并不重要,关键在于:当涉及到三方参与者的信息时,将其表示为一个节点可以使后续再添加更多相关信息成为可能,比如在某个过程中谁替换了另一个人来负责这项工作。
当你发现自己需要为某种关系添加一个属性,以便用来描述除了那两个特定节点之外的其他信息时,那就说明你需要使用这种结构。
当事实随时间发生变化时的版本控制
图结构很容易进行原地更新,因此人们往往会想要直接覆盖原有的数据。但如果历史记录很重要,那么就应该避免这样做。
当所有权的持有者发生变化时,将关系指向新的所有者会抹去之前其他人曾拥有过这一关系的记录。另一种处理方法是为旧的关系设置一个结束日期,然后建立一个新的关系,这样历史记录就能被保留下来。如果你不采取任何措施,最终就会发生数据被覆盖的情况。
通常的做法是保留原有的关系结构,并将其标记为已关闭状态,而不是直接删除它:
// 将旧的所有权关系标记为已关闭状态,而不是直接删除它
MATCH (e:Engineer {email: $old})-[r:OWNS]->(s:Service {name: $service})
WHERE r.until IS NULL
SET r_until = date()
// 建立新的所有权关系
MATCH (e:Engineer {email: $new}), (s:Service {name: $service})
MERGE (e)-[r2:OWNS]->(s)
ON CREATE SET r2.since = date()
这样一来,当前的所有权信息就会通过WHERE r.until IS NULL这一条件来表示,而当有人询问去年谁负责这项工作时,历史记录仍然可以被查询到。不过这样做的一个缺点是,任何与“当前”状态相关的查询都需要使用这个过滤条件,因此必须谨慎决策,而不能随意操作。
层次结构与哪些图表特别适用
在SQL中,树结构会带来很多麻烦,但在这里却非常简单。组织结构、分类树、文件夹结构以及依赖关系链,它们的形态都是相同的。
// 找出给定经理下属的所有成员,无论处于哪一层
MATCH path = (m:Engineer {email: $email})<-[:REPORTS_TO*1..10]-(report:Engineer)
RETURN report.name AS name, length(path) AS depth
ORDER BY depth, name
使用path =来指定路径,这样就可以调用length()函数了,该函数会返回所经过的关系数量,从而说明这个人在树结构中的位置。
正是这种查询方式让人们改变了原有的处理方式。在SQL中,这种操作需要通过递归的公共表表达式来实现,大多数工程师每次使用都需要查阅相关资料;而在这里,只需要一行代码即可完成相同的功能,而且改变参数的值就能得到不同的结果。
从Python中加载数据
现代的驱动程序提供了一种执行查询的方法:execute_query。它能够自动管理会话并重试操作,因此这是最合适的默认选择。
先从一名工程师和一项服务开始操作吧。
driver.execute_query(
"""
MERGE (e:Engineer {email: $email})
SET e.name = $name
MERGE (s:Service {name: $service})
MERGE (e)-[:OWNS]->(s)
""",
email="ada@example.com",
name="Ada",
service="payments",
database_="neo4j",
)
这段代码中有三点值得注意。
使用MERGE而不是CREATE
CREATE操作总会创建一个新的节点。如果多次运行加载脚本,就会生成两个完全相同的工程师和两项服务,从而导致数据混乱。
MERGE会先查找与指定模式匹配的节点,只有在没有找到匹配项时才会创建新节点。这样,即使脚本在第一次执行过程中出现错误,也可以再次安全地运行它。
一个简单的经验法则是:当你确定某条数据是新的时候使用CREATE,而当你从可能已经包含现有数据的源中加载数据时,则使用MERGE。
先根据标识属性进行合并,然后再设置其他字段的值
请仔细观察这些属性是如何被处理的。
MERGE (e:Engineer {email: $email})
SET e.name = $name
MERGE>操作是针对email这个属性进行的,而名称则是通过SET命令之后才被设置的。
如果同时根据email和name两个属性进行合并,那么当有人更改自己的姓名时,系统就会创建一个新节点,而不是更新原有的节点。这样最终就会出现两个名为“Ada”的记录,但它们却属于不同的对象,而且系统中也不会有任何错误提示。
应该先根据能够唯一标识节点的属性进行合并,然后再设置其他字段的值。

有两个脚本,它们都能正常运行且都会显示“成功”结果。左边的脚本会根据电子邮件和姓名进行合并;右边的脚本则先仅根据电子邮件进行合并,之后再设置姓名。
将这两个脚本运行一次后,它们的输出看起来是完全相同的。然后Ada结婚了,她的姓名变成了Ada Okonjo,但电子邮件地址保持不变。在左边的脚本中,由于姓名发生了变化,匹配规则不再适用,因此MERGE操作会创建一个新的节点;此时Ada的相关信息被分摊到了两个新的节点上,任何关于她的查询都会返回部分正确的信息。
而在右边的脚本中,由于电子邮件地址没有改变,MERGE操作会找到原有的节点,并将姓名字段的值进行更新,因此Ada的相关关系仍然保持在与原来相同的节点上。
原则是:只根据能够唯一标识节点的属性来进行合并操作,而那些仅仅用于描述节点的属性则不需要进行合并;如果某个值的改变并不会影响节点本身的本质特征,那么这个值就不应该被作为键来使用。通过两次加载数据并检查节点数量是否相同,就可以发现这类错误,而这样做只需要三行代码而已。
MERGE (e:Engineer {email: $email})
ON CREATE SET e.name = $name, e.created = datetime()
ON MATCH SET e.name = $name, e.last_seen = datetime()
参数:永远不要使用字符串格式化
这些值应该分别以`$email`和 `$name`的形式传递;千万不要通过连接字符串来构建查询语句。
这样做可以有效地防止注入攻击,这也是最明显的理由;此外,从性能角度来看,这也非常重要:Neo4j会根据查询语句的内容来缓存查询计划。参数化的查询每次执行时其文本都是相同的,因此查询计划只会被编译一次之后被重复使用;而使用字符串格式化的方式来传递参数的话,每当参数值发生变化时,都会生成新的查询计划,这会导致查询计划缓存中充斥着无用的数据,并且需要不断重新编译查询计划。
利用UNWIND功能进行大规模数据加载
如果一次只加载一个节点,那么每个节点都需要完成一次网络请求才能完成数据加载;因此,如果要加载一万条记录的话,这种方式会非常慢,而且几乎所有时间都会被用来等待而不是实际进行数据加载操作。
更好的方法是直接传递一个列表,让Cypher在数据库内部循环处理这些数据。
rows = [
{"email": "ada@example.com", "name": "Ada", "service": "payments"},
{"email": "linus@example.com", "name": "Linus", "service": "checkout"},
{"email": "grace@example.com", "name": "Grace", "service": "payments"},
]
driver.execute_query(
"""
UNWIND $rows AS row
MERGE (e:Engineer {email: row.email})
SET e.name = row.name
MERGE (s:Service {name: row.service})
MERGE (e)-[:OWNS]->(s)
""",
rows=rows,
database_="neo4j",
)
UNWIND功能会将一个列表转换成多行数据,因此它后面的所有操作都会针对列表中的每个元素分别执行一次,而且整个过程都在同一个事务中完成,也只需要进行一次网络请求。

导致批量数据加载速度变慢的原因并不在于写入操作本身,而在于每次写入之间的等待时间。如果每行使用一条SQL语句进行写入,那么就会为每一行产生一次网络请求往返;而使用`UNWIND`指令,则可以一次性将所有数据发送到数据库,从而减少内部处理环节带来的延迟。
这种优化效果非常显著。以向包含75,500条记录的数据集写入1,000行数据为例,如果采用每行一条SQL语句的方式,那么需要执行1,000次网络请求往返;而使用`UNWIND`指令后,只需要执行1次网络请求即可完成全部操作:
| 方法 | 网络请求次数 | 。耗时 |
|---|---|---|
| 每行一条SQL语句 | 1,000次 | 2,758毫秒 |
| 使用`UNWIND`指令 | 1次 | 64毫秒 |
由此可见,使用`UNWIND`指令的效率要高出43倍。而且,在与客户端位于同一台机器上的数据库上运行时,由于网络请求的成本几乎可以忽略不计,这种优化效果会更加明显。你可以亲自尝试一下,也会得到类似的结果:在同一台机器上进行测试时,使用`UNWIND`指令的耗时仅为66毫秒。
不过,这种效率差异会随着数据传输距离的增加而进一步扩大。我在另一座城市的一台托管实例上进行了同样的测试,结果发现使用`UNWIND`指令的耗时为91,722毫秒,而传统方法的耗时仅为150毫秒,两者之间的差距达到了613倍。其实,处理过程本身并没有发生变化,只是因为数据传输距离的增加,导致每次网络请求所消耗的时间大大增加罢了。原本需要1分半钟才能完成的任务,现在只需要7分之一秒即可完成。
这才是真正的关键所在:数据交互所带来的开销并不是固定不变的,而是取决于你的数据库位于多远的地方,以及你需要与它进行多少次交互。
对于大量的数据来说,采用批量处理的方式是最佳选择。将所有的修改操作集中在一次事务中,直到事务被提交之后才将这些更改应用到数据库中;如果一次事务中包含了上百万条修改记录,那么这种处理方式很可能会耗尽操作系统的内存资源。
def load_in_batches(driver, rows, batch_size=5000):
query = """
UNWIND $rows AS row
MERGE (e:Engineer {email: row.email})
SET e.name = row.name
MERGE (s:Service {name: row.service})
MERGE (e)-[:OWNS]->(s)
"""
for start in range(0, len(rows), batch_size):
batch = rows[start:start + batch_size]
driver.execute_query(query, rows=batch, database_="neo4j")
print(f"已加载了{start + len(batch)}条记录,共{len(rows)}条")
每次批量处理几千条记录是一个合理的起点。可以根据实际运行情况调整批量大小,而不是通过猜测来决定。
从CSV文件中导入数据
大多数实际数据最初都是以电子表格或导出文件的格式存在的。有两种方法可以将这些数据导入数据库,选择错误的方法往往会带来麻烦。
选项1:使用Python读取数据,然后通过`UNWIND`指令导入数据库
这通常是首选方法。因为你已经熟悉这种操作方式,而且它可以在任何环境下运行;同时,在数据导入的过程中还可以对数据进行必要的处理。
import csv
def load_csv(driver, path, batch_size=5000):
with open(path, newline="", encoding="utf-8") as f:
rows = list(csv.DictReader(f))
query = """
UNWIND $rows AS row
MERGE (e:Engineer {email: row.email})
SET e.name = row.name
MERGE (s:Service {name: row.service})
MERGE (e)-[:OWNS]->(s)
"""
for start in range(0, len(rows), batch_size):
driver.execute_query(query, rows=rows[start:start + batch_size], database_="neo4j")
csv.DictReader会为每一行生成一个以列标题作为键的字典,而这正是UNWIND所需要的数据结构。
需要提醒大家的一点是:CSV文件中的所有值都表现为字符串形式。数字列会被存储为"42"而不是42,日期列则会被存储为"2026-03-01"。如果直接以原始字符串的形式存储这些数据,后续在进行比较操作时很可能会出错,因为当两个值都是字符串时,"9" > "10"这个表达式的结果会是“true”。因此,在读取数据时应该立即将其转换为适当的类型:
for row in rows:
row["headcount"] = int(row["headcount"]) if row["headcount"] else None
选项#3:在数据库内部加载CSV文件
Cypher本身就可以直接读取文件。对于非常大的文件来说,这种方式会更快,因为数据根本不会经过Python进程进行处理。
LOAD CSV WITH HEADERS FROM 'file:///engineers.csv' AS row
CALL {
WITH row
MERGE (e:Engineer {email: row.email})
SET e.name = row.name
MERGE (s:Service {name: row.service})
MERGE (e)-[:OWNS]->(s)
} IN TRANSACTIONS OF 1000 ROWS
CALL { ... } IN TRANSACTIONS OF 1000 ROWS这一行代码非常重要。如果没有它,整个文件的导入操作就会被视为一笔事务来完成,而这样会导致内存消耗量急剧增加。
关于LOAD CSV命令,还有两个可能会让人感到意外的限制:
首先,文件必须位于数据库能够访问的位置,而不是用户自己可以访问的地方。file:///表示文件位于服务器上的导入目录中。在Docker环境中,可以通过-v $(pwd)/data:/var/lib/neo4j/import命令将文件夹挂载到容器内;而在Aura平台上,则完全不能使用本地文件,因此文件路径必须是一个公开可访问的https://地址。
CSV文件中的所有值都会以字符串的形式被读取进来,包括数字也不例外。因此,当进行比较操作时,"9" > "10"这样的表达式依然是“true”,从而导致过滤结果出现错误。所以,在将数据读入数据库之前,必须先将其转换为适当的类型。
其次,所有数据在Cypher中仍然被视为字符串。不过Cypher提供了相应的转换函数来处理这类问题:
LOAD CSV WITH HEADERS FROM 'https://example.com/services.csv' AS row
MERGE (s:Service {name: row.name})
SET s.headcount = toInteger(row.headcount),
s.launched = date(row.launched)
toInteger、toFloat、date和datetime这些都是你会经常使用的转换函数。toInteger在无法解析某个值时,会返回null,而不是抛出异常,这一设计非常实用;同时,如果某列数据中存在输入错误,使用这个函数后,那一列就会变成充满null的值。因此在数据导入完成后,一定要检查一下数据是否正确。
MATCH (s:Service) WHERE s.headcount IS NULL RETURN count(*) AS unparsed
更新与删除操作
仅仅进行数据加载是不够的。当数据发生变化时,用于修改数据的命令可能会带来一些问题。
修改属性值
SET用于添加或覆盖某个属性的值。REMOVE则会彻底删除该属性,这与将其设置为null是不同的。
MATCH (e:Engineer {email: $email})
SET e.name = $name, e.updated = datetime()
REMVE e.legacy_id
如果需要一次性修改多个属性,可以使用以下简写方式:
MATCH (e:Engineer {email: $email})
SET e += $props
+=会将映射中的属性值合并到节点中,而那些未在映射中出现的属性则保持不变。而普通的=操作会替换掉节点中的所有属性值,那些不在映射中的属性将会被无声地删除。这种区别可能会让人丢失重要的数据,因此值得仔细阅读。
删除操作
如果你尝试删除一个仍然与其他节点存在关联关系的节点,Neo4j会拒绝执行该操作,因为这样的操作会导致图结构变得混乱。
// 如果该工程师还拥有其他资源,此操作将会失败
MATCH (e:Engineer {email: $email}) DELETE e
DETACH DELETE会先删除节点与其他节点之间的关联关系,然后再删除该节点本身:
MATCH (e:Engineer {email: $email}) DETACH DELETE e
这种操作非常方便,但同时也存在风险——原因就在于它会在同一时间执行多个删除操作。因此,每次在使用MATCH命令时,都应该先使用RETURN来查看查询结果。
如果在实验过程中需要清除整个数据库中的数据,可以这样操作:
MATCH (n) DETACH DELETE n
对于只有几千个节点的数据库来说,这种做法是可以接受的;但对于拥有数百万个节点的数据库而言,这绝对是一个糟糕的选择,因为这样做会生成一个庞大的事务操作。如果需要大规模地清除数据,应该直接删除整个数据库,或者使用CALL { ... } IN TRANSACTIONS分批执行删除操作。
使用Neo4j的数据类型
Neo4j存储的不仅仅是字符串和数字,正确地使用各种数据类型可以避免以后需要从文本中手动解析日期等信息。
| 数据类型 | 示例 | 。备注 |
|---|---|---|
| String、Integer、Float、Boolean | 'payments', 42, 1.5, true |
符合预期 |
| List | ['a','b','c'] |
由基本数据类型组成的列表 |
| Date、DateTime、Time | date('2026-03-01'), datetime() |
真正的时间类型,可以相互比较和排序 |
| Duration | duration('P30D') |
表示持续时间,可以加到日期上 |
| Point | point({latitude: 51.5, longitude: -0.12}) |
空间类型,具有距离计算功能 |
一个属性不能同时包含映射或节点。如果你需要在某个属性内部使用嵌套结构,那么这种嵌套内容通常应该被视作一个节点。
时间类型是那些能够立即发挥作用的数据类型:
MATCH (e:Engineer)-[r:OWNS]->(s:Service)
WHERE r.since < date() - duration('P1Y')
RETURN e.name, s.name, duration.between(r.since, date()).years AS years
将日期直接作为日期进行比较,而不是将其视为字符串进行处理,这样就能有效避免很多错误。
在Python端,驱动程序会自动完成这些转换。`date`和`datetime`类型会被转换为`neo4j.time`对象;如果你需要使用Python原生的`datetime`类型,可以使用`.to_native()`方法:
records, _, _ = driver.execute_query(
"MATCH (e:Engineer)-[r:OWNS]->(s:Service) WHERE r.since IS NOT NULL RETURN r.since AS since",
database_="neo4j",
)
for r in records:
print(r["since"], "->", r["since"].to_native())
你的第一个Cypher查询
在某些方面,Cypher与SQL看起来很相似,但它们的核心思想是不同的。你需要绘制出你想要查找的数据结构,然后数据库会找出图中所有符合这种结构的部分。
在表达模式时,使用圆括号表示节点,用箭头表示关系:
(e:Engineer)-[:OWNS]->(s:Service)
读出来就是:一个工程师节点,一个指向另一个服务节点的“拥有”关系。这个表达式本身就是一个查询语句。
对于表达式`(e:Engineer)-[:OWNS]->(s:Service)`,有五个需要注意的地方:圆括号表示节点,`e`是一个可选变量,只有当你需要将其结果保留下来时才需要指定它的名称;`:Engineer`是一个标签,用于限定节点的类型;方括号和箭头表示关系及其存储的方向,`:OWNS`就是这种关系的类型名称;Neo4j会首先根据类型来过滤数据,因此使用特定类型的查询语句会提高查询效率。
读出来就是:“一个拥有服务的工程师”。而SQL语句则会描述如何重建这种连接关系,但Cypher直接指出了这种关系本身是什么。
查找数据
records, summary, keys = driver.execute_query(
"""
MATCH (e:Engineer)-[:OWNS]->(s:Service {name: $service})
RETURN e.name AS name, e.email AS email
ORDER BY name
""",
service="payments",
database_="neo4j",
)
for record in records:
print(record["name"], record["email"])
`execute_query`方法会返回三样结果:查询得到的记录数据、一个总结信息,以及被返回的键值对。
大多数情况下,你只需要记录数据,因此经常可以看到另外两个返回的结果会被用下划线标出并忽略掉。
Neo4j浏览器正在执行这个多跳查询,其结果以表格的形式呈现。这就是你上面写的那个查询,只不过参数是手动输入的。当你在浏览器中探索数据时,就是这么操作的,而不是通过Python来执行的。
这个查询从事件INC-4471开始,通过AFFECTS>关系找到它所影响的服务,然后再通过OWNS>关系追溯到拥有这些服务的工程师。最终返回的结果是这些工程师的姓名和电子邮件地址,结果按姓名排序。
同样的查询,在Neo4j浏览器中执行也会得到相同的结果。返回的两列分别是name和email>,每行代表一位工程师的信息。
过滤
WHERE>语句的使用方式与你的预期完全一致。
MATCH (e:Engineer)-[r:OWNS]->(s:Service)
WHERE r.since < date('2026-01-01') AND s.tier = 'critical'
RETURN e.name, s.name, r.since
需要注意的是,你可以像查询节点的属性一样,轻松地查询关系中的属性r.since。这就是这种建模方式的优势所在——它能够准确地反映各种实体之间的关联关系。
计数与分组
Cypher语言中没有GROUP BY>语句。聚合操作是隐式的:任何在查询结果中出现的非聚合值都会自动被用作分组键。
MATCH (t:Team)<-[:MEMBER_OF]-(e:Engineer)-[:OWNS]->(s:Service)
RETURN t.name AS team, count(DISTINCT s) AS services
ORDER BY services DESC
这样每个团队都会对应一行结果,因为t.name是查询结果中唯一的非聚合值。
当某些数据可能不存在时
MATCH>语句会自动剔除那些不符合查询模式的记录。如果你想获取所有工程师的信息,无论他们是否拥有任何服务,可以使用OPTIONAL MATCH>语句,它的效果类似于左外连接。
MATCH (e:Engineer)
OPTIONAL MATCH (e)-[:OWNS]->(s:Service)
RETURN e.name AS name, collect(s.name) AS services
那些没有任何服务的工程师会在结果中显示为空列表,而不会从查询结果中完全消失。
那个让整个系统变得有意义的多跳查询
现在让我们回到最初提出的问题上来。
某个事件影响了一些服务,那么谁对这些服务拥有相关信息呢?
records, _, _ = driver.execute_query(
"""
MATCH (i:Incident {ref: $ref})-[:AFFECTS]->(:Service)<-[:OWNS]-(e:Engineer)
RETURN DISTINCT e.name AS name, e.email AS email
""",
ref="INC-4471",
database_="neo4j",
)

一个事件,经过两次跳转,最终会涉及到六个节点。这里的“工作量”实际上指的是每一步中需要处理的数据量,而不是与数据库中存储的数据总量成正比。
如果从左到右阅读这些模式,它们其实与英文句子的逻辑是相近的。
以下是从终端对实时的Neo4j Aura实例执行的查询语句:
再次使用相同的查询语句,这次是通过cypher-shell在浏览器之外执行的,结果仍然是一样的:返回了三个名称:"Ada Okonjo"、"Grace Lin"和"Linus Berg"。
从事件开始,通过AFFECTS关系找到受影响的服务,然后再通过OWNS关系反向追踪到负责这些服务的工程师。
向左方向的箭头<-[:OWNS]-实际上起到了关键作用。所有权信息是按照工程师→服务这样的顺序存储的,因此要从服务层面追溯到工程师,就必须沿着这种存储结构反向进行查询。
如果新手在执行查询时没有得到任何结果,最常见的原因就是没有正确地使用这些关系方向。如果查询返回了空结果,而你原本期望会得到一些数据,那么首先检查一下箭头的指向是否正确。
在Neo4j浏览器中,同样的查询结果也可以以图表的形式呈现。事件位于图的一端,受影响的服务位于中间,而负责这些服务的工程师则位于另一端。查询所经过的路径会以图形的形态显示出来,而不是以表格的形式。
现在让我们把这个图表的范围扩大一下:哪些整个团队实际上参与了这些受影响的服务的开发工作呢?
以下是大多数人首先会编写的查询语句。这种写法是错误的,而且会导致查询默默地失败,因此值得我们进行分析。
// 错误的写法:会忽略一些团队。具体原因如下:
MATCH (i:Incident {ref: $ref})-[:AFFECTS]->(:Service)<-[:OWNS]-(:Engineer)
-[:MEMBER_OF]->(t:Team)<-[:MEMBER_OF]-(e:Engineer)
RETURN DISTINCT t.name AS team, e.name AS name
ORDER BY team, name
如果使用这个查询语句来分析本手册中的数据集,它会返回三行结果,而这些结果都来自“Platform”团队。尽管Linus负责开发Checkout模块,而且该模块也受到了影响,但“Commerce”团队却并没有被包含在结果中。
关系的唯一性:隐藏答案的陷阱
这里我们将两种不同的查询方式并排展示。第一种方式的逻辑看起来是正确的,而且确实返回了三行结果;但当将这个查询分为两个部分,并通过WITH连接起来时,同样的查询却返回了四行结果。图表清楚地说明了原因:因为这种查询方式需要先沿着MEMBER_OF关系找到相关服务,然后再沿相同的路径回到工程师信息那里,而Cypher会自动忽略重复的部分,因此最终只返回了三行结果。将查询模式拆分成多个部分可以消除这种限制,因为规则只适用于某个特定的模式内部,而不会覆盖整个查询;而WITH DISTINCT指令则能确保重复的行不会被再次计算。
Cypher能够保证同一个模式不会多次遍历同一条关系。这种现象被称为“关系同构性”,它的存在就是为了防止查询模式无限循环。
以商业团队为例来说,该团队的唯一成员是Linus,而Linus同时也是这个团队的所有者。因此,在进行匹配操作时,查询模式需要先通过Linus的MEMBER_OF关系找到整个团队,然后再沿着同一条关系找到具体的成员。由于这是同一条关系被重复使用了两次,所以Cypher会自动忽略这些重复的数据。
在这种情况下,系统不会显示任何错误或警告信息,只是返回的结果不够准确而已。
解决这个问题的方法就是将原来的单个查询模式拆分成两个部分,这样规则就不会同时覆盖这两个部分了:
MATCH (i:Incident {ref: $ref})-[:AFFECTS]->(:Service)<-[:OWNS]-(:Engineer)-[:MEMBER_OF]->(t:Team)
WITH DISTINCT t
MATCH (t)<-[:MEMBER_OF]-(e:Engineer)
RETURN t.name AS team, e.name AS name
ORDER BY team, name
使用WITH指令可以结束一个查询模式,并开始另一个新的查询模式。因此第二个MATCH操作会重新开始计算,从而能够再次获取到所有者所属团队的信息。
经过这样的修改后,查询结果会包含四行数据,其中当然包括商业团队和Linus。
这种方法在大规模数据集上仍然有效吗?
对于上面的内容,有人可能会提出这样一个合理的质疑:仅仅14个节点的数据并不能证明什么。那么下面我们就用同样的多跳查询来测试75,500个节点的数据集:
查询结果为:返回了33名工程师的信息,发生了150次数据库访问操作,耗时4.6毫秒
这次的数据集规模大约是之前的五千倍,但查询语句完全相同,最终仍然检测到了大约150个相关元素。
这恰恰证明了:在没有索引的情况下,这种查询方法依然能够实现预期的功能。它的执行效率与所遍历的数据范围无关,而只与所涉及的节点数量有关。如果使用三个如此庞大的表来进行联接操作,那么为了得到相同的结果,需要处理的数量将会多得多。
你可以自己亲自验证这一点。相关的数据集已经托管在这个配套仓库中,而benchmark.py文件会执行这个测试,并同时展示本文中的其他测试结果。
总结来说:每当一个查询模式离开某个节点后,又回到了同类型的另一个节点上时,就要思考这两个部分是否属于同一条关系。如果它们确实属于同一条关系,就应该使用WITH指令来拆分这个查询模式。这种错误是Cypher中导致结果不完整的最常见原因之一,而且通过阅读查询语句很难发现这个问题,因为这些查询语句看起来完全正确,返回的数据也似乎合乎逻辑。
即使经过四次跳转操作,整个查询语句仍然可以清晰地表达其含义。而如果用SQL语言来实现同样的功能,就需要进行多次联接操作,并使用DISTINCT指令来去除重复数据;另外,将“两次操作”改为“三次操作”也会导致代码结构的巨大变化。变长路径及其安全保护方法
有时候,你并不知道需要经过多少个中间环节。服务之间的依赖关系就是典型的例子:支付功能依赖于身份验证,而身份验证又依赖于用户信息存储系统,因此你必须确保所有下游环节在发生故障时仍能正常运行。
MATCH (s:Service {name: $name})<-[:DEPENDS_ON*1..4]-(affected:Service)
RETURN DISTINCT affected.name
*1..4表示要追踪一到四个DEPENDS_ON关系。
对于变长路径,必须为其设定上限。因为每个中间环节都会使查询范围扩大,所以[:DEPENDS_ON*]这种写法不会对查询效率产生负面影响,而[:DEPENDS_ON*1..4]则会产生影响。在连接紧密的图中,无上限的查询会导致系统响应速度极慢,甚至无法完成查询。
必须为变长路径设定上限。在连接良好的图中,无上限的*可能会遍历数据库中的大部分数据,因此在测试数据上运行很快的查询,在生产环境中就会变得极其缓慢。这就是导致图数据库性能下降的最常见原因。
以下是每增加两个中间环节所带来的影响。以这个包含75,500个节点的数据集为例,其中某个服务拥有10,039个服务依赖关系:
有限制的情况
覆盖的服务数量
。数据库访问次数
*1..2
30
290
*1..4
133
1,620
*1..6
481
6,388
可以看到,中间环节从两个增加到六个时,覆盖的服务数量增加了15倍以上,而数据库访问次数则增加了22倍。其实查询本身的逻辑并没有发生变化,只是表达方式有所不同而已。
这一点需要牢记:查询范围呈几何级数增长,相应的处理工作量也会随之增加。在连接更加紧密的图中,这种增长幅度会更大。因此,在社交图或依赖关系图中,无上限的查询可能会导致系统瞬间崩溃,而这种问题在测试环境中是不会出现的。
我故意没有给出这三种情况的具体执行时间。在这种规模的数据集上,它们的执行时间都在2到4毫秒之间,它们之间的差异只是测量误差,并非真正的性能差异。而数据库访问次数的统计结果才是可靠的比较依据,而且这种数据在你的机器上也会得到相同的结果。
你还可以查询两个节点之间的最短路径。在SQL中这是一个相当复杂的操作,但在这里只需要一行代码就可以完成:
MATCH p = shortestPath(
(a:Engineer {email: $from})-[:MEMBER_OF|OWNS*..6]-(b:Engineer {email: $to})
)
RETURN [n IN nodes(p) | coalesce(n.name, n.email)] AS hops
这种查询方式能够揭示连接两个人的一系列关联关系。推荐系统、欺诈检测以及访问分析功能,其实都是基于这一基本查询思路衍生出来的应用。
索引的真正含义
在使用索引之前,弄清楚它的本质是非常重要的,因为本文中提到的几乎所有性能问题,最终都可以追溯到这个概念上。
想象有一本900页厚的教科书,你需要查找关于光合作用的内容。这时你有两种选择:要么从第1页开始逐页阅读直到找到相关内容,要么直接翻到书后的索引目录,找到“光合作用”这一条目,然后直接跳到第412页。
这两种方法最终都会找到相同的内容,只不过一种需要阅读整整900页,而另一种只需要翻两页而已。
数据库索引就相当于书后的索引目录。它是数据库在数据之外维护的另一种结构,它能够将某个属性值与包含该属性值的记录节点对应起来。你不需要直接查询索引,也不必告诉Cypher系统使用它——只需创建一次索引,之后在需要的时候规划器就会自动利用它。
左图展示了AllNodesScan这种查询方式:需要读取75,500个记录,其中只有1条记录包含所需信息,其余75,499条记录都需要被读取。右图则展示了NodeUniqueIndexSeek的方式:只需要读取两次数据,一次是索引条目,另一次才是目标页面的内容。
这个示例还展示了本手册后面会提到的一个测试结果:在包含75,500个记录的数据集上,使用NodeUniqueIndexSeek方法后,数据库的访问次数从151,002次减少到了3次。需要记住的是,你根本不需要告诉Cypher系统使用索引——只需创建一次索引,之后在需要的时候它就会自动利用它。
下面是在包含75,500个记录的数据集上,用三种不同的方式进行的相同查询操作。这三种方法最终都找到了同一位工程师的信息,也得到了相同的结果。不同的是,数据库为完成这些查询所需要付出的努力程度。
第一种方式:不使用标签,也不使用索引。
PROFILE MATCH (n) WHERE n.email = 'eng25000@example.com' RETURN n.name
操作方式 描述 执行时间 记录数 数据库访问次数
ProduceResults `n.name` 3775 1 0
Projection n.name AS `n.name` 3775 1 1
Filter n.email = $autostring_0 3775 1 75500
AllNodesScan n 75500 75500 75501
AllNodesScan这种方式意味着数据库需要读取所有的75,500条记录,包括那些可能根本不存在电子邮件属性的服务、团队或事件信息。之后Filter操作才会检查这些记录中的电子邮件字段。最终:数据库共进行了151,002次访问才找到了目标记录。
第二种方式:使用了标签,但仍然没有使用索引。
PROFILE MATCH (e:Engineer) WHERE e.email = 'eng25000@example.com' RETURN e.name
操作方式 详细信息 执行次数 访问次数 数据库访问次数
ProduceResults `e.name` 2500 1 0
Projection e.name AS `e.name` 2500 1 1
Filter e.email = $autostring_0 2500 1 50000
NodeByLabelScan e:Engineer 50000 50000 50001
NodeByLabelScan这种方式更好。它只读取那50,000个工程师的相关信息,而不是全部75,500个节点的数据。不过,它仍然需要读取每一个节点的信息。总访问次数:100,002次。标签的作用虽然缩小了搜索范围,但并没有让我们能够直接找到目标数据。
第三种方法:使用索引。
CREATE CONSTRAINT engineer_email IF NOT EXISTS
FOR (e:Engineer) REQUIRE e.email IS UNIQUE
PROFILE MATCH (e:Engineer) WHERE e.email = 'eng25000@example.com' RETURN e.name
操作方式 详细信息 执行次数 访问次数 数据库访问次数
ProduceResults `e.name` 1 1 0
Projection e.name AS `e.name` 1 1 1
NodeUniqueIndexSeek UNIQUE e:Engineer(email) WHERE email = $auto 1 1 2
NodeUniqueIndexSeek这种操作方式取代了之前的扫描和过滤步骤。总数据库访问次数:3次。
在这里,我们用三种不同的方式来查找同一个工程师的信息。在包含75,500个节点的图中,我们需要从这50,000个工程师中找到目标对象。这些操作是在Neo4j 5.26.29 Community版本上使用PROFILE命令进行的。三种方法得出的结果都是相同的。不同的是处理数据的方式:一种是读取所有具有该标签的节点,另一种是通过属性来缩小搜索范围,而第三种则是直接利用索引来快速定位目标数据。
第三种方法的效率是151,000种方法的1/151,000,这就是为什么在表格中会明确指出使用索引带来的效果:
方式
操作类型
。数据库访问次数
无标签,无索引
AllNodesScan
151,002
有标签,无索引
NodeByLabelScan
100,002
使用索引
NodeUniqueIndexSeek
3
在我的机器上,不使用索引时查询时间为35.4毫秒,而使用索引后查询时间仅为4.0毫秒,速度提高了大约9倍。
但是在引用这类数据时一定要小心。虽然数据库需要执行的操作次数减少了33,334倍,但实际执行速度并没有提高33,334倍,因为每次查询仍然需要完成连接处理、计划查询方案以及返回结果等步骤,而这些过程并不会因为使用了索引而发生变化。因此,效率的提升幅度是一个相对稳定的数值;而具体的速度提升程度则取决于你的硬件配置、缓存效果以及服务器当时正在执行的其它任务等因素。你不会得到9这样的结果。当我从一个新的代码环境中再次运行这个相同的测试时,对相同数据进行的相同查询,其执行速度实际上是17倍快,而不是9倍。两次测试中,数据库访问次数也是完全相同的:分别是100,002次和3次。
这种对比恰恰说明了问题的关键所在。数据库访问次数是由你的数据和查询语句决定的,因此每次测试的结果都应该是一致的;而执行速度则取决于你使用的机器性能,所以两次测试的结果可能会有所不同。当你比较两种不同的查询编写方式时,应该关注的是它们对应的数据库访问次数。
Neo4j提供的索引类型
大多数教程都只介绍了一种索引类型,然后就不再深入讲解了。但实际上Neo4j 5提供了六种不同的索引类型,如果选择了错误的索引类型,其效果相当于根本没有使用任何索引,因为查询优化器会自动忽略那些无法满足你的查询条件的索引。
索引类型
适用场景
。创建方式
范围索引
用于精确匹配、范围查询、STARTS WITH操作以及排序。这是默认使用的索引类型。
CREATE INDEX ... FOR (n:Label) ON (n.prop)
文本索引
用于字符串属性上的CONTAINS和ENDS WITH操作。
CREATE TEXT INDEX ...
点索引
用于地理点的距离查询及边界框计算。
CREATE POINT INDEX ...
标记索引
按标签或关系类型查找节点。
系统默认会创建两种这样的索引。
全文本索引
用于在文本中进行搜索,并根据相关性对结果进行排序。该功能由Lucene技术提供支持。
CREATE FULLTEXT INDEX ...
向量索引
用于基于嵌入向量的最近邻查询。
CREATE VECTOR INDEX ...
人们常常会疑惑的是“范围索引”和“文本索引”之间的区别。范围索引能够很好地处理STARTS WITH操作,因为具有相同前缀的名称在排序后的结果中会相邻排列,就像“photosynthesis”和“photosphere”在书籍目录中是相邻的条目一样。然而,文本索引无法用于CONTAINS或ENDS WITH操作,因为你要搜索的内容可能位于字符串值的任意位置,而排序后的结构无法帮助你缩小搜索范围——而这正是文本索引存在的意义所在。
Neo4j提供了六种不同的索引类型,每种类型都有其特定的适用场景。如果选择了错误的索引类型,其效果相当于根本没有使用任何索引,因为查询优化器会自动忽略那些无法满足查询条件的索引,而你却无从察觉这一情况。
如果你完全不指定使用哪种索引类型,系统会默认使用“范围索引”,而对于绝大多数情况来说,这种设置已经是非常合适的选择了:
CREATE INDEX service_tier IF NOT EXISTS FOR (s:Service) ON (s.tier)
你也可以同时为多个属性创建索引,这种索引被称为复合索引:
CREATE INDEX service_tier_name IF NOT EXISTS FOR (s:Service) ON (s.tier, s.name)
复合索引与两个独立的索引不同。它是一个整体结构:首先按照层级进行排序,然后在同一层内再按照名称进行排序,就像按城市和姓氏对电话簿进行排序一样。当你需要同时根据这两个条件进行过滤时,复合索引非常有用;但如果你只使用第二个条件进行过滤,那么复合索引就毫无用处了,因为在这种情况下,你无法在仅按城市分类的电话簿中查找特定的姓氏。
复合索引涵盖了多个属性的组合,而这些属性的排序顺序决定了它能够服务于哪些查询。如果只根据第一个属性进行过滤,那么就可以使用这个复合索引;但如果只根据第二个属性进行过滤,就无法使用它。
关系也可以被创建为索引,使用的语法与创建普通属性索引的语法相同,只需在字段名前加上“r:”即可:
CREATE INDEX owns_since IF NOT EXISTS FOR ()-[r:OWNS]-() ON (r.since)
要查看自己创建了哪些索引,可以执行以下命令:
SHOW INDEXES
为什么你的索引没有被使用
如果某个索引存在但却从未被使用过,那确实是一件非常令人沮丧的事情,因为表面上一切看起来都是正确的。造成这种情况的常见原因有四种,而PROFILE命令可以帮助你确定具体是哪种原因导致了这个问题。
你创建的索引与实际用于过滤的数据属性不同。例如,如果为email字段创建了索引,那么对于那些根据name字段进行过滤的查询来说,这个索引根本无法发挥作用。
你的查询条件无法使用该类型的索引。比如,使用CONTAINS操作符对范围索引进行查询时,即使索引确实存在,查询引擎也会判断它无法提供帮助。
你将某个属性放在了函数内部。例如,如果写成WHERE toLower(e.email) = 'x'这样的语句,那么索引就无法被使用,因为索引中存储的是原始值,而不是小写后的值。应该将属性的值进行规范化处理后再创建索引。
你没有为该节点指定标签。索引是建立在节点标签基础上的。如果MATCH (n) WHERE n.email = ...这样的查询语句没有可用的标签,那么系统就会扫描数据库中的所有节点,从而导致性能下降。
约束条件,以及会让你陷入困境的因素
索引可以使查找操作变得快速;而约束条件则是一些必须严格遵守的规则。它们的作用不同,但之所以会被一起讨论,是因为在Neo4j中,其中一种机制会在无形中实现另一种机制的功能。
每次执行MERGE操作时,系统都必须检查是否存在具有相同标签的节点。如果没有索引,这种检查就需要遍历所有带有该标签的节点。
当节点数量仅为一千个时,这种检查不会对性能产生明显影响;但当节点数量达到十万个时,导入操作会变得非常缓慢,而且原因并不容易察觉,因为并没有任何功能出现故障——只是在进行大量不必要的处理而已。
对于那些用于进行合并操作的属性,应该为其设置唯一性约束。这样既能确保数据的正确性,同时系统也会自动生成相应的索引。
下面我们展示了在设置唯一性约束之前和之后,同一检查操作的执行过程。在没有约束的情况下,要判断“这个工程师是否存在”,就需要读取所有工程师节点的信息并比较它们的电子邮件地址,最终只保留匹配的结果,而其他结果则会被忽略;对于下一行数据,也需要重复这一过程。相应的查询计划显示为NodeByLabelScan。而当设置了唯一性约束后,数据库会自动生成索引,因此可以直接找到对应的节点,而无需遍历所有节点,相应的查询计划显示为NodeUniqueIndexSeek。
当节点数量仅为一千个时,这种检查不会对性能产生明显影响;但当节点数量达到十万个时,导入操作会变得非常缓慢,而且输出结果中也不会说明原因。无论哪种情况,所需的处理成本都是相同的,因此没有必要省略这一步骤。
要想了解自己的查询语句具体是如何执行的,可以在查询语句前加上PROFILE关键字,然后查看最终的执行计划。如果最终显示的是NodeByLabelScan,那就意味着系统中缺少相应的索引,而这正是导致Cypher查询速度缓慢的最常见原因之一。
你完全可以自己验证这一结论,而不必相信我的话:
SHOW INDEXES YIELD name, type, owningConstraint
WHERE owningConstraint IS NOT NULL
RETURN name, type, owningConstraint
name type owningConstraint
engineer_email RANGE engineer_email
incident_ref RANGE incident_ref
service_name RANGE service_name
team_name RANGE team_name
通过设置这四种约束,系统会自动生成四个范围索引,每个索引都由相应的约束条件所“拥有”。正因为如此,本文中提到的加载脚本才从未单独创建这些索引:这样做是多余的,而且Neo4j也会将其视为冲突而拒绝执行。
Neo4j提供了四种类型的约束条件:
约束类型
强制遵守的规定
IS UNIQUE
具有相同标签的两个节点不能拥有相同的属性值
IS NOT NULL
该属性必须存在
IS NODE KEY
上述两条规则同时适用于一个或多个属性
IS :: TYPE
该属性必须是某种特定类型,例如STRING
问题在于:只有第一个约束条件在Neo4j社区版中才能正常使用,而这篇文章中提供的Docker镜像正是社区版。另外三个约束条件属于企业版的功能,在Aura环境中可以正常使用。
这意味着同样的脚本在Aura环境下可以成功执行,但在本地的Docker容器中却会失败。这对于初学者来说确实会让人感到困惑。错误信息如下:
Neo.DatabaseError.Schema.ConstraintCreationFailed
无法创建类型为“NODE PROPERTY EXISTENCE”、模式为(:Engineer {name})的约束条件:
该约束条件需要Neo4j企业版才能使用
这不是你的错误,而是版本限制导致的。如果你仔细阅读错误信息,就会发现其中明确提到了这一点。
IS UNIQUE这个约束条件在社区版中是可以使用的,因为这篇文章中提供的Docker镜像正是社区版。此外,它还会生成相应的索引。但是还有另外三种约束条件社区版是不支持的:IS NOT NULL用于检查属性是否存在,IS NODE KEY用于确保某个属性在一个或多个字段中都是唯一的,而某些属性类型约束(比如要求属性必须是STRING类型)也只有在企业版中才能使用。
Aura运行的是企业版,因此同样的脚本在Aura环境中可以成功执行,但在本地电脑上就会失败。这不是你的错误,错误信息中明确提到了这一点:Neo.DatabaseError.Schema.ConstraintCreationFailed,后面还跟着“企业版”这几个字。
这本手册中的所有示例都只使用了IS UNIQUE这个约束条件,因此它们都是在社区版环境中运行的。
CREATE CONSTRAINT engineer_email IF NOT EXISTS
FOR (e:Engineer) REQUIRE e.email IS UNIQUE
请在加载数据之前执行这条命令,而不是之后。
对于那些你经常需要查询但又不唯一的属性,可以创建普通的索引:
CREATE INDEX service_tier IF NOT EXISTS
FOR (s:Service) ON (s.tier)
以下是我们模型中的一些基本约束条件设置:
CREATE CONSTRAINT engineer_email IF NOT EXISTS FOR (e:Engineer) REQUIRE e.email IS UNIQUE;
CREATE CONSTRAINT service_name IF NOT EXISTS FOR (s:Service) REQUIRE s.name IS UNIQUE;
CREATE CONSTRAINT incident_ref IF NOT EXISTS FOR (i:Incident) REQUIRE i.ref IS UNIQUE;
CREATE CONSTRAINT team_name IF NOT EXISTS FOR (t:Team) REQUIRE t.name IS UNIQUE;
在初始化时,用Python一次性执行以下代码:
CONSTRAINTS = [
"CREATE CONSTRAINT engineer_email IF NOT EXISTS FOR (e:Engineer) REQUIRE e.email IS UNIQUE",
"CREATE CONSTRAINT service_name IF NOT EXISTS FOR (s:Service) REQUIRE s.name IS UNIQUE",
"CREATE CONSTRAINT incident_ref IF NOT EXISTS FOR (i:Incident) REQUIRE i.ref IS UNIQUE",
"CREATE CONSTRAINT team_name IF NOT EXISTS FOR (t:Team) REQUIRE t.name IS UNIQUE",
]
for statement in Constraints:
driver.execute_query(statement, database_="neo4j")
IF NOT EXISTS 这一语法确保了该代码块在每次系统启动时都能被安全地执行。
查询规划器会如何处理你的查询
Cypher是一种声明性语言。你只需要描述自己想要得到的结果的结构,而无需指定具体的查找方式。这种设计确实非常方便,但这也意味着必须有其他机制来决定具体的执行步骤。
这个负责做出决策的机制就是查询规划器。
当你提交一条查询语句时,Neo4j会首先解析这条查询,然后考虑所有可能的执行方案。对于我们的多跳查询来说,它既可以从事件节点开始查找相关的工程师,也可以从所有的工程师节点开始反向追踪到事件节点。这两种方法最终都会得到相同的结果,只不过前者可能只需要访问少量的节点,而后者则可能需要访问数万个节点。
查询规划器会根据它所掌握的关于你的数据的信息来选择最合适的执行方案。这些信息包括:每种标签对应的节点数量、各种类型的关系数量,以及某个索引属性存在的不同值的数量。根据这些信息,规划器可以估算出每一种执行方案会生成多少条记录,从而选择成本最低的方案。正因为如此,这种查询规划器被称为“基于成本的规划器”,而且每个执行方案的标题中都会显示Planner COST这一字段。
Cypher是一种声明性语言,因此你无需指定具体的查找步骤。不过仍然需要有其他机制来做出决策,而这种决策过程决定了查询执行的速度快慢。查询规划方案其实就是这种决策的书面体现。
对于用户来说,一个重要的结论是:查询规划器其实是在进行猜测。这种猜测是基于真实的数据统计结果进行的,但终究还是猜测而已。如果它的猜测完全错误,那么查询的执行速度就会变得非常慢,而这种问题正是通过查询规划方案来发现的。
EXPLAIN和PROFILE命令的作用
有两个关键字可以帮助你查看查询的执行方案,它们之间的区别也非常重要。
EXPLAIN 命令仅会规划查询方案,而不会实际执行它。你会看到规划器选择的具体操作步骤以及它预估的执行结果所需的记录数。这个命令不会消耗任何资源,因此你可以用它来检查那些可能需要花费很长时间才能完成的查询。
PROFILE 命令则会先规划查询方案,然后再实际执行它。你不仅能得到EXPLAIN提供的信息,还能了解到实际的执行结果:真实的记录数以及每个操作步骤对数据库造成的影响。
下面是同一个查询使用这两种命令得到的结果对比:
EXPLAIN MATCH (e:Engineer)-[:OWNS]->(s:Service {tier:'critical'}) RETURN count(e) AS c
操作步骤 详细信息 预估记录数 实际记录数 对数据库的影响次数
ProduceResults c 1 ? ?
EagerAggregation count(e) AS c 1 ? ?
Filter e:Engineer 2401 ? ?
Expand(All) (s)<-[anon_0:OWNS]-(e) 2401 ? ?
Filter s.tier = $autostring_0 250 ? ?
NodeByLabelScan s:Service 5000 ? ?
所有的rows和dbHits数值都显示为“?”,因为实际上没有任何操作被执行。现在来看PROFILE的结果:
操作符 详细信息 估计值 行数 数据库访问次数
ProduceResults c 1 1 0
EagerAggregation count(e) AS c 1 1 0
Filter e:Engineer 2401 7573 7573
Expand(All) (s)<-[anon_0:OWNS]-(e) 2401 7573 17871
Filter s.tier = $autostring_0 250 786 5000
NodeByLabelScan s:Service 5000 5000 5001
EXPLAIN用于生成查询计划,而PROFILE则用于实际执行该查询。操作符名称及估计值之所以相同,是因为规划器在这两种情况下得出的结论是一样的。不过,EXPLAIN无法告诉你实际上发生了什么;而在估计结果错误的情况下,这些实际发生的情况才是你所需要的信息。
当你想要了解数据库打算执行哪些操作,或者当运行某个查询会耗费大量资源或导致不良后果时,应该使用EXPLAIN。而当你想要知道查询实际完成了什么操作时,就应该使用PROFILE。
EXPLAIN还有一个非常实用的功能:它可以在不接触任何数据的情况下解析并生成查询计划,因此它是检查某个查询是否有效的最快方法。你可以将代码库中的所有Cypher语句都通过EXPLAIN进行测试,从而在它们被投入生产环境之前发现其中的拼写错误或属性名称变更等问题。
正是配套仓库中的check_cypher.py脚本在做这件事:它从这篇文章中提取出了39个Cypher语句,分别用EXPLAIN进行验证,如果其中任何一个语句无效,程序就会立即停止执行。
阅读查询计划时,请从底部开始
只有这样,查询计划才能真正被人们理解——而这与大多数人的习惯恰恰相反。
查询计划是从底部开始逐行读取的。最底层的操作符是“叶节点”,数据就是从这里进入查询过程的。其上方的每一层操作符都会接收来自下层的数据,对这些数据进行相应的处理,然后将结果传递给上一层。位于顶部的操作符总是ProduceResults,它负责将最终的结果输出到数据库之外。
以上面的查询计划为例,正确的阅读顺序如下:
NodeByLabelScan会读取全部5,000个服务对象。这就是“叶节点”:数据就是从这里进入查询流程的。
Filter会从中筛选出只有786个关键的服务对象。
Expand(All)会根据OWNS关系,从这786个服务对象中反向追踪到对应的工程师信息,从而生成7,573行结果。
Filter会再次确认这些对象确实都属于Engineer类型。
EagerAggregation会统计这些工程师的数量。
ProduceResults最终会返回这个统计结果。
计划是从下往上读取的。最底层的行是数据输入的位置,上面的每一层都会接收这些数据,对它们进行加工处理,然后再将结果传递下去,最终到达ProduceResults阶段。因此,如果从上往下阅读这个计划,起初会觉得它看起来像是一串杂乱无序的信息。
缩进格式用于表示操作符之间的父子关系。一个操作符的子操作符会位于其下一层。大多数操作符只有一个子操作符,而有些操作符(比如连接操作)则有两个子操作符,在这种情况下,右边的子操作符会被放在更靠下的位置,并且会有更多的缩进。
各列的含义
列名
其所表示的意义
操作符
表示正在执行的操作类型:扫描、查找、扩展或过滤等
ID
用于在此计划内部进行交叉引用的唯一编号
详细信息
具体指代的内容:哪个标签、哪种模式、哪个谓词等
预计产生的行数
规划器估计这一步骤会生成多少行数据
实际生成的行数
实际产生的行数。PROFILE报告中才会显示这一数值
存储引擎的访问次数
存储引擎为执行此操作所进行的操作次数。PROFILE报告中才会显示这一数值
内存使用量(字节)
该操作符在执行过程中消耗的最大内存量。PROFILE报告中才会显示这一数值
页面缓存命中/未命中的次数
数据是从内存中获取还是从磁盘上读取的频率
其中有两个概念经常被误解,因此有必要在这里加以说明。
存储引擎的访问次数并不等于生成的行数。这个数值表示存储引擎为执行某项操作而进行的低级访问操作次数:比如读取一个节点、读取某个属性值或读取索引条目等。即使最终只返回了一行数据,也可能需要多次访问存储引擎才能完成这些操作。再来看上面的Expand(All)这条记录:虽然生成了7,573行数据,但存储引擎实际上进行了17,871次访问操作。行数代表最终的结果规模,而访问次数则反映了执行这个操作所消耗的资源。
页面缓存命中/未命中的次数可以用来判断数据是否已经存在于内存中。如果缓存未命中,就意味着存储引擎必须从磁盘上读取数据。在第一次处理从未被访问过的数据时,缓存未命中的情况会比较多;而在第二次处理相同的数据时,缓存命中的情况就会增多。因此,比较两次处理同一数据时的执行时间并不能提供有用的信息。这个列是Enterprise Edition版本才具备的功能,在本文中使用的Community Docker镜像上,这一列的值始终显示为0/0。这并不是一个错误,也不表示你的缓存为空。
整个计划中最有用的信息
请将预计生成的行数与实际生成的行数进行对比。
这种估算值其实是规划者在选择该方案时所依据的假设;而实际的行数才代表真实数据情况。当这两种数值相差不大时,规划者就能根据对数据的准确了解来做出决策;但当它们之间的差异很大时,规划者可能会为一个并不存在的数据集选择相应的方案,而这往往正是导致查询速度变慢的根本原因。
请看上面那个分析报告中列出的数据:
操作类型
预计结果
)实际结果 偏差值
NodeByLabelScan
5,000
5,000
完全一致
按tier字段进行过滤
250
786
偏差为3.1倍
Expand(All)
2,401
7,573
偏差为3.2倍
规划器原本认为,如果只过滤掉关键服务,最终剩下的数量应该是5,000中的250个;但在我们的数据中,实际剩余的数量是786个,因为实际上大约有15%的服务属于关键服务,而非规划器默认假设的5%。这种误差会进一步影响后续的决策:由于规划器预计需要250名工程师来处理这些任务,因此它认为需要安排约2,401名工程师,但实际上最终分配了7,573名工程师。
在这个例子中,这种误差带来的后果并不严重。但在处理规模更大的查询时,如果规划器对所需资源的数量估计错误了三倍,那么整个执行策略就很可能会失败,因为它会误以为自己正在将一个小任务与一个大规模的任务结合起来进行处理,而实际上是在同时处理两个大规模的任务。
“预计行数”是指规划器在选择某个执行方案时所预估的结果;而“实际行数”才是最终的实际执行结果。两者之间的偏差往往就是导致查询速度变慢的原因,因为规划器优化的是一个与数据实际情况不符的执行方案。
如果你的所有查询中预测值都一直存在误差,那么这些预测所依据的统计信息很可能已经过时了。
因此,我们应该养成这样一个习惯:运行PROFILE命令后,从报告的底部开始阅读,并仔细核对每一项预测值与实际结果是否一致。你不需要寻找那些巨大的数字,而是要关注规划器在哪个步骤出现了预测错误。
三个值得注意的关键点
这是在Neo4j浏览器中查看PROFILE报告的结果,报告中会显示每一步操作所对应的预计行数和实际行数。这就是NodeUniqueIndexSeek这个操作的解释所参考的实际输出数据。
除了检查预测值之外,方案中还有三处具体的内容值得你特别注意。
当你看到某些具体的操作类型时,它们会告诉你一些重要的信息。NodeByLabelScan这种操作意味着没有使用任何索引;而每个条目都会将问题的症状、原因以及解决方法对应起来展示。
NodeByLabelScan表示数据库会读取所有带有该标签的节点。在起始节点上使用这种操作时,几乎可以肯定是因为缺少索引才导致这种情况的。这是最常见的错误原因之一。
行数先急剧增加后又骤然减少的情况:** 如果某个操作步骤会生成二十万行数据,而接下来的步骤却将这些数据数量减少到四十行,那么这就意味着你在白白消耗计算资源。通常情况下,可以重新安排操作顺序,让筛选操作先执行。
CartesianProduct这种模式表示你的查询结构中存在两部分彼此没有关联,因此数据库会将左边的每一行都与右边的每一行进行匹配。这种情况几乎总是无意的,但恰恰就是它导致查询执行时间从几毫秒延长到几分钟的原因。
解决这些问题的方法都是相同的:为查询规划器提供更高效的执行路径。索引可以让全表扫描变为快速查找;重新安排操作顺序可以让筛选操作先进行;而消除那些不必要的关联关系也能避免产生CartesianProduct这种匹配情况。
你真正会遇到的六个问题
<这些问题往往会让人们花费整个下午的时间来解决。它们都不会生成明显的错误提示,正因为如此,才值得被列出出来。
查询结果为空,而你原本期望得到一些数据
<首先检查你的操作方向是否正确。(a)-[:OWNS]->(b)和(a)<-[:OWNS]-(b)代表的是完全不同的查询逻辑,只有后者才符合你的需求——当你从“被拥有者”这个角度开始进行查询时,应该使用后者。如果你不确定该使用哪种方向,可以直接使用-[:OWNS]-,因为这种操作方式适用于任意方向。如果查询仍然能够返回结果,那就说明方向设置是正确的。
查询返回的数据行数少于实际应该有的数量
<这种情况其实属于前面提到的“关系唯一性陷阱”。如果你的查询结构中存在某个节点被多次访问,而这两次访问实际上代表的是同一种关系,那么Cypher会自动忽略这些重复的结果。此时,你应该使用WITH来分割查询逻辑。
原本快速的查询突然变得很慢
>检查PROFILE配置中是否存在CartesianProduct这种模式。如果存在,那就说明你的查询结构中存在两部分彼此没有关联,导致所有左边的数据都被与右边的所有数据进行了匹配。通常情况下,可能是忘记了某个变量,或者本应使用一个MATCH子句的地方却写了两处。
合并操作导致了重复数据的产生
>如果你使用了多个识别属性来进行合并操作,那么MERGE (e:Engineer {email: $email, name: $name})会将名称发生变化的记录视为不同的节点。应该先根据唯一标识字段进行合并,然后再使用SET命令来设置其他属性的值。
合并操作的速度慢得离谱
>如果你在用于合并的属性上没有创建索引,那么每次合并操作都会扫描所有具有该标签的节点。因此,应该在加载数据之前就创建索引,而不是之后。
整个导入操作耗尽了内存
>如果你将所有的数据都放在同一个事务中进行处理,那么就会导致内存不足的问题。建议分批处理数据,每次交易处理几千行数据是比较合理的做法。而CALL { ... } IN TRANSACTIONS这个语法可以让Cypher在单个查询中自动完成分批处理的操作。以下是一份简短的检查清单,建议您将其放在手边方便查阅:
症状
首先需要检查的内容
没有行
箭头指向的方向
行数太少
关系的唯一性,使用WITH进行分割处理
性能突然变慢
对CartesianProduct使用PROFILE进行分析
存在重复节点
在多个条件上进行合并操作时会出现这种情况
MERGE操作速度缓慢
可能缺少约束条件或索引
内存不足
可能是由于存在一个庞大的事务导致的
事务机制以及出现问题时会发生什么
execute_query会为每次调用自动创建一个事务,并在遇到临时性错误(例如集群中的领导节点选举失败)时自动重试。对于大多数情况来说,这种处理方式正是我们所需要的,因此无需额外考虑这些细节。
下面将介绍在整个驱动程序、会话层以及数据库层面实际发生的情况,包括大家最关心的那种“写入操作中途失败”的情形。
从您的代码开始,经过驱动程序和会话层,最终到达Neo4j数据库。每个应用程序都会使用一个GraphDatabase.driver(uri, auth)来创建驱动程序实例,而每项具体操作又会对应一个会话对象。会话对象的创建成本较低且生命周期较短,而驱动程序实例的创建成本较高但生命周期较长;因此,在这些组件之间进行频繁切换往往是导致应用程序运行速度变慢的原因之一。
关键在于事务的执行过程。一旦事务开始执行,它在执行过程中所写入的所有数据在事务提交之前都是不可见且不会被持久保存的。如果在执行过程中的某个步骤出现错误,系统也不会留下部分完成的数据,而会恢复到事务开始之前的状态。
当有多条语句需要同时成功或失败时,您需要自行管理这些事务:
def reassign_service(tx, service, from_email, to_email):
tx.run(
"""
MATCH (:Engineer {email: $from_email})-[r:OWNS]->(s:Service {name: $service})
DELETE r
""",
from_email=from_email, service=service,
)
tx.run(
"""
MATCH (e:Engineer {email: $to_email}), (s:Service {name: $service})
MERGE (e)-[:OWNS {since: date()}]->(s)
""",
to_email=to_email, service=service,
)
with driver.session(database="neo4j") as session:
session.execute_write(reassign_service, "payments", "ada@example.com", "grace@example.com")
execute_write会将您的函数代码放在一个事务中执行。如果其中任何一条语句出现错误,整个事务都会被回滚,数据库中的数据也会恢复到初始状态。此外,该函数还会在遇到临时性故障时自动重试,因此需要将其写成函数形式而不是内联代码:因为这个函数可能会被多次执行,所以必须确保其重复执行是安全的。
最后这一点值得明确说明:任何你传递给execute_write的函数都必须是幂等的,也就是说,运行两次与运行一次的效果是一样的。重试会让你从函数的开头重新开始执行它,因此任何会增加计数器值或向列表中添加元素的操作都会被执行两次。这就是为什么在某些情况下应该使用MERGE而不是CREATE的原因。
测试与图结构交互的代码
编写图结构相关的代码既简单,也容易出错,正如本手册前面提到的“关系唯一性陷阱”所展示的那样。通过测试,你可以一次性找到这类错误,而无需反复调试。
不要模拟数据库
人们往往会想要模拟数据库驱动程序,并验证你的函数是否使用了特定的字符串来调用它。但你应该抵制这种冲动。因为如果你的Cypher代码有误,这样的测试反而会通过,而恰恰这种错误才是我们需要发现的。图结构相关代码中的问题几乎从来都不在于查询周围的Python代码,而是出在查询本身。
应该在真实的Neo4j环境中运行测试。在Docker中启动它只需要几秒钟,而且这样做的目的就是为了让查询引擎得到实际的使用机会。
为每个测试提供一个干净的图结构环境
import os
import pytest
from neo4j import GraphDatabase
@pytest.fixture(scope="session")
def driver():
d = GraphDatabase.driver(
os.environ.get("NEO4J_TEST_URI", "bolt://localhost:7687"),
auth=("neo4j", os.environ["NEO4J_TEST_PASSWORD]),
)
d.verify_connectivity()
yield d
d.close()
@pytest.fixture(autouse=True)
def clean(driver):
"""在每次测试之前清除之前的操作结果,以防止测试之间的影响相互干扰。"""
driver.execute_query("MATCH (n) DETACH DELETE n", database_="neo4j")
由于创建数据库驱动程序的成本较高,因此它只会为整个会话被创建一次。而在每次测试之前都会执行清除操作,因为如果某个测试依赖于前一个测试留下的结果,那么单独运行这个测试可能会通过,但在整个测试套件中就会失败。
测试真正出问题的地方
一个有用的测试应该是那种能够真正发现错误的测试。下面是一个针对前面提到的“关系唯一性陷阱”设计的测试:
def test_teams_includes_a_team_whose_only_member_is_the_owner(driver):
driver.execute_query(
"""
MERGE (e:Engineer {email: 'linus@example.com'}) SET e.name = 'Linus'
MERGE (s:Service {name: 'checkout'})
MERGE (t:Team {name: 'Commerce'})
MERGE (i:Incident {ref: 'INC-1'])
MERGE (e)-[:OWNS]->(s)
MERGE (e)-[:MEMBER_OF]->(t)
MERGE (i)-[:AFFECTS]->(s)
""",
database_="neo4j",
)
teams = teams_involved(driver, "INC-1")
# 使用单模式查询时,这里会返回空列表,且不会产生任何错误。
assert [t["team"] for t in teams] == ["Commerce"]
那次测试的价值远超过其他十几次针对你的Python代码进行的测试。它能够检测出某种特定且隐蔽、难以被发现的错误;而如果有人试图将那个查询语句简化为某种固定的模式,那么程序就会出现明显的错误。同时验证记录的数量与内容是否正确
“静默式数据获取”是一种常见的图数据库错误,因此不仅要确认获取到的数据是否正确,还要验证实际获取到了多少条记录:
def test_load_is_idempotent(driver):
load(driver)
_, summary, _ = driver.execute_query(
"MATCH (e:Engineer) RETURN count(e) AS c", database_="neo4j"
)
first = driver.execute_query("MATCH (e:Engineer) RETURN count(e) AS c", database_="neo4j")[0][0]["c"]
load(driver) # 再运行一次查询
second = driver.execute_query("MATCH (e:Engineer) RETURN count(e) AS c", database_="neo4j")[0][0]["c"]
assert first == second, "两次加载操作产生了重复记录,这意味着使用的MERGE键设置有误"
这种简单的验证方法能够及时发现最严重的数据加载错误,也就是那些因使用了多个识别属性而导致的合并问题。
同样的图结构,在Neo4j浏览器中作为数据模型展示时,与实时的Aura实例也是相同的:
在Aura控制台中运行CALL db.schemavisualization()命令,就可以看到当前数据库中存储的所有数据结构。该命令会显示四个节点标签:Engineer、Incident、Service和Team,以及它们之间存在的四种关系类型:事件AFFECTS服务,服务DEPENDS_ON另一项服务,工程师OWNS某项服务,以及工程师属于某个TEAM。所使用的属性键包括email、name、ref和summary。
这就是你在本地构建的相同数据模型,在托管服务上运行后,这种方法可以快速验证数据加载操作是否达到了预期的效果。
从图数据库到知识图谱
到目前为止,我们讨论的所有内容都与图数据库有关。而知识图谱则不同:在这种模型中,节点代表你所在领域中的真实实体,关系则表示关于这些实体的有意义的信息,因此整个图结构本身就构成了你所掌握知识的模型。
从图数据库转向知识图谱,主要体现在数据来源的不同。此时,你不是从表格中读取数据,而是从文档、工单、维基页面、代码或对话记录中提取实体和关系信息。
不过,你之前学过的那些技术方法并不会因此而改变:
def add_fact(driver, subject, predicate_service, source_doc):
driver.execute_query(
"""
MERGE (e:Engineer {email: $subject})
MERGE (s:Service {name: $service})
MERGE (e)-[r:OWNS]->(s)
ON CREATE SET r.source = $source, r.extracted = datetime()
""",
subject=subject, service=predicate_service, source=source_doc,
database_="neo4j",
)
请注意r.source。当数据是被提取出来而非手动输入时,记录每条数据的来源信息是必不可少的。每当有人询问为什么该图表会得出某种结论时,你都需要这些信息;同样,当原始文档被修改后,你也需要这些信息来查找所有由此衍生出来的内容。
有两种习惯能够帮助提取出的图表保持其有效性:
将数据来源信息存储在相关关系中。具体是哪份文档、哪个版本、以及信息采集的时间。
确保数据提取过程具有幂等性。对同一份文档重新进行数据处理时,不应产生重复的数据,而MERGE操作正是通过识别属性来实现这一点的。
为什么人工智能系统会不断重新生成图表
正是这一点,使得那些从未接触过图表的人也能理解它们的意义。
让语言模型访问数据的标准方法是将文档转换成向量形式,然后检索与问题最相似的部分。这种方法效果不错,但也存在一些特定且可预测的问题。
相似性检索能够告诉你两件事物之间存在关联,但它无法说明这种关联的具体方式。
正因如此,单独使用这两种方法都是不够的,我们需要知道应该以什么样的顺序将它们结合起来使用。
如果仅使用向量搜索,会找到四份与问题相关的文档,但其中没有任何一份包含答案。从“事件”到“服务”,再到“负责人”和“团队”,这一整个链条分布在这四份文档中,因此没有哪一份文档能够完整地涵盖这个信息,也没有任何一部分数据的得分足够高以至于可以被单独提取出来。
如果仅使用图谱遍历,一旦开始操作就能得到准确的结果:第一步可以从“事件”直接找到“支付”和“结账”相关的内容,第二步可以找到与“Ada”和“Grace”相关的信息,第三步就可以找到“平台团队”的相关信息。但问题在于如何开始这个遍历过程,因为“昨晚的支付事件”只是一个短语,并不是一个节点,而图谱中并没有存储过这样的信息。
如果将这两种方法按正确的顺序结合起来使用:首先将问题转换成向量形式,找出它所涉及的具体实体;然后从这些实体出发进行图谱遍历,因为相关关系是存储在这些实体中的,所以可以直接读取这些信息,而无需进行推理;最后返回一组精确且带有来源信息的数据,而不是五段内容松散相关的文字。
相似性检索能够告诉你两件事物之间存在关联,但它无法说明这种关联的具体方式,因此当需要深入分析原因时,这些答案往往就变成了基于猜测的结论。
如果问“关于昨晚的支付事件,我应该和谁联系”,向量存储系统会返回那些与这个问题最相似的文档片段。然而,它无法反映出这个事件影响了一项服务、这项服务由某位工程师负责、而这位工程师又属于某个团队这些信息。每一条这样的信息可能都存在于不同的文档中,因此没有任何一份文档能够完整地包含这一整个链条。p>图能够明确地存储这些信息链条。对于那些需要多步骤分析的问题,可以通过图结构进行遍历,从而得到确切的答案,而不仅仅是猜测。
p>这两种方法并不是竞争对手,将它们视为对手其实是一个错误。在实际应用中,应该将两者结合起来使用:
任务
最佳工具
原因
从模糊的语言表述中找出切入点
向量搜索
能够处理图结构中从未出现过的表达方式
从该切入点开始,遍历图结构以获取相关信息
图结构
各种关系都是预先存储在图中,而非通过推理得出的
回答“哪些事物相互关联,以及这种关联的方式是什么”
图结构
路径本身就构成了查询的内容
回答“这段文字表达了什么意思”
向量搜索
文本本身就是答案
p>在实际应用中,应该按照这样的步骤来操作:首先利用向量搜索确定问题所涉及的具体“实体”,然后通过图结构遍历这些实体,从而构建出完整的上下文信息,再将这些信息传递给模型进行处理。
p>Neo4j也可以存储向量数据,这样就可以将这两种处理方式整合到同一个系统中。你可以为包含嵌入信息的属性创建一个向量索引:
CREATE VECTOR INDEX service_notes IF NOT EXISTS
FOR (s:Service) ON (sembedding)
OPTIONS {indexConfig: {
`vector.dimensions`: 1536,
`vector.similarity_function`: 'cosine'
}}
p>这样,混合查询就可以通过一次操作完成:向量搜索用于确定问题的切入点,而图结构则用于获取其余的信息。
def context_for_question(driver, question_embedding, k=3):
records, _, _ = driver.execute_query(
"""
// 1. 向量搜索用于确定问题所涉及的服务
CALL db.index.vector.queryNodes('service_notes', $k, $embedding)
YIELD node AS s, score
// 2. 图结构能够提供向量搜索无法提供的信息:各种事物之间的关联关系
OPTIONAL MATCH (s)<-[:OWNS]-(owner:Engineer)-[:MEMBER_OF]->(t:Team)
OPTIONAL MATCH (s)<-[:AFFECTS]-(i:Incident)
RETURN s.name AS service, score,
collect(DISTINCT owner.name) AS owners,
collect(DISTINCT t.name) AS teams,
collect(DISTINCT i.ref) AS incidents
ORDER BY score DESC
""",
embedding=question_embedding, k=k, database_="neo4j",
)
return [dict(r) for r in records]
p>让我们来看看这两种方法各自的作用。向量索引能够帮助我们确定“这个问题似乎是关于哪些服务的”,而仅靠图结构是无法做到这一点的,因为用户的表述方式可能与图结构中的节点名称不匹配。
p>然后,通过图结构的遍历,我们可以得到“这些服务由谁拥有、属于哪些团队、最近发生了什么事情”等信息,而这些信息也是向量搜索无法提供的,因为它们分散在不同的文档中,没有哪一份文档能够完整地包含这些信息链条。
p>最终传递给模型的信息是一组结构清晰、相互关联的信息,而而不是一些内容松散、缺乏逻辑关系的文字。通常来说,这就是答案与随意猜测之间的区别所在。关于输出结果中诚实性的说明:由于所有信息都来源于图表,因此你可以直接引用这些数据。将关系来源与具体事实一同呈现出来,可以让模型明确指出每一条信息的出处;当模型出现错误时,你也可以据此进行检查。
同样的原理也解释了为什么人工智能代理的持久性记忆结构最终会呈现出图形的形态。
这个例子涉及三条信息:note-03表示“我们决定使用Mongo来存储支付相关数据”,note-09表示“Mira将支付功能迁移到了Postgres上”,note-14则表示“已经审查了支付数据的存储方式,但没有采取任何行动”。当被问到“支付数据是存储在哪个数据库中”时,这三条信息作为松散的文本来看,似乎都同样相关,因此智能代理会随机选择其中一条来回答。
如果将这些信息以图形的形式呈现出来,新的决策节点use Postgres会有一条SUPERSEDES边指向Mongo相关的决策节点,还会有一条APPLIES_TO边指向支付服务相关节点。这种在文字描述中难以体现的逻辑关系,现在被转化为图表形式,智能代理就可以据此进行操作了。
一个具有记忆功能的智能代理需要知道:某个决策是谁做出的、它取代了之前的什么决策、以及哪些后续操作依赖于这个决策。这些都属于带有方向性和特定属性的关系信息。如果将这些信息以松散的文本形式存储起来,然后期望通过相似性搜索来重新构建这些关系,那么智能代理就很有可能出现自相矛盾的情况。
这一切都不需要掌握新的技能。其实这就是本手册前面部分介绍的建模方法,只不过现在是将这些方法应用于从文本中提取出的信息,而不是表格中的数据而已。正因为如此,建模相关的内容才值得再次阅读。
从文本构建知识图谱
到目前为止,所有的信息都是以结构整齐的Python字典形式呈现出来的。而真正的知识图谱通常是通过分析散文形式的文字资料来构建的,比如事故报告、维基页面、工单记录、提交信息以及支持论坛的内容等。
在信息提取阶段,人们要么能够构建出持久性强的知识结构,要么就会得到一团混乱的数据。有三条规则可以帮助我们确保提取出的信息具有持久性,下面来看看这些规则在处理流程中的位置:
原始文本首先会被送入提取器,提取器会生成候选的实体和关系信息,这些信息随后会被合并到知识图谱中。这些处理步骤是可分离的,这一点非常重要,因为提取器是你可以随时更换或重新运行的部分。
请注意检查机制的位置:模式验证是在任何数据被写入之前进行的,而不是之后。一旦某种人为定义的关系类型被添加到知识图谱中,它就与真实存在的关系类型无法区分了,到时候你就只能手动去清理这些错误信息了。
规则#1:必须按照固定的模式进行提取,不能随意操作
如果你允许提取工具自行定义关系类型,最终会遇到OWNS、owns、IS_OWNER_OF和RESPONSIBLE_FOR这些关系类型都表示相同的意思,而且没有任何查询能够同时检索到这四种关系类型。
首先确定你要使用的词汇表,然后让提取工具从这个词汇表中选择相应的关系类型:
NODE_LABELS = ["Engineer", "Service", "Incident", "Team"]
REL_TYPES = ["OWNS", "AFFECTS", "MEMBER_OF", "DEPENDS_ON"]
无论使用哪种方法进行数据提取(无论是语言模型、正则表达式还是人工操作),其核心任务都是生成仅使用这些预先定义的词汇的关系三元组。任何其他形式的表示方式都会被拒绝,而不会被纳入结果中。
规则#2:每个提取出的事实都应标明其来源
从左到右,这个过程分为三个阶段:文档被输入系统后,提取工具会使用预先定义的词汇表生成关系三元组;随后系统会合并这些数据,并记录下每个事实的具体来源。
这张图重点说明了ON CREATE与ON MATCH之间的区别:当一个事实首次被创建时,它的来源信息会被一次性记录下来;而每当同一个事实再次被检测到时,系统会更新其“最后被看到”的时间戳。这样一来,即使对同一份文档重新进行提取操作,也不会覆盖原有的来源信息。
这种设计在文档内容出现错误时显得非常有用,因为通过查询来源属性,可以一次性撤销所有与该错误信息相关的事实记录。人们常常忽略的一点是置信度评分的设置:虽然应该将其保存下来,但在实际使用中却往往不会加以利用——毕竟0.4这样的置信度值显然不能被视为确凿的事实。
当数据是由人工输入时,你可以直接询问相关人员;但当数据是由机器提取出来的时候,你就无法这样做了。最终,总会有有人会提出疑问:“为什么系统认为Ada拥有‘checkout’这个权限?”
def write_triple(driver, subject_email, rel_type, object_name, source_doc, confidence):
if rel_type not in REL_TYPES:
raise ValueError(f"未知的关系类型:{rel_type}")
driver.execute_query(
f"""
MERGE (e:Engineer {{email: $subject}})
MERGE (s:Service {{name: $object}})
MERGE (e)-[r:{rel_type}]->(s)
ON CREATE SET r.source = $source,
r.confidence = $confidence,
r.extracted_at = datetime()
ON MATCH SET r.last_seen = datetime()
"""
, subject=subject_email, object=object_name,
source=source_doc, confidence=confidence,
database_="neo4j"
)
关于上面的代码片段,有两点需要注意。
在Cypher语言中,关系类型是唯一不能作为参数传递的内容。因此,-[r:$type]->这种写法是不被允许的,所以它必须被嵌入到字符串中才能被使用。
正是这种模式导致了注入漏洞的出现,因此上面的 if rel_type not in REL_types 这一检查机制实际上起到了保护作用——它确保了代码的安全性。只有通过这一检查,才能保证字符串生成过程的安全性;绝对不能在未先将模型输出与固定列表进行比对的情况下,直接使用模型的原始输出来构建字符串。ON CREATE和ON MATCH允许你一次性记录信息的来源,而每次提取数据时都会更新其新鲜度信息。这意味着,对同一份文档重新执行提取操作不会覆盖原有的数据源。
规则#3:确保重新提取操作的安全性
有时你需要重新执行提取操作,因为文档可能已经被修改,或者你的查询语句得到了优化,又或者某个错误被修复了。但如果第二次提取操作会重复生成所有数据,那么这样的图结构就毫无意义了。
由于上述所有的写操作都是针对具有唯一标识性的属性进行的MERGE操作,因此从设计上来说,重新执行这些操作是安全的。这与在数据加载阶段提到的幂等性原理是一样的,而在这里这种性质显得尤为重要。
如果需要从已经发生变化的文档中提取信息,可以这样操作:
MATCH ()-[r]->()
WHERE r.source = $source_doc
DELETE r
之后再重新执行提取操作。之所以能够通过来源字段来删除数据,是因为你之前已经存储了这些来源信息,而这正是规则二所强调的必要性。
关于置信度的注意事项
如果你的提取工具能够生成置信度分数,那么请务必将其保存下来,并且在实际查询中加以使用。如果一个图结构中同时包含了人类确认过的事实以及模型仅以0.4的置信度推测出的事实,而在查询时对它们一视同仁地进行处理,那么就会得到错误的、但看似“正确”的结果。
MATCH (e:Engineer)-[r:OWNS]->(s:Service)
WHERE r.confidence IS NULL OR r.confidence > 0.8
RETURN e.name, s.name
r.confidence IS NULL这条语句用于保留那些人工输入的信息,因为这些信息本来就没有置信度分数。
完整的脚本
下面是将本手册中的所有内容整合成一个可执行的文件。这个脚本会创建各种约束条件,加载数据,并解答引言中提出的问题。如果你一直按照步骤来操作,那么现在这个文件就包含了所有的必要代码。
"""一个端到端的简单知识图谱构建示例。"""
import os
from neo4j import GraphDatabase
URI = os.environ.get("NEO4J_URI", "bolt://localhost:7687")
AUTH = (
os.environ.get("NEO4J_USER", "neo4j"),
os.environ["NEO4J_PASSWORD"],
)
CONSTRAINTS = [
"CREATE CONSTRAINT engineer_email IF NOT EXISTS FOR (e:Engineer) REQUIRE e.email IS UNIQUE",
"CREATE CONSTRAINT service_name IF NOT EXISTS FOR (s:Service) REQUIRE s.name IS UNIQUE",
"CREATE CONSTRAINT incident_ref IF NOT EXISTS FOR (i:Incident) REQUIRE i.ref IS UNIQUE",
"CREATE CONSTRAINT team_name IF NOT EXISTS FOR (t:Team) REQUIRE t.name IS UNIQUE",
]
PEOPLE = [
{"email": "ada@example.com", "name": "Ada Okonjo", "service": "payments", "team": "Platform"},
{"email": "grace@example.com", "name": "Grace Lin", "service": "payments", "team": "Platform"},
{"email": "linus@example.com", "name": "Linus Berg", "service": "checkout", "team": "Commerce"},
{"email": "mira@example.com", "name": "Mira Haddad", "service": "auth", "team": "Platform"},
{"email": "tom@example.com", "name": "Tom Ferreira", "service": "search", "team": "Discovery"},
]
# 定义一个没有负责任何服务的工程师,这样示例中的可选匹配操作才能有结果。如果没有这个工程师,那个查询语句就会和普通的MATCH语句没有任何区别。
UNASSIGNED = {"email": "nadia@example.com", "name": "Nadia Rossi"}
# 定义服务之间的依赖关系
DEPENDENCIES = [
{"upstream": "auth", "downstream": "payments"},
{"upstream": "auth", "downstream": "checkout"},
{"upstream": "payments", "downstream": "checkout"},
{"upstream": "search", "downstream": "checkout"},
]
INCIDENT = {"ref": "INC-4471", "summary": "卡支付过程中出现了5xx错误",
"services": ["payments", "checkout"]}
def setup(driver):
"""首先创建约束条件。这些约束条件能够确保数据结构的正确性,并生成索引,从而避免MERGE操作扫描所有节点。"""
for statement in CONSTRAINTS:
driver.execute_querystatement, database_="neo4j")
def load(driver):
"""先加载人员信息和团队信息,然后是未分配任务的工程师的信息,接着是服务之间的依赖关系,最后是事件信息。整个数据集需要执行四次这样的操作。"""
driver.execute_query(
"""
UNWIND $rows AS row
MERGE (e:Engineer {email: row.email})
SET e.name = row.name
MERGE (s:Service {name: row.service})
MERGE (t:Team {name: row.team})
MERGE (e)-[:OWNS]->(s)
MERGE (e)-[:MEMBER_OF]->(t)
""",
rows=PEOPLE, database_="neo4j",
)
driver.execute_query(
"MERGE (e:Engineer {email: $email}) SET e.name = $name",
**UNASSIGNED, database_="neo4j",
)
driver.execute_query(
"""
UNWIND $rows AS row
MATCH (u:Service {name: row.upstream}), (d:Service {name: row.downstream})
MERGE (d)-[:DEPENDS_ON]->(u)
""",
rows=DEPENDENCIES, database_="neo4j",
)
driver.execute_query(
"""
MERGE (i:Incident {ref: $ref}) SET i.summary = $summary
WITH i
UNWIND $services AS svc
MATCH (s:Service {name: svc})
MERGE (i)-[:AFFECTS]->(s)
""",
**INCIDENT, database_="neo4j",
)
def who_has_context(driver, ref):
"""回答引言中提出的问题,使用一种统一的查询模式。"""
records, _, _ = driver.execute_query(
"""
MATCH (i:Incident {ref: $ref})-[:AFFECTS]->>(:Service)<-[:OWNS]-(e:Engineer)
RETURN DISTINCT e.name AS name, e.email AS email
ORDER BY name
""",
ref=ref, database_="neo4j",
)
return [dict(r) for r in records]
def teams_involved(driver, ref):
"""故意将查询分为两种模式。如果使用单一的模式,就会违反关系唯一性的规则,导致那些唯一的团队被忽略掉。」
records, _, _ = driver.execute_query(
"""
MATCH (i:Incident {ref: $ref})-[:AFFECTS]->>(:Service)<-[:OWNS]-(:Engineer)-[:MEMBER_OF]->(t:Team)
WITH DISTINCT t
MATCH (t)<-[:MEMBER_OF]-(e:Engineer)
RETURN t.name AS team, collect(e.name) AS members
ORDER BY team
""",
ref=ref, database_="neo4j",
)
return [dict(r) for r in records]
def main():
with GraphDatabase.driver(URI, auth=AUTH) as driver:
driver.verify_connectivity()
setup(driver)
load(driver)
print("与事件INC-4471相关的工程师信息:")
for row in who_has_context(driver, "INC-4471"):
print(f" {row['name']:<14} {row['email']}")
print("\n涉及的团队信息:")
for row in teams_involved(driver, "INC-4471"):
print(f" {row['team']:<10} {', '.join(row['members'])}")
if __name__ == "__main__":
main()
请在环境中使用您的密码来运行程序,而不是将密码写入文件中:
export NEO4J_PASSWORD='your-password'
python3 knowledge_graph.py
注意要使用方括号来定义变量 `os.environ["NEO4J_PASSWORD"]`,而不是使用 `.get()` 方法。这样设计是有意为之的——如果该变量不存在,程序在启动时会发出明显的错误提示,而不会试图使用 `None` 值进行连接操作,从而避免出现令人困惑的认证错误。
下一步该做什么
现在您已经掌握了所有关键要素:一个可以用来构建数据模型的基础结构、一个可以安全地重复运行的加载脚本、一些用于查询数据的有效方法、能够提高数据访问速度的索引系统,以及一种用于诊断程序运行缓慢原因的工具。
以下是关于如何进一步利用这些知识的三个建议:
首先从您已经熟悉的主题入手进行建模。建模本身就是整个过程中最困难的部分,而当您事先就已经清楚数据应该用来回答哪些问题时,判断模型是否正确就会变得容易得多。您自己的代码库、团队的服务项目,或者您正在阅读的资料,都是比下载来的数据集更适合作为初始建模对象的选择。
在构建模型之前先明确需要解决的具体问题。花十分钟时间来思考这些问题,可以避免后续需要重新编写代码。这一习惯在整个指南中都具有极高的实用性。
然后尝试使用非人类主体作为数据模型的测试对象。一旦您正确地建立了数据模型,将其与语言模型结合起来进行应用,其实会是一件相对简单的事情——因为真正困难的部分从来都不是建模本身,而是弄清楚各种数据元素的具体含义以及它们之间的关联关系。
相关的代码仓库地址是 https://github.com/ronidas39/knowledge-graph-python-neo4j。该仓库包含了完整的脚本、包含75,500个节点的数据集(以CSV格式保存)、本文中所有测试结果所依据的基准数据,以及一个能够对所有39个Cypher查询语句进行解释分析的工具。您可以克隆这个仓库,运行 `verify_dataset.py` 脚本,确认您的数据是否符合我们的标准,然后再信任任何测量结果。
如果您想深入了解更多内容,我会在 systemdesign.academy 上撰写关于系统设计的文章,并在 我的YouTube频道 上发布更深入的工程教程。
相关文章
为什么你的可穿戴设备需要收集数周的数据后才能真正发挥作用?
我还能清楚地记得第一次戴上Oura戒指,第二天早上查看自己的状态得分时的情景——那一刻,我觉得自己就像在查看考试成绩一样。 我看到得分大概是62分,顿时慌了神:难道是我生病了吗?还是压力太大了?又或者是睡眠姿势不对导致的? 但实际上,这些根本无关紧要,因为这款戒指根本不了解我的具体情况。它所掌握的关于我的信息仅仅是一晚上的数据而已。而我却把这些数据当成了绝对可靠的依据。 对于那些购买了智能手表或智能手环,却发现它们无法立刻了解自己的健康状况而感到失望的人来说,这款产品非常适合你们。事实上,在使用的第一周内,你们的可穿戴设备并没有出现故障,只是它还没有真正掌握你的健康数据而已。 目录 使用初期的
阅读全文
Flutter中的低功耗蓝牙技术:开发者手册
大多数Flutter教程都只涉及到网络调用和REST API。但一旦你需要与物理设备进行交互——比如心率监测器、智能灯泡、健身追踪器、工业传感器,或者你自己定制的硬件设备——你就不得不离开HTTP这个“舒适的环境”,转而使用蓝牙低功耗技术。 本指南会教你如何在Flutter中正确且全面地实现这些功能。 移动设备上的蓝牙功能其实相当复杂。Android和iOS之间的权限设置有所不同,即使是同一款Android系统的不同版本,权限要求也会存在差异。蓝牙连接的生命周期包含许多状态,服务与特征的数据模型也会让新手感到困惑,而字节级的数据编码方式几乎会让每个人在初次尝试时遇到麻烦。 flutter_bl
阅读全文
如何在SQL中使用子查询
每当你在SQL中看到一个查询嵌套在另一个查询内部时,这就是子查询。子查询也被称为内查询,而包含它的那个查询则被称为主查询或外查询。 子查询的作用是为主查询提供额外的数据,这些数据可以以派生列或派生表的形式出现,或者它们也可以用来过滤主查询返回的行。 对于刚开始学习SQL的初学者来说,子查询可能相当难以理解。本文将帮助大家简化这一概念,使其更易于理解。读完这篇文章后,你应该能够更加熟练地使用子查询来解决问题了。 目录 先决条件 子查询的工作原理 执行顺序 子查询的类型 非相关子查询 相关子查询 结论 先决条件: 子查询属于高级SQL概念,因此,必须牢固掌握SQL的基础知识,包括SELECT、FR
阅读全文
在React中处理高频实时数据:从环形缓冲区到离屏canvas技术
React在很多方面都表现得非常出色。但如果你曾经尝试过每秒向它传输数千个数据点,你就会很快意识到:React并不像一根能输送大量水流的消防水管,而更像是一根普通的花园浇水软管。 如果强迫它处理过多的数据,要么会导致“草坪被淹没”(即DOM结构变得混乱),要么会使得“管道爆裂”(也就是应用程序运行出现严重问题)。 还有另一个与上述观点相关的观察结果:你的笔记本电脑通常拥有8到16个CPU核心,而你的React应用程序几乎总是只使用其中的一个核心。主线程负责处理JavaScript代码、DOM操作、布局计算以及绘制工作;而其他核心则处于闲置状态,因为主线程实在难以维持每秒60帧的渲染速度。 这两
阅读全文