
简介面向高校计算机相关专业人工智能、物联网、自动化等的农业领域知识图谱构建与Neo4j可视化完整工程可满足毕业设计、课程设计及项目初期立项演示等需求。项目基于Python实现百科数据爬取、停用词过滤、实体识别与关系抽取并将三元组数据写入Neo4j完成图谱可视化技术链路完整。资源共46个文件以py脚本、txt语料/词典/停用词表、csv结构化数据、md说明文档为主压缩包约21.42MB目录按数据爬取、三元组抽取、图谱入库等模块组织层次清晰。目前已有57人学习。包内不仅提供针对茶叶、普通农作物等场景的三元组与NER结果还包含jieba自定义词典和多种停用词表能帮助读者理解知识图谱构建全过程也可直接作为毕设参考或在此基础扩展其他农业实体适合初中级Python开发者学习进阶。1. 农业领域知识图谱到底在做什么从爬虫到 Neo4j 可视化的一条线农业数据平时散落在行情网站、批发市场报价、气象接口、论文资料里彼此孤立且命名混乱。这个项目标题看起来像“爬虫 图数据库 前端图表”的拼装实际落地时你会发现最难的不是把数据丢进 Neo4j而是让“土豆”“马铃薯”“洋芋”在图中成为同一个实体让“产地”和“供应商”的关系不重复、不丢失。完整做下来你需要处理数据采集、字段清洗、本体设计、实体对齐、Cypher 批量导入、可视化接口六层问题。这篇博文按一条可复现的技术链路来拆先讲农业数据源选型和抓取策略再讲如何把杂乱网页文本转成三元组然后落到 Neo4j 的建模和导入最后说清怎么验证图谱质量和整理配套文档。适合要交课程设计、做农业数据中台、或者想从零构建一个领域知识图谱的工程师。整个方案不依赖某个特定网站换数据源只需改解析规则。2. 数据爬取层农业数据来源、抓取策略与字段设计2.1 农业数据源选型为什么首选惠农网与公开农业数据库农业领域知识图谱的实体覆盖作物、产地、价格、品种、病虫害几类。能稳定抓取且字段结构接近三元组的数据源我一般优先考虑四类数据源可获取实体特点抓取成本惠农网农产品、产地、供应商、价格页面结构清晰有分类目录低中国农业信息网价格日报、市场行情表格型数据适合 LOAD CSV 直接消费低气象公开接口天气、温度、地区JSON 格式需按日期对齐中百科类词条品种、病虫害、防治方法半结构化信息框需要解析高选惠农网作为演示源是因为它的 URL 带分类 ID列表页和详情页分离方便做增量抓取。但要注意任何网站都有 robots.txt 和使用条款个人学习抓取的数据不要商用。公开农业数据库如各地农业数据中心通常以 CSV 或 Excel 提供下载反而更适合做知识图谱的底座。抓取策略上不要一上来就写并发 50 的异步爬虫。农业数据更新频率低每日增量不过几千条单线程 限速足够。使用requests加BeautifulSoup最直观项目里贴上去能跑通等需要管理代理池或断点续爬时再换 Scrapy 也不迟。2.2 用 Python 写一个稳定的爬虫请求头、限速与异常重试抓取网页时服务端反爬主要由User-Agent、请求频率、Cookie 三部分触发。农业类网站反爬较弱但依然需要把请求伪装成浏览器。下面是一段可直接运行的抓取函数我通常把它独立成crawler.py复用import time import random import requests from bs4 import BeautifulSoup HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36, Accept-Language: zh-CN,zh;q0.9, } def fetch_page(url: str, retries: int 3) - BeautifulSoup: 抓取单页并返回 BeautifulSoup 对象失败时按退避策略重试。 for attempt in range(retries): try: resp requests.get(url, headersHEADERS, timeout10) resp.raise_for_status() resp.encoding resp.apparent_encoding return BeautifulSoup(resp.text, html.parser) except (requests.RequestException, ValueError) as err: wait 2 ** attempt random.uniform(0, 1) print(f第 {attempt 1} 次请求失败: {err}, 等待 {wait:.2f}s) time.sleep(wait) raise RuntimeError(f页面无法访问: {url}) def parse_product_list(html: BeautifulSoup): 解析列表页提取农产品名称、链接和价格文本。 items [] for card in html.select(div.product-card): name card.select_one(a.name).get_text(stripTrue) href card.select_one(a.name)[href] price_text card.select_one(span.price).get_text(stripTrue) items.append({name: name, url: href, price_raw: price_text}) return itemsfetch_page里有两个关键参数retries控制在网络波动或返回 5xx 时尝试 3 次重试等待时间用2 ** attempt random.uniform(0, 1)做指数退避避免重试时再次触发限频。resp.encoding resp.apparent_encoding是必须的很多农业站页面声明的字符集和实际内容不一致不重设 encoding 会导致中文乱码。parse_product_list里的选择器.product-card、.name、.price是针对演示站点的结构写的。真实项目里我会先抓一个页面打印 HTML用 Chrome DevTools 检查实际 class 名再替换。不要轻信网上现成的选择器网站改版后第一个挂掉的就是它们。2.3 数据清洗与字段映射从 CSV 到知识图谱三元组的转换抓下来的原始数据不能直接入图。价格字段往往是“1.20 元/斤”这种带单位文本产地可能是“山东省潍坊市寿光市”需要拆分。清洗后的中间格式我统一存成 CSV每一行代表一条记录列名在设计时直接对齐 Neo4j 属性item_name,item_variety,origin_province,origin_city,price,unit,date,url 土豆,荷兰十五,山东省,潍坊市,1.20,元/斤,2024-03-15,https://example.com/pid123 马铃薯,黄心,云南省,曲靖市,0.95,元/斤,2024-03-15,https://example.com/pid456注意第一行和第二行实际是同一个农作物“土豆”和“马铃薯”。知识图谱里不能直接建两个节点否则后续“产地-作物”的聚合查询会把同一实体拆成多个。所以清洗阶段要留一列为normalized_name通过别名映射表统一实体名称ALIAS_MAP { 土豆: 马铃薯, 洋芋: 马铃薯, 马铃薯: 马铃薯, 西红柿: 番茄, 番茄: 番茄, } def normalize_item(raw_name: str) - str: 返回统一后的标准作物名未命名的则原样返回。 name raw_name.strip() return ALIAS_MAP.get(name, name)映射表是实体对齐的最简单形态只适合“一对一别名”。真实农业数据里还有“山东大葱”“章丘大葱”这种带地名的品种不能简单替换。我通常先把地名前缀拆掉再查别名表最后保留原始品种字段这样图里的“大葱”节点和“章丘大葱”节点可以形成“品种-属于-作物”的关系而不是互相覆盖。清洗完成后下一层才做三元组抽取把 CSV 记录映射为( 作物, 产于, 产地 )、( 作物, 具有价格, 价格记录 )、( 价格记录, 采集时间, 日期 )这类关系。这一步也可以由 Cypher 导入语句直接完成放在第 4 章讲。3. 知识图谱本体设计与实体对齐农业概念怎样变成图结构3.1 本体设计作物、产地、价格、病虫害的实体与关系知识图谱没有“标准答案”本体的设计决定了后续查询能回答什么问题。农业领域我倾向于保留 5 类节点、6 种关系覆盖最常见的看图场景节点类型属性示例关系方向说明Crop作物name, category, seasonCrop - [产于] - Region核心实体Region产地province, city, countyRegion - [下辖] - Region支持层级查询Price报价price, unit, date, marketCrop - [有报价] - Price时间序列Pesticide农药name, target_pestCrop - [用] - Pesticide防治关系Pest病虫害name, symptom, spreadPest - [危害] - Crop双向关系用 Cypher 创建约束时节点标签和唯一键需要提前想好。比如Crop的唯一键设成name但“马铃薯”“土豆”两个名会在清洗阶段合并所以这里假设已经做了归一化。不要把price属性设成唯一键同一个市场同一天不同供应商的价格可以不同设唯一键会把合法数据丢弃。3.2 实体对齐与消歧同名异义与异名同义的坑上面提到的ALIAS_MAP只解决“异名同义”。更难处理的是“同名异义”比如“青椒”在北方指一种甜椒在四川可能指辣椒。单纯按词匹配会让图谱出现两个毫无关系的实体被错误合并。常用的解决办法是代码里引入“上下文特征”def disambiguate_crop(raw_name: str, description: str) - str: 基于描述文本语境为作物名消歧。 if raw_name 青椒: if 辣 in description or 四川 in description: return 辣椒 return 甜椒 if raw_name 苹果: if 手机 in description or 数码 in description: return 电子产品 return raw_name这段代码的优点是你可以把任何“看起来像作物名但实际是其他领域实体”的情况拦截在入库前。缺点是不可扩展全靠人工维护。项目答辩时可以说明这是“基于规则的知识库消歧”适用的场景是限定域文本如果后续数据量大了再换成基于向量相似度的实体链接用jieba分词后做 TF-IDF 比对。我见过不少人在这一步偷懒直接把“苹果”当作水果节点入库。结果知识图谱里一个节点上混了“苹果手机的价格”和“红富士苹果的价格”查询时数据打架。农业领域图谱必须守住“领域限定”这条线不属于本体的实体一律丢弃或映射到“未知”节点。3.3 三元组抽取规则与词典结合的策略从清洗后的 CSV 生成三元组有两种路线一种是在 Python 里生成三元组列表然后用py2neo写入另一种是只生成 CSV用 Cypher LOAD CSV 在导入时建关系。前者适合数据量在 10 万级以内后者适合百万级。我建议项目里两种都做开发调试用 Python 生成正式导入用 LOAD CSV。这里给一个 Python 端抽取三元组的示例import csv import json def extract_triples(csv_path: str) - list: 读清洗后 CSV生成 (头实体, 关系, 尾实体) 三元组列表。 triples [] with open(csv_path, r, encodingutf-8) as f: reader csv.DictReader(f) for row in reader: crop row[normalized_name].strip() if not crop: continue triples.append((crop, 产于, row[origin_province])) triples.append((crop, 有报价, row[price] / row[unit])) return triples if __name__ __main__: triples extract_triples(clean_agriculture.csv) with open(triples.json, w, encodingutf-8) as fp: json.dump(triples, fp, ensure_asciiFalse, indent2) print(f抽取三元组 {len(triples)} 条前 3 条例: {triples[:3]})代码里抽产于时直接用origin_province没有保留城市因为 Neo4j 里可以建一个地区层级关系之后从省节点向下查城市。如果城市信息也要入库就再写一组( 城市, 属于省, 省 )关系。注意price / unit把价格文本放在一个属性里查询价格区间时就很吃亏。正确的做法是 Price 单独做成节点属性拆成value和unit本章节为了演示三元组抽取简化了实际项目里不要省这一步。4. Neo4j 导入与可视化Cypher、批量导入与前端对接4.1 Neo4j 部署与配置社区版安装和内存参数Neo4j 社区版对学习场景完全够用不支持集群但单机写入十万到百万节点没问题。部署到 Linux 服务器时我习惯用 tgz 包而不是 apt 源这样可以控制 Java 版本。wget https://dist.neo4j.org/neo4j-community-5.19.0-unix.tar.gz tar -xzf neo4j-community-5.19.0-unix.tar.gz cd neo4j-community-5.19.0 export JAVA_HOME/usr/lib/jvm/java-17-openjdk-amd64 ./bin/neo4j start启动后先改密码默认用户名neo4j初始密码neo4j。浏览器访问http://localhost:7474在首次登录页面会要求重置密码。如果你是远程服务器记得在conf/neo4j.conf里确认server.bolt.listen_address和server.http.listen_address绑定的 IP默认只监听本机会导致前端连不上。内存配置是投产前必改的项。编辑conf/neo4j.confserver.memory.heap.initial_size512m server.memory.heap.max_size1G server.memory.pagecache.size512mpagecache是 Neo4j 缓存节点和关系的主要内存不能超过物理内存的 70%。如果数据量只有几十万条512m 就够把堆内存调得很大但没有给 pagecache 合理空间反而容易频繁 GC。改完配置用./bin/neo4j restart生效。4.2 Cypher 建模节点、关系、索引与约束登录 Neo4j Browser 后先用一段 Cypher 把本体和约束建好CREATE CONSTRAINT crop_name IF NOT EXISTS FOR (c:Crop) REQUIRE c.name IS UNIQUE; CREATE CONSTRAINT region_key IF NOT EXISTS FOR (r:Region) REQUIRE r.full_name IS UNIQUE; CREATE INDEX price_date_idx IF NOT EXISTS FOR (p:Price) ON (p.date); CREATE (c:Crop {name: 马铃薯, category: 蔬菜, season: 春季}) CREATE (r:Region {full_name: 山东省-潍坊市, province: 山东省, city: 潍坊市}) CREATE (c)-[:产于 {source: 惠农网, date: 2024-03-15}]-(r);这里用了CREATE CONSTRAINT ... IF NOT EXISTS避免重复执行时报错。CREATE INDEX用来加速按日期查询价格记录的请求。full_name对 Region 做唯一约束是因为省和城市连在一起建节点不同省份可能都有“滨海县”只拿城市名当唯一键会冲突。图里多了一个“价格记录”节点应该是 Price。接上节的说明正确的建模是CREATE (p:Price {value: 1.20, unit: 元/斤, date: 2024-03-15, market: 寿光物流园})不要把价格直接挂在 Crop 的属性上。价格随时间变化属性会被后续数据覆盖丢失历史。用 Price 节点后回答“马铃薯近 30 天价格走势”的查询就很自然了。4.3 批量导入LOAD CSV vs py2neo性能与边界数据量不大时用py2neo单条插入容易理解。数据量超过 5 万条我会先用 CSV 文件再用 Cypher 的LOAD CSV导入。原因是 Neo4j 对LOAD CSV做了事务批量处理速度比逐条CREATE快一个数量级。先准备两个 CSVcrops.csv和relations.csv。前者是节点去重后的列表后者是关系边表crop_id,name,category,season C001,马铃薯,蔬菜,春季 C002,番茄,蔬菜,夏季crop_id,relation,region_name,price,unit,date C001,产于,山东省-潍坊市,1.20,元/斤,2024-03-15然后执行导入脚本LOAD CSV WITH HEADERS FROM file:///crops.csv AS row MERGE (c:Crop {crop_id: row.crop_id}) SET c.name row.name, c.category row.category, c.season row.season; LOAD CSV WITH HEADERS FROM file:///relations.csv AS row MATCH (c:Crop {crop_id: row.crop_id}) MATCH (r:Region {full_name: row.region_name}) MERGE (c)-[:产于 {date: row.date}]-(r);LOAD CSV WITH HEADERS要求 CSV 第一行是列名且文件放在 Neo4j 的import目录下。如果你用 Docker 安装需要挂载卷把文件放到容器内的/var/lib/neo4j/import。MERGE和CREATE的区别在于MERGE会先查重存在则跳过CREATE无条件新建。节点去重后用MERGE防重复关系上用MERGE避免同样日期同一条产地关系插入两遍。代价是慢一些但如果数据源里确有重复CREATE会让图谱膨胀得很难看。py2neo适合从 Python 程序里动态写入比如你在清洗流程里已经算出了三元组不需要额外生成 CSV。下面是用py2neo的批量写法注意不要逐条提交from py2neo import Graph, Node, Relationship graph Graph(bolt://localhost:7687, auth(neo4j, your_password)) def batch_write(triples, batch_size1000): tx graph.begin() for i, (head, rel, tail) in enumerate(triples): tx.merge(Node(Crop, namehead), Crop, name) tx.merge(Node(Region, full_nametail), Region, full_name) tx.merge(Relationship( Node(Crop, namehead), rel, Node(Region, full_nametail) )) if i % batch_size 0: tx.commit() tx graph.begin() tx.commit()tx.begin()开启一个事务每 1000 条提交一次。这里的事务提交会让 Neo4j 批量刷盘比每条都 commit 快很多且中途程序崩了只回滚最近 1000 条。注意py2neo的Node构造函数的第二个参数是要 merge 的属性名我传了name确保重复执行时不会生成重复节点。4.4 可视化方案Neo4j Browser、ECharts 关系图与前端对接Neo4j Browser 自带可视化适合自己调试。它的一个常见坑是默认只显示 25 个标签全网搜“知识图谱只显示25个标签”指的就是这个设置。解决办法是在 Browser 底部的命令行输入:config maxRows: 1000这个配置只影响当前浏览器回话刷新页面后可能失效。如果想让结果里的节点全显示还要在查询语句里去掉LIMIT 25并在 Browser 设置里关闭Auto-complete限制。这些只是展示层的限制不影响图数据本身。给用户看的可视化页面我用 ECharts 关系图比较多因为它能直出普通 HTML不需要额外后端服务。前端通过neo4j-driver查询数据转成 ECharts 需要的nodes和links数组const neo4j require(neo4j-driver); const driver neo4j.driver( bolt://localhost:7687, neo4j.auth.basic(neo4j, your_password) ); async function fetchGraph() { const session driver.session(); try { const result await session.run( MATCH (c:Crop)-[r:产于]-(reg:Region) RETURN c.name AS source, reg.full_name AS target LIMIT 100 ); const nodes []; const links []; const map new Map(); result.records.forEach((record) { const s record.get(source); const t record.get(target); if (!map.has(s)) { map.set(s, { id: s, category: Crop }); } if (!map.has(t)) { map.set(t, { id: t, category: Region }); } links.push({ source: s, target: t }); }); return { nodes: [...map.values()], links: links }; } finally { session.close(); } }LIMIT 100是防止前端渲染卡死。农业图谱数据量不大但 ECharts 在画大量连线时性能会指数下降。如果你要展示全图推荐点击某个作物节点后再展开它的邻居而不是一次性拉全量。前端这块还有一个“可视化大屏适配”的问题关系图在 16:9 大屏上通常没问题手机端需要把 ECharts 的roam打开否则节点挤在一起无法查看。5. 项目落地时的验证技巧与文档组织拿到一个做好的农业知识图谱项目先别急着看可视化面板。用几句 Cypher 做完整性验证比看界面更可靠。// 统计各类节点数量 MATCH (n) RETURN labels(n)[0] AS label, count(*) AS count ORDER BY count DESC; // 查找没有任何关系的孤立节点 MATCH (c:Crop) WHERE NOT (c)--() RETURN c.name LIMIT 20; // 验证同一作物是否被重复创建 MATCH (c:Crop) WITH c.name AS name, count(*) AS cnt WHERE cnt 1 RETURN name, cnt;第一句能看出图谱整体规模有没有异常比如 Crop 数量比自己导入的 CSV 行数还多说明清洗时没有去重。第二句找孤立节点如果 Crop 节点没有关系大多是实体对齐阶段名字没统一导致MERGE时没有匹配到已有节点。第三句专门查重复知识图谱项目最隐蔽的问题是“看着没重复实际两个节点属性只有一字之差”。把c.name改成小写再去重能发现更多问题。文档组织方面一个让评委或同事 20 分钟上手的项目包应该包含四份文档README.md写运行步骤data_dictionary.md写 CSV 字段含义ontology.md写实体和关系定义troubleshooting.md写装错 Neo4j 版本、中文乱码、端口占用等常见坑。配套代码目录按crawler/、clean/、import/、web/划分每个目录里必须有requirements.txt或package.json不要只放源码文件。最后分享一个本地验证技巧当你改了实体对齐规则后选一个代表性作物如“马铃薯”执行MATCH path (c:Crop {name:马铃薯})-[*1..3]-() RETURN path LIMIT 50看它周围的关系是否符合预期。这一步会把爬虫、清洗、本体设计、导入所有环节串起来一次性检验。本文还有配套的精品资源点击获取