基于知识图谱的电影问答系统:用三张CSV实现实体识别与模板匹配 简介一份基于知识图谱的电影问答系统完整毕设项目面向计算机专业毕业生及需要项目实战的Python学习者可帮助快速理解知识图谱构建、实体关系抽取与问答交互的落地流程。项目包含源码与配套说明文档整体共116个文件涵盖Python核心逻辑脚本、前端页面HTML/CSS/JavaScript、数据文件CSV/JSON以及配置说明TXT等压缩包约6.3MB目录划分清晰便于按模块研读。该项目已获得99分的高分评价代码可运行特别适合用于毕业设计、课程设计或期末大作业的参考与二次开发。已有161人学习使用读者可从中获得完整的电影知识图谱数据如电影、演员、类型等实体关系、问答接口实现思路以及前后端联调方法并能直接在此基础上扩展功能。1. 基于知识图谱的电影问答系统这份毕设源码是怎么把三张 CSV 变成一部问答机的做电影问答系统不一定非要上 Neo4j 图数据库。我拆完这份高分毕设源码后最大的感触是它把知识图谱的核心——实体、关系、属性——全部落到了 movie.csv、person_to_movie.csv、movie_to_genre.csv 三张表里再配合一个实体识别加模板匹配的问答引擎就能回答“周星驰演过哪些电影”这类问题。整套代码完整可运行没有复杂的分布式组件导航清晰特别适合做 Python 课程设计、期末大作业和本科毕设。接下来我从数据层、问答层、前端展示三个维度拆开讲顺便把最容易翻车的几个坑也一并说清楚。2. 系统拆解知识图谱问答的完整链路与前端文件的作用2.1 三张 CSV 才是真正的知识图谱存储层知识图谱在学术定义上是“由节点和边组成的语义网络”节点代表实体边代表实体之间的关系。这份源码没有用 Neo4j 或 ArangoDB而是用三张 CSV 表完成了图谱的存储这个设计对毕设来说非常务实。movie.csv 是主表存电影ID、电影名、上映年份、导演、主演、简介等信息person_to_movie.csv 存人物与电影的参演关系每一行是一条关系记录movie_to_genre.csv 存电影与类型的归属关系。三者合在一起就是一个完整的实体-关系-实体三元组集合。这种存储方式有几个明显优势。第一是零部署成本不需要安装数据库服务打开就能读第二是调试直观用 Excel 或文本编辑器打开 CSV 就能检查数据正确性第三是答辩时容易讲清楚评委会问你“知识图谱的节点和边在哪”你可以直接把三张表拍在屏幕上解释主表就是节点集合、关系表就是边集合。当然缺点也有数据量大了之后查询性能会下降但电影问答这个场景几千条数据完全够用。有一件事拿到源码要立刻做确认 person_to_movie.csv 的列顺序。如果是“人物名, 电影名”那查询演员参演电影时就应该以第一列为键如果倒过来是“电影名, 人物名”建索引的方向就完全反了。我一般会写三行代码把每张表的表头和前五行打印出来先搞清楚关系方向再往下走。2.2 前端文件各司其职bootstrap 全家桶的页面结构源码目录里的 bootstrap.css、style000.css、font-awesome.css、owl.carousel.css、poposlides.css、circles.css、style.css 乍一看像随手丢进去的静态资源实际上每份都有自己的分工。bootstrap.css 提供响应式栅格系统和基础弹窗组件owl.carousel.css 负责首页推荐电影的轮播效果poposlides.css 是弹层样式circles.css 用来渲染环形统计图style000.css 和 style.css 则是覆盖 Bootstrap 默认样式、定制门户风格的自定义样式表。这套前端本质上是“电影门户 问答交互”的组合。首页展示轮播图与推荐电影问答页则通过 Ajax 把用户问题发送给后端 Python 接口拿回结果后渲染成电影卡片列表。如果你打算改造成自己的毕设前端不建议大改把精力放在问答准确率上。需要注意的一个点是 owl.carousel 在初始化时会操作固定的 DOM 容器如果问答结果要插入同一个页面区域最好通过脚本动态生成新的 DOM 节点避免和轮播组件抢占容器导致样式错乱。2.3 前后端的数据交互格式问答接口的请求与响应建议都走 JSON。前端把用户输入的问题放进{ question: 周星驰演过哪些电影 }后端解析后返回{ code: 0, intent: actor_movies, entity: 周星驰, answers: [...] }。前端拿到 answers 数组后循环渲染卡片。这个约定虽然简单但有两个好处一是前端调试时可以脱离后端直接 mock 数据二是答辩演示时打开浏览器开发者工具就能看到完整的数据流显得工程素养到位。3. 数据清洗与图谱构建实战从原始 CSV 到可查询的索引结构3.1 编码检测与脏数据处理大部分 CSV 编码问题都出在 Windows 环境下。Excel 默认以 ANSI 编码保存文件也就是 GBK而 Python 的默认读取编码是 UTF-8两者不匹配pd.read_csv直接抛 UnicodeDecodeError。处理方式是先用 chardet 探测实际编码再指定编码读取。import chardet def detect_encoding(path): with open(path, rb) as f: raw f.read(5000) result chardet.detect(raw) return result[encoding] # 用法先探测再读取 encoding detect_encoding(movie.csv) print(检测到的编码:, encoding)这段代码的作用是读取文件前 5000 字节交给 chardet 判断编码格式。为什么只读前 5000 字节因为编码探测不需要读完整个文件取头部样本足够而且大文件全量读取会拖慢启动速度。如果探测结果是 gbk而读取过程仍然报错改用 encodinggb18030它是 gbk 的超集对生僻字兼容性更好。读完数据之后重点处理三类脏数据。一是空值电影名或人物名缺失的直接丢弃或填充默认值二是重复值同一部电影因为大小写不同出现两次三是全半角符号不统一“蜘蛛侠英雄远征”和“蜘蛛侠:英雄远征”冒号不同匹配时会对不上。解决方案是写一个规范化函数全角转半角、去除首尾空格。import unicodedata def normalize(text): if not isinstance(text, str): return str(text) # 全角字符转半角 text unicodedata.normalize(NFKC, text) # 去掉首尾空格 return text.strip()这个函数的核心是unicodedata.normalize(NFKC, ...)它能把全角冒号、全角括号转成半角字符。为什么不用简单的 str.replace因为全角字符范围很广手工替换容易漏NFKC 规范化一步到位。构建实体词典和索引之前对所有 key 都过一遍这个函数能省掉后面大量的匹配问题。3.2 实体词典构建最长匹配的排序逻辑问答系统要识别出问句中的实体本质是词典匹配。词典的来源有三个movie.csv 里的电影名、person_to_movie.csv 里的人物名、movie_to_genre.csv 里的类型名。合并后去重做成一个候选实体列表。匹配时采用最长匹配策略也就是优先匹配长度最长的实体。def build_entity_dict(movies, persons, genres): entities set() entities.update(movies[title].tolist()) entities.update(persons[person].tolist()) entities.update(genres[genre].tolist()) # 按长度降序排列长度相同按字典序 return sorted(entities, keylambda x: (len(x), x), reverseTrue) def extract_entity(question, entity_list, min_len2): for entity in entity_list: if len(entity) min_len: continue if entity in question: return entity return Nonebuild_entity_dict的排序方式值得细看。reverseTrue保证最长的实体排在前面这样“流浪地球2”会先于“流浪地球”被匹配。min_len2是过滤单字实体否则“人”“影”这种字会被误识别成实体导致问答结果完全错乱。这里有一个我和同事经常踩的坑有些人会把 sort 写成不降序匹配时短实体先命中问“蜘蛛侠3是什么类型”匹配到的却是“蜘蛛侠”返回的结果完全对不上。需要说明的是这个方案对实体名完全包含在问句里的场景有效但如果用户问“星爷拍过什么电影”词典里没有“星爷”这个别名就匹配不到“周星驰”。加强方案是维护一个别名映射表把“星爷”“星仔”映射到标准名“周星驰”在实体识别前先做一次别名替换。这个功能也能在答辩时讲成亮点说“系统考虑了实体的别名归一化”。3.3 关系表索引化查询从 O(N) 降到 O(1)实体识别完成之后需要根据实体类型去关系表里查答案。如果每次查询都遍历整个 CSV 文件性能没法接受且代码很丑陋。标准做法是启动时把关系表读进内存构建成字典。person_to_movie.csv 对应“人名 - 电影列表”movie_to_genre.csv 对应“电影名 - 类型列表”。from collections import defaultdict def build_indexes(person_movie_path, movie_genre_path): person_movies defaultdict(list) with open(person_movie_path, encodingutf-8) as f: for line in f: line line.strip() if not line: continue person, movie line.split(,) person_movies[normalize(person)].append(normalize(movie)) movie_genres defaultdict(list) with open(movie_genre_path, encodingutf-8) as f: for line in f: line line.strip() if not line: continue movie, genre line.split(,) movie_genres[normalize(movie)].append(normalize(genre)) return person_movies, movie_genres这里用defaultdict(list)而不是普通 dict好处是访问不存在的键时不会抛 KeyError而是自动创建一个空列表。查一个演员参演过的所有电影直接person_movies.get(周星驰, [])拿到列表查不到也不会报错返回空列表即可。很多人会在这一步用line.split(,)但如果 CSV 里有电影简介包含英文逗号直接 split 会多拆出字段导致数组越界。稳妥做法是改用 Python 标准库csv.reader它能正确处理引号包裹的含逗号字段。源码里如果数据很规整split 也能跑但作为工程习惯我建议用 csv 模块。3.4 图谱统计信息怎么来circles.css 做的环形图一般用来展示知识图谱的规模。需要统计四个数电影总数、人物总数、类型总数、关系总数。这些数直接从三张表里数就行len(movies)得到电影数len(person_movies)得到人物数关系总数是 person_movies 里所有列表长度之和。统计逻辑虽然简单但答辩时很出效果评委能直观看到这个系统里有多少实体和关系对“知识图谱”的分量有感知。而且这些数字是动态算出来的数据文件更新后图表自动变化不需要手工维护。4. 问答引擎实现模板匹配的意图识别与参数调优4.1 三层判断先实体、后类型、再模板问答引擎的经典架构是“实体识别 - 实体类型判断 - 意图分类 - 答案组装”。实体识别完成后要判断这个实体属于电影、人物还是类型。判断依据是看实体出现在哪张表里在 movie.csv 中说明是电影在 person_to_movie.csv 中说明是人物在 movie_to_genre.csv 中说明是类型。这里有个优先级问题如果某个实体名既是电影名又是人物名比如“成龙”既是人名也有同名纪录片需要引入上下文判断。常见做法是按“人物 电影 类型”的优先级。确定实体类型后进入意图分类。这套系统的意图分三类演员类意图问某人的作品、电影类意图问某部电影的属性、类型类意图问某类型的电影列表。意图分类用模板匹配实现模板是“关键词包含”规则。INTENT_TEMPLATES [ { intent: actor_movies, entity_type: person, keywords: [演过, 出演, 参演, 拍过, 作品, 电影有哪些] }, { intent: movie_info, entity_type: movie, keywords: [类型, 导演, 主演, 年份, 简介, 上映] }, { intent: genre_movies, entity_type: genre, keywords: [推荐, 有哪些, 有什么] } ] def classify_intent(question, entity_type): for template in INTENT_TEMPLATES: if entity_type ! template[entity_type]: continue for kw in template[keywords]: if kw in question: return template[intent] return None这套设计的好处是模板之间不会互相干扰。判断逻辑先过滤实体类型实体是“科幻”时只会去类型类模板里匹配“有哪些”“推荐”实体是“周星驰”时只会去演员类模板里匹配“演过”“作品”。如果把所有关键词放在一起不加类型过滤就会出现问“有哪些科幻电影”被“电影有哪些”这几个字误导错误进入演员类分支的翻车现场。关键词的覆盖要收敛每个意图 4-6 个核心词就够不用追求穷举。问“周星驰的电影作品有哪些”这种带“作品”的句子靠第一个模板里的“作品”也能命中。4.2 答案组装与结果排序意图确定后答案组装根据意图分支处理。演员类意图从 person_movies 索引查电影列表类型类意图需要反向遍历 movie_genres找出所有包含该类型的电影电影类意图则直接去 movie.csv 主表提取对应记录。组装时要做一次过滤电影名必须存在于 movie.csv 主表中否则前端会渲染出信息残缺的卡片。def assemble_answer(intent, entity, person_movies, movie_genres, movie_map): if intent actor_movies: titles person_movies.get(entity, []) results [] for title in titles: if title in movie_map: results.append(movie_map[title]) return sorted(results, keylambda x: x.get(year, 1900), reverseTrue) if intent genre_movies: results [] for movie, genres in movie_genres.items(): if entity in genres and movie in movie_map: results.append(movie_map[movie]) return sorted(results, keylambda x: x.get(year, 1900), reverseTrue) if intent movie_info: result movie_map.get(entity) return [result] if result else []这段代码里最需要说明的是两个地方。第一movie_map是 movie.csv 转成的字典键是规范化后的电影名值是包含年份、导演、简介的完整信息。第二结果按年份倒序排列回答“星爷拍过哪些电影”时最新的电影排在前面更符合用户预期。类型类意图的遍历是 O(N) 操作数据量几千条时无感但如果将来数据扩展到十万级建议增加一个类型到电影列表的倒排索引结构是genre_movies_map: { 科幻: [流浪地球, ...] }查询时直接取列表。4.3 未命中与兜底话术问答系统一定会遇到识别不了的问句比如用户输入“你觉得哪部电影最好看”这类问题没有明确实体也匹配不上模板。源码里需要有一个兜底分支返回code: 1和一个提示话术前端展示“没太理解换个问法试试”。更进一步的做法是把识别到的部分信息回显给用户“你是想问关于‘周星驰’的问题吗”这种澄清机制在答辩现场很讨喜因为评委通常会故意刁难系统一个得体的兜底比报错强一百倍。兜底逻辑还能记录未命中日志把识别失败的问句写进日志文件方便迭代时补充模板。这是很多新手会忽略的工程细节。我一般会在每次问答请求里打印完整链路原始问句、识别出的实体、实体类型、命中的模板意图、返回结果条数。有了这个日志调什么都是一眼的事。5. 避坑这份源码最容易翻车的五个地方5.1 CSV 编码错乱导致中文全部乱码现象用 pandas 或 csv 模块读取 movie.csv 后打印出来的中文全是乱码或者直接抛 UnicodeDecodeError 中断程序。原因文件在 Windows Excel 下以 GBK 编码保存而 Python 开发环境默认按 UTF-8 解码两边编码不一致系统直接罢工。解决先探测后读取。用 chardet.detect 拿到真实编码再传给读取函数。如果探测结果是 gbk读取时指定 encodinggbk遇到读一半报错的情况换成 encodinggb18030它比 gbk 覆盖更多生僻汉字。拿到手先改编码再讲别的这两分钟花得值。5.2 关系方向搞反演员和电影张冠李戴现象问“周星驰演过哪些电影”返回空列表但数据文件里明明有周星驰这条记录。手动查看 person_to_movie.csv 发现数据都在但系统就是查不到。原因person_to_movie.csv 的列顺序不是预期中的“人物, 电影”而是“电影, 人物”。代码按第一列做人名索引结果把电影名当成了人名。解决第一步永远先打印表头和数据前五行确认每一列的真实含义。列名如果写的是 person, movie直接按这个顺序解析如果列名缺失就抽查“周星驰”这个值出现在文件的第几列。关系方向是一个只有 0 和 1 的问题错了就是全错没有中间态。改之后把“周星驰”作为测试用例重新跑一遍问答流程确认返回结果包含《功夫》《喜剧之王》等已知作品。5.3 实体最长匹配没生效短实体先命中现象问“蜘蛛侠3是什么类型”系统返回的是“蜘蛛侠”的信息而不是“蜘蛛侠3”。比如实体列表里同时有“蜘蛛侠”和“蜘蛛侠3”如果短实体排在前面就会被先匹配到。原因实体词典构建时没有按长度降序排列。Python 的 set 是无序的直接遍历 set 做匹配时命中哪一个完全随机大概率是短的先撞上。解决词典排序必须显式指定sorted(entities, keylambda x: len(x), reverseTrue)。同时在 extract_entity 里加最小长度过滤排除单字避免“人”“影”这种字符被识别。我建议写完这段代码后专门写两个测试问句一句包含长实体“蜘蛛侠3”一句包含短实体“蜘蛛侠”跑完确认各自命中正确对象。5.4 模板关键词互相兜住类型查询被演员查询抢跑现象问“有哪些科幻电影”系统返回空列表或者答非所问。原因“科幻”这个实体被识别成类型后进入 actor_movies 模板匹配。actor_movies 模板里有关键词“电影有哪些”而“有哪些科幻电影”里包含“电影”和“有哪些”字样被错误拦截。解决在 classify_intent 里先做实体类型过滤。实体类型是 genre 时只允许 genre_movies 模板参与匹配。这是代码结构问题不是模板数量问题。用三层判断实体 - 类型 - 模板后演员模板和类型模板完全隔离互相不干扰。改完之后用同一组问句跑回归测试重点盯那些包含“电影”二字的类型类问题。5.5 前端弹窗组件和问答结果冲突现象点击电影卡片弹出详情弹窗关闭弹窗后问答列表消失或者页面无法滚动。原因poposlides 或 Bootstrap modal 在打开时给 body 加了 overflow: hidden 的样式关闭时如果意外中断样式没有恢复页面就“锁死”了。解决弹窗关闭事件里手动重置 body 样式document.body.style.overflow auto。问答结果数据用全局变量暂存弹窗关闭后重新渲染列表而不是重新请求后端接口。调试时打开浏览器控制台看报错信息弹窗类问题九成是 DOM 状态没恢复到初始状态导致的。6. 进阶20 问自测法与模板扩展规则让系统在答辩现场不翻车我见到的毕设问答系统很多能回答训练时设计好的十来个问题评委换一种说法就当场卡壳。要避免这个尴尬最有效的方法是建立一套自我验证流程。具体做法是准备一个文本文件每行一条测试问句挑涵盖三类意图的 20 条左右比如“星爷演过哪些电影”“《流浪地球》上映于哪一年”“推荐几部爱情片”。运行一个自测脚本逐条调用问答函数把识别出的实体、命中的意图和返回结果条数打印出来人工比对正确性。加模板时要守一条红线新模板不能和已有模板存在包含关系。比如“有什么电影”和“有什么好看的电影”后者包含前者如果先匹配后者前者的场景就永远走不到。正确的做法是把它们合并成一个模板keywords里只留“有什么电影”再增加一个扩展词表“好看的|经典的|最近上映的”作为修饰词。匹配逻辑改成修饰词可选核心词必须出现。这样扩展性更强也不会互相拦截。另外我建议在自测脚本里加一个基准测试每次模板修改后跑一遍统计正确率。正确率低于 90% 就要检查是不是新模板破坏了已有规则。这样你能建立对系统的信任感——知道这个模型能干什么不能干什么。答辩时如果评委问“系统有哪些限制”你可以诚实说“对口语化的无实体问句支持不好”然后主动演示一个能答的问题把节奏拉回到系统擅长的领域。最后分享一个我个人的教训。有一次我想提升召回率把关键词从 4 个加到 20 个结果新加的关键词“还有”把很多无关问句全抢到了演员分支自测正确率直接从 93% 掉到 67%。从那以后我每次改完模板都强制跑一遍 20 问自测只看正确率这一个指标低于 90% 就不允许交付。这套自测流程帮我挡掉了不少隐藏雷区希望也能帮到你。本文还有配套的精品资源点击获取