Neo4j

简介 #

Neo4j 是什么

Neo4j 是图数据库。它的数据单位不是“表、行、外键”,而是:

  • 节点:例如 Person、Evidence、Account、Group
  • 关系:例如 HasEvidence、HasAccount、ContactWith
  • 标签:节点类型,比如 :Person
  • 属性:节点或关系上的字段,比如 personName、virtualId、chatCount
  • Cypher:Neo4j 的查询语言,类似 SQL,但更擅长表达路径和关系

主要特点

  • 使用 Cypher 查询语言(专为图数据库设计)
  • 支持 ACID 事务
  • 自带可视化图形界面(Neo4j Browser)
  • 典型场景:社交网络、推荐系统、知识图谱、欺诈检测,以及本项目的「人 — 检材 — 账号」关系图谱

下载安装 #

docker run --name neo4j-learning `  -p 7474:7474 `  -p 7687:7687 `  -e NEO4J_AUTH=neo4j/secretgraph `  -d neo4j:latest 

启动后访问:

  • 浏览器控制台:http://localhost:7474
  • 数据库连接地址:neo4j://localhost:7687 或 bolt://localhost:7687
  • 用户名:neo4j
  • 密码:secretgraph

Neo4j 官方 Go Driver 文档也推荐用 Docker 快速启动本地实例,并说明 7474 是 HTTP Browser 端口,7687 是 Bolt 驱动连接端口。参考:Neo4j Go Driver InstallDocker Access Neo4j

生产环境部署(Docker + 备份/调优配置) #

学习用实例可直接 docker run;生产环境推荐用 Docker Compose 配合 .env,包含镜像源、内存调优、多库上限与自动备份等配置:

REGISTRY=registry-mirror.forensix.cn
TAG=apoc-dozerdb-5.26.3
NEO4J_PASS=1HzkUyoVykNV
WEB_PORT=7474
BOLT_PORT=7687
NEO4J_SERVER_MEMORY_HEAP_INITIAL_SIZE=8G
NEO4J_SERVER_MEMORY_HEAP_MAX_SIZE=8G
NEO4J_SERVER_MEMORY_PAGECACHE_SIZE=16G
NEO4J_MAX_DATABASES=10000
BACKUP_TAG=1.5.0.385-neo4j
BACKUP_NAME=huoyan-neo4j
BACKUP_PASSPHRASE=Op!xoFNNU5dP8AY#
BACKUP_STORAGE=borg
BACKUP_LOGICAL_INCR_CRON=daily
BACKUP_LOGICAL_FULL_CRON=weekly
BACKUP_VERIFY_FAST_CRON=monthly
BACKUP_VERIFY_FULL_CRON=yearly
BACKUP_VERIFY_FULL_IGNORE_OSS=true
BACKUP_VERIFY_ONLINE=true
BACKUP_DUMP_PATH=./backup/dump
BACKUP_RESTORE_PATH=./backup/restore
BACKUP_CONFIG_PATH=./backup/config
BACKUP_CACHE_PATH=./backup/cache
BACKUP_OSS_TYPE=
BACKUP_OSS_ACCESS_KEY=
BACKUP_OSS_SECRET_KEY=
BACKUP_OSS_ENDPOINT=
BACKUP_OSS_BUCKET=
BACKUP_OSS_CRON=
BACKUP_HOST=
BACKUP_PORT=
BACKUP_BORG_PORT=
BACKUP_RESTIC_PORT=
BACKUP_TOKEN=
TRUST_BACKUP_SERVER=true

数据库连接 #

GES_NEO4J_SERVICE_ADDR=neo4j://localhost:7687
GES_NEO4J_USERNAME=neo4j
GES_NEO4J_PASSWORD=secretgraph

官方 Go Driver 当前文档说明:应用一般创建一个 Driver,用 VerifyConnectivity 检查连接,并在结束时关闭。参考:Neo4j Go Driver Connection

"github.com/neo4j/neo4j-go-driver/v5/neo4j"

neo4jClient := neo4j.NewClient().
    SetAddr(global.Config.Neo4jServiceAddr).
    SetAuth(global.Config.Neo4jUsername, global.Config.Neo4jPassword)

数据结构 #

Neo4j 的核心结构是:

  • 节点:实体,比如人、账号、检材、群组
  • 关系:实体之间的边,比如拥有、通联、入群
  • 标签:节点类型,比如 :Person
  • 属性:节点或关系上的字段,比如 personName、chatCount
  • Cypher:Neo4j 的查询语言

项目里的主业务图谱可以理解成:

graph LR
  P["Person 持有人"] -->|HasEvidence| E["Evidence 检材"]
  E -->|HasAccount| A["Account 账号"]
  A -->|ContactWith| A2["Account 好友账号"]
  A -->|ContactWith| G["Group 群组"]
(p:Person {personName: "张三"})-[r:HasEvidence]->(e:Evidence {eid: 1001})

这里:

  • p、r、e:只是 Cypher 查询里的临时变量,不存入数据库。
  • Person、Evidence:节点标签,也就是节点类型。
  • HasEvidence:关系类型,不是节点标签,但也是图结构的一部分。
  • personName、eid:属性,是真正存储在节点或关系上的字段。
graph LR
  P["节点标签: Person<br/>属性: personName, aiSummary, remark, position"] -->|关系类型: HasEvidence| E["节点标签: Evidence<br/>属性: eid, position"]
  E -->|关系类型: HasAccount<br/>属性: tid, nickName, eid, userId| A["节点标签: Account<br/>属性: virtualId, fromApp, nickName, phoneNumber 等"]
  A -->|关系类型: ContactWith<br/>属性: pid, tid, dataType, chatCount, transCount 等| A2["节点标签: Account"]
  A -->|关系类型: ContactWith<br/>属性: pid, tid, dataType, chatCount, transCount 等| G["节点标签: Group<br/>属性: groupId, groupName, fromApp, memberCount 等"]

节点标签 #

项目主业务图谱里,真正的节点标签主要是这几个:

Neo4j 标签 项目含义 对应 Go 结构体
Person 持有人、机主、案件中的人 NodePerson
Evidence 检材、设备、数据源 NodeEvidence
Account 微信、QQ、手机号等账号 NodeAccount
Group 群聊、群组 NodeGroup

注意:NodePerson 是 Go 结构体名,不是 Neo4j 标签。真正写进 Neo4j 的标签是 Cypher 里的 :Person。

例如项目创建 Person 节点时:

MERGE (p:Person {personName: person.personName}) 

这里 Person 是标签,personName 是属性。

节点属性 #

Person 节点属性大概对应:

属性名 含义
personName 持有人姓名,项目里用它作为 Person 的主要匹配字段
aiSummary AI 分析总结
remark 备注
position 前端图谱里的节点坐标

示例:

(:Person {  personName: "张三",  aiSummary: "...",  remark: "...",  position: "{\"x\":100,\"y\":200}" }) 

Evidence 节点属性:

属性名 含义
eid 检材 ID,项目里用它作为 Evidence 的主要匹配字段
position 前端图谱里的节点坐标

项目创建方式:

MERGE (e:Evidence {eid: evidence.eid})

Account 节点属性比较多:

属性名 含义
virtualId 账号虚拟 ID,核心匹配字段之一
fromApp 来源应用,核心匹配字段之一
nickName 昵称
phoneNumber 手机号
phoneMark 手机号标记,通话画像里有用
trueName 真实姓名
display 是否展示
caseRelatedScore 案件相关度评分
friendCount 好友数量
groupCount 群数量
aiSummary AI 总结
remark 备注
position 前端图谱坐标

项目里 Account 的主匹配方式是:

MERGE (a:Account {virtualId: account.virtualId, fromApp: account.fromApp})

Group 节点属性:

属性名 含义
groupId 群 ID,主要匹配字段
groupName 群名称
fromApp 来源应用
memberCount 群成员数量
notice 群公告
display 是否展示
caseRelatedScore 案件相关度评分
aiSummary AI 总结
remark 备注
position 前端图谱坐标
owerAccount 群主账号,字段名代码里就是这个拼写
owerName 群主名称

项目创建 Group:

MERGE (g:Group {groupId: groupItem.groupId})

关系类型 #

关系类型是方括号里的冒号后内容:

(p)-[:HasEvidence]->(e)

项目里主关系类型是:

关系类型 方向 含义
HasEvidence Person -> Evidence 某个持有人拥有某个检材
HasAccount Evidence -> Account 某个检材中发现某个账号
ContactWith Account -> Account 账号和账号之间有通联
ContactWith Account -> Group 账号和群组之间有通联
HasRelation Person - Person 项目里有查询使用,偏 AI 总结/评分关系,不是主写入链路

HasEvidence 当前写入时基本不存额外属性:

MATCH (p:Person {personName: rel.personName}) MATCH (e:Evidence {eid: rel.eid}) MERGE (p)-[r:HasEvidence]->(e) 

HasAccount 有属性:

MERGE (e)-[r:HasAccount {tid: rel.tid, nickName: rel.nickName}]->(u) SET r += {  eid: rel.eid,  tid: rel.tid,  nickName: rel.nickName,  userId: rel.userId } 

它表示:某个检材里的某个任务识别出了某个账号。

ContactWith 是最重要的业务关系,属性最多:

属性名 含义
pid 数据节点 ID,项目里常用于定位跳转
tid 任务 ID
eid 检材 ID
dataType 数据类型,比如聊天、通话等
chatCount 聊天次数
transCount 交易次数
deleteChatCount 删除聊天数量
friendRemark 好友备注
sourceVirtualId 来源账号
sourceNickName 来源昵称
sentMessageCount 发送消息数
earliestMessageTime 最早消息时间
latestMessageTime 最新消息时间
callDuration 通话时长
aiSummary AI 总结
remark 备注

项目写账号到账号通联时:

MATCH (from:Account {virtualId: rel.fromAccount, fromApp: rel.fromApp}) MATCH (to:Account {virtualId: rel.toAccount, fromApp: rel.fromApp}) MERGE (from)-[r:ContactWith {  pid: rel.relation.pid,  tid: rel.relation.tid,  dataType: rel.relation.dataType }]->(to) 

一个完整例子

假设张三的检材 eid=1001 里有微信账号 wx001,它和账号 wx002 聊过 20 次,图里可以理解成:

(:Person {personName: "张三"})  -[:HasEvidence]-> (:Evidence {eid: 1001})  -[:HasAccount {tid: 501, eid: 1001, userId: "wx001"}]-> (:Account {virtualId: "wx001", fromApp: 1, nickName: "张三微信"})  -[:ContactWith {tid: 501, eid: 1001, dataType: "chat", chatCount: 20}]-> (:Account {virtualId: "wx002", fromApp: 1, nickName: "李四微信"}) 

所以你判断“标签还是属性”可以用这个规则:

  • 写在 (:XXX) 里的 XXX 是节点标签。
  • 写在 -[r:XXX]-> 里的 XXX 是关系类型。
  • 写在 {key: value} 里的 key 是属性。
  • 写在 SET a.xxx = … 里的 xxx 也是属性。
  • p、e、a、r、cw 都只是查询变量,不是数据库结构。

项目里最核心的一句话就是:

MATCH (p:Person)-[:HasEvidence]->(e:Evidence)-[:HasAccount]->(a:Account)-[cw:ContactWith]-(target) 

这句话翻译成人话就是:找到一个持有人 Person,沿着“持有检材”找到 Evidence,再沿着“检材拥有账号”找到 Account,再沿着“通联关系”找到对端账号或群。

底层存储 #

Neo4j 不是简单用“二维数组邻接矩阵”存图,更接近“记录数组 + 指针/偏移 + 链表/树 + 索引”的组合。

先给结论:Neo4j 逻辑上是属性图,物理上按节点、关系、属性分别存储;关系直接挂在节点附近,遍历时沿关系记录走,而不是像关系型数据库那样每跳都做 join。

逻辑结构到物理结构 #

以项目里的这条关系为例:

(:Person {personName: "张三"})
-[:HasEvidence]-> 
(:Evidence {eid: 1001})
-[:HasAccount]-> 
(:Account {virtualId: "wx001", fromApp: 1})
-[:ContactWith {chatCount: 20}]-> 
(:Account {virtualId: "wx002", fromApp: 1}) 

Neo4j 底层大概会拆成几类记录:

NodeStore:  
	NodeRecord#1  Person 节点  
	NodeRecord#2  Evidence 节点  
	NodeRecord#3  Account wx001 节点  
	NodeRecord#4  Account wx002 节点 
RelationshipStore:  
	RelRecord#10  #1 - HasEvidence -> #2  
	RelRecord#11  #2 - HasAccount  -> #3  
	RelRecord#12  #3 - ContactWith -> #4 
PropertyStore:  
	personName = "张三"  
	eid = 1001  
	virtualId = "wx001"  
	fromApp = 1  
	chatCount = 20  
	... 

也就是说,标签、关系、属性在逻辑上看起来都写在一条 Cypher 里,但落盘时不是一个“大对象”,而是拆到不同 store 中。

节点记录里存什么 #

一个节点记录通常保存:

NodeRecord
	nodeId
	labels
	firstRelationshipPointer
	firstPropertyPointer 

对项目来说,Person 节点类似:

NodeRecord#1  
	labels = [Person] 
	firstRelationship = RelRecord#10 
	firstProperty = PropertyRecord#100 

属性链可能是:

PropertyRecord#100: personName = "张三", next = #101 PropertyRecord#101: aiSummary = "...", next = #102 PropertyRecord#102: position = "{\"x\":100,\"y\":200}" 

这里你可以把 firstRelationship、firstProperty 理解成链表头指针。

关系记录里存什么 #

关系记录是 Neo4j 最关键的地方。一个关系不仅存“从谁到谁”,还存它在两个节点关系链上的前后指针。

简化后类似:

RelationshipRecord#12
  type = ContactWith
  startNode = NodeRecord#3
  endNode = NodeRecord#4

  startNodePrevRel
  startNodeNextRel
  endNodePrevRel
  endNodeNextRel

  firstProperty = PropertyRecord#200

ContactWith 的属性链可能是:

PropertyRecord#200: chatCount = 20, next = #201 PropertyRecord#201: transCount = 0, next = #202 PropertyRecord#202: tid = 501, next = #203 PropertyRecord#203: dataType = "chat" 

这就是为什么 Neo4j 遍历很快。比如查询:

MATCH (a:Account {virtualId: "wx001"})-[cw:ContactWith]-(target) RETURN target 

执行过程大致是:

1. 通过索引或扫描找到 Account(wx001)
2. 拿到这个节点的 firstRelationship
3. 沿着关系链找 ContactWith
4. 通过关系记录里的 startNode/endNode 直接跳到 target 节点

它不是去一张“关系表”里反复 join。

稀疏节点和密集节点 #

如果一个节点关系不多,比如普通账号只有几十个好友,关系链就像链表:

Account(wx001)
  -> Rel#12
  -> Rel#13
  -> Rel#14

如果一个节点关系特别多,比如某个大群、超级联系人、公共账号,Neo4j 会把它当成 dense node,按关系类型和方向组织得更细。

可以理解成:

Dense Account
  ContactWith outgoing -> 一组关系
  ContactWith incoming -> 一组关系
  HasAccount incoming  -> 一组关系

新版 Enterprise 的 block 格式还会用更复杂的块、动态记录、B+Tree 风格结构来存大节点关系。官方文档说明:Neo4j 当前有多种 store format,Community 默认 aligned,Enterprise 新库默认推荐 block;aligned 更接近链表式记录结构,block 会把更多相关数据放近一些,减少读取次数。参考:Neo4j Store formats

标签和关系类型怎么存 #

你在 Cypher 里写:

(:Account) 
[:ContactWith] 

Neo4j 不会在每个节点上反复存字符串 “Account”,也不会在每条关系上反复存完整字符串 “ContactWith”。

更像这样:

LabelToken:
  1 = Person
  2 = Evidence
  3 = Account
  4 = Group

RelationshipTypeToken:
  1 = HasEvidence
  2 = HasAccount
  3 = ContactWith

节点记录里存的是标签 token id,关系记录里存的是关系类型 token id。这样比存字符串省空间,也方便索引。

7. 属性怎么存 属性是 key-value:

virtualId = "wx001"
fromApp = 1
chatCount = 20
aiSummary = "..."

属性名也会 token 化:

PropertyKeyToken:
  1 = virtualId
  2 = fromApp
  3 = chatCount
  4 = aiSummary

短小的值可能直接内联在记录里。长字符串、数组、大字段会放到动态区域,再用指针引用。

你项目里这些字段可能会触发动态存储:

  • aiSummary
  • remark
  • jumpDataList
  • position,项目里经常把坐标 JSON 转成字符串存
  • 群公告 notice

官方旧版知识库对 record 格式的描述很直观:节点、关系、属性都是固定大小记录,属性是链表,节点引用第一条关系,关系引用起点和终点节点,并在起点/终点两边各维护前后关系指针。参考:Understanding Neo4j’s data on disk

索引在这里起什么作用 #

Neo4j 不是所有查询都从链表开始。比如:

MATCH (a:Account {virtualId: "wx001", fromApp: 1}) RETURN a 

如果有索引:

CREATE INDEX account_internal_id IF NOT EXISTS FOR (a:Account) ON (a.virtualId, a.fromApp) 

Neo4j 会先用索引找到账号节点,再从这个节点沿关系走。

所以可以理解为:

索引:负责快速找到起点节点 关系链/关系结构:负责从起点快速扩展邻居 属性 store:负责读取节点和关系上的业务字段 

对应到你项目的查询 #

项目里经常有这种查询:

MATCH (p:Person)-[:HasEvidence]->(e:Evidence)-[:HasAccount]->(a:Account)-[cw:ContactWith]-(f:Account) 

底层动作可以理解成:

1. 找 Person 节点
2. 沿 Person 的关系链找到 HasEvidence
3. 跳到 Evidence 节点
4. 沿 Evidence 的关系链找到 HasAccount
5. 跳到 Account 节点
6. 沿 Account 的关系链找到 ContactWith
7. 跳到另一个 Account 或 Group
8. 读取 ContactWith 上的 chatCount、transCount、tid、eid 等属性

这就是 Neo4j 适合关系图谱的原因:查询重点是“沿边走”,而不是“按表 join”。

你可以把 Neo4j 和常见图结构这样类比:

邻接矩阵:
  nodeA -> nodeB 是否有边,用 matrix[a][b]
  空间大,不适合稀疏大图

普通邻接表:
  nodeA -> [edge1, edge2, edge3]
  适合稀疏图

Neo4j:
  NodeRecord + RelationshipRecord + PropertyRecord
  关系记录把起点、终点、类型、属性、前后关系指针都存下来
  磁盘上是记录和指针结构,查询时结合索引、缓存、事务日志

更贴近项目的一句话:Account 节点不是保存一个“好友数组”,而是通过很多 ContactWith 关系记录连接到其他 Account 或 Group;每条 ContactWith 自己又保存 chatCount、transCount、tid、eid 等属性。

增删查改 #

Neo4j 用 Cypher 做 CRUD。官方文档里,MATCH 用于图模式查询,MERGE 用于“有则匹配、无则创建”,DELETE / DETACH DELETE 用于删除。参考:MATCHMERGEDELETE

新增或更新节点,项目大量使用 MERGE:

MERGE (p:Person {personName: "张三"}) MERGE (e:Evidence {eid: 1001}) MERGE (p)-[:HasEvidence]->(e) 

批量写入时使用 UNWIND:

UNWIND $persons AS person MERGE (p:Person {personName: person.personName})

查询某个持有人下面的账号:

MATCH (p:Person)-[:HasEvidence]->(e:Evidence)-[:HasAccount]->(a:Account) WHERE p.personName = "张三" RETURN p, e, a 

查询两个持有人的共同好友:

MATCH (p1:Person {personName: "张三"})-[:HasEvidence]->(:Evidence)-[:HasAccount]->(:Account)-[:ContactWith]-(friend:Account)
MATCH (p2:Person {personName: "李四"})-[:HasEvidence]->(:Evidence)-[:HasAccount]->(:Account)-[:ContactWith]-(friend)
RETURN friend

修改属性:

MATCH (a:Account {virtualId: "wx001", fromApp: 1}) SET a.remark = "重点账号",    a.caseRelatedScore = 90 RETURN a 

项目里更新节点属性使用:

MATCH (n) WHERE elementId(n) = $elementId SET ...

删除关系:

MATCH ()-[r:ContactWith {tid: 123}]-() DELETE r 

删除节点及其所有关系:

MATCH (n) WHERE elementId(n) = $elementId DETACH DELETE n

运维常用命令 #

查询数据库是否存在 #

docker exec -it 6df312464227 cypher-shell -u neo4j -p '1HzkUyoVykNV' -d system "SHOW DATABASES YIELD name WHERE name='case17relationgraph' RETURN count(*) AS cnt;"

# 6df312464227   容器名
# 1HzkUyoVykNV   Neo4j 密码
# case17relationgraph  数据库名

验证 Qdrant 数据集是否存在(关联校验) #

CID=17; KEY='QToV7Bk7JLHu'; BASE='http://172.16.60.175:6333'; cnt=0; for c in "faceCollection-$CID" "cnClipCollection-$CID"; do [ "$(curl -s -o /dev/null -w '%{http_code}' -H "api-key: $KEY" "$BASE/collections/$c")" = "200" ] && cnt=$((cnt+1)); done; echo "cnt=$cnt"