基于Neo4j的电影知识图谱问答系统构建与实现

发布时间:2026/9/11 20:59:13
基于Neo4j的电影知识图谱问答系统构建与实现 简介一套基于知识图谱与 Neo4j 图数据库的电影知识问答系统项目面向知识图谱方向研究者、Python 初学者以及准备毕业设计或课程大作业的高校学生。项目后端采用 Flask 框架通过爬虫采集电影数据完成实体识别和关系抽取后写入 Neo4j 图数据库再结合微信小程序提供自然语言问答界面从数据获取、知识存储、接口服务到前端展示形成完整工程闭环。资源压缩包共 55 个文件大小约 5.77MB以 Python 脚本、CSV 数据表格、JSON 配置文件为主同时包含 SVG 架构图、JavaScript/WXML/WXSS 小程序代码、Markdown 说明文档以及 PNG 图片目录结构清晰便于按模块阅读和二次开发。目前已有 423 人学习下载通过研读源码可以掌握 Flask 后端接口设计、Neo4j 图数据库建模与图查询、知识图谱构建流程以及小程序问答交互的实现思路对于课程设计、毕业设计或知识图谱实战入门均有较强的参考价值。整体工程可直接运行体验适合作为知识图谱应用落地的入门范例。1. 基于知识图谱和neo4j图数据库的电影知识问答系统在解决什么问题如果只用一个词概括这套系统的核心特征不是“智能”也不是“语义”而是“可解释的查询路径”。传统电影问答要么用Elasticsearch做关键词召回要么用大模型直接生成答案前者答不了“刘德华和梁朝伟合作演过哪些电影”这种二跳问题后者答不准还能一本正经地编片名。把电影数据建成知识图谱放进Neo4j本质上是把“问答”变成“图遍历”用户说一句话系统提取实体和意图生成一条Cypher沿着演员、电影、导演之间的边给出确定性的答案。这套方案在毕业设计和中小型知识库场景里非常常见整体是标准的三段式Neo4j存图谱、Python做自然语言处理、FastAPI做接口层。适合已经会基础SQL和Python、想完整跑通一个知识图谱项目的开发者。下面我会把从建图到问答上线的每个环节拆开讲代码按可以直接抄走的粒度给。2. 电影知识图谱的Neo4j建模从实体关系到约束索引2.1 为什么电影数据天然适合属性图而不是关系表电影领域里演员、导演、电影、类型这四类实体之间有明确的语义关系。用关系型数据库存回答“X导演拍过哪些评分超过8的科幻片”需要join三张以上表回答“两个演员是否合作过”需要做两次自关联随着演员数量增长join的代价是平方级上升。Neo4j把join变成路径遍历查询复杂度和图的局部规模相关和全库数据量弱相关这是图数据库在这个场景里的根本优势。另一个选型原因是Cypher对多跳查询的表达力。一条MATCH可以描述指定步数的合作关系路径这在SQL里要么写不出来要么写得极丑。问答系统做“推荐”类问题时这种多跳表达几乎是刚需。Neo4j社区版对这个体量的电影数据完全没有压力几百MB的CSV导入后查询依然在毫秒级不需要上分布式图计算。2.2 节点、关系与属性设计一个可落地的电影图谱模型节点控制在4类以内关系控制在5种以内最稳定。节点设计如下表。节点类型关键属性说明Moviemovie_id, title, rating, release_date, plot电影实体movie_id取外部数据源主键Personperson_id, name, birth_date, gender演员和导演共用Person节点用关系区分身份Genrename类型节点独立成点便于按类型遍历Keywordname可选做标签补充不需要可去掉关系类型设计如下。关系起点终点属性ACTED_INPersonMoviecharacter角色名DIRECTEDPersonMovie-BELONGS_TOMovieGenre-Person共用节点的取舍要说明。演员和导演共用Person靠关系边区分职业好处是“既是导演又是演员”的实体只存一份坏处是查询时必须显式指定关系类型否则会把两类结果混到一起。Genre独立成节点而不是做成Movie的字符串属性是因为“演过科幻片的女演员”这类查询需要从Genre节点反向遍历字符串属性做不到这一点。2.3 约束、索引与Neo4j导入前的准备工作Neo4j导入前先建约束。movie_id和person_id设唯一约束title和name加普通索引。约束本身就是索引唯一约束保证同一字段的值不重复MERGE依赖它防重。不建索引时按name定位Person会触发全库扫描数据到十万条量级时一次查询能到秒级问答系统根本没法用。CREATE CONSTRAINT movie_id_unique IF NOT EXISTS FOR (m:Movie) REQUIRE m.movie_id IS UNIQUE; CREATE CONSTRAINT person_id_unique IF NOT EXISTS FOR (p:Person) REQUIRE p.person_id IS UNIQUE; CREATE INDEX title_index IF NOT EXISTS FOR (m:Movie) ON (m.title); CREATE INDEX name_index IF NOT EXISTS FOR (p:Person) ON (p.name);第一行是Neo4j 5.x的语法旧版4.x写CREATE CONSTRAINT ON (m:Movie) ASSERT m.movie_id IS UNIQUE。加IF NOT EXISTS让脚本可以重复执行重启环境不会报错。索引建在问答查询的入口字段上后面按电影名或人名定位节点靠的就是这两个索引。建索引时有个常见坑不要把release_date存成字符串Cypher里做年份过滤时要先转换。CSV里统一成yyyy-MM-dd格式导入时用date()函数转换存成原生Date类型后面写“1990年到2000年之间的电影”这类查询才能直接用比较运算符。3. 用LOAD CSV把电影数据集构建成Neo4j知识图谱3.1 数据源整理与neo4j import目录常见做法是准备三份CSVmovies.csv、persons.csv、acted_in.csvDIRECTED关系可以放在persons.csv里用一列标注也可以单独一份。字段统一UTF-8无BOM编码字段内如果有逗号整个字段要用双引号包起来否则LOAD CSV解析会错位。文件放到Neo4j安装目录下的import文件夹这是社区版的默认导入路径。neo4j.conf里dbms.directories.import控制这个路径改成别的目录的话LOAD CSV里的file:///相对路径也要跟着变。3.2 先MERGE节点再MATCH建关系常见的错误做法是先把所有数据一股脑塞进一条LOAD CSV语句里节点和关系一起MERGE这样很容易因为文件行顺序导致关系先建、端点还没创建。正确顺序是先建所有节点再建所有关系。LOAD CSV WITH HEADERS FROM file:///movies.csv AS row MERGE (m:Movie {movie_id: row.movie_id}) ON CREATE SET m.title trim(row.title), m.rating toFloat(row.rating), m.release_date date(row.release_date), m.plot row.plot ON MATCH SET m.title trim(row.title);这段代码里MERGE按movie_id匹配已存在的节点不存在就创建。ON CREATE SET只在新建节点时执行ON MATCH SET在节点已存在时更新字段。trim()去掉标题首尾空格toFloat()把CSV里的字符串评分转成浮点数避免后续ORDER BY时按字符串排序得到错误结果。Person节点同理用person_id做MERGE的定位键。3.3 关系导入的锚点定位与去重LOAD CSV WITH HEADERS FROM file:///acted_in.csv AS row MATCH (p:Person {person_id: row.person_id}) MATCH (m:Movie {movie_id: row.movie_id}) MERGE (p)-[r:ACTED_IN {character: row.character}]-(m);关系导入必须先MATCH两端的端点把Neo4j内部的节点引用拿到再进行MERGE。如果直接在MERGE语句里写{person_id: row.person_id}Cypher不会自动解引用到已有节点反而会尝试创建新的孤立节点。MERGE整条关系模式的好处是同一个人同一部电影的同角色记录重复出现时不会生成重复关系。角色名做在关系属性里而不是关系本身这样一行数据就是一个唯一的出演记录。3.4 验证导入结果与数据质量检查MATCH (m:Movie) RETURN count(m) AS movies; MATCH (p:Person) RETURN count(p) AS persons; MATCH (p:Person)-[r:ACTED_IN]-(m:Movie) RETURN count(r) AS acted_in_relations;三个count先确认数量级对不对。再查孤立节点通常是外键不匹配导致关系没建成MATCH (m:Movie) WHERE NOT (m)-[:ACTED_IN]-() RETURN m.title LIMIT 20;如果这里返回很多电影说明acted_in.csv里的person_id和persons.csv里的person_id对不上回去检查数据源。LOAD CSV导入失败时Neo4j日志里会给出具体行号最常见的报错是字段类型转换失败比如rating列里混了空字符串toFloat()抛异常。解决方法是预处理时把空值统一写成空字符串Cypher里用CASE WHEN row.rating THEN 0.0 ELSE toFloat(row.rating) END兜住或者直接用apoc.load.csv配合可空类型处理。4. 问答引擎核心实体识别、意图匹配与Cypher模板生成4.1 问答管线为什么不走模型微调这个规模的电影问答系统绝大多数实现是“规则模板为主词典分词为辅”不训练模型。原因是问题类型高度可枚举问演员、问作品、问导演、问合作、问类型推荐每种对应一个或几个Cypher模板。模板方案的可解释性好答错了能顺着日志追到具体是哪一步识别错这是模型方案做不到的。下面的实现都围绕模板问答展开。4.2 用jieba加载自定义实体词典Neo4j里的电影名和人名分词器默认不认识。常见的“我不是药神”会被切成“我/不是/药神”识别不出完整片名。解决方案是从图数据库导出所有title和name生成自定义词典让jieba优先按整词切分。import jieba jieba.load_userdict(data/entity_dict.txt) def extract_entities(question: str): tokens [w for w in jieba.cut(question) if w.strip()] entities [w for w in tokens if w in ENTITY_SET] return {tokens: tokens, entities: entities}load_userdict的优先级高于默认词典整词会被保留。ENTITY_SET是一个Python集合来自Neo4j的查询结果把Movie.title和Person.name都放进去。这里有个细节词典文件的每一行格式是“词 词频 词性”“我不是药神 100 nf”这种写法能显著提高命中率只写词不写词频效果也不差但遇到短名字时容易漏掉。4.3 意图模板表与Cypher生成逻辑意图识别用关键词规则。每个意图有一组触发词命中哪个关键词就选哪个模板。模板表如下。意图触发表达目标实体Cypher模板actor_films演过、主演、作品、出演PersonMATCH (p:Person)-[:ACTED_IN]-(m:Movie) WHERE p.name $name RETURN m.title, m.rating ORDER BY m.rating DESCfilm_cast谁演的、演员有、主演是MovieMATCH (m:Movie)-[:ACTED_IN]-(p:Person) WHERE m.title $title RETURN p.namedirector_films导演、执导、拍过PersonMATCH (p:Person)-[:DIRECTED]-(m:Movie) WHERE p.name $name RETURN m.titleco_actors合作、一起演过PersonMATCH (p1:Person)-[:ACTED_IN]-(:Movie)-[:ACTED_IN]-(p2:Person) WHERE p1.name $name AND p2.name $name RETURN DISTINCT p2.namegenre_top推荐、经典、高分 Genre名GenreMATCH (g:Genre {name: $genre})-[:BELONGS_TO]-(m:Movie) WHERE m.rating 8.0 RETURN m.title, m.rating ORDER BY m.rating DESC LIMIT 10生成Cypher时使用session.run(cypher, params)传参不用f-string拼接实体值。参数化不只是防注入更关键的是让Neo4j能复用查询计划重复问答时的执行开销比每次重新解析Cypher低得多。4.4 实体类型与槽位的匹配校验模板确定后还要确认实体到底填到$name还是$title。同样一个词“霸王别姬”可能是电影名也可能是别的实体名。所以实体识别后要做类型判断读一次Neo4j确认def resolve_entity_type(entity: str): with driver.session() as session: movie_hit session.run( MATCH (m:Movie {title: $name}) RETURN m.title LIMIT 1, nameentity ).single() person_hit session.run( MATCH (p:Person {name: $name}) RETURN p.name LIMIT 1, nameentity ).single() if movie_hit: return Movie if person_hit: return Person return None这段代码返回实体的类型标签问答主流程再根据intent要求的类型做匹配。如果问题要的是Person识别的实体却是Movie说明分词把片名误当晚人名此时直接返回“没找到该演员”比硬查Cypher更友好。类型校验这一步拦住了一大批查询结果为空的情况。4.5 兜底回答与日志留痕没有命中任何意图或者实体识别为空系统不能返回空数组。常见做法是维护一个提示语列表引导用户换一种问法同时把原始问题写进日志文件。后期如果想迭代成模型方案这些日志就是现成的训练集。这一步成本低但对体验提升非常明显。5. 用FastAPI封装问答服务并连接Neo4j5.1 neo4j Python Driver的初始化方式from neo4j import GraphDatabase class MovieQA: def __init__(self, uri: str, username: str, password: str): self.driver GraphDatabase.driver(uri, auth(username, password)) self.entity_dict self._load_entity_dict() def _load_entity_dict(self): with self.driver.session() as session: movies session.run(MATCH (m:Movie) RETURN collect(m.title) AS titles).single()[titles] persons session.run(MATCH (p:Person) RETURN collect(p.name) AS names).single()[names] return set(movies persons) def close(self): self.driver.close()driver是线程安全的连接池对象整个进程只初始化一次。不要在每次请求里创建新driver否则连接池会被反复耗尽Neo4j端会报“connection refused”。自建Neo4j社区版的默认bolt端口是7687HTTP端口7474GraphDatabase.driver传入的是bolt协议地址。如果用云上的图数据库实例USER和PASSWORD换成实例创建时设置的凭证。5.2 一个可用的/chat接口from fastapi import FastAPI from pydantic import BaseModel app FastAPI() qa MovieQA(bolt://localhost:7687, neo4j, your_password) class ChatRequest(BaseModel): question: str app.post(/chat) def chat(req: ChatRequest): result qa.answer(req.question) return {question: req.question, answer: result}answer方法内部把实体识别、意图匹配、Cypher执行串联起来。Pydantic的BaseModel做请求体校验前端传一个JSON对象字段名必须是question。返回结构固定成question和answer两个字段前端拿数据时不需要解析额外的嵌套结构。如果问答流程中某个环节抛异常FastAPI会按500返回但更好的做法是在answer内部捕获异常统一返回“这个问题我暂时答不上来”。5.3 接口端点与调试入口端点方法作用/chatPOST接收问题返回答案/healthGET检查服务与Neo4j连接状态/debug/cypherPOST传入问题和意图名称返回实际生成的Cypher与查询结果/debug/cypher这个端点是排错利器。问答系统答错时先看生成的Cypher对不对再判断是实体识别的问题还是Cypher本身的问题不用反复在前端页面里试。5.4 最小前端调试页不用引入脚手架一个HTML文件里写一个输入框和按钮请求/chat接口就能完成联调。前端发送的是POST JSONCORS中间件需要在FastAPI里配置app.add_middleware(CORSMiddleware, allow_origins[*], allow_methods[*])。本地调试阶段用通配符没问题部署到公网时要收紧成明确的域名列表。6. 上线前必看的PROFILE验证、中文分词边界与实体消歧6.1 用EXPLAIN和PROFILE确认查询走了索引同样的查询在Movie数据量超过5万条时有没有走索引性能差一个数量级。用PROFILE验证PROFILE MATCH (p:Person)-[:ACTED_IN]-(m:Movie) WHERE p.name 刘德华 RETURN m.title看执行计划的db hits数值。走了NodeIndexSeek的查询db hits是常量级别走了NodeByLabelScan的全库扫描db hits会等于Person节点总数。EXPLAIN不真正执行查询只生成执行计划适合看规划PROFILE会实际执行并返回统计信息两种都能用来确认索引是否生效。6.2 中文分词边界关闭HMM和不完全切词jieba默认开了HMM会把“周星驰”这种不在词典里的词拆成“周星”和“驰”。在自定义词典已经包含完整姓名的情况下可以关掉HMMjieba.cut(question, HMMFalse)。电影名与普通词冲突的情况也很常见“我和我的祖国”里的“我”会被当成代词丢掉解决方案是在entity_dict.txt里给“我和我的祖国”设置1000以上的高词频强制分词器按整词保留。6.3 同名导演与演员的消歧“王晶”既是导演又是演员问答系统直接按name匹配会同时命中两类节点。常见做法是先按问题语境过滤问题里出现“导演”一词优先走DIRECTED关系出现“主演”或“演员”优先走ACTED_IN关系。genre_top这一层判断放到意图匹配阶段做而不是放在实体识别阶段这样同一实体在不同的问法下可以进到不同的模板。如果两种关系都满足且没有足够的语境线索就返回消歧问句“您说的王晶指的是导演王晶还是演员王晶”用户确认后再执行查询。把这个逻辑写成一个函数放到问答管线的最后一段能让系统的准确率明显提升。关掉HMM、给电影名加高权重词频、对同名实体做反问消歧这三件事做完把第4章里所有意图模板各挑三个问题重跑一遍对比改动前后的命中率基本就能确认问答系统的上线状态了。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询