☰
基于Python的电影信息智能问答系统:从自然语言到SQL查询
2026/10/3 15:26:58 网站建设 项目流程

简介:基于Python与Django框架实现的电影信息智能问答系统项目,面向计算机相关专业学生、毕业设计开发者以及NLP入门学习者。项目以电影知识问答为核心,涵盖问句预处理、问题分类、模板匹配、答案生成等完整流程,并配有SQLite数据库与基础电影数据,可作为毕业设计、课程作业或二次开发的起点。资源内含67个文件,压缩包约875KB,以18个Python源码为主,另有Django配置、HTML/CSS/JS静态页面、XML配置文件、SQLite数据库及说明文档,结构清晰,便于按模块阅读和运行调试。目前已有78人浏览学习。代码经过测试可运行,下载后可参考README快速启动,也可在此基础上修改扩展,用于理解基于模板的问答系统实现思路。

1. 电影信息智能问答系统到底在解决什么问题:从一句问话到一条 SQL

“基于Python实现的电影信息智能问答系统”这个名字听起来很唬人,拆开看它要解决的事其实非常具体:输入一句“霸王别姬是谁导演的”,系统返回“陈凯歌”;输入“评分最高的悬疑片”,系统返回对应的电影列表。它的核心不是让模型去“理解”语义,而是把自然语言翻译成一条可执行的结构化查询,再对着电影数据库把答案捞回来。这个方案适合做课程设计、毕业设计的同学,也适合手里有一份电影JSON或CSV数据、想快速搭一个可演示问答原型的从业者——它能让你在一周内做出一个不依赖在线大模型、可本地运行、可解释性强的成品。我为什么强调“不依赖在线大模型”?因为这类项目最常见的失败点不是算法不够新,而是环境依赖太重、模型太大、现场演示时网络一断就哑火。

2. 先把数据铺平:电影库的建表设计、导入与文件组织

2.1 为什么选 MySQL 而不是 SQLite 或 Redis:这个系统最吃“稳定交付”

问答系统的底层是一个结构化电影库,选型第一原则是“别人拿到源代码后能直接跑起来”。我一般会选 MySQL,理由很简单:课程设计和工程交付场景里,MySQL 5.7/8.0 是出镜率最高的数据库,老师或同事的机器上大概率装过;即便没装,安装和排错资料也最多。SQLite 虽然零配置,但你在文档说明里写“嵌入式数据库”会显得项目分量不够,而且它在多线程写入时容易出现锁等待;Redis 适合做缓存和热数据索引,但让一个问答系统把全量电影数据放 Redis 并不合适,毕竟电影库本身才几万条,远没到需要分布式缓存的程度。

数据量级在这里决定架构复杂度。一万部电影,每部十几个字段,MySQL 建一张表就够;问答阶段把全表载入内存做成字典,单次查询时间在毫秒级。有人会纠结“要不要上 Elasticsearch”,我的看法是没必要——你只有一万条数据,ES 的索引优势完全发挥不出来,反而把部署复杂度拉高了一个档次。先想清楚数据规模,再决定要不要引入重型组件。

存储引擎用 InnoDB,字符集用 utf8mb4,这两条几乎是固定答案。InnoDB 支持事务和行级锁,导入数据时即使中途报错,回滚也方便;utf8mb4 能存下 emoji 和生僻字,避免电影名里的特殊字符让入库直接失败。表结构设计也要考虑“问答系统要查什么”,而不是“电影信息要存什么”——字段够用即可,不用堆太多。

2.2 建表与字段:挡住 80% 中文乱码和空值问题的设计

电影信息问答系统需要支撑的问题类型,基本决定了表字段。需要支持的问法包括:导演、主演、上映年份、类型、评分、片长、简介。把这几类问题对应的字段列出来,表结构自然就出来了:

CREATE DATABASE IF NOT EXISTS movie_qa DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE movie_qa; DROP TABLE IF EXISTS movie; CREATE TABLE movie ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, title VARCHAR(128) NOT NULL COMMENT '电影名', director VARCHAR(64) DEFAULT NULL COMMENT '导演', actors VARCHAR(255) DEFAULT NULL COMMENT '主演,多个用顿号分隔', genre VARCHAR(64) DEFAULT NULL COMMENT '类型,多个用顿号分隔', year SMALLINT UNSIGNED DEFAULT NULL COMMENT '上映年份', rating DECIMAL(3,1) DEFAULT NULL COMMENT 'IMDb/豆瓣评分', runtime SMALLINT UNSIGNED DEFAULT NULL COMMENT '片长,单位分钟', synopsis VARCHAR(1000) DEFAULT NULL COMMENT '一句话剧情简介', PRIMARY KEY (id), UNIQUE KEY uk_title (title) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

建库语句里前后两处字符集,有的人只写建库不写建表,导致表还是继承默认的 latin1,中文入库后直接变成问号。我的习惯是库和表都显式指定 utf8mb4。COLLATE 用 utf8mb4_unicode_ci,它对比中文和英文字符时足够准,排序规则也更接近 UTF-8 的通用定义。

title 加 UNIQUE KEY 的作用不是省空间,是为了让导入脚本可以幂等重跑。电影名是天然的业务主键,同一部电影重复入库时,靠唯一索引做“存在则更新”,比先查一次再插入要省事得多。字段注释也建议写全,文档说明里可以直接复用这份注释,老师或同事阅读代码时能少问很多问题。

2.3 批量导入:从 CSV 到 MySQL 的幂等脚本

常见做法是从公开数据集下载 CSV,或者用 python 爬虫抓一份列表,然后统一清洗入库。导入脚本用 pymysql 写一个方法,核心逻辑是“每行一条 INSERT,遇到重复电影名则更新部分字段”,这样脚本跑两遍也不会产生脏数据:

import csv import pymysql conn = pymysql.connect( host="127.0.0.1", port=3306, user="root", password="你的密码", database="movie_qa", charset="utf8mb4", ) def import_movies(csv_path: str) -> int: count = 0 with conn.cursor() as cur: with open(csv_path, encoding="utf-8-sig") as f: reader = csv.DictReader(f) for row in reader: # 空字符串统一转 None,避免库里出现大量 '' row = {k: (v.strip() if v and v.strip() else None) for k, v in row.items()} sql = ( "INSERT INTO movie " "(title, director, actors, genre, year, rating, runtime, synopsis) " "VALUES (%(title)s, %(director)s, %(actors)s, " "%(genre)s, %(year)s, %(rating)s, %(runtime)s, %(synopsis)s) " "ON DUPLICATE KEY UPDATE director=VALUES(director), rating=VALUES(rating)" ) cur.execute(sql, row) count += 1 conn.commit() return count

代码里有三个细节值得说。第一,文件打开用 encoding="utf-8-sig",原因是 CSV 常常带 UTF-8 BOM 头,不去掉的话,第一列字段名会变成“\ufefftitle”,DictReader 匹配列名时直接翻车。第二,SQL 参数用字典而不是 f-string 拼接,既防注入,也让字段和值一一对应,排查少一个参数时一目了然。第三,ON DUPLICATE KEY UPDATE 里我只更新 director 和 rating,这相当于“重复导入时只刷新部分热门字段”,actors、synopsis 这类长文本字段不会被旧数据覆盖。如果你希望整行覆盖,把字段列全即可。

2.4 数据来源与清洗:python 爬虫拿到的数据不能直接入库

导入前最容易被忽略的是数据清洗。从公开站点爬来的电影信息,字段格式几乎不可能直接入库:年份可能长成“2019-04-01”,评分长成“8.7/10”,类型长成“剧情 / 爱情”带空格和斜杠。你需要在写入之前把这些统一成表结构约定的格式,一个很省事的做法是写一个 normalize 函数,在 import_movies 的循环里先过一遍。常见坑是“豆瓣评分 8.7”这种带中文后缀的字符串,如果直接用 DECIMAL 字段接收会报错,需要用正则把数字部分抠出来。

数据来源方面,我一般会用 python 爬虫从公开的电影资料站点抓取,也可以直接用网上整理好的 CSV。但不管来源是什么,入库前都要过一遍 dedupe:同样一部电影,不同站点可能一个写“霸王别姬”,一个写“霸王别姬 (1993)”,后者会被当成另一部电影。简单的做法是把标题里的年份括号部分去掉再比较;更稳的做法是同时比对导演和年份字段。这些清洗规则建议写进文档说明,因为老师问你“数据哪里来的”的时候,清洗过程比爬虫代码本身更能体现工作量。

3. 问答系统的“理解”是怎么实现的:实体识别 + 规则分类器

3.1 不用余弦相似度的理由:电影名分词才是最大的翻车点

很多人拿到“智能问答”第一反应是“做语义相似度”:把问题转成 TF-IDF 向量,和库里的句子算余弦相似度,取得分最高的当答案。这个思路在小规模测试里能跑,但放到电影问答场景会撞上两个硬伤。第一,电影名是专有名词,jieba 默认词典不认识“霸王别姬”,会把“霸王”和“别姬”切开,向量化之后“霸王”被匹配到其他含“霸王”字样的内容上,召回完全失控。第二,相似度检索返回的是“句子”,但用户要的是“字段值”——问“导演是谁”,你要给的是一个人名,不是一段相似文本,中间还得再套一层抽取逻辑,兜兜转转绕回来了。

所以常见做法是“实体识别 + 规则分类器”,这也是这类课程项目里最稳的方案。先把问题里的电影名抽出来,再判断问的是导演、演员还是评分,最后映射到一条 SQL。整个过程可解释、可测试、可排错,而且不依赖外网模型,演示时只要 MySQL 在本地就永远能跑通。

3.2 让 jieba 认出电影名:自定义字典的生成与加载

问题里最先要被提取的是电影名,而 jieba 默认词典对电影名基本没有覆盖。解决办法是把所有电影名导出来,生成一个自定义词典,加载后再分词,“霸王别姬”就会作为一个完整词出现:

import jieba jieba.load_userdict("data/movie_names.txt")

movie_names.txt 每行一个电影名。如果想顺便控制词性和词频,可以写成“霸王别姬 10 nz”这种带空格的三段式格式,词频数字填 10 以上,词性填 nz(专有名词)或 nr。实际写作里我建议只用电影名,不带频率,让 jieba 自己根据词频统计处理,减少格式出错的可能。

这个词典文件必须从数据库里生成,而不是手写,否则库里新增了电影,词典没同步更新,问答系统会出现“数据库里有但答不出来”的怪现象。生成脚本也很简单:

import pymysql def dump_movie_dict(out_path: str = "data/movie_names.txt") -> int: conn = pymysql.connect( host="127.0.0.1", port=3306, user="root", password="你的密码", database="movie_qa", charset="utf8mb4", ) with conn.cursor() as cur: cur.execute("SELECT title FROM movie") titles = [r[0] for r in cur.fetchall() if r[0]] with open(out_path, "w", encoding="utf-8") as f: f.write("\n".join(titles)) return len(titles)

这里有个顺序问题需要强调:先导库,再生成词典,再依赖加载。如果你在一开始就 load_userdict,但库里还没数据,词典为空文件,jieba 会直接跳过,之后即使库里有了数据也不会自动重新读 file 了。最稳妥的做法是把这两步拆成两个脚本,做成“初始化流程”里的一先一后。

3.3 问题分类器:把“导演是谁”映射成 SQL 字段

“智能”的另一半是判断用户在问什么。常见做法是维护一张规则表,用关键词命中来做意图分类,优先级从上往下取第一个命中的。问“霸王别姬的导演是谁”,命中“导演是谁”返回 director;问“这电影谁演的”,命中“演员”返回 actors:

def classify_question(question: str) -> str: rules = [ ("director", ("导演是谁", "谁导演", "导演是", "谁拍的", "谁执导")), ("actors", ("主演", "演员", "谁演的", "谁主演", "饰演")), ("year", ("哪一年上映", "上映年份", "什么时候上映", "哪年")), ("genre", ("什么类型", "类型是", "属于什么类型", "分类")), ("rating", ("评分", "豆瓣评分", "几分", "多少分")), ("runtime", ("时长", "多长", "片长", "多少分钟")), ("synopsis", ("简介", "剧情简介", "讲了什么", "讲的什么")), ] for qtype, keywords in rules: for kw in keywords: if kw in question: return qtype return "unknown"

规则的顺序不是随便排的,优先级越高越靠前。比如“评分最高的悬疑片”同时含“评分”和“类型”,如果 rating 排在前面先命中,系统会去查某部电影的评分,但实际这是一个“找列表”的统计问题,后面 4.2 会专门说处理办法。设计成“先命中就先得”,配合 whitelist 式的关键词,比一次性把所有逻辑揉进一个函数好维护得多。

3.4 电影名抽取:分词命中与兜底策略

意图识别和实体抽取是两个独立步骤,顺序我一般先抽实体再判断意图。电影名抽取用上面生成的词表,把问题交给 jieba 切词,再逐个和电影名索引比对:

# 全局电影名索引,加载完成后用于常数级匹配 movie_index = {m["title"]: m for m in load_all_movies()} _KNOWN_TITLES = set(movie_index.keys()) def extract_movie(question: str) -> str | None: words = jieba.lcut(question) for w in words: w = w.strip() if w in _KNOWN_TITLES: return w return None

这个函数有两条分支让人容易踩坑。第一,jieba.lcut 返回的列表里可能在词首词尾带空格或标点,所以每轮都要 strip 一下。第二,如果电影名是四个字以上,比如“夏洛特烦恼”,一旦自定义词典没加载成功,jieba 会切出“夏洛特”和“烦恼”,两个词都不在标题集合里,extract_movie 返回 None。排查时先打印 jieba.lcut(question) 看看切成了什么,就能判断是不是词典加载失效。兜底手段是“整句去掉疑问词后直接匹配”,例如把“霸王别姬的导演是谁”去掉“的导演是谁”再与标题比对,但这是一个模糊策略,尽量留到规则无法处理时再用。

4. 问答闭环:从查库、格式化到高频问法覆盖

4.1 一个能直接跑的回答函数:查库、格式化、兜底

有了实体和意图,最后一步是把两者拼起来查库并组织答案。我习惯把所有逻辑收在一个 answer 函数里,对外只暴露一个入口:

def answer(question: str) -> str: qtype = classify_question(question) title = extract_movie(question) if title is None: return handle_stat_or_general(question, qtype) movie = movie_index[title] field_map = { "director": ("导演", movie.get("director")), "actors": ("主演", movie.get("actors")), "year": ("上映年份", movie.get("year")), "genre": ("类型", movie.get("genre")), "rating": ("评分", movie.get("rating")), "runtime": ("片长", movie.get("runtime")), "synopsis": ("剧情简介", movie.get("synopsis")), } if qtype in field_map: label, value = field_map[qtype] if value: return f"{title}的{label}是{value}" return f"抱歉,库里还没有收录{title}的{label}信息" return "这个问题我暂时没有学会,你可以试试问导演、主演、评分、上映年份。"

query 阶段不再连数据库,而是直接查内存里的 movie_index,这是速度快的关键。一万部电影的 dict 查找是常数级,即使加上分词耗时,整体响应也在几十毫秒内,足够现场演示。field_map 把“意图类型”和“显示标签+取值”绑定在一起,新增一个可回答的字段只需要在这里加一行。

注意一个问题:如果用户连着问“那评分呢”,没有带上电影名,extract_movie 一定返回 None,这时直接走 handle_stat_or_general,它负责两件事——处理“评分最高的悬疑片”这类统计问题,以及提示“你说的是哪部电影”。你可以在 session 里存上一次命中的电影名,让“那评分呢”这种带指代的问题也能答上。这个功能属于加分项,但对提升演示效果非常明显。

4.2 高频问法覆盖表:别名、无标点、倒序都怎么处理

用户不会按你代码里的规则说话,这是问答系统玄学最多的部分。同一个“霸王别姬的导演是谁”,真实环境里会演化出好几种写法,我把最常遇到的整理成一个覆盖表,按这个表设计测试用例:

问法示例意图实际处理结果
霸王别姬的导演是谁director命中“导演是谁”,抽取“霸王别姬”
霸王别姬 导演是谁(无标点)director分词后直接命中,不需要特殊处理
导演是谁 霸王别姬(倒序)director分类器先命中“导演是谁”,抽取时扫到“霸王别姬”
霸王别姬谁拍的director命中“谁拍的”关键词
霸王别姬是哪一年上映的year命中“哪一年上映”
评分最高的悬疑片rating + genre 组合不走单值查询,走统计路由
霸王别姬 评分rating命中“评分”,无标识符也能处理

倒序问题的关键在于意图分类和实体抽取互不干扰。classify_question 只关心关键词是否在字符串里,extract_movie 只关心分词结果是否命中标题集合,两个函数都做“包含判断”而不是“顺序判断”,所以“导演是谁霸王别姬”也能命中。这个设计比正则写 ORDER 顺序要省事得多,代价是“张艺谋导演的电影有哪些”这类反查问题会全部落空——反查需要知道“张艺谋”是人名而非电影名,需要另外维护导演名词表,属于第 4.3 节的统计路由职责,不在单值问答范围内。

4.3 内存索引与 MySQL 的分工:什么时候该走 SQL

单部电影的信息查询走内存 dict,列表类、统计类查询走 MySQL,这是我给这类系统定的边界。理由很简单:内存 dict 只适合“给定电影名,取字段”的精确查询;而“评分最高的悬疑片”要在 genre 里做模糊匹配,再按 rating 排序取前几条,用 Python 遍历一万条也能跑,但既然有数据库,用 SQL 表达更清晰,也更有“数据库”的设计感:

def search_by_genre_top(genre: str, limit: int = 5): conn = get_conn() with conn.cursor() as cur: sql = ( "SELECT title, rating FROM movie " "WHERE genre LIKE %s " "ORDER BY rating DESC, year DESC " "LIMIT %s" ) cur.execute(sql, (f"%{genre}%", limit)) return cur.fetchall()

LIKE %genre% 在这里完全可以接受,因为数据量才一万条,全表扫一次不过几毫秒,不用上全文索引。ORDER BY rating DESC, year DESC 让评分相同的情况下优先展示较新的电影,这个排序细节会让结果看起来“聪明”很多。统计路由函数里要做的判断是:问题里是否包含“最高/最低/前/最老/最近”这类词,且没有明确的电影名实体,有就进 SQL 分支,没有就走“请告诉我是哪部电影”的兜底话术。

5. 避坑记录:电影问答系统最容易翻车的 4 个地方

5.1 中文乱码:建库、连接、导入三处都要显式指定

现象:导入脚本跑完,SELECT 一看,电影名全是“?”,或者报错 “Incorrect string value: ‘\xE9\xBE\x99...’ for column ‘title’”。

原因:三层都有嫌疑。建库时没指定 utf8mb4,表继承了 latin1;或者 pymysql.connect 没传 charset="utf8mb4”;或者 CSV 文件带 BOM,被 DictReader 读出了残缺字段名。这三个问题经常同时出现,但报错信息只指向其中一层,容易让人误判。

解决:直接在初始化流程里把三处固定下来。建库 SQL 写死 DEFAULT CHARACTER SET utf8mb4;pymysql.connect 显式加 charset="utf8mb4";文件打开用 encoding="utf-8-sig"。写完脚本后不要看一眼就过,先往库里插一条“霸王别姬”,再查出来确认,这一步 30 秒能挡住后面一整天的血泪。

5.2 “霸王别姬”被 jieba 切碎:自定义词典的常见失效姿势

现象:extract_movie 返回 None,可你明明在词典文件里写上了“霸王别姬”。

原因:最常见的是加载顺序错了。有的同学把 load_userdict 放在程序开头,但词典文件当时还没生成(或生成失败),jieba 静默跳过,不报错。第二个常见原因是词典文件编码不对,Windows 记事本默认可能存成 GBK,jieba 读出来全是乱码词。第三个原因是你用的是 jieba.analyse 接口而不是 jieba.lcut,前者不一定读取 userdict。

解决:把“生成词典”和“加载词典”做成两个单独步骤,在启动脚本里按顺序执行。排查时直接打印 jieba.lcut("霸王别姬是谁导演的"),如果输出是“霸王 / 别姬”,说明词典没生效;如果输出是“霸王别姬 / 是 / 谁 / 导演 / 的”,说明已经正常。确认编码时用file data/movie_names.txt看一行输出,出现 UTF-8 字样才放心。

5.3 统计型问题被意图分类器误判:评分最高 vs 评分是多少

现象:用户问“评分最高的悬疑片”,系统返回“霸王别姬的评分是 9.6”,完全答非所问。

原因:classify_question 只看关键词,先命中了“评分”,就当成单值问题处理。它没有区分“查某部电影的评分”和“按评分找电影列表”。

解决:在 answer 函数里,先判断是否属于统计路由,再走单值字段。统计路由的触发条件是:问题里含“最高、最低、前 N、最老、最近”等排序词,且 extract_movie 返回 None。命中统计路由就直接进数据库查询,不再用 classify_question 的结果。注意把统计判断放在“识别到电影名”之后,这样“霸王别姬评分最高的版本是哪年”这类混合问题还能优先按电影名单值处理。

5.4 演示现场失联:连接配置与依赖清单没有一起交付

现象:“导入电影失败,pymysql 报 2003 Can’t connect to MySQL server”。演示现场十有八九是这个错。

原因:数据库连接密码写死在脚本里,但现场机器上 MySQL 密码不一致;或者 MySQL 服务没启动、端口被防火墙挡了。这不算 bug,是交付时没把“别人怎么跑起来”的路铺好。

解决:把 host、user、password、database 统一收敛到一个 config.py 或环境变量文件里,脚本全部从配置读取;requirements.txt 里写全 pymysql、jieba、pandas 的版本;再提供一个 start.sh 按顺序执行“建库→建表→导入→生成词典→启动问答”。做这一步的本质是:把“我能跑”变成“你也能跑”,文档说明里最有含金量的就是这个启动顺序和每条命令对应的报错排查方法。

6. 交付一个能现场演示的完整项目:验证脚本与文档说明怎么写

6.1 回归验证脚本:让你的问答对是可重复的

项目交付前,我会写一个纯 Python 的回归测试脚本,把高频问法覆盖表里的问法变成可断言的真值表。这样每次改分类器或词典,跑一遍就知道有没有改坏原来的功能:

CASES = [ ("霸王别姬的导演是谁", "director", "霸王别姬"), ("霸王别姬谁拍的", "director", "霸王别姬"), ("这个电影的评分多少", "rating", None), # 缺实体,期望走兜底 ] def run_tests() -> None: passed = 0 for question, expected_type, expected_title in CASES: qtype = classify_question(question) title = extract_movie(question) ok = (qtype == expected_type) and (title == expected_title) print(("PASS" if ok else "FAIL"), question, qtype, title) passed += int(ok) print(f"通过 {passed}/{len(CASES)}")

这个测试脚本的价值不在于覆盖率,而在于“可复现”。现场演示前花十秒跑一遍,确认核心路径没有翻车,比临场碰运气安心得多。把测试用例和电影数据一起放进源代码包,对方也能通过跑测试验证系统真的可用。

6.2 README 文档说明:五段式结构就够了

文档说明不需要长篇大论,但要做到“按顺序执行就能跑通”。我一般按五段来写:环境要求(Python 版本、MySQL 版本)、初始化步骤(建库、建表、导入、生成词典)、启动方式(入口文件、端口)、测试用例(运行回归脚本、预期结果)、项目结构(源码目录里每个文件是干什么的)。整个包的核心文件大致是:建表 SQL、导入脚本、词典生成脚本、问答主程序、回归测试、requirements.txt、start.sh。把这几个文件组织好,源代码+文档说明+数据库三件套就齐了。

我自己在做类似问答项目时最后悔的一件事,是在第一天没有把编码、自定义词典、统计路由三个基础问题定好,导致后面每一轮调试都在跟翻车现场周旋。如果你在建表时就按 utf8mb4 写入、让词典从数据库自动生成、把统计问题单独分流,这套系统会顺利得超出预期。希望帮到你。

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

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

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

立即咨询