ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

基于Scrapy与Neo4j的军事装备问答知识图谱构建实战

基于Scrapy与Neo4j的军事装备问答知识图谱构建实战 简介这份资源面向军事装备数据爱好者、知识图谱与爬虫初学者提供一套从数据采集到智能问答的完整实践项目。项目以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轴。第一个写法提取表格中“键—值”对# 假设页面结构为 trtd最大速度/tdtd60 km/h/td/tr speed response.xpath( //td[contains(text(),最大速度)]/following-sibling::td/text() ).get() # 逻辑先定位包含关键词的单元格再取它右边兄弟节点的文本 # 参数说明contains 做模糊匹配避免“最大速度公路”这类变体漏掉第二个写法处理嵌套标签里的文本# 结构div classparamspan射程/spanem500 km/em/div range_val response.xpath( //div[classparam]/span[text()射程]/following-sibling::em/text() ).get() # 逻辑用 text()射程 精确匹配再取 em 标签内容 # 注意如果 em 里还有子标签text() 只返回直接文本节点需要加 //text()第三个写法提取列表项并拼接# 结构ulli乘员3人/lili重量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( urldetail_url, meta{ playwright: True, playwright_page_methods: [ PageMethod(wait_for_selector, table.param-table), ], }, callbackself.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), namename, categorycategory, paramsparams ) # 逻辑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 生成与查询结果组装意图和槽位确定后用模板生成 CypherCYPHER_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, signalsignals.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 个页面确认数据质量再全量跑。这个习惯帮我省过很多次后悔药——有次没测试直接全量结果单位换算写反了整库数据全得重来。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

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