ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

用Neo4j构建豆瓣图书知识图谱:从数据清洗到推荐系统实战

用Neo4j构建豆瓣图书知识图谱:从数据清洗到推荐系统实战 简介基于豆瓣图书的推荐与知识图谱构建实践包面向人工智能、知识图谱方向的学习者、课程设计者及开发者演示如何借助Neo4j图数据库完成图书数据建模、个性化推荐与简易知识引擎搭建。压缩包共11个文件含4个CSV数据集、2个Python脚本、3张结果截图、1个说明文档及附属压缩包整体约14.12MB目录结构清晰便于对照代码理解从数据生成到图谱查询的完整流程。已有99人学习。内容覆盖用户行为分析与协同过滤推荐、实体关系抽取、Cypher查询与搜索模块设计等关键环节可帮助读者快速复现项目、掌握图数据库在图书场景中的落地方法同时项目完整呈现从数据预处理、图模型构建到推荐结果可视化的闭环适合作为相关课程设计或毕业设计的参考实现也可为进一步扩展知识引擎能力提供可修改的基础代码与数据。1. 基于豆瓣图书的知识图谱推荐这个练习项目真正在练什么把豆瓣图书数据接进 Neo4j建一张覆盖作者、出版社、标签与评分的关系网再让这张网替你回答“这本书像什么”——这就是基于豆瓣图书的知识图谱推荐项目在做的事。它不追求把协同过滤做到多精确重点是你亲手走完从原始数据清洗、图建模、Cypher 导入到推荐查询和知识引擎封装的全链路。适合三类人正在交人工智能大作业的学生、毕业设计打算用 neo4j 构建知识图谱的开发者、以及想给自己的推荐系统增加可解释性的从业者。别把它当成又一个爬虫练手项目数据抓回来怎么建模、怎么喂给图库、怎么把图结构变成推荐理由才是这个项目真正值钱的部分。2. 数据准备与本体建模把豆瓣图书拆成图能懂的实体和关系2.1 豆瓣图书数据的常见形态书、作者、出版社、标签、评分不管数据是爬虫抓的还是手工整理的豆瓣图书项目的原始数据通常长这样一个 CSV 或 Excel 表格每一行是一本书字段包括 book_id、书名、作者、出版社、出版年、ISBN、豆瓣评分、评分人数、标签。麻烦的是作者和标签都是多值字段一本书可能有三四个作者、五六个标签分隔符可能是斜杠也可能直接就是中文逗号。评分字段也不干净既有 9.2 这样的浮点数也有“暂无评分”这样的字符串导入 Neo4j 之前必须统一洗掉。“知识图谱”这四个字落到这一步本质就是本体建模。你得先把现实世界里的书、作者、出版社、标签抽成实体类型把“谁写了谁”“谁属于谁”“谁被打上什么标签”抽成关系类型再决定哪些字段是属性而不是节点。这个决策会直接影响后面每一条 Cypher 查询的写法和推荐效果。比如把“作者”当成书的一个属性塞进去后面想做“共同作者推荐”就得先拆分字符串图数据库的路径优势完全用不上。2.2 本体模型设计哪些东西做节点哪些东西做属性常见做法是建四类节点、四类关系。书、作者、出版社、标签各是一类节点书的评分、出版年、ISBN、书名做属性作者名、出版社名、标签名也做属性。关系这边书到作者用 WRITTEN_BY书到出版社用 PUBLISHED_BY书到标签用 TAGGED_AS。如果你想做“喜欢这本书的人也喜欢”这种强关联还可以额外加一类 SIMILAR_TO 关系在数据里直接存关联书的 ID 列表。节点标签关键属性来源字段Bookbook_id, title, rating, pub_year, isbn主表Authorname作者字段拆分后去重Publishername出版社字段去重Tagname标签字段拆分后去重关系类型起点 → 终点备注WRITTEN_BYBook → Author可反向查询作者的书PUBLISHED_BYBook → Publisher默认没有属性TAGGED_ASBook → Tag推荐推荐的主干路径RATEDUser → Book只有你拿到用户行为数据才需要这里最容易纠结的是豆瓣评分到底做属性还是做关系。如果这个项目只做基于内容的图书推荐评分留在 Book 节点上就够用按评分过滤和排序都方便如果以后要上协同过滤就得引入 User 节点把评分建模成 (User)-[:RATED {score: 5}]-(Book) 的关系属性。我的建议是第一步按内容推荐来做别一开始就把用户维度加进去数据清洗量会直接翻倍。2.3 用 Python 清洗数据并导出 CSV一个可复用的脚本数据清洗阶段的目标是产出三个节点文件和四个关系文件每个文件去掉表头问题、空值问题和多值问题。下面这段脚本处理最常见的“一行书、多值作者/标签”场景import pandas as pd df pd.read_csv(douban_books_raw.csv, encodingutf-8) # 只保留有书名的行评分里的非数字统一清掉 df df[df[title].notna()] df[rating] pd.to_numeric(df[rating], errorscoerce).fillna(0) # 按斜杠拆多值字段保持 book_id 关联 def expand_column(df, col): records [] for _, row in df.iterrows(): values [v.strip() for v in str(row[col]).split(/) if v.strip()] for v in values: records.append({book_id: row[book_id], value: v}) return pd.DataFrame(records) authors_df expand_column(df, author) tags_df expand_column(df, tags) # 节点文件 df[[book_id, title, rating, pub_year]].to_csv(books.csv, indexFalse) authors_df.drop_duplicates().to_csv(authors.csv, indexFalse) tags_df.drop_duplicates().to_csv(tags.csv, indexFalse) # 关系文件 authors_df.rename(columns{value: author_name}).to_csv(rel_written_by.csv, indexFalse) tags_df.rename(columns{value: tag_name}).to_csv(rel_tagged_as.csv, indexFalse)这段脚本的核心是 expand_column它把一个多值字段拆成多行同时保留 book_id 作为外键这样后面在 Neo4j 里执行 LOAD CSV 时每读一行都能通过 book_id 精确 MATCH 到书节点。pd.to_numeric 配合 errorscoerce 会把“暂无评分”这类脏数据变成 NaN再 fillna(0) 补成 0避免后续转换报错。如果你手里的数据分隔符不是斜杠而是逗号或中文顿号把 split(/) 换成对应分隔符即可这是唯一的改动点。3. Neo4j 落地社区版安装、内存配置与 LOAD CSV 导入3.1 Neo4j 社区版下载、安装与内存配置Neo4j 社区版是本地跑这个项目最省事的图数据库选择开箱即用也够支撑几万本书的查询量。下载后解压Windows 下直接跑 bin\neo4j.bat consoleLinux 或 macOS 下跑 bin/neo4j console。启动前一定要改内存配置否则默认堆内存很可能把你的开发机卡死这几行参数是我在低配机器上常用的配置# 编辑 $NEO4J_HOME/conf/neo4j.conf dbms.memory.heap.initial_size512m dbms.memory.heap.max_size1g dbms.memory.pagecache.size512mheap 是 JVM 堆内存主要给查询执行和结果集缓存用pagecache 是图数据的页缓存给磁盘上的节点和关系文件做读缓存。图书项目数据量通常只有几百 MB1g 堆 512m 页缓存完全够用机器只有 8G 内存就不要往上加。改完配置再启动浏览器打开 http://localhost:7474默认账号 neo4j初始密码 neo4j第一次登录会让你改密码。3.2 用 LOAD CSV 把节点和关系灌进图库启动之后先把 import 目录里的数据文件放进去Neo4j 的 LOAD CSV 只认 $NEO4J_HOME/import 下的文件。导入顺序有讲究先建约束再导节点最后导关系。约束的作用是让 MATCH 能命中唯一节点同时顺便去重不建约束的话重复导第二次会生成一堆重复节点。CREATE CONSTRAINT book_id IF NOT EXISTS FOR (b:Book) REQUIRE b.book_id IS UNIQUE; CREATE CONSTRAINT author_name IF NOT EXISTS FOR (a:Author) REQUIRE a.name IS UNIQUE; CREATE CONSTRAINT publisher_name IF NOT EXISTS FOR (p:Publisher) REQUIRE p.name IS UNIQUE; CREATE CONSTRAINT tag_name IF NOT EXISTS FOR (t:Tag) REQUIRE t.name IS UNIQUE; LOAD CSV WITH HEADERS FROM file:///books.csv AS row CREATE (b:Book {book_id: row.book_id, title: row.title, rating: toFloat(row.rating), pub_year: row.pub_year}); LOAD CSV WITH HEADERS FROM file:///authors.csv AS row CREATE (a:Author {name: row.value}); LOAD CSV WITH HEADERS FROM file:///tags.csv AS row CREATE (t:Tag {name: row.value}); LOAD CSV WITH HEADERS FROM file:///rel_written_by.csv AS row MATCH (b:Book {book_id: row.book_id}) MATCH (a:Author {name: row.author_name}) MERGE (a)-[:WRITTEN_BY]-(b); LOAD CSV WITH HEADERS FROM file:///rel_tagged_as.csv AS row MATCH (b:Book {book_id: row.book_id}) MATCH (t:Tag {name: row.tag_name}) MERGE (b)-[:TAGGED_AS]-(t);这段 Cypher 要分五次执行。books.csv 里我用 toFloat(row.rating) 把评文字符串转成浮点数这一步如果你前面 Python 清洗没做干净这里就会报 Could not convert to Float。关系导入必须用 MATCH 先定位两端的节点再用 MERGE 建关系CREATE 会在重复执行时产生二重边。这里的 MERGE 不是可选步骤图模型里同一种书作者关系只能有一条MERGE 是常态。3.3 索引、约束与导入完成后的检查书籍数据量到一万本以上时LOAD CSV 会开始变慢这时候可以用 USING PERIODIC COMMIT 控制批大小USING PERIODIC COMMIT 500 LOAD CSV WITH HEADERS FROM file:///rel_tagged_as.csv AS row MATCH (b:Book {book_id: row.book_id}) MATCH (t:Tag {name: row.tag_name}) MERGE (b)-[:TAGGED_AS]-(t);PERIODIC COMMIT 的意思是每处理 500 行提交一次事务避免单个大事务把堆内存打爆。写完 LOAD CSV 后建议查一下节点数和关系数确认导入没缺量MATCH (n) RETURN count(n) AS node_count; MATCH ()--() RETURN count(*) AS rel_count;count 返回的数值应当和 Python 导出的行数对得上。比如 books.csv 有 8000 行Books 节点就应该是 8000rel_tagged_as.csv 有 32000 行TAGGED_AS 关系也应该是 32000。对不上就回头看清洗脚本的 drop_duplicates 是不是把合法重复也去掉了尤其是不同书有同名作者时Author 节点可以只有一条WRITTEN_BY 关系则必须保留全部。4. 基于知识图谱的图书推荐三种 Cypher 查询让推荐有据可依4.1 推荐为什么能从图里长出来共同作者、标签共现、阅读链知识图谱推荐和协同过滤最本质的区别是协同过滤看的是用户行为矩阵图谱推荐看的是实体之间的路径。A 书和 B 书共享同一个作者那 B 就可能值得推荐共享同一个标签同理如果一条路径从 A 出发经过作者再经过另一本书再回到某个标签那这条路径本身就是推荐理由。你在 Neo4j 里写一段模式匹配就是在给推荐结果找“证据链”这让推荐结果可以解释也适合在毕业论文或答辩里展示。4.2 三个核心推荐查询从单路径到多跳第一个查询是共同作者推荐逻辑最简单找目标书的作者再看这些作者还写了哪些书按共现作者数量排序。MATCH (b:Book {title: 挪威的森林})-[:WRITTEN_BY]-(a:Author)-[:WRITTEN_BY]-(other:Book) WHERE other b RETURN other.title AS title, count(*) AS shared_authors ORDER BY shared_authors DESC LIMIT 10;这个查询的模式是从 Book 反向走到 Author再从 Author 正向走到其他 Book。count(*) 统计的是“共同作者数量”一本书如果被同一个作者写过两次比如再版可能重复计数所以严谨一点应该写 count(DISTINCT a.name)。LIMIT 10 控制推荐列表长度实际项目里可以把它放到上层接口的请求参数里。第二个查询是共同标签推荐也是最常用的推荐路径MATCH (b:Book {book_id: 12345})-[:TAGGED_AS]-(t:Tag)-[:TAGGED_AS]-(other:Book) WHERE other b RETURN other.title AS title, count(DISTINCT t) AS shared_tags ORDER BY shared_tags DESC LIMIT 10;这段查询把 tag 当作桥梁统计目标书和其他书共享的标签数量。这里务必使用 count(DISTINCT t)因为同一本书可能同一条 TAGGED_AS 关系重复导入过会导致 shared_tags 虚高。只看共享标签数量有个问题热门标签比如“小说”“经典”几乎人手一个会让推荐结果偏向大众书后面我会在第 5 章讲怎么过滤。第三个查询是多跳路径推荐适合在图谱里挖“隐式相似”。比如找和目标书共享至少两个标签、且目标书的作者也参与过的其他书MATCH (b:Book {title: 百年孤独})-[:TAGGED_AS]-(t:Tag)-[:TAGGED_AS]-(candidate:Book) WHERE candidate b WITH b, candidate, collect(DISTINCT t.name) AS shared_tags WHERE size(shared_tags) 2 MATCH (b)-[:WRITTEN_BY]-(a:Author)-[:WRITTEN_BY]-(candidate) RETURN candidate.title AS title, shared_tags, count(DISTINCT a) AS author_score ORDER BY size(shared_tags) DESC, author_score DESC LIMIT 10;这个查询先用 WITH 把候选集合压缩到共享标签不小于两个的书再走作者路径把作者共现数量作为第二排序条件。这里的 WHERE size(shared_tags) 2 既是过滤也是去重能把大量只有“小说”一个共性标签的书排掉。4.3 让推荐可解释输出完整路径而不是只有书名推荐系统的工程落地难点不是召回而是说服用户“为什么给我推这本”。知识图谱的优势恰恰在这里把查询改成 RETURN 路径上的中间实体推荐理由就自动生成了MATCH (b:Book {title: 百年孤独})-[:TAGGED_AS]-(t:Tag)-[:TAGGED_AS]-(other:Book) WHERE other b RETURN other.title AS title, collect(DISTINCT t.name) AS reason_tags, count(DISTINCT t) AS score ORDER BY score DESC LIMIT 10;collect(DISTINCT t.name) 返回的是一个数组比如 [“魔幻现实主义”, “拉美文学”, “经典”]上层接口可以直接把它拼成“因为你也喜欢魔幻现实主义、拉美文学、经典”。这段查询给推荐结果加了解释字段项目里叫“可解释推荐”在毕业论文里是个很好的加分点。5. 避坑Neo4j 中文图书数据项目中的 5 个常见问题5.1 导入变慢或导入进程卡死内存与批大小设置不当现象LOAD CSV 执行到一半就没响应Neo4j 日志出现 GC overhead limit exceeded甚至整个进程被系统杀掉。原因文件太大默认的 LOAD CSV 事务把所有行一次性加载进堆内存加上你用 CREATE CONSTRAINT 时图库正在重建索引双重压力把内存打满了。解决把配置里的 dbms.memory.heap.max_size 至少提到 1g并在每个 LOAD CSV 语句前加 USING PERIODIC COMMIT 500手动控制事务批大小。另外先导节点再导关系不要在导节点的同时加所有约束建约束放在导节点之前分批执行。5.2 中文乱码、标签丢字CSV 编码与分隔符不一致现象导入后查询书名显示为“⽇本⽂学”或者某个字直接变成问号标签更是可能整段丢失。原因Windows 上用 Excel 另存的 CSV 默认是 ANSI/GBK 编码Neo4j 的 LOAD CSV 按 UTF-8 读取就会出现乱码有时是 BOM 头问题导致第一列字段名带看不见的 \ufeff 前缀。解决统一在 Python 里用df.to_csv(books.csv, indexFalse, encodingutf-8-sig)导出utf-8-sig 会写入 BOM 头Neo4j 读起来最稳。如果 CSV 已经存成 GBK先用iconv -f GBK -t UTF-8 books.csv books_utf8.csv转码再放进 import 目录。5.3 LOAD CSV 报列不存在或类型转换失败表头空格与脏数据现象报错信息为column rating not found或Could not convert to Float。原因CSV 表头里带了空格比如ratingLOAD CSV WITH HEADERS 按列名精确匹配就直接找不到类型转换失败则是因为 rating 字段里有空字符串toFloat() 会直接报错。解决清洗阶段把所有列名统一做 strip用 pandas 的df.columns [c.strip() for c in df.columns]rating 字段统一 fillna(0) 和 pd.to_numeric保证没有空串。这个问题的隐蔽性在于 LOAD CSV 不会定位到具体行排查时可以用LOAD CSV WITH HEADERS FROM file:///books.csv AS row RETURN row LIMIT 5先看前几行原始值。5.4 查询从一个节点出发连出多条关系时超时现象用户在 Neo4j 里写MATCH (b:Book)-[]-()-[]-() RETURN ...这类多跳查询数据量到几万节点后响应要十几秒甚至直接超时。原因中间结果集爆炸。比如一本书有 10 个标签、每个标签有 2000 本书两跳之后的候选集就是 10 × 2000 20000 个中间节点全在内存里排序。解决这是 Cypher 查询优化问题常见的做法是先用 WITH 把候选集压缩再做后续匹配。也就是我在 4.2 里写的WITH b, candidate, collect(...) WHERE size(shared_tags) 2让下一跳匹配只针对少量候选执行。另外尽量在叶子节点上用WHERE other b提前过滤别等查询完再处理。5.5 推荐结果全是热门书冷门书永远没有曝光现象无论选哪本书做种子推荐列表里都被“小说”“经典”“文学”这类大众标签霸榜冷门书、新书完全没有机会。原因标签度数分布极不均匀少数标签覆盖了绝大多数书共同标签数量排序天然偏向大众书。解决在清洗阶段给标签加权重或导入时过滤掉高频标签。最常见做法是统计每个 Tag 关联的 Book 数量把关联书数超过总书数 10% 的标签视为“停用词”直接从 TAGGED_AS 关系里剔除。冷启动的图书可以回退到出版社推荐按 PUBLISHED_BY 关系找同出版社的书这是图谱里的兜底路径。6. 知识引擎的简单构建用 Python 把图查询封装成推荐服务知识引擎这个词听起来很高落到这个项目里其实就是一层查询路由与结果组装的服务你把书名和推荐策略传进去它负责拼 Cypher、连 Neo4j、执行查询、把路径结果包装成带理由的 JSON 返回。加上一层简单关键词判断就能做到类似“语义层”的效果——不懂 Cypher 的上层业务也能拿到推荐结果。from neo4j import GraphDatabase class BookKnowledgeEngine: def __init__(self, uri, user, password): self.driver GraphDatabase.driver(uri, auth(user, password)) def recommend_by_tag(self, title, limit10): query MATCH (b:Book {title: $title})-[:TAGGED_AS]-(t:Tag)-[:TAGGED_AS]-(o:Book) WHERE o.title $title RETURN o.title AS title, collect(DISTINCT t.name) AS reasons, count(DISTINCT t) AS score ORDER BY score DESC LIMIT $limit with self.driver.session() as session: result session.run(query, titletitle, limitlimit) return [record.data() for record in result] def close(self): self.driver.close() engine BookKnowledgeEngine(bolt://localhost:7687, neo4j, your_password) print(engine.recommend_by_tag(百年孤独, limit5)) engine.close()这段代码里最重要的一点是参数化查询$title 和 $limit 由驱动层直接绑定值不要用 f-string 拼进 Cypher否则会有注入风险这也是做知识引擎接口时最容易翻车的地方。recommend_by_tag 返回的 reasons 字段来自 collect(DISTINCT t.name)上层拿到后直接展示“推荐理由魔幻现实主义、拉美文学、经典”推荐的可解释性就在这里体现。我个人的习惯是把查询和路由分开写引擎只负责执行 Cypher 和返回结构化的记录路由层根据 strategy 参数决定走 tag 还是 author 还是 publisher 查询这样后面加新的推荐策略不用动引擎代码。这个项目做到这个程度已经足够作为“基于知识图谱的推荐与知识引擎构建”的完整实践交付。验证时建议手动挑 5 本冷门书打印出每本书的推荐理由肉眼检查路径是否合理如果理由都是“小说、文学”这类大路货就回头调整停用词阈值。希望帮到你。本文还有配套的精品资源点击获取
返回列表