简介:面向计算机专业毕业设计及项目实战学习者,内含一套基于知识图谱与生成式AI的智能食谱推荐系统完整源码。项目以知识图谱组织食材、菜谱与营养标签关系,结合生成式模型输出个性化推荐方案,覆盖从需求分析、数据库设计到前后端联调的完整流程,适合用于毕设、课程设计或期末大作业。资源包共42个文件,约681KB,以tsx、less、ts等前端源码为主,包含React页面与样式模块,另有Python后端处理脚本、JSON与YAML配置、shell部署脚本等,兼顾开发调试与发布环境。目前已有404人学习或下载。项目经导师指导并评审98分,本地调试可运行,内部按前端、后端、公共组件等模块划分,便于阅读和二次扩展,对理解知识图谱落地及生成式AI应用有参考价值。
1. 基于知识图谱和生成式AI的智能食谱推荐系统,到底做成了什么
打开你自己的前端页面,输入“冰箱里有一块鸡胸肉、半颗西兰花、两个鸡蛋”,系统不会只给你十条菜名列表,而是会根据健康目标返回“低卡鸡胸肉炒西兰花”,并附上一句“这道菜蛋白质含量约22g,脂肪低于6g,符合你的减脂需求”。这就是基于知识图谱和生成式AI的智能食谱推荐系统:知识图谱把食材、菜谱、营养标签和用户偏好之间的关联存成一张可跳转的图,生成式AI再把推荐结果包装成有人味、有理由的话术。这份Python毕业设计源码是一套完整的前后端分离项目,前端工程是food-react-master,后端入口是main.py,能跑通从用户输入到图谱查询、再到AI生成推荐文案的整条链路。它适合正在做毕设的计算机专业学生,也适合想练全栈实践、又不想从零开始写推荐逻辑与前端页面的学习者。
2. 架构与技术选型:为什么要把“食材关系”和“推荐文案”拆开处理
2.1 标签推荐的问题与知识图谱的选型理由
如果只用贴标签的方式做推荐,比如给鸡胸肉贴“低脂”“高蛋白”,那用户问“我花生过敏能吃什么”时,系统就只能靠人工维护一份过敏名单,根本解释不了“因为宫保鸡丁里有花生碎,而你点了过敏体质标签,所以它被排除了”这种多级关系。知识图谱把实体抽象成节点,把“菜谱包含食材”“食材适合体质”“食材禁忌体质”这些关系抽象成边,查询时可以从任意节点出发,沿着关系路径跳转,这是普通关系型数据库和标签体系做不到的。
从实现成本来看,在毕设周期里用图数据库Neo4j存食材关系并不算重。Neo4j自带Cypher查询语言,支持事务、索引和批量写入,后端Python通过py2neo就能直接连接。相比用二维表硬维护多对多关系,图结构天然适合食材、菜谱、营养、体质这几个实体之间的复杂关联。而且答辩时画一张“鸡胸肉—西兰花—减脂菜谱”的关系子图,比贴一大堆SQL表直观得多。更重要的是,后续加新关系类型不需要改表结构,只要在Cypher里新增一种关系就能往上挂数据。
2.2 生成式AI在系统里的职责边界
第一次做这类系统的人,最容易犯的错是让AI直接生成菜谱。我见过最离谱的输出是“香蕉炒茄子是传统菜肴”。原因很简单:没有知识图谱约束的生成式AI,本质是在做续写,不是在查事实。所以在这份源码的推荐链路里,生成式AI不是决策器,只是文案包装器——先由知识图谱确定候选菜谱,再由自然语言模型生成推荐理由和适配说明。这种分工有两个直接好处:候选集可控,推荐结果不会出现不存在的菜;推荐语有解释性,用户能看到“为什么给我推这道菜”。
我在调试时踩过这个坑,一开始让模型直接输出菜名,跑十条有两条是瞎编的。后面改成“图谱选菜、AI说话”,稳定性立刻上来了。你答辩时也可以抓住这个点:知识图谱保证推荐的菜真实存在,生成式AI保证推荐的理由像人话,两者缺一不可。如果方向反了,系统就成了一个披着AI外套的抽奖机。
2.3 前端与后端工程结构:从 .umirc.ts 到 main.py
这份源码的目录能看出它是真实的前后端分离工程,不是拿单页HTML糊出来的。前端基于React和UmiJS,后端是Python,整体结构都在压缩包里。为了让你启动时心里有数,我把关键文件和目录的作用列成了一张表。
| 文件/目录 | 作用 |
|---|---|
| main.py | Python后端入口,包含API接口与推荐服务 |
| food-react-master/.umirc.ts | UmiJS配置文件,路由、代理、打包参数都在这 |
| food-react-master/typings.d.ts | TypeScript类型声明 |
| food-react-master/src/pages | 前端页面,比如推荐页、食谱详情页 |
| food-react-master/src/components | 可复用组件,比如菜谱卡片、食材输入框 |
| food-react-master/src/common | 公共工具函数和请求封装 |
| food-react-master/src/layouts | 页面整体布局 |
| package.json / pnpm-lock.yaml | 依赖声明与锁文件,注意用pnpm安装 |
| publish.sh / publish_oss.sh | 前端部署和OSS上传脚本 |
从main.py看,后端更像FastAPI风格,带类型注解和接口文档。如果你打开过FastAPI项目,会看到路由装饰器、Query参数、Response Model都可以写在同一个文件里。启动命令通常是uvicorn main:app --reload,启动后浏览器访问/docs就能看到接口列表,这个特性在答辩演示时很有用,不用写测试工具就能手动调接口。前端那边,UmiJS有约定式路由,在src/pages下建一个文件就自动生成对应路由,所以看到目录里有多个tsx页面文件时,基本上对应了页面导航上的几个Tab。
3. 把食材关系写成图:知识图谱建模与 Cypher 查询实战
3.1 实体与关系设计:一份能跑的图模型
知识图谱建模不需要把菜谱所有属性都塞进去,只留推荐系统真正用得到的属性就好。我按五个实体理解这源码里的图谱:食材、菜谱、营养标签、体质标签、用户洞察。食材是关键节点,菜谱通过“包含”关系指向食材;营养标签通过“属于”关系挂在菜谱上;体质标签通过“适合”或“禁忌”关系连接食材。用户那边则通过“偏好”关系与菜谱关联,这样推荐时可以把用户做过的菜、收藏过的菜考虑进来。
关系类型建议用英文命名,避免中文关系在JSON和代码拼接时出现转义问题。下面这组关系是我调试时常用的,可以直接当建库脚本参考。
| 实体A | 关系 | 实体B | 含义 |
|---|---|---|---|
| 菜谱(Recipe) | CONTAINS | 食材(Ingredient) | 菜谱包含某食材 |
| 食材(Ingredient) | SUITABLE_FOR | 体质(Tag) | 食材适合某体质 |
| 食材(Ingredient) | FORBIDDEN_FOR | 体质(Tag) | 某体质禁忌该食材 |
| 菜谱(Recipe) | HAS_TAG | 标签(Tag) | 菜谱带低脂、高蛋白等标签 |
| 用户(User) | PREFERS | 菜谱(Recipe) | 用户偏好某菜谱 |
注意不要把所有属性都堆在关系上。比如“菜谱包含食材”的用量,可以写在CONTAINS关系属性里,方便之后计算营养;用户最后点击时间写在PREFERS关系属性上,查询偏好时就能按时间倒序。我把这个设计原则总结成一句话:节点存稳定基本信息,关系存交互行为和场景量值。
3.2 用 py2neo 建节点与关系
写图数据库前,建议先确认Neo4j已经启动,Bolt端口可通。然后用py2neo批量写入,几千个节点完全不需要走官方Import工具,直接用Subgraph一次提交几百条就够了。
from py2neo import Graph, Subgraph, Node, Relationship graph = Graph("bolt://localhost:7687", auth=("neo4j", "password123")) graph.run("MATCH (n) DETACH DELETE n") # 调试时重置库 nodes = [] rels = [] # 建食材和菜谱节点 ingredients = { "chicken_breast": {"name": "鸡胸肉", "protein_g": 22, "calorie": 118}, "broccoli": {"name": "西兰花", "fiber_g": 2.6, "calorie": 34}, } recipes = { "stir_fry_broccoli_chicken": {"name": "西兰花炒鸡胸肉", "difficulty": "低"}, } for key, attrs in ingredients.items(): nodes.append(Node("Ingredient", **attrs)) for key, attrs in recipes.items(): nodes.append(Node("Recipe", **attrs)) # 把菜谱和食材关联起来 rels.append(Relationship(recipes["stir_fry_broccoli_chicken"], "CONTAINS", ingredients["chicken_breast"])) rels.append(Relationship(recipes["stir_fry_broccoli_chicken"], "CONTAINS", ingredients["broccoli"])) graph.create(Subgraph(nodes, rels))这段代码有两个容易翻车的地方。一是Relationship构造时直接用字典里的Node对象,不要只用字符串名字,否则Neo4j可能找不到对应节点;二是graph.create是自动提交事务的,写入量太大时建议分批提交,比如每500个节点一个Subgraph,避免内存突然涨起来。写完以后,在Neo4j浏览器执行MATCH p=(n:Recipe)--() RETURN p,就能看到关系图长什么样。
连接参数里,bolt://localhost:7687是Neo4j的Bolt协议端口,auth里第一个是用户名,第二个是安装时设置的密码。如果你的Neo4j跑在Docker里,端口可能映射到别的宿主机端口,记得同步改main.py里的连接配置,否则后端起服务时会报连接失败。
3.3 查询候选菜谱:Cypher 写法与参数解释
当用户在前端输入几个食材后,后端要查“这些食材能做哪些菜”。Cypher里先定位食材节点,再通过CONTAINS反查菜谱,最后返回菜谱和全部配料,方便后续做营养计算和缺料提示。
MATCH (r:Recipe)-[:CONTAINS]->(i:Ingredient) WHERE i.name IN $userIngredients WITH r, collect(i.name) AS matched MATCH (r)-[:CONTAINS]->(all:Ingredient) WITH r, matched, collect(all.name) AS allIngredients WHERE size(matched) >= toInteger($minMatch) RETURN r.name AS recipe, matched, allIngredients ORDER BY size(matched) DESC LIMIT toInteger($limit)这段查询的逻辑是:先搜集用户输入的食材名,找出所有包含至少minMatch个食材的菜谱,再把菜谱的全部配料带出来,按命中数量排序。minMatch一般设成2,意思是至少两个食材对得上,推荐结果才不太偏;limit设成5到10,后端拿到候选后再交给生成式AI包装。如果用户输入的食材在知识图谱里完全没有匹配节点,返回就是空列表,前端需要处理“未找到候选菜谱”的情况,不能让它直接传到AI那边。
实际执行时用graph.run(query, parameters={"userIngredients": ["鸡胸肉", "西兰花"], "minMatch": 2, "limit": 5})传参。千万不要用f-string直接拼用户输入进Cypher,一旦引号没处理好就会报语法错误,而且有注入风险。我习惯把所有查询参数都放进字典,这样Neo4j驱动会自动做类型检查和转换。
4. 生成式AI接进推荐链路:Prompt 设计、参数设置与结果校验
4.1 推荐链路里生成式AI的“位置”
整个推荐接口的执行顺序是:接收用户请求 → 解析食材和偏好 → 查询知识图谱拿到候选菜谱 → 组装Prompt → 调用生成式AI接口 → 解析AI返回的JSON → 把推荐理由附在菜品结果里返回前端。
这个顺序意味着生成式AI永远位于图谱查询之后,只做翻译,不做决定。很多翻车案例都是因为流程反了:让AI先给菜名,再去图数据库里找这个菜是否存在。一旦找不到,程序只能报错或者返回预置文案。把图谱候选塞进Prompt后,AI的任务变成“从候选里挑一个最合适的,并写理由”,它没有机会自创菜谱,推荐结果的可靠性就高了不少。
4.2 调用生成式AI的Python封装
后端里一般会有一个build_prompt函数和一个call_llm函数。下面是简化后的骨架,可以放到main.py里跑通。
def build_prompt(user_input: str, candidates: list) -> str: candidate_lines = "\n".join( f"{i+1}. {item['recipe']},食材:{','.join(item['ingredients'])}" for i, item in enumerate(candidates) ) return f""" 你是家常菜推荐助手。依据下面的候选菜谱,从用户需求出发挑一个最合适的。 只允许从候选里选,不要自创菜名。输出JSON,格式: {{"recommendation": "菜名", "reason": "推荐理由", "replacements": "可替代食材(如有)"}} 候选菜谱: {candidate_lines} 用户需求:{user_input} """ def call_llm(prompt: str): # 实际项目中换成你自己的大模型接口配置 response = openai.ChatCompletion.create( model="gpt-3.5-turbo", messages=[ {"role": "system", "content": "你只输出JSON,不要输出多余文本。"}, {"role": "user", "content": prompt}, ], temperature=0.3, max_tokens=256, request_timeout=15, ) return response["choices"][0]["message"]["content"]这里几个参数对毕设项目很重要。temperature=0.3是为了降低随机性,让同一道菜在不同次调用里的推荐理由差异不要太大;max_tokens=256限制输出长度,防止模型为了凑字数写成长篇大论;request_timeout=15避免模型接口卡住时整个推荐接口无响应。如果你没有openai库,也可以直接用requests调HTTP接口,但最好在服务端做一层超时控制,并且用json参数严格解析返回内容。
4.3 防止AI瞎编食材:前置约束与后置兜底
前置约束是把候选菜谱名单写进Prompt,并且强调“只允许从候选里选”。但即使这样,模型还是有概率把理由里的食材写得和候选不一致,比如候选里根本没有牛奶,模型却因为语言惯性写了一句“加入牛奶更顺滑”。所以后置校验一定要做:校验菜名在不在候选集里,校验理由里出现的食材在不在菜谱配料单里。
import json def parse_llm_output(text: str, candidates: list, fallback_reason: str) -> dict: try: data = json.loads(text) recipe_name = data.get("recommendation", "") if not any(c["recipe"] == recipe_name for c in candidates): raise ValueError("模型输出不在候选集中") return { "recipe": recipe_name, "reason": data.get("reason", fallback_reason), "replacements": data.get("replacements", []) } except Exception: return {"recipe": candidates[0]["recipe"], "reason": fallback_reason}兜底逻辑里,我默认把匹配食材最多的候选菜放第一位,作为AI输出非法时的替代。理由文案用“该菜谱使用的食材最贴近你的输入,且整体营养搭配均衡”这种通用句,保证前端不会展示空内容。如果你觉得一次生成失败概率高,可以加一次重试,最多重试两次,再不行就走兜底。毕设阶段我建议不要做过多重试,因为演示时等待时间太长体验反而变差。
如果你担心模型API费用,又想离线演示,可以换transformers加载本地模型。本地模型同样要用低temperature,并且把max_new_tokens设到100到200,同时注意模型权重要预加载,不要在每个请求里重复加载,否则一次请求可能要等十几秒。
5. 避坑记录:从 Neo4j 连不上到前端打包失败的五个问题
这个课题的坑主要集中在环境、连接和AI输出三块。我把自己调试时踩过的五条记录按“现象→原因→解决”写出来,你照着排查会快很多。
5.1 Neo4j 连接失败:Failed to establish connection
现象:运行main.py时直接报py2neo.connector或Bolt连接超时,浏览器里却打不开Neo4j的Web管理页面。
原因:八成是Neo4j服务没启动,或者Bolt端口没有监听。新版Neo4j Desktop启动后,还需要手动打开当前数据库实例,不是装完就自动运行。还有一种是密码不对,Neo4j 4.x后初始密码不会再用neo4j/neo4j,安装时必须设置,很多人拿旧教程里的默认密码去连,自然报错。
解决:先打开Neo4j Browser,确认数据库状态是RUNNING。再用命令行检查端口是否监听,Linux和macOS下执行lsof -i:7687,Windows下用netstat -ano | findstr 7687。如果端口没起来,去Neo4j配置里看server.bolt.listen_address是不是默认localhost:7687。最后确认main.py里Graph("bolt://localhost:7687", auth=("neo4j", "密码"))的写法。我见过有人把bolt写成http,那是Neo4j Browser的端口,不是驱动连接的端口。
5.2 npm install 和 pnpm-lock.yaml 一起用导致依赖崩溃
现象:在food-react-master目录下执行npm install,报了各种extraneous、peer dependency冲突,哪怕删掉node_modules重装还是不行。
原因:源码里写的是pnpm-lock.yaml,说明前端的依赖锁定是基于pnpm生成的。直接用npm install会按npm自己的解析规则重新计算版本,遇到锁定文件不兼容就会出现依赖树混乱。
解决:优先使用pnpm。先确认全局安装了pnpm,然后执行:
cd food-react-master pnpm install pnpm dev如果公司或学校网络拉取慢,可以先把.npmrc文件里的registry换成本地镜像,再重新安装。我不建议删除pnpm-lock.yaml去强行兼容npm,因为锁文件里的版本组合是作者验证过的,删了之后前端很可能会出现样式错乱或组件编译不过去。
5.3 前端请求后端接口 404 或跨域失效
现象:前端页面能打开,但一点“开始推荐”就报404,或者浏览器提示CORS policy拦截。
原因:开发阶段前后端端口不同,浏览器默认跨域。UmiJS项目里应该在.umirc.ts中配置proxy,把/api开头的请求转发到Python后端。如果缺少这段配置,请求直接打到前端开发服务器,当然会404。
解决:在.umirc.ts里增加代理配置:
proxy: { '/api': { target: 'http://127.0.0.1:8001', changeOrigin: true, pathRewrite: { '^/api': '' }, }, }注意pathRewrite这段:如果后端接口本身没有/api前缀,必须把/api去掉,否则后端收到的路径里带着/api,匹配不上路由。如果你后端main.py里定义的是@app.get("/recommend"),前端请求路径就应该是/api/recommend,然后代理重写为/recommend。改完配置要重启pnpm dev,Ctrl+C再重新运行一次,让配置生效。
5.4 推荐理由里出现不存在的食材
现象:AI给的推荐理由写“这道菜加入了牛奶更香”,但知识图谱里这道菜根本不含牛奶,甚至用户还点了乳制品不耐受标签。
原因:模型在生成理由时,会顺着语言惯性补充细节,把“牛奶”这种常见食材写进去。中文大模型的幻觉概率比想象中高很多,只靠Prompt约束无法根治。
解决:后置校验不能只查菜名,还得校验理由里的每个食材词是否都在菜谱配料里。我在4.3节提到了这个思路,具体实现时可以把理由按词切分后和配料做交集。更简单的方法是:额外加一个字段“严格依据给定配料”,同时把temperature降到0.2,大部分情况下能避免。如果校验失败,就丢弃模型输出,使用模板文案。我一般设置最多重试一次,因为连续两次生成同样幻觉的概率很低,确保演示流畅。
5.5 知识图谱更新后查询结果不对
现象:往Neo4j里新增了菜谱和食材,但推荐接口怎么都查不到新数据,重启后端也没用。
原因:可能是创建节点和关系时没有提交事务,也可能是没有给name属性建索引。Neo4j在数据量变大后,按属性查询会变慢,甚至出现长时间扫描导致超时。py2neo的graph.create虽然是自动事务,但如果你把写入逻辑分散在多个函数里,某个函数中途抛异常会导致提交不完整。
解决:到Neo4j Browser执行一条索引创建命令:
CREATE INDEX FOR (i:Ingredient) ON (i.name); CREATE INDEX FOR (r:Recipe) ON (r.name);建完索引后再跑查询,速度会明显改善。同时检查写入代码有没有在batch末尾统一调用graph.commit。如果用的是py2neo的autocommit模式,要确保最后出现graph.create(subgraph)而不是只调了Graph()却忘写create。我自己的习惯是每写一批就立刻查询一次,确认入图成功再继续下一批,这样能从源头定位漏写的数据。
这五条基本覆盖了从环境安装到推荐输出的闭环问题。按我的经验,遇到报错先看日志,其次查端口,再看配置文件,最后才怀疑业务逻辑。不要一上来就改main.py里的推荐算法,那是效率最低的做法。
6. 进阶技巧:用“解释回溯法”验证推荐质量,让毕业演示更可信
如果你已经把推荐链路跑通,先不要急着加用户登录、收藏这些边角功能,而是要做一次“解释回溯”验证。方法很简单:对每次AI返回的推荐理由,检查理由里出现的食材,是否都能在知识图谱中找到对应关系。这一步能自动发现AI输出里的幻觉,也能在答辩时展示完整的逻辑闭环。
我在源码基础上写过这样一个验证脚本:给一个菜名,从图谱里取出它包含的全部食材,然后和AI返回的理由做比对。
def verify_recommendation(graph, recipe_name): result = graph.run( "MATCH (r:Recipe {name: $name})-[:CONTAINS]->(i:Ingredient) " "RETURN collect(i.name) AS ingredients", name=recipe_name ).data() return result[0]["ingredients"] if result else []你可以把这脚本嵌进测试模块,每次改完Prompt就运行一次,随机喂20组不同的用户输入,把推荐结果和验证结果一起输出到Excel。答辩演示时,打开这个表格,左边是AI给出的理由,右边是图谱里实际存在的食材清单,两者能对得上,说明系统没有自说自话。如果再想加深印象,可以加一个“缺料替代”功能,让AI返回一个replacements字段,字段里的替代食材也可以通过图谱里同体质标签的食材去生成,整个项目就从“能跑”升级成了“有逻辑”。
这套验证方法看起来简单,但实际能拦住不少问题。我记得有一次改完Prompt后,理由里出现了“虾仁”,可对应菜谱是纯素的,幸好跑了回溯脚本才在答辩前发现。从那以后,我每次改完Prompt或更新图谱数据,都会强制跑一遍脚本,至少检查20条推荐结果,再演示给别人看。希望帮到你。
本文还有配套的精品资源,点击获取