
简介基于知识图谱的电影问答系统是一套完整的NLP与知识图谱实战项目面向人工智能、自然语言处理方向的学习者与开发者演示从知识图谱构建、问句意图分类、实体识别到查询生成与答案输出的全流程。资源共52个文件压缩包约3.48MB核心为6个Python脚本覆盖建立图谱、构建词表、问题分类、查询解析和答案搜索另含5个csv与21个txt数据文件提供电影、演员、类型等实体关系及词表数据16张png集中展示BERT、TextCNN、ESIM、BiLSTMCRF、Seq2Seq等模型结构图、词云与实验对比图便于理解方案选型。目前已有439人学习下载。作者采用基于规则的类别判定与实体提取整体思路清晰、结构分明适合希望快速跑通电影问答闭环、并对照深度学习模型理解不同NLP技术路线的读者。通过阅读README和代码可学习图谱三元组构建、问句匹配、查询语句生成等关键环节并可直接替换数据集迁移到其他领域。1. 知识图谱让电影问答从“检索”变成“推理”我把一个基于知识图谱的电影问答系统完整跑了一遍发现它最值得拆的不是模型有多新而是那条“自然语言 - 实体 - Cypher - 答案”的流水线。整个项目的代码量不大先建立图谱再做问句类别判定然后抽取实体最后根据类别与实体构造查询并输出。它稳定回答“周星驰演过哪些电影”“《霸王别姬》的评分是多少”这类问题靠的是知识图谱本身的结构化语义而不是关键词检索。这套设计适合两类人一是刚入门自然语言处理NLP的学生想看清限定域问答的完整骨架二是已经在做检索式问答的工程师想换知识图谱路径来减少同义改写压力。需要先说明的是项目里的类别判定用的是规则方法没有上深度学习模型但图谱构建和查询生成部分是可以直接复用的。下面按构建顺序拆。2. 图谱构建用 py2neo 把 CSV 装进 Neo4j2.1 为什么要先有图谱而不是先做模型在电影问答这个场景里答案不是“从文档里抽出来”而是“从实体关系里算出来”。比如“周星驰演过哪些电影”如果用 ES 做检索需要提前把“周星驰演了电影”的句子都建索引换个说法“周星驰参演的作品”就搜不到。图谱的好处是关系即答案只要图谱里有(:Person {name:周星驰})-[:ACTED_IN]-(:Movie)这条边任何问法都能映射到同一条 Cypher 查询上。所以项目的第一步不是训练模型而是做结构化数据导入。输入文件是 movie.csv、person.csv、genre.csv 以及多对多的 movie_to_genre.csv、person_to_movie.csv。这些 CSV 来自爬虫或公开数据集绝大多数情况下是扁平表格需要自己把它拆成节点和关系。另一个容易被忽略的文件是“建立词表.py”它会把电影名、人名、类型名全部抽出来生成 vocabulary.txt 和 userdict3.txt后者供 jieba 加载保证后面实体抽取时不把“大话西游”切成“大话/西游”。2.2 实体标签和关系建模先确定知识图谱的 Schema。我的习惯是先用小表建模再写代码导入因为改 Schema 的成本比改代码高。以这个项目为例实体和关系可以设计成这样实体/关系标签/类型关键属性数据来源电影Movietitle, rating, release_datemovie.csv人物Personname, birth_dateperson.csv类型Genrenamegenre.csv出演关系ACTED_IN无person_to_movie.csv导演关系DIRECTED无person_to_movie.csv类型归属HAS_GENRE无movie_to_genre.csv关系方向统一写成 Person 指向 Movie、Movie 指向 Genre这样查询时读起来很顺(p:Person)-[:ACTED_IN]-(m:Movie)。如果后续想扩展“哪些演员合作过”反向遍历即可不需要额外建边。2.3 导入代码与约束用 py2neo 写导入脚本是常见做法。下面这段对应“建立图谱.py”的核心逻辑from py2neo import Graph, Node, Relationship import pandas as pd graph Graph(bolt://localhost:7687, auth(neo4j, your_password)) graph.run(MATCH (n) DETACH DELETE n) # 重建前清空避免重复 movies pd.read_csv(movie.csv) persons pd.read_csv(person.csv) movie_nodes, person_nodes {}, {} for _, row in movies.iterrows(): n Node(Movie, titlerow[title], ratingrow.get(rating)) movie_nodes[row[title]] n graph.create(n) for _, row in persons.iterrows(): n Node(Person, namerow[name]) person_nodes[row[name]] n graph.create(n)这段代码先建立节点索引字典再批量写入。用dict.get是因为 movie.csv 里不一定每行都有 rating缺省时存成None。auth参数里填的是 Neo4j 的用户名和密码bolt://localhost:7687是 Neo4j 默认的 bolt 协议端口如果用 http 接口就写http://localhost:7474但后面执行查询时 bolt 性能更好。节点建完后再建关系rels pd.read_csv(person_to_movie.csv) for _, row in rels.iterrows(): p person_nodes.get(row[person]) m movie_nodes.get(row[movie_title]) if p is None or m is None: continue rel_type DIRECTED if row[relation] 导演 else ACTED_IN graph.create(Relationship(p, rel_type, m))这里用continue跳过缺失实体避免因为脏数据挂掉。注意Relationship的第一个参数是起点第二个参数是关系类型第三个参数是终点关系类型顺序错了会导致查询方向反掉。导入完成后建议立刻建唯一约束防止后面重复导入时产生重复节点CREATE CONSTRAINT movie_title_unique IF NOT EXISTS FOR (m:Movie) REQUIRE m.title IS UNIQUE; CREATE CONSTRAINT person_name_unique IF NOT EXISTS FOR (p:Person) REQUIRE p.name IS UNIQUE;这段 Cypher 在 Neo4j 4.x/5.x 里都能跑。唯一约束等于给 title 和 name 建了索引后面按名称精确匹配实体时会快很多。提示不要省掉这一步否则多跑几次“建立图谱.py”之后同一个周星驰会有多个节点问答系统会出现重复答案。提示每次重建前用DETACH DELETE清库是最直接的做法。如果数据量大到几百万节点应该换neo4j-admin import或apoc.periodic.iterate批量导入避免 py2neo 一条条 create 太慢。3. 问句类别判定与实体抽取规则方法为何够用3.1 先分类再抽取实体顺序不能反问答系统拿到用户句子后如果直接抽实体会出现“评分”“导演”这样的词也被当成实体塞进图谱匹配。所以要先把问句映射到一个语义类别再用类别决定抽哪些实体。这个项目的 question_classifier.py 承担的就是这个职责。它用的是规则方法准备一批关键词问句命中哪个关键词就属于哪个类别。常见做法是把类别和触发词做成一张配置表。下面这张表是根据项目逻辑整理出来的question_type语义意图触发词示例需要抽取的实体movie_rating电影评分评分、几分、多少分moviemovie_release_date上映时间上映、什么时候、档期moviemovie_actor演员列表主演、演员、饰演movieactor_movie参演作品演过、出演、作品persondirector_movie导演作品导演、执导、拍过personmovie_genre类型类型、属于什么movie这张表里每一行都对应 question_parser 里的一条 Cypher 模板。所以分类表本身就是领域词典新增一个问法只是加一行词比重新训模型成本低。要注意“演员”这个词在“演员有哪些”里是意图触发词在“周星驰是演员吗”里是待判断的实体规则法的局限就在这里同义词覆盖靠人工补充边界案例要靠优先级。3.2 规则分类器的代码骨架下面是 question_classifier.py 中核心逻辑的简化版。它先维护语义词典再对每种意图做关键词命中判断class QuestionClassifier: def __init__(self): self.semantic_dict { movie_rating: [评分, 几分, 多少分], movie_release_date: [上映, 什么时候], actor_movie: [演过, 参演, 作品], director_movie: [导演, 执导], movie_genre: [类型, 属于什么] } def get_question_type(self, question): types [] for q_type, words in self.semantic_dict.items(): if any(w in question for w in words): types.append(q_type) return types这里的逻辑按照关键词包含关系给出类别列表。用any()比写多个 if 更清晰返回值是列表而不是单个字符串因为“周星驰导演过哪些电影”同时命中actor_movie的可能关键词“电影”以及director_movie的“导演”。如果不做多类别就会漏掉导演维度。实际项目里还会给每个类别加一个优先级比如“导演”命中时优先把实体当成 person。3.3 实体抽取自定义词典 最长匹配实体抽取这一步项目没有用 BiLSTMCRF 模型而是直接拿词表和问句做包含匹配。先用“建立词表.py”生成的人名表和电影名表作为候选实体库class QuestionClassifier: def __init__(self): self.person_names [line.strip() for line in open(person.txt, encodingutf-8)] self.movie_names [line.strip() for line in open(vocabulary.txt, encodingutf-8)] def extract_entity(self, question): entities {} for name in self.person_names: if name and name in question: entities[person] name break for title in self.movie_names: if title and title in question: entities[movie] title break return entities这段逻辑看起来简单但有两个坑。第一个坑是电影名可能是另一个电影名的子串比如《大话西游》和《大话西游之大圣娶亲》直接 break 会抽到短的那个。我一般先按长度从长到短排序命中后记录再用question.replace(name, )把已命中的实体挖掉避免重叠。第二个坑是 jieba 默认词典会把人名切开所以项目里特意生成了 userdict3.txt并在主流程里加载import jieba jieba.load_userdict(userdict3.txt)加载后jieba.lcut(周星驰演过的电影)会正确分出“周星驰”而不是“周星/驰”。需要说明的是这只是在分词层面保证了实体完整性真正做实体选择时仍以词表匹配为准。3.4 什么时候才需要换 BiLSTMCRF项目素材里出现了 BiLSTMCRF 和 TextCNN 的示意图说明作者对比过深度学习方法但最终选规则。原因有三个一是训练数据量不足以支撑一个泛化好的分类模型二是规则结果可解释线上出错可以直接改词典三是电影领域实体有限不用做大规模序列标注。但规则方法的边界也很明显。比如“女儿国国王的扮演者还演过什么电影”这里面“女儿国国王”是电影里的角色名不在 person.txt 中规则方法抽不出实体。遇到这种指代和角色映射就需要 CRF 做序列标注再配合实体链接。如果你想把这套系统开放给真实用户我建议保留规则兜底同时用少量标注数据训练一个 BiLSTMCRF 作为增强不要直接删掉规则冷启动阶段规则比模型更可靠。4. 查询构建与答案生成从语义到 Cypher 的模板映射4.1 把 question_type 翻译成 Cypherquestion_classifier 产出的类别和实体最终要交给 question_parser.py。它的职责是拼接 Cypher 查询。用模板而不是在线训练模型来生成查询是限定域问答的常见做法类别数量有限模板能穷举而且方便审计。下面是简化的 build_query 方法class QuestionParser: def build_query(self, question_type, entities): cql [] if question_type movie_rating and movie in entities: cql.append( MATCH (m:Movie {title: $title}) RETURN m.rating AS rating, ) elif question_type actor_movie and person in entities: cql.append( MATCH (p:Person {name: $name})-[:ACTED_IN]-(m:Movie) RETURN m.title AS movie, ) elif question_type movie_actor and movie in entities: cql.append( MATCH (m:Movie {title: $title})-[:ACTED_IN]-(p:Person) RETURN p.name AS actor, ) return cql注意这里用了参数化查询$title、$name而不是直接把实体字符串拼进 Cypher。原始项目如果用%拼接会存在 Cypher 注入风险比如电影名里包含单引号参数化查询是最低要求。build_query 返回列表而不是单条语句是为了支持后面“多轮查询”合并结果比如同时问评分和上映时间。下面的表是类别到 Cypher 模板的映射实际写代码时可以直接做成配置字典question_typeCypher 模板返回字段movie_ratingMATCH (m:Movie {title:$title}) RETURN m.ratingratingmovie_release_dateMATCH (m:Movie {title:$title}) RETURN m.release_daterelease_dateactor_movieMATCH (p:Person {name:$name})-[:ACTED_IN]-(m:Movie) RETURN m.titlemoviemovie_actorMATCH (m:Movie {title:$title})-[:ACTED_IN]-(p:Person) RETURN p.nameactordirector_movieMATCH (p:Person {name:$name})-[:DIRECTED]-(m:Movie) RETURN m.titlemoviemovie_genreMATCH (m:Movie {title:$title})-[:HAS_GENRE]-(g:Genre) RETURN g.namegenre这张表的关键在关系方向。actor_movie使用p-[:ACTED_IN]-mmovie_actor是反向遍历m-[:ACTED_IN]-p。如果建图谱时所有边都建成了双向语法上反而容易写错我习惯统一向外建边查询时靠箭头方向控制语义。4.2 执行查询与结果格式化answer_search.py 负责执行查询并把结果拼成答案。执行部分from py2neo import Graph class AnswerSearcher: def __init__(self): self.graph Graph(bolt://localhost:7687, auth(neo4j, your_password)) def search_main(self, question_type, entities): cql_list QuestionParser().build_query(question_type, entities) if not cql_list: return None answers [] for cql in cql_list: # 从实体字典里取参数按 Cypher 中出现的占位符传 params {key: value for key, value in entities.items()} result self.graph.run(cql, **params).data() answers.append(result) return answersgraph.run(cql).data()会把 Cypher 查询结果转换成 list of dict。比如actor_movie查到三部电影返回[{movie: 大话西游}, {movie: 少林足球}]。这个结构方便后续做模板拼接。需要说明的是params里如果带着实体字典里多余的键Cypher 中不出现的参数会被 py2neo 忽略但缺少任何一个 Cypher 中出现的占位符就会报ClientError。更稳的做法是让 build_query 同时返回(cql, params)把参数和查询语句绑定在一起执行层不需要猜测。结果格式化部分def answer(self, question_type, results): if not results or not results[0]: return 抱歉我还没有查到相关信息 if question_type actor_movie: names [r[movie] for r in results[0]] return 、.join(names) 都是他主演的电影。 if question_type movie_rating: return 这部电影的评分是 %s % results[0][0][rating] return str(results)这里做两件事先判断空结果再按类型拼接。很多初学者在空结果时直接抛异常其实是没查到这个实体或关系这时候应该把上一章抽取到的实体打印出来确认是分类错还是图谱缺边。5. 从单跳查询到多跳检索几个可以直接抄的调试技巧5.1 用 debug 模式定位“答错”的环节问答系统答错时最怕直接看最终答案。我会在 chatbot_graph.py 的主循环里加一个 debug 开关def chat(self, question, debugTrue): q_type self.classifier.get_question_type(question) entities self.classifier.extract_entity(question) cql_list self.parser.build_query(q_type, entities) if debug: print(类型:, q_type) print(实体:, entities) print(Cypher:, cql_list) answers self.searcher.search_main(q_type, entities) return self.searcher.answer(q_type, answers)这个输出能很快把问题拆到三段如果类型为空去扩充触发词如果实体为空检查自定义词典是否加载如果类型和实体都正确但答案为空检查图谱关系是否存在。常见坑是 movie.csv 里电影名带书名号而问句里没带导致实体匹配不上。解决方法是建词表时统一去掉书名号。5.2 把单跳模板改成可变深度多跳查询原项目停留在单跳查询但知识图谱真正的优势在多跳。比如“周星驰的合作演员”就不是直接边查询周星驰-[:ACTED_IN]-电影-[:ACTED_IN]-合作演员这是两跳。Cypher 里可以写成MATCH (p:Person {name: $name})-[:ACTED_IN]-(m:Movie)-[:ACTED_IN]-(co:Person) WHERE co p RETURN DISTINCT co.name AS co_actor LIMIT 20如果你想回答“通过最多两部电影产生合作的演员”可以用变长关系MATCH (start:Person {name: $name})-[:ACTED_IN*1..2]-(m:Movie)-[:ACTED_IN]-(co:Person) WHERE co start RETURN DISTINCT co.name AS co_actor注意*1..2表示允许 start 沿ACTED_IN走 1 到 2 步到达某部电影再从电影反向关联到合作演员。这个写法适合“演过同一系列电影的演员”这类场景如果要查“周星驰合作过的所有演员”直接前的两跳模板更安全。把这类多跳模板放进 question_parser 的字典后系统就从一个“电影问答”扩展成“影人关系查询”这是知识图谱相对检索问答最能拉开差距的地方。验证多跳查询可以直接用 py2neo 把结果打印成表格graph.run( MATCH (p:Person {name:周星驰})-[:ACTED_IN]-(m:Movie)-[:ACTED_IN]-(co:Person) RETURN DISTINCT co.name LIMIT 5 ).to_table()实际执行时把LIMIT 5去掉就能拿到全量合作演员列表再结合去重和排序就能作为新的问答类别输出。本文还有配套的精品资源点击获取