
简介这份资源面向计算机、数学、电子信息等专业的学生与知识图谱初学者提供一套基于Neo4j的农业领域知识图谱构建完整源码可用于课程设计、期末大作业、毕设或项目立项演示。项目覆盖从百度百科爬取农业数据、数据分类到结构化数据生成三元组再到非结构化数据的分句、分词、命名实体识别与依存句法分析主谓关系等抽取三元组最终写入Neo4j实现可视化链路完整且贴近真实工程。压缩包共46个文件以23个txt语料与结果文件、8个Python脚本、7个csv数据表为主另含xml配置、md说明等整体约21.41MB目录按茶叶、农作物、植物等主题分模块组织便于对照调试。目前已有87人学习下载。读者可据此掌握爬虫、LTP与jieba处理、依存句法抽取及Neo4j建图的完整实现思路并借助停用词表、词典与中间结果文件快速复现与二次开发。1. 从百度百科到 Neo4j一条农业知识图谱的完整流水线农业领域的数据有个特点散、杂、非结构化。一个作物品种的适种区域可能藏在百科词条的段落里一条病虫害防治方法可能混在表格和自然语言之间。想把它们变成可查询、可推理的知识图谱靠人工整理不现实靠通用大模型抽取又容易在专业术语上翻车。我最近跑通的一条链路是用百度百科作为数据源先做结构化与非结构化的分流结构化数据直接映射三元组非结构化文本走 LTP 分句加依存句法分析抽主谓关系最后统一写入 Neo4j 做可视化。这套方案适合有一定 Python 基础、想快速搭出领域图谱原型的开发者也适合做农业信息化、垂直搜索、智能问答的团队做技术验证。整条链路不依赖昂贵的标注数据核心成本在爬取频率控制和句法规则的调优上。2. 数据爬取与分类把百科词条拆成能用的原料2.1 百度百科页面的结构特征与爬取入口选择百度百科的词条页面并不是一个规整的 API它的内容分布在多个区块里基本信息栏infobox是键值对表格正文段落是自然语言部分词条还有分类导航和参考资料列表。直接请求词条 URL 拿到的是完整 HTML需要先定位到div.main-content这个主内容容器再按区块切分。我一般会先请求词条的lemmaId或者直接用词条名拼 URL比如https://baike.baidu.com/item/水稻但要注意 URL 编码和重定向问题。请求头里必须带User-Agent否则大概率返回 403。频率上单 IP 建议控制在每分钟 10 到 15 次请求间隔用time.sleep(random.uniform(1, 3))做随机化避免触发风控。import requests from bs4 import BeautifulSoup import time import random HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 } def fetch_baike_page(lemma_name): 根据词条名抓取百度百科页面返回 HTML 文本 url fhttps://baike.baidu.com/item/{lemma_name} try: resp requests.get(url, headersHEADERS, timeout10) resp.raise_for_status() resp.encoding utf-8 return resp.text except requests.RequestException as e: print(f[抓取失败] {lemma_name}: {e}) return None def parse_infobox(html): 提取基本信息栏的键值对返回 dict soup BeautifulSoup(html, html.parser) infobox {} # 基本信息栏通常在 dl 或 table 结构中 for dl in soup.select(dl.basicInfo-block): dt dl.find(dt) dd dl.find(dd) if dt and dd: key dt.get_text(stripTrue) val dd.get_text(stripTrue) infobox[key] val return infobox def parse_paragraphs(html): 提取正文段落文本过滤掉参考资料和导航 soup BeautifulSoup(html, html.parser) paragraphs [] for p in soup.select(div.main-content p): text p.get_text(stripTrue) if len(text) 20: # 过滤短句和噪声 paragraphs.append(text) return paragraphs这段代码做了三件事请求页面、解析 infobox、抽取正文段落。parse_infobox里用dl.basicInfo-block选择器是因为百度百科的基本信息栏通常用定义列表渲染但不同词条模板可能有差异实际跑的时候要先用浏览器开发者工具确认一下当前页面的 DOM 结构。parse_paragraphs里加len(text) 20是为了过滤掉“参考资料”“编辑”这类短文本噪声。参数上timeout10是防止某个词条卡死整个队列resp.encoding utf-8必须显式设置否则中文会乱码。2.2 结构化数据与非结构化数据的分流逻辑抓下来的内容要分成两路。结构化数据指的是 infobox 里的键值对比如“中文名”“界”“门”“纲”“目”“科”“属”“种”“分布区域”这些字段它们天然就是实体和属性的关系。非结构化数据指的是正文段落里面包含大量描述性语句比如“水稻喜高温、多湿、短日照对土壤要求不严”这种句子需要靠句法分析才能抽出三元组。分流的意义在于结构化数据可以直接映射准确率高但覆盖面窄非结构化数据覆盖面广但需要 NLP 处理两者互补。我一般会把 infobox 存成 JSON正文段落存成纯文本文件按词条名分目录方便后续批量处理。import os import json def save_raw_data(lemma_name, infobox, paragraphs, base_dirraw_data): 将结构化与非结构化数据分别落盘 lemma_dir os.path.join(base_dir, lemma_name) os.makedirs(lemma_dir, exist_okTrue) # 结构化数据infobox 存 JSON with open(os.path.join(lemma_dir, infobox.json), w, encodingutf-8) as f: json.dump(infobox, f, ensure_asciiFalse, indent2) # 非结构化数据正文段落存 txt一行一段 with open(os.path.join(lemma_dir, paragraphs.txt), w, encodingutf-8) as f: for para in paragraphs: f.write(para \n) print(f[已保存] {lemma_name}: {len(infobox)} 个属性, {len(paragraphs)} 个段落)分流后的目录结构是raw_data/水稻/infobox.json和raw_data/水稻/paragraphs.txt。这样做的好处是后续处理可以并行结构化数据走映射脚本非结构化数据走 LTP 流水线互不阻塞。注意ensure_asciiFalse必须加否则 JSON 里的中文会变成 Unicode 转义虽然不影响解析但可读性差。3. 结构化数据生成三元组从 infobox 到 Neo4j 节点3.1 实体识别与关系定义的映射规则infobox 里的键值对不能直接当三元组用因为键名不统一。比如“分布区域”和“分布范围”可能是同一个关系“界门纲目科属种”是分类学层级关系。我一般会先定义一个映射表把原始键名归一化到标准关系名同时确定头实体和尾实体的类型。以作物为例头实体是作物本身尾实体根据键名不同可能是“区域”“分类层级”“形态特征”等。映射表用 Python dict 维护改起来方便。# 关系映射表原始键名 - (标准关系名, 尾实体类型) RELATION_MAP { 分布区域: (分布于, 区域), 分布范围: (分布于, 区域), 界: (属于界, 分类层级), 门: (属于门, 分类层级), 纲: (属于纲, 分类层级), 目: (属于目, 分类层级), 科: (属于科, 分类层级), 属: (属于属, 分类层级), 种: (属于种, 分类层级), 形态特征: (具有特征, 特征), 生长习性: (具有习性, 习性), } def infobox_to_triples(lemma_name, infobox): 将 infobox 转换为三元组列表 (头实体, 关系, 尾实体) triples [] for key, value in infobox.items(): if key in RELATION_MAP: relation, tail_type RELATION_MAP[key] # 处理多值情况比如分布区域可能用逗号分隔 for v in value.replace(、, ,).split(,): v v.strip() if v: triples.append((lemma_name, relation, v, tail_type)) return triples这段代码的核心是RELATION_MAP它决定了哪些 infobox 字段能被转成三元组。infobox_to_triples里对值做了逗号切分因为“分布区域”经常写成“中国、日本、东南亚”这种形式切分后每个区域单独成三元组。尾实体类型tail_type是为了后续在 Neo4j 里给节点打标签用的比如区域节点和分类层级节点要分开。实际跑的时候映射表需要根据你的领域词条不断补充农业领域常见的还有“产量”“抗病性”“适宜海拔”等。3.2 用 py2neo 批量写入 Neo4j 的节点与关系三元组生成后写入 Neo4j 用py2neo比较顺手。核心操作是merge而不是create因为同一个实体可能在多个词条里出现merge能保证节点不重复。关系也用merge避免重复边。批量写入时用事务包起来性能比单条插入高一个数量级。from py2neo import Graph, Node, Relationship # 连接 Neo4j默认 bolt 端口 7687 graph Graph(bolt://localhost:7687, auth(neo4j, your_password)) def write_triples_to_neo4j(triples): 将三元组批量写入 Neo4j tx graph.begin() for head, relation, tail, tail_type in triples: # 头实体统一为 Entity 标签用 name 属性唯一约束 head_node Node(Entity, namehead) tx.merge(head_node, Entity, name) # 尾实体按类型打标签 tail_node Node(tail_type, nametail) tx.merge(tail_node, tail_type, name) # 关系用 merge 避免重复 rel Relationship(head_node, relation, tail_node) tx.merge(rel) tx.commit() print(f[写入完成] 共处理 {len(triples)} 条三元组)graph.begin()开启一个事务所有merge操作在事务内执行最后commit一次性提交。tx.merge(head_node, Entity, name)里的后两个参数是主键约束意思是按Entity标签和name属性来判断节点是否已存在。尾实体用tail_type作为标签这样在 Neo4j 浏览器里可以用不同颜色区分区域节点和分类层级节点。注意your_password要换成你本地 Neo4j 的实际密码首次连接前确保 Neo4j 服务已启动并且bolt端口没有被防火墙挡住。4. 非结构化数据的分句与依存句法分析4.1 LTP 分句与分词把段落切成可分析的句子非结构化文本处理的第一步是分句。中文分句不能简单按句号切因为还有问号、感叹号、分号以及省略号。LTP 提供了split_sentence接口但实际用的时候我更喜欢先用正则做粗切再用 LTP 做精切。分句之后是分词和词性标注LTP 的pipeline可以一次性输出分词、词性、依存句法等信息。安装 LTP 用pip install ltp模型下载用ltp.download()但模型文件有几个 G建议提前下好放到本地目录。import re from ltp import LTP # 初始化 LTP首次使用会自动下载模型 ltp LTP(LTP/small) # small 模型速度快精度略低large 模型精度高但慢 def split_sentences(text): 按中文标点分句保留分号作为分隔 # 先按句号、问号、感叹号、省略号切 parts re.split(r[。…], text) sentences [] for part in parts: part part.strip() if len(part) 5: continue # 再按分号切一次因为分号前后常是并列关系 for sub in re.split(r[;], part): sub sub.strip() if len(sub) 5: sentences.append(sub) return sentences def ltp_analyze(sentence): 对单句做分词、词性、依存句法分析 seg, hidden ltp.seg([sentence]) pos ltp.pos(hidden) dep ltp.dep(hidden) # 依存句法返回 (head_index, relation, dep_index) return seg[0], pos[0], dep[0]split_sentences先用[。…]做一级切分再用[;]做二级切分这样能把长段落拆成适合句法分析的短句。ltp_analyze返回三个列表分词结果、词性标注、依存关系。依存关系里每个元素是(head_index, relation, dep_index)head_index是核心词的索引relation是关系类型比如SBV主谓关系、VOB动宾关系dep_index是当前词的索引。注意 LTP 的索引是从 1 开始的不是 0写代码时容易在这里翻车。4.2 依存句法分析抽取主谓宾三元组的规则依存句法分析的核心思路是找到谓语动词然后根据SBV关系找主语根据VOB关系找宾语组合成(主语, 谓语, 宾语)三元组。但农业文本里很多句子没有明确宾语或者主语省略这时候需要降级处理比如只抽(主语, 谓语)或者(谓语, 宾语)。我一般会优先抽完整主谓宾抽不到再抽主谓或谓宾。def extract_triples_from_dep(seg, pos, dep): 基于依存句法抽取三元组 triples [] # dep 格式[(head_idx, relation, dep_idx), ...] # 先找所有动词作为谓语候选 for head_idx, relation, dep_idx in dep: if relation SBV: # dep_idx 是主语head_idx 是谓语 subject seg[dep_idx - 1] # LTP 索引从 1 开始 predicate seg[head_idx - 1] # 在 dep 里找该谓语的 VOB for h2, r2, d2 in dep: if h2 head_idx and r2 VOB: obj seg[d2 - 1] triples.append((subject, predicate, obj)) # 降级只抽主谓 if not triples: for head_idx, relation, dep_idx in dep: if relation SBV: triples.append((seg[dep_idx - 1], seg[head_idx - 1], None)) return triples这段代码先遍历依存关系找SBV拿到主语和谓语再在同一谓语下找VOB拿宾语。seg[dep_idx - 1]里的减一是因为 LTP 索引从 1 开始。如果找不到完整主谓宾就降级只抽主谓宾语置None。实际跑的时候农业文本里“水稻喜高温”这种句子SBV是“水稻-喜”但没有VOB降级后得到(水稻, 喜, None)后续可以人工补全或者用规则匹配“高温”作为属性值。注意dep里的head_idx和dep_idx都是整数索引不是词本身所以必须用seg做映射。5. 避坑与排查这条链路上最容易翻车的五个地方5.1 爬取频率过高导致 IP 被封现象跑了几十个词条后请求开始返回 403 或者验证码页面。原因百度百科对单 IP 的请求频率有阈值超过后触发风控。解决把请求间隔调到 2 到 5 秒用random.uniform做随机化同时准备多个User-Agent轮换。如果已经被封换 IP 或者等几小时再跑。我一般会在爬取脚本里加一个计数器每抓 50 个词条就sleep(60)一次。5.2 LTP 索引从 1 开始导致取词错位现象抽出来的三元组里主语和谓语对不上比如“水稻喜高温”抽成“喜水稻高温”。原因LTP 的依存句法返回的索引是从 1 开始的而 Python 列表是从 0 开始忘记减一就会错位。解决所有用seg[idx]取词的地方统一写成seg[idx - 1]并且在代码里加注释提醒。这个坑我踩过两次血泪经验是写完先拿一个短句手动验证索引。5.3 infobox 键名不统一导致三元组丢失现象明明 infobox 里有“分布范围”字段但生成的三元组里没有。原因映射表里只写了“分布区域”没写“分布范围”。解决先把所有词条的 infobox 键名收集起来统计频次然后批量补充映射表。我一般会写一个collect_keys脚本跑一遍原始数据输出所有出现过的键名和次数再人工归一化。5.4 Neo4j 重复节点导致图谱膨胀现象同一个“中国”节点在数据库里有几十个只是name属性一样但节点 ID 不同。原因写入时用了create而不是merge或者merge的主键参数写错了。解决确保所有节点写入都用tx.merge(node, label, name)并且name属性在同一个标签下唯一。如果已经有重复节点用 Cypher 的apoc.refactor.mergeNodes合并。5.5 依存句法在长句上准确率骤降现象短句抽取得好好的一到 50 字以上的长句就抽出一堆乱七八糟的三元组。原因LTP 的依存分析在长句上容易把修饰关系搞混尤其是多个逗号并列的时候。解决分句时严格控制单句长度超过 40 字的句子再按逗号切一次。另外可以在抽取后加一层过滤比如谓语必须是动词词性主语和宾语不能是标点或停用词。6. 进阶技巧用规则加句法做混合抽取把准确率再拉一截纯依存句法抽取的上限很明显它依赖句法树的正确性而农业文本里大量存在省略、倒装、并列结构句法分析一错三元组就废了。我后来改成了混合策略先用依存句法抽一遍再用领域规则做补充和校验。规则部分主要做三件事一是维护一个农业实体词典抽出来的主语和宾语如果命中词典置信度加分二是维护一个关系触发词表比如“分布于”“原产于”“适宜在”这些词直接触发关系抽取不依赖句法三是对抽出来的三元组做去重和冲突检测同一个头实体和关系下如果有多个尾实体保留频次高的。# 领域实体词典示例 AGRI_ENTITIES {水稻, 小麦, 玉米, 中国, 日本, 东南亚, 高温, 多湿} # 关系触发词表 RELATION_TRIGGERS { 分布于: 分布于, 原产于: 原产于, 适宜在: 适宜在, 喜: 具有习性, } def rule_based_extract(sentence): 基于触发词的规则抽取 triples [] for trigger, relation in RELATION_TRIGGERS.items(): if trigger in sentence: parts sentence.split(trigger) if len(parts) 2: head parts[0].strip() tail parts[1].strip() if head and tail: triples.append((head, relation, tail)) return triples def merge_and_validate(dep_triples, rule_triples): 合并句法抽取和规则抽取的结果做去重和置信度排序 merged {} for t in dep_triples rule_triples: key (t[0], t[1], t[2]) merged[key] merged.get(key, 0) 1 # 按频次排序频次高的优先保留 sorted_triples sorted(merged.items(), keylambda x: x[1], reverseTrue) return [list(k) for k, v in sorted_triples]rule_based_extract用触发词直接切句子不依赖句法树所以在句法分析翻车的时候它能兜底。merge_and_validate把两路结果合并用频次做置信度排序。实际跑的时候AGRI_ENTITIES和RELATION_TRIGGERS需要根据你的领域不断扩充我一般会先跑一批数据把抽出来的三元组人工过一遍把高频错误和遗漏补进词典和触发词表。这个迭代过程大概跑三轮准确率能从 60% 左右拉到 80% 以上。验证方法上我习惯抽 100 个词条做人工标注然后算准确率和召回率。准确率看抽出来的三元组里有多少是对的召回率看人工标注的三元组里有多少被抽出来了。如果准确率低优先查依存句法的索引和分句逻辑如果召回率低优先补触发词表和映射表。Neo4j 浏览器里可以用MATCH (n)-[r]-(m) RETURN n, r, m LIMIT 100快速看图谱形态如果发现大量孤立节点说明关系抽取漏了如果发现某个节点连接数异常高说明实体归一化没做好。这套方案我前后调了大概两周最大的教训是不要指望句法分析一步到位规则和词典才是稳定器。每次改完抽取逻辑先拿 20 个词条跑回归确认没有把之前对的结果改错再全量跑。希望帮到你。本文还有配套的精品资源点击获取