简介 #
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 Install、Docker 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 用于删除。参考:MATCH、MERGE、DELETE。
新增或更新节点,项目大量使用 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"