简介:这份资源面向军事装备数据爱好者、知识图谱与爬虫初学者,提供一套从数据采集到智能问答的完整实践项目。项目以Scrapy爬虫框架抓取武器装备网页数据,结合MongoDB存储结构化知识图谱,并实现基于自然语言的问答系统,覆盖爬虫编写、数据清洗、模型设计与查询优化等环节。压缩包共17个文件,约3.75MB,包含3个Python脚本用于数据采集与入库,5个XML与1个IML为IDE工程配置,4张PNG展示系统架构与数据样例,另有JSON数据样本、PPTX架构说明及README文档,便于快速理解项目结构。目前已有514人学习下载。读者可借此掌握Scrapy与MongoDB的整合方式、知识图谱实体关系设计思路,以及问答系统的问题理解与答案检索流程,适合作为课程设计或技术练手的参考案例。
1. 从零搭建军事装备问答知识图谱:QAonMilitaryKG 到底解决了什么问题
做军事装备领域的问答系统,最头疼的从来不是模型选型,而是数据从哪来、怎么组织。你手头可能有一堆装备手册 PDF、维基页面、新闻稿,但用户问一句“某型主战坦克的火控系统由哪些子系统构成”,系统要么答不上来,要么答得七零八落。QAonMilitaryKG 这个项目给出的思路是:用 Scrapy 爬虫把散落在公开网页上的装备信息抓下来,经过清洗和实体关系抽取,灌进 Neo4j 图数据库,最后在上面架一层自然语言问答接口。整条链路覆盖了爬虫、知识图谱构建、问答系统三个环节,适合想入门知识图谱但不知道从哪下手的人,也适合已经做过通用爬虫、想往垂直领域知识工程方向走的工程师。它不追求大而全,而是把“装备—武器—参数—关联”这条线跑通,让你能照着复现一套最小可用的军事领域问答原型。
2. 爬虫层怎么设计:从 Scrapy 项目结构到装备数据字段映射
2.1 为什么选 Scrapy 而不是 requests + BeautifulSoup
很多人做爬虫的第一反应是 requests 加 BeautifulSoup,写起来快,几十行就能跑。但 QAonMilitaryKG 这类项目要抓的不是一两个页面,而是成百上千个装备条目,每个条目可能分布在列表页、详情页、分类页三层结构里。这时候 Scrapy 的优势就出来了:内置的调度器帮你管理请求队列,中间件机制让你统一处理请求头和重试,Item Pipeline 把清洗逻辑从解析逻辑里剥离开。更关键的是,Scrapy 的CrawlSpider和Rule能自动跟进链接,你只需要定义“从列表页提取详情页链接”这一条规则,剩下的翻页和深度控制交给框架。
我一般会这样组织项目目录:
scrapy startproject military_kg cd military_kg scrapy genspider weapon_spider example.com生成的骨架里,items.py定义数据结构,spiders/放爬虫逻辑,pipelines.py做清洗和入库。对于装备数据,Item 字段至少要有:装备名称、所属军种、类型(坦克/战机/舰艇)、主要参数(速度、射程、重量)、关联装备、数据来源 URL。字段映射这一步别偷懒,后面图谱的节点属性直接依赖它。
2.2 用 XPath 提取装备参数:text() 函数的三个实用写法
军事装备页面的参数通常以表格或定义列表形式出现,XPath 的text()函数是提取这类文本的主力。但直接写//td/text()往往拿到一堆空白和换行,需要配合normalize-space()和following-sibling轴。
第一个写法,提取表格中“键—值”对:
# 假设页面结构为 <tr><td>最大速度</td><td>60 km/h</td></tr> speed = response.xpath( "//td[contains(text(),'最大速度')]/following-sibling::td/text()" ).get() # 逻辑:先定位包含关键词的单元格,再取它右边兄弟节点的文本 # 参数说明:contains 做模糊匹配,避免“最大速度(公路)”这类变体漏掉第二个写法,处理嵌套标签里的文本:
# 结构:<div class="param"><span>射程</span><em>500 km</em></div> range_val = response.xpath( "//div[@class='param']/span[text()='射程']/following-sibling::em/text()" ).get() # 逻辑:用 text()='射程' 精确匹配,再取 em 标签内容 # 注意:如果 em 里还有子标签,text() 只返回直接文本节点,需要加 //text()第三个写法,提取列表项并拼接:
# 结构:<ul><li>乘员:3人</li><li>重量:55吨</li></ul> items = response.xpath("//ul/li/text()").getall() # 返回 ['乘员:3人', '重量:55吨'] # 后续用 split(':') 拆成键值对,注意中英文冒号差异这三个写法覆盖了大部分装备参数页面的提取需求。实际跑的时候,先用scrapy shell交互式验证 XPath,确认能拿到数据再写进 spider,能省掉大量反复调试的时间。
2.3 动态页面与 iframe 的处理:Playwright 中间件的接入方式
有些装备展示页面是前端渲染的,或者参数表格嵌在 iframe 里,Scrapy 默认的下载器拿不到内容。常见做法是接入scrapy-playwright中间件。安装后在settings.py里启用:
# settings.py DOWNLOAD_HANDLERS = { "http": "scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler", "https": "scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler", } TWISTED_REACTOR = "twisted.internet.asyncioreactor.AsyncioSelectorReactor"然后在 Request 里加meta参数:
yield scrapy.Request( url=detail_url, meta={ "playwright": True, "playwright_page_methods": [ PageMethod("wait_for_selector", "table.param-table"), ], }, callback=self.parse_detail, )这里wait_for_selector是关键,它让浏览器等到参数表格渲染出来再返回 HTML。如果不加,拿到的是空壳。iframe 的情况更麻烦一些,Playwright 需要用frame_locator切换上下文,但很多军事装备页面其实把数据放在主文档里,只是用 CSS 隐藏了,这种情况直接解析 HTML 也能拿到,不必上浏览器。先判断页面类型再决定是否引入 Playwright,否则爬取速度会掉一个数量级。
3. 知识图谱构建:从爬取数据到 Neo4j 节点关系落库
3.1 实体关系抽取的轻量方案:规则 + 词典匹配
爬下来的数据是半结构化的文本,要变成图谱,先得抽实体和关系。QAonMilitaryKG 这类项目通常不上一整套 NLP 模型,而是用规则加词典的方式,原因很简单:军事装备领域的实体类型相对固定(装备名、参数名、数值、军种),关系也有限(“属于”“装备于”“衍生自”),规则匹配的准确率反而比通用模型高。
具体做法是维护三份词典:装备名词典、参数名词典、军种词典。对每条爬取记录,先用装备词典匹配出主语实体,再用参数词典匹配出属性名,数值用正则提取。关系抽取则依赖句式模板,比如“X 装备于 Y 部队”匹配“装备于”关系,“X 是 Y 的改进型”匹配“衍生自”关系。
import re WEAPON_DICT = {"99A主战坦克", "歼-20", "山东舰"} # 实际从文件加载 PARAM_PATTERN = re.compile(r"(最大速度|射程|战斗全重|乘员)[::]\s*([\d.]+\s*\w+)") def extract_entities(text): entities = [] for weapon in WEAPON_DICT: if weapon in text: entities.append(("装备", weapon)) for match in PARAM_PATTERN.finditer(text): entities.append(("参数", match.group(1), match.group(2))) return entities # 逻辑:先做装备名精确匹配,再用正则抓参数键值对 # 参数说明:PARAM_PATTERN 里的 \w+ 匹配单位,如 km/h、吨、人 # 注意:词典要持续补充,新装备出现时规则会漏这套方案的上限取决于词典覆盖度,但胜在可解释、易调试。跑通之后再考虑引入 BERT 做命名实体识别也不迟。
3.2 Neo4j 建图:节点、关系与索引的 Cypher 写法
数据抽出来后,用 py2neo 或官方 neo4j 驱动写入。建图之前先规划好 Schema:装备节点用Weapon标签,参数作为属性直接挂在节点上,军种和类型作为独立节点用关系连接。这样查询“某军种下所有坦克”时不用扫全表。
from neo4j import GraphDatabase driver = GraphDatabase.driver("bolt://localhost:7687", auth=("neo4j", "password")) def create_weapon(tx, name, category, params): tx.run( "MERGE (w:Weapon {name: $name}) " "SET w.category = $category, w += $params " "MERGE (c:Category {name: $category}) " "MERGE (w)-[:BELONGS_TO]->(c)", name=name, category=category, params=params ) # 逻辑:MERGE 保证节点不重复,SET w += $params 批量更新属性 # 参数说明:params 是字典,如 {"speed": "60 km/h", "weight": "55t"} # 注意:属性名不要用中文,否则 Cypher 查询要加反引号,容易出错 with driver.session() as session: session.execute_write(create_weapon, "99A主战坦克", "坦克", {"speed": "60 km/h"})建完节点后加索引,否则数据量上万后查询会明显变慢:
CREATE INDEX weapon_name IF NOT EXISTS FOR (w:Weapon) ON (w.name); CREATE INDEX category_name IF NOT EXISTS FOR (c:Category) ON (c.name);索引建在name属性上,因为问答系统最常用的查询就是按名称找实体。如果还涉及按参数范围筛选,可以考虑对数值型属性建范围索引,但军事装备参数单位不统一,这一步要谨慎。
3.3 数据清洗的四个边界:空值、单位、别名、重复
爬下来的数据直接入库,图谱质量会很差。我踩过的坑集中在四个地方。空值处理:参数缺失时不要写空字符串,直接不设该属性,否则查询时w.speed IS NOT NULL会失效。单位统一:有的页面写“60km/h”,有的写“60 千米/小时”,入库前统一转成标准单位,否则没法做数值比较。别名归并:“99A”“ZTZ-99A”“99A式”指向同一装备,需要维护别名映射表,入库时统一到主名称。重复记录:同一装备从多个来源爬取,属性值可能冲突,常见做法是保留来源可信度最高的那条,或者取多数值。
def clean_params(raw): cleaned = {} for k, v in raw.items(): if not v or v.strip() in ("", "未知", "N/A"): continue v = v.replace("千米/小时", "km/h").replace("公里/小时", "km/h") cleaned[k] = v.strip() return cleaned # 逻辑:先过滤空值,再统一单位,最后去空白 # 参数说明:单位映射表可以扩展,覆盖更多变体 # 注意:不要用正则全局替换,容易误伤,比如“公里”出现在装备名里清洗这一步花的时间往往比爬取还多,但值得。图谱质量差,后面问答系统再花哨也救不回来。
4. 问答系统对接:从自然语言到 Cypher 查询的映射
4.1 意图识别与槽位填充的最小实现
问答系统的入口是一句自然语言,比如“99A主战坦克的最大速度是多少”。要把它转成 Cypher,先得识别意图(查属性)和槽位(装备名=99A主战坦克,属性=最大速度)。轻量做法是用关键词匹配加正则,不需要上模型。
INTENT_PATTERNS = { "query_attribute": re.compile(r"(.+?)的(.+?)(是多少|是什么|为多少)"), "query_relation": re.compile(r"(.+?)和(.+?)有什么关系"), } def parse_question(q): for intent, pattern in INTENT_PATTERNS.items(): match = pattern.search(q) if match: return intent, match.groups() return "unknown", None # 逻辑:按意图优先级依次匹配,返回意图和槽位 # 参数说明:正则里的 (.+?) 是非贪婪匹配,避免跨实体吞掉内容 # 注意:装备名里可能含“的”,比如“某型的改进型”,需要词典辅助切分匹配到意图和槽位后,查词典把装备别名归一化,再拼 Cypher。这一步的关键是词典要和图谱里的节点名称对齐,否则查不到。
4.2 模板化 Cypher 生成与查询结果组装
意图和槽位确定后,用模板生成 Cypher:
CYPHER_TEMPLATES = { "query_attribute": ( "MATCH (w:Weapon {name: $name}) " "RETURN w[$attr] AS value" ), } def build_cypher(intent, slots): name, attr = slots[0], slots[1] attr_map = {"最大速度": "speed", "射程": "range", "战斗全重": "weight"} attr_key = attr_map.get(attr) if not attr_key: return None, "未收录该参数" return CYPHER_TEMPLATES[intent], {"name": name, "attr": attr_key} # 逻辑:把中文属性名映射到图谱属性名,再套模板 # 参数说明:attr_map 需要和入库时的属性名严格一致 # 注意:Cypher 里 w[$attr] 动态取属性,Neo4j 4.x 以上支持查询结果组装成自然语言回复时,把属性名和值拼回去就行。如果查不到,返回“图谱中暂无该装备的此项参数”,不要返回空字符串,用户体验差很多。
4.3 多跳查询:装备关联关系的展开方式
单跳查询只能回答属性问题,用户还会问“99A主战坦克属于哪个军种”“歼-20衍生自哪个型号”。这类问题需要多跳查询。Cypher 的优势在这里体现得很明显:
MATCH (w:Weapon {name: $name})-[:BELONGS_TO]->(c:Category) RETURN c.name AS category如果关系层级更深,比如“某装备的所属军种下还有哪些同类装备”,可以写成:
MATCH (w:Weapon {name: $name})-[:BELONGS_TO]->(c:Category)<-[:BELONGS_TO]-(other:Weapon) WHERE other.name <> w.name RETURN other.name AS related多跳查询的坑在于关系方向容易写反,建议先在 Neo4j Browser 里手动跑通再写进代码。另外,跳数超过三跳后查询性能会下降,必要时加LIMIT限制返回数量。
5. 避坑与排查:爬虫被封、图谱脏数据、问答答非所问
5.1 爬虫请求被拒或返回空页面
现象:爬取一段时间后,返回 403 或页面内容为空。原因:目标站点识别到高频请求,触发反爬。解决:在settings.py里调低DOWNLOAD_DELAY,启用AUTOTHROTTLE,并轮换 User-Agent。如果站点用 JavaScript 检测,接入 Playwright 时设置合理的wait_for_timeout,不要用固定 sleep。
5.2 XPath 提取到大量空白和换行
现象:get()返回的文本带一堆\n和空格。原因:HTML 源码里标签之间有换行和缩进。解决:用normalize-space()包裹 XPath,或者在 Pipeline 里统一strip()。注意normalize-space()会合并中间空格,如果参数值本身含空格(如“60 km/h”),要确认合并后不影响解析。
5.3 Neo4j 导入时节点重复创建
现象:同一个装备在库里出现多个节点。原因:CREATE不检查存在性,或者名称有细微差异(空格、全半角)。解决:统一用MERGE代替CREATE,入库前对名称做strip()和全半角转换。如果已经产生重复节点,用 Cypher 的apoc.refactor.mergeNodes合并。
5.4 问答系统返回“未找到”但图谱里明明有
现象:用户问“99A的最大速度”,系统说没有。原因:槽位里的装备名是“99A”,图谱里存的是“99A主战坦克”,词典没覆盖这个别名。解决:维护别名映射表,查询前先归一化。另一个常见原因是属性名映射错了,比如用户说“速度”,代码里映射到speed,但入库时写的是max_speed,两边要对齐。
5.5 Playwright 模式下爬取速度骤降
现象:启用 Playwright 后,每分钟只能爬几个页面。原因:每个请求都启动浏览器上下文,开销大。解决:只在确认为动态页面的 URL 上启用 Playwright,静态页面走默认下载器。在meta里按条件设置playwright: True,不要全局开启。
6. 进阶技巧:用扩展点做爬虫监控与图谱增量更新
Scrapy 的 extensions 机制很多人没注意,但它特别适合做爬虫运行时的监控。你可以写一个扩展,在爬虫关闭时统计抓取数量、错误率,并推送到日志或监控接口。实现方式是在extensions.py里定义一个类,挂到signals.spider_closed信号上:
from scrapy import signals class StatsMonitor: def __init__(self, stats): self.stats = stats @classmethod def from_crawler(cls, crawler): ext = cls(crawler.stats) crawler.signals.connect(ext.spider_closed, signal=signals.spider_closed) return ext def spider_closed(self, spider): item_count = self.stats.get_value("item_scraped_count", 0) error_count = self.stats.get_value("spider_exceptions", 0) spider.logger.info(f"抓取完成:{item_count} 条,异常 {error_count} 次") # 逻辑:从 stats 收集器读取计数,在爬虫结束时输出 # 参数说明:item_scraped_count 是 Scrapy 内置统计键 # 注意:在 settings.py 里启用 EXTENSIONS = {'military_kg.extensions.StatsMonitor': 500}图谱增量更新是另一个实用方向。全量重建图谱耗时且容易丢数据,更好的做法是每次爬取后只更新变化的节点。具体思路是给每个节点加last_updated属性,爬取时对比新旧数据,只有属性值变化才执行SET。Cypher 里可以用ON MATCH SET配合条件判断:
MERGE (w:Weapon {name: $name}) ON CREATE SET w.created = timestamp(), w += $params ON MATCH SET w.updated = timestamp(), w += $params这样新装备插入,老装备更新,不会重复建节点。配合爬虫的增量抓取(只抓更新时间晚于上次的页面),整条链路就能持续运转。
验证图谱质量有个简单办法:随机抽 20 个装备节点,人工核对属性完整率和准确率。如果完整率低于 80%,回去检查爬虫的 XPath 和清洗规则;如果准确率有问题,多半是别名归并或单位换算出了错。我自己的习惯是每次调整爬虫规则后,先跑一个小规模测试(限制 50 个页面),确认数据质量再全量跑。这个习惯帮我省过很多次后悔药——有次没测试直接全量,结果单位换算写反了,整库数据全得重来。希望帮到你。
本文还有配套的精品资源,点击获取