ARTICLE DETAIL

资讯详情

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

用Python构建中华美食知识图谱:从本体设计到Neo4j实践

用Python构建中华美食知识图谱:从本体设计到Neo4j实践 简介中华美食知识图谱构建与应用系统是一份面向知识图谱学习者和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, langzh))) print(fdefinition 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, encodingutf-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(fraw{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 fDish:{dish[name]} if dish[cuisine]: triples.append((d_node, belongs_to_cuisine, fCuisine:{dish[cuisine]})) for ing in dish[ingredients]: triples.append((d_node, uses_ingredient, fIngredient:{normalize_ingredient(ing)})) for seas in dish[seasonings]: triples.append((d_node, uses_seasoning, fSeasoning:{normalize_ingredient(seas)})) return triplesnormalize_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( fCREATE CONSTRAINT {label.lower()}_unique IF NOT EXISTS fFOR (n:{label}) REQUIRE n.name IS UNIQUE ) def upsert_relationship(graph, src_label, src_name, rel_type, dst_label, dst_name): graph.run( fMATCH (a:{src_label} {{name:$src_name}}), f(b:{dst_label} {{name:$dst_name}}) fMERGE (a)-[:{rel_type}]-(b), src_namesrc_name, dst_namedst_name, ) def batch_import(graph, triples, batch_size500): 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)为什么用MATCHMERGE而不是MERGE一条语句搞定因为MERGE (a:Entity {name:$name})当实体数量多时逐个写入会反复扫描已有节点。先MATCH定位已有节点再MERGE关系能明确控制写入的实体标签避免全都挤进一个泛化的Entity类型里。batch_size我一般定在 200500太大容易把事务内存打爆太小写入速度慢。这个参数在不同配置的机器上差异很大建议先拿 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 内存配置。默认内存参数针对小规模读写大批量写入时事务状态全部驻留内存很容易触顶。解决把批量写入改成每 300500 条三元组一个事务事务结束显式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 用得多熟而是“先窄后宽、每步校验”的做事顺序。从那以后我每次做知识图谱都会强制自己走完三件事本体先定边界、数据清洗整词操作、入库前加唯一约束。这三点守住了后面再怎么扩展都不会出大乱子。希望帮到你。本文还有配套的精品资源点击获取
返回列表