简介:这份源码资源面向希望掌握图数据库应用开发与推荐系统设计的学习者,以Neo4j为核心数据库,构建了一套社交兴趣推荐系统。系统围绕用户、兴趣及社交关系三类数据元素展开,通过节点与关系建模,结合协同过滤、图遍历等算法挖掘相似用户与潜在兴趣,并对外提供API接口供前端调用。压缩包共439个文件,约78.48MB,以Java后端代码、JavaScript脚本、HTML与CSS页面样式为主,同时包含GIF、JPG、PNG等界面素材及XML、properties配置文件和字体资源,目录结构清晰,便于按模块检索。源码涵盖数据导入、推荐引擎、接口服务、数据库连接配置与测试代码等组件,读者可借此理解Neo4j的Cypher查询、数据建模与推荐算法落地过程,适合具备一定Java与数据库基础、想深入图数据库实战的开发者。目前已有404人学习下载。
1. 基于 Neo4j 社交兴趣推荐系统:从图模型到可复现的推荐链路
社交场景里的推荐,最容易被低估的是「关系」本身。用户 A 关注了 B,B 收藏了某话题,C 又和 A 在同一个兴趣小组里——这种多跳关系用关系型数据库写 JOIN 会越写越深,查询计划一崩,延迟直接起飞。基于 Neo4j 社交兴趣推荐系统源码这套东西,核心思路就是把「人—兴趣—内容—社群」全部建模成图,让推荐从「算相似度」变成「沿路径找邻居」。它适合两类人:一是手里有社交行为数据、想快速验证图推荐是否比协同过滤更贴合的开发者;二是已经在用 Neo4j 做关系分析、想把推荐模块接进现有图库的工程师。这篇不聊虚的,直接拆图模型怎么设计、Cypher 怎么写、参数怎么调、哪些坑我踩过。
2. 图模型设计:把「兴趣」和「社交」拆成可查询的边
2.1 为什么推荐系统适合用属性图而不是宽表
宽表做推荐,本质是把用户和物品的交互压成一行行 (user_id, item_id, score)。一旦要引入「好友的好友喜欢什么」这种二跳、三跳逻辑,就得反复自连接,SQL 会变得又长又慢。属性图把每个实体当节点、每种关系当边,二跳查询就是(u)-[:FOLLOW]->(v)-[:LIKES]->(tag)这样一条路径,数据库原生按指针跳转,不需要临时建索引表。
更关键的是,社交兴趣推荐里「兴趣」本身有层级。比如「摄影」下面有「人像」「风光」「胶片」,如果全拍平成标签,推荐粒度会失控。用图可以把兴趣建成树:(:Interest {name:'摄影'})-[:SUBCLASS_OF]->(:Interest {name:'人像'}),推荐时既能命中细粒度,也能向上泛化。这是宽表很难优雅表达的。
我一般会先定四类节点:User、Interest、Content、Group。边则按业务语义拆:FOLLOW(关注)、LIKES(点赞/收藏)、BELONGS_TO(属于小组)、TAGGED(内容被打标签)、INTERESTED_IN(用户对兴趣的显式声明)。注意 FOLLOW 和 INTERESTED_IN 要分开,前者是社交关系,后者是兴趣声明,推荐权重完全不同。
2.2 节点与关系的约束、索引先建好再灌数据
Neo4j 社区版没有企业级的在线约束,但唯一约束和索引必须提前建,否则数据量上到几十万后写入会明显变慢。下面这段 Cypher 是我每次初始化图库都会先跑的:
// 用户唯一约束,防止重复导入 CREATE CONSTRAINT user_id_unique IF NOT EXISTS FOR (u:User) REQUIRE u.userId IS UNIQUE; // 兴趣名称唯一,避免同名兴趣节点分裂 CREATE CONSTRAINT interest_name_unique IF NOT EXISTS FOR (i:Interest) REQUIRE i.name IS UNIQUE; // 内容唯一 CREATE CONSTRAINT content_id_unique IF NOT EXISTS FOR (c:Content) REQUIRE c.contentId IS UNIQUE; // 为高频查询字段建索引:用户所在城市、内容发布时间 CREATE INDEX user_city_index IF NOT EXISTS FOR (u:User) ON (u.city); CREATE INDEX content_time_index IF NOT EXISTS FOR (c:Content) ON (c.createdAt);逻辑说明:约束(CONSTRAINT)不仅保证唯一,还会自动创建对应索引,所以 userId、name、contentId 不需要再单独建索引。参数上,IF NOT EXISTS保证脚本可重复执行,适合放进初始化流程。城市和发布时间索引是为后面「同城推荐」「时间衰减」准备的,如果业务里没有这两个维度,可以删掉,少一个索引就少一份写入开销。
灌数据时有个血泪经验:不要用一条大CREATE批量插几千个节点,Neo4j 事务日志会膨胀。常见做法是用LOAD CSV配合CALL {} IN TRANSACTIONS分批提交,每批 1000 到 5000 行比较稳。如果数据源是 MySQL,也可以先用 Python 读出来再走 Bolt 驱动批量UNWIND。
2.3 用 UNWIND 批量写入用户和兴趣关系
假设已经从业务库导出了两个 CSV:users.csv(userId, name, city)和 interests.csv(userId, interestName)。下面这段是批量建节点和关系的写法:
// 批量导入用户节点 LOAD CSV WITH HEADERS FROM 'file:///users.csv' AS row CALL { WITH row MERGE (u:User {userId: row.userId}) SET u.name = row.name, u.city = row.city } IN TRANSACTIONS OF 2000 ROWS; // 批量导入兴趣节点并建立 INTERESTED_IN 关系 LOAD CSV WITH HEADERS FROM 'file:///interests.csv' AS row CALL { WITH row MERGE (u:User {userId: row.userId}) MERGE (i:Interest {name: row.interestName}) MERGE (u)-[:INTERESTED_IN]->(i) } IN TRANSACTIONS OF 2000 ROWS;逻辑说明:MERGE保证节点不存在才创建,避免重复导入产生脏数据。CALL {} IN TRANSACTIONS OF 2000 ROWS是 Neo4j 5 的分批提交语法,每 2000 行一个事务,内存占用可控。参数 2000 不是固定的,机器内存小就降到 500,SSD 好、内存足可以提到 5000,但超过 10000 容易触发 GC 停顿。导入前记得把 CSV 放到 Neo4j 的 import 目录,否则file:///找不到文件。
3. 推荐查询:用 Cypher 写出可解释的兴趣推荐
3.1 基于二跳社交关系的兴趣召回
最朴素的社交推荐逻辑是:找到我关注的人,看他们喜欢什么兴趣,排除我已经有的,按共同关注人数排序。这条查询在图上非常自然:
// 为指定用户推荐兴趣:二跳社交 + 兴趣共现 MATCH (me:User {userId: $userId})-[:FOLLOW]->(friend:User) -[:INTERESTED_IN]->(interest:Interest) WHERE NOT (me)-[:INTERESTED_IN]->(interest) RETURN interest.name AS recommendInterest, count(DISTINCT friend) AS friendCount ORDER BY friendCount DESC LIMIT 20;逻辑说明:$userId是参数化查询,避免字符串拼接和注入风险,也利于 Neo4j 缓存执行计划。count(DISTINCT friend)统计有多少个我关注的人喜欢这个兴趣,作为热度分。WHERE NOT做排除,保证不推荐已有兴趣。参数 LIMIT 20 是召回数量,实际生产里可以放到 50 再交给排序层精排。
这条查询在十万级用户、百万级关系下,如果 FOLLOW 和 INTERESTED_IN 都有索引支撑,通常几十毫秒能出结果。但如果某个大 V 被几十万人关注,二跳会爆炸,需要加LIMIT在 friend 层,或者用apoc.path.expandConfig控制深度和数量。
3.2 引入兴趣层级和内容标签做加权打分
光靠社交热度不够,还要考虑兴趣的语义层级和内容热度。下面这条查询把兴趣树和内容标签串起来,给每个候选兴趣算一个加权分:
// 加权推荐:社交热度 + 兴趣层级泛化 + 内容热度 MATCH (me:User {userId: $userId})-[:FOLLOW]->(friend:User) -[:INTERESTED_IN]->(rawInterest:Interest) WHERE NOT (me)-[:INTERESTED_IN]->(rawInterest) OPTIONAL MATCH (rawInterest)-[:SUBCLASS_OF*0..2]->(parent:Interest) OPTIONAL MATCH (content:Content)-[:TAGGED]->(rawInterest) WITH rawInterest, count(DISTINCT friend) AS socialScore, count(DISTINCT parent) AS generality, count(DISTINCT content) AS contentHeat RETURN rawInterest.name AS interest, socialScore * 2.0 + contentHeat * 0.5 + generality * 0.3 AS score ORDER BY score DESC LIMIT 20;逻辑说明:SUBCLASS_OF*0..2表示向上追溯最多两层父兴趣,generality越大说明这个兴趣越泛,适当加分可以避免推荐过窄。权重 2.0、0.5、0.3 是经验值,社交关系权重最高,内容热度次之,泛化性最低。这三个数需要根据业务反馈调,如果发现推荐太集中在热门兴趣,就降低 contentHeat 权重;如果推荐太泛,就提高 socialScore 权重。
参数上,*0..2的深度不要超过 3,否则路径数量指数增长。如果兴趣树很深,建议在写入时预计算一个level属性,查询时直接按 level 过滤,比变长路径快。
3.3 用 Python 驱动把推荐结果接回业务
Cypher 查出来的结果要落到业务接口,通常用官方 Python 驱动。下面是一个最小可用的推荐服务片段:
from neo4j import GraphDatabase class InterestRecommender: def __init__(self, uri, user, password): self.driver = GraphDatabase.driver(uri, auth=(user, password)) def recommend(self, user_id, limit=20): query = """ MATCH (me:User {userId: $userId})-[:FOLLOW]->(friend:User) -[:INTERESTED_IN]->(interest:Interest) WHERE NOT (me)-[:INTERESTED_IN]->(interest) RETURN interest.name AS name, count(DISTINCT friend) AS score ORDER BY score DESC LIMIT $limit """ with self.driver.session() as session: result = session.run(query, userId=user_id, limit=limit) return [{"interest": r["name"], "score": r["score"]} for r in result] def close(self): self.driver.close() # 使用示例 rec = InterestRecommender("bolt://localhost:7687", "neo4j", "your_password") print(rec.recommend("u1001", limit=10)) rec.close()逻辑说明:驱动用 Bolt 协议连接,session.run传参用命名参数,和 Cypher 里的$userId对应。limit也参数化,避免硬编码。生产环境里 driver 应该做成单例,不要每次请求都新建,否则连接池开销很大。密码不要写死在代码里,走环境变量或配置中心。
参数上,bolt://localhost:7687是默认地址,如果 Neo4j 和业务服务不在同一台机器,换成实际 IP。连接池大小默认 100,高并发下可以在GraphDatabase.driver里加max_connection_pool_size=200,但要注意 Neo4j 服务端的并发上限。
4. 避坑与排查:图推荐上线后最容易翻车的五件事
4.1 现象:推荐结果全是热门兴趣,长尾完全出不来
原因:社交热度用count(DISTINCT friend)做排序,大 V 的兴趣天然占优,冷门兴趣永远排不上。解决:在打分里引入逆文档频率思路,对兴趣的全局出现次数做惩罚。可以在 Interest 节点上维护一个globalCount属性,查询时用1.0 / log(globalCount + 2)做权重。或者直接对每个用户的推荐结果做多样性约束,比如同一父兴趣下最多出 3 个。
4.2 现象:二跳查询偶尔超时,日志里出现事务内存告警
原因:某些超级节点(被大量关注的用户)导致路径爆炸。解决:在MATCH的 friend 层加LIMIT,或者用 APOC 的apoc.path.subgraphNodes限制遍历节点数。更彻底的做法是在写入时对超级节点做标记,查询时用WHERE friend.followerCount < 10000过滤掉。参数上,事务内存可以在 neo4j.conf 里调db.memory.transaction.total.max,但治本还是控制路径规模。
4.3 现象:MERGE 导入数据后出现重复兴趣节点
原因:MERGE 匹配的是完整模式,如果两个兴趣名称大小写不同、前后有空格,会被当成不同节点。解决:导入前在 Python 或 Cypher 里统一toLower(trim(name))。另外,MERGE 在并发写入时可能产生重复,虽然 Neo4j 有锁,但高并发下仍建议先建唯一约束,让数据库直接拒绝重复。
4.4 现象:推荐接口 P99 延迟从 50ms 涨到 800ms
原因:随着图规模增长,没有索引的WHERE条件开始全图扫描。解决:用PROFILE或EXPLAIN看执行计划,确认是否走了 NodeIndexSeek。常见漏索引的字段包括User.city、Content.createdAt、Interest.level。另外,如果推荐查询里用了OPTIONAL MATCH且没有限制,也会拖慢,建议把可选匹配拆成独立查询再合并。
4.5 现象:Neo4j 内存占用持续上涨,最终 OOM
原因:页面缓存(page cache)配置过大,或者查询返回了超大结果集。解决:在 neo4j.conf 里把server.memory.pagecache.size设为物理内存的 50% 左右,不要超过 70%。查询侧强制加LIMIT,禁止无限制MATCH (n) RETURN n。如果做图算法(如 PageRank),用 GDS 库的流式模式,不要一次性把全图拉进内存。
5. 进阶技巧:用 GDS 做兴趣社群发现和离线评估
图推荐做到后面,纯 Cypher 会碰到瓶颈:想算用户相似度、想跑社区发现、想做离线 AUC 评估,手写查询又慢又难维护。Neo4j 的 Graph Data Science(GDS)库就是干这个的。下面是一个用 GDS 做兴趣社群发现的最小流程,先投影子图,再跑 Louvain,最后把社群 ID 写回节点:
// 1. 投影一个只含 User 和 INTERESTED_IN 的子图 CALL gds.graph.project( 'interestGraph', ['User', 'Interest'], { INTERESTED_IN: { orientation: 'UNDIRECTED' } } ); // 2. 跑 Louvain 社区发现,结果写入内存 CALL gds.louvain.write('interestGraph', { writeProperty: 'communityId', includeIntermediateCommunities: false }) YIELD communityCount, modularity; // 3. 查看社群数量和模块度 RETURN communityCount, modularity; // 4. 用完释放投影,避免占内存 CALL gds.graph.drop('interestGraph');逻辑说明:gds.graph.project把子图加载到内存,orientation: 'UNDIRECTED'表示忽略方向,适合兴趣共现。Louvain 的writeProperty把社群 ID 写回 User 节点,后续推荐可以加一条「同社群优先」的规则。modularity是模块度,一般大于 0.3 说明社群结构明显,低于 0.2 就要考虑是不是图太稀疏。跑完一定记得gds.graph.drop,否则投影图会一直占内存,这是很多人第一次用 GDS 会踩的坑。
离线评估方面,我一般会留出最近 7 天的交互做测试集,用 GDS 的gds.alpha.linkprediction算 Adamic-Adar 或共同邻居分数,再和线上推荐结果对比命中率。参数上,测试集比例控制在 10% 到 20%,太少评估不稳,太多训练数据不够。评估指标优先看 Recall@20 和 Coverage,前者衡量能不能召回用户真正喜欢的兴趣,后者衡量推荐是不是只集中在头部。
最后说个我自己的习惯:每次改完图模型或权重,先在小图上跑一遍PROFILE,确认没有全图扫描再上生产。图推荐这东西,玄学的地方在于路径深度和权重组合,但能解释、能复现的部分一定要用执行计划和离线指标卡死。希望帮到你。
本文还有配套的精品资源,点击获取