☰
基于知识图谱与生成式AI的智能食谱推荐系统设计与实现
2026/10/18 12:51:25 网站建设 项目流程

简介:基于知识图谱和生成式AI的智能食谱推荐系统毕业设计源码,面向计算机相关专业正在准备毕设的学生及需要项目实战练习的学习者。项目以知识图谱组织食谱数据,结合生成式AI提供智能推荐,难度适中,适合作为课程设计或期末大作业。压缩包共42个文件,约681KB,其中以19个tsx、9个less等前端类型文件为主,辅以3个ts、2个json配置文件,以及main.py、yaml等后端与部署相关文件,结构清晰,便于快速定位前端页面、组件、布局与后端入口。项目经导师指导并通过评审,得分98分,源码全部本地编译可运行,经过严格调试。目前已有404人学习下载,适合需要参考完整项目结构、理解知识图谱与AI推荐结合方式,或希望直接获取可运行源码进行二次开发的读者。

1. 这个基于知识图谱和生成式AI的智能食谱推荐系统,解决的是什么问题

在各种推荐系统里,最容易翻车的场景是:用户说“今晚想吃得清淡点,蛋白质高一点,但不能碰花生,最好十五分钟能端上桌”。协同过滤在没有用户行为数据时直接失效,纯标签匹配又答不了这种组合约束。这个基于知识图谱和生成式AI的智能食谱推荐系统,就是冲这类需求来的毕业设计源码:先用图数据库把食材、菜谱、营养标签、场景之间的关联结构化,再把图查询召回的结果交给大模型,生成带理由的推荐文案。项目主干分成 Python 后端、React 前端和知识图谱数据层,源码本地编译可运行,适合计算机相关专业做毕设、课程设计或期末大作业,有 Web 基础就能改。

2. 知识图谱层:四类节点和五条关系,如何把推荐约束变成图查询

2.1 推荐为什么需要一个图结构:从冷启动和组合约束说起

先说结论:传统推荐在这个场景下的短板不在算法本身,而在数据表达。协同过滤依赖“用户—物品”交互记录,新用户进来没有任何行为,推荐结果只能靠全局热门,这就是冷启动问题。关键词标签匹配稍微好一点,但它是平面结构,回答不了“低卡但要有鸡胸肉、还要适合减脂期”这种带多约束的查询——标签系统里这些条件只能靠 AND 硬拼,拼出来的结果往往既不准也不可解释。

知识图谱把“菜谱包含哪些食材”“菜谱打了什么营养标签”“菜谱适合什么场景”“哪些食材互相冲突”这些关系变成图上的路径。查询条件变成一次图遍历,比如“从鸡胸肉食材节点出发,走到所有菜谱节点,再过滤掉含花生的路径”,这就是天然的可解释推荐。后面接大模型时,图谱已经给出了结构化候选集,大模型只负责润色文案、补充理由,而不是凭空生成菜谱,这样既避免了 AI 幻觉,也保住了推荐结果可控。

2.2 节点与关系设计:四类实体和五条关系,照着建就行

这个系统里我按使用频率把图谱设计成四类节点:Recipe,即菜谱节点,记录菜名、热量、烹饪时间、难度;Ingredient,食材节点,记录名称、分类,比如肉类、蔬菜、豆制品;NutrientTag,营养标签节点,比如低卡、高蛋白、少油;Scene,场景节点,比如减脂期、学生餐、节日聚餐。

关系也固定成五条:Recipe 指向 Ingredient 的 CONTAINS,表示菜谱包含某食材;Recipe 指向 NutrientTag 的 HAS_TAG,表示菜谱具备某营养属性;Recipe 指向 Scene 的 SUIT_FOR,表示适合某场景;Ingredient 指向自身的 IS_A,表示上下级分类,比如“鸡胸肉 IS_A 禽肉”;Ingredient 之间用 CONFLICT_WITH 表示禁忌冲突,比如“花生 CONFLICT_WITH 对坚果过敏人群”。

对应建库时,先给节点字段建唯一约束,避免导入重复数据。这里给出初始化结构的 Cypher 语句:

// 为两类核心节点建立唯一约束,防止重复导入 CREATE CONSTRAINT recipe_id IF NOT EXISTS FOR (r:Recipe) REQUIRE r.id IS UNIQUE; CREATE CONSTRAINT ingredient_id IF NOT EXISTS FOR (i:Ingredient) REQUIRE i.id IS UNIQUE; // 建立索引,按名称查食材时避免全图扫描 CREATE INDEX ingredient_name IF NOT EXISTS FOR (i:Ingredient) ON (i.name);

逻辑说明:第一段给菜谱和食材的 id 字段建唯一约束,这是后续用 MERGE 做幂等写入的前提,重复执行导入脚本不会产生双份数据。第二段给食材名称建索引,推荐接口里大量查询以食材名称为入口,没有索引时节点多了会明显变慢。

参数说明:约束名 recipe_id、ingredient_id 是自定义的,可改成你习惯的命名;REQUIRE 语法要求当前图数据库版本支持,如果用的是老版本,需要把约束语法换成 CREATE CONSTRAINT ON (r:Recipe) ASSERT r.id IS UNIQUE 这种旧写法。

2.3 召回查询:一个 Cypher 同时过滤热量、食材与场景

图模型立住以后,核心召回逻辑就变成一条 Cypher 查询。后端拿到用户输入“鸡胸肉 + 低卡 + 减脂期”后,把约束翻译成图遍历,召回符合条件的菜谱并按热量升序返回:

// 召回:含鸡胸肉、带低卡标签、适合减脂期的菜谱 MATCH (r:Recipe)-[:CONTAINS]->(i:Ingredient {name: '鸡胸肉'}) WITH r MATCH (r)-[:HAS_TAG]->(t:NutrientTag {name: '低卡'}) MATCH (r)-[:SUIT_FOR]->(s:Scene {name: '减脂期'}) RETURN r.name AS recipe_name, r.calories AS calories, r.cook_time AS cook_time ORDER BY r.calories ASC LIMIT 10

逻辑说明:第一段先锁定包含鸡胸肉的菜谱,这一步把候选集收窄;WITH r 把当前结果传递到下一段,避免后续 MATCH 产生笛卡尔积膨胀。后面两段分别校验“低卡”标签和“减脂期”场景,三个条件全部满足的菜谱才进入返回结果。

参数说明:name 的值是中文时注意保证 Neo4j 数据库存储编码为 UTF-8,否则匹配不上;LIMIT 10 控制召回数量,实际项目里我建议调到 20,给后面规则过滤和 LLM 排序留一些余量。ORDER BY r.calories ASC 是简单的热量升序,如果你想优先推荐口味更重的菜,可以改成按评分字段降序。

2.4 数据初始化:从 CSV 到图数据库的常规导入路径

这个项目里的菜谱和食材数据量不大,常见做法是用 CSV 存放在后端某个 data 目录里,首次启动时一次性导入。导入脚本长这样:

// 从 CSV 导入食材节点,MERGE 保证重复执行不产生重复节点 LOAD CSV WITH HEADERS FROM 'file:///ingredients.csv' AS row MERGE (i:Ingredient {id: row.id}) SET i.name = row.name, i.category = row.category;

逻辑说明:LOAD CSV WITH HEADERS 会把 CSV 第一行当字段名,row.id、row.name 对应每一列。MERGE 会先按 id 查找节点,存在则只更新属性,不存在才创建,配合前面建的唯一约束,这个脚本可以反复执行。

参数说明:file:///ingredients.csv 是相对路径,文件必须放在图数据库安装目录的 import 文件夹下,否则会报找不到文件的错误。导入中文 CSV 时,我犯过一个低级错误:用 Excel 另存的 CSV 默认是 ANSI 编码,进图数据库后中文全变乱码,解决方法是把文件另存为 UTF-8 编码,或者用 Python 脚本先做一次编码转换再导入。

3. Python 后端:main.py 里的混合推荐主链路,从路由到模型调用

3.1 从入口看整体流程:FastAPI 风格的路由与中间件

打开 main.py,从目录结构和路由写法能看出这个工程用的是 FastAPI 风格。整个后端流程是:前端把用户需求 POST 到 /recommend 接口,服务端先连接图数据库做召回,再做规则过滤,最后调用大模型生成推荐文案。下面这段代码是这个工程里最核心的应用初始化部分:

from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel app = FastAPI(title="recipe-recommender") # 前后端分离开发时必须配 CORS,否则浏览器会拦截请求 app.add_middleware( CORSMiddleware, allow_origins=["http://localhost:8000"], allow_methods=["*"], allow_headers=["*"], ) class RecommendRequest(BaseModel): ingredients: list[str] # 用户手头有的食材 max_calories: int = 600 # 热量上限,默认 600 千卡 tags: list[str] = ["低卡"] # 营养标签,可多选 scene: str = "减脂期" # 场景 avoid_food: list[str] = [] # 禁忌食材

逻辑说明:CORSMiddleware 加在应用实例上,用于解决前端页面在 localhost 不同端口调用接口时的跨域拦截问题。RecommendRequest 是一个 Pydantic 模型,FastAPI 会自动校验请求体结构,前端漏传字段时用默认值兜底,避免接口直接报 422 错误。

参数说明:allow_origins 里的地址要写成前端实际跑起来的地址,比如前端用 8000 端口,这里就放 http://localhost:8000。max_calories、tags、scene 的默认值是我按“减脂推荐”这个典型场景设置的,实际使用中前端会显式传值覆盖。avoid_food 是禁忌食材列表,对应图谱里的 CONFLICT_WITH 关系,下面过滤阶段会用到。

3.2 图召回、规则过滤、大模型解读:三段式推荐逻辑

推荐主链路我习惯拆成三个函数,每个函数只干一件事,出了问题也好排查。第一段 graph_recall 负责把请求参数拼成 Cypher 查询,执行并返回候选菜谱;第二段 rule_filter 负责把候选里含禁忌食材的菜谱剔除掉,顺带做去重;第三段 llm_interpret 把结构化候选交给大模型生成带理由的推荐文案。

def recommend(req: RecommendRequest): # 第一步:按请求参数查知识图谱,召回候选菜谱 candidates = graph_recall(req) # 第二步:剔除含禁忌食材的菜谱,并做基础去重 filtered = rule_filter(candidates, req.avoid_food) # 第三步:调用大模型,把结构化候选转成推荐文案 result = llm_interpret(filtered, req) return {"items": result}

逻辑说明:候选人少时,rule_filter 里直接用 Python 集合操作判断菜谱包含的食材是否和 avoid_food 有交集即可,不需要再查图。llm_interpret 放在最后一步,是因为大模型调用耗时且可能失败,先尽量把候选集缩到最小再调用,能显著降低超时概率和调用成本。

参数说明:graph_recall 内部把 req.ingredients 里的食材名称用参数化查询拼进 Cypher,避免字符串拼接注入风险。rule_filter 注意保留原始顺序,因为图查询结果已经是按热量排好序的,过滤后如果重新排序会丢失这一层优先级。

3.3 大模型调用封装:temperature、timeout 和降级策略

大模型在这个系统里的定位不是生成菜谱,而是把图谱召回结果“翻译”成用户能直接看的推荐理由。所以 prompt 里要明确告诉模型:只能基于给定菜谱列表回答,不要自己编新菜。下面这段是这类调用比较稳的封装方式:

import requests def call_llm(prompt: str) -> str: resp = requests.post( LLM_ENDPOINT, headers={"Authorization": f"Bearer {LLM_API_KEY}"}, json={ "model": LLM_MODEL, "messages": [{"role": "user", "content": prompt}], "temperature": 0.3, "max_tokens": 800, }, timeout=20, ) resp.raise_for_status() return resp.json()["choices"][0]["message"]["content"] def llm_interpret(candidates, req): if not candidates: return {"message": "没有找到匹配的菜谱", "items": []} try: prompt = build_prompt(candidates, req) content = call_llm(prompt) return parse_llm_reply(content) except Exception: # 大模型挂了也不让接口挂:降级返回结构化候选 return {"items": candidates[:3], "source": "graph_fallback"}

逻辑说明:call_llm 把 prompt 和参数包成 POST 请求,拿到响应后只取 choices 里的文本内容。llm_interpret 里 try 包裹整个调用过程,任何异常都走降级分支,直接返回图谱候选,保证接口始终有响应,不会因为外部服务不稳定而整体不可用。

参数说明:temperature 设为 0.3,是为了让模型在“基于事实改写”时少发散,数值越高越容易发挥但越容易偏离候选菜谱。max_tokens 限制回复长度,按 800 够输出三四条带理由的推荐了。timeout 设 20 秒是这个场景的临界值,低于 10 秒在模型服务忙时基本必超时,高于 30 秒会让前端等到失去耐心。

3.4 配置管理:密钥、数据库连接串不要写进仓库

这个项目运行时需要两类敏感配置:大模型 API 密钥和图数据库连接信息。常见做法是单独建一个 config.py,从环境变量读取,而不是把真实密钥硬编码进 main.py。我见过太多临时毕设项目把密钥写死在代码里,最后打包发布时跟着源码一起泄露。

import os LLM_ENDPOINT = os.getenv("LLM_ENDPOINT", "") LLM_API_KEY = os.getenv("LLM_API_KEY", "") LLM_MODEL = os.getenv("LLM_MODEL", "default-model") NEO4J_URI = os.getenv("NEO4J_URI", "bolt://localhost:7687") NEO4J_USER = os.getenv("NEO4J_USER", "neo4j") NEO4J_PASSWORD = os.getenv("NEO4J_PASSWORD", "")

逻辑说明:每个变量用 os.getenv 读取,带默认值的适合本地开发,比如数据库地址默认连本机;不带默认值的强制要求环境变量存在,缺失时启动阶段就应该抛错提醒。这个文件本身建议写进 .gitignore。

参数说明:NEO4J_URI 默认 bolt://localhost:7687,这是图数据库的 Bolt 协议端口,注意不要和浏览器访问用的 7474 端口混淆,Python 驱动用的是前者。LLM_MODEL 里的默认值按你实际接的模型服务商文档填,不同厂商的模型标识符差异很大。

4. React 前端:food-react-master 的工程结构、启动与联调

4.1 前端页面与组件职责:从 pages 到 common 的目录分工

前端部分是一个标准的 umi 工程,目录结构里 src 下面分了 pages、components、assets、layouts、common 几类。pages 是页面级路由组件,components 放在页面里复用的业务组件,layouts 是整体布局,common 主要放请求封装、常量定义这类跨页面共享的逻辑。

按这个工程实际页面来对,我判断至少有四类页面:首页,展示推荐入口和菜品卡片流;推荐结果页,展示后端返回的菜谱列表和推荐理由;知识图谱可视化页,用关系图组件展示食材和菜谱的关联结构;AI 对话页,用户输入需求,后端返回生成式文案。组件层面会拆出菜谱卡片、食材标签选择器、搜索框、加载状态这几个高频复用件。

4.2 pnpm 安装与启动:一次跑通的命令序列

项目里带 pnpm-lock.yaml,说明依赖管理用的是 pnpm,不是 npm。新机器上第一次跑,命令顺序和版本匹配是两个最容易踩坑的点。按下面顺序来通常能直接跑通:

# 进入前端工程目录 cd food-react-master # 安装依赖,使用 lock 文件锁定版本 pnpm install # 启动开发服务器 pnpm start

逻辑说明:pnpm install 严格按 pnpm-lock.yaml 解析依赖版本,同一个 lock 文件在不同机器上装出来的依赖树是一致的,这是保证“本地编译可运行”的关键。pnpm start 会读取 .umirc.ts 里的配置,默认起一个开发服务器,并监听代理转发规则。

参数说明:如果本机没装 pnpm,先通过系统包管理器或 Node 自带的 corepack 启用;启动前检查 Node 版本,和工程开发时用的版本差异过大时,pnpm install 可能会因为某些原生依赖编译失败而报错。另外注意不要直接删掉 pnpm-lock.yaml 改成 npm install,这会把依赖树整个打散,容易触发版本不兼容。

4.3 请求封装与跨域代理:前端怎么和后端连通

前后端分离开发时,最省事的连通方式不是在后端配 CORS,而是在前端开发服务器里配代理。.umirc.ts 里通常有一段 proxy 配置,把前端发出的 /api 请求转发到后端地址:

export default { npmClient: 'pnpm', proxy: { '/api': { target: 'http://127.0.0.1:8000', changeOrigin: true, }, }, };

逻辑说明:开发服务器收到路径以 /api 开头的请求时,会转发到 target 指定的 8000 端口。这样浏览器里页面请求的同源地址,实际上被开发服务器代理到了后端,天然绕开跨域限制。

参数说明:target 要写后端实际监听地址,如果后端跑在别的机器上就改成对应 IP;changeOrigin 设为 true 后,后端收到请求时的 Host 头会是 target 地址,否则某些框架会因 Host 校验拒绝请求。上线时这段代理配置不再生效,需要由部署端 Web 服务器统一做反向代理。

请求调用的封装通常放在 common 目录,统一走 umi 自带的 request 方法:

import { request } from 'umi'; // 把后端 /recommend 接口封装成可复用函数 export async function fetchRecommend(params: RecommendParams) { return request('/api/recommend', { method: 'POST', data: params, }); }

逻辑说明:umi 的 request 自动带上 JSON 序列化和 Content-Type 头,页面组件里只需要调 fetchRecommend 拿到 Promise。返回体结构要和后端保持一致,推荐接口的返回是 { items: [...] },页面拿到 items 后渲染成菜谱卡片列表。

参数说明:params 是调用方传入的对象,对应后端 RecommendRequest 的字段名,建议和 Python 侧的字段保持完全一致,比如 ingredients、max_calories,否则后端解析会得到空值或报 422。

4.4 知识图谱可视化与 AI 对话页的数据流

图谱可视化页不是重点功能,但做好了答辩效果会好看不少。数据通常来自后端一个只读接口,返回图谱的节点和关系数组,前端用关系图组件渲染。数据流是:页面加载时 GET /graph 拿到 { nodes: [...], links: [...] },传给图表组件生成力导向图。注意数据量,知识图谱节点几百个时问题不大,几千个时浏览器会明显卡顿,此时后端要做节点数截断。

AI 对话页的数据流稍有不同:用户输入一句话,前端 POST 到 /recommend 或单独的 /chat 接口,后端查图拿到候选后交给大模型,返回的文案带流式效果。如果没有实现流式输出,可以退而求其次,把大模型响应整个拿回来后一次性渲染,交互略笨但功能完整。

5. 避坑记录:安装、图数据与接口联调的 5 个典型翻车点

5.1 依赖安装与版本类

现象:前端目录执行 pnpm install,中途报错 panic,或者装完以后 pnpm start 直接白屏,控制台一堆模块找不到。

原因:大多数情况是本机 Node 版本和工程开发时期的主版本不一致。pnpm-lock.yaml 里锁了部分依赖的安装包版本,但 Node 运行时的原生模块需要重新编译,版本差异大就编译失败。

解决:先看 package.json 里的 engines 字段或 README 里是否标注了兼容 Node 版本;没有标注就查看 lock 文件首部的 lockfileVersion 判断年代,把本机 Node 切到该时代的主流版本区间。切换版本用 nvm 这类版本管理工具最省事,装好以后重新跑 pnpm install,不要在原报错目录上反复重试。

5.2 数据库连接类

现象:后端启动时报连接失败,提示 localhost 对应端口没有服务监听,或认证失败无法连接数据库。

原因:两个端口容易搞混——图数据库的浏览器界面走 HTTP 端口 7474,Python 驱动连的是 Bolt 端口 7687。后端配置里写的是 bolt 端口,但有人用浏览器先打开了 7474 确认服务在跑,后端却还是连不上;另一种情况是数据库服务压根没启动,或密码和 Neo4j 安装时设置的不一致。

解决:先用浏览器访问 7474 端口确认数据库面板能打开,再用命令行或 Python 脚本测试 7687 端口通不通。认证错误时,到数据库配置文件里临时改回初始密码,或者通过命令行工具执行重置密码语句。最保险的做法是把 NEO4J_PASSWORD 配置通过环境变量传入,别写死在 config.py 里。

5.3 食材名称匹配不上

现象:接口返回空列表,但明明图数据库里有这道菜,直接在图查询工具里执行同样的 Cypher 却查得到。

原因:中文名称匹配不上,基本都是编码或字段值不一致。最常见的是导入 CSV 时编码不是 UTF-8,图数据库里存的是乱码;其次是查询参数里带空格,比如“鸡胸肉 ”和“鸡胸肉”不匹配;还有一种情况是导入时某个食材把“鸡胸肉”写成了“鸡小胸”,名称字段不统一。

解决:把导入用的 CSV 全部转成 UTF-8 编码再导入;在查询前对请求参数做 strip 去空格;名称统一问题先在项目里建一个食材别名映射表,把“鸡小胸”“鸡里脊”这类近似食材映射到标准名,再进图查询。

5.4 大模型接口超时导致推荐失败

现象:前端点推荐按钮后转圈几十秒,最后接口报错,页面弹网络错误;后端日志显示 requests.post 一直卡住直到抛异常。

原因:大模型服务响应慢是常态,尤其在调用高峰时段。没设 timeout 时,requests 库默认会一直等到底层 TCP 超时,用户体验就是白等。更隐蔽的问题是重试机制缺失:一次偶发超时就直接让整个推荐链路失败,而图召回阶段的结果本来是可用的。

解决:调用层统一加 timeout,并且把重试逻辑做成两层——第一层针对连接错误快速重试一次,第二层在最终失败时走降级,直接返回图谱候选集。这样即使大模型完全不可用,推荐功能也只是少了文案,不会整体崩溃。

5.5 跨域与请求路径不一致

现象:前端页面能打开,但所有推荐请求都报 CORS error 或 404。CORS error 说明请求发出去了但响应被浏览器拦截;404 说明请求路径和后端路由对不上。

原因:CORS error 通常是后端中间件没配或 allow_origins 没包含前端地址;404 大部分是代理配置里前端发 /api/recommend,后端路由却定义在 /recommend,代理没有做路径重写。

解决:路径不一致时,在代理配置里加一层路径重写,把 /api 前缀剥掉再转发;或者在 FastAPI 侧把路由统一加 /api 前缀。两种做法选一种,但前后端要约定一致,不要出现前端发 /api/recommend、后端只接受 /recommend 的割裂状态。

6. 验证推荐质量:用一份需求清单把系统调到能答辩的状态

系统跑通只是第一步,推荐质量才是答辩时被追问的重点。我习惯在项目里维护一份“需求清单”,包含 5 类典型用户需求:低卡减脂、高蛋白增肌、少油清淡、不吃某类食材、节日聚餐。每轮改完推荐逻辑后,不用人工去页面点,直接用 curl 把清单里的需求逐条打进接口,看返回结果是否符合硬约束:

curl -X POST http://127.0.0.1:8000/recommend \ -H "Content-Type: application/json" \ -d '{"ingredients":["鸡胸肉"],"max_calories":600,"tags":["低卡"],"scene":"减脂期","avoid_food":["花生"]}'

判断返回结果时我只看三件事:硬约束有没有违反,比如含花生却被推荐;排序是否符合用户预期,低卡需求下热量低的排前面;生成的推荐理由是不是从候选菜谱事实出发,有没有出现模型凭空编造的菜名。三条都满足,这一轮调优就算过了。

这类“清单自测”适合开发期反复执行,比页面点点点快得多。真正答辩前再补一次链路走查:启动后端,确认图数据库服务在跑,再启动前端,从页面完成一次完整推荐。后端日志里重点看两个指标,Cypher 查询执行时间是否在百毫秒级,大模型调用是否稳定。太慢就检查图谱数据量是否过大,或查询是否没有走索引。

这套代码本身已经把三种关键技术串起来了:知识图谱保证推荐可解释可约束,生成式 AI 保证推荐文案像真人写的,前后端分离方便各部分单独调试。如果你想在毕设里加点差异化改进,我建议优先做一层缓存,把相同查询参数的推荐结果缓存几分钟,图查询和大模型调用都省了,系统响应速度会有一个明显提升——这个改动要动的地方不多,后端推荐函数外面包一层缓存逻辑就行。

这项目我先后跑过两遍,第一遍就是没注意 Node 版本,卡在前端白屏上大半天;第二遍老老实实按清单先检查环境再启动,半小时就跑通了全部链路。从那以后我每次拿到新源码,都强制先看一眼 lock 文件、再确认数据库服务状态,才动手执行任何安装命令。希望帮到你。

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

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

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

立即咨询