Neo4j:图数据库入门:节点、关系、标

4 阅读7分钟

一句话价值:图数据库的概念其实比关系型数据库还少,核心就三样东西。但 Neo4j 里有两个"看起来多余"的设置——公共标签和别名——恰恰是一张图能不能干活的开关。这篇用一张真实的奶茶图谱把它们讲透。


从一个"查不到"说起

先看一个真实的翻车。我建了一张奶茶知识图谱,里面有这样一条事实:

(台湾奶茶:Type)-[:属于]-(珍珠奶茶:Product)

然后用户问:

台式奶茶的饮品都有哪些配料?

Cypher 是这么写的:

MATCH (t:Type {name: '台式奶茶'})<-[:属于]-(p:Product)-[:包含]->(i:Ingredient)
RETURN DISTINCT p.name, i.name

结果返回 空数组。

不是语法错了,也不是数据缺失。而是——库里根本没有一个节点叫"台式奶茶",它叫"台湾奶茶"。

属性匹配是精确的,差一个字,就是查不到。这个坑后面几篇会反复用到,先记住它。

要搞懂为什么会这样、以及怎么救,得先回到最底层:图数据库到底是怎么存东西的。


一、图数据库只有三样东西

比你想象的简单:

概念是什么奶茶图谱里的例子
节点 Node一个实体珍珠奶茶、珍珠、煮制、年轻人
关系 Relationship两个节点之间的连线,有方向、有类型(珍珠奶茶)-[:包含]->(珍珠)
属性 Property挂在节点或关系上的键值对name、description、aliases

用 Cypher 写出来就是一个节点长这样:

(台湾奶茶:Type:Entity {
    name:        "台湾奶茶",
    aliases:     ["台式奶茶", "台茶"],
    description: "台湾奶茶,源自台湾的奶茶流派,以珍珠、芋圆等丰富配料和偏甜的口味著称。",
    embedding:   [0.12, -0.45, ... 共 1024 个数]
})

拆开看:

  • 台湾奶茶 是变量名,只在当前这条语句里有效,不是存储的一部分
  • :Type:Entity 是标签(下面细讲)
  • {...} 里的全是属性

注意最后两行:aliases 和 embedding 不是什么特殊机制,它们就是跟 name 一样的普通属性。只不过一个的值是列表,一个是一千多个浮点数的列表。图数据库不关心里面装的是什么。


二、关系:图数据库的立身之本

关系型数据库里,"珍珠奶茶包含珍珠"这个事实表现为两张表 + 一个外键,两边的关联是查询时用 JOIN 临时算出来的。

图数据库里,这条关系是一个物理存在的对象:

MATCH (p:Product {name: "珍珠奶茶"}), (i:Ingredient {name: "珍珠"})
CREATE (p)-[:包含]->(i)

[:包含] 这个方括号里的东西,就是关系——它有类型(包含)、有方向(Product → Ingredient),还能有自己的属性(比如 {since: "2024"})。

更关键的是它在存储层的实现:每个节点直接存着"我的第一条关系在哪",每条关系存着"我的前后关系在哪"。所以"从 A 走到它的邻居"是一次指针跳转,跟全库有多少节点无关。这就是所谓的免索引邻接。

这个原理我单独写过一篇(《图数据库为什么查关系快:免索引邻接》),这里不展开。记住一句话就够:关系是"存出来的",不是"查出来的"。


三、标签:回答"这是什么"

标签(Label)回答一个问题:这个节点属于哪一类?

类比一下:

  • MySQL 靠表名区分:users 表里的行是人,orders 表里的行是订单
  • 图数据库靠标签区分:带 :Product 标签的节点是奶茶产品,带 :Ingredient 的是配料

奶茶图谱里有 6 个标签:

Product  Type  Ingredient  Method  People  Entity

前 5 个是业务标签,表达"这是什么"——这就是我们给 LLM 写 prompt 时描述的那套 schema:

节点:
- Product: 奶茶品牌
- Ingredient: 配料
- Type: 奶茶类型
- Method: 制作工艺
- People: 适合人群

关键点来了:一个节点可以同时有多个标签。 这正是让很多人懵的地方。


四、:Entity 是什么——一个"看起来多余"的公共标签

把库里的真实数据捞出来看:

节点它的标签
珍珠奶茶["Product", "Entity"]
台湾奶茶["Type", "Entity"]
珍珠["Ingredient", "Entity"]
煮制["Method", "Entity"]
年轻人["People", "Entity"]

每个节点都多带了一个 Entity。 这个标签不带任何业务含义,它只说一件事:"我是个实体。"

为什么非加不可?因为 Neo4j 建索引必须指定标签:

CREATE VECTOR INDEX entity_desc FOR (n:Entity) ON (n.embedding)
                                   ^^^^^^^^^ 只能针对某一个标签

如果不加这个公共标签,你就得给 5 个业务标签各建一遍索引:

5 个向量索引(Product 一个、Type 一个、Ingredient 一个……)
+ 5 个全文索引

查询时还要写 5 个 UNION 把它们拼起来。

加了 Entity 之后,建一次就覆盖全部:

CALL db.index.vector.queryNodes('entity_desc', 6, $vec)
YIELD node, score
RETURN node.name, labels(node)   // 捞出来的可能是 Product,也可能是 Ingredient

这一行查询不需要知道用户问的是产品还是配料——统一在 Entity 这层捞就完了。

而这恰恰是混合检索能成立的前提:用户问"喝了会上瘾的那种奶茶"时,系统事先并不知道该去哪个业务标签里找。公共标签让"一把捞住所有实体"成为可能。

一句话理解:业务标签是给人和 LLM 看的(这是什么),公共标签是给索引和检索引擎用的(能被一把捞住)。


五、aliases 是什么——别名表

回到开头那个翻车。"台式奶茶"查不到,因为库里叫"台湾奶茶"。

最直觉的解法是让模型去理解"台式≈台湾"。但这条路不可靠——实测用向量检索,"台式奶茶"这个说法只把 台湾奶茶 排到第 7 位,压根进不了候选。

唯一确定的方式是显式登记:你白纸黑字地告诉系统,这两个词指同一个东西。

这就有了别名表:

规范名(name)别名(aliases)
台湾奶茶["台式奶茶", "台茶"]
珍珠奶茶["波霸奶茶", "珍奶"]
珍珠["波霸", "粉圆"]
奶盖["芝士奶盖", "奶泡"]
学生["学生党"]

它就是一个普通的列表属性——一个东西可以有零个或多个别名。

有了它之后,建索引时把别名一起索引进去:

CREATE FULLTEXT INDEX entity_fulltext
FOR (n:Entity) ON EACH [n.name, n.aliases, n.description]

于是用户说"台式奶茶",倒排索引里有"台式"这个词(因为它来自别名表),就能命中 台湾奶茶 这个节点。

对照一下:

加上别名之前加上别名之后
MATCH (t:Type {name:'台式奶茶'}) → []全文索引命中 → 拿到 台湾奶茶 节点
三种检索全挂混合检索答对

六、这三个概念,别混在一个层面

初学者最容易懵的地方,是把它们当成同一类东西。其实它们分工很明确:

name     规范名    —— 图谱的"官方名字",唯一。业务概念
aliases  别名      —— 用户可能说的其它叫法,可多个,人工维护。业务概念
Entity   公共标签  —— 让所有实体能被同一个索引一把覆盖。纯技术手段

前两个回答"怎么让人找得到你",第三个回答"怎么让索引只建一次"。

想清楚这一层,再看 Neo4j 里任何一张图,都不会懵了。


本篇小结

  1. 图数据库核心只有三样:节点、关系、属性。aliases 和 embedding 都只是普通属性,没有特殊机制。
  2. 关系是物理存储的对象(免索引邻接),不是查询时 JOIN 出来的——这是图数据库快在多跳的根本原因。
  3. 标签回答"这是什么";一个节点可以有多个标签。
  4. 公共标签 :Entity 是纯技术手段,让索引只需建一次、检索能一把捞住任意类型的实体。
  5. 别名 aliases 是人工维护的业务资产,解决"用户说的词和库里存的词对不上"的问题——台式奶茶 能查到 台湾奶茶,全靠它。
  6. 前两个是业务概念,第三个是技术手段,不在一个层面,别混着理解。

系列预告

下一篇往上一走:为什么有了 embedding 还要配关键词检索、还要配图? 答案藏在三类不同的"敏感"里——字面敏感、词义敏感、词汇表敏感,它们各自要一种检索来治。开头的"台式奶茶"会在那里被彻底拆开。