☰
用Python构建中华美食知识图谱:从本体设计到Neo4j实践
2026/10/3 10:33:50 网站建设 项目流程

简介:中华美食知识图谱构建与应用系统是一份面向知识图谱学习者和Python开发者的完整实践项目,围绕菜谱领域展示从实体识别、关系抽取到知识存储与检索的应用链路,适合希望掌握知识图谱构建流程、KBQA问答和可视化展示的读者参考。资源共91个文件,压缩包约1.03MB,主要包含7个Python脚本、7个JSON数据文件、1个NT三元组文件、4个Markdown说明文档,以及大量jpg/png菜谱图片和html可视化页面;zbak备份文件可辅助理解开发迭代过程。已有46人学习下载。通过该资源可了解中式菜谱知识图谱的数据组织方式、实体对齐与SPARQL问答实现思路,并结合可视化图表观察知识关联,对入门知识图谱项目设计具有实际参考价值。

1. 中华美食知识图谱构建:从“菜单查询”到“语义问答”

做这个中华美食知识图谱构建系统的起因,是一次点菜需求:不吃猪肉的朋友想在川菜里找能吃的菜。正常菜谱平台只能按菜名搜,翻了很多页都没法回答这种带排除条件的查询。知识图谱构建的思路恰恰是反过来——把菜品拆成实体和关系,让计算机先理解「宫保鸡丁 — 所属菜系 — 川菜」「宫保鸡丁 — 使用食材 — 花生米」,再通过图查询一条路径解决。这套系统完全用 Python 实现,涵盖本体建模、数据清洗、关系抽取、Neo4j 存储到最终查询应用。适合想用 Python 落地知识图谱但不想停留在 demo 的开发者,也适合做菜谱类产品的后端工程师把搜索能力升级一档。

2. 本体设计先行:六层概念与七条关系怎么落到代码

本体建模听起来玄学,但本质只是把领域知识变成计算机能处理的约定。我第一次做美食图谱时习惯一上来就写爬虫,结果抓了一堆菜谱发现不知道存成什么样,返工两次才明白:图谱项目的第一个动作不是写代码,而是拿白纸把概念层画清楚。这一层画歪了,后面所有数据清洗和入库逻辑都会跟着歪。

2.1 动手前先回答三个问题

第一个问题是领域边界。美食图谱可以做得很大,菜品、食材、调味料、菜系、技法、口味、营养、地域乃至历史掌故都能放进来。但如果数据源主要是菜谱网站的结构化字段,最多只能支撑六层:菜品、食材、调味料、菜系、技法、口味。边界定得越宽,后续数据补全成本越高。我最终只保留这六层,把“营养”“历史”这类信息先塞进节点的描述属性里,不单独建类。

第二个问题是关系粒度。比如“宫保鸡丁用花生米”,关系到底写“使用食材”还是细分成“主料/辅料/调料”?细分的好处是查询更精确,坏处是标注和清洗成本成倍上升。我的方案是:食材归入 Ingredient,调味料单独拆成 Seasoning,技法归入 Technique,关系只保留七条主链路。这样既不丢关键语义,又保证人工维护词表在可控范围内。

第三个问题是概念冲突。同一个词在不同场景下身份不同,比如“花椒”既是食材又是调味料,“藤椒”和“花椒”能不能合并成同一个节点。这些问题不在前期想清楚,后面做实体对齐时得反复改逻辑。我的处理方式是给每个实体分配一个主类型,跨类型的使用放在关系里表达,而不是为每个歧义词单独建类。

2.2 用 rdflib 把类层次定义写进代码

本体设计最终要落成机器可读的定义,一方面团队对齐,另一方面后续的数据校验可以直接拿这份定义做检查。这里用 rdflib 把六层类和注释写进一个内存图:

from rdflib import Graph, Namespace, RDF, RDFS, Literal FOOD = Namespace("http://example.com/food#") g = Graph() classes = { "Dish": "菜品,一道完整的菜,如宫保鸡丁、麻婆豆腐", "Ingredient": "食材,如鸡胸肉、花生米、青椒", "Seasoning": "调味料,如酱油、白糖、花椒油", "Cuisine": "菜系,如川菜、粤菜、鲁菜", "Technique": "烹饪技法,如炒、蒸、炖、煎、炸", "Flavor": "口味标签,如麻辣、酸甜、咸鲜", } for cls_name, desc in classes.items(): cls_uri = FOOD[cls_name] g.add((cls_uri, RDF.type, RDFS.Class)) g.add((cls_uri, RDFS.comment, Literal(desc, lang="zh"))) print(f"definition triples: {len(g)}")

这段代码不需要引入推理机,它的意义是把概念层固定下来。后面无论写爬虫映射、做实体抽取还是入库,都按这套类名走。实际项目中我还会把这份本体导出成 JSON 放一份在仓库里,前端同学直接看结构就能理解数据长什么样。

七条关系是这么定的:菜品和食材之间是uses_ingredient,菜品和调味料之间是uses_seasoning,菜品和菜系之间是belongs_to_cuisine,菜品和技法之间是adopts_technique,菜品和口味之间是has_flavor,菜品之间用related_dish记录相似菜,每个实体自己保留has_alias表达别名。开始不要贪多,这七条已经能覆盖绝大多数菜谱场景。

2.3 数据属性设计:给节点留出扩展空间

除了对象关系,每类节点还需要数据属性支撑展示。我给 Dish 设置了 difficulty、cooking_time、description 三个字段,给 Ingredient 设置了 alias 和 substitute 字段。下面是落地的属性清单:

类数据属性说明
Dishname / difficulty / cooking_time / description名称和展示信息
Ingredientname / alias / substitute别名与后续替代推荐
Cuisinename / region菜系与所属区域
Techniquename / description技法简单解释

这些属性不必一次填满,尤其是substitute这类和推荐算法相关的字段,可以先留空,等图谱数据稳定后再补。我把这一阶段属性设计当接口约定而不是最终 schema,数据多了自然会调。写完本体后建议拿一两道菜做“纸上走查”:手写宫保鸡丁的完整链路——Dish 宫保鸡丁 -> uses_ingredient 鸡胸肉/花生米/干辣椒,uses_seasoning 生抽/醋/白糖,adopts_technique 炒,has_flavor 咸鲜/微辣。这步能提前发现关系缺失,比写完代码再返工划算得多。从那以后我每次做知识图谱,第一版本体都只用一天定稿,第二版开始用真实数据反推修改,几乎不再被字段打回。

3. 数据管道:从菜谱清洗到三元组抽取的 Python 实现

本体定了,下一步就是把半结构化的菜谱数据变成三元组。我的数据来源是公开食谱数据集的 JSON 字段,加上一部分手工整理的菜系菜名清单。这个阶段最常见的问题是脏数据:同一家菜馆在不同页面里的“白糖”和“白砂糖”混着出现,“鸡胸肉”有时候写成“鸡脯肉”。这些不处理,图谱建成之后查询结果会莫名其妙少一半。

3.1 数据源与清洗逻辑

原始数据大概长这样——每道菜是一个字典,包含名称、所属菜系、食材列表、调味料、做法步骤和时间字段。清洗时我通常分三步:去重、字段归一、格式校验。先按菜名去重,再对食材和调味料做单位与别名归一,最后丢弃缺少关键字段的样本。

import json from collections import defaultdict def load_raw(path: str) -> list[dict]: with open(path, encoding="utf-8") as f: return json.load(f) def clean_dish(item: dict) -> dict | None: name = item.get("name", "").strip() cuisine = item.get("cuisine", "").strip() ingredients = [x.strip() for x in item.get("ingredients", []) if x.strip()] seasonings = [x.strip() for x in item.get("seasonings", []) if x.strip()] if not name or not ingredients: return None return { "name": name, "cuisine": cuisine, "ingredients": ingredients, "seasonings": seasonings, "steps": item.get("steps", []), "time": item.get("time", ""), } raw_items = load_raw("recipes.json") cleaned = [d for item in raw_items if (d := clean_dish(item))] print(f"raw={len(raw_items)} cleaned={len(cleaned)}")

清洗要控制两个参数:最少食材数量阈值和字段是否允许为空。我一般要求菜品至少有 3 个食材,没有菜系字段的可以归到“未知菜系”临时节点,而不是直接丢弃。这样后续查询时能看出数据缺口在哪儿,而不是黑盒地少了一堆菜。

3.2 实体抽取:词典加规则,而不是先上深度学习

实体识别这里,我先说结论:不要一上来就上 BERT NER。食材和调味料在美食领域基本是封闭词表,词典匹配足够,而且结果可控、出错好查。深度学习模型对“藤椒鱼”这类组合词的识别可能不稳定,还需要标注数据,在这个一万道菜规模的项目里性价比很低。

我用 jieba 加载自定义词典,每个词的词频和词性通过load_userdict读入。词典文件每行格式是“词 词频 词性”,比如“白砂糖 100 n”。

import jieba import jieba.posseg as pseg jieba.load_userdict("food_dict.txt") INGREDIENTS = load_words("ingredients.txt") # 与本体 Ingredient 类对应的词表 SEASONINGS = load_words("seasonings.txt") # 与本体 Seasoning 类对应的词表 TECHNIQUES = {"炒", "蒸", "炖", "煎", "炸", "凉拌", "煮", "烤"} def extract_entities(text: str) -> dict: entities = {"ingredient": set(), "seasoning": set(), "technique": set()} words = pseg.lcut(text) for word, flag in words: if word in INGREDIENTS: entities["ingredient"].add(word) elif word in SEASONINGS: entities["seasoning"].add(word) if flag == "v" and word in TECHNIQUES: entities["technique"].add(word) return {k: sorted(v) for k, v in entities.items()}

参数说明:load_userdict加载的词典文件优先级高于 jieba 默认词典,能解决“白砂糖”被拆成“白砂糖”和“糖”的问题。词频参数建议不低于 50,太低会让错误分词占据主导。词性过滤用的是flag == "v",这能排除“炒”出现在“炒作”这类语境里被误识别的情况,准确率提升不少。

3.3 关系抽取与实体对齐

实体抽取完,接下来要把清洗后的数据组装成三元组列表。对菜谱场景,关系基本是显式的:菜品直接连接它字段里的食材和调味料,菜系从字段映射,技法从做法步骤里抽取。

def build_triples(dish: dict) -> list[tuple[str, str, str]]: triples = [] d_node = f"Dish:{dish['name']}" if dish["cuisine"]: triples.append((d_node, "belongs_to_cuisine", f"Cuisine:{dish['cuisine']}")) for ing in dish["ingredients"]: triples.append((d_node, "uses_ingredient", f"Ingredient:{normalize_ingredient(ing)}")) for seas in dish["seasonings"]: triples.append((d_node, "uses_seasoning", f"Seasoning:{normalize_ingredient(seas)}")) return triples

normalize_ingredient是一个统一的归一化入口,内部维护一份同义词表:白砂糖→白糖、鸡脯肉→鸡胸肉、葱段→葱。对齐规则我是怎么写的呢?先用精确匹配查表,查不到再用最长公共子串判断,最后人工审核兜底。要特别注意只做整词替换,不要用str.replace直接换字符串——那会把“白砂糖”替换成“白糖”后,“糖醋里脊”里的“糖”也会被误伤。这条坑我在第 5 章单独展开。

4. 入库 Neo4j:批量写入、唯一性约束和索引设计

三元组准备好了,接下来就是“neo4j 构建知识图谱”的核心环节。为什么选 Neo4j 而不是 MySQL?因为美食场景的查询天然是多跳:想找“和宫保鸡丁用了至少三种相同食材的川菜”,SQL 里要做多次 join 再加集合运算,语句绕到没法维护,Cypher 两行就能写出来。这个差异在数据量破万之后会非常明显。

4.1 部署与连接配置

本地开发我直接用 Docker 跑 Neo4j 社区版,省掉本机安装的麻烦。映射 7474 和 7687 两个端口,设置好认证账号就够用。生产环境建议把数据目录挂到宿主机,这一点不加的话容器重建后数据全丢,属于最痛的教训之一。

连接配置写在config.py里:

NEO4J_URI = "bolt://localhost:7687" NEO4J_USER = "neo4j" NEO4J_PASSWORD = "your_password"

从 py2neo 连接时,连接池默认大小就够用,不需要特意调。如果后面并发大了,再考虑每批写入用独立事务。

4.2 批量写入:MERGE 加唯一约束

写入图谱最忌讳用CREATE一路怼,跑完一遍重复节点遍地开花。正确姿势是先给每个标签的name字段建唯一约束,再用MERGE写入。唯一约束本身会隐式创建索引,所以不需要额外再建普通索引。

from py2neo import Graph graph = Graph(NEO4J_URI, auth=(NEO4J_USER, NEO4J_PASSWORD)) def create_constraints(): for label in ["Dish", "Ingredient", "Seasoning", "Cuisine", "Technique", "Flavor"]: graph.run( f"CREATE CONSTRAINT {label.lower()}_unique IF NOT EXISTS " f"FOR (n:{label}) REQUIRE n.name IS UNIQUE" ) def upsert_relationship(graph, src_label, src_name, rel_type, dst_label, dst_name): graph.run( f"MATCH (a:{src_label} {{name:$src_name}}), " f"(b:{dst_label} {{name:$dst_name}}) " f"MERGE (a)-[:{rel_type}]->(b)", src_name=src_name, dst_name=dst_name, ) def batch_import(graph, triples, batch_size=500): for i in range(0, len(triples), batch_size): batch = triples[i:i + batch_size] for head, rel, tail in batch: src_label, src_name = head.split(":", 1) dst_label, dst_name = tail.split(":", 1) upsert_relationship(graph, src_label, src_name, rel, dst_label, dst_name)

为什么用MATCH+MERGE而不是MERGE一条语句搞定?因为MERGE (a:Entity {name:$name})当实体数量多时,逐个写入会反复扫描已有节点。先MATCH定位已有节点再MERGE关系,能明确控制写入的实体标签,避免全都挤进一个泛化的Entity类型里。batch_size我一般定在 200~500,太大容易把事务内存打爆,太小写入速度慢。这个参数在不同配置的机器上差异很大,建议先拿 1000 条数据试跑,观察 Neo4j 的内存占用曲线再定。

4.3 图谱质量校验:计数、孤立节点、抽样

写完别急着查,先跑一遍校验脚本。我每次入库后固定跑三个检查:节点和关系总量是否符合预期、是否存在没有任何关系的孤立节点、随机抽 20 道菜验证它们的出边度数。

# 检查节点与关系总量 cypher-shell -u neo4j -p your_password "MATCH (n) RETURN count(n)" cypher-shell -u neo4j -p your_password "MATCH ()-[r]->() RETURN count(r)" # 找到没有关系的孤立节点 cypher-shell -u neo4j -p your_password \ "MATCH (n) WHERE NOT (n)--() RETURN n.name LIMIT 20"

孤立节点的出现通常意味着清洗阶段漏了某类关系。比如调味料只有uses_seasoning关系,但有一批菜在爬取时没抓到调味料字段,这些菜就成了只有食材、没有调味料的半孤立节点。这不是硬错误,但查询“酸甜口的菜”时会发现结果少得异常,回到清洗阶段补数据才能解决。

5. 避坑指南:知识图谱构建过程中的四个翻车现场

这个项目我从本体到入库踩了不少坑,把印象最深的四个写在这里。每一条都是“现象 → 原因 → 解决”的结构,很多坑不是一次踩完,是反复踩了三四轮才总结出规律。

5.1 本体膨胀:六层概念变成六十层的代价

现象:刚开始做本体时,把营养元素、地域、历史典故全建成了类,类层次三层起步。结果数据清洗跑完,一半类下面没有任何实例,图谱里全是空壳节点,查询时还得反复判空。

原因:没有控制领域边界,把“未来可能用到的语义”全部装进第一版本体。数据和本体不匹配,本体就成了空中楼阁。

解决:砍到六层,每层必须有真实数据支撑。营养元素和历史典故放进 Dish 的 description 属性里,字符串存着不丢,等以后有数据了再拆类。这个“先窄后宽”的思路后来被我写进项目注释里:任何没有数据落地的类,都不允许进入本体文件。

5.2 用 str.replace 做实体对齐,污染了节点名称

现象:做同义词合并时,我图省事直接对文本做str.replace("白糖", "糖"),结果“白砂糖”整词被替换成“糖”,但“糖醋里脊”里的“糖”也被错误替换了一次,导致“白糖”和“糖”两个节点同时存在,查询结果翻倍。

原因:str.replace是子串替换,不看词边界。食材词表里有大量包含关系的词:“姜”和“生姜”,“葱”和“葱段”,直接替换必然互相污染。

解决:先用 jieba 分词,再查同义词表,替换后校验节点名与词表完全匹配。现在写对齐脚本时,我强制要求每条规则都必须是整词匹配,并且替换后跑一次节点去重检查。那次翻车之后,我再也没有在任何数据清洗代码里用过裸的str.replace做词汇归一。

5.3 MERGE 没加唯一约束,重复节点翻倍

现象:第一批数据入库后,MATCH (n:Dish)返回了预期两倍的节点数。查了一下,同一道菜因为名称里带了空格和没带空格两个变体,各创建了一次。

原因:写入时只写了MERGE,没建唯一约束。MERGE 的语义是“不存在则创建”,但“不存在”的判定依赖你给的条件。名称不一致时它认为这是两个不同节点,跟是否加唯一约束无关。

解决:在建库阶段先执行CREATE CONSTRAINT ... REQUIRE n.name IS UNIQUE再加数据。唯一约束不仅防止重复,还能让后续MATCH走索引,查询速度也上来了。这个坑最诡异的地方在于:它不报错,一切都是静默发生的,等你发现时数据已经脏了。

5.4 大批量事务把 Neo4j 内存打爆

现象:第一次导入一万条三元组时,我用单条事务把所有数据一次性写入,结果 Neo4j 直接报事务内存不足,导入中断。重跑时因为是MERGE,没报重复但也浪费了大量时间。

原因:单事务写入量超出 Neo4j 内存配置。默认内存参数针对小规模读写,大批量写入时事务状态全部驻留内存,很容易触顶。

解决:把批量写入改成每 300~500 条三元组一个事务,事务结束显式commit。这个参数和 JVM 堆大小、机器内存都有关系,稳妥做法是用二分法找到当前机器的最优批大小。我在这台 8G 内存的机器上,500 是临界点,600 偶尔会失败。

6. 进阶落地:把图谱查询变成可复用的知识服务

图谱建完只是半边,真正让系统产生价值的是查询层。这里分享三组我实际在用的查询范式和一套验证习惯。

第一组是条件过滤查询。比如回答最初那个“川菜里不用猪肉的菜”:

MATCH (d:Dish)-[:belongs_to_cuisine]->(c:Cuisine {name:"川菜"}) WHERE NOT (d)-[:uses_ingredient]->(:Ingredient {name:"猪肉"}) RETURN d.name LIMIT 20

第二组是替代食材的启发式推荐。逻辑很朴素:同一道菜里出现过的食材,在替换时更可能被接受。查“和鸡胸肉共同出现过最多食材的肉类”:

MATCH (a:Ingredient {name:"鸡胸肉"})<-[:uses_ingredient]-(d:Dish), (d)-[:uses_ingredient]->(b:Ingredient) WHERE b.name <> "鸡胸肉" RETURN b.name, count(DISTINCT d) AS co_dish ORDER BY co_dish DESC LIMIT 5

第三组是用最短路径找出两个看似无关食材之间的连接。比如“豆瓣酱”和“花生”通过哪些菜关联:

MATCH p = shortestPath((a:Ingredient {name:"豆瓣酱"})-[*..5]-(b:Ingredient {name:"花生"})) RETURN p

这套查询跑通之后,我的验证习惯是准备一份“回归清单”:包含 10 道常见菜的预期关系数、5 条必须成立的查询、3 个已知会出错的边界条件。每次修改本体或数据管道,就重新跑一遍清单,确认没有破坏已有能力。图谱项目最怕的不是查询慢,而是某次调整后查询结果悄悄变化,没人发现。

回头来看,这个项目真正让我建立信心的不是 Neo4j 用得多熟,而是“先窄后宽、每步校验”的做事顺序。从那以后我每次做知识图谱,都会强制自己走完三件事:本体先定边界、数据清洗整词操作、入库前加唯一约束。这三点守住了,后面再怎么扩展都不会出大乱子。希望帮到你。

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

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

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

立即咨询