ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

知识图谱与生成式AI融合的食谱推荐系统构建实践

知识图谱与生成式AI融合的食谱推荐系统构建实践

简介:面向毕业设计与课程设计的智能食谱推荐系统源码,以Python为开发语言,结合Neo4j知识图谱和生成式AI,构建了从数据建模到界面交互的完整工程。源码本地编译可运行,评审得分98分,难度适中,适合有Python基础的开发者参考。压缩包共52个文件,约686KB,主要包含tsx/less前端页面与样式、py程序入口、json/yaml配置、sh发布脚本及zbak备份文件,目录按页面、组件、文档等模块划分,涵盖健康记录、今日分析、食谱管理等典型功能模块,便于分块学习。当前已有90人学习,可用作期末大作业或毕业设计选题。通过该项目可掌握知识图谱在推荐场景的建模思路、生成式AI的内容生成流程,以及Python与TypeScript混合项目组织方法;附带说明文档与发布脚本也能帮助快速搭建运行环境,提升整体工程实践能力。

1. 食谱推荐系统为什么要同时上知识图谱和生成式AI:先想清楚绑定关系再动手

你打开外卖或菜谱App,搜“鸡胸肉”,得到的结果是按点击量排序的一堆菜名,有的要油炸、有的要烤箱、有的用了你家里根本没有的配料。这背后是一个典型的推荐系统问题:用户输入的是食材或口味,想要的是一个“我现在就能做”的答案,可传统协同过滤和热度排序根本不知道“番茄炒蛋”和“鸡蛋”之间有什么关系。这个毕设把知识图谱和生成式AI绑在一起,解决的就是三件事:让推荐结果看得懂食材之间的关联、让推荐理由不再是“猜你喜欢”这种黑匣子、让用户拿冰箱里仅剩的几样东西也能得到靠谱方案。适合正在选毕设题目的计算机、大数据专业学生,也适合想给自己项目加记忆点的开发者——这个方向不用堆算力,核心是把数据建模做扎实。

  • Neo4j直接可视化图谱,答辩演示效果好
  • 用户输入食材即可推荐,逻辑清晰
  • 大模型生成推荐理由和营养分析,加分项强

就算你还没定技术栈,下来先把知识图谱和生成式AI的分工搞清楚,你就知道为什么不能只靠调大模型接口糊弄过去。

2. 构建菜谱知识图谱:先建模再写代码,这里决定你毕设的下限

2.1 食谱领域的实体与关系设计:抄医疗图谱的思路,但别照搬

知识图谱不是把数据塞进图数据库就完事,第一步是把“菜谱”拆成可以推理的实体和关系。我见过不少学生直接拿爬虫抓一万条菜谱,然后一股脑导入Neo4j,结果查询时根本不知道“鱼香肉丝”和“莴笋炒肉”之间怎么关联——因为它们的食材实体没有打通。做这个毕设前,你先要画一张实体关系图,这一步宁可多花两天,也别急着写代码。参考医疗知识图谱和工业知识图谱里沉淀的建模套路,食谱场景核心实体就四类:菜谱、食材、菜系、口味标签;核心关系就五条:菜谱用到食材、食材属于类别、菜谱属于菜系、菜谱具备口味、食材之间可替代。这个建模粒度足够支撑毕设的推荐逻辑,再多就乱。

实体属性设计见下表,这是我在做类似项目时沉淀的最稳方案。

实体必要属性说明
菜谱name、cook_time、difficulty、calories、stepssteps存文本,不拆节点
食材name、category、unit、calories_per_100gcategory用来做食材间关联
菜系name、region川菜、粤菜等
口味标签name酸甜、麻辣、清淡

关键在设计“食材可替代”这条关系。比如“五花肉”和“鸡腿肉”在知识图谱里不是一回事,但用户没有五花肉时,系统能通过替代关系推荐用鸡腿肉做的菜。这比协同过滤的“看了又看”高级得多,也是答辩时能讲出亮点的部分。

2.2 利用清华开源数据集构建知识图谱

图谱数据从哪来?自己写爬虫不是不行,但耗时且版权风险高。常见做法是找开源的菜谱数据集,比如利用清华大学开源的中文菜谱数据集,配合食材营养数据库,构建出知识图谱。清华开源的中文菜谱数据集包含菜名、食材、菜系、口味和步骤信息,格式为JSON,营养数据库用开源的中国食物成分表。这两份数据都不需要你去工厂实习就能拿到,结构清洗后正好喂给图谱。下面这段代码演示了用纯Python把JSON数据集解析成三元组的核心逻辑,不依赖框架,方便你理解清洗过程。先解析菜谱的食材列表,再拆出口味和菜系,最后组装成图谱写入脚本能直接消费的列表。

# parse_recipe_data.py import json from collections import defaultdict def load_recipe_json(path="recipes.json"): """加载菜谱数据集,返回三元组列表,供图谱写入脚本使用""" triples = [] with open(path, "r", encoding="utf-8") as f: recipes = json.load(f) for recipe in recipes: # 三元组:菜谱 -[用到]-> 食材 for ingredient in recipe.get("ingredients", []): triples.append((recipe["name"], "用到", ingredient["name"])) # 三元组:菜谱 -[属于]-> 菜系 if recipe.get("cuisine"): triples.append((recipe["name"], "属于", recipe["cuisine"])) # 三元组:菜谱 -[口味]-> 标签 for taste in recipe.get("taste", []): triples.append((recipe["name"], "口味", taste)) return triples

这段代码用一个三元组列表统一承接三种关系,后面写入Neo4j时就一个函数搞定。参数只有path一个,但内部逻辑有讲究:食材字段在原始数据里可能叫ingredients,也可能叫material,清洗脚本里必须先做字段名映射,否则一遍遍返工。有些数据集没有口味字段,那就根据菜系推断,川菜默认麻辣、粤菜默认清淡,虽然不精确但能先把图建起来,后面再人工抽检修正。

2.3 用py2neo写Neo4j:建节点和关系的完整脚本

解析完数据,下一步就是写Neo4j。我用py2neo,因为它比原生的bolt驱动更省事,直接封装了Node和Relationship对象。下面这份代码是毕设项目里最核心的一段,建议直接基于它改,不要自己去造轮子。

# build_graph.py from py2neo import Graph, Node, Relationship # 连接本地Neo4j,默认账号neo4j,密码自己改 graph = Graph("bolt://localhost:7687", auth=("neo4j", "your_password")) def create_recipe_graph(triples): """把三元组写入Neo4j,节点不存在则创建,关系去重""" for head, rel, tail in triples: # 按三元组的方位判断节点类型 head_node = graph.nodes.match("菜谱", name=head).first() if not head_node: head_node = Node("菜谱", name=head) graph.create(head_node) # 关系另一端的节点可能是食材/菜系/口味标签,全按名称去重 tail_node = graph.nodes.match(rel, name=tail).first() if not tail_node: # 用关系类型反推实体标签,这在建模时就要定好 label = {"用到": "食材", "属于": "菜系", "口味": "口味标签"}[rel] tail_node = Node(label, name=tail) graph.create(tail_node) # 创建关系前先查重,防止重复导入造成图膨胀 existing = graph.run( "MATCH (a {name:$a})-[r:" + rel + "]->(b {name:$b}) RETURN r LIMIT 1", a=head, b=tail ).data() if not existing: graph.create(Relationship(head_node, rel, tail_node)) print("图构建完成,节点和关系写入成功")

这脚本有三个参数值得你调:第一是graph连接串里的密码和端口,Neo4j Desktop默认端口是7687,不要写错成7474,那个是HTTP管理端口;第二是entity_label映射字典,它必须和2.1节的建模方案一致,如果后面你要增加“营养”这类关系,就在字典里补一项;第三是查询去重用到了参数化查询,别用字符串拼接的方式,否则中文菜名里的引号会把Cypher语法打崩。跑完后在Neo4j Browser里执行MATCH (n) RETURN n LIMIT 25,能看到图谱的星型结构就说明写入成功。

3. 基于图结构的推荐逻辑:从“猜你喜欢”升级成“因为吃了这个,所以推荐那个”

3.1 Cypher查询做候选召回:按食材、口味、菜系三重路径找菜

图谱建好之后,推荐就不是玄学了,而是图上的路径查找。用户输入“鸡肉、青椒”,你要在图谱里找到同时用到这两种食材的菜谱,这是最基础的召回。更聪明的做法是找“替代食材”路径:用户没青椒,但有尖椒,图谱里有一条“青椒-可替代-尖椒”的关系,那么用到青椒的菜也能被召回。这段Cypher就是干这个事的,它是整个推荐系统的召回层核心。

// 同时包含两种食材的菜谱,用于核心召回 MATCH (r:菜谱)-[:用到]->(i1:食材 {name:'鸡肉'}), (r)-[:用到]->(i2:食材 {name:'青椒'}) RETURN r.name AS recipe_name, r.difficulty AS difficulty LIMIT 20; // 用替代关系扩展召回,尖椒替代青椒 MATCH (r:菜谱)-[:用到]->(i:食材 {name:'青椒'}), (i)-[:可替代]->(alt:食材 {name:'尖椒'}) RETURN r.name AS recipe_name, '因替代召回' AS reason LIMIT 20;

第一条查询是精确匹配,食材越多结果越少,所以LIMIT设20足够。第二条查询走的是食材替代关系,是图谱区别于普通标签系统的核心优势。这里有个关键参数你要注意:替代关系在建模时要设计成有向的,(青椒)-[:可替代]->(尖椒)意思是青椒可以用尖椒顶上,别把方向搞反。

召回之后还要按菜系和口味做过滤,比如用户不吃辣,就在Cypher里加一句WHERE NOT (r)-[:口味]->(:口味标签 {name:'麻辣'})。这一层的价值在于:把图谱的路径查询能力转化为可解释的推荐候选集,每一道菜被召回,都有一条从用户需求到菜谱的路径背书。

3.2 用Jaccard相似度给召回结果排序:不调大模型也能有不错的推荐

召回之后排序怎么做?如果你的毕设只求稳,用基于图谱的Jaccard相似度就够了,生成的推荐结果在逻辑上也站得住。看过医疗知识图谱的排序方式,基本都是这个套路:计算菜谱之间的食材重合度,越像的越靠前。下面这段Python代码在引擎层做排序,把召回的多条路径合并去重,再按相似度打分排序,可控性比直接把排序交给大模型强得多。

# rank_recipes.py def jaccard_similarity(recipe1_ingredients, recipe2_ingredients): """计算两个菜谱食材集合的Jaccard相似度,作为排序依据""" set1 = set(recipe1_ingredients) set2 = set(recipe2_ingredients) intersection = set1 & set2 union = set1 | set2 if not union: return 0.0 return round(len(intersection) / len(union), 4)

这里的门槛设0.3:相似度低于0.3的菜谱不建议推荐,否则用户会觉得“你推荐的根本不是我想要的”。参数0.3来自我的经验值,你可以在自己的数据集上画一条分布曲线再调,但记住不要低于0.25,太低会让推荐结果发散。

排序策略还可以再叠一层用户行为加权:模拟用户历史上点过“宫保鸡丁”,那么排序时所有属于川菜的菜谱加5分,用到鸡肉的加3分,这是模仿协同过滤的思路做在图谱上,能让答辩评委眼前一亮的。

3.3 用户画像构建和冷启动解法:冰箱里有什么就推荐什么

推荐系统永远绕不开冷启动。这个毕设没有真实用户数据,所以冷启动一定要做,而且要做法得当。最简单可靠的方案是把“冷启动”换成“冰箱模式”:让用户勾选家里现有的3到5种食材,系统直接用这些食材做召回。这个模式对食谱推荐场景本身就很自然,而且完全不依赖用户历史行为。

构建用户画像的常用做法是维护一个user_pref.json,记录用户的忌口、偏好菜系和已有食材。在冷启动阶段,用户画像为空时,系统走冰箱模式;用户点过几次菜之后,把行为写入画像,推荐时用3.2节的加权逻辑做个性化排序。这样整个系统就有了完整的推荐链路:冷启动用食材召回,热启动用行为排序,两种状态都能演示,不用怕评委问到“没有用户数据怎么办”。

不要在这个阶段投入精力去搞分布式或实时计算,四个小时的复现提交,一个单机Neo4j加一个Python服务就够跑完所有演示路径。

4. 生成式AI接入:让大模型当“解说员”,别让它当“数据库”

4.1 大模型在系统里的定位:生成推荐理由与营养评语,不参与核心召回

很多同学误以为生成式AI就是让大模型直接输出菜谱推荐,这是对毕设的误解。你想想,大模型训练数据里的食谱和你图谱里清洗出来的食谱,哪边更可信?图谱里的食材关系是你精心构建的,新鲜且逻辑一致;大模型只会根据自己的语料库给出模糊答案,这会导致推荐完全偏离用户输入。正确做法是:图谱负责召回和排序,大模型负责把排序结果“翻译”成人话——也就是让大模型根据回调回来的推荐数据,输出这盘菜的营养分析、热量估算和推荐理由。这比直接让大模型生成菜谱靠谱得多,你的图谱也不会成为摆设。

4.2 Prompt模板设计:让大模型说人话,并输出结构化JSON

生成式AI接入的关键是Prompt设计。提示词要提前固定好,因为推荐理由的质量直接决定答辩演示效果。我在项目中用过下面这个模板,拿ChatGPT类模型实测效果很稳。模板里最重要的就是“只输出JSON,不输出废话”这四行字。

# llm_client.py def build_recommendation_prompt(recipe_data): """根据图谱查出的菜谱数据构造Prompt,让大模型生成推荐理由""" prompt = f""" 你是一位营养学专家和美食评论家。用户根据自己的食材获得了以下推荐菜谱: {recipe_data} 请为这道菜生成一段不超过80字的推荐理由,并包含热量估算和主要营养亮点。 要求: 1. 用中文回答; 2. 第一句直接说推荐理由,不要铺垫; 3. 只输出JSON,格式为: {{"reason": "推荐理由", "calories": "约xxx千卡", "highlights": "蛋白质丰富,维生素C含量高"}} 4. 不要输出JSON以外的任何内容。 """ return prompt

这里有个参数要注意:recipe_data里带的是图谱查询结果的字典,包括菜名、食材列表、口味标签和菜系。还有别让大模型输出过长内容,80字卡在演示界面上正好能一行展示完,字数太长大模型容易开始编造营养数据。

真实调用大模型接口时,可以用openai SDK或chatgpt的API,把上面生成的prompt作为message发过去。

# 调用大模型接口的调用示例 import json # 假设从OpenAI SDK获取到响应内容 # 你在自己的demo里可以用Flash、GPT等模型替换 response_text = llm_response["choices"][0]["message"]["content"] # 用最保险的方式提取JSON内容 try: parsed = json.loads(response_text) except json.JSONDecodeError: # 如果模型输出带注释或额外文字,就要做截取 parsed = json.loads( response_text[response_text.find("{"): response_text.rfind("}") + 1] ) print(parsed["reason"])

这段代码里的llm_response是模拟的返回结构,实际接入时各家平台的返回字段略有不同,注意看官方示例。find和rfind做截取堪称后悔药:很多模型就是会在JSON前后加几句多余的话,不做清洗,连json.loads都过不了。这一步属于“生成式AI的稳定性保障”,没有它,你的前端页面会不定期白屏。

4.3 没有API Key怎么降级:本地规则模板也能撑住演示

这里就体现了“无限制无审核生成式AI网页版”的另一种思路。如果你在毕设阶段没有合适的API Key,或者担心接口消耗,还有一条不用外部API的降级方案:用本地模板生成推荐理由,同时保留大模型接口的扩展位。其实大多数毕设演示根本用不到实时大模型接口,本地模板完全能覆盖。我建议你在项目里做一层接口抽象,默认实现是规则模板,当外部API不可用时自动降级,保证演示不翻车。

# fallback_recommender.py def generate_local_reason(recipe_name, ingredients, cuisine): """免API降级方案:根据图谱数据组装推荐语""" main_ingredient = ingredients[0] if ingredients else "食材" reason = f"{recipe_name}以{main_ingredient}为主料,属于{cuisine}菜系,做法家常且食材易得。" calories = len(ingredients) * 30 + 200 highlights = "富含蛋白质和膳食纤维" if len(ingredients) > 2 else "清淡爽口,适合家常烹饪" return { "reason": reason, "calories": f"约{calories}千卡", "highlights": highlights }

“本地规则模板生成推荐理由”和“大模型生成推荐理由”之间存在一个取舍:本地模板的优势是零成本、响应快、稳定不胡编,缺点是每道菜的话术雷同;大模型则相反。毕设答辩时,你可以先演示本地模板,再切到大模型,展示两种模式的对比,这反而成为项目的加分设计。

5. 避坑:毕设级食谱推荐系统里那些绕不过去的坑

5.1 食材字段没对齐,图谱里出现“番茄”和“西红柿”两个节点

  • 现象:图谱查询“番茄炒蛋”时,返回的结果数量只有个位数,而且很多菜谱明明用到番茄却查不出来。
  • 原因:源数据集里“番茄”写成“西红柿”,“土豆”写成“马铃薯”,导致同一个食材在图谱里被建成两个节点,路径完全断裂。这就是智能食谱推荐系统太依赖结构化数据带来的问题。
  • 解决:导入前做一遍食材名归一化,用字典映射加相似度匹配两步走。先建一个手工同义词表,再对剩余名称用文本相似度算法自动聚合,最后人工抽检确认。

这道工序是图谱构建中工作量最大的部分,但它和推荐效果直接挂钩,值得专门写一个函数处理。

5.2 Neo4j关系类型不统一,Cypher查询时全查不到

  • 现象:在代码里创建关系时,一会儿用用到,一会儿用contain,一会儿又用uses,查询时写对了其中一种就漏掉另外两种,结果图看着很大,一查就丢数据。
  • 原因:写导入脚本时图省事,没有给关系类型做常量统一管理。
  • 解决:在所有代码里建一个常量枚举类,定义RELATION的固定常量,导入脚本、召回脚本、排序脚本全部从这个类里取名称,不直接写字符串。这个教训来自我的血泪经验,一个项目里只要出现三种以上写法,基本就要返工重导。

5.3 食材单位没归一化,营养计算全偏

  • 现象:生成式AI给出的热量估算明显偏低或偏高,比如“米饭”的热量算出来只有10千卡。
  • 原因:原始菜谱里“适量”、“少许”、“一把”、“两勺”这类非标准单位统统入库,营养计算脚本拿这些值去做乘法,结果肯定离谱。
  • 解决:导入前把所有非标准食材替换成可配置的默认重量,比如“适量”按30克算,“一把”按50克算,“两勺”按15克算。转换表直接写配置文件,以后调整不用改代码。

5.4 大模型返回的JSON不稳定,前端拿不到reason字段

  • 现象:页面偶发白屏,后端日志报json.loads失败,有时甚至报KeyError找不到reason键。
  • 原因:大模型的输出带有Markdown代码块或额外说明文字,json.loads无法解析。
  • 解决:在提取JSON时用4.2节里的截取逻辑,先取第一个{到最后一个}中间的字符串,再做json.loads。如果解析失败,直接降级到本地模板返回,不让异常抛到前端。这条防线做好,答辩时不管网络怎么抽风,页面都不会崩。

5.5 图谱数据量不够,冷启动推荐结果稀疏

  • 现象:用户选了3种食材后,系统提示“没有找到相关菜谱”,体验很不好。
  • 原因:图谱里的菜品数量太少,食材组合覆盖不全,尤其是小众食材组合基本查不到。
  • 解决:把本地的开源菜谱数据集从1000条扩到3000条以上,并在召回查询里把食材匹配改成“至少匹配2种食材”而不是“全部匹配”。召回结果少于5条时,再加一层替代食材扩召回。这个参数调整比改模型算法更有效,属于性价比最高的优化手段。

6. 让推荐系统闭环:从图谱可视化到质量评估,答辩演示更有说服力

图谱不展示出来等于白建。Neo4j Browser自带可视化,但那是最原始的展示方式,不够精致。建议用Neo4j Bloom做一次简单的可视化视图,把食材节点按类别着色,菜谱节点按菜系着色,演示时用“食材查询菜谱”的过程能直观展示图谱路径的变化。如果你更喜欢前端可控,用ECharts的graph类型把图谱数据转成JSON后渲染效果也不错。推荐后者,因为它能嵌入你自己的系统页面里,答辩时全程不切窗口,连贯性好。

推荐质量验证是另一个容易遗漏的环节。不需要搞离线评测集,只要做一个基于规则的一致性检查就行:系统推荐的理由是否与图谱数据吻合,比如推荐理由里声称“富含维生素C”,那这道菜的食材里是否真的有高维C食材。逐条抽检20条推荐,记录三项指标:理由与图谱数据的一致率、食材召回覆盖率、热量估算的偏差率,做成一张表格放在论文附录里,比口头说“效果不错”有说服力得多。

最终我的习惯是:所有接口都保留两个实现——一个是完整的大模型版本,一个是降级模板,开发调试时直接跑降级版本,速度够快;答辩演示前再切回完整版。另外,把食材归一化做扎实,再加一层主料优先召回,整个系统的稳定性就有保障了。别小看这两个细节,它们就是判断一个毕设是“能跑的源码”还是“能演示的作品”的分水岭。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进