☰
从百度百科到Neo4j:农业知识图谱构建全流程
2026/10/29 1:26:21 网站建设 项目流程

简介:一套基于Neo4j的农业领域知识图谱生成算法实现,适合计算机、数学、电子信息等专业学生用于课程设计、期末大作业或毕设项目,也可作为知识图谱入门实战参考。资源覆盖百度百科数据爬取、数据分类、结构化数据三元组生成,以及非结构化数据的分句、分词、命名实体识别和依存句法分析等完整流程。通过阅读源码可掌握百度百科词条采集、基于LTP的实体识别与关系抽取、三元组组织及Neo4j导入等关键技巧,配合中间数据与说明文档便于逐步调试。整个压缩包共46个文件,以Python脚本、TXT文本、CSV数据表为主,另有少量XML、配置与说明文件,大小约21.41MB。已有87人学习浏览,适合希望从数据到图谱完整跑通的中初级学习者。

1. 从"查不到"到"问得到":农业知识图谱为什么值钱

同样是"水稻得了稻瘟病怎么办",普通搜索给你十个页面自己翻,知识图谱直接给出 水稻—易感→稻瘟病—防治→三环唑 这条链。这个标题项目的本质就是把这条链从零搭起来:用爬虫从百度百科拿农业词条做粗分类,把结构化字段转成三元组,再把非结构化正文丢给 LTP 分句和依存句法分析(主谓关系)抽关系,最后导入 neo4j 生成可视化农业领域知识图谱。它是一套从数据到图库的算法流水线,适合正在做农业问答、农技推荐,或者想亲手写一条知识图谱管道的工程向读者。下面每一步的选型、参数和踩坑,都按我自己动手做这条线的经验来讲。

2. 百度百科爬取与农业数据分类:先把语料备齐,再谈三元组

2.1 爬虫设计:种子词表 + infobox 解析的最小实现

知识图谱的数据质量上限在语料,不在算法。农业领域最容易拿到的中文公开语料之一就是百度百科的农业词条:每个词条有一段半结构化的 infobox(词条左侧的"基本信息"表格),还有若干自然语言段落。前者可以直接映射三元组,后者要交给第 3 章的 LTP。爬虫的目标很简单:把"有哪些农业实体"这个问题,转换成"从哪批词条开始爬"。

我的做法是准备五类种子词:作物(水稻、小麦、玉米……)、病害(稻瘟病、锈病……)、虫害(玉米螟、蚜虫……)、农药(三环唑、吡虫啉……)、肥料(尿素、复合肥……)。每类先人工列 20 到 30 个,爬完后再把 infobox 和正文里高频出现的新词条名作为二级种子补进去。这样领域收敛得比放开 BFS 好得多——放开全百科爬虫大概率会爬到明星和电视剧,农业图谱里混进一堆无关节点。

下面这段是抓取 infobox 和正文段落的最小实现:

import requests import time import random import re from bs4 import BeautifulSoup HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Referer": "https://baike.baidu.com/", } def fetch_baike_page(lemma): url = f"https://baike.baidu.com/item/{requests.utils.quote(lemma)}" resp = requests.get(url, headers=HEADERS, timeout=10) if resp.status_code != 200: return {}, [] soup = BeautifulSoup(resp.text, "html.parser") info = {} dl = soup.find("dl", attrs={"class": "basic-info"}) if dl: for dt in dl.find_all("dt"): key = dt.get_text(strip=True).rstrip("::") dd = dt.find_next_sibling("dd") if dd is None: continue val = dd.get_text(strip=True) val = re.sub(r"\[\d+\]", "", val) # 去掉引注角标 info[key] = val paras = [p.get_text(strip=True) for p in soup.select(".para")] return info, paras

逻辑说明:url 里的词条名要先用 requests.utils.quote 做百分号编码,否则中文链接会丢失参数。找 infobox 用的是 dl.basic-info 这个容器,但百科改版动过 class 名,拿到页面先打印确认,不要照抄选择器。val 里的引注角标 [1]、[2] 不删干净会污染三元组的宾语。抓取节奏我一般 sleep 0.5 到 1.5 秒随机,单进程串行,一晚上能跑完几百个种子词条,也不容易触发页面的风控页。

这套代码没有断点续传。爬几百个词条可以接受中断后重跑,但如果打算长期维护,建议把已抓词条名存一份 set 到本地文件,每次启动读回来跳过,否则重复数据会让后面的图谱多出大量重复实体。把已抓 id 留底,是给后续清洗留的后悔药,类似问题到第 5 章统一展开。

2.2 数据分类:用百科分类锚点和规则把词条分成五类

爬完词条不能直接建图,先要回答一个问题:"水稻"和"稻瘟病"在 Neo4j 里该用同一个标签还是不同标签。标题里写着"数据分类",对知识图谱而言,分类的意义是给节点定标签,标签决定 Cypher 查询的起点。

分类逻辑我分两层。第一层看百科词条页底部的"百科分类"锚点文本,比如"粮食作物""植物病害"这些词;第二层看 infobox 的字段名,比如出现"纲:单子叶植物纲"时基本可以判断是植物类。两层都空的词条打入 other,后面人工扫一遍。规则用白名单实现:

CATEGORY_RULES = { "crop": ["粮食作物", "农作物", "蔬菜", "水果", "经济作物"], "disease": ["植物病害", "真菌病害", "病毒病"], "pest": ["昆虫", "害虫", "螨类"], "pesticide": ["农药", "杀菌剂", "杀虫剂", "除草剂"], "fertilizer": ["肥料", "化肥", "复合肥"], } def classify_lemma(category_texts, info): haystack = "".join(category_texts) + "".join(info.keys()) for cat, keys in CATEGORY_RULES.items(): if any(k in haystack for k in keys): return cat return "other"

参数说明:这个函数返回的是一个五选一标签,不做多标签。真实数据里"三环唑"的百科分类经常同时挂"农药"和"化学物质",我一般只保留业务侧更关心的 pesticide,多标签会让后续图谱的节点标签查询出现语义分裂。分类可以先粗后细,把五类做完再考虑要不要分"杀菌剂"这类二级标签——Neo4j 用标签做索引时,标签越多索引碎片越多,查询计划也越难优化。

crop 内部还有粮食、蔬菜、水果的区分。不建议在图上直接挂死二级标签,而是在节点加一个 crop_type 属性存字符串,查询时用属性过滤。这样改分类不用重建索引,对数据源持续更新的场景很省事。

这一层分类本质是穷举关键词,挡不住"玉米"被分到 crop、"玉米螟"也被分到 crop 这类边界错误。玉米螟在百科分类里通常归到"昆虫/害虫",规则能走对,但如果某天百科调整了分类锚点文本,就需要第 6 章的回归测试来兜底。

3. 三元组生成:结构化映射 + LTP 分句 + 依存句法主谓抽取

3.1 结构化数据生成三元组:infobox 字段映射与多值展开

infobox 是百科词条里最接近 RDF 的结构:左边 key,右边 value,主语就是词条名。难点不在提取,而在把"界""门""纲""防治方法"这类自由文本字段统一成一个谓词。知识图谱最忌讳的是同一个关系写成十个名字:"防治方法""防治措施""防治"如果并存,Cypher 查起来就要 OR 一长串。

我先建一个谓词映射表,把同义字段折叠到唯一谓词,再用映射表把 infobox 转成三元组。

PRED_MAP = { "界": "属于", "门": "属于", "纲": "属于", "目": "属于", "科": "属于", "属": "属于", "种": "属于", "防治": "防治方式", "防治方法": "防治方式", "防治措施": "防治方式", "分布": "分布区域", "分布区域": "分布区域", } def info_to_triples(subject, info): triples = [] for key, value in info.items(): pred = PRED_MAP.get(key, f"属性:{key}") value = re.sub(r"\[\d+\]", "", value) for obj in re.split(r"[,,;;、]", value): obj = obj.strip() if len(obj) >= 2 and obj != subject: triples.append((subject, pred, obj)) return triples

参数说明:re.split 的切分符号包含中文逗号、顿号、分号,百科的列表值大量用"、"连接,只按英文逗号切会漏。长度过滤 len(obj) >= 2 和 obj != subject 是为了扔掉空串和自引用——自引用在"水稻"的"种:水稻"这类条目里很常见。谓词表单独存成一个 json 文件做版本管理,它是图谱语义里最需要人工维护的资产。

这套映射的局限在宾语类型。infobox 里"适宜温度:20-30℃"转成三元组后宾语是文本,不是数值,没法直接做范围查询。标题项目没强制要求数值化,我一般保持字符串原样,等图谱长大再按谓词分批迁移。

3.2 非结构化数据分句:LTP 的 sent_split 到底在切什么

正文段落和 infobox 是两个世界。正文里的知识是"水稻稻瘟病是水稻生产上的重要病害。该病在阴雨高湿天气下易流行"这种句子,主语、谓语、宾语藏在自然语言里。直接对整个段落做依存句法分析,跨句的词会互相产生依赖弧,噪声大到没法用,所以标题里步子拆得很对:先分句,再抽主谓关系。

分句我选的是哈工大 LTP。中文分句表面上看是标点切分问题,但句号、分号、冒号、感叹号都有语义边界,"3.5%"里的小数点不能当句号;LTP 的解决方案是在模型里做句子边界预测,比正则可靠。ltp 4.x 的接口大致是这样:

from ltp import LTP ltp = LTP("LTP/model") # 模型目录,按 LTP 官方说明准备 def split_to_sentences(text): sents, _ = ltp.sent_split([text]) sents = [s for s in sents if len(s) >= 4] return sents sample = "水稻稻瘟病是水稻生产上的重要病害。该病在阴雨高湿天气下易流行。" for s in split_to_sentences(sample): print(s)

说明:LTP 的模型目录不是 pip 包自带的,需要按官方项目页的说明单独准备。sent_split 返回的句子列表要做一次长度过滤,因为百科正文里偶尔会切出"1。"这类残片。不同小版本的返回结构略有差异,以你安装的版本官方文档为准。

分句后的数据我直接追加到本地 jsonl,一行一个句子,带 source_lemma 字段。后续抽取完要保留 provenance(来源),否则图谱里的关系说不清出处,给业务方验收时会很难交代。这个字段在整条管线里都值得带,哪怕是临时脚本也别省。

3.3 基于依存句法分析的主谓关系抽取:从 SBV+VOB 到三元组

LTP 的依存句法把每个词标注一个到父节点的依存关系。和三元组最直接的两个关系是 SBV(主谓关系)和 VOB(动宾关系):当同一个谓词既作为某名词的父节点(SBV),又作为另一个名词的父节点(VOB),(主语, 谓语, 宾语) 就是一个候选三元组。标题里的"主谓关系等"就是这一节的抓手。

def dep_triples(sent, ltp): seg, hidden = ltp.seg([sent]) postags = ltp.pos(hidden) arcs = ltp.dep(hidden)[0] tokens = seg[0] sbv = {} # 谓语节点下标 -> 主语节点下标 vob = [] # (谓语节点下标, 宾语节点下标) for idx, (head, rel) in enumerate(arcs): if head < 0: continue if rel == "SBV": sbv[head] = idx elif rel == "VOB": vob.append((head, idx)) triples = [] for pred_idx, subj_idx in sbv.items(): for pred_i, obj_i in vob: if pred_i == pred_idx: s = tokens[subj_idx] p = tokens[pred_i] o = tokens[obj_i] if len(s) > 0 and len(o) > 0: triples.append((s, p, o)) return triples

逻辑说明:ltp.dep 返回的每条弧是 (head, rel),head 是该词的父节点下标,rel 是关系名,根节点的 head 为 -1。代码核心是用 sbv 字典记"哪个谓词带着哪个主语",再遍历所有动宾对,找到同一个谓词名下的宾语,拼成三元组。

这一步真实的正确率不会太高。百科正文里一个句号里常出现两套主谓结构:"该病由真菌引起,高温高湿时发病重"这样的并列句,SBV 和 VOB 会对到不同的谓词上,漏抽。我第一版只做单谓词匹配,抽出来 300 条人工看准确率大概五成上下,把并列结构、兼语结构加进去后能到七成。别在规则上过度堆,性价比高的下一步是给抽取结果加置信度:主语是专有名词、宾语是名词性短语的优先入库,含代词的降级。

标题里"等"字后面还有别的可用关系。ATT(定中关系)能抽"(黑条矮缩病的)传毒介体——是——灰飞虱"这种修饰结构,但 ATT 噪声比 VOB 大得多,第一版不要开。

4. Neo4j 图建模与可视化:从三元组到能查的农业知识网络

4.1 实体标签与关系类型:先定图模型再动手写数据

三元组到手,开始设计图模型。Neo4j 里节点标签就是第 2 章 classify 的结果:crop、disease、pest、pesticide、fertilizer。关系类型用谓词映射后的有限集合,包括属于、防治方式、分布区域,以及"属性:xxx"这样的兜底。

图建模的顺序是先定唯一约束,再谈写入。实体(尤其病害名)经常有别名,比如"稻瘟病"也叫"稻热病",如果词条本身是独立页面就用不同节点,但同一个词条不能被重复写进去。Neo4j 4+ 建约束的 Cypher 是这样:

CREATE CONSTRAINT crop_name IF NOT EXISTS FOR (n:Crop) REQUIRE n.name IS UNIQUE;

说明:REQUIRE 是 Neo4j 4.x 的语法,如果还在用 Neo4j 3.5,把 REQUIRE 换成 ASSERT 即可。约束的作用不只是去重,它还会为 name 建索引,后面按名字 MATCH 的速度会明显变快。

关系类型不用全量映射。常见做法是只保留高价值谓词,比如"防治方式""传播途径""症状""分布区域";其余的低频属性字段统一落成一个 generic 关系,属性里记录原始 key。这样查询语义干净,又不会丢掉信息。

4.2 批量导入:neo4j-admin import 与 py2neo 的选择

导入有两条路。小数据量(几千节点、几万关系)用 py2neo 或官方 driver 逐条写事务,配合 MERGE 去重;大数据量(十万以上)离线批量导入。标题场景里词条几百个,加上关系撑死了几万条,两条路都能跑,我一般倾向离线 import,因为可重放、不依赖图库在线,失败了大不了重导一次。

离线导入前要先把三元组摊平成两个文件:节点文件按标签分别导出,关系文件按三元组导出。neo4j-admin import 的典型命令:

neo4j-admin import \ --database=agriculture.graphdb \ --nodes=/data/nodes_crop.csv \ --nodes=/data/nodes_pest.csv \ --relationships=/data/rels.csv \ --delimiter="|"

文件格式说明:nodes_crop.csv 的第一行是表头 id:ID(Crop)|name:string|description:string,rels.csv 的表头是 :START_ID(Crop)|:END_ID(Pest)|:TYPE|name:string。:TYPE 对应关系类型,不能是中文,所以关系文件里写的是 HAS_DISEASE,中文字段名放在 name 属性里。用管道符"|"是因为清洗后的宾语里可能残留英文逗号。

在线逐条写也有坑:全部用 CREATE 会写进重复节点,全部用 MERGE 又慢。折中是在事务里先 MATCH 节点 id,再 CREATE 关系。导入完成后做一次统计,数字和心理预期对不上时,先查数据清洗,别急着调图模型。

4.3 可视化:Neo4j Browser 查询与展示的边界

Neo4j 自带 Browser 就是最顺手的可视化工具,输入 Cypher 就能看到图渲染。一个能直接验收的查询:

MATCH (n:Crop {name: "水稻"})--(m) RETURN n, m LIMIT 50;

这个查询会把水稻直接相关的节点和关系在 Browser 里渲染成一张局部星型图。Browser 渲染的默认配色按标签区分,超过 100 个节点后整个画布开始糊成一团,所以 LIMIT 50 不是偷懒,是让图保持在可辨认的密度。

可视化这一步很多读者会以为要接一个前端大屏。实际落地中 Browser 已经能满足验收,真需要嵌入业务系统再考虑独立前端方案。在那之前,把精力放在 Cypher 查询模板上,因为业务方提的问题本质上都是"水稻的防治方式有哪些""哪些病害用三环唑",这些用一段 Cypher 就能回答。

5. 避坑与常见问题排查:5 条能省半天的血泪记录

5.1 启动与版本类:Neo4j 与 JDK、LTP 与模型包的版本玄学

第一条,Neo4j 服务起不来。现象是执行 neo4j start 后进程秒退,logs/neo4j.log 里出现 UnsupportedClassVersionError。原因基本是 JDK 版本不匹配:Neo4j 3.5 时代必须用 JDK 8,而很多机器上默认 JDK 11 甚至 17。解决:先 java -version 看当前版本,再安装对应 JDK 并 export JAVA_HOME 指向它,重启 Neo4j。网上大量教程仍停留在 3.5,遇到时报错先怀疑版本冲突,比查配置高效得多。

第二条,LTP 加载模型报错。现象是 ltp = LTP("LTP/model") 直接抛异常,或者分句返回全是空列表。常见原因有两个:一是没有准备模型目录,pip 包本身不带模型;二是拿 pyltp 的模型目录喂给 ltp 4.x,两代模型格式不兼容。解决也是两步:确认模型文件真实存在于目录里,再确认包和模型是同一代版本,不要混用。这个报错信息往往很模糊,属于典型的"黑匣子"问题,只能按版本链路倒查。

5.2 数据与导入类:中文编码、验证页、谓词过散的踩坑记录

第三条,导入后中文乱码。现象是 Browser 里节点名显示成乱码,关系还连不上。原因十有八九是 CSV 在 Windows 下被另存成了 ANSI 或者带 BOM,neo4j-admin import 读的时候表头对不上。解决:所有 CSV 用 Python 显式写 utf-8-sig 编码,表头字段名不要用中文,分隔符统一,字段顺序和表头严格一致。

第四条,爬虫爬到一半遇到验证中间页。现象是突然所有请求都返回 200,但解析出来的 infobox 是空的——因为返回的是风控提示页,不是词条。原因很简单:请求频率太高,没有做随机限速。解决:串行爬取,每次 sleep 0.5 到 1.5 秒随机值,不要开 20 个线程;给 requests 加 retry 和退避,遇到连续三次空数据就手动看一眼响应内容,别死循环。这个坑和爬虫框架无关,纯粹是节奏问题。

第五条,图谱里关系类型爆炸。现象是 MATCH ()-[r]->() RETURN type(r) 一查出来几十种,大部分是"属性:产地""属性:别称",查询模板没法写。这通常是 PRED_MAP 覆盖太窄导致的,infobox 里每个低频字段都被兜底成了 属性:xxx。解决不是删数据,而是把兜底谓词按出现次数排序,TOP 20 逐一人工归并到通用谓词,重跑导入。谓词表维护三到五个轮回后,关系类型会收敛到两位数以内。

每条踩坑记录看似三句话讲完,但都在日志和脏数据面前蹲过一两个小时。版本不匹配尤其气人,Neo4j 对 JDK 的挑剔已经到了玄学的程度;数据问题则相反,它没有玄学,只是脏。先怀疑数据,再怀疑配置,是这条管线的排错顺序。

6. 验证与进阶:用 50 条标注样本守住抽取质量

6.1 最小验收集:三元组抽取的 P/R/F1

第 3 章说依存句法抽取第一版正确率只有五成,这句话不是感觉,是拿 50 条标注样本量出来的。做法是:从已爬语料里随机挑 50 个句子,人工写出句子里的正确三元组作为标注集,再把抽取结果和标注结果对比,算准确率、召回率、F1。这个脚本不需要复杂框架,几条 Python 集合操作就能做,关键是标注集必须冻结版本,不能今天改明天改。农业领域天然适合用"品种—病害—药剂"这种 50 到 200 条的标注集做回归验收,因为知识边界比开放域窄得多。

6.2 图完整性检查与查询模板

数据进图后还要跑一遍图完整性检查。我常用的检查是算平均度,即每个节点平均挂多少条关系:

MATCH (n) WITH count(n) AS node_cnt MATCH ()-[r]->() WITH node_cnt, count(r) AS rel_cnt RETURN node_cnt, rel_cnt, toFloat(rel_cnt) / node_cnt AS avg_degree;

如果平均度小于 1,说明大量节点是孤岛,关系没有连起来,回到第 3 章查抽取。

扩展方向有三个:一是给节点加别名属性,把"稻热病"这类同义词并到同一实体;二是关系类型收敛后,按关系类型分别做精度抽检,比如只查"防治方式"的 50 条是否有病句;三是把谓词映射表和分类规则都进 git,每次百科改版后重跑一遍标注集,不回归就不发布。我现在的习惯是每更新一批语料就重跑一次 6.1 的脚本,数字掉到 70% 以下就拦住不发布新图谱。第一次跑通全流程时的兴奋感很容易让人忽略准确率,而知识图谱这个行业里,准确率低的东西发出去就是事故,不是功能。希望帮到你。

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

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

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

立即咨询