随着Neo4j 5 LTS的发布,让我们来看看它在Cypher功能方面提供了哪些看似微不足道但实际上影响巨大的改进。关于那些备受瞩目的功能——如并行运行时、块格式、量化路径模式、向量索引、变更数据捕获以及性能改进——已经有很多讨论,但那些宣传较少的改进又如何呢?在某些情况下,它们的影响甚至可能比你经常听到的那些功能还要大。
Cypher的Unicode规范化
假设你有一个数据库,比如客户数据库,它是随着时间推移而建立的。数据来自各种工具,有些可能是从其他系统导入的。
让我们用下面的代码创建这个图的一小部分。要尝试这个,请将代码复制并粘贴到Neo4j查询或浏览器中,因为你无法自己手动输入:
CREATE (a1:Address {street: "Fake street 3B", city: "Malmö"})
CREATE (a2:Address {street: "Imaginary Alley 116", city: "London"})
CREATE (a3:Address {street: "Fake street 6A", city: "Malmö"})
CREATE (c1:Customer {name: "Jason Bourne"})
CREATE (c2:Customer {name: "Sarah Connor"})
CREATE (c3:Customer {name: "James Bond"})
CREATE (c4:Customer {name: "Ned Stark"})
CREATE (c1)-[:LIVES_AT]->(a1)
CREATE (c2)-[:LIVES_AT]->(a3)
CREATE (c3)-[:LIVES_AT]->(a2)
CREATE (c4)-[:LIVES_AT]->(a1)
请注意,上述语句使用了Cypher中传统上使用的CREATE,但也支持新的符合GQL标准的INSERT。
现在列出所有客户以及他们居住的城市:
MATCH (c:Customer)-[:LIVES_AT]->(a:Address)
RETURN c.name AS Customer, a.city as City
结果将会是:
Customer City
"Jason Bourne" "Malmö"
"Sarah Connor" "Malmö"
"James Bond" "London"
"Ned Stark" "Malmö"
因此,我们有三位客户在马尔默,一位在伦敦。但希望对于我们的业务来说,客户数量远不止这四个;此外,我们假设我们有一个长期以来构建的大型数据库。因此,逐行阅读以查看谁住在马尔默可能并不实际,而且毕竟,过滤是大多数查询语言都非常擅长的事情。所以,我们只查询那些住在马尔默的客户:
MATCH (c:Customer)-[:LIVES_AT]->(a:Address)
WHERE a.city = "Malmö"
RETURN c.name AS Customer, a.city as City
结果:
Customer City
"Jason Bourne" "Malmö"
"Ned Stark" "Malmö"
等等——莎拉怎么了?她也住在马尔默啊!原因,你可能已经猜到了,就是Unicode的问题。同一个字符在Unicode中可以用不同的方式表示。第一个马尔默地址(a1)使用了‘ö’的标准表示法,即十六进制F6(“\u00F6”),而第二个则使用了一个标准的‘o’(“\u006F”),后面跟着一个修饰符来添加点(“\u0308”)。
Unicode在这方面是不可预测的。还有其他字符有更多的表示方式,比如‘Å’,它有三种表示方式(“\u00C5”,“\u212B”,和“\u0041\u030A”)。为了解决这个问题,Cypher引入了normalize()函数,你可以这样使用它:
MATCH (c:Customer)-[:LIVES_AT]->(a:Address)
WHERE normalize(a.city) = normalize("Malmö")
RETURN c.name AS Customer, normalize(a.city) as City
这次,我们得到了预期的结果:
Customer City
"Jason Bourne" "Malmö"
"Sarah Connor" "Malmö"
"Ned Stark" "Malmö"
这可能看起来是一件小事,但如果你有一个包含大量未规范化字符串的大型数据库,这可能会成为一个很大的痛点。
基于属性的访问控制
基于角色的访问控制(RBAC)已经是Neo4j多个版本的一部分,但现在你也可以基于节点的属性来控制访问。这值得再举一个例子。
我们的人力资源记录作为一个图,其中有一个名为Employee的节点标签。Employee有四个属性:姓名、薪水、角色和部门。任何人都应该能够读取姓名、角色和部门,但只有部门主管应该能够读取薪水。在这样的设置下,重要的是部门主管没有对部门属性的写入权限,否则将很容易绕过这一限制。
我们在公司中添加了几名员工:
CREATE (:Employee {name: "Dilbert", department: "Engineering", role: "Software Engineer", salary: 100000})
CREATE (:Employee {name: "Alice", department: "Engineering", role: "Software Engineer", salary: 100000})
CREATE (:Employee {name: "Wally", department: "Engineering", role: "Software Engineer", salary: 80000})
CREATE (:Employee {name: "Asok", department: "Engineering", role: "Intern", salary: 50000})
CREATE (:Employee {name: "Pointy-Haired Boss", department: "Engineering", role: "Engineering Manager", salary: 150000})
CREATE (:Employee {name: "Catbert", department: "HR", role: "HR Manager", salary: 150000})
CREATE (:Employee {name: "Tina", department: "Documentation", role: "Technical writer", salary: 100000})
CREATE (:Employee {name: "Dogbert", department: "Executive", role: "World Dominator", salary: 10000000})
然后我们创建一个EngineeringManager角色,该角色只对员工有读取权限,并且只有当部门是“Engineering”时才能看到薪水。
CREATE ROLE EngineeringManager IF NOT EXISTS;
GRANT ACCESS ON DATABASE * TO EngineeringManager;
GRANT MATCH {*} ON GRAPH * TO EngineeringManager;
DENY READ {salary} ON GRAPH * FOR (n:Employee) WHERE n.department <> "Engineering" TO EngineeringManager;
CREATE USER pointyhairedboss IF NOT EXISTS SET PASSWORD 'password' CHANGE NOT REQUIRED;
GRANT ROLE EngineeringManager TO pointyhairedboss;
(没错,Pointy-Haired Boss可能会用“password”作为密码。)
如果我们登出后再以pointyhairedboss的身份登录,然后尝试列出员工以查看薪水,我们会得到:
MATCH (e:Employee)
RETURN e.name AS Employee, e.department AS Department, e.salary AS Salary
Employee Department Salary
"Alice" "Engineering" 100000
"Wally" "Engineering" 80000
"Asok" "Engineering" 50000
"Pointy-Haired Boss" "Engineering" 150000
"Catbert" "HR" null
"Tina" "Documentation" null
"Dogbert" "Executive" null
"Dilbert" "Engineering" 100000
看起来这个设置是有效的,因为我们看不到非工程部门员工的薪水。而且,如果我们尝试在浏览器中查看节点,我们也看不到薪水属性。
出于安全考虑,可能值得测试一下我们是否能更改Catbert的部门,以查看我们是否能绕过这条规则:
MATCH (e:Employee {name: "Catbert"}) SET e.department = "Engineering"
但是,正如预期的那样,我们被基于属性的访问控制阻止了:
Neo.ClientError.Security.Forbidden
Set property for property 'department' on database 'hr' is not allowed for user 'pointyhairedboss' with roles [EngineeringManager, PUBLIC].
动态标签
我们都知道,并非所有查询都是静态的。如果一个应用程序每次执行都完全相同,那它就不会太吸引人。如果我们通过名字搜索一个人,而要搜索的名字是用户输入的,那么一种实现方式可能是:
executeQuery("MATCH (p:Person) WHERE p.name = \"" + name + "\" RETURN p");
然而,正如我们所有人从xkcd中学到的那样,这是一个坏主意,因为它使我们容易受到Cypher注入攻击。有人可能会输入:
Robert" RETURN p; MATCH (n) DETACH DELETE n //
作为他们的名字,这将使查询产生完全不同的结果。
解决方案是在客户端使用参数而不是字符串拼接:
MATCH (p:Person) WHERE p.name = $name RETURN p
然而,在Neo4j 5之前的版本中,你无法为标签名称使用参数。你不能这样做:
MATCH (p:$($label)) WHERE p.name = $name RETURN p
但现在你可以了。能够以一种防注入的安全方式拥有动态标签,而无需在客户端进行变量清理和字符串拼接,这是至关重要的。但在其他情况下,这也很方便——例如,如果我们想使用LOAD CSV命令从CSV文件中导入数据,并且标签名称是字段之一时:
LOAD CSV WITH HEADERS FROM 'file:///names.csv' AS line
CALL {
WITH line
CREATE (p:$(line.profession) {id: line.id, name: line.name})
} IN TRANSACTIONS
尽管在Neo4j 5之前的版本中也可以通过CASE语句来实现这一点,但那需要编写更多的代码。而且,每当添加新的职业时,都需要更新该语句,而上述方法则不需要。
有人可能会说,CASE变体可以提供更多保护,防止CSV中出现格式错误的数据。好吧,请稍安勿躁,很快你就会有一个可选的图模式来解决这个问题了。
请注意,在Neo4j 5之前,你可以通过使用Cypher上的Awesome Procedures(APOC)来实现动态标签,尽管在语言中原生支持它有很多好处。它不仅使代码更可读、更易于使用,而且查询规划器能够感知并优化,这是自定义过程所不具备的。
类型谓词表达式
Neo4j 5中一个微妙但强大的新增功能是检查属性类型的能力。你可以检查属性是否为某种类型,并获取类型的字符串表示。
为了演示这一点,我们将设想有一个没有模式(至少没有已知模式)的图,我们想用它来进行GraphRAG(见下文)。也许这个图是由AI服务从无结构数据中创建的,或者我们只是还没有收到关于所有节点类型及其可能具有的属性的描述。
如果你不知道GraphRAG是什么,它是关于使用LLM聊天机器人(例如ChatGPT)来询问你自己的数据集的问题。你将知识图的部分内容提供给聊天机器人,并从这些部分中寻求答案。为了知道哪些部分与你的问题相关,你使用向量搜索。
为了能够做到这一点,我们必须首先对每个节点中的相关文本属性进行向量嵌入。但是,如果我们不知道模式,怎么知道哪个是相关的文本属性呢?正确的答案可能是花一些时间去了解你的图,弄清楚它的内容是什么。但如果你不想这么做,这里有一个捷径:我们简单地假设每个节点中最长的字符串属性就是我们想要嵌入的属性。
为了实现这一点,我们需要知道哪些属性是字符串属性,这就是新的类型谓词发挥作用的地方。以下是我们可以如何做到这一点:
MATCH (n)
CALL(n) {
UNWIND keys(n) AS p
WITH n, p WHERE n[p] IS :: STRING NOT NULL ORDER BY size(p) DESC LIMIT 1
RETURN p
}
WITH collect(n[p]) AS properties, collect(n) AS nodes
CALL genai.vector.encodeBatch(properties, "OpenAI", {token: $apiKey}) YIELD index, resource, vector
CALL db.create.setNodeVectorProperty(nodes[index], "embedding", vector)
OPTIONAL CALL
你已经写了一段时间的Cypher查询,并开始感觉自己很熟练了。然后有一天——可能是一个阳光明媚的春天,鸟儿在歌唱,空气中弥漫着草香——你突然领悟了OPTIONAL MATCH的用途以及它能做什么。
Neo4j 5引入了OPTIONAL CALL,它对CALL的作用就像OPTIONAL MATCH对MATCH的作用一样。
让我们先快速看一下OPTIONAL MATCH是做什么的。我们有一个由Person节点组成的图。其中一些(但不是全部)拥有狗。有些人甚至拥有不止一只狗。
CREATE (q:Person {name: "Queen Elizabeth II"})
CREATE (c:Person {name: "Chris Evans"})
CREATE (k:Person {name: "Kim Kardashian"})
CREATE (m:Dog {name: "Muick"})
CREATE (s:Dog {name: "Sandy"})
CREATE (d:Dog {name: "Dodger"})
CREATE (q)-[:OWNS]->(m)
CREATE (q)-[:OWNS]->(s)
CREATE (c)-[:OWNS]->(d)
现在,我们想列出所有人,并且对于那些养狗的人,我们想要为每只狗列出一行。如果我们只是这样做:
MATCH (p:Person)-[:OWNS]->(d:Dog)
RETURN p.name AS Person, d.name AS Dog
那么我们将完全看不到Kim Kardashian,因为她没有养狗。即使我们将查询拆分为两个MATCH查询,也会发生同样的情况:
MATCH (p:Person)
MATCH (p)-[:OWNS]->(d:Dog)
RETURN p.name AS Person, d.name AS Dog
解决方案是使用OPTIONAL MATCH,即使没有匹配项,它也会始终返回至少一行(作为null):
MATCH (p:Person)
OPTIONAL MATCH (p)-[:OWNS]->(d:Dog)
RETURN p.name AS Person, d.name AS Dog
在这里,我们也将得到Kim Kardashian作为结果,但Dog列将为null。这在Neo4j 5中并不是新功能,但将此机制用于CALL则是新功能。
我住在马尔默,我想去欧洲所有的首都城市旅游,但我也很懒,想先从最容易到达(即驾驶距离最短)的城市开始。我将使用Dijkstra的最短路径算法来获取到每个城市的驾驶距离。但我不想因为无法驾车到达而错过像雷克雅未克这样的城市;当我在列表中看到它们时,我会选择飞行。以下是我如何按照我将访问城市的顺序获取这个列表:
MATCH (m:City {name: "Malmö"})
MATCH (c:City {capital: true, continent: "Europe"})
OPTIONAL CALL apoc.algo.dijkstra(m, c, "ROAD", "distance") YIELD path, weight
RETURN c.name AS City, weight AS Distance ORDER BY weight
如果没有OPTIONAL CALL,雷克雅未克以及所有与马尔默没有陆路连接的首都都将被排除在结果之外。
OPTIONAL CALL 也适用于子查询
MATCH (p:Person)
OPTIONAL CALL(p) {
MATCH (p)-[:OWNS]->(d:Dog)
RETURN d
}
RETURN p.name AS Person, d.name AS Dog
这与前面提到的OPTIONAL MATCH示例效果相同,在这种情况下,OPTIONAL MATCH是更好的方法,但这里只是为了展示OPTIONAL CALL是如何工作的。
POINT索引
Neo4j 5引入了新的索引类型——向量索引(如我们在本文前面所见,用于生成式AI操作,如向量嵌入/向量搜索和GraphRAG)。在之前的版本中,确实存在一些其他索引——范围索引和点索引——但在Neo4j 5之前,它们并未被用于查询计划,而现在你可以充分享受它们带来的性能提升。
点索引的一个应用场景可能是在我们想要可视化图形时,数据太多以至于无法很好地全部呈现,但节点具有地理位置(无论是地图上的二维位置还是空间中的三维位置),并且并非所有节点都同时可见。这可以应用于我们在地图上移动时,只想查看当前视野内的子集,而不想获取图形中可能在当前视口之外的数十亿个节点。
以下是一个类似场景的描述:我们在三维空间中飞行,并希望渲染当前附近的节点。这使用了与我在GraphRAG文章中相同的IMDb数据集,包含IMDb中的所有电影和人物——总共2400万个节点,数量太多以至于无法很好地可视化。
相反,我为图形做了一个静态布局,为每个节点在空间中分配了一个固定位置。然后,我开发了一个JavaFX三维应用程序,允许我在这个三维空间中飞行,体验电影和人物,同时在后台持续更新三维模型,只包含附近的对象。得益于点索引,尽管数据集庞大,但这一过程可以快速完成。
首先,我给所有我希望参与渲染的节点添加了一个名为Node3D的标签。在我的情况下,这是除了Genre节点之外的所有节点(Genre节点在渲染方面带来了另一种复杂性,因为它们平均每个节点都有近50万个关系)。
MATCH (n:!Genre)
CALL(n) {
SET n:Node3D
} IN TRANSACTIONS
现在,我给所有这些节点在名为graph3d的属性中赋予了一个三维位置。这个属性的类型是在三维笛卡尔空间(SRID 9157)中的一个点,使用任意单位。一开始,我只是给它们分配了一个在球形空间中均匀分布的随机位置:
MATCH (n:Node3D)
CALL(n) {
WITH n,
rand() AS u,
rand() AS v,
rand() AS w
WITH n,
$radius * (u ^ (1.0/3.0)) AS radius,
acos(2*v - 1) AS theta,
2 * pi() * w AS phi
WITH n, radius, theta, phi,
radius * sin(theta) * cos(phi) AS x,
radius * sin(theta) * sin(phi) AS y,
radius * cos(theta) AS z
SET n.graph3d = point({x: x, y: y, z: z})
} IN TRANSACTIONS
在一个大型数据集上,实现更复杂的布局(如基于力的布局)是很困难的。但为了让图形更有生命力,我希望将紧密相关的项放在一起。这个查询将叶子节点围绕其父节点分组成蒲公英形状:
CALL() {
MATCH (n:Node3D)
WHERE COUNT{(n)-[]-(c:Node3D) WHERE labels(n) = labels(c)} = 1
MATCH (n)-[]-(p:Node3D)
WHERE labels(n) = labels(p)
RETURN n,p
UNION
MATCH (n:Node3D)
WHERE COUNT{(n)-[]-(:Node3D)} = 1
MATCH (n)-[]-(p:Node3D)
RETURN n,p
}
CALL(*) {
WITH n, p,
rand()*2.0*pi() AS a1,
rand()*2.0*pi() AS a2
WITH n, p, a1, a2,
sin(a1)*$circle AS y1,
cos(a1)*$circle AS r
WITH n, p, a2, r, y1,
cos(a2)*r AS x1,
sin(a2)*r AS z1
SET n.graph3d = point({
x: p.graph3d.x + x1,
y: p.graph3d.y + y1,
z: p.graph3d.z + z1})
} IN TRANSACTIONS
现在我们来到了索引本身:这个机制使得这种渲染成为可能:
CREATE POINT INDEX graph3d_index FOR (n:Node3D) ON (n.graph3d)
在第一个查询中,我们完成了随机布局,并为整个空间设置了一个半径为125000的球(这仍然只是一个任意单位)。首先,我们将自己(即相机)置于这个球的中心。但是,我们并没有获取所有节点,而只是获取了距离我们当前位置15000范围内的节点:
MATCH (n:Node3D)
WHERE point.distance(n.graph3d, $center) < 15000
RETURN n
这使得我们能够“看到”整个空间半径的12%,但我们所看到的球体体积只占整个球体体积的0.17%,平均而言,这只需要我们获取大约41,500个节点。并且由于使用了点索引,上述查询在几毫秒内就能执行完毕。
在我本地的MacBook上,上述查询在Neo4j 5中耗时半秒。而在Neo4j 4中,尽管已经创建了点索引,但查询耗时8秒。这不是一个科学的基准测试,只是给出一个大致的概念。
当我们获取节点时,我们会记住获取时的位置。一旦我们从这个位置移动了1,000个单位,我们就会在后台线程中再次调用上述查询,以更新我们本地模型中的节点(丢弃那些移出本地空间的节点)。
在JavaFX中,可见性设置为13,000——这是移动距离的两倍且低于获取半径,这样当我们移动时,物体就会平滑地出现在我们的视线中,而后台获取过程则不可见。
我们还有一个问题需要解决:渲染关系。这比渲染节点要困难得多。一个节点要么在我们的可见球体内,要么不在。但即使关系的两个端点都很远,关系也可能穿过我们的可见球体。
为了解决这个问题,我们不得不做出简化:我们只渲染那些起点或终点在可见球体内的关系。即便如此,在这个局部区域中,关系的数量仍然远远多于节点的数量,因此我们将用于获取关系的可见球体半径减小为节点可见球体半径的五分之一。
为了处理关系,我们将上述查询改写为:
MATCH (n:Node3D)
WHERE point.distance(n.graph3d, $center) < 15000
OPTIONAL MATCH (n)-[r]-(:Node3D)
WHERE point.distance(n.graph3d, $center) < (15000/5)
RETURN n, r, startNode(r).graph3d AS startPos, endNode(r).graph3d AS endPos
现在,我们只需要一个JavaFX 3D应用程序,它允许我们在球形空间中飞行,同时我们的本地模型在后台更新,而我们甚至都察觉不到。这一切都要归功于点索引,它使我们能够基于距离快速获取节点。
语法改进
上述描述展示了新的语言结构,这些结构使得在Neo4j 5之前难以完成的事情变得可能。但除此之外,还有很多语言上的改进,只是让语言更加简洁,而不一定引入新的功能。其中一些改进是为了适应新的GQL标准而进行的。
带作用域的CALL
在Neo4j 5之前,你必须以WITH语句开始一个CALL子查询,指定发送到子查询的参数。然而,这个WITH的行为与其他WITH不同——例如,你不能添加WHERE子句。为了做到这一点,你必须在之后直接使用另一个WITH。
MATCH (n)
CALL {
WITH n
WITH n WHERE n.age >= 21
...
}
现在,你可以在CALL语句本身中直接提供参数:
MATCH (n)
CALL(n) {
WITH n WHERE n.age >= 21
...
}
你也可以使用CALL(*)来引用所有在作用域中的参数(就像之前使用WITH *一样),如果没有参数,则可以使用CALL()。
MATCH (c:Child)-[o:OWNS]->(d:Dog)
CALL(*) {
MERGE (c)-[:HAS_PARENT]->(p:Parent)
MERGE (p)-[:HAS_TO_WALK]->(d)
}
更紧凑的CASE语句
在Neo4j 5中,当用户希望CASE表达式中的多个分支具有相同输出时,我们允许使用更紧凑的CASE语句。它还允许更多样化的简单CASE语句,如下所示:
MATCH (x)
RETURN
CASE x.age
WHEN 5, 7, 9 THEN true
WHEN > 20 THEN true
WHEN < 0 THEN true
ELSE false
END
图形模式匹配改进
图形模式匹配(GPM)是Cypher和GQL的核心。它是用于在图中匹配模式的ASCII艺术语法,如()-[]->()。自Cypher诞生以来,这种语法就一直存在,但最近有了相当大的发展。在Neo4j 5中,最大的新增功能是量化路径模式(QPP),这在本文开头已经提及。但还有一些较小的调整,使得模式匹配更加方便使用。
在Neo4j 4中,我们已经增加了在节点模式中进行内联过滤的可能性:
MATCH (p:Person WHERE p.born > 1950) RETURN p
而在Neo4j 5中,现在也可以对关系进行同样的内联过滤:
MATCH (p)-[k:KNOWS WHERE k.since > 2019]->(p2) Return p2
Neo4j 5中的另一个非常实用的图形模式匹配(GPM)功能是标签模式表达式,它允许你执行类似这样的操作:
MATCH (superhero:Marvel|(DCComics & !Batman)) RETURN superhero
就像在所有基于C的语言中一样,| 表示 OR(或),& 表示 AND(与),! 表示 NOT(非)。因此,上面的表达式将匹配具有 Marvel 或 DC Comics 标签的节点(但如果它们同时具有 Batman 标签则除外)。
所有这些改进既可以独立使用,也是我们构建新的量化路径模式功能的基础。