一句话价值:图数据库的概念其实比关系型数据库还少,核心就三样东西。但 Neo4j 里有两个"看起来多余"的设置------公共标签 和别名------恰恰是一张图能不能干活的开关。这篇用一张真实的奶茶图谱把它们讲透。
从一个"查不到"说起
先看一个真实的翻车。我建了一张奶茶知识图谱,里面有这样一条事实:
scss
(台湾奶茶:Type)-[:属于]-(珍珠奶茶:Product)
然后用户问:
台式奶茶的饮品都有哪些配料?
Cypher 是这么写的:
cypher
MATCH (t:Type {name: '台式奶茶'})<-[:属于]-(p:Product)-[:包含]->(i:Ingredient)
RETURN DISTINCT p.name, i.name
结果返回 空数组。
不是语法错了,也不是数据缺失。而是------库里根本没有一个节点叫"台式奶茶",它叫"台湾奶茶"。
属性匹配是精确的,差一个字,就是查不到。这个坑后面几篇会反复用到,先记住它。
要搞懂为什么会这样、以及怎么救,得先回到最底层:图数据库到底是怎么存东西的。
一、图数据库只有三样东西
比你想象的简单:
| 概念 | 是什么 | 奶茶图谱里的例子 |
|---|---|---|
| 节点 Node | 一个实体 | 珍珠奶茶、珍珠、煮制、年轻人 |
| 关系 Relationship | 两个节点之间的连线,有方向、有类型 | (珍珠奶茶)-[:包含]->(珍珠) |
| 属性 Property | 挂在节点或关系上的键值对 | name、description、aliases |
用 Cypher 写出来就是一个节点长这样:
cypher
(台湾奶茶:Type:Entity {
name: "台湾奶茶",
aliases: ["台式奶茶", "台茶"],
description: "台湾奶茶,源自台湾的奶茶流派,以珍珠、芋圆等丰富配料和偏甜的口味著称。",
embedding: [0.12, -0.45, ... 共 1024 个数]
})
拆开看:
台湾奶茶是变量名,只在当前这条语句里有效,不是存储的一部分:Type:Entity是标签(下面细讲){...}里的全是属性
注意最后两行:aliases 和 embedding 不是什么特殊机制 ,它们就是跟 name 一样的普通属性。只不过一个的值是列表,一个是一千多个浮点数的列表。图数据库不关心里面装的是什么。
二、关系:图数据库的立身之本
关系型数据库里,"珍珠奶茶包含珍珠"这个事实表现为两张表 + 一个外键 ,两边的关联是查询时用 JOIN 临时算出来的。
图数据库里,这条关系是一个物理存在的对象:
cypher
MATCH (p:Product {name: "珍珠奶茶"}), (i:Ingredient {name: "珍珠"})
CREATE (p)-[:包含]->(i)
[:包含] 这个方括号里的东西,就是关系------它有类型(包含)、有方向(Product → Ingredient),还能有自己的属性 (比如 {since: "2024"})。
更关键的是它在存储层的实现:每个节点直接存着"我的第一条关系在哪",每条关系存着"我的前后关系在哪"。所以"从 A 走到它的邻居"是一次指针跳转 ,跟全库有多少节点无关。这就是所谓的免索引邻接。
这个原理我单独写过一篇(《图数据库为什么查关系快:免索引邻接》),这里不展开。记住一句话就够:关系是"存出来的",不是"查出来的"。
三、标签:回答"这是什么"
标签(Label)回答一个问题:这个节点属于哪一类?
类比一下:
- MySQL 靠表名 区分:
users表里的行是人,orders表里的行是订单 - 图数据库靠标签 区分:带
:Product标签的节点是奶茶产品,带:Ingredient的是配料
奶茶图谱里有 6 个标签:
python
Product Type Ingredient Method People Entity
前 5 个是业务标签,表达"这是什么"------这就是我们给 LLM 写 prompt 时描述的那套 schema:
diff
节点:
- Product: 奶茶品牌
- Ingredient: 配料
- Type: 奶茶类型
- Method: 制作工艺
- People: 适合人群
关键点来了:一个节点可以同时有多个标签。 这正是让很多人懵的地方。
四、:Entity 是什么------一个"看起来多余"的公共标签
把库里的真实数据捞出来看:
| 节点 | 它的标签 |
|---|---|
| 珍珠奶茶 | ["Product", "Entity"] |
| 台湾奶茶 | ["Type", "Entity"] |
| 珍珠 | ["Ingredient", "Entity"] |
| 煮制 | ["Method", "Entity"] |
| 年轻人 | ["People", "Entity"] |
每个节点都多带了一个 Entity。 这个标签不带任何业务含义,它只说一件事:"我是个实体。"
为什么非加不可?因为 Neo4j 建索引必须指定标签:
cypher
CREATE VECTOR INDEX entity_desc FOR (n:Entity) ON (n.embedding)
^^^^^^^^^ 只能针对某一个标签
如果不加这个公共标签,你就得给 5 个业务标签各建一遍索引:
diff
5 个向量索引(Product 一个、Type 一个、Ingredient 一个......)
+ 5 个全文索引
查询时还要写 5 个 UNION 把它们拼起来。
加了 Entity 之后,建一次就覆盖全部:
cypher
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) |
|---|---|
| 台湾奶茶 | ["台式奶茶", "台茶"] |
| 珍珠奶茶 | ["波霸奶茶", "珍奶"] |
| 珍珠 | ["波霸", "粉圆"] |
| 奶盖 | ["芝士奶盖", "奶泡"] |
| 学生 | ["学生党"] |
它就是一个普通的列表属性------一个东西可以有零个或多个别名。
有了它之后,建索引时把别名一起索引进去:
cypher
CREATE FULLTEXT INDEX entity_fulltext
FOR (n:Entity) ON EACH [n.name, n.aliases, n.description]
于是用户说"台式奶茶",倒排索引里有"台式"这个词 (因为它来自别名表),就能命中 台湾奶茶 这个节点。
对照一下:
| 加上别名之前 | 加上别名之后 |
|---|---|
MATCH (t:Type {name:'台式奶茶'}) → [] |
全文索引命中 → 拿到 台湾奶茶 节点 |
| 三种检索全挂 | 混合检索答对 |
六、这三个概念,别混在一个层面
初学者最容易懵的地方,是把它们当成同一类东西。其实它们分工很明确:
arduino
name 规范名 ------ 图谱的"官方名字",唯一。业务概念
aliases 别名 ------ 用户可能说的其它叫法,可多个,人工维护。业务概念
Entity 公共标签 ------ 让所有实体能被同一个索引一把覆盖。纯技术手段
前两个回答"怎么让人找得到你 ",第三个回答"怎么让索引只建一次"。
想清楚这一层,再看 Neo4j 里任何一张图,都不会懵了。
本篇小结
- 图数据库核心只有三样:节点、关系、属性。
aliases和embedding都只是普通属性,没有特殊机制。 - 关系是物理存储的对象(免索引邻接),不是查询时 JOIN 出来的------这是图数据库快在多跳的根本原因。
- 标签回答"这是什么";一个节点可以有多个标签。
- 公共标签
:Entity是纯技术手段,让索引只需建一次、检索能一把捞住任意类型的实体。 - 别名
aliases是人工维护的业务资产,解决"用户说的词和库里存的词对不上"的问题------台式奶茶能查到台湾奶茶,全靠它。 - 前两个是业务概念,第三个是技术手段,不在一个层面,别混着理解。
系列预告
下一篇往上一走:为什么有了 embedding 还要配关键词检索、还要配图? 答案藏在三类不同的"敏感"里------字面敏感、词义敏感、词汇表敏感,它们各自要一种检索来治。开头的"台式奶茶"会在那里被彻底拆开。