电影知识图谱问答系统实战:Neo4j与规则解析构建可解释推荐
2026/9/23 7:26:56 网站建设 项目流程

简介:这份资源是面向计算机相关专业学生与项目实战学习者的毕业设计完整方案,主题为Python基于知识图谱的电影推荐问答系统,难度适中,适合用作大作业、毕设或知识图谱与推荐系统方向的练手项目。压缩包共44个文件,约1.15MB,以py源码、csv数据、xml配置、txt词表与png示意图为主,另含少量css、js、html前端文件及md说明文档,覆盖数据预处理、问题分类、模板匹配、图谱构建与服务端接口等模块。项目源码均经本地编译调试,可正常运行,并附有说明文档辅助理解整体流程。目前已有85人学习下载。读者可据此掌握从电影、类型、人物等数据到Neo4j图谱的构建思路,理解问句分类与模板化问答的实现方式,并参考目录结构快速定位核心脚本,为二次开发或答辩准备提供完整参考。

1. 电影知识图谱问答系统:从「看不懂推荐」到「问得出理由」

你打开一个推荐系统,它给你推了《星际穿越》。你心里冒出的第一个问题往往不是「还有没有类似的」,而是「为什么给我推这部」。传统协同过滤答不上来,它只知道「跟你口味相似的人还看了什么」,至于电影之间是导演相同、题材相近还是共享同一套世界观,它一概不知。知识图谱问答系统要解决的正是这个黑匣子问题:把电影、导演、演员、类型、评分这些实体和关系显式地存进图数据库,用户用自然语言提问,系统沿着图谱里的边去检索、推理,最后给出一个带解释的答案。

这套方案适合谁?正在做计算机毕业设计、想找一个既有工程量又不至于失控的选题的同学;也适合已经会写 Python、想补上「知识图谱 + 问答」这条链路的后端或数据方向从业者。它不需要训练大模型,核心工作量在数据清洗、图谱建模和查询意图解析上,一台普通笔记本就能跑通全流程。下面我按自己搭过一遍的顺序,把选型、构建、问答实现和踩过的坑讲清楚。

2. 技术选型与图谱 Schema:为什么是 Neo4j 加规则解析

2.1 存储层选 Neo4j 而不是关系库的理由

电影推荐问答的本质是「多跳关系查询」。比如「找一部由诺兰执导、汉斯·季默配乐、评分高于 8.5 的科幻片」,这条查询要跨越导演、配乐、类型、评分四类关系。用 MySQL 写就是四五个 JOIN 叠在一起,SQL 又长又难维护,改一个条件就要动整条语句。Neo4j 用 Cypher 表达同样的逻辑,是沿着图上的边一步步走,语义直观,而且多跳查询在索引命中的情况下响应能压到毫秒级。

常见做法是用 Neo4j 社区版,本地起一个实例即可,不需要集群。Python 侧通过官方驱动neo4j连接,版本上注意驱动 5.x 和 Neo4j 5.x 配套,混用 4.x 驱动连 5.x 服务端会在认证握手阶段报错,这是新手最容易翻车的地方之一。

2.2 电影知识图谱的节点与关系设计

Schema 设计决定了后面问答能回答哪些问题。我一般会先列出「用户可能问什么」,再倒推需要哪些实体和关系。电影领域高频问题集中在:某导演的作品、某演员参演的电影、某类型的推荐、某部电影的相似推荐、评分区间筛选。据此设计如下结构。

节点标签关键属性说明
MoviemovieId, title, year, rating, overview电影主体,rating 为均分
PersonpersonId, name导演与演员共用,用关系区分角色
Genrename类型,如科幻、剧情
Countryname制片国家
关系类型方向说明
DIRECTEDPerson → Movie导演关系
ACTED_INPerson → Movie参演关系,可带 role 属性
BELONGS_TOMovie → Genre类型归属
PRODUCED_INMovie → Country制片国家
SIMILAR_TOMovie → Movie相似度边,权重用共现或向量算

把导演和演员都建成 Person 节点、用关系类型区分,比拆成两个标签更省空间,查询时也少一层判断。SIMILAR_TO 这条边是推荐能力的来源,可以先用「共同类型 + 共同演员」的 Jaccard 相似度算出来,权重存在关系属性上,问答时按权重排序取 TopN。

2.3 环境搭建与依赖安装的最小命令

先把运行环境固定下来,避免后面因为版本漂移排查半天。Python 建议 3.9 到 3.11,太新的版本部分图算法库还没跟上。

# 创建独立虚拟环境,避免污染全局包 python -m venv venv # Windows 激活 venv\Scripts\activate # macOS / Linux 激活 source venv/bin/activate # 安装核心依赖 pip install neo4j==5.14.0 pandas==2.1.1 jieba==0.42.1 py2neo==2021.2.4

neo4j是官方驱动,负责连接和事务;pandas用来清洗 CSV 数据;jieba做中文分词,问答意图识别要用;py2neo提供更顺手的图操作封装,二选一即可,我习惯两者都装,批量导入用官方驱动,日常查询用 py2neo。装完先验证连接:

from neo4j import GraphDatabase # 连接本地 Neo4j,默认 bolt 协议端口 7687 driver = GraphDatabase.driver("bolt://localhost:7687", auth=("neo4j", "你的密码")) def test_conn(tx): result = tx.run("RETURN 'connected' AS msg") return result.single()["msg"] with driver.session() as session: print(session.execute_read(test_conn)) driver.close()

这段代码做的是建立驱动、开一个只读会话、跑一条最简单的 Cypher 验证链路。auth里的密码是 Neo4j 首次启动时设置的,忘了就去neo4j-admin重置。如果这里报ServiceUnavailable,九成是服务没起来或端口被占,先确认 Neo4j Desktop 或命令行实例处于运行状态。

3. 数据清洗与图谱批量导入:把 CSV 变成可查询的边

3.1 电影数据的字段清洗与去重

公开电影数据集常见问题是字段缺失和重复。title 里混着上映年份,比如「Toy Story (1995)」,得先用正则把年份抽出来单独存,否则按标题精确匹配会失败。评分字段有空值,直接参与均值计算会拉低结果,我一般用该电影已有评分的均值填充,或者干脆过滤掉评分记录少于 5 条的电影。

import pandas as pd import re # 读取原始数据,注意编码,中文数据常见 gbk movies = pd.read_csv("movies.csv", encoding="utf-8") ratings = pd.read_csv("ratings.csv", encoding="utf-8") # 从标题中抽取年份,生成独立列 def extract_year(title): match = re.search(r"\((\d{4})\)", title) return int(match.group(1)) if match else None movies["year"] = movies["title"].apply(extract_year) # 去掉标题里的年份括号,保留干净片名 movies["title"] = movies["title"].str.replace(r"\s*\(\d{4}\)", "", regex=True) # 计算每部电影的均分和评分人数 agg = ratings.groupby("movieId")["rating"].agg(["mean", "count"]).reset_index() agg.columns = ["movieId", "rating", "rating_count"] # 过滤评分人数过少的电影,避免噪声 agg = agg[agg["rating_count"] >= 5] movies = movies.merge(agg, on="movieId", how="inner") movies.to_csv("movies_clean.csv", index=False) print(f"清洗后电影数:{len(movies)}")

extract_year用正则捕获四位年份,str.replace把括号年份从标题里抹掉,保证后续按片名查询能命中。rating_count >= 5这个阈值是经验值,太低会让冷门烂片混进推荐,太高会砍掉大量长尾电影,5 到 10 之间按数据量调。清洗完一定要打印行数,和原始数据对比,掉太多说明正则或合并逻辑有问题。

3.2 用 Cypher 的 LOAD CSV 批量建节点

数据干净后导入 Neo4j。节点量在几万级别时,LOAD CSV是最省事的方式,不用写 Python 循环,直接在 Neo4j Browser 里跑。先把清洗后的 CSV 放到 Neo4j 的 import 目录下,这是硬性要求,路径不对会报找不到文件。

// 建唯一约束,既加速查询又防止重复节点 CREATE CONSTRAINT movie_id IF NOT EXISTS FOR (m:Movie) REQUIRE m.movieId IS UNIQUE; CREATE CONSTRAINT person_name IF NOT EXISTS FOR (p:Person) REQUIRE p.name IS UNIQUE; // 导入电影节点 LOAD CSV WITH HEADERS FROM 'file:///movies_clean.csv' AS row MERGE (m:Movie {movieId: toInteger(row.movieId)}) SET m.title = row.title, m.year = toInteger(row.year), m.rating = toFloat(row.rating) RETURN count(m);

MERGE而不是CREATE是关键,重复执行脚本不会产生重复节点,这在调试阶段能省很多事。toIntegertoFloat做类型转换,CSV 读进来默认都是字符串,不转的话后面按评分范围筛选会得到错误结果。约束要在导入前建好,否则大数据量下 MERGE 会退化成全表扫描,慢到怀疑人生。

3.3 关系边的导入与相似度计算

节点建完再连边。导演、演员、类型这些关系直接从数据集的字段拆出来。类型字段常是「Action|Adventure」这种竖线分隔,要先 split。

# 生成类型关系 CSV genre_rows = [] for _, row in movies.iterrows(): for g in str(row["genres"]).split("|"): genre_rows.append({"movieId": row["movieId"], "genre": g}) pd.DataFrame(genre_rows).to_csv("movie_genre.csv", index=False)
// 导入类型节点与归属关系 LOAD CSV WITH HEADERS FROM 'file:///movie_genre.csv' AS row MERGE (g:Genre {name: row.genre}) WITH g, row MATCH (m:Movie {movieId: toInteger(row.movieId)}) MERGE (m)-[:BELONGS_TO]->(g);

相似度边是推荐的核心,用共同类型数除以类型并集数算 Jaccard 系数,超过阈值就连一条 SIMILAR_TO。这一步在 Python 里算好写成 CSV 再导入,比在 Cypher 里做笛卡尔积高效得多。阈值我一般设 0.3,太低会连出一堆弱相关边,让推荐结果发散;太高则很多电影连不上边,推荐召回不足。导入完成后用一条查询验证图谱连通性:

MATCH (m:Movie)-[:BELONGS_TO]->(g:Genre) RETURN g.name AS genre, count(m) AS cnt ORDER BY cnt DESC LIMIT 10;

如果某个类型数量为 0,说明那批电影的导入漏了,回去检查 CSV 里 movieId 类型是否和节点一致。

4. 问答意图解析与 Cypher 模板:让自然语言落到图上

4.1 基于规则与分词的问题分类

不训练模型的前提下,意图识别用「关键词 + 模板匹配」最稳。先把用户问题用 jieba 分词,再按关键词命中判断意图类别。电影问答的高频意图就那么几类:查导演作品、查演员作品、按类型推荐、查相似电影、按评分筛选。每类对应一个 Cypher 模板。

import jieba # 意图关键词表,命中即归类 INTENT_KEYWORDS = { "director": ["导演", "执导"], "actor": ["主演", "演员", "出演"], "genre": ["类型", "题材", "科幻", "喜剧", "动作"], "similar": ["类似", "相似", "像"], "rating": ["评分", "高分", "好看"], } def detect_intent(question): words = set(jieba.cut(question)) for intent, kws in INTENT_KEYWORDS.items(): if words & set(kws): return intent return "unknown"

jieba.cut把句子切成词集合,用集合交集判断命中,比字符串in更准,能避免「导演」被「演」这种子串误伤。关键词表要按实际语料补,比如用户爱说「片子」「电影」,就加进去。unknown兜底后可以返回一句引导语,而不是硬答。

4.2 实体抽取与 Cypher 模板填充

意图定了还要抽出问题里的电影名或人名,才能填进模板。实体抽取同样用规则:拿图谱里已有的片名和人名做匹配,谁出现在问题里就取谁。这比上 NER 模型轻量得多,对毕业设计规模的数据足够。

def extract_entity(question, known_names): # 长名优先,避免「星际」误匹配「星际穿越」 for name in sorted(known_names, key=len, reverse=True): if name in question: return name return None # 各意图对应的 Cypher 模板 CYPHER_TEMPLATES = { "director": """ MATCH (p:Person)-[:DIRECTED]->(m:Movie) WHERE p.name = $name RETURN m.title AS title, m.year AS year, m.rating AS rating ORDER BY m.rating DESC LIMIT 10 """, "similar": """ MATCH (m:Movie {title: $name})-[s:SIMILAR_TO]->(other:Movie) RETURN other.title AS title, s.weight AS weight ORDER BY s.weight DESC LIMIT 5 """, }

sorted(..., key=len, reverse=True)保证先匹配长名字,否则「星际穿越」会被「星际」截胡。模板里用$name参数占位,执行时传参,不要用字符串拼接,否则片名里带引号就会引发 Cypher 语法错误,这也是个隐蔽的坑。

4.3 执行查询并组装自然语言答案

拿到模板和实体后执行查询,把结果行拼成一句人话返回。这一步决定了系统「像不像在对话」。

def answer(question, session, known_names): intent = detect_intent(question) entity = extract_entity(question, known_names) if intent not in CYPHER_TEMPLATES or not entity: return "没太理解你的问题,可以换个说法,比如「诺兰导演过哪些电影」" cypher = CYPHER_TEMPLATES[intent] records = session.run(cypher, name=entity) rows = [r.data() for r in records] if not rows: return f"没有找到和「{entity}」相关的{intent}信息" if intent == "director": titles = "、".join(r["title"] for r in rows[:5]) return f"{entity}执导的作品包括:{titles}" if intent == "similar": titles = "、".join(r["title"] for r in rows) return f"和《{entity}》相似的有:{titles}" return str(rows)

session.run传参执行,r.data()把记录转成字典列表。返回前按意图做不同的措辞组装,导演类问题列作品,相似类问题列片名。空结果要给出友好提示而不是抛异常,用户体验差别很大。这套流程跑通后,一个最小可用的问答闭环就成立了。

5. 避坑与排查:那些让我重跑一整天的细节

5.1 导入报「Couldn't load the external resource」

现象是LOAD CSV直接失败,提示找不到文件。原因通常是 CSV 没放进 Neo4j 的 import 目录,或者用了绝对路径。Neo4j 出于安全默认只允许从 import 目录读文件。解决办法是把 CSV 拷进安装目录下的import文件夹,Cypher 里用file:///文件名的相对写法。如果确实要用别的路径,得改neo4j.conf里的dbms.security.allow_csv_import_from_file_urlsdbms.directories.import,但毕业设计没必要折腾,放 import 目录最省心。

5.2 中文片名匹配不上

现象是问「星际穿越」返回空结果,但图谱里明明有。原因是导入时标题带了首尾空格或全角括号,和用户输入不一致。解决是在清洗阶段统一str.strip(),并把全角括号转半角。查询侧也可以在匹配前对实体做一次归一化。血泪经验是:中文数据的空格和标点问题比英文多得多,导入前不处理,后面每个查询都要还债。

5.3 多跳查询越来越慢

现象是图谱涨到几万节点后,相似推荐查询从毫秒变成几秒。原因是 SIMILAR_TO 边没有索引,或者查询没走约束。解决是给高频查询的入口属性建索引,比如CREATE INDEX movie_title IF NOT EXISTS FOR (m:Movie) ON (m.title)。另外检查 Cypher 有没有写成MATCH (m:Movie) WHERE m.title = ...这种全表扫描,改成MATCH (m:Movie {title: ...})让优化器用上索引。索引不是越多越好,写多的场景下每个索引都会拖慢导入。

5.4 意图识别把「导演」误判成「演员」

现象是问「某某导演的电影」被当成演员查询。原因是关键词表里「导演」和「演员」都含「演」字,分词后集合交集可能同时命中。解决是调整判断顺序,把更具体的关键词放前面,或者用「先长词后短词」的匹配策略,命中「导演」就不再判「演员」。规则系统的通病就是关键词打架,靠优先级和词表维护来压。

5.5 相似度边把不相关电影连在一起

现象是推荐结果里出现八竿子打不着的片子。原因是 Jaccard 阈值设太低,或者只用了类型一个维度。解决是把阈值提到 0.3 以上,并叠加演员、导演维度的共现权重,综合打分后再连边。推荐质量对阈值非常敏感,建议先用小样本调,看 TopN 结果合不合理,再全量跑。

6. 把问答做成可演示的界面与效果验证

毕业设计最终要能演示,命令行问答不够看。最省事的做法是用 Streamlit 套一层 Web 界面,几十行代码就能出一个输入框加结果区,比 Flask 写前端快得多。

import streamlit as st from neo4j import GraphDatabase driver = GraphDatabase.driver("bolt://localhost:7687", auth=("neo4j", "你的密码")) st.title("电影知识图谱问答") question = st.text_input("输入你的问题,例如:诺兰导演过哪些电影") if question: with driver.session() as session: # 这里调用第 4 章的 answer 函数 result = answer(question, session, known_names) st.write(result)

st.text_input拿到问题,st.write渲染答案,known_names从图谱里查一次缓存起来即可,不用每次请求都拉。跑streamlit run app.py就能在浏览器打开。演示时准备几个典型问题:导演作品、相似推荐、类型筛选,覆盖不同意图,效果最直观。

验证系统好不好用,我一般看三个指标。一是意图识别准确率,手工标 50 条问题跑一遍,看分类对不对,低于 80% 就回去补关键词。二是查询响应时间,单跳查询应在 100 毫秒内,多跳相似推荐控制在 500 毫秒内,超了查索引。三是推荐结果的人工合理性,随机抽 20 部电影看相似推荐,明显不相关的超过三成,就调相似度阈值和维度权重。这三个指标不用写进论文当创新点,但自己心里得有数,答辩被问到才不慌。

最后说个我自己的习惯:图谱建好后先别急着写问答,用 Neo4j Browser 把每类查询手写一遍,确认 Cypher 逻辑和返回结果都对,再封装进 Python。我早期图省事直接在代码里调,结果 Cypher 写错了排查半天,分不清是查询问题还是代码问题。先把图查明白,再让代码去调,这个顺序能省下大量返工。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询