
简介一套基于知识图谱的豆瓣书籍推荐可视化与问答系统 Python 源码及部署资料包主要面向计算机、人工智能等专业学生的课程设计、毕业设计或初期立项演示。压缩包共 181 个文件约 14.62MB以 Python/JS/CSS 源码、HTML 页面、JSON 数据及 GIF 演示录屏为主涵盖爬虫、Neo4j 图数据库构建、LTP 分词问答及前端可视化等完整模块。已有 1001 人学习下载。内含可直接运行的 app.py 主入口、配置好的图数据库构建脚本creat_graph.py、查询脚本query_graph.py以及爬虫数据images 和 json并附有详细的部署说明可帮助快速跑通从数据处理、图谱构建到问答交互的完整链路。1. 大二课设选「基于知识图谱的豆瓣书籍推荐可视化问答」值不值先把这个标题翻译成人话用 Python 把豆瓣书籍的标题、作者、出版社、评分、标签整理成一张知识图谱存进图数据库再基于图谱里的关系做书籍推荐用网页把知识点之间的关系画出来最后搭一个能回答「某本书的作者是谁」「推荐类似的书」这类问题的问答系统。一个大二课程设计覆盖了爬虫、数据清洗、图数据库、推荐算法、前端可视化和 NLP 入门六个技术点做完之后简历上和答辩 PPT 里能讲的东西比同组同学多出一大截。我见过不少同学拿到这样一份带 Python 源码和部署说明的压缩包后第一反应是解压、装依赖、跑 main.py结果在数据导入这一步卡一下午。这类项目的真实难度不在写代码而在把脏数据喂进图数据库、让前后端和 Neo4j 正确连起来。它值不值得做取决于你想混学分还是真想练手。这篇我按自己做过类似方案的思路把它拆到可以照着复现的程度。2. 数据建模与 Neo4j 导入把豆瓣书籍数据变成可查询的图2.1 数据从哪来爬虫方案与离线数据集的边界这个项目的第一道坎是数据。豆瓣书籍页面的结构相对稳定直接爬取可控但要注意请求频率另一个方案是找一份现成的豆瓣书籍公开数据CSV 格式含书名、作者、出版社、评分、标签字段导入成本低但字段质量不稳定。我的建议是两条腿走路先用一份离线数据把全流程跑通再补爬少量新书验证爬虫代码能不能用。如果选爬虫方案核心是一个定向抓取脚本只取书籍详情页的标题、作者、出版社、出版年份、评分、评分人数、标签这些字段# fetch_book.py - 定向抓取单本书详情页字段 import time import requests from bs4 import BeautifulSoup HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/122.0 Safari/537.36, Accept-Language: zh-CN,zh;q0.9, } def fetch_book(book_id: str) - dict: url fhttps://book.douban.com/subject/{book_id}/ resp requests.get(url, headersHEADERS, timeout10) if resp.status_code ! 200: print(f[warn] {book_id} 返回 {resp.status_code}) return None resp.encoding utf-8 soup BeautifulSoup(resp.text, html.parser) title soup.select_one(h1 span).text.strip() rating soup.select_one(.rating_num).text.strip() rating_count soup.select_one(.rating_people span).text.strip() tags [a.text for a in soup.select(.tags-body a)] # 从 #info 信息栏里抽作者和出版社 info_text soup.select_one(#info).text author 未知 publisher 未知 for line in info_text.splitlines(): if 作者 in line and author 未知: author line.split(:, 1)[-1].strip() if 出版社 in line and publisher 未知: publisher line.split(:, 1)[-1].strip() return { book_id: book_id, title: title, author: author, publisher: publisher, rating: float(rating), rating_count: int(rating_count), tags: tags, } if __name__ __main__: # 用一批已知 book_id 循环抓取,每次间隔 2-4 秒 for bid in [1007305, 1770782, 1084336]: data fetch_book(bid) if data: print(data) time.sleep(2 hash(bid) % 3) # 随机化间隔,避免固定节奏逻辑说明这段代码只抓一本书并解析字段。.rating_num取分数.rating_people span取评分人数.tags-body a取全部标签作者和出版社从#info区域的文本里按行匹配关键字匹配到就截取冒号后面的值。返回字典方便后续拼成 DataFrame 或直接写 CSV。参数说明timeout10给慢网络留余量time.sleep的随机间隔是血泪经验固定 2 秒比随机 2 到 4 秒更容易被识别为机器行为User-Agent用浏览器版是为了让服务端看到的是正常客户端特征。演示规模控制在 500 到 1000 本书足够别贪多后面图查询和前端渲染都会感谢你。2.2 实体与关系建模四类节点和五类关系怎么定知识图谱的核心表达是「实体—关系—实体」三元组。对这个项目实体四类就够Book书籍、Author作者、Publisher出版社、Tag标签。关系按语义定义关系起点终点含义written_byBookAuthor某书由某作者撰写published_byBookPublisher某书由某出版社出版tagged_asBookTag某书带有某标签similar_toBookBook两本书被判定为相似靠标签重合度生成user_ratedUserBook用户评分可选做评分过滤时用这个建模的核心思路是「实体薄、关系多」。常见翻车点是把「评分」做成节点或者把「出版社」做成 Book 的属性。评分应该放在 Book 节点上因为推荐里它是过滤条件不是连接点出版社单独成节点是为了能回答「某出版社出过哪些高分书」这类纯图查询问题。数据清洗阶段有三个高频问题作者字段存在「XX 著」「XX、YY 译」等混合写法要用正则拆出主要作者出版社名称存在主社和副牌的差异用包含关系做归并评分和评分人数可能是空字符串要统一转成 0 或 NaN避免 Cypher 类型报错。清洗逻辑放独立脚本clean_data.py不要和导入逻辑混在一起后期调数据会轻松很多。2.3 批量导入 Neo4jCSV 准备与 LOAD CSV 实操数据清洗完导出三份 CSVbooks.csv含 book_id、title、author、publisher、pub_year、rating、rating_count、book_tags.csvbook_id 与 tag 的对应行。放到 Neo4j 安装目录的 import 文件夹再执行 Cypher 导入// 导入 Book 节点 LOAD CSV WITH HEADERS FROM file:///books.csv AS row MERGE (b:Book {book_id: row.book_id}) SET b.title row.title, b.author_name row.author, b.publisher_name row.publisher, b.pub_year toInteger(row.pub_year), b.rating toFloat(row.rating), b.rating_count toInteger(row.rating_count);这段是节点导入。MERGE而不是CREATE是为了幂等重复执行不会产生重复节点toInteger和toFloat处理类型转换CSV 里所有字段都是字符串不显式转换后面排序和过滤会出现各种玄学问题。book_id用豆瓣的 subject id天然唯一比用书名做 MERGE 条件可靠——同名书不少同 id 不会重。// 创建作者节点并建立 written_by 关系 LOAD CSV WITH HEADERS FROM file:///books.csv AS row MERGE (a:Author {name: row.author}) MERGE (b:Book {book_id: row.book_id}) MERGE (a)-[:written_by]-(b);// 导入标签及 tagged_as 关系 LOAD CSV WITH HEADERS FROM file:///book_tags.csv AS row MERGE (t:Tag {name: row.tag}) MERGE (b:Book {book_id: row.book_id}) MERGE (b)-[:tagged_as]-(t);逻辑说明第二段先 MERGE 作者再 MERGE 书最后 MERGE 关系。关系的方向定义为 Bookwritten_byAuthor查询时从书出发写(b)-[:written_by]-(a)更顺。标签关系同理一本书十几个标签就是十几行最终图里 Tag 节点的度数会很高这正好是后面推荐的依据。导入完先做两个验证查询再往下走// 看图规模,确认导入没跑偏 MATCH (n) RETURN labels(n) AS type, count(*) AS cnt; // 抽查一本具体的书,看它的标签 MATCH (b:Book {title: 三体})-[:tagged_as]-(t:Tag) RETURN t.name LIMIT 20;参数说明第一句返回每种标签的节点数Book 数量应等于 CSV 去重行数如果 Book 数远大于预期说明 book_id 没对齐或 CSV 有重复行。第二句把书名换成你的目标书能查出标签说明 tagged_as 关系建对了。到这一步知识图谱底座就有了后面推荐和问答都在这张图上跑。3. 基于知识图谱的书籍推荐元路径查询与排序参数3.1 为什么选元路径推荐而不是协同过滤课程设计的背景决定了推荐算法不能是黑匣子。传统协同过滤CF要算用户-物品矩阵、做相似度计算还需要用户行为数据而这个数据集里「用户」是缺席的——豆瓣评分是聚合结果不是单个用户的行为记录。元路径推荐恰好绕开这个问题它只利用书籍之间的属性联系顺着图结构找「和你喜欢的那本书有共同作者、共同标签」的候选书。元路径就是图上的一个关系序列最常用的三条元路径含义适用场景Book -tagged_as- Tag -tagged_as- Book有共同标签找同类型书泛化能力强Book -written_by- Author -written_by- Book有共同作者找同一作者作品精准但候选少Book -published_by- Publisher -published_by- Book同出版社找同社书系通常作辅助信号3.2 Cypher 实现共同标签路径与打分排序推荐接口的核心是一条 Cypher 查询。以「找与《三体》最像的 10 本书」为例MATCH (b:Book {title: 三体})-[:tagged_as]-(t:Tag)-[:tagged_as]-(cand:Book) WHERE cand b AND cand.rating 7.0 AND cand.rating_count 100 WITH cand, count(t) AS shared_tags MATCH (cand)-[:written_by]-(a:Author)-[:written_by]-(b) WITH cand, shared_tags, count(a) AS shared_authors RETURN cand.title, cand.rating, shared_tags, shared_authors, shared_tags * 2 shared_authors * 5 AS score ORDER BY score DESC, cand.rating DESC LIMIT 10;这是整个项目最关键的一段查询。第一段 MATCH 找所有和《三体》共享至少一个标签的候选书WHERE 里的两个过滤是调参重点——rating 7.0过滤低分书rating_count 100过滤掉评分人数太少、分数不可信的冷门书。第二段 MATCH 计算候选书和源书共享作者的数量。最后 score 是加权打分每共享一个标签加 2 分每共享一个作者加 5 分。参数说明rating_count阈值按数据分布调整数据量大就收紧到 1000量小就放宽。score 权重 2/5 来自实践对比标签太宽泛「小说」「文学」「经典」会让标签路径全是噪声提高作者权重能把真正相关的书排到前面。这个权重在你的系统里做成配置项答辩时讲「我对比过 2/5、1/3、1/1 三组权重最终按相关性选了 2/5」比单纯贴代码有说服力。3.3 冷启动兜底与可解释性输出元路径推荐的最大问题是冷启动新书标签关系少或评分人数太少过不了过滤。常见做法是加一个回落策略fallback当候选书不足 3 本时降级为按标签聚合推荐# fallback_recommend.py - 冷启动兜底 def recommend_by_tag(tag_name: str, limit: int 10): query MATCH (b:Book)-[:tagged_as]-(t:Tag {name: $tag}) WHERE b.rating_count 50 RETURN b.title, b.rating ORDER BY b.rating DESC LIMIT $limit # 用 neo4j 驱动执行 query,标签名和 limit 必须参数化要强调参数化$tag和$limit用参数传入不要用 f-string 拼进 Cypher。中文书名里带引号或特殊字符时字符串拼接轻则查不到数据重则产生注入风险。参数化之后这些问题全部消失。可解释性是这个项目容易拿分的点。推荐结果里附带shared_tags和shared_authors两个字段前端可以渲染成「因为和《三体》共享【科幻】【刘慈欣】等 4 个标签推荐给你」。答辩时把推荐理由放在卡片上老师一眼就能看出这不是黑匣子模型的随机输出。4. 可视化与问答系统实现ECharts 关系图 模板匹配问答4.1 Flask 后端与 Neo4j 驱动的数据接口整体架构是 Cypher 在 Neo4j 里算Flask 把结果转 JSON 给前端。先搭最小后端# app.py - Flask 后端主文件 from flask import Flask, jsonify, request from neo4j import GraphDatabase app Flask(__name__) driver GraphDatabase.driver( bolt://localhost:7687, auth(neo4j, 你的密码) ) app.route(/api/graph, methods[GET]) def graph(): 返回以某本书为中心的子图(一跳内),供前端关系图渲染 title request.args.get(title, 三体) query MATCH (b:Book {title: $title})-[r]-(n) RETURN b, r, n LIMIT 200 with driver.session() as session: records session.run(query, titletitle).data() nodes, edges [], [] for rec in records: nodes.append({id: rec[b][title], category: book}) nodes.append({id: rec[n][title], category: other}) edges.append({source: rec[b][title], target: rec[n][title]}) return jsonify({nodes: nodes, edges: edges})逻辑说明这个接口只查一跳关系返回节点去重后约几十个前端关系图能撑住。要做两跳以上子图必须在 Cypher 里做节点截断只保留度数前 20 的中间节点否则一个高热度节点会把图撑爆。with driver.session()确保连接用完即还长跑服务不会吃满连接池。参数说明bolt://localhost:7687是 Neo4j 默认 bolt 端口auth里的密码是 Neo4j 首次启动设置的默认neo4j/neo4j会在首登强制改密。LIMIT 200是防御性参数没有它一个大热标签会把子图撑到几千节点浏览器直接卡死。4.2 问答系统模板匹配加实体识别不上大模型的理由大二课设阶段别碰大模型——成本和不确定性太高。模板匹配方案足够支撑答辩演示而且每行逻辑都能讲明白。核心两步先识别问题意图再抽书名实体。# qa.py - 模板匹配问答 import re import jieba # 意图模板: 正则 - 意图名 INTENT_PATTERNS [ (r^(.*?)的作者(是|是谁|有哪些)$, query_author), (r^(.*?)的评分(是|是多少|多少分)$, query_rating), (r^(有没有|推荐|找).*?(类似|像|相似).*?(.*?)$, query_similar), (r^(.*?)是哪年出版的$, query_pub_year), ] def parse_question(text: str): for pattern, intent in INTENT_PATTERNS: m re.match(pattern, text.strip()) if m: groups list(m.groups()) # 书名实体取最后一个非空分组,并去掉语气词和书名号 entity [g for g in groups if g][-1].strip(的。《》 ) return intent, entity return None, None def answer(intent: str, entity: str): if intent query_rating: # 这里接 Neo4j 查询,返回评分 return f{entity} 的评分是 9.2 (示例) return 暂不支持该问题逻辑说明这个解析器假设问题结构是「书名 问点」比如「《三体》的作者是谁」「红楼梦的评分是多少」。query_similar的正则把「类似」「相似」放中段这样「有没有和《XX》类似的书」「推荐《XX》类似的书」两种问法都能命中。实体提取用正则会有遗漏所以加了strip清洗书名号和语气词。参数说明如果你发现「三体的作者」被识别成「体」这种短实体别急着改正则先用 jieba 加词典# 自定义词典,把书名整体切成一个词 jieba.add_word(三体) jieba.add_word(百年孤独) jieba.add_word(人类简史)这是问答模块最大的坑jieba 的分词粒度是词级别遇到书名这种专名会拆开。把演示要用的十几本书名add_word准确率立刻从「看人品」变「稳定命中」。这是规则系统的典型后门参数——不通用但对固定演示集有效。4.3 前端关系图渲染的三个必调参数前端用 ECharts 的 graph 类型核心配置是这三处// graph_view.js - ECharts 关系图关键配置 option { series: [{ type: graph, layout: force, // 力导向布局,节点会自己摊开 roam: true, // 允许拖拽缩放,答辩时很好用 force: { repulsion: 300, // 节点间斥力,值越大图越松 edgeLength: [60, 120] // 边的目标长度范围 }, label: { show: true, fontSize: 12 }, data: nodes, links: edges }] };参数说明repulsion: 300对应几百节点的子图节点太密就调大、太散就调小edgeLength控制边长短值越小图越紧凑太紧凑会导致标签互相遮挡。roam: true必须开否则答辩现场没法放大看局部关系。数据超过 500 节点时力导向明显掉帧所以前面 Flask 接口的LIMIT才是真正的性能开关前端配置只是兜底。架构上Flask 渲染一个 index.html页面里一个 div 放 ECharts 图、一个输入框放问答栏、一个区域放推荐卡片。不需要单独前端工程一个 HTML 加一个 JS 就能讲清楚前后端交互。5. 部署与运行避坑5 个最常翻车的点5.1 Neo4j 启动后浏览器访问不了数据现象neo4j console正常启动浏览器打开 http://localhost:7474 一直转圈或连接拒绝日志却无报错。原因Neo4j 5.x 默认 HTTP 监听地址绑定 localhost如果你用的是远程服务器或虚拟机本机浏览器访问的地址对不上另一个高频原因是系统防火墙拦了 7474 端口。解决确认 Neo4j 版本5.x 在 neo4j.conf 里把server.http.listen_address设为0.0.0.0:7474。先在本机curl http://localhost:7474验证通不通通了再排查防火墙。课程设计 90% 场景是本机跑打不开先试127.0.0.1:7474。5.2 LOAD CSV 报 Couldnt load the external resource现象执行 LOAD CSV 提示文件路径读不到或中文文件名乱码。原因Neo4j 的 LOAD CSV 只能读 import 目录下的文件你用绝对路径如 C:/Users/xx/books.csv会被安全机制拒绝。中文文件名在 Windows 上会因编码不对显示成乱码路径。解决把 CSV 复制到安装目录下的 import 文件夹Cypher 里写file:///books.csv三斜杠指 import 根目录。文件名全用英文数字如果必须用中文先用文本编辑器另存为 UTF-8 编码再导入——这一步是实操经验编码不对会在 LOAD CSV 阶段静默丢字段。5.3 前端关系图渲染卡死或白屏现象打开可视化页面浏览器卡死或 ECharts 画出来一片空白但控制台无报错。原因卡死是后端LIMIT没写返回上千个节点白屏通常是节点 id 重复ECharts 要求每个节点 id 唯一你用书名做 id 遇到同名书就会冲突graph 渲染直接失败。解决后端 Cypher 强制LIMIT 200值可调但必须有前端节点 id 不用书名改用book_id显示名单独存 title 字段。判断是不是 id 重复在浏览器控制台打印 nodes 数组看有没有同名项一查一个准。5.4 问答系统答非所问书名总被拆碎现象输入「三体的作者是谁」返回的不是作者而是抽取失败结果或实体识别成了单字。原因jieba 没把「三体」当一个词分词变成「三 / 体」正则捕获组取不到完整书名。两个字或四个字的书名最常出这类问题。解决加载用户词典 add_word。开项目时把要演示的书名收集到 dict.txt每行一个词放在项目根目录jieba.load_userdict(dict.txt)。把至少 20 本演示书加进去问答准确率从「看运气」变「稳定命中」同时模板正则里把书名限制在两个字符以上{2,}过滤单字误匹配。5.5 Python 连 Neo4j 报认证失败或连接被拒绝现象neo4j 驱动里auth填了(neo4j, 123456)运行报 auth failed或报 Failed to establish connection to bolt://localhost:7687。原因Neo4j 首次启动默认密码是 neo4j未改密就连接会被拒绝新版浏览器首次登录强制改密但 Python 代码还在用旧密码。连接被拒一般是服务没启动或 bolt 端口被占。解决先用 Neo4j 浏览器登录一次改好密码再把新密码同步到 app.py 的auth。端口被占用时用netstat -ano | findstr 7687Windows或lsof -i:7687Linux查看占用进程然后把 Neo4j 的server.bolt.listen_address改到 7688 并同步代码。部署时记住「先启动 Neo4j 再跑 Flask」能避开一大半这类问题。6. 从课设到加分作品两个值得做的进阶点6.1 把评分从简单平均改成加权分豆瓣原版就用的加权算法课程设计里复刻一遍答辩讲「我参考了 IMDb 的加权评分公式」比单纯用 rating 排序高半个档次。# weighted_rating.py - 加权评分 def weighted_rating(rating, rating_count, min_count100): # 加权公式: 让低评分人数的分数向基准收敛 base 8.0 # 全局平均分基线 return (rating * rating_count base * min_count) / (rating_count min_count)参数说明min_count是置信阈值评分人数低于它分数会向基线base拉高于它原始评分占主导。把这个函数替换推荐 Cypher 里的cand.rating排序条件能让「只有 5 个人打了 9 分」的书不再排在「2000 人打了 8.7 分」的书前面。6.2 用验证集确认推荐不是玄学我在这个方向上吃过亏自己标的推荐结果怎么看都合理换一本新书就翻车。后来养成习惯留 20 本书做验证集对每本书跑算法取 top10人工标注其中几本确实是同类型或同作者算出 topN 命中率。命中率 60% 以上说明权重设置合理低于 40% 优先检查标签是否太宽泛而不是算法写错。这两个点做完课程设计就从「能跑」升级成「能讲」数据导入可复现推荐权重有对比问答稳定命中还有一组人工验证数据撑着。最后说一个我的习惯每次改完权重或数据结构先把 Neo4j 里的图清空重灌一遍确认导入脚本是幂等的。这个习惯让我在答辩前一周避免了「演示到一半数据被改没了」的尴尬。希望帮到你。本文还有配套的精品资源点击获取