ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

知识图谱电影问答系统:从CSV数据清洗到Neo4j Cypher查询的Python实战

2026/10/3 8:51:13 拓冰建站 浏览量
知识图谱电影问答系统:从CSV数据清洗到Neo4j Cypher查询的Python实战 简介一份面向计算机专业毕业设计的高分完整项目基于Python构建知识图谱电影问答系统覆盖数据清洗、实体构建、图谱存储与问答交互的完整开发链路。项目经导师指导并获九十九分评审代码完整、可直接运行即使刚入门的新手也能参照说明逐步启动适用于毕业设计、课程设计及期末大作业等场景。资源包为zip压缩格式大小约六点三MB共一百一十六个文件包括十三份Python源码、八个HTML页面、七份CSS样式和多个JavaScript脚本还包含JSON、XML配置文件及电影、人物、类型等CSV关系数据为知识图谱搭建提供了完整数据支撑。前端页面与后端逻辑分层清晰并配有Bootstrap等样式组件方便二次修改与学习。该资源已有一百六十一人学习下载借助源码内说明文档、数据组织方式和工程配置读者可较快理解从零实现一个知识图谱问答系统的方法减少踩坑提高毕设完成质量。1. 知识图谱电影问答系统一份能讲清楚“数据到问答”的 Python 毕设如果你见过毕业答辩现场评委最常问的不是“代码能不能跑”而是“你这个项目到底解决了什么问题”。这套基于知识图谱的电影问答系统源码值得拿下的点就在这它把整个问题拆成了“数据建模—图谱构建—自然语言问答”三段每一段都能单独讲、单独改。我第一次拆这份资源时以为难点在回答模板怎么写结果发现最难的是电影名消歧和 Cypher 查询组装——同一个人名、同一部电影在 CSV 里和用户问句里的写法可能完全不一样。它适合正在做 Python 毕业设计、知识图谱课程设计或者想完整走一遍数据到问答全链路的人。前端用 Bootstrap 那套样式后端逻辑集中在数据清洗和问答模块不是一句句写死答案的玩具。2. 先拆文件再谈原理CSV 数据模型与系统三层架构2.1 只看文件名就能还原数据模型movie、person_to_movie、movie_to_genre拿到源码包我先把目录翻了一遍记住一件事真正决定问答效果的不是那些 CSS 文件而是三个 CSV——movie.csv、person_to_movie.csv、movie_to_genre.csv。前端那几个样式文件bootstrap.css、style000.css、font-awesome.css、owl.carousel.css、poposlides.css、circles.css、style.css只是把问答页面、结果列表这些展示层做得像点样子属于静态资源图谱导入和问答逻辑都不会碰它们。在常见建模方式里movie.csv 是电影主体记录电影 ID、电影名、评分、上映年份等信息person_to_movie.csv 是人物与电影的关联表至少包含人物标识、电影标识和角色类型有些版本里会直接把“导演”“演员”这类角色写成一个字段movie_to_genre.csv 负责把电影和喜剧、动作、科幻这些类型挂上钩。这三张表翻译成知识图谱就是两类节点加两类关系Movie、Person、Genre 是节点person_to_movie 映射成“出演/导演”关系movie_to_genre 映射成“属于”关系。文件内容在知识图谱里的角色movie.csv电影主体信息Movie 节点person_to_movie.csv人物与电影的关联Person 节点 出演/导演关系movie_to_genre.csv电影与类型的关联Movie 节点 属于关系*.css页面样式静态资源不参与问答逻辑从命名规则也能看出一件事这套资源不是单文件脚本而是一个小工程。movie.csv 与 movie_to_genre.csv 通过 movie_id 连接person_to_movie.csv 同时连接 Person 和 Movie 两类节点。关系是有方向的人演电影电影属于类型。在 Neo4j 里方向就是箭头Cypher 查询句法严重依赖方向导入时方向反了问答结果就全是反的。这张表列的是典型列结构不同版本列名会有些出入比如 person_to_movie.csv 里可能没有独立的人名列只有 person_id 和电影 ID那就要在清洗阶段把“人物节点”单独拆出来。这也给你一个判断标准如果导入后关系数量对不上优先检查关联表里的 ID 是不是字符串一致性出了问题而不是急着改问答代码。2.2 三层架构“数据清洗—图谱查询—问答组装”的选型理由我习惯把这类项目拆成三层来读。第一层是数据层CSV 进来以后先做清洗编码统一、去重、空值处理得到一批干净的节点和关系第二层是图谱层实体和关系落到 Neo4j用 Cypher 完成“查某人的作品”“查某片的导演”这类查询第三层是问答层接收用户问句解析出电影名或人名、判断意图、拼 Cypher、把结果拼回一句人话返回给前端。为什么会选 Neo4j 而不是 MySQL关键差别在“多跳关系”。问“周星驰主演过的喜剧片里评分最高的三部”MySQL 得写三四层 join图数据库用模式匹配一句话就出来了。对毕设来说图模型在答辩时也更容易讲清楚“多对多关系怎么建模”。但整套流程真正耗时的地方是数据清洗和实体匹配不是 Neo4j 本身。所以我拿到这份源码后的操作顺序是先看你手上的 CSV 能不能对上 ID再看 Neo4j 连接能不能通最后才动问答逻辑。问答请求链路通常是这样的前端提交问句Flask 路由收下交给一个问答服务类类里先做意图分类再抽取实体然后调知识图谱查询最后把结果格式化返回。由于数据量不大这种单体结构完全够用也比前后端分离更容易在答辩现场演示。还有一点值得说这套资源把数据放在 CSV 而不是数据库脚本里最大的好处是离线可读、方便 Python 处理也方便你替换成自己的数据集。只要保持列名不变问答逻辑几乎不用动。你完全可以换成漫威电影、动画番剧或者图书数据CSV 文件本身就是你的“数据接口”。这个收益在中期答辩时非常明显导师问“数据哪来的”你直接给 CSV 和清理脚本就能说清楚。2.3 那些 CSS 文件到底做什么用先分清静态资源和业务代码目录里有 bootstrap.css、style000.css、font-awesome.css、owl.carousel.css、poposlides.css、circles.css、style.css 一堆样式第一次见这股阵仗的初学者容易误以为这是下载来的整站模板。实际上这些文件只负责页面表现bootstrap 是最基础的栅格和组件样式font-awesome 提供图标owl.carousel 和 poposlides 是做轮播和滚动展示的插件样式circles 多半用于圆形图表style.css 是定制样式。它们的存在说明资源包带了一个相对完整的前端页面不需要你另找模板。处理方式是不用逐行读这些 CSS。把入口 HTML 模板打开找到引入 CSS 的标签就知道哪个文件作用于哪个模块。真要替换成自己的界面只保留 style.css 和 bootstrap.css 也够用。如果你只想跑通问答逻辑这些静态文件可以直接忽略它不会影响 Neo4j 导入和问答结果。拿到资源先分清“业务代码”和“静态资源”能省下不少无效阅读时间。3. 把 CSV 变成图谱数据清洗、实体对齐与 Cypher 导入3.1 用 pandas 清洗三份 CSV编码、空值、去重与列名规范清洗这一步决定后面百分之八十的成败。我拿到 CSV 第一件事是判断编码。用 Excel 改过或 Windows 环境下另存过的 CSV经常带有 BOM 头pandas 用默认 utf-8 读会把第一列列名读成带“\ufeff”的脏字符串另外 CSV 里某些字段因为含逗号会被解析成多列。所以读取时我一般固定用 utf-8-sig 和 enginepython。import pandas as pd movie pd.read_csv(movie.csv, encodingutf-8-sig, enginepython) person_to_movie pd.read_csv(person_to_movie.csv, encodingutf-8-sig, enginepython) movie_to_genre pd.read_csv(movie_to_genre.csv, encodingutf-8-sig, enginepython) # 后续 Cypher 里要用一致的主键先把列名固定成英文 movie.columns [movie_id, title, rating, release_date] person_to_movie.columns [person_id, person_name, movie_id, role] movie_to_genre.columns [movie_id, genre_name] # 主键必须不为空且不重复 movie movie.dropna(subset[movie_id]).drop_duplicates(subset[movie_id]) person_to_movie person_to_movie.dropna(subset[person_id, movie_id]) movie_to_genre movie_to_genre.dropna(subset[movie_id, genre_name]) # 关键一步主键统一转成字符串并去掉空格防止 Cypher 里数字和字符串不匹配 movie[movie_id] movie[movie_id].astype(str).str.strip() person_to_movie[movie_id] person_to_movie[movie_id].astype(str).str.strip() person_to_movie[person_id] person_to_movie[person_id].astype(str).str.strip() movie_to_genre[movie_id] movie_to_genre[movie_id].astype(str).str.strip()几个参数值得说清楚。encodingutf-8-sig 会在读取时自动吃掉开头的 BOM多花的时间几乎为零但能避免很多“列名对不上”的玄学问题enginepython 是让 pandas 用 Python 解析器而不是 C 解析器碰到字段里有逗号、引号错位时更稳缺点是慢但这份数据量根本感觉不出来。dropna 和 drop_duplicates 是实体唯一性的第一道防线因为 LOAD CSV 导入时如果主键重复MERGE 会静默覆盖或产生意外属性。astype(str) 也很有必要LOAD CSV 读进来的字段全部是字符串而 Excel 里的 ID 容易被存成数字两边不一致就容易匹配失败。清洗完以后如果你手上的 person_to_movie.csv 只有 person_id 和 movie_id没有单独的人物表需要额外拆一张 Person 节点表person person_to_movie[[person_id, person_name]].drop_duplicates() person.to_csv(person.csv, indexFalse, encodingutf-8-sig)person.csv 是给 Neo4j 导入“人物节点”用的中间文件。实际项目里还可能遇到同一个人名对应多个 person_id 的情况这就属于实体对齐任务放到后面小节讲。提示清洗脚本和导入脚本建议分开保存。清洗只产出 CSV导入负责写 Neo4j这样换数据集时不用改导入逻辑。3.2 Neo4j 建约束和索引先定义实体唯一性在导入数据前我强烈建议先在 Neo4j 里建好唯一约束。它有三个作用防止同一个电影、人物、类型出现重复节点加速后续查询告诉评审你的建模有“唯一性设计”。以下 Cypher 在 Neo4j 4.x 和 5.x 里都可以直接执行CREATE CONSTRAINT movie_unique IF NOT EXISTS FOR (m:Movie) REQUIRE m.movie_id IS UNIQUE; CREATE CONSTRAINT person_unique IF NOT EXISTS FOR (p:Person) REQUIRE p.person_id IS UNIQUE; CREATE CONSTRAINT genre_unique IF NOT EXISTS FOR (g:Genre) REQUIRE g.genre_id IS UNIQUE;注意这里给 Genre 节点加了 genre_id 属性它可以直接用 genre_name 作为 ID。约束一旦创建后面再往库里写入相同主键时CREATE 会直接报错所以数据导入脚本里才统一用 MERGE。如果你用的是老版本 Neo4j 3.x要写成 FOR (m:Movie) ON (m.movie_id) ASSERT m.movie_id IS UNIQUE很多教程没区分版本照抄会翻车。3.3 批量导入节点与关系LOAD CSV 与逐条写入的选择节点导入用 LOAD CSV 最省事。先把清洗好的 movie.csv 和 person.csv 放进 Neo4j 的 import 目录。在社区版默认配置里LOAD CSV 只能读 file:/// 开头的文件不能随便读磁盘任意路径这是 Neo4j 出于安全考虑的限制。LOAD CSV WITH HEADERS FROM file:///movie.csv AS row MERGE (m:Movie {movie_id: row.movie_id}) SET m.title row.title, m.rating toFloat(row.rating), m.release_date row.release_date; LOAD CSV WITH HEADERS FROM file:///person.csv AS row MERGE (p:Person {person_id: row.person_id}) SET p.name row.person_name;用 MERGE 而不是 CREATE是因为这条导入脚本你可能要跑好几次MERGE 保证按主键幂等执行重复跑不会生成重复电影。rating 字段这里用 toFloat 转一次否则 Cypher 里排序时会把评分按字符串排序“9.0”会排在“10.0”前面这类隐蔽问题在问答排名场景里特别容易踩。关系和节点不一样person_to_movie.csv 里的 role 字段决定了是“出演”还是“导演”用纯 Cypher 写条件比较绕我一般选择用 Python 的 neo4j driver 逐条建立关系。数据量只有几百上千条这种写法的性能完全够而且逻辑清楚from neo4j import GraphDatabase driver GraphDatabase.driver(bolt://localhost:7687, auth(neo4j, your_password)) def create_relations(person_to_movie_df): with driver.session() as session: for row in person_to_movie_df.itertuples(): if 导演 in str(row.role): rel_type DIRECTED else: rel_type ACTED_IN session.run( f MATCH (p:Person {{person_id: $pid}}) MATCH (m:Movie {{movie_id: $mid}}) MERGE (p)-[:{rel_type}]-(m) , pidrow.person_id, midrow.movie_id, )这里 f-string 只拼接关系类型关系类型是程序里算出来的不是用户输入没有注入风险而节点 ID 必须走参数避免拼接字符串碰到特殊字符。如果用 py2neo把 session.run 换成 graph.run逻辑一致。批量的另一种做法是先写出 relationship CSV 再用 LOAD CSV但需要保证 role 到关系类型的映射在文件里就已经处理好适合数据量更大时用。3.4 导入后自检孤立节点和重复关系怎么查导入完成不要急着写问答先做三分钟自检。先在 Neo4j Browser 里执行三个统计查询看看节点数和关系数是否符合预期MATCH (m:Movie) RETURN count(m) AS movies; MATCH (p:Person) RETURN count(p) AS persons; MATCH (p:Person)-[r]-(m:Movie) RETURN type(r), count(*) AS cnt ORDER BY cnt DESC;第三个查询如果出现多个关系类型说明 role 字段没洗干净。还有一种常见情况movie_id 在 CSV 里有前导空格或者 person_id 是数字格式导致关系表里的 ID 匹配不上电影节点结果就是一堆孤立电影。查孤立节点的语句要记住MATCH (m:Movie) WHERE NOT exists((m)--()) RETURN m.title LIMIT 20;有孤立节点不一定会报错但“查某导演的电影”时结果会莫名少几部。所以我的习惯是导入后先把这个查询跑一遍看到返回空列表才继续做问答层。4. 让自然语言落到图谱问句分类与 Cypher 查询组装4.1 意图分类规则模板为何是毕业设计的稳妥选择问答层最核心的模块是“把自然语言变成 Cypher”。现在大模型很火但毕设项目我一般不推荐直接上大模型做语义解析原因有四个需要 GPU、响应慢、结果不可控、评委追问一句“底层怎么实现的”很难讲清楚。规则模板虽然“笨”但每个分支都是你能解释的逻辑也足够覆盖电影问答的常见问法。先做意图分类。常见意图有四类查某人的作品、查某部电影的导演、查某部电影的类型、查评分排名。用关键词加优先级就能处理def parse_intent(question: str) - str: q question.strip() # 注意顺序谁导 先命中导演意图因为它比 导演 更具体 if any(w in q for w in [谁导, 导演]): return director_of if any(w in q for w in [主演, 演过, 出演, 参演]): return acted_in if any(w in q for w in [类型, 什么类型, 属于什么]): return genre_of if any(w in q for w in [评分最高, 评分从高到低, 排名]): return top_rated return unknown这个函数的边界条件是关键词顺序。比如“谁导演的”同时包含“导演”和“谁导”必须先匹配更具体的“谁导”“评分最高的科幻片”同时有“评分最高”和“类型”两个信号如果意图判断先命中 genre_of就会丢掉排名逻辑。所以我的方法是先抽实体再判断有没有排序词最后再分主意图。4.2 电影名/人名抽取基于实体词典的最大匹配问句里实体名是最不确定的部分。用户可能说“周星驰演的电影”也可能直接说“星爷的电影”而库里存的是全名。通用分词工具如 jieba会把“大话西游之大圣娶亲”切成“大话西游 / 之 / 大圣娶亲”只匹配“大话西游”会查出两条不同电影。所以更稳的做法是先把自己库里所有电影名、人名做成词典按长度倒序做包含匹配。def load_entities(graph): entities { movie: [r[title] for r in graph.run(MATCH (m:Movie) RETURN m.title AS title)], person: [r[name] for r in graph.run(MATCH (p:Person) RETURN p.name AS name)], } return entities def extract_entity(question: str, entities): q question.replace( , ).replace(\u3000, ) for name in sorted(entities[movie], keylen, reverseTrue): if name in q: return movie, name for name in sorted(entities[person], keylen, reverseTrue): if name in q: return person, name return None, None排序按长度倒序是这里的关键。如果不排序而按字典顺序“功夫”会先于“功夫熊猫”被命中用户明明问的是《功夫熊猫》最后查出来的却是《功夫》。“先长后短”就是最大匹配的核心思路。还有一个处理是去掉全角空格和普通空格因为用户在浏览器里输入的文本经常混入不可见字符如果不处理contains 匹配会失败。抽实体放在意图分类之前还是之后取决于问法。我实践下来的顺序是先抽实体再判断意图。因为“评分最高”这类词不依赖实体而“周星驰演过哪些喜剧”需要先知道实体是人名才能把“评分最高”和“出演”组合起来。顺序换一下代码会多出很多 if 分支。4.3 把意图映射成 Cypher 并拼接答案实体和意图都出来后剩下就是模板映射。每个意图对应一段 Cypher 模板用参数化查询把实体名传进去避免拼接字符串带来的引号问题。下面是一个简化版def build_cypher(intent: str, entity_type: str, entity_name: str): if intent acted_in and entity_type person: return MATCH (p:Person {name: $name})-[:ACTED_IN]-(m:Movie) RETURN m.title AS title, m.rating AS rating ORDER BY m.rating DESC if intent director_of and entity_type movie: return MATCH (m:Movie {title: $name})-[:DIRECTED]-(p:Person) RETURN p.name AS name if intent genre_of and entity_type movie: return MATCH (m:Movie {title: $name})-[:BELONGS_TO]-(g:Genre) RETURN g.name AS genre return None然后用统一入口执行def run_qa(question: str): intent parse_intent(question) entity_type, entity_name extract_entity(question, entities) if not entity_name: return [我还没学会回答这个问题试着换个说法] cypher build_cypher(intent, entity_type, entity_name) if cypher is None: return [这个问题缺少可执行的查询模板] result graph.run(cypher, nameentity_name).data() return format_answer(result, intent)format_answer 按意图拼答案比如 acted_in 就把每个 title 用顿号连接查不到时返回“库中没有找到相关作品”。这一步看起来简单但很重要很多源码在查不到结果时直接返回空页面用户体验很不好也容易被评委挑出边界场景。加上一个空结果兜底答辩时就能多说一句“我处理了查无此人、查无此片的情况”。5. 避坑与排查从环境初始化到中文匹配的高频故障5.1 环境初始化Python 版本、依赖版本与 Neo4j 认证这份源码的依赖集中在 py2neo 或 neo4j-driver、Flask、pandas 这几项。很多跑不通的问题都不是业务逻辑问题而是版本组合不对。我本地使用过的组合是 Python 3.8 Neo4j 4.4 py2neo 2021.2.3先把流程跑通再考虑迁移到更高版本。如果你用的是 Python 3.10 以上和 py2neo连接时容易出现加密协议相关报错这时候换成官方 neo4j-driver 会更省心。neo4j-driver 的写法是 GraphDatabase.driver而 py2neo 是 Graph(bolt://localhost:7687, auth...)区别只在驱动层业务代码基本不用动。认证问题几乎每届毕设都会遇到Neo4j 初次启动默认账号密码是 neo4j/neo4j首次登录会强制让你改新密码。如果导入脚本里写死旧密码要么连接报认证失败要么第一次能连、重启后就连不上。我的习惯是把连接参数统一放到一个 config.py 里密码、URI、数据库名都从环境变量读取避免答辩换机器时手忙脚乱。5.2 运行期高频故障乱码、中文匹配、LOAD CSV 权限、同名实体下面这五条是我拆这类型项目时遇到频率最高的坑每条都按现象、原因、解决来写坑 1pandas 读出来的第一列列名带着“\ufeff”。现象print(movie.columns) 第一个列名是 “\ufeffmovie_id”按 movie_id 取列时报 KeyError。原因CSV 被 Windows 下 Excel 保存过带 UTF-8 BOM。解决读取时加 encodingutf-8-sig一次性吞掉 BOM不要在清洗脚本里 str.replace 反复操作。坑 2LOAD CSV 一直提示找不到文件。现象执行 LOAD CSV WITH HEADERS FROM file:///movie.csv 报 Couldnt load the external resource。原因Neo4j 默认只允许从安装目录下的 import 目录读取 file:/// 开头的文件你把 CSV 放在了 D 盘任意目录。解决把三个 CSV 复制到 $NEO4J_HOME/import 目录重启 Neo4j 再执行如果实在不想挪文件也可以改 dbms.security.allow_csv_import_from_file_urlstrue但生产环境不建议这么放开。坑 3问“功夫”查不到库里明明有《功夫》。现象问答返回空列表用 Cypher 手动查又能查到。原因用户输入“功夫”没带年份库里存的是“功夫(2004)”或者电影名里混了全角空格。解决导入时给电影加一个 alias 属性把“功夫(2004)”的标题剥离年份后存成别名抽取实体时优先查 title再查 alias。这个内容我会在 6.2 里给出具体做法。坑 4评分排序结果不对。现象查“评分最高的电影”返回的是“9.0、8.7、10.0”10.0 排到了最后。原因rating 字段以字符串形式进入 Neo4j字符串排序按字符逐位比较。解决LOAD CSV 时用 toFloat(row.rating) 转类型如果已经在库里了用 SET m.rating toFloat(m.rating) 批量修正。坑 5同名演员把问答结果搞混。现象问“张国立演过哪些电影”返回里混进了另一位演员的作品。原因person 节点只按名字唯一约束没有区分出生日期或人物 ID。解决确认 person_id 在源数据里是否全局唯一如果唯一把关系建立全部改为按 person_id 匹配不要按 name 匹配。若源数据只有名字就需要额外维护一个别名表做消歧。页面问答时可以额外列出“同名人物”给用户选择这也是一个很实用的小功能。6. 把它改造成“你的”毕设答案校验与两个低成本技巧6.1 搭一个最小的答案校验脚本问答系统的通病是你只记得测试过成功的问法忘记验证失败的问法。我拿到这类源码后会先写一个十几行的冒烟测试集把每个意图的中文问法、预期答案都放进去跑一遍统计准确率。test_set [ (周星驰主演过哪些电影, [功夫, 大话西游]), (《功夫》的导演是谁, [周星驰]), (《大话西游》是什么类型, [喜剧]), (评分最高的科幻片, [星际穿越]), (张国立的作品, []), # 预期失败看兜底逻辑 ] correct 0 for question, expected in test_set: answer run_qa(question) hit len(set(answer) set(expected)) 0 correct int(hit) print(f{question}: {answer} - 期望 {expected} - {OK if hit else MISS}) print(f准确率: {correct / len(test_set):.2%})这里用集合交集判断命中而不是完全相等是因为答案顺序、标点不影响语义能容忍“大话西游”和“大话西游之月光宝盒”这类差异。每次修改问答模板或清洗逻辑后跑一遍这个脚本能很快发现哪一类问法被改坏了。这个方法也是答辩时可以说出口的“验证策略”。6.2 两个低成本改造jieba 候选词和前端实体提示第一个改造是给实体抽取加一层模糊兜底。用户输入“星爷”或“周星星”这类别称词典匹配会落空。常见做法是引入 jieba 切词后做候选召回但一定要把库里的电影名优先注册进词典import jieba for name in entities[movie] entities[person]: jieba.suggest_freq(name, True)suggest_freq 的作用是提高整词成词概率避免“大话西游之大圣娶亲”被拆碎。配合全量实体名做包含匹配比纯正则更抗造。这不会让你的准确率一夜暴涨但能把问句里“别名”“简称”的体验拉上去。第二个改造是前端输入候选列表。前端模板里给搜索框加一个数据源后端暴露一个 /api/entity/search 接口根据用户已输入的两个字返回前十个相似的电影名或人名。用户基本不会打错全名问答命中率自然就上去了。这个接口实现起来只有十几行但答辩演示时比纯空框好看得多。改造完以后我每次拿到新的 CSV 数据都会强制走一遍同样的流程清洗、建约束、导入、跑测试集。那次就是因为我偷懒跳过数据核对直接把新电影导进 Neo4j结果上映年份列错位评分排名整整乱了一晚上。从那以后数据进库前的自检脚本就成了固定动作希望帮到你。本文还有配套的精品资源点击获取