🌊 向量库和 ES 都查不出"关系",我只好给奶茶建了一张人脉网

用 Neo4j + Cypher 手搓一个奶茶知识图谱,顺便搞清楚图数据库到底强在哪

先说一个扎心的真相

我做了这么久的 RAG,一直有这么一种恍惚感:

  • Milvus / PgVector 的向量检索,能把"意思相近"的文档捞出来;
  • ElasticSearch 的倒排索引 + BM25,能把"关键词命中"的文档捞出来;
  • 再加一层 ReRank 精排,看起来已经挺完美了。

直到有一天,用户问我:"珍珠奶茶和台式奶茶到底啥关系?"

我:......

向量库沉默了。它俩在向量空间里可能确实挺像,但"像"和"是同一类"完全是两码事。 ES 也沉默了。这俩词确实都命中了,但 ES 只能告诉我"都出现了",没法告诉我"谁属于谁"。

这就是那句写在笔记里、越看越对的话:

它们无法捕捉数据之间的关联关系,只能实现"单点式"检索,难以应对需要挖掘数据内在逻辑、关联链路的场景。

翻译成人话:向量库和 ES 更像是"精准找货"的工具。但如果你想搞清楚货和货之间的关系、货的来龙去脉,它们就抓瞎了。

打个比方:

  • 向量检索 ≈ 拿一张照片去海选相似的人脸;
  • ES 检索 ≈ 拿着名字去点名册上打勾;
  • 但如果你想知道"老王和小李是啥关系 ",你需要的不是点名册,而是一张社会关系网。

而这张"关系网",在数据库界有一个正经的名字:知识图谱。

Neo4j:一个专注"搞关系"的数据库

笔记里对 Neo4j 的定位写得很清楚:

Neo4j 是一款原生图数据库,以节点、关系存储数据,擅长高效查询实体间复杂关联关系。

它最大的特点是------不关注单个数据本身(关系型数据库、Milvus、ES 都关注单个数据),而是专注于存储和挖掘数据之间的关系。

我们熟悉的 MySQL,干的是"用 SQL 查询记录"这件事:

sql 复制代码
SELECT * FROM milk_tea WHERE name = '珍珠奶茶';

你拿到的是一行行孤立的记录。

而 Neo4j 干的是"用 Cypher 查询节点间的关系":

cypher 复制代码
MATCH (p:Product {name: '珍珠奶茶'})-[:包含]->(i)
RETURN p, i

你拿到的是一张有牵连的网。

笔记里那句形容我特别喜欢:

分散的文档、实体、关键词,像蜘蛛网一样串联起来,形成清晰的知识图谱,完美解决 Milvus 和 ES 无法捕捉数据之间关联关系的痛点。

传统 RAG 拿到的是碎片化信息,没结构、没关联,像一个个信息孤岛 。 而知识图谱要做的事,就是把这些孤岛之间架起桥。

比如把"奶茶、配料、制作工艺、适合人群"都塞进 Neo4j,就能轻松实现这条链路:

检索「珍珠奶茶」→ 关联到配料「珍珠」→ 串联到制作工艺 → 延伸到适合的消费场景

这就是传说中的多跳全链路检索 。于是 RAG 也就顺势进化成了 Agentic RAG + Graph RAG。

(GraphRAG = 基于图数据库实现的关联检索。这一篇我们先动手把图建起来,下一篇再让大模型自己来查。)

第一步:用 Docker 把 Neo4j 跑起来

老规矩,docker-compose.yml 伺候:

yaml 复制代码
services:
  neo4j:
    image: neo4j:latest
    container_name: neo4j-container
    ports:
      - "7474:7474"   # Web 管理界面
      - "7687:7687"   # Bolt 协议(代码连接)
    environment:
      - NEO4J_AUTH=neo4j/12345678  # 账号:neo4j  密码:12345678
      - NEO4J_PLUGINS=["apoc"]     # 安装必备插件
      - NEO4J_dbms_security_procedures_unrestricted=apoc.*
    volumes:
      - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/neo4j/data:/data
      - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/neo4j/logs:/logs
      - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/neo4j/conf:/conf
    restart: no

几个必须划重点的地方:

  • 两个端口分工不同 :7474 是给你我这种人类点开浏览器用的 Web 管理界面;7687 是 Bolt 协议,专门给代码连的。别搞混了。
  • NEO4J_AUTH :默认账号 neo4j,密码 12345678(图方便,生产别这么干)。
  • APOC 插件 :图数据库界的"瑞士军刀",一堆实用过程函数都在里面,通过 NEO4J_PLUGINS=["apoc"] 装,再配一句 NEO4J_dbms_security_procedures_unrestricted=apoc.* 放开权限。
  • volumes 三件套:data / logs / conf 全给你挂出去,容器删了数据还在,这才是老玩家的姿势。

docker compose up -d 一把梭,浏览器打开 http://localhost:7474,看到登录框就说明活了。

第二步:用 Cypher 手搓一张奶茶人脉网

Neo4j 的查询语言叫 Cypher ,语法特别像在画图,圆括号 () 是节点,方括号 [] 是关系,箭头 -> 是方向。记住这三样,你已经会一半了。

1. 先建节点(把"人"请进通讯录)

cypher 复制代码
CREATE (product:Product {name: '珍珠奶茶'})
CREATE (type1: Type {name: '台湾奶茶'})
CREATE (type2: Type {name: '港式奶茶'})
CREATE (brand1: Brand {name: '蜜雪冰城'})
CREATE (brand2: Brand {name: '古茗'})
cypher 复制代码
CREATE (ing1: Ingredient {name: '珍珠'})
CREATE (ing2: Ingredient {name: '芋圆'})
CREATE (ing3: Ingredient {name: '果糖'})
CREATE (ing4: Ingredient {name: '红茶'})
CREATE (ing5: Ingredient {name: '牛奶'})
cypher 复制代码
CREATE (method1: Method {name: '煮制'})
CREATE (method2: Method {name: '冲泡'})

CREATE (people1: People {name: '年轻人'})
CREATE (people2: People {name: '学生'})
CREATE (people3: People {name: '甜食爱好者'})

(变量名:标签 {属性}) ------ 变量名是临时的,标签(Product / Ingredient / Brand / Type / Method / People)才是它的"身份证类别"。

2. 再拉关系(这才是图谱的灵魂)

光有一堆节点,那叫"通讯录";有了关系,才叫"人脉网"。

cypher 复制代码
MATCH (p:Product {name: '珍珠奶茶'}), (t:Type {name: '台湾奶茶'})
CREATE (p)-[:属于]->(t)
cypher 复制代码
MATCH (p:Product {name: '珍珠奶茶'}), (i:Ingredient {name: '珍珠'})
CREATE (p)-[:包含]->(i)
MATCH (p:Product {name: '珍珠奶茶'}), (i:Ingredient {name: '芋圆'})
CREATE (p)-[:包含]->(i)
MATCH (p:Product {name: '珍珠奶茶'}), (i:Ingredient {name: '果糖'})
CREATE (p)-[:包含]->(i)
MATCH (p:Product {name: '珍珠奶茶'}), (i:Ingredient {name: '红茶'})
CREATE (p)-[:包含]->(i)
MATCH (p:Product {name: '珍珠奶茶'}), (i:Ingredient {name: '牛奶'})
CREATE (p)-[:包含]->(i)
cypher 复制代码
MATCH (p:Product {name: '珍珠奶茶'}), (peo:People {name: '年轻人'})
CREATE (p)-[:适合]->(peo)
MATCH (p:Product {name: '珍珠奶茶'}), (peo:People {name: '学生'})
CREATE (p)-[:适合]->(peo)
MATCH (p:Product {name: '珍珠奶茶'}), (peo:People {name: '甜食爱好者'})
CREATE (p)-[:适合]->(peo)

注意这里的套路:先用 MATCH 把两个节点"找到",再用 CREATE 在它俩之间"牵线" 。[:属于]、[:包含]、[:适合] 就是关系的类型,方向由箭头决定。

于是"珍珠奶茶"这个节点,瞬间就有了:一类出身(台湾奶茶)、五个配料(珍珠/芋圆/果糖/红茶/牛奶)、三拨粉丝(年轻人/学生/甜食爱好者)。这才叫社会关系丰富。

3. 查询验证(看看网织得对不对)

先看一眼整张网:

cypher 复制代码
MATCH (n)-[*]->(m)
RETURN n, m

[*] 是"任意长度的关系路径",相当于说"把这张网整个端上来"。(数据量小的时候随便浪,数据量大了慎用,容易把机器问懵。)

然后是重头戏------多跳关联查询,这才是 GraphRAG 的看家本领:

cypher 复制代码
MATCH (p:Product {name: '珍珠奶茶'})-[:包含]->(i)-[:使用]->(m)
RETURN p.name, i.name, m.name

这一句干了什么?从「珍珠奶茶」出发 → 沿「包含」找到配料 → 再沿「使用」找到制作工艺,一口气跳了两跳,把"奶茶-配料-工艺"整条链路拎出来了。

再补一句"适合谁":

cypher 复制代码
MATCH (p:Product {name: '珍珠奶茶'})-[:适合]->(people)
RETURN p.name, people.name

对了,配料和工艺之间也得牵个线,不然上面那条两跳路径就断在中间:

cypher 复制代码
MATCH (i:Ingredient {name: '珍珠'}), (m:Method {name: '煮制'})
CREATE (i)-[:使用]->(m)

4. 更新节点属性(给"人"补上备注)

cypher 复制代码
MATCH (p:Product {name: '珍珠奶茶'})
SET p.calorie = "中高热量", p.taste = "甜香"
cypher 复制代码
MATCH (i:Ingredient {name: '珍珠'})
SET i.origin = "台湾", i.hard = "Q弹"

SET 就是"改属性",可以一次改好几个,用逗号隔开。(顺便灵魂拷问:珍珠奶茶中高热量这件事,终于被写进数据库了。)

5. 删除(这里有坑,重点看)

删关系:

cypher 复制代码
MATCH (p:Product {name: '珍珠奶茶'})-[r:适合]->(s.People {name: "学生"})
DELETE r

删节点(这个节点刚好没别的关系,可以直删):

cypher 复制代码
MATCH (t:Type {name: "港式奶茶"})
DELETE t

删一个"还带着关系"的节点------这就是新人最容易踩的坑:

cypher 复制代码
MATCH (i:Ingredient {name: '芋圆'})-[r]-()
DELETE r, i

注意 [r]-() 这里故意不写方向、也不写关系类型 :因为要先把"挂在芋圆身上的所有关系"一条条找出来,先 DELETE r 把线剪断,再删节点 i 。Neo4j 有个硬规矩:一个节点只要还有关系连着,就不许直接删。 你必须先把线剪了。

实在懒得一条条剪?那就上终极大招:

cypher 复制代码
MATCH (n)
DETACH DELETE n

DETACH DELETE 的 DETACH 意思是"先自动把关系全拆了,再删节点",等于一键清空整个库------注意,是删库,慎用!

第三步:不写 Cypher 面板,用代码直接查

光在浏览器里点来点去不够爽,我们整段代码来跑。用官方的 neo4j-driver:

javascript 复制代码
import neo4j from "neo4j-driver"

const driver = neo4j.driver(
    "bolt://localhost:7687",
    neo4j.auth.basic("neo4j", "12345678"),
);

// 获取会话
const session = driver.session();

注意地址用的是 bolt:// ,端口 7687------就是前面 docker-compose 里映射出来的那个 Bolt 协议端口。

接下来几件事,跟面板里干的一模一样。建数据:

javascript 复制代码
async function createData() {
    const result = await session.run(`
        CREATE (p:Product {name: "珍珠奶茶"})
        CREATE (i:Ingredient {name: '珍珠'})
    `)
    console.log(result)
}

建关系:

javascript 复制代码
async function createRelation() {
    const result = await session.run(`
        MATCH (p:Product {name: "珍珠奶茶"}), (i:Ingredient {name: '珍珠'})
        CREATE (p)-[:包含]->(i)
    `)
    console.log("关系创建成功", result)
}

查询 + 优雅地取值(这段值得收藏):

javascript 复制代码
async function queryData() {
    const result = await session.run(`
        MATCH (p:Product {name: "珍珠奶茶"})-[r]->(i)
        RETURN p, r, i
    `)
    result.records.forEach(record => {
        console.log('奶茶:', record.get('p').properties.name);
        console.log('关系:', record.get('r').type);
        console.log('配料:', record.get('i').properties.name);
    })
}

划重点:返回的不是普通对象,而是 records 数组 。每条记录都要用 record.get('别名') 取出对应节点,节点的业务属性藏在 .properties 里,关系的类型藏在 .type 里。第一次接触 Neo4j 的人十有八九会在这里卡一下。

更新:

javascript 复制代码
async function updateData() {
    await session.run(`
        MATCH (p:Product {name: "珍珠奶茶"})
        SET p.price = 15
    `)
    console.log("更新成功")
}

(给珍珠奶茶定价 15 块,感觉比某蜜接地气。)

删关系 / 删节点:

javascript 复制代码
async function deleteRelation() {
    await session.run(`
        MATCH (p:Product {name: "珍珠奶茶"})-[r:包含]->(i:Ingredient {name: '珍珠'})
        DELETE r
    `)
    console.log("删除关系成功")
}

async function deleteNode() {
    await session.run(`
        MATCH (p:Product {name: "珍珠奶茶"})
        DELETE p
    `)
    console.log("删除节点成功")
}

最后,一定要记得还资源------这是驱动程序的铁律:

javascript 复制代码
// 释放 session 与 driver 链接
await session.close()
await driver.close()

session 用完就关,driver 整个程序退出前也要关。不关的话连接池迟早被你自己占满,然后你就开始怀疑人生。

小结 & 下一集预告

到这儿,我们就用 Neo4j 给奶茶建好了一张关系网,手写 Cypher 完成了:建节点 → 拉关系 → 多跳查询 → 改属性 → 删(关系/节点/彻底清空) 的全套操作,还用 neo4j-driver 把它搬到了代码里。

但问题也随之而来:

用户问的是人话------"珍珠奶茶里都有啥配料 "。 但我每次都得手动翻译成 MATCH (p:Product {name: '珍珠奶茶'})-[:包含]->(i) RETURN ...。

这翻译官,我当一天可以,当一年不行。

所以下一篇 ,我们就让大模型自己来当这个翻译官:Text2Cypher + LangGraph,把"用户的自然语言"自动变成"Cypher 语句",再自动查图、自动总结答案,把整条 GraphRAG 链路彻底跑通。

顺便还会附上一张向量检索 vs 关键词检索 vs 图谱检索的三方对照表------毕竟都是成年人了,我们全都要。

下篇见。

相关推荐
优选资讯1 小时前
标签打印软件怎么对接 Excel 数据
前端·excel
思无邪662 小时前
用 AI 做 JS 逆向:从抓包到复现的完整方法论
开发语言·javascript·人工智能
一个风轻云淡2 小时前
GCC 和 GDB命令简单解读
java·linux·前端
u0111026753 小时前
图片处理接口如何统一错误信息 前端提示与后端错误码设计
前端·状态模式
福兮说3 小时前
浏览器里把 iPhone 的 HEIC 转成 JPG:Chrome 解不开、转完大了七成、拍摄时间全丢,六个坑实测
前端·javascript·图像处理·chrome·ios·iphone·heic
libokaifa3 小时前
把语音模型塞进手机:sherpa-onnx 在 Android 上的 ASR / TTS
前端
deli0074 小时前
DLA 枝晶生长实测:5000 个粒子集体停摆之后,分形维数收敛到 1.70
前端
IT_陈寒4 小时前
Redis卡顿的锅,这次真不是大key的错
前端·人工智能·后端
可乐鸡翅yeah_4 小时前
video.js 集成 hls.js 开发 M3U8 播放器,新手高频踩坑
开发语言·前端·javascript·后端·ecmascript·m3u8·音视频在线播放