拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

爬虫+知识图谱+模板问答:军事武器KG问答系统实战

爬虫+知识图谱+模板问答:军事武器KG问答系统实战

简介:这是一套面向军事装备数据采集与智能问答的完整实战项目,适合正在学习Scrapy爬虫、知识图谱构建及自然语言查询的开发者参考。项目围绕“武器装备知识图谱”展开,覆盖爬虫抓取、数据清洗、MongoDB存储、图谱建模与问答推理等关键环节,可帮助读者掌握从非结构化网页到结构化知识服务的全流程实现方法。压缩包共17个文件,内含Python脚本(爬虫采集与数据入库)、XML工程配置、JSON数据样例、PNG结果截图、PPTX系统架构图及Markdown说明文档,整体大小3.75MB,目录结构清晰便于按模块查阅。目前已有513人学习浏览,适用于希望从零搭建知识图谱问答系统、或研究军事领域数据处理的进阶学习者。通过该资源可获取完整项目代码、系统架构图表、数据样本及核心实现思路,为复现武器类知识图谱项目、扩展问答系统功能提供扎实基础。

1. 军事武器知识图谱问答:爬虫只是把料搬回来,问答才是见真章

把 QAonMilitaryKG 这个项目标题拆开看,核心其实就三件事:用 Scrapy 爬军事武器相关数据,建成知识图谱,再在上面做问答系统。很多人第一眼会盯着"问答"两个字,觉得难点在模型和算法。但真把一个垂直领域的知识图谱问答跑通之后,你会发现最耗时、最决定成败的反而是前半段——爬虫采集和数据建图。料没搬对,后面再怎么调模型都白搭。

这类项目适合谁?适合手里有明确垂直领域数据(比如武器、医学、法律、工业设备),想把它变成可检索、可问答的知识服务的人。它典型到什么程度?一套"爬虫 + Neo4j + 模板问答"的链路,能覆盖日常 80% 的常见问题。别一上来就研究大模型,先把这条链路跑通,你会看到一个很反直觉的事实:用模板和规则做出来的问答,在垂直领域里的准确率比你想象的高得多,而且每条回答都能追溯到图谱里的实体,出了问题好排查。

接下来,我把这个项目从数据采集到问答落地的完整路径拆开讲,连参数、坑和排查方法一起给到你。

2. 用 Scrapy 搭建武器数据采集管道:字段设计、入库与断点续爬

2.1 先定数据边界:武器实体要抓哪些页面和字段

我不建议拿到一个网站就开始写 Spider。第一步永远是设计数据模型。为什么?因为知识图谱的实体和属性,直接由你要采集的字段决定。模型没定清楚,后面每改一次字段,爬虫、入库、建图全要跟着动,血泪经验告诉你,返工成本极高。

常见做法是先画一张武器实体的字段表。以公开军事百科这类数据源为例,我一般会定这样一套最小字段集:

字段说明示例是否必填
weapon_name武器名称歼-20是
weapon_type武器类型战斗机是
origin_country研发国中国是
developer研发方成飞否
service_year服役年份2017否
caliber / range口径/射程155mm / 280km否
weight重量19吨否
description简介一段文本否
image_url图片链接https://...否

抓取范围也提前想好:一个列表页(展示武器条目入口 + 翻页)+ 若干个详情页。列表页用于发现武器 URL,详情页用于提取字段。不要贪多,先把主流程跑通,再考虑是不是要抓多语言站点、要不要抓图片。数据源的页面结构也不要假设统一,很多站点列表页和详情页不是同一套模板,后面避坑章里会专门讲这个。

2.2 最小可用的 Scrapy Spider 与 Item Pipeline

数据模型定完之后,写 Spider。这里给一个可以直接改来用的最小实现,抓取对象是公开军事百科的武器列表页和详情页,你只需要替换选择器和分页规则。

# spiders/weapon_spider.py import scrapy from urllib.parse import urljoin from qaon_kg.items import WeaponItem class WeaponSpider(scrapy.Spider): name = "weapon" allowed_domains = ["example-military-wiki.org"] # 替换为实际域名 start_urls = ["https://example-military-wiki.org/weapons/"] def parse(self, response): # 1. 提取当前列表页里所有详情页链接 # 公开百科的武器列表通常在 li/h3/a 标签里 detail_links = response.css("div.weapon-list a::attr(href)").getall() for link in detail_links: yield response.follow(link, callback=self.parse_detail) # 2. 翻页:找到下一页的 URL,继续交给 parse next_page = response.css("a.next::attr(href)").get() if next_page: yield response.follow(next_page, callback=self.parse) def parse_detail(self, response): item = WeaponItem() # 详情页字段提取:优先用 info-box 这类结构化区域 item["weapon_name"] = response.css("h1#firstHeading::text").get() item["weapon_type"] = response.css("td.type::text").get() item["origin_country"] = response.css("td.country::text").get() item["developer"] = response.css("td.developer a::text").get() item["service_year"] = response.css("td.service-year::text").get() item["caliber"] = response.css("td.caliber::text").get() item["range"] = response.css("td.range::text").get() item["weight"] = response.css("td.weight::text").get() # 详情页里没有的字段,让 Item 的默认值兜底 item["description"] = response.css("div.description p::text").get() item["image_url"] = response.css("img.main-image::attr(src)").get() yield item

这段代码逻辑分两层:parse负责列表页,既提取详情页链接、又负责翻页,两个任务都交给同一个回调处理,简洁而且不容易漏;parse_detail只做一件事,把详情页的 HTML 结构映射到 Item 字段上。

几个值得注意的参数。allowed_domains一定要设,防止爬到外链域名上;response.follow会自动处理相对路径和 URL 拼接,不需要手写urljoin;字段用css::text拿文本、css::attr(href/src)拿链接,这是 Scrapy 里最基础也最稳定的提取方式。如果目标站点是动态渲染的,这套方案会抓不到数据,常见做法是换成scrapy-playwright来渲染 JS,这个后面会在避坑章里细说。

2.3 用 SQLAlchemy 落库而不是直接拼 SQL

爬虫抓到数据之后,第一个问题是:存哪?很多新手会直接写一条INSERT INTO weapon VALUES (...),然后把这条 SQL 拼进 Pipeline。这个做法在字段一多、爬虫要反复跑的时候就很难受:一次没跑完中断了,下一轮启动就会插入重复数据。

我一般用 SQLAlchemy 做落库。原因有三:一是 ORM 让你能用对象方式操作数据,字段增加时不用改 SQL 字符串;二是它自带的insert().on_conflict_do_nothing()这类 upsert 语法能天然解决重复插入问题;三是连接池、事务管理帮你处理好了,不用自己手写重连逻辑。

# models.py from sqlalchemy import create_engine, Column, String, Integer, Text, UniqueConstraint from sqlalchemy.orm import declarative_base, sessionmaker Base = declarative_base() class Weapon(Base): __tablename__ = "weapon" id = Column(Integer, primary_key=True, autoincrement=True) weapon_name = Column(String(128), nullable=False) weapon_type = Column(String(64)) origin_country = Column(String(64)) developer = Column(String(128)) service_year = Column(String(16)) caliber = Column(String(32)) range = Column(String(32)) weight = Column(String(32)) description = Column(Text) image_url = Column(String(512)) source_url = Column(String(512)) # 用名称+来源URL做唯一约束,避免重复入库 __table_args__ = ( UniqueConstraint("weapon_name", "source_url", name="uq_weapon_source"), ) engine = create_engine("mysql+pymysql://user:pass@localhost:3306/qaon_kg?charset=utf8mb4") Base.metadata.create_all(engine) Session = sessionmaker(bind=engine)
# pipelines.py from sqlalchemy.dialects.mysql import insert from models import Weapon, Session class WeaponPipeline: def process_item(self, item, spider): # on_conflict_do_nothing: 同武器名+同来源URL时跳过插入 stmt = insert(Weapon).values(**item) stmt = stmt.on_conflict_do_nothing(index_elements=["weapon_name", "source_url"]) with Session() as sess: sess.execute(stmt) sess.commit() return item

Pipeline 里用insert().on_conflict_do_nothing(),这句是 MySQL 方言语法,换成 PostgreSQL 就写成on_conflict_do_nothing(index_elements=[...]),SQLite 则用insert().on_conflict_do_nothing()配合表上的唯一约束。核心逻辑很简单:同一来源的同名武器,重复跑了也不产生脏数据,这就是断点续爬的底气。

还有三个设置在settings.py里是必须调校的:

# settings.py ITEM_PIPELINES = { "qaon_kg.pipelines.WeaponPipeline": 300, } DOWNLOAD_DELAY = 0.8 # 每请求间隔0.8秒,别把对方站点压垮 CONCURRENT_REQUESTS = 8 # 并发调低,军事类站点反爬敏感 RETRY_TIMES = 2 # 失败重试2次,再多就是网络或封IP问题 USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"

DOWNLOAD_DELAY和CONCURRENT_REQUESTS配合着用:延迟太小并发太高,容易被站点封 IP;延迟太大并发又上不去,跑几万条数据要等好几个小时。我的经验值是延迟 0.5~1.5 秒、并发 4~8,具体看对方站点的响应速度和反爬强度。总之一句话:先把字段和入库链路跑通,再优化爬取速度。

3. 从抓回来的半结构化数据到 Neo4j 知识图谱:本体设计与实体消歧

3.1 武器领域本体建模:实体、属性、关系怎么定

数据进了 MySQL 之后,下一步是把它们变成图。不是所有数据都需要图,但问答类的查询天然适合图结构,比如"歼-20 是哪国研发的"、"中国有哪些第四代战斗机",这类问题在关系数据库里要么写多表 JOIN,要么连表都建不出来(武器和研发国的多对多关系非常常见)。常见做法是直接用 Neo4j 做存储和查询。

本体设计我有四条经验。第一,节点类型控制在 4~6 种,不要一上来就建十几个标签,否则边角关系会爆炸。第二,属性只放问答里确实要用的字段。第三,关系命名用动词分词,DEVELOPED_BY、PRODUCED_IN这种一眼能看懂含义的。第四,关系不要设计成"可绕过"的冗余,比如既建了PRODUCED_IN又建了COUNTRY_IS,问答模板会因此出现两套 SQL 路径,排查时十分痛苦。

我常用的武器领域本体是这么设计的:

  • Weapon(武器):属性 weapon_name、weapon_type、caliber、range、weight
  • Country(国家):属性 country_name
  • Developer(研发方):属性 developer_name
  • Category(武器类别):属性 category_name,如"战斗机""导弹""坦克"
  • Era(年代):属性 era_name,如"冷战""现代"

关系就四条:

关系含义例子
(Weapon)-[:DEVELOPED_BY]->(Developer)武器由谁研发歼-20 -> 成飞
(Weapon)-[:PRODUCED_IN]->(Country)武器生产国歼-20 -> 中国
(Weapon)-[:BELONGS_TO]->(Category)武器类别歼-20 -> 战斗机
(Weapon)-[:SERVED_IN]->(Era)服役年代歼-20 -> 现代

这个本体设计紧凑,武器实体的核心属性都在节点上,问答里"X 的 Y 是什么"这类查询最多跳两步。你不用急着做成可以无限扩展的宏大本体,那是工业级图谱才需要考虑的,跑通问答优先。

3.2 实体抽取与关系识别:正则、词典与少量标注兜底

有人看到"实体抽取"四个字就想上 NER 模型。但在垂直领域且数据已经结构化的前提下,最省力、最可控的做法是:词典 + 规则 + 少量人工校正。用 NER 模型去抽取本来就结构化的字段,等于把已经到手的答案重新猜一遍,容易引入错误。

我一般的处理步骤是这样的:先从 MySQL 把 Weapon 表读出来,然后用词典把origin_country和developer映射到 Country、Developer 实体,用规则正则从weapon_type里抽出 Category 实体。最后把实体关系写入两个 CSV 文件,供后续导入 Neo4j 用。

# prepare_graph.py import re import pandas as pd from sqlalchemy import create_engine from models import Weapon engine = create_engine("mysql+pymysql://user:pass@localhost:3306/qaon_kg?charset=utf8mb4") df = pd.read_sql(Weapon.__table__.select(), engine) # 1. 用词典把“国别”字段映射成 Country 节点 country_synonyms = { "中国": "中国", "中华人民共和国": "中国", "美国": "美国", "美利坚合众国": "美国", "俄罗斯": "俄罗斯", "俄国": "俄罗斯", } df["country_entity"] = df["origin_country"].map(country_synonyms).fillna(df["origin_country"]) # 2. 用正则提炼武器类型,比如“战斗机”“导弹”“坦克” type_patterns = { "战斗机": r"战斗机|歼击机|战隼", "导弹": r"导弹|飞弹", "坦克": r"坦克|主战坦克", } def extract_category(text): if not isinstance(text, str): return "未知" for cat, pattern in type_patterns.items(): if re.search(pattern, text): return cat return "未知" df["category_entity"] = df["weapon_type"].apply(extract_category) # 3. 生成 Neo4j 导入用的节点和关系 CSV df[["weapon_name", "caliber", "range", "weight"]].to_csv("weapons.csv", index=False) df[["country_entity"]].drop_duplicates().to_csv("countries.csv", index=False) df[["developer"]].drop_duplicates().to_csv("developers.csv", index=False) relations = df[["weapon_name", "developer", "country_entity", "category_entity"]] relations.to_csv("relations.csv", index=False)

这段代码的核心思路是"能放在 SQL 里的用 SQL,能放在词典里的用词典"。map(country_synonyms)做同义词归一,解决"俄国"和"俄罗斯"并存的问题;正则提取类别比 NER 快得多,而且可以随时改规则。真正需要人工处理的,只有那些词典和正则都拿不准的少数异常值,保留一个unknown标签兜底即可。

做个取舍判断:如果武器数据源里名称乱、国别字段五花八门,我再加一步用difflib做相似度匹配人工确认;数据源规范化程度高的话,这套规则流程一天内就能跑完。不要为了炫技而上模型。

3.3 Cypher 批量导入与索引设计

CSV 生成之后导入 Neo4j。导入方式有两个选择:数据量小(几千节点)用LOAD CSV就够了,数据量大(几十万节点以上)用neo4j-admin import离线导入。演示项目可以直接用LOAD CSV,在 Neo4j Browser 或 cypher-shell 里执行。

导入顺序必须注意:先建节点,再建关系。另外,导入前先把约束和索引建好,否则每次查武器名称都是全库扫描,数据量上去之后问答响应会从毫秒级掉到秒级,这个在前面规划时不注意,后面必然踩坑。

// 1. 先建唯一约束,防止重复节点 CREATE CONSTRAINT weapon_name_unique IF NOT EXISTS ON (w:Weapon) ASSERT w.weapon_name IS UNIQUE; CREATE CONSTRAINT country_name_unique IF NOT EXISTS ON (c:Country) ASSERT c.country_name IS UNIQUE; CREATE CONSTRAINT developer_name_unique IF NOT EXISTS ON (d:Developer) ASSERT d.developer_name IS UNIQUE; CREATE CONSTRAINT category_name_unique IF NOT EXISTS ON (c:Category) ASSERT c.category_name IS UNIQUE; // 2. 导入武器节点 LOAD CSV WITH HEADERS FROM "file:///weapons.csv" AS row MERGE (w:Weapon {weapon_name: row.weapon_name}) ON CREATE SET w.caliber = row.caliber, w.range = row.range, w.weight = row.weight; // 3. 导入国家和研发方节点(先 MERGE,后面关系直接引用) LOAD CSV WITH HEADERS FROM "file:///countries.csv" AS row MERGE (c:Country {country_name: row.country_entity}); LOAD CSV WITH HEADERS FROM "file:///developers.csv" AS row MERGE (d:Developer {developer_name: row.developer}); // 4. 导入关系 LOAD CSV WITH HEADERS FROM "file:///relations.csv" AS row MATCH (w:Weapon {weapon_name: row.weapon_name}) MATCH (d:Developer {developer_name: row.developer}) MERGE (w)-[:DEVELOPED_BY]->(d); LOAD CSV WITH HEADERS FROM "file:///relations.csv" AS row MATCH (w:Weapon {weapon_name: row.weapon_name}) MATCH (c:Country {country_name: row.country_entity}) MERGE (w)-[:PRODUCED_IN]->(c);

其中的逻辑说明:MERGE保证节点不存在时创建、存在时直接匹配,天然幂等,比CREATE安全得多;MATCH必须先存在节点,所以前面一定要按"节点→关系"的顺序执行;关系导入不能用CREATE因为文件里的重复行会建出重复关系,用MERGE就没事。

建立约束之后,Neo4j 会自动为对应属性创建索引。如果你还想支持武器名称的模糊搜索,再补一句:

CREATE FULLTEXT INDEX weapon_name_fulltext IF NOT EXISTS FOR (w:Weapon) ON EACH [w.weapon_name];

这条索引在后面的问答系统里会很有用:用户问"歼20"和库里存的"歼-20"没精确匹配上时,全文检索可以召回候选,再交给主问答逻辑去做实体链接。

4. 把图谱变成问答:意图识别、Cypher 生成与答案兜底

4.1 问句分类与槽位提取:先做规则,别急着上模型

知识图谱问答最核心的一步是把自然语言问句映射成图谱查询。这里有一个常见误区:一上来就接大模型或者训一个语义解析模型。在武器这样的小领域里,用户问法其实非常有限,一百条真实问答样本就能覆盖九成问题。用规则做意图分类、用词典+正则做槽位提取,准确性完全够用,而且每条错误你都知道错在哪,好修。

我一般把问句分成四类意图,每类对应一套 Cypher 模板:

意图问法示例对应查询
查属性歼-20的射程是多少MATCH (w:Weapon) RETURN w.range
查关系歼-20是谁研发的MATCH (w:Weapon)-[:DEVELOPED_BY]->(d) RETURN d
查列表中国有哪些导弹MATCH (w:Weapon)-[:PRODUCED_IN]->(c:Country) WHERE ...
对比歼-20和苏-57谁重分别查两武器重量后比较

槽位提取需要两个部分:武器名槽位、属性槽位。武器名用别名词典加最长匹配,属性槽位用属性关键词表,比如"射程""口径""重量""研发方""国家"。写法很朴素但效果稳定:

# nlu.py import re intent_rules = { "query_attribute": ["射程", "口径", "重量", "长度"], "query_developer": ["研发", "研制", "制造"], "query_country": ["哪国", "哪个国家", "生产国"], "query_list": ["有哪些", "列举", "多少种"], } attribute_map = { "射程": "range", "口径": "caliber", "重量": "weight", } def detect_intent(question: str) -> str: for intent, keywords in intent_rules.items(): for kw in keywords: if kw in question: return intent return "query_attribute" # 兜底意图 def extract_slots(question: str, weapon_names: list[str]) -> dict: # 用武器名词典做最长匹配,避免“歼-20”被拆成“歼”和“20” matched = [] for name in sorted(weapon_names, key=len, reverse=True): if name in question: matched.append(name) question = question.replace(name, " ") attribute = None for attr, alias in attribute_map.items(): if attr in question: attribute = alias return {"weapons": matched, "attribute": attribute}

这段代码有两个关键点:sorted(weapon_names, key=len, reverse=True)是为了让"歼-20"这种带分隔符的名称先匹配,而不是先匹配到"歼"产生歧义;question.replace(name, " ")是暂时把已命中的实体从问句里去掉,防止它们干扰后面的属性槽位提取。

4.2 动态 Cypher 模板与参数绑定:不许拼字符串

意图和槽位都提取完之后,下一步是生成 Cypher。这里最需要注意的安全和实践问题是:不能用字符串拼接的方式把用户输入直接塞进查询。原因有两个:第一是 Cypher 注入风险,二是用户输入一旦带引号或特殊符号,查询直接报错。正确做法是用参数化查询。

class QueryExecutor: def __init__(self, driver): self.driver = driver def execute(self, intent: str, slots: dict): if intent == "query_attribute": # 模板里的 {attribute} 由内部映射决定,不走用户输入 cypher = ( "MATCH (w:Weapon {weapon_name: $name}) " "RETURN w.{attribute} AS value".format(attribute=slots["attribute"]) ) params = {"name": slots["weapons"][0]} elif intent == "query_developer": cypher = ( "MATCH (w:Weapon {weapon_name: $name})-[:DEVELOPED_BY]->(d:Developer) " "RETURN d.developer_name AS value" ) params = {"name": slots["weapons"][0]} elif intent == "query_country": cypher = ( "MATCH (w:Weapon {weapon_name: $name})-[:PRODUCED_IN]->(c:Country) " "RETURN c.country_name AS value" ) params = {"name": slots["weapons"][0]} elif intent == "query_list": # 查列表:中国有哪些导弹? cypher = ( "MATCH (w:Weapon)-[:PRODUCED_IN]->(c:Country {country_name: $country}) " "MATCH (w)-[:BELONGS_TO]->(cat:Category {category_name: $category}) " "RETURN w.weapon_name ORDER BY w.weapon_name LIMIT 20" ) params = {"country": slots["country"], "category": slots["category"]} with self.driver.session() as session: result = session.run(cypher, **params) return [record["value"] for record in result]

逻辑说明一句话就够:所有用户输入都通过$name、$country这类参数传进去,属性名等不能参数化的部分,只用内部映射,不让用户输入直接出现在 Cypher 里。这样一个设计,既防注入,也避免用户输入'之类字符把查询打断。

还有一个值得说的细节:query_list里先MATCH国家再MATCH类别,会形成一个交集的语义。用户问"中国有哪些导弹",它翻译出来的含义就是"生产国为中国且类别为导弹的武器",这也是知识图谱相对关系型数据库的一个明显优势:多条件查询不需要 JOIN,多一个 MATCH 就行了。

4.3 答案组装与无结果兜底:不知道怎么答就说不知道

问答系统最后一步是组装答案。这里有两个经常被忽略的点:一是属性值的单位换算,二是查询无结果时必须友好兜底。

先说单位。图谱里的射程可能是"280km",用户问"最大射程是多少公里",答案直接返回字符串"280km"没问题;但用户如果问"最大射程是多少米",那就得在答案层做一次换算。我的经验是:图谱属性里只存标准数值和单位,答案组装时把单位转换逻辑集中在一个函数里,别散落在每个模板中。

再说无结果兜底。用户问"歼-20的重量"但图谱里重量字段是空的,或者用户问了一个图谱里不存在的武器名,这两种情况必须区别对待:

def compose_answer(records, intent, slots): if not records: # 兜底1:图谱里找不到该武器,用全文索引召回相近名字 similar = fuzzy_search(slots["weapons"][0]) if similar: return f"没有找到“{slots['weapons'][0]}”的数据,你是不是想问“{similar[0]}”?" return "抱歉,我还不知道这个问题的答案。" value = records[0] if value is None or str(value).strip() == "": # 兜底2:实体存在,但该属性没采集到 return f"“{slots['weapons'][0]}”的这条属性暂无数据。" return value

兜底逻辑的好处在于,问答系统不怕答不出来,最怕答错还硬撑。把"无数据"和"无此实体"分开,日志里就能看到图谱的覆盖缺口。下一步是去补爬数据还是补本体,一目了然。这也是为什么我说别一上来就上复杂模型:模板系统里每个错误都能追到具体环节,模型系统里很难。

5. 军事武器 KG 问答的五个高频坑:现象、原因与解法

5.1 爬虫抓到一半字段大面积为空

现象:跑完一整轮爬虫,入库后发现developer、service_year这类字段有六成是空的,问答系统一查一个空。

原因:很多军事百科站点的详情页并不是统一模板,早期条目和近期条目的表格结构不一致,比如老条目用<td class="developer">,新条目直接用<th>研发方</th><td>成飞</td>,只写一个选择器当然抓不全。

解决:Spider 里针对不同模板各写一套选择器,用get()逐个尝试,谁先命中用谁。下面这个写法是标准做法:

item["developer"] = ( response.css("td.developer a::text").get() or response.css("th:contains('研发方') + td::text").get() or response.css("tr:nth-child(3) td::text").get() )

另外,入库前在 Pipeline 里做一个必填字段校验,把缺关键字段的记录单独打日志存到error_items.json,方便回头补爬。

5.2 同名武器在库里对应了多个实体

现象:用户问"鹰击-18的射程",返回结果变成了好几个同名实体的属性列表,让问答系统不知道选哪个。

原因:武器命名有大量同名或同系列不同型号的情况。比如同一型号被多个国家引进装备,或者同一名称下有海基、陆基、空基变体,爬虫把它们作为不同条目抓进来,图谱里就出现了多个weapon_name相同的节点,唯一约束没起作用。

解决:约束不能只建在weapon_name上,用"武器名+国别+类型"组合建唯一键;同时图谱里保留实体同义词属性(aliases),问答时优先按用户问句里提到的国别或型号上下文去限定:

MATCH (w:Weapon) WHERE w.weapon_name = $name AND (w.origin_country = $country OR $country IS NULL) RETURN w

这样既保留同名武器的多样性,又能保证问答返回唯一结果。

5.3 Cypher 查询越跑越慢,最后直接超时

现象:图谱数据量到了两三万节点后,同一个问答请求的响应时间从几十毫秒涨到几秒,再往后直接超时报错。

原因:两个。第一,导入 CSV 时没有先建约束和索引,导致MATCH (w:Weapon {weapon_name: $name})每次都全库扫描;第二,RETURN没有加LIMIT,比如"中国有哪些武器"这类查询可能一次返回几百上千条记录,序列化和传输都耗时。

解决:先建约束再导数据,这个顺序别反了。具体做法是:在导入脚本里先执行一遍第 3.3 节里的约束语句,再执行LOAD CSV。查询层面,凡是列表类问题强制加LIMIT 20,同时限定ORDER BY字段,避免随机返回。

5.4 中文问句把"歼-20"识别成了"歼"和"20"

现象:用户问"歼-20的研发方",系统识别出的武器名是"歼",导致查不到数据。

原因:jieba 默认分词器不知道"歼-20"是一个整体。如果用分词结果做槽位提取,就会被拆开。我见过最夸张的翻车是把"东风-17"问句里的"东风"分词成了"东风牌汽车"的东风,匹配到了完全无关的实体。

解决:两手一起抓。第一,给 jieba 加载自定义词典,把常见武器名整体加入;第二,槽位提取不用分词结果,改用"武器名列表 + 最长匹配优先"的方式,先做词典匹配,再做分词。这个顺序很关键:先replace掉武器名,再对剩余文本做意图识别,能避开九成问题。

5.5 问答答错了但日志看不出来

现象:在线系统里用户反馈某个问题答错了,但你打开日志发现只有"question=xxx, answer=yyy",完全看不出是意图识别错了、槽位提取错了,还是 Cypher 写错了。

原因:没有把"问句 → 中间结果 → 最终答案"完整链路记录下来。问答系统是个黑匣子,中间任何一个环节出错都表现为"答非所问",没有链路日志就没法定位。

解决:给每条问答请求生成一个question_id,把意图、槽位、生成的 Cypher、参数、返回记录数、最终答案全部记录到一张 audit 表里。排查时只需要对着question_id拉一条数据,一眼就知道问题出在哪一环。这个习惯无论项目大小都值得保持,论坛里常说的"问答系统调试靠文化卖萌"的段子,本质上就是缺链路日志。

6. 再往前一步:实体链接、缓存与评测集让系统真正可用

跑通基础问答之后,真正让系统从"能跑"变成"能用"的,是三件不那么显眼但价值很高的事。

第一是实体链接的增强。用户问"歼二十的航程是多少",图谱里存的是"歼-20"。要做的是维护一张别名表,把正式名、别名、用户口语和历史日志里出现过的变体都收进去。常见做法是启动时加载别名表到内存,查询时先做别名映射,再走正文匹配。别名表不用一开始就做全,从日志里每天捞新的未命中问句,人工确认后补进去,慢慢就全了。

第二是热问答缓存。知识图谱问答的查询模式其实非常集中,前一百个高频问句可能覆盖了百分之八十的流量。在问答 API 层加一层 Redis 缓存,以"规范化后的问句"做 key,把 Cypher 查询结果缓存十分钟到半小时。热点问题不用每次击穿 Neo4j,响应时间从几十毫秒降到个位数毫秒。注意 key 要做归一化,比如去掉标点和多余空格,否则"歼20 射程"和"歼-20射程"会命中两个缓存条目,等于没缓存。

第三是评测集。没有评测集的问答系统,改一次分词词典或者调一条 Cypher,你都不知道是变好了还是变坏了。做法是准备一百条左右的高频真实问句,人工标注标准答案,每次改动跑一遍全量,统计准确率。模板系统的优势这时候就体现出来了:每一条错误都能定位到具体模块,改起来非常快。

我在跑这类项目时最大的教训就是:别一上来就折腾复杂模型,先把五十条黄金问答对用模板跑通,再做增量优化。这个顺序走下来,项目每天都有可交付的进度,投入产出比明显更稳。希望这些经验帮到你。

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

返回列表