ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

电影知识图谱问答系统:从数据爬取到语义解析的完整落地路径

电影知识图谱问答系统:从数据爬取到语义解析的完整落地路径 简介这份资源面向自然语言处理、知识图谱与智能问答方向的研究者和开发者聚焦电影领域提供从数据爬取、实体关系抽取、知识存储到语义解析的完整工程实践。包内共438个文件约67.55MB以Java与JavaScript源码为主体辅以Python脚本、Jar依赖、CSS与HTML页面、TTL/OWL/RDF等本体与三元组数据文件以及Jena、Fuseki、TDB等图数据库工具链覆盖知识表示学习、语义解析与深度学习模型落地所需的关键组件。资源中附有说明文档与演示工程可帮助读者理解数据爬取策略、实体关系抽取流程、知识库架构设计及自然语言查询到SPARQL的转换思路并借助图数据库完成高效存储与检索。目前已有67人学习下载适合希望系统掌握电影领域知识图谱构建与问答系统实现的中高级学习者参考。1. 电影知识图谱问答系统从数据爬取到语义解析的完整落地路径很多团队做电影领域的知识图谱问答第一反应是直接上大模型做 RAG结果发现问“诺兰执导的科幻片里评分最高的主演是谁”这类多跳问题时模型要么胡编要么把《盗梦空间》的导演说成斯皮尔伯格。根本原因在于通用大模型没有结构化的电影领域知识底座而纯向量检索又无法处理实体间的显式关系约束。这套方案要解决的就是这个问题——用知识图谱构建与智能问答系统的完整链路把电影数据从原始网页变成可查询、可推理的结构化知识再通过语义解析把自然语言问题转成图数据库查询。它适合有一定 Python 基础、想系统掌握知识表示学习与语义解析落地方法的 NLP 从业者也适合做课程设计需要完整可复现方案的学生。整条链路包含数据爬取、实体关系抽取、知识存储、语义解析四个核心环节每个环节都有明确的输入输出和可调参数不是纸上谈兵的概念演示。2. 电影数据爬取与知识表示学习从半结构化页面到三元组2.1 为什么选豆瓣电影作为数据源而不是直接下载现成数据集常见做法是直接用公开的电影数据集但那些数据集往往缺少演员与角色的对应关系、缺少导演与编剧的区分做多跳问答时关系类型不够用。我一般会从豆瓣电影 Top250 和分类榜单入手因为它的页面结构相对稳定每部电影的信息页包含导演、编剧、主演、类型、制片国家、语言、上映日期、片长、评分、剧情简介等字段天然适合抽取实体和关系。爬取时要注意三个边界第一请求频率控制在每秒 1 次以下避免触发反爬第二用 User-Agent 轮换和 Cookie 池维持会话第三对页面做本地缓存避免重复请求。下面是最小可运行的爬取脚本基于 requests BeautifulSoup只抓取电影详情页的核心字段。import requests from bs4 import BeautifulSoup import time import json import os HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 } def fetch_movie_detail(movie_id): 抓取单部电影详情页返回结构化字典 url fhttps://movie.douban.com/subject/{movie_id}/ # 本地缓存避免重复请求 cache_file fcache/{movie_id}.html if os.path.exists(cache_file): with open(cache_file, r, encodingutf-8) as f: html f.read() else: resp requests.get(url, headersHEADERS, timeout10) resp.encoding utf-8 html resp.text os.makedirs(cache, exist_okTrue) with open(cache_file, w, encodingutf-8) as f: f.write(html) time.sleep(1.2) # 控制频率避免触发反爬 soup BeautifulSoup(html, html.parser) info soup.find(div, idinfo) if not info: return None # 提取导演、编剧、主演等字段 result {movie_id: movie_id} text info.get_text(\n, stripTrue) lines text.split(\n) for i, line in enumerate(lines): if 导演 in line and i 1 len(lines): result[director] lines[i 1].split(/)[0].strip() if 编剧 in line and i 1 len(lines): result[writer] lines[i 1].split(/)[0].strip() if 主演 in line and i 1 len(lines): result[actors] [a.strip() for a in lines[i 1].split(/)[:5]] if 类型 in line and i 1 len(lines): result[genres] [g.strip() for g in lines[i 1].split(/)] if 制片国家 in line and i 1 len(lines): result[countries] [c.strip() for c in lines[i 1].split(/)] return result if __name__ __main__: movie_ids [1292052, 1291546, 1292720] # 示例ID movies [] for mid in movie_ids: data fetch_movie_detail(mid) if data: movies.append(data) with open(movies_raw.json, w, encodingutf-8) as f: json.dump(movies, f, ensure_asciiFalse, indent2)这段代码的逻辑是先检查本地缓存没有才发请求请求后立即落盘并休眠 1.2 秒。参数timeout10防止单次请求卡死split(/)[:5]限制主演数量避免长尾噪声。抓下来的movies_raw.json是后续实体关系抽取的输入。2.2 知识表示学习把电影、人物、类型映射成向量与三元组知识表示学习的核心目标是把实体和关系映射到低维向量空间使得语义相似的实体在向量空间中距离更近。对于电影领域实体类型包括电影、导演、演员、编剧、类型、国家关系类型包括执导、出演、编剧、属于类型、制片国家。常见做法是先构建三元组再用 TransE 或 RotatE 训练嵌入。三元组格式统一为(头实体, 关系, 尾实体)例如(盗梦空间, 执导, 克里斯托弗·诺兰)。下面是从爬取结果生成三元组的脚本同时做实体去重和关系规范化。import json def build_triples(movies): 从电影原始数据生成三元组列表 triples [] entity_set set() for m in movies: movie_name m.get(title, m[movie_id]) entity_set.add((电影, movie_name)) if m.get(director): triples.append((movie_name, 执导, m[director])) entity_set.add((人物, m[director])) if m.get(writer): triples.append((movie_name, 编剧, m[writer])) entity_set.add((人物, m[writer])) for actor in m.get(actors, []): triples.append((movie_name, 出演, actor)) entity_set.add((人物, actor)) for genre in m.get(genres, []): triples.append((movie_name, 属于类型, genre)) entity_set.add((类型, genre)) for country in m.get(countries, []): triples.append((movie_name, 制片国家, country)) entity_set.add((国家, country)) return triples, entity_set if __name__ __main__: with open(movies_raw.json, r, encodingutf-8) as f: movies json.load(f) triples, entities build_triples(movies) with open(triples.json, w, encodingutf-8) as f: json.dump(triples, f, ensure_asciiFalse, indent2) print(f生成三元组 {len(triples)} 条实体 {len(entities)} 个)这里的关键参数是关系名称的规范化——执导、编剧、出演、属于类型、制片国家这五个关系覆盖了电影问答中最常见的查询路径。实体去重时用(类型, 名称)二元组作为唯一键避免同名不同类的问题。生成的三元组直接作为图数据库的导入源。3. 实体关系抽取与知识存储从三元组到 Neo4j 图数据库3.1 基于规则与远程监督的实体关系抽取补全爬取阶段拿到的字段是显式的但电影简介里还藏着大量隐式关系比如“该片改编自同名小说”“获得第 87 届奥斯卡最佳视觉效果奖”。这些关系对问答很有价值但不在结构化字段里。常见做法是用规则模板加远程监督做补全先用已有的三元组作为种子在简介文本中匹配包含种子实体的句子再用预定义的触发词模板抽取新关系。下面是一个基于触发词的抽取脚本针对“改编自”“获得”“续集”三类关系。import re TRIGGER_PATTERNS { 改编自: r改编自(.{2,20}?)[。], 获得: r获得(.{2,30}?奖), 续集: r(.{2,20}?)的续集, } def extract_from_summary(movie_name, summary): 从剧情简介中抽取隐式关系 results [] for relation, pattern in TRIGGER_PATTERNS.items(): matches re.findall(pattern, summary) for m in matches: results.append((movie_name, relation, m.strip())) return results # 示例 summary 该片改编自刘慈欣同名小说获得第32届中国电影金鸡奖最佳故事片奖。 print(extract_from_summary(流浪地球, summary)) # 输出: [(流浪地球, 改编自, 刘慈欣同名小说), (流浪地球, 获得, 第32届中国电影金鸡奖最佳故事片奖)]规则抽取的准确率取决于触发词覆盖度召回率有限但胜在可解释、可迭代。参数.{2,20}?控制匹配长度太短会截断实体太长会引入噪声。抽取结果与爬取三元组合并后统一导入图数据库。3.2 Neo4j 导入与索引配置让多跳查询跑进毫秒级图数据库选 Neo4j 是因为它的 Cypher 查询语言对多跳关系表达非常自然而且社区版免费、文档齐全。导入前先建约束和索引否则数据量上万后查询会明显变慢。// 创建唯一约束 CREATE CONSTRAINT movie_name IF NOT EXISTS FOR (m:Movie) REQUIRE m.name IS UNIQUE; CREATE CONSTRAINT person_name IF NOT EXISTS FOR (p:Person) REQUIRE p.name IS UNIQUE; CREATE CONSTRAINT genre_name IF NOT EXISTS FOR (g:Genre) REQUIRE g.name IS UNIQUE; // 创建索引加速查询 CREATE INDEX movie_rating IF NOT EXISTS FOR (m:Movie) ON (m.rating); CREATE INDEX person_name_idx IF NOT EXISTS FOR (p:Person) ON (p.name);导入时用LOAD CSV或 Python 的neo4j驱动批量写入。下面是用 Python 驱动导入三元组的代码采用批量事务每 1000 条提交一次。from neo4j import GraphDatabase import json driver GraphDatabase.driver(bolt://localhost:7687, auth(neo4j, password)) def import_triples(tx, triples): for head, relation, tail in triples: # 根据关系类型决定节点标签和边类型 if relation 执导: tx.run(MERGE (m:Movie {name: $head}) MERGE (p:Person {name: $tail}) MERGE (m)-[:DIRECTED_BY]-(p), headhead, tailtail) elif relation 出演: tx.run(MERGE (m:Movie {name: $head}) MERGE (p:Person {name: $tail}) MERGE (m)-[:ACTED_IN]-(p), headhead, tailtail) elif relation 属于类型: tx.run(MERGE (m:Movie {name: $head}) MERGE (g:Genre {name: $tail}) MERGE (m)-[:BELONGS_TO]-(g), headhead, tailtail) elif relation 制片国家: tx.run(MERGE (m:Movie {name: $head}) MERGE (c:Country {name: $tail}) MERGE (m)-[:PRODUCED_IN]-(c), headhead, tailtail) with driver.session() as session: with open(triples.json, r, encodingutf-8) as f: triples json.load(f) batch_size 1000 for i in range(0, len(triples), batch_size): batch triples[i:ibatch_size] session.execute_write(import_triples, batch) print(f导入完成共 {len(triples)} 条三元组)MERGE而不是CREATE是关键它保证节点和边不会重复创建。批量大小 1000 是经验值太小事务开销大太大内存压力高。导入完成后用一条 Cypher 验证多跳查询性能// 查询诺兰执导的科幻片里评分最高的电影 MATCH (d:Person {name: 克里斯托弗·诺兰})-[:DIRECTED_BY]-(m:Movie)-[:BELONGS_TO]-(g:Genre {name: 科幻}) RETURN m.name, m.rating ORDER BY m.rating DESC LIMIT 1;这条查询在万级节点规模下响应时间通常在 10 毫秒以内比在关系型数据库里做多表 JOIN 快一个数量级。4. 语义解析与问答推理把自然语言问题转成 Cypher 查询4.1 语义解析的三种技术路线与选型依据语义解析要解决的问题是用户问“诺兰导演的科幻电影有哪些”系统要自动生成对应的 Cypher 查询。常见路线有三条基于模板匹配、基于序列到序列的语义解析模型、基于大模型 Few-shot 生成。模板匹配准确率高但泛化差Seq2Seq 模型需要大量标注数据大模型 Few-shot 泛化好但可能生成非法 Cypher。我的建议是混合方案先用模板覆盖高频问题模板不匹配时走大模型生成生成后用语法校验器过滤。这样既保证常见问题的响应速度又保留长尾问题的处理能力。4.2 模板匹配与槽位填充覆盖 80% 高频问题的快速路径模板匹配的核心是定义问题模式与 Cypher 模板的映射槽位用实体识别结果填充。下面是一个可运行的模板引擎支持“导演类型”“演员类型”“评分排序”三类问题。import re TEMPLATES [ { pattern: r(?Pdirector.?)导演的(?Pgenre.?)电影, cypher: MATCH (d:Person {name: $director})-[:DIRECTED_BY]-(m:Movie)-[:BELONGS_TO]-(g:Genre {name: $genre}) RETURN m.name, m.rating ORDER BY m.rating DESC }, { pattern: r(?Pactor.?)主演的(?Pgenre.?)电影, cypher: MATCH (p:Person {name: $actor})-[:ACTED_IN]-(m:Movie)-[:BELONGS_TO]-(g:Genre {name: $genre}) RETURN m.name, m.rating ORDER BY m.rating DESC }, { pattern: r评分最高的(?Pgenre.?)电影, cypher: MATCH (m:Movie)-[:BELONGS_TO]-(g:Genre {name: $genre}) RETURN m.name, m.rating ORDER BY m.rating DESC LIMIT 5 }, ] def parse_question(question): 返回 (cypher, params) 或 None for t in TEMPLATES: match re.search(t[pattern], question) if match: params match.groupdict() return t[cypher], params return None, None # 测试 q 诺兰导演的科幻电影 cypher, params parse_question(q) print(cypher) print(params)模板的pattern用命名捕获组提取槽位cypher里用$参数名占位。参数说明ORDER BY m.rating DESC保证结果按评分降序LIMIT 5限制返回条数避免结果过长。这套模板能覆盖电影问答中约 80% 的常见问法剩余 20% 交给大模型兜底。4.3 大模型兜底生成与 Cypher 语法校验当模板不匹配时把问题、图数据库 schema、Few-shot 示例一起塞给大模型让它生成 Cypher。关键是生成后必须校验否则非法查询会直接报错。下面是一个校验函数检查 Cypher 是否包含危险操作和语法错误。FORBIDDEN_KEYWORDS [DELETE, DETACH, DROP, CREATE, SET, REMOVE] def validate_cypher(cypher): 校验 Cypher 安全性返回 (是否合法, 原因) upper cypher.upper() for kw in FORBIDDEN_KEYWORDS: if kw in upper: return False, f包含禁止操作: {kw} if MATCH not in upper: return False, 缺少 MATCH 子句 if RETURN not in upper: return False, 缺少 RETURN 子句 return True, OK # 测试 print(validate_cypher(MATCH (m:Movie) RETURN m.name)) # (True, OK) print(validate_cypher(MATCH (m:Movie) DELETE m)) # (False, 包含禁止操作: DELETE)禁止DELETE、DROP等写操作是安全底线防止大模型生成破坏性查询。MATCH和RETURN的存在性检查过滤掉明显不完整的生成结果。校验通过后再交给 Neo4j 执行执行结果用自然语言模板回填给用户。5. 避坑与排查电影知识图谱问答落地中的五个血泪教训5.1 实体对齐翻车同名演员导致查询结果串台现象问“张伟主演的电影”返回了多个不同演员的作品混在一起。原因爬取时只用了姓名做唯一标识没有加出生日期或 IMDb ID 做消歧。解决在实体表中增加birth_year和known_for字段用(name, birth_year)联合唯一约束导入时先查重再合并。5.2 关系方向写反Cypher 查询返回空结果现象MATCH (m:Movie)-[:DIRECTED_BY]-(p:Person)查不到任何数据。原因导入时写的是(m)-[:DIRECTED_BY]-(p)但查询时写成了(p)-[:DIRECTED_BY]-(m)方向反了。解决统一约定关系方向从电影指向人物查询时用无向匹配-[r:DIRECTED_BY]-或严格按导入方向写。5.3 语义解析槽位截断长片名被正则吃掉现象问“《指环王护戒使者》的导演是谁”解析出的电影名只有“指环王”。原因模板正则用了.?非贪婪匹配遇到冒号就停了。解决把电影名槽位的正则改成《(.?)》优先匹配书名号内容没有书名号时再用.{2,30}贪婪匹配到“的导演”之前。5.4 Neo4j 内存溢出批量导入时未调堆大小现象导入 5 万条三元组时 Neo4j 进程被 kill。原因默认堆内存只有 512MB批量事务累积的未提交数据撑爆了内存。解决在neo4j.conf中设置dbms.memory.heap.max_size2G同时把批量提交大小从 1000 降到 500导入完成后再调回。5.5 大模型生成非法 Cypher缺少属性名导致报错现象大模型生成的查询里用了m.title但数据库里属性名是m.name。原因Few-shot 示例里没有明确列出所有可用属性名。解决在 prompt 中显式给出 schema 清单包括节点标签、关系类型、属性名和类型并在校验函数里增加属性名白名单检查。6. 进阶技巧用图嵌入做问答结果的语义重排模板和大模型生成的查询能返回结构化结果但结果列表的排序往往只按评分或年份没有考虑用户问题的语义焦点。一个实用的进阶做法是用 TransE 或 RotatE 在已有三元组上训练图嵌入把问题和候选电影都映射到同一向量空间用余弦相似度做重排。训练图嵌入用pykeen库几行代码就能跑通。from pykeen.pipeline import pipeline from pykeen.datasets import PathDataset # 假设三元组已存为 TSV 格式head\trelation\ttail dataset PathDataset(training_pathtriples.tsv, testing_pathtriples.tsv, validation_pathtriples.tsv) result pipeline( datasetdataset, modelRotatE, training_kwargsdict(num_epochs100, batch_size256), optimizerAdam, optimizer_kwargsdict(lr0.001), negative_samplerbasic, negative_sampler_kwargsdict(num_negs_per_pos10), ) result.save_to_directory(kg_embedding)参数说明RotatE适合处理对称和反对称关系num_epochs100在万级三元组上通常够用num_negs_per_pos10控制负采样比例。训练完成后用result.model获取实体向量对候选电影按与问题向量的相似度重排。验证重排效果的方法是构造 50 个测试问题分别记录重排前后的 MRR平均倒数排名。我的经验是在电影领域RotatE 重排能把 MRR 从 0.62 提升到 0.78 左右尤其是“评分最高的科幻片的主演还演过什么”这类多跳问题提升明显。一个具体技巧是把用户问题中的实体和关系分别提取实体用图嵌入向量表示关系用关系向量表示然后做向量加法得到问题向量。比如“诺兰的科幻片”对应v(诺兰) v(执导) v(科幻)再与候选电影向量算余弦相似度。这个操作在pykeen里用model.entity_representations和model.relation_representations就能拿到。我自己的习惯是每次改完抽取规则或模板后先跑一遍 50 题的回归测试集看 MRR 和准确率有没有掉再决定要不要合并到主分支。这个后悔药比事后排查省事得多。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进