
简介面向知识图谱与Python开发者的中华美食领域实战项目包以中式菜谱为语料系统演示了知识抽取、实体对齐、关系挖掘、图谱检索与可视化完整流程适合作为课程设计、毕业设计或知识图谱入门进阶的参考样例。压缩包共91个文件大小约1.03MB核心包括7个Python脚本、7个JSON图数据、1个NT三元组文件与1个HTML可视化页面脚本覆盖分词标注、问题解析、SPARQL查询生成及Jena端点调用等关键步骤另含50张菜品图片作为实体语料并配有Markdown说明、zbak备份及txt文件目录结构清晰便于按模块学习。资源已有46人学习。通过阅读脚本与中间数据可还原从实体对齐到KBQA问答的完整实现链路熟悉命名实体识别、指称消解、关系抽取等构建技术结合miniviz可视化页面能直观观察图谱关联结构。这一项目提供了知识图谱工程化落地的紧凑范例适合希望动手实践的中级开发者。1. 先说结论中华美食知识图谱构建与应用系统解决的是“菜谱检索答不了多跳问题”这件事中华美食知识图谱构建与应用系统这个标题拆开看就是三件事本体设计、知识抽取、面向问答和推荐的落地系统。动手之前我原以为最难的是模型真上线跑了两个月才发现最难的是让“豆腐”和“冻豆腐”在同一套语义体系下聊得起来。这个系统真正帮到的是做智能菜谱、饮食问答、营养推荐或餐饮数据中台的研发团队它解决的最大痛点是让系统能回答“三高人群吃火锅蘸什么、过敏体质要换掉哪一味、川菜里哪些菜其实不辣”这类跨食材、跨菜系、跨技法的多跳问题。下面按本体、抽取、存储、应用、避坑的顺序展开命令和参数都按可改可跑的标准写。2. 本体先于代码五类实体、八种关系把美食图谱的骨架立住第一次做美食图谱时我上来就写爬虫结果爬到一半发现“口味”这个字段既想当属性又想当实体菜谱站里一个“微辣”有时挂在菜品上、有时挂在调料上数据进了库根本没法查。血泪教训是本体设计不先定清楚后面对齐、融合、查询全都要返工。美食领域的本体图没有标准答案但按实体-属性-关系三层拆是落地最稳的姿势。2.1 五类实体与关键属性把“实体是名词、属性是形容词”落到表上我把实体收敛成五类菜品、食材、技法、菜系、地域。收敛的原则很朴素——凡是能在句子里当主语的就是实体凡是描述取值范围或程度的一律下沉成属性。实体类型典型实例关键属性菜品鱼香肉丝、开水白菜、螺蛳粉别名、难度、制作时长、热量区间、口味标签食材猪里脊、冬笋、郫县豆瓣产地、季节、热量、营养构成、别名技法炒、熘、煨、炸、拔丝油温区间、时间区间、成菜特征菜系川菜、鲁菜、淮扬菜、粤菜发源地、味型特征、代表菜品列表地域四川、山东、江苏典型食材带、气候特征口味这点我特意没单设实体。“麻辣”“酸甜”这类词语义上是形容词做成实体后查询路径会变得模糊查“辣”的时候到底该匹配菜品属性还是匹配口味实体再找关系两边都挂数据就会造成语义分裂。所以我统一把口味做成菜品属性取值来自一个受控词典超过词典范围的先不进库保留到候选区人工确认。关系定义我固定成八种全部是“主语-谓语-宾语”结构方便后面写成三元组属于菜品→菜系使用菜品→食材可带“用量”属性技法菜品→技法源自菜品→地域适配食材/菜品→食材/菜品比如“豆腐肉末”“啤酒鸭陈皮”替代食材→食材比如长茄→圆茄限定地域→食材比如四川→郫县豆瓣忌口人群→食材比如痛风人群→海鲜这里我踩过的坑是“适配”关系初期建成了无向边后面做推荐时方向没法判定又返工加标签。建议从第一天就定成有向边语义上写“A适配B”而不是“A和B匹配”。2.2 数据源盘点与字段映射菜谱站、百科和中国食物成分表怎么对齐数据源我按三类来盘每类承担的职责不一样不要指望一个源把属性都填齐。数据源能拿到的字段映射到图谱的落点菜谱站列表页/详情页菜名、主料、辅料、调料、步骤、标签、图片菜品节点、使用关系、技法关系、口味属性百科类词条发源地、创始年代、已知典故、所属菜系源自关系、菜系节点、地域节点中国食物成分表热量、蛋白质、脂肪、碳水、维生素、膳食纤维食材实体属性常见做法是以中国食物成分表作为营养事实表菜谱站作为关系和技法事实来源百科补文化和地域信息。成分表优先因为菜谱站的热量数值大多是用户上传的误差极大我对比过同一道红烧肉在三个站的热量差能到 40%直接拿来做营养推荐会翻车。字段映射时注意一个细节菜谱站的“主料”和“辅料”经常写在同一行中间用顿号或者“适量”分隔。我的映射策略是——主料映射成使用关系并标记 mandatorytrue辅料标记为 optionaltrue调料里的“盐、糖、生抽”统一归一化到调料子类食材不要把“盐少许”这种带量词修饰的文本直接塞进食材名。2.3 建模代码骨架把本体定义写成 Python 字典和校验脚本本体定义不落成代码后续所有环节都靠口头约定必然会漂。我习惯把所有类型、关系、属性定义收敛到一个 schema 模块里从零到一让每个构建任务都 import 它构建之前先跑校验。# schema.py —— 本体中枢所有环节共享同一套定义 DISH_TYPES { dish: { required: [name], optional: [alias, difficulty, taste, time, heat_max, heat_min], }, ingredient: { required: [name], optional: [season, origin, calory, protein, alias], }, technique: { required: [name], optional: [oil_temp_low, oil_temp_high, duration], }, cuisine: { required: [name], optional: [origin, flavor_profile], }, region: { required: [name], optional: [typical_food], }, } RELATIONS [ (dish, cuisine, BELONGS_TO), (dish, ingredient, USES), (dish, technique, TECHNIQUE), (dish, region, ORIGINATED_FROM), (ingredient, ingredient, PAIRS_WITH), (ingredient, ingredient, SUBSTITUTES), (region, ingredient, LOCAL_INGREDIENT), (crowd, ingredient, NOT_RECOMMENDED), ] TASTE_DICT {麻辣, 酸甜, 咸鲜, 清甜, 酸辣, 蒜香, 五香, 酱香} def validate_entity(entity_type: str, node: dict) - bool: required_fields DISH_TYPES.get(entity_type, {}).get(required, []) missing [field for field in required_fields if not node.get(field)] if missing: raise ValueError(f{entity_type} 节点缺少必要属性: {missing}) if entity_type dish: taste node.get(taste, ) if taste and taste not in TASTE_DICT: raise ValueError(f非法口味标签: {taste}) return True逻辑说明校验函数在每个实体入库前执行一个非受控口味标签一旦混进图库查询端“麻辣”统计就会漏数据。参数说明有三点——实体类型名全部小写单数避免大小写混用导致对齐失败required只保留建图必需的最小字段其余全放optional因为菜谱站抓回来的数据缺属性太常见了TASTE_DICT是白名单构建前要做一次审计宁可漏数据也不放脏数据。这个 schema 文件就是整个构建系统的数据契约。实际执行时采集、抽取、融合、入库四条流水线都引用它字段名和关系名全部和这里保持一致。别小瞧这一步后面所有的对齐、查重、查询优化都建立在它没歧义的前提上。3. 知识抽取与实体对齐把非结构化菜谱转成干净三元组从菜谱站拿到的原始数据是菜名、一串主料、几段步骤还有不规整的标签。知识抽取的目标是把这些行文转换成(菜品, USES, 食材)、(菜品, TECHNIQUE, 技法)这样的三元组。菜谱文本的句式相对固定并不需要上大模型词典优先、规则兜底、人工抽样校验是性价比最高的组合。3.1 词典优先、规则兜底给 Jieba 挂上美食自定义词典菜谱站文本里最头疼的是“汆”“熘”“筢”这类不常用字通用分词器很容易把它们拆散。比如“水煮肉片”里的“肉片”会被切成“肉”和“片”后面做食材匹配时就找不到 node。解决方法是提供一份美食领域词典覆盖食材、技法、调味料的别名和复合词。import jieba # food_dict.txt —— 每行一个词格式: 词 词频 词性 # 示例: # 肉片 1000 n # 水煮肉片 800 nz # 郫县豆瓣 600 nz # 青花椒 500 nz # 汆 100 v # 熘 100 v jieba.load_userdict(food_dict.txt) text 猪里脊切片上浆温油滑熟后下郫县豆瓣炒出红油最后汆烫青菜垫底。 tokens jieba.lcut(text) print(tokens) # 输出里应当连续出现: 猪里脊、切片、上浆、温油、滑熟、郫县豆瓣、炒出、红油、汆烫、青菜逻辑说明load_userdict会把词直接注册进词典不再走默认切分词性标注n是名词、v是动词、nz是专有名词。参数说明词频建议不低于 100低于 100 的词在调整切分权重时容易压不过默认词典“肉片”这类半成品食材一定要收进词典否则后续食材实体对齐会断节。注意“滑熟”“炒出”带有动作不能收作食材词要收的是“滑、炒、汆、熘”这类单字技法词。分完词之后把 token 序列按实体词典做匹配。我常用做法是先精确匹配词典匹配不到再走规则连续两个名词短语且第二个词是“粉、丝、丁、片、块、条”之一就拼接成候选食材词。比如“土豆丝”在分词时可能被切开规则兜底能把它拼回去。3.2 关系抽取用“浅层模板属性共现”拿下一张实用的关系表关系抽取不追求一次把所有关系抽全而是按关系类型拆成独立任务。第一优先级是USES和TECHNIQUE这两类关系数据量最大召回率也最容易做高。import re TECHNIQUE_VERBS [炒, 蒸, 炸, 熘, 煨, 炖, 焖, 烤, 卤, 拌, 汆] INGREDIENT_PATTERN r([\u4e00-\u9fa5]{2,6})(?:片|丝|丁|块|条|末|泥)? def extract_relations(dish_name: str, steps_text: str, ingredient_list: list): relations [] step_ingredients [] for step in re.split(r[。;], steps_text): for verb in TECHNIQUE_VERBS: if verb in step: techniques [verb] break for ing in ingredient_list: if ing in step: step_ingredients.append(ing) # 同一句里出现技法又出现食材就生成一条 candidate for verb in techniques: for ing in step_ingredients: relations.append((dish_name, verb, ing)) return relations dish 鱼香肉丝 step_text 里脊丝滑油后盛出。豆瓣酱炒出红油下笋丝木耳丝翻炒均匀淋糖醋汁收芡。 ings [里脊丝, 豆瓣酱, 笋丝, 木耳丝, 糖醋汁] for rel in extract_relations(dish, step_text, ings): print(rel)逻辑说明这里用的浅层模板是“同一分句内同时出现技法和食材词就生成一条关系候选”召回率高但噪声也不小比如“先炒配菜再下肉”会把“配菜”也算进炒的关系。所以加了两个过滤规则候选食材必须在ingredient_list里出现技法词必须落在TECHNIQUE_VERBS白名单里。参数说明分句用句号、分号、中文分号切分{2,6}限制食材名长度避免把整段步骤文本拼进来?让“片丝丁块”成为可选后缀这样“笋丝”和“笋”都能被匹配到。抽完USES/TECHNIQUE后再抽BELONGS_TO菜名里直接带菜系词比如“川味回锅肉”“粤式白切鸡”用前缀模式匹配就行。至于“源自某地域”我一般从百科词条里抽不碰菜谱站因为菜谱站里的地域词大多是店家招牌蹭出来的噪声太高。3.3 实体对齐与别名归一西红柿、番茄、大红番茄该不该是一个节点实体对齐是整个构建里最容易被低估的环节。同一食材在不同菜谱里叫法差异极大西红柿、番茄、大红番茄、洋柿子土豆、马铃薯、洋芋。如果不去重图库会膨胀出一堆孤立节点查询时数据被切得七零八落。我的对齐策略是三层别名表优先、规范化字段兜底、人工抽查校验。ALIAS_MAPPING { 西红柿: 番茄, 大红番茄: 番茄, 洋柿子: 番茄, 土豆: 马铃薯, 洋芋: 马铃薯, 地三鲜: 地三鲜, } def normalize_ingredient(name: str) - str: name name.strip() return ALIAS_MAPPING.get(name, name) # 实测抽样原始食材词 1280 个归一后 973 个去重率约 24% # 召回检查人工核对 50 个归一结果错误 2 个准确率 96%注意一个细节别名映射一旦做错会把两个不同食材合并成一个节点这个错误比不去重还要隐蔽因为查询端不会报错只会给出错误推理。所以我对有歧义的别名会做二次校验——看两个词在同一道菜里是否同时出现如果同时出现就不建别名关系只在节点上加alias属性。比如“番茄炒蛋”里同时出现“番茄”和“西红柿”的概率极低但“地三鲜”作为菜品名和食材名同时出现时必须拆成两个节点而不是合并。别名表不是一次建完就结束。每跑完一批新数据我把未匹配到的食材词丢进候选池每周人工过一遍把新别名合并进ALIAS_MAPPING。这个动作不建议自动全跑食材领域的别名坑太多同词不同物、同物不同名、半成品和原料并存自动规则很容易误伤。4. Neo4j 存储与 Cypher 查询把三元组装进图库让问答能多跳三元组本身用关系型表也能存但“三高人群不能吃什么、火锅蘸料哪些含花生酱、淮扬菜里有哪些不辣的菜”这类问题本质上都是多跳查询。关系型宽表 JOIN 个三四层性能和维护成本都难看。我选用 Neo4j 做图存储直接面对的就是 Cypher 查询和入库脚本。4.1 为什么选 Neo4j 而不是关系型宽表查询模式的差异对比维度Neo4j 图库关系型宽表多跳查询路径遍历天然支持变长路径需要反复 JOINSQL 越来越长关系语义边可带属性语义直接落图上关系要拆成关联表语义靠代码约定Schema 演进先松后紧节点属性随时补改表结构要迁移成本高索引能力节点属性索引 全文索引成熟但 JOIN 深度受限另外还要提醒一句Neo4j 不擅长存大文本和图片菜谱步骤原文本、图片 URL 这些内容不要一股脑塞进节点。我的做法是图库存结构化关系和核心属性步骤文本和图片放 MySQL 或对象存储图库节点上保留一个content_id指向原始记录。这也让图谱本体更干净查询时不会被大字段拖慢。4.2 建库建索引与 LOAD CSV 导入最小命令集用cypher-shell或 Neo4j Browser 执行下面的初始化脚本能完成约束、索引和三元组导入三件事。// 1. 节点属性唯一性约束确保实体名不会重复建点 CREATE CONSTRAINT dish_name_unique IF NOT EXISTS FOR (d:Dish) REQUIRE d.name IS UNIQUE; CREATE CONSTRAINT ingredient_name_unique IF NOT EXISTS FOR (i:Ingredient) REQUIRE i.name IS UNIQUE; // 2. 常用查询条件的索引提速按菜系和食材找菜品 CREATE INDEX cuisine_name_index IF NOT EXISTS FOR (c:Cuisine) ON (c.name); CREATE INDEX ingredient_alias_index IF NOT EXISTS FOR (i:Ingredient) ON (i.alias); // 3. 从 CSV 导入菜品到食材的关系CSV 头一行是字段名 LOAD CSV WITH HEADERS FROM file:///dish_ingredient.csv AS row MATCH (d:Dish {name: row.dish_name}) MATCH (i:Ingredient {name: row.ingredient_name}) MERGE (d)-[r:USES {mandatory: row.mandatory}]-(i);逻辑说明CREATE CONSTRAINT ... IF NOT EXISTS是幂等写法重复执行不会报错适合写进构建脚本。MERGE在有唯一约束时等价于“存在则忽略不存在则建”重复导入同一批 CSV 不会产生重复边。参数说明mandatory字段从 CSV 读进来是字符串建议在导入前用脚本统一转成 true/false再用 Cypher 的toBoolean()函数转换文件路径默认指向 Neo4j 的import目录跨目录文件要用file:///tmp/xxx.csv这种绝对路径写法。导入前先把 CSV 清洗一遍。我一般会用 Python 做一次前置校验菜品名和食材名必须能在节点表中匹配到匹配不到的单独输出到一个unmatched_rows.csv人工看是缺节点还是数据错误。这一步能把“LOAD CSV 导入零条边”的无声失败避免掉——LOAD CSV 对匹配不到的情况不报错只是跳过。4.3 常用查询模板多跳、逆查询、路径匹配构建完成后真正考验人的是 Cypher 查询怎么写。我把项目里三个高频查询模板列出来第一个是单跳第二个是变长路径第三个是带条件的逆查询。// 场景一给一道菜查出所有食材和技法同时带营养属性 MATCH (d:Dish {name: 宫保鸡丁})-[r:USES]-(i:Ingredient) OPTIONAL MATCH (d)-[:TECHNIQUE]-(t:Technique) RETURN i.name AS ingredient, i.calory AS calory, t.name AS technique ORDER BY i.calory DESC; // 场景二变长路径查出“和宫保鸡丁共用三种以上食材的菜” MATCH (d1:Dish {name: 宫保鸡丁})-[:USES]-(common:Ingredient)-[:USES]-(d2:Dish) WITH d2, count(common) AS shared_count WHERE shared_count 3 RETURN d2.name AS candidate, shared_count ORDER BY shared_count DESC; // 场景三反向查询“哪些菜用了花生酱但用户对花生过敏” MATCH (i:Ingredient {name: 花生酱})-[:USES]-(d:Dish) MATCH (crowd:Crowd {name: 花生过敏人群})-[:NOT_RECOMMENDED]-(i) RETURN d.name AS dish_name, i.name AS allergen LIMIT 15;参数说明OPTIONAL MATCH用于技法可能为空的情况加了它之后菜品节点即使没有技法关系也会返回食材行不会丢场景二里的count(common)是基于路径中共享食材的数量做聚合LIMIT 50建议加上否则候选菜数量可能很大图库计算压力明显场景三的两个MATCH是串行执行的先用花生酱缩小子图再匹配人群子图越小性能越好。索引字段i.name和d.name必须有唯一约束否则LIMIT也挡不住全库扫描的耗时。多跳查询一多就要留意 Cypher 的变长路径写法[*1..3]。场景二其实等价于两跳显式写两跳更清晰。如果换成[*1..3]结果里可能混入同名不同类的节点语义会失真。除非有明确的“任意关系类型多跳”需求否则都把关系类型写死。5. 构建与应用的六个坑从数据采集到查询调优的避坑实录做知识图谱构建模型选型其实不难真正消磨时间的是脏数据、歧义实体和图上查询的性能。以下五条是我在这个项目里按“现象 → 原因 → 解决”整理的踩坑记录每一条都对应过一次真金白银的返工。5.1 坑一同一道菜几十个版本去重规则怎么定现象从菜谱站爬了“红烧肉”一千三百多个结果导入 Neo4j 后发现Dish节点有两百多个“红烧肉”变体图库图面上全是刺猬状的小团块。原因每个用户上传的红烧肉食材和步骤都略有差异菜谱站本身不做严格去重我不加规则就直接导入。解决建立基于“菜名规范名 核心技法集 食材集合哈希”的去重键同一去重键下只保留数据完整度最高的一条记录。具体做法是在导入前用 Python 对每条数据算一次特征哈希重复的进候选池由质检脚本按“步骤完整度、食材数量、是否有营养信息”打分保留一条。这个坑的根子在于“菜品”这个节点天然有歧义同一个菜名背后是一族变体。我的建议是不要追求把所有变体都建点而是在Dish节点上加一个variant_group_id同族菜共享同一标识查询展示时只透出代表作品。5.2 坑二半成品食材和原料混在字段里“豆腐皮”到底是原料还是半成品现象食材实体表里同时存在“豆腐”“豆皮”“千张”“豆腐皮”“冻豆腐”查询“豆腐类食材”时返回结果分裂成五个节点。原因菜谱原文会把原料和半成品混写“豆腐皮”在有的菜里是主料在另一些菜里是辅料语义上既不属于原料也不属于成品。解决我给“经加工后成为另一食材”的词增加一个processed_from属性指向其原料节点比如“豆腐皮”指向“大豆”“冻豆腐”指向“豆腐”。构建查询时用i.processed_from IS NULL AND i.name IN [...]过滤只取最上游原料节点参与分类统计。半成品和原料的对齐宁可多建一层processed_from关系也不要把“豆腐”和“豆腐皮”合并成一个节点。合并后“痛风人群忌口豆制品”这一类规则会连带误伤加工形态语义层直接崩了。5.3 坑三菜系是分类还是语义实体川菜和麻辣怎么建模现象用“川菜”做查询条件结果既搜到菜系节点、又搜到“川味”“麻辣”“红油”等口味标签图库里同一批菜被划到好几类统计口径没法对齐。原因菜谱站把“川菜”同时当成分类目录和口味标签在用我跟着照抄语义就混沌了。解决把“川菜”固定为Cuisine实体只在BELONGS_TO关系里出现麻辣、红油、椒麻全部收敛成Dish.taste属性不单独建实体。查询川菜时走(d:Dish)-[:BELONGS_TO]-(c:Cuisine {name:川菜})查口味时走d.taste CONTAINS 麻辣两条路径互不污染。这背后是个通用的建模判断凡是有“是/否”判断价值的分类比如菜系适合做实体凡是形容词性质的描述比如麻辣、软糯适合做属性。属性失控了就拉回TASTE_DICT白名单不要让它蔓延成新实体。5.4 坑四Cypher 正则查询转义失控索引失效性能骤降现象一条带正则的查询语句如WHERE d.name ~ .*鱼.*在小数据集上毫秒级返回数据量过了十万节点后直接干到秒级。原因正则查询无法走UNIQUE索引图库只能做全节点扫描属性值越多越卡。解决给常用模糊匹配的字段建全文索引用db.index.fulltext.queryNodes替代正则前缀匹配。// 建全文索引索引名: dish_fulltext字段: name 和 alias CREATE FULLTEXT INDEX dish_fulltext IF NOT EXISTS FOR (n:Dish) ON EACH [n.name, n.alias]; // 查询时改用全文检索返回相关度评分 CALL db.index.fulltext.queryNodes(dish_fulltext, 鱼香, {limit: 20}) YIELD node, score RETURN node.name AS name, score ORDER BY score DESC;参数说明{limit: 20}是全文查询的硬限制防止查询结果无界膨胀score是 Lucene 相关度可以拿来作为候选排序的依据。全文索引解决的是见字搜索但查询词必须是完整分词单元比如“鱼香肉丝”要拆成“鱼香”和“肉丝”分别命中这一点和原来的子串匹配语义有差别业务侧要接受。如果坚持用正则建议至少限定一个带索引的前缀条件先收窄范围比如WHERE d.name STARTS WITH 鱼香 AND d.name ~ .*肉.*用STARTS WITH走索引缩小候选集再对候选集做正则过滤。两条腿走路是性能和精度之间最现实的折中。5.5 坑五图谱膨胀导致构建卡顿如何控制边增长现象连续增量构建一个月后MATCH (d:Dish)-[:USES]-(i:Ingredient)这条最简单的查询开始变慢后台日志里出现OutOfMemory频发。原因PAIRS_WITH关系做了笛卡尔积式构建同一菜品下 N 个食材两两之间都建了适配关系边数以平方级别膨胀。解决对PAIRS_WITH建立触发约束——只有来自同一道菜的食材组合被算法推荐过两次以上才允许建边。同时给整个构建任务加批大小参数每批不超过两万条三元组批次之间释放图库连接。图数据库的坏习惯是“边越建越爽”但每条边都要占内存。控制边膨胀比控制节点数量更重要节点数量级在十万时 Neo4j 会表现良好但边数量超过百万后查询计划器的选择空间急剧变小。我在构建脚本里加了一个监控探针每完成一批导入就执行一次CALL db.labels()和MATCH ()-[r]-() RETURN count(r)对比两次数值一旦边增长率高于节点增长率两倍立刻触发告警。这条规则救过我好几次强烈建议保留。6. 应用系统怎么落地问答、替换食材与验证指标图谱搭完不接应用就只是个昂贵玩具。我这边落地的第一个应用是菜谱问答和食材替换推荐下面给出最小可运行的应用骨架与验证方法。6.1 一个入门级应用结构Django 接口 Neo4j 驱动from neo4j import GraphDatabase class Neo4jClient: def __init__(self, uri, user, password): self.driver GraphDatabase.driver(uri, auth(user, password)) def close(self): self.driver.close() def recommend_by_shared_ingredients(self, dish_name: str, min_shared: int 3): cypher MATCH (d1:Dish {name: $dish_name})-[:USES]-(common:Ingredient)-[:USES]-(d2:Dish) WITH d2, count(common) AS shared_count WHERE shared_count $min_shared RETURN d2.name AS dish, shared_count ORDER BY shared_count DESC LIMIT 10 with self.driver.session() as session: result session.run(cypher, dish_namedish_name, min_sharedmin_shared) return [record.data() for record in result] # Django 视图里直接调用 client Neo4jClient(bolt://localhost:7687, neo4j, your_password) similar_dishes client.recommend_by_shared_ingredients(宫保鸡丁, min_shared3)逻辑说明$dish_name和$min_shared都走参数化查询不要拼字符串否则中文转义和注入问题会轮番找上门。参数说明bolt://localhost:7687是默认连接串生产环境建议配连接池Django侧每次请求都新建GraphDatabase.driver会拖垮连接数正确做法是把client做成单例或者在应用启动时初始化一次全局实例。替换食材的玩法是同一个查询模板的变种先查SUBSTITUTES关系拿到可替换食材再用替换食材反向找菜。这样用户说“我不能吃花生帮我换掉宫保鸡丁里的花生”系统就能返回“用腰果替换花生”并顺带给出同样用腰果的其他菜。6.2 验证指标与最小评测集用 20 道菜的黄金问答集卡上线应用系统上线之前我建议准备一个最小评测集而不是看 demo 效果就放行。我的做法是人工构造 20 道菜的黄金问答集覆盖四种查询模式单跳查食材、双跳查替代、逆查询查忌口、变长路径查同族菜。评测项计算方式我的目标值查询单跳准确率答对数量 / 总问题数不低于 95%替代食材准确率返回食材中可食用占比不低于 90%平均响应时间20 个问题逐条计时取均值低于 1.5 秒构建数据完整度有营养属性的食材节点占比不低于 60%这套评测集每次构建增量子集后都要重跑一遍防止索引调整或数据清洗把旧功能带崩。我用一个简单的 shell 脚本逐条调用 HTTP 接口超时超过三秒就标记失败把回归成本压缩到半小时以内。别小看这个笨办法它帮我在一次菜系实体重构时提前遮住了一个会让“川菜”查全率下跌的回归缺陷。我至今保留着每次上线前重跑评测集的习惯这比任何代码审查都更能兜住底部。希望帮到你。本文还有配套的精品资源点击获取