资讯动态

从Scrapy爬虫到Neo4j知识图谱:军事装备问答系统构建全解析

发布时间:2026/10/3 3:33:39 来源:尧图企业网站定制
简介面向军事装备领域的数据采集与智能问答场景这套实战项目以Scrapy爬虫框架为核心结合知识图谱、MongoDB与问答系统为想要系统掌握“爬取—存储—建模—问答”全流程的开发者提供一份可直接运行的代码参考。压缩包共17个文件、约3.75MB主体为三个Python脚本爬虫采集、数据入库、问答运行和一份MongoDB可导入的JSON数据样例配以系统架构PPT、演示图片以及XML配置和说明文档方便按模块复盘设计思路。项目从爬取武器名称、类型、性能参数等网页数据入手经过清洗与格式化后构造武器、制造商、类型等实体及其关系形成知识图谱问答端则通过问题理解、知识检索与答案生成来响应用户查询其中还涉及kg-scrapy定制解析、分页爬取、查询优化等细节。已有513人学习下载适合对爬虫和知识图谱感兴趣的开发人员、研究者用作课程设计或项目起步参考。1. 先别急着爬QAonMilitaryKG 到底在解决什么问题军事装备数据不是“爬下来”就完事而是“爬下来之后怎么让机器回答人话”。QAonMilitaryKG 这个项目技术栈落在 scrapy 爬虫、知识图谱和问答系统三件事上先用 scrapy 从公开页面抓武器条目再用知识图谱把武器、国家、年代、参数这些实体之间的关系建模最后用一套问答接口把“某型坦克的服役时间”这种自然语言问题翻译成图谱查询。它解决的问题很具体军事领域的武器数据分散在百科、军贸展资料、新闻稿里光有数据不够得有人能按需把它“问”出来。适合谁想练爬虫 知识图谱 问答链路全流程的从业者或者需要做垂直领域问答原型的工程师。网上很多入门项目只做到“爬下来存进 CSV”但这个标题的落点在 Qaon——问答系统也就是最后一公里是接口、是查询、是能回答问题的服务。所以这篇不聊爬虫入门聊的是“爬完以后怎么办”怎么把半结构化页面变成实体关系怎么把关系存进图数据库怎么让人用日常问法把数据取回来。2. 爬虫层scrapy 抓军事装备数据的三个关键选择2.1 为什么选 scrapy请求调度、去重和注入点的匹配度军事装备类站点的数据源通常长这样一个武器列表页每行一个条目链接点进去是详情页详情页里有型号、国家、服役年份、重量、长度等结构化字段。这种“列表页 详情页”的站点结构正好落在 scrapy 的舒适区里。scrapy 的核心不是“能发请求”而是请求调度和去重。你写一个起始 URLscrapy 自动管理并发、重试、超时和去重这些是 requests 手动写循环时要自己实现的活。对“需要抓几百个详情页”的场景scrapy 的并发调度直接省掉一层开发量。加上 scrapy 的 Item Pipeline 天然适合做数据清洗和入库这在后续对接 sqlalchemy 和知识图谱时非常顺。另一个选择理由是 scrapy 的请求去重机制。军事装备数据站点经常有“同一个武器在不同分类下重复出现”的情况scrapy 内置的 RFPDupeFilter 会按请求 URL 去重避免同一个详情页被抓两次。真要按内容去重爬完在 Item Pipeline 里加 hash 就行。# scrapy 爬虫核心部分仅保留调度框架 class WeaponSpider(scrapy.Spider): name weapon_spider def start_requests(self): # 列表页一般分页按 page 参数控制抓取范围 for page in range(1, 30): yield scrapy.Request( urlfhttps://example.com/weapon/list?page{page}, callbackself.parse_list, meta{page: page}, # 记录页码用于日志排查 ) def parse_list(self, response): # 详情页链接在列表页的卡片中用 xpath 抽取相对路径 for href in response.xpath(//div[classitem]//a/href).getall(): yield response.follow(href, callbackself.parse_detail) def parse_detail(self, response): # 详情页字段抽取xpath 写好后入库由 pipeline 处理 yield { name: response.xpath(//h1/text()).get(), country: response.xpath(//td[contains(text(),国家)]/following-sibling::td/text()).get(), year: response.xpath(//td[contains(text(),服役)]/following-sibling::td/text()).get(), }上面的代码里response.follow是 scrapy 处理相对链接的标准写法爬虫会自动拼接完整 URL。meta里传页码参数是排查问题时的好习惯——发现某页抓不到时能直接从日志里定位是哪一页出了问题。td[contains(text(),国家)]这种 xpath 写法专门应对“字段名在表格左列、值在右列”的军事装备详情页比硬写.//td[2]抗页面改版能力更强。2.2 用 scrapy sqlalchemy 落库为什么不在爬虫里直接写 SQL爬虫抓下来的数据要进知识图谱中间通常会经过一个关系型数据库做中转常见做法是 PostgreSQL 或 MySQL 存原始字段清洗后再导入 Neo4j。这一步不直接用 scrapy 写 SQL 的原因有两个一是 scrapy 的 Item Pipeline 是异步调用的直接在 pipeline 里建立数据库连接会频繁开关连接性能差二是清洗逻辑和抓取逻辑应该分开抓错了重跑爬虫清洗错了重跑清洗不要两个步骤耦合在一个脚本里。sqlalchemy 在这里的价值是 ORM 映射。你定义一个Weapon表结构pipeline 里拿到 item 后直接丢给 ORM不用手写 INSERT 语句的字段拼接。# items.py 定义字段结构 class WeaponItem(scrapy.Item): name scrapy.Field() country scrapy.Field() year scrapy.Field() length scrapy.Field() weight scrapy.Field() url scrapy.Field()# pipelines.py 里通过 sqlalchemy 入库 from sqlalchemy.orm import sessionmaker from sqlalchemy import create_engine from .models import Weapon, Base class WeaponPipeline: def open_spider(self, spider): engine create_engine(postgresql://user:passlocalhost/military_kg, echoFalse) Base.metadata.create_all(engine) self.Session sessionmaker(bindengine) def process_item(self, item, spider): session self.Session() # 按 URL 去重避免重复写入 exists session.query(Weapon).filter_by(urlitem[url]).first() if not exists: weapon Weapon(**item) session.add(weapon) session.commit() session.close() return item这段 pipeline 里有两个细节值得注意open_spider里创建的 engine 在整个爬虫生命周期里只建一次避免每个 item 都新建连接按url查询去重是“请求去重之外的第二道保险”因为 scrapy 的去重只针对请求不保证页面内容不重复。字段名**item展开能直接匹配 ORM 模型的前提是 scrapy.Field 的 key 和 Weapon 表的列名完全一致所以爬虫字段命名要提前和表结构对齐。2.3 动态 iframe 页面scrapy 拿不到渲染后 DOM 时的处理顺序很多军事装备站点用的是 iframe 嵌套详情页scrapy 默认只能拿到初始 HTMLiframe 里的内容是浏览器后续加载的。这种情况下直接在 parse_detail 里写 xpath 是抓不到的response 里根本没有 iframe 内层的 DOM。处理顺序我一般按三步走第一步先看 iframe 的src是不是一个独立 URL很多站点 iframe 里嵌的其实就是另一个可访问的页面直接response.follow那个 URL 抓就行根本不需要浏览器渲染。第二步才是上 selenium 或 playwright但这类工具会把爬虫速度拖慢一个量级而且需要额外维护一个浏览器实例。第三步是判断 iframe 内容是否真的必要——如果 iframe 里只有图片或相关性较低的内容跳过反而更划算。# 处理 iframe 的推荐路径先取 src 再抓子页面 def parse_detail(self, response): # xpath 取出 iframe 的真实地址 iframe_src response.xpath(//iframe[iddetail_frame]/src).get() if iframe_src: # 子页面可能是相对路径需要 urljoin 才能访问 yield response.follow(iframe_src, callbackself.parse_iframe_detail) else: # 无 iframe 时直接解析当前页面 yield self.extract_from_page(response)response.follow会自行处理相对路径拼接但如果 iframe 的 src 是//cdn.example.com/xxx.html这种无协议地址需要先补上https:再交给 follow。这一步是 iframe 场景最常见的翻车点现象是回调没报错但拿到的 response 是 404原因就是 src 的协议被浏览器自动补全scrapy 不会替你补。还有个血泪经验iframe 里的页面经常带自己的反爬策略比如需要 Referer 校验。如果子页面返回 403在 Request 里加上headers{Referer: response.url}通常能解决。这个坑在动态 iframe 页面里出现频率极高属于“写爬虫必踩、踩完才记得”的类型。3. 知识图谱层把武器数据变成 KG 的本体设计与入库3.1 本体设计军事装备领域拆四类节点才够用知识图谱构建最怕的是“什么都想建最后建出来的图没人看得懂”。QAonMilitaryKG 这种军事装备领域的问答按常见的从业方案本体设计拆四类节点就够用第一类节点是武器系统本身包括型号名称、类型坦克/战机/舰船、服役年份、制造商第二类是属性参数包括重量、长度、速度、武器口径这些数值型数据第三类是组织实体包括国家、研发单位、生产厂商第四类是事件实体包括首飞时间、退役时间、实战使用记录。这四个类型的实体之间关系可以是“武器 - 属于 - 国家”“武器 - 搭载 - 武器系统”“武器 - 研发机构 - 厂商”问答系统查询时基本都是沿这些边走路。本体建模时要注意属性节点和属性值要区分开。重量 30 吨很多人会做成(武器)-[:重量]-(30吨)但更合理的做法是把“重量”作为关系属性而不是一个独立节点。因为数值型属性几乎不会被用户当作实体去查询——没人会问“30吨的武器有哪些”但会问“T-90 的重量是多少”。独立属性节点只适用于“国家”“制造商”这种会在多个武器之间复用的实体。# 本体定义的伪代码指导后续入库时的节点和关系划分 node_types { Weapon: [name, type, service_year], Country: [name, region], Manufacturer: [name, location], Event: [name, date, description], } relationships [ (Weapon, belongs_to, Country), (Weapon, developed_by, Manufacturer), (Weapon, has_event, Event), ]这个伪代码不是 Neo4j 的 Cypher而是入库前对齐本体结构的规划。实际入库时先建节点再建关系顺序反了会导致关系引用不存在的节点图数据库不会报错但查询结果会是空的。建议在建关系之前用MATCH确认节点数量对得上再跑关系导入。3.2 从 sqlalchemy 到 Neo4j实体对齐与关系去重上一章抓进 PostgreSQL 的数据导入 Neo4j 前要做一次实体对齐。什么叫实体对齐举个例子爬虫抓到两个字段country: 美国和country: USA在关系型数据库里它们各自独立但在知识图谱里它们必须合并成同一个Country节点否则问答系统问“美国有哪些主战坦克”时只会命中一半数据。实体对齐的常见做法是维护一个同义词映射表在导入脚本里做一次归一化。这个表不需要一开始就建全跑完导入后把零散节点导出看一眼人工归类常见写法就行。# 实体对齐脚本在导入 Neo4j 前归一化国家名 ENTITY_ALIASES { 美国: [美国, USA, U.S., 美利坚], 俄罗斯: [俄罗斯, 俄国, Russia, USSR, 苏联], } def normalize_entity(value): for target, aliases in ENTITY_ALIASES.items(): if value in aliases or value target: return target return value # 不在映射表里的保持原名等下一轮补充关系去重是本体的关键。同一篇报道可能同时出现“T-90 属于俄罗斯”和“俄罗斯研制 T-90”两句话建模时一个会建成(T-90)-[:belongs_to]-(俄罗斯)另一个会建成(俄罗斯)-[:developed]-(T-90)两种关系表达的语义不同不能合并。但同一个belongs_to关系可能被多次插入导入时要先去重。# 使用 py2neo 批量导入 Neo4j带关系去重 from py2neo import Graph, Node, Relationship graph Graph(bolt://localhost:7687, auth(neo4j, password)) def import_relationship(subject, relation, object_): # 先查节点是否存在不存在则创建再建关系 subj_node graph.nodes.match(Weapon, namesubject).first() obj_node graph.nodes.match(Country, nameobject_).first() if not subj_node: subj_node Node(Weapon, namesubject) graph.create(subj_node) if not obj_node: obj_node Node(Country, nameobject_) graph.create(obj_node) # 检查关系是否已存在避免重复边 existing graph.match((subj_node, obj_node), rtyperelation).first() if not existing: graph.create(Relationship(subj_node, relation, obj_node))这段代码里每建一条边都先查后建速度肯定比不上UNWIND批量导入但对万级节点的小型知识图谱完全够用而且排查麻烦时容易定位。超过十万条关系后建议改用UNWIND加MERGE的 Cypher 方式这个阈值一般项目很难达到不需要提前优化。3.3 Neo4j 查询设计的反向思维问答问什么图就怎么建知识图谱的节点和关系设计应该在写问答系统之前先想清楚“用户会问哪些问题”。QAonMilitaryKG 里问答系统要问什么典型问题是“某武器的国家”“某国家的武器列表”“某武器的参数”这些对应到图谱上就是两类查询按属性查节点和按关系边遍历。一个常见的设计错误是关系方向不一致。比如把belongs_to建成(武器)-(国家)查询“国家有哪些武器”时要沿反向边遍历但如果在导入时忘记统一方向有的边是(武器)-(国家)有的是(国家)-(武器)问答系统就会漏答案。解决方法是约定一个方向规范所有关系统一从武器指向属性实体查询时用反向遍历处理。# 统一的查询模式所有关系从 Weapon 指向其他实体 MATCH (w:Weapon)-[r:belongs_to]-(c:Country) WHERE c.name $country_name RETURN w.name4. QA 问答系统从自然语言到图谱查询的翻译链路4.1 意图识别与实体链接不用大模型的轻量方案问答系统的入口是用户问题比如“中国有哪些主战坦克”“M1A2 的重量是多少”。要让机器理解这两句话常见做法是拆两步意图识别和实体链接。意图识别不需要上大模型用规则就能覆盖垂直领域的大部分问题。军事装备领域的问法基本可以归纳为三类查询属性“某武器的重量是多少”、查询关系“某国有哪些武器”、查询存在性“某武器是否存在”。规则匹配的核心是维护一组模板词和类别词。比如“重量”“长度”“速度”映射到属性查询“有哪些”“装备”“列装”映射到关系查询。# 意图识别模板先抽实体再按关键词定意图 import re INTENT_PATTERNS { attribute: [重量, 长度, 速度, 口径, 服役时间], list_by_country: [有哪些, 装备了哪些, 列装, 服役于], existence: [是否存在, 有没有], } def detect_intent(question): for intent, keywords in INTENT_PATTERNS.items(): for kw in keywords: if kw in question: return intent return unknown实体链接比意图识别麻烦一点。用户不会每次都说“M1A2 艾布拉姆斯”可能会说“M1”“艾布拉姆斯”“美军主战坦克”。实体链接的常见做法是先做词典匹配再叠加 jieba 分词和自定义词典。词典匹配的准确率比模型高很多——军事装备领域的实体名高度专一一个实体名几乎不会跨领域产生歧义不需要用深度学习做消歧。把武器名、国家名、厂商名都维护进 jieba 的自定义词典分词后的词条直接映射到图谱里的节点名。4.2 Flask 包一层 REST API问答链路与超时控制问答链路做成 HTTP 接口是通用做法Flask 最轻量。接口接收question参数内部流程是意图识别 → 实体链接 → Cypher 查询 → 回答生成 → 返回 JSON。# Flask 问答接口完整链路在 QASystem 类中实现 from flask import Flask, request, jsonify from qa_system import QASystem app Flask(__name__) qa QASystem() app.route(/qa, methods[POST]) def answer(): data request.get_json() question data.get(question, ) if not question: return jsonify({error: question is required}), 400 result qa.answer(question) return jsonify(result) app.route(/health, methods[GET]) def health(): return jsonify({status: ok})问答链路里最容易出问题的不是查询而是 Cypher 查询超时。用户问法稍微复杂一点生成的 Cypher 可能遍历全图Neo4j 默认不设超时一个慢查询能拖死整个问答接口。常见做法是在 Cypher 上包apoc.cypher.runTimeboxed或者给查询统一加超时参数。4.3 回答模板让零命中也有体面的兜底话术问答系统的回答生成在垂直领域里不建议用生成式模型——数据量不够、回答不可控、错误信息在军事装备领域会造成严重误导。更可靠的方案是模板回答根据意图把查询结果填充进预设句式。# 回答生成的模板匹配逻辑 def generate_answer(intent, query_result): if intent attribute: if query_result: name, attr, value query_result[0] return f{name} 的 {attr} 是 {value} else: return 暂时没查到该武器的该属性数据 elif intent list_by_country: if query_result: weapons [r[name] for r in query_result] return .join(weapons) 等 else: return 知识库里暂无该国家的武器记录 return 这个问题超出我目前的知识范围这段代码覆盖了三种情况正常命中、零命中、意图未知。零命中的兜底话术不一致就会露馅——“没查到”和“不在乎”观感完全不同。模板回答另一个好处是答案格式稳定前端展示时不需要再对文本做二次解析。数据更新后知识图谱里的内容变了模板不用动新数据会自动被查询到。这也是模板比生成式模型更适合垂直领域问答的原因之一。5. 避坑手册从爬虫被封到图谱查询慢的 6 个真实教训5.1 现象scrapy 并发一提高就被封 IP日志里全是 403原因军事装备类站点对爬虫的容忍度极低默认配置的并发 16、下载延迟 0 在对方看来就是攻击流量。解决把CONCURRENT_REQUESTS降到 4 以下DOWNLOAD_DELAY调到 2 秒以上同时配置 AutoThrottle 让爬虫根据响应时间动态调整延迟。抓这么小的数据集根本不缺时间被封了重跑一次损失的时间更多。# settings.py 里的保护性配置 CONCURRENT_REQUESTS 4 DOWNLOAD_DELAY 2.0 AUTOTHROTTLE_ENABLED True AUTOTHROTTLE_START_DELAY 2.05.2 现象爬虫正常运行但库里武器名大量重复原因同一型号在不同页面有不同写法比如“T-90A”和“T90”。scrapy 的 URL 去重只能防同链接重复抓防不了同实体不同写法。解决入库后加一层名称清洗去掉武器名里的连字符、统一大写。清洗逻辑放在 sqlalchemy 导入那一层不要放在爬虫里保持爬虫只做“拿原始数据”。5.3 现象Neo4j 里节点建了但问答查询总是返回空原因实体链接阶段提取出的实体名和图里的节点名不完全一致比如用户说“中国”图里节点叫“中华人民共和国”。解决在实体链接层做一次归一化映射把用户问题的说法映射到图的节点名。这个映射表和前面提到的实体对齐映射表可以共用一套配置Excel 或 JSON 维护都行。5.4 现象问答接口第一次查询要等好几秒后面就快了原因Neo4j 首次连接要握手认证后续连接复用就快。解决在 Flask 应用初始化时预热连接提前执行一个MATCH (n) RETURN count(n)之类的轻查询。这不是分布式系统那种大问题但用户感知上第一次访问慢就是体验缺陷。5.5 现象动态 iframe 页面通过 selenium 能抓到但 scrapy 里怎么都拿不到原因iframe 的 URL 是靠 JavaScript 动态生成的初始 html 里只有 iframe 标签没有 src 属性。解决找到生成 src 的那个 js 接口直接请求接口拿 URL。比让 selenium 渲染整个页面快得多而且是“动态页面静态抓”的核心思路。找不到接口再上 playwright 兜底。5.6 现象爬虫抓的数据里有字符串乱码sqlalchemy 插入报错原因页面编码判断出错。解决在 scrapy 的Request上显式指定编码encodingutf-8或者在下游用iconv清洗一遍。军事装备站点老站较多GBK 编码的页面很常见不要相信Content-Type头里的 charset 声明一定要看实际字节。6. 进阶把新词收进词典问答命中率再涨一截问答系统上线跑一段时间后你会发现一个规律用户问法永远比你预想的多。设计时只维护了“中国有哪些主战坦克”用户实际会问“兔子家有啥坦克”或者“PLA 现在用什么坦克”。这些问法和图谱节点名差得远实体链接很容易失败。血泪经验最先要做的不是调算法而是把这些新词加进自定义词典和同义词映射表。jieba 的分词结果直接决定实体链接准不准——“主战坦克”如果被切成“主战”“坦克”实体链接会把“主战”当实体名拿去匹配图谱结果自然是空。把“主战坦克”整体加进 jieba 词典分词才会正确。# jieba 自定义词典的追加写法 import jieba extra_words [主战坦克, M1A2艾布拉姆斯, T-90A, 中国人民解放军] for word in extra_words: jieba.add_word(word)还有一个习惯值得沿用每次发现没命中的问题不要只在代码里临时补规则而是把这条问题记进一个“未命中日志表”每周跑一次统计把出现频率超过两次的问法批量加进词典和模板。这样整个问答系统的命中率会呈现阶梯式上升而不是靠每次手动补丁救火。# 未命中日志的落库结构跑一周就能看出用户问法规律 CREATE TABLE IF NOT EXISTS qa_miss_log ( id SERIAL PRIMARY KEY, question TEXT, -- 用户原始问法 intent VARCHAR(50), -- 意图识别结果 entity VARCHAR(100), -- 实体链接结果 created_at TIMESTAMP DEFAULT NOW() );按这个节奏维护两到三周问答系统对实际用户问法的覆盖率会明显高于刚上线时。知识图谱的价值是靠问答被“用出来”的图谱建得再漂亮问不到答案就是白搭。我也曾在这个项目的问答环节上翻过车——光顾着优化 Cypher忘了用户根本不会用图谱节点名的说法去提问。后来把同义词映射做厚了之后整体链路才真正转起来希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑