简介:一套基于知识图谱的豆瓣书籍推荐可视化与问答系统 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;q=0.9", } def fetch_book(book_id: str) -> dict: url = f"https://book.douban.com/subject/{book_id}/" resp = requests.get(url, headers=HEADERS, timeout=10) 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。
参数说明:timeout=10给慢网络留余量;time.sleep的随机间隔是血泪经验,固定 2 秒比随机 2 到 4 秒更容易被识别为机器行为;User-Agent用浏览器版是为了让服务端看到的是正常客户端特征。演示规模控制在 500 到 1000 本书足够,别贪多,后面图查询和前端渲染都会感谢你。
2.2 实体与关系建模:四类节点和五类关系怎么定
知识图谱的核心表达是「实体—关系—实体」三元组。对这个项目,实体四类就够:Book(书籍)、Author(作者)、Publisher(出版社)、Tag(标签)。关系按语义定义:
| 关系 | 起点 | 终点 | 含义 |
|---|---|---|---|
| written_by | Book | Author | 某书由某作者撰写 |
| published_by | Book | Publisher | 某书由某出版社出版 |
| tagged_as | Book | Tag | 某书带有某标签 |
| similar_to | Book | Book | 两本书被判定为相似,靠标签重合度生成 |
| user_rated | User | Book | 用户评分(可选,做评分过滤时用) |
这个建模的核心思路是「实体薄、关系多」。常见翻车点是把「评分」做成节点,或者把「出版社」做成 Book 的属性。评分应该放在 Book 节点上,因为推荐里它是过滤条件不是连接点;出版社单独成节点,是为了能回答「某出版社出过哪些高分书」这类纯图查询问题。
数据清洗阶段有三个高频问题:作者字段存在「XX 著」「XX、YY 译」等混合写法,要用正则拆出主要作者;出版社名称存在主社和副牌的差异,用包含关系做归并;评分和评分人数可能是空字符串,要统一转成 0 或 NaN,避免 Cypher 类型报错。清洗逻辑放独立脚本clean_data.py,不要和导入逻辑混在一起,后期调数据会轻松很多。
2.3 批量导入 Neo4j:CSV 准备与 LOAD CSV 实操
数据清洗完导出三份 CSV:books.csv(含 book_id、title、author、publisher、pub_year、rating、rating_count)、book_tags.csv(book_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, title=title).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 报 Couldn't 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 7687(Windows)或lsof -i:7687(Linux)查看占用进程,然后把 Neo4j 的server.bolt.listen_address改到 7688 并同步代码。部署时记住「先启动 Neo4j 再跑 Flask」,能避开一大半这类问题。
6. 从课设到加分作品:两个值得做的进阶点
6.1 把评分从简单平均改成加权分
豆瓣原版就用的加权算法,课程设计里复刻一遍,答辩讲「我参考了 IMDb 的加权评分公式」,比单纯用 rating 排序高半个档次。
# weighted_rating.py - 加权评分 def weighted_rating(rating, rating_count, min_count=100): # 加权公式: 让低评分人数的分数向基准收敛 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 里的图清空重灌一遍,确认导入脚本是幂等的。这个习惯让我在答辩前一周避免了「演示到一半数据被改没了」的尴尬。希望帮到你。
本文还有配套的精品资源,点击获取