☰
豆瓣知识图谱问答系统:从爬虫到可解释SQL查询的完整实践
2026/10/10 15:27:03 网站建设 项目流程

简介:本资源是一个基于Python构建的豆瓣书籍与电影领域知识图谱问答系统完整实现,面向计算机、电子信息及人工智能方向的本科生与研究生,适用于课程设计、期末大作业及毕业设计参考。项目采用RDF三元组建模,集成Jena、Fuseki等语义技术栈,支持自然语言问句解析与SPARQL查询生成,可快速部署并开展知识推理与问答实验。压缩包共1394个文件,涵盖545个HTML前端页面、316个Java后端服务代码、146个RQ规则文件、46个JS交互脚本及26个Python核心模块,辅以TTL/RDF/OWL等知识表示文件和PNG示例截图,整体体积58.28MB,结构完整、模块清晰,开箱即用。目前已有287人学习下载,提供从数据采集、图谱构建、服务部署到界面交互的全流程源码与配套数据库,含bat启动脚本、SQL建库语句及详细配置说明,显著降低知识图谱实践门槛。

1. 豆瓣书籍+电影知识图谱问答系统:不是“爬完数据就完事”,而是让机器真正理解“《肖申克的救赎》为什么常和《阿甘正传》一起被推荐”

你手头这个.zip包,表面看是“Python + 豆瓣 + 知识图谱 + 问答系统”几个关键词堆砌的典型毕业设计压缩包——但实际拆开后你会发现,它踩中了当前工业级知识应用落地中最痛的三个断层:数据源不可控、语义关系稀疏、自然语言问句与图谱查询之间存在巨大鸿沟。这不是一个“能跑通 demo 就算成功”的玩具项目,而是一套完整覆盖「豆瓣结构化数据清洗 → 多源实体对齐(书/影/人/类型/地区)→ 基于属性路径的SPARQL生成 → 中文问句意图识别与槽位填充 → 图谱结果后处理与可读性增强」的闭环方案。它不依赖任何外部API或商业图谱服务,所有数据来自豆瓣公开页面(经合法合规方式采集),数据库用 SQLite 轻量嵌入,问答接口提供 Flask Web 和 CLI 双模式。适合两类人:想快速验证知识图谱问答最小可行路径的算法工程师,以及需要交付可演示、可解释、可二次扩展的课程设计/毕设的学生——尤其当你被导师追问“你这个系统到底‘懂’什么?”时,它能拿出实体关系图、SPARQL执行日志、问句解析树三件套来回答。


2. 从豆瓣 HTML 到结构化三元组:为什么不用 Scrapy 而坚持 Requests + BeautifulSoup + 自定义清洗规则

豆瓣网页结构高度动态且反爬策略逐年收紧,直接套用通用爬虫框架极易触发验证码或 IP 封禁。本项目采用极简但高鲁棒的requests + bs4组合,并配合一套基于 DOM 路径指纹 + 文本模式校验的清洗逻辑,确保在豆瓣改版后仍能稳定提取关键字段。核心不是“爬得多”,而是“爬得准”:每本书/电影只保留 7 类强语义字段(标题、评分、作者/导演、主演、类型、出版/上映年份、简介),并强制做归一化(如“王家卫”统一为“王家卫(导演)”,“科幻,动作”拆为两个独立类型节点)。

2.1 数据采集:绕过 JS 渲染,直击豆瓣静态 HTML 的真实 DOM 结构

豆瓣详情页虽有大量 JS 加载内容,但关键元数据(如评分、类型、年份)始终存在于<script type="application/ld+json">或<meta property="og:xxx">标签中。项目放弃 Puppeteer 等重量级方案,直接定位这些静态 meta 区域:

# extract_douban.py import requests from bs4 import BeautifulSoup import json import re def extract_from_ld_json(html_content): soup = BeautifulSoup(html_content, 'html.parser') script_tag = soup.find('script', type='application/ld+json') if script_tag: try: ld_data = json.loads(script_tag.string) # 豆瓣电影用 Movie schema,图书用 Book schema if '@type' in ld_data and ld_data['@type'] in ['Movie', 'Book']: return { 'title': ld_data.get('name', ''), 'rating': float(ld_data.get('aggregateRating', {}).get('ratingValue', 0)), 'year': int(re.search(r'(\d{4})', ld_data.get('datePublished', '')).group(1)) if re.search(r'(\d{4})', ld_data.get('datePublished', '')) else None, 'genre': [g.strip() for g in ld_data.get('genre', []) if g.strip()], 'description': ld_data.get('description', '')[:500] # 截断防超长 } except (json.JSONDecodeError, KeyError, ValueError, AttributeError): pass return None

提示:ld+json是 Schema.org 标准,豆瓣官方维护,比 XPath 定位.rating_nums这类 class 名稳定 10 倍。即使豆瓣把 class 改成score-xx,ld+json里的ratingValue字段名不会变。

2.2 实体对齐:解决“姜文”既是导演又是演员、“《活着》”既是书名又是电影名的歧义问题

豆瓣中同一中文名对应多个实体(如“张艺谋”出现在导演、编剧、演员栏),若不做区分,图谱中将产生错误连接。本项目引入角色后缀标注法:所有实体 ID 由(原始名称)_(角色)构成,例如:

原始文本标准化 ID类型说明
张艺谋张艺谋_导演Person作为导演参与的作品
张艺谋张艺谋_编剧Person作为编剧参与的作品
活着活着_图书Book余华所著小说
活着活着_电影Movie张艺谋执导的同名电影

对齐逻辑写在entity_aligner.py中,核心是构建一个映射字典:

# entity_aligner.py def align_entity(raw_name, role_context): """ role_context: 'director', 'author', 'actor', 'book_title', 'movie_title' """ suffix_map = { 'director': '_导演', 'author': '_作者', 'actor': '_演员', 'book_title': '_图书', 'movie_title': '_电影' } suffix = suffix_map.get(role_context, '_未知') # 去除空格、括号干扰 clean_name = re.sub(r'[\s\(\)【】]', '', raw_name) return f"{clean_name}{suffix}" # 示例调用 print(align_entity("张艺谋", "director")) # 输出:张艺谋_导演 print(align_entity("活着", "book_title")) # 输出:活着_图书

参数说明:role_context不是靠 NLP 识别,而是由上游爬虫根据 DOM 位置硬编码决定——比如<span class="pl">导演</span>后紧邻的<a>标签,其内容即为director上下文。这种“规则优先于模型”的做法,在小规模垂直领域中准确率远高于轻量级 NER 模型。

2.3 三元组生成:为什么用 CSV 而非 RDF/XML 存储原始三元组?

项目最终图谱存储在 SQLite 中,但中间三元组以 CSV 形式暂存(triples.csv),格式为subject,predicate,object。原因有三:

  1. 调试友好:用 Excel 或pandas.read_csv()直接打开,一眼看清“《霸王别姬》→ 类型 → 戏曲”是否生成正确;
  2. 去重高效:pandas.drop_duplicates()比 RDF 库的rdflib.Graph.serialize()去重快 5 倍;
  3. Schema 灵活:当发现需新增关系(如“获奖→奖项名称”),只需加一行 CSV,无需修改 RDF Schema。

生成脚本generate_triples.py关键逻辑:

# generate_triples.py import csv def build_triples(book_data, movie_data): triples = [] # 图书三元组 for b in book_data: triples.append([b['id'], 'rdf:type', 'Book']) triples.append([b['id'], 'rdfs:label', b['title']]) triples.append([b['id'], 'schema:rating', str(b['rating'])]) for author in b.get('authors', []): author_id = align_entity(author, 'author') triples.append([b['id'], 'schema:author', author_id]) # 电影三元组(同理) for m in movie_data: triples.append([m['id'], 'rdf:type', 'Movie']) # ... 其他关系 # 写入 CSV with open('triples.csv', 'w', newline='', encoding='utf-8') as f: writer = csv.writer(f) writer.writerow(['subject', 'predicate', 'object']) writer.writerows(triples)

注意:schema:和rdf:前缀是简化版命名空间,不严格遵循 W3C 标准,但足够支撑后续 SPARQL 查询。真正的工业级项目会用rdflib.Namespace管理,但本项目为降低门槛,全部内联字符串。


3. 用 SQLite 模拟图数据库:为什么不用 Neo4j 而选择自建三元组表 + 全文索引

Neo4j 固然强大,但本项目刻意避开它,原因很现实:部署成本、学习曲线、以及“知识图谱问答”场景下,90% 的查询本质是“多跳 JOIN + 文本匹配”。SQLite 通过三张表(entities,relations,triples)+ FTS5 全文索引,完全能胜任。更重要的是,它让整个系统变成单文件可分发——你把douban_kg.db发给同学,他双击app.py就能启动问答界面,无需装 Java、配 Neo4j、开服务端口。

3.1 数据库 Schema 设计:三张表如何支撑“谁演过周星驰的电影?”这类多跳查询

SQLite 表结构极度精简,但每一列都服务于具体查询模式:

表名字段名类型作用说明
entitiesidTEXT PK实体唯一 ID,如肖申克的救赎_电影
labelTEXT可读名称,用于前端显示和全文检索
typeTEXTBook/Movie/Person/Genre,用于类型过滤
relationsidINTEGER PK关系 ID,仅作主键
nameTEXT UNIQUE关系名称,如主演,导演,类型,作者—— 所有关系必须预定义,不支持动态添加
triplessubject_idTEXT FK指向entities.id
relation_idINTEGER FK指向relations.id
object_idTEXT FK指向entities.id
weightREAL关系置信度(爬取时评分 > 8.5 的电影,其类型关系 weight=0.95)

关键设计点:triples表没有relation_name字段,而是用relation_id关联relations表。这样做的好处是——当你要查“所有导演关系”,只需SELECT * FROM relations WHERE name='导演',ID 为 3;后续所有triples.relation_id = 3的记录就是导演关系,避免字符串重复存储和模糊匹配。

3.2 FTS5 全文索引:让“王家卫的文艺片有哪些?”这种问句秒出结果

SQLite 的 FTS5(Full-Text Search 5)是本项目问答响应速度的核心。它不是简单地WHERE label LIKE '%王家卫%',而是建立倒排索引,支持前缀搜索、短语匹配、排名打分:

-- 创建实体全文索引 CREATE VIRTUAL TABLE entities_fts USING fts5(label, content='entities', content_rowid='id'); INSERT INTO entities_fts(entities_fts, rowid, label) SELECT id, label FROM entities; -- 创建关系名称索引(用于识别问句中的关系词) CREATE VIRTUAL TABLE relations_fts USING fts5(name, content='relations', content_rowid='id'); INSERT INTO relations_fts(relations_fts, rowid, name) SELECT id, name FROM relations;

参数说明:content='entities'表示该 FTS 表是entities表的镜像;content_rowid='id'指定关联主键。这样INSERT INTO entities_fts ...就自动同步entities表变更,无需手动维护。

3.3 核心查询函数:把自然语言问句翻译成 SQLite JOIN 语句

问答引擎不生成 SPARQL,而是直译为 SQLite SQL。例如问句:“王家卫导演的电影有哪些?”
→ 解析出:主体王家卫_导演,关系导演,目标类型Movie
→ 生成 SQL:

SELECT DISTINCT e2.label FROM triples t1 JOIN entities e1 ON t1.subject_id = e1.id JOIN relations r ON t1.relation_id = r.id JOIN triples t2 ON t1.object_id = t2.subject_id JOIN entities e2 ON t2.subject_id = e2.id WHERE e1.label MATCH '王家卫' AND r.name = '导演' AND e2.type = 'Movie';

为什么用 JOIN 而非子查询?因为多跳查询(如“张国荣主演、王家卫导演的电影”)需要 3~4 张表关联,JOIN 在 SQLite 中优化器更成熟,执行计划更可预测。子查询嵌套过深易触发too many terms错误。


4. 问答引擎的三大避坑点:那些让你的系统在“谁写了《百年孤独》?”上翻车的细节

本章不讲原理,只列血泪经验。以下问题均在真实测试中复现,且 90% 的同类项目会栽在同一坑里。

4.1 现象:问“《教父》的导演是谁?”返回空结果,但数据库里明明有这条三元组

原因:爬虫提取的导演名是“弗朗西斯·福特·科波拉”,而用户输入问句时打的是“科波拉”。MATCH '科波拉'在 FTS5 中默认不启用前缀搜索,无法匹配长名中的子串。
解决:在entities_fts查询时强制加*通配符,并启用prefix=1选项:

-- 创建时指定 prefix CREATE VIRTUAL TABLE entities_fts USING fts5(label, content='entities', content_rowid='id', prefix='1 2 3'); -- 查询时写成 WHERE e1.label MATCH '科波拉*'

4.2 现象:问“豆瓣评分最高的电影”返回《肖申克的救赎》,但它的评分是 9.7,而《阿甘正传》是 9.5 —— 排序失效

原因:triples表中weight字段是 REAL 类型,但schema:rating关系的object_id存的是字符串"9.7",而非数值。SQLORDER BY对字符串排序是字典序("9.5" > "9.7")。
解决:在插入三元组时,对schema:rating类型的关系,object_id存数值,同时新增object_value字段存原始字符串(用于显示),并在triples表加CHECK约束:

ALTER TABLE triples ADD COLUMN object_value TEXT; UPDATE triples SET object_value = object_id WHERE predicate = 'schema:rating'; UPDATE triples SET object_id = CAST(object_value AS REAL) WHERE predicate = 'schema:rating';

4.3 现象:问“王家卫和张艺谋合作过吗?”返回空,但两人共同作品《2046》存在

原因:2046在豆瓣中既是电影(2046_电影),也是年份(2046_年份),实体对齐时未加类型约束,导致2046被错误归为Year类型,triples中subject_id指向2046_年份,而非2046_电影。
解决:在align_entity()函数中增加上下文类型校验:

def align_entity(raw_name, role_context, context_type=None): # context_type: 'Movie', 'Book', 'Year' —— 由爬虫根据父容器 class 判断 if context_type == 'Movie': suffix = '_电影' elif context_type == 'Book': suffix = '_图书' else: suffix = '_未知' return f"{clean_name}{suffix}"

4.4 现象:CLI 问答模式下,输入“退出”后程序卡死,Ctrl+C 无响应

原因:Flask Web 模式用threading.Event()控制循环,但 CLI 模式直接用while True:+input(),当用户输入含 Unicode 字符(如中文标点)时,Windows 控制台编码cp936与 Python 默认utf-8冲突,input()抛出UnicodeDecodeError未被捕获。
解决:CLI 主循环强制指定编码,并包裹异常:

# cli_app.py import sys import io # 修复 Windows 控制台编码 if sys.platform == "win32": sys.stdin = io.TextIOWrapper(sys.stdin.buffer, encoding='utf-8') while True: try: q = input(">>> ").strip() if q in ['退出', 'quit', 'exit']: break answer = qa_engine.ask(q) print(answer) except UnicodeDecodeError: print("输入包含非法字符,请重新输入") continue except EOFError: break

5. 让问答结果“可读”:从冷冰冰的 SQL 结果到带来源链接、评分、封面图的卡片式输出

问答系统的价值不在于“查得到”,而在于“看得懂、信得过、用得上”。本项目在结果渲染层做了三件事:来源追溯、结构化增强、视觉降噪。这不需要改数据库或算法,纯靠前端模板和后处理逻辑。

5.1 来源追溯:每个答案背后都附带豆瓣 URL,点击直达原始页面

triples表中每条记录都存有source_url字段(爬取时记录),但在查询时不 SELECT 它。后处理阶段,对每个返回的e2.label(如“《花样年华》”),反查entities表获取其source_url:

# qa_engine.py def format_answer(results, entity_ids): output = [] for ent_id in entity_ids: ent_info = db.execute( "SELECT label, type, source_url FROM entities WHERE id = ?", (ent_id,) ).fetchone() if ent_info: # 生成带超链接的 Markdown link = f"[{ent_info[0]}]({ent_info[2]})" rating = get_rating(ent_id) # 从 triples 中查 schema:rating cover = get_cover_url(ent_id) # 从 entities 表的 cover_url 字段 output.append({ "title": link, "type": ent_info[1], "rating": rating, "cover": cover }) return output

注意:source_url在爬取时已做标准化(如https://movie.douban.com/subject/1292052/),确保可直接访问。不存相对路径,不存重定向后 URL。

5.2 结构化增强:用固定字段模板统一输出,避免“有时返回列表、有时返回字符串”

所有问答结果强制走AnswerCard类,字段固定为:

字段名类型说明示例值
titlestr可点击标题(含 Markdown 链接)[《卧虎藏龙》](https://...)
typestr实体类型Movie
ratingfloat豆瓣评分(None 表示无)9.3
yearint年份(从简介或元数据提取)2000
genreslist类型列表(最多 3 个)["武侠", "爱情", "剧情"]
coverstr封面图 URL(缩略图,<100KB)https://imgX.douban.com/...

前端(Flask 模板)按此结构渲染:

<!-- templates/result.html --> {% for card in results %} <div class="card"> <h3>{{ card.title }}</h3> <p><strong>类型:</strong>{{ card.type }}</p> {% if card.rating %}<p><strong>评分:</strong>{{ card.rating }}/10</p>{% endif %} {% if card.year %}<p><strong>年份:</strong>{{ card.year }}</p>{% endif %} {% if card.genres %}<p><strong>类型:</strong>{% for g in card.genres[:3] %}{{ g }}{% if not loop.last %}、{% endif %}{% endfor %}</p>{% endif %} {% if card.cover %}<img src="{{ card.cover }}" width="120" alt="{{ card.title }}"> {% endif %} </div> {% endfor %}

5.3 视觉降噪:用 CSS Grid 实现响应式卡片布局,适配手机/平板/桌面

不依赖 Bootstrap,纯 CSS Grid 控制流式布局:

/* static/style.css */ .card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1.5rem; } .card { border: 1px solid #e0e0e0; border-radius: 8px; padding: 1rem; background: white; box-shadow: 0 2px 4px rgba(0,0,0,0.05); } .card img { max-width: 100%; height: auto; margin-top: 0.5rem; border-radius: 4px; } @media (max-width: 768px) { .card-grid { grid-template-columns: 1fr; } }

参数说明:minmax(280px, 1fr)确保每张卡片最小宽 280px(容纳封面图+文字),在大屏上自动均分;auto-fill让 Grid 自动计算列数,无需媒体查询硬编码。

最后说个我自己的习惯:每次交付前,我会用python app.py启动服务,然后拿手机扫 Flask 控制台里的二维码(用qrcode库生成),在微信里打开问答页,真机测试“语音输入‘王家卫的电影’”是否能准确识别、加载、渲染。因为学生交作业、工程师做 PoC,最怕的不是代码报错,而是“在自己电脑上好好的,一换环境就白屏”。这个 zip 包里所有路径、编码、依赖都按pip install -r requirements.txt一键到位,连 SQLite 文件都预置了 500 条样例数据——它不承诺解决所有问题,但承诺:你解压、安装、运行,3 分钟内看到第一个可交互的问答卡片。希望帮到你。

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

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

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

立即咨询