简介:面向计算机相关专业学生与开发者,围绕基于Python、知识图谱(Neo4j)和生成式AI的智能食谱推荐系统,这份资源提供了一套可直接用于毕业设计、课程设计或项目立项的高分完整项目,尤其适合软件工程、人工智能等方向的学生参考。资源内包含前端页面、组件、公共布局、Python后端入口及部署脚本等模块,并附详细文档、全部数据资料与图片素材,代码结构清晰,目录划分明确,便于快速定位推荐逻辑、知识图谱构建、接口调用与界面渲染等关键部分。压缩包共43个文件,核心为19个tsx、9个less等前端组件与样式文件,搭配ts逻辑、Python脚本、YAML配置与Shell部署文件,整包约682KB,下载后即可在本地环境运行调试。目前已有293人浏览学习。项目在mac及Windows 10/11上验证通过,答辩评审分达95分,既可直接提交使用,也可针对推荐算法、图谱查询或AI交互模块进行二次扩展,是兼顾完成度与学习价值的实用范例。
1. 智能食谱推荐系统为什么需要知识图谱和生成式AI一起上
当用户说“今天想吃点清淡的,最好能控糖”,普通推荐系统能做的只是把“清淡”拆成关键词,剩下全靠猜。基于Python+知识图谱(Neo4j)的做法则把食材GI值、烹饪方式、用户忌口都建模成显式关系,推荐问题变成一条带约束的图查询;生成式AI补的是最后一环,把结构化查询结果翻译成有做法、有步骤的人话。三者合在一起就是这个智能食谱推荐系统的核心链路:Python做编排,Neo4j做事实层,LLM做语义转换。适合两类人看:准备毕业设计或课程项目的同学,想把图谱建模、图查询调优和LLM输出控制一次练完;以及想了解推荐系统如何从行为矩阵切到语义约束的工程师。这个题目最大的价值不在算法多深,而在于每条推荐都能用一条关系路径解释给评委听。
2. 食谱知识图谱怎么建:实体关系、约束索引与 Neo4j 导入 CSV 的三种姿势
2.1 为什么选知识图谱而不是协同过滤或向量检索
食谱推荐最常见的错误是照搬商品推荐的协同过滤套路,给用户-菜品行为矩阵算相似度。但食谱场景里绝大多数用户不会留下足够密集的评分,一道菜只被吃过两三次就进了稀疏矩阵的角落;向量检索能解决文本召回,却处理不了“不含花生”“小于30分钟”“要蒸的”这类反向与范围约束。知识图谱把约束放在边上和属性上,查询时用NOT EXISTS和范围过滤直接完成,推理过程也能回溯给用户看。三者的定位差异如下:
| 方案 | 数据依赖 | 反向约束 | 推荐理由可解释性 | 冷启动表现 |
|---|---|---|---|---|
| 协同过滤 | 用户行为矩阵 | 不支持,要额外做过滤层 | 弱 | 差 |
| 向量检索 | 菜谱文本向量 | 需要后处理 | 中 | 中 |
| 知识图谱 | 食材-菜品关系 | 原生NOT EXISTS支持 | 强 | 中 |
这不是说知识图谱万能。它的代价在构建阶段:食材别名、单位换算、分类树都要整理过一遍才能支撑查询。毕业设计的数据规模通常在一千至几千道菜,建图成本完全可控,还能顺便展示本体建模能力。所以这个标题把知识图谱(Neo4j)放在C位是成立的——建模成本低、查询表达力强、演示效果好三者同时成立。
2.2 实体与关系设计:把用量放在关系属性上
设计食谱图谱时,第一件事是定节点与关系的粒度。最常见的结构是三层:Dish表示菜品,Ingredient表示食材,Category表示烹饪方式或菜系分类,User表示用户。关系上,Dish到Ingredient用INCLUDES,Dish到Category用BELONGS_TO,User与Dish之间保留HISTORY,User与Category或Ingredient之间放LIKES/DISLIKES。先建约束和索引,保证后面MERGE幂等:
CREATE CONSTRAINT dish_name IF NOT EXISTS FOR (d:Dish) REQUIRE d.name IS UNIQUE; CREATE CONSTRAINT ingredient_name IF NOT EXISTS FOR (i:Ingredient) REQUIRE i.name IS UNIQUE; CREATE INDEX category_name_index IF NOT EXISTS FOR (c:Category) ON (c.name);约束在这里同时是唯一索引,能让MERGE按名称定位节点,避免重复导入产生“同菜多名”。下面这一段体现关键设计决策:
MERGE (d:Dish {name: '清蒸鲈鱼'}) MERGE (i:Ingredient {name: '鲈鱼'}) MERGE (c:Category {name: '蒸菜'}) MERGE (u:User {id: 'u1001'}) MERGE (d)-[:INCLUDES {amount: 600, unit: 'g', role: 'main'}]->(i) MERGE (d)-[:BELONGS_TO]->(c) MERGE (u)-[:HISTORY {rating: 5, at: datetime()}]->(d);注意amount、unit、role放在INCLUDES关系上而不是Ingredient节点上。理由:同一食材在不同菜里的用量和主辅角色不同,放属性上才能直接过滤“主料必须包含鲈鱼”,而不必去读食材的全局属性。另外User到Dish的HISTORY带着评分和时间戳,评分供排序,时间戳用来剔除近三个月重复推荐。
2.3 Neo4j 导入 CSV 的三种姿势与字段清洗细节
食材和菜品数据最常以CSV提供,Neo4j导入CSV文件有两条主流路径:交互式清洗用LOAD CSV,首次建库的百万级导入用neo4j-admin import,脚本管道里也可以交给APOC的apoc.load.csv。三者的边界如下:
| 方式 | 适用量级 | 事务性 | 是否支持MERGE | 典型用途 |
|---|---|---|---|---|
| LOAD CSV | 几十万行以内 | 每批自动提交 | 支持 | 日常补数据、带清洗的导入 |
| neo4j-admin import | 百万级 | 离线全量 | 不支持 | 重建数据库 |
| apoc.load.csv | 中等 | 可控制 | 支持 | 导入同时调用其他过程 |
最常见的作业场景是LOAD CSV。注意CSV所有字段进入Cypher后都是字符串,数值必须显式转换;同时养成用trim清理空格的习惯:
LOAD CSV WITH HEADERS FROM 'file:///dishes.csv' AS row WITH row WHERE trim(row.name) <> '' MERGE (d:Dish {name: trim(row.name)}) SET d.cooking_time = toInteger(row.cooking_time), d.difficulty = row.difficulty, d.rating = toFloat(row.rating);这段导入逻辑里,WHERE先过滤掉空行,避免MERGE建出空节点;toInteger和toFloat负责类型转换。若字段分隔符与菜名里的逗号冲突,把CSV导出为管道符分隔,然后在LOAD CSV后加FIELDTERMINATOR '|'。关于批量提交:在Neo4j 5.x里LOAD CSV按事务自动分批,不需要额外加USING PERIODIC COMMIT;如果环境是4.x,则在LOAD CSV之前写USING PERIODIC COMMIT 500,并配合内存参数避免事务膨胀。
3. 生成式 AI 参与推荐的链路:LLM 解析用户意图,图谱查询出事实,再生成菜谱文案
3.1 把自然语言解析成固定 JSON:Prompt 白名单与 temperature 参数
生成式AI在这套系统里最稳的位置是“语义解析器”和“文案生成器”,而不是事实来源。第一步,把用户自由文本转成结构化约束。常见做法是调用兼容OpenAI协议的接口,本地可以接ollama这类服务,线上则填对应的API地址;关键是Prompt要定义字段白名单和输出格式。我用temperature=0和json_object响应格式,保证相同输入不抖动:
import json from openai import OpenAI client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama") SYSTEM_PROMPT = """你是一条菜谱查询解析器,只输出JSON,不要输出解释。 规则: 1. 字段只允许 methods, avoid_ingredients, max_cooking_time, taste, servings 2. methods 取值只能是 蒸、煮、炒、烤、炖、拌、炸 3. 用户没提到的约束不要凭空添加 4. 食材名使用常见中文名""" def parse_intent(text: str) -> dict: resp = client.chat.completions.create( model="qwen2.5:7b", messages=[ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": text}, ], response_format={"type": "json_object"}, temperature=0, ) return json.loads(resp.choices[0].message.content)这里最容易被忽略的是第2条白名单:把methods限制死,大模型就不会生成图谱里根本不存在的分类名,后面的Cypher查询才能稳定命中。response_format和temperature=0是组合使用的,只开一个都不够。首次运行前,记得在VS Code里配好Python虚拟环境并执行pip install openai neo4j fastapi uvicorn,依赖装齐再调试。
3.2 编排代码:先让 Neo4j 出事实,再交给生成式 AI 措辞
推荐引擎的编排顺序决定了系统可信度。我一般会先查图谱拿候选菜品,再做文案生成;反过来先让LLM生成食谱再接图谱校验,会把幻觉扩散到最终展示层。参与各环节的职责分配如下:
| 环节 | 输入 | 输出 | 事实责任 |
|---|---|---|---|
| LLM意图解析 | 用户文本 | JSON约束 | 语义转换 |
| Neo4j图谱查询 | JSON约束 | 菜品候选与食材事实 | 事实来源 |
| LLM文案生成 | 候选列表与食材事实 | 完整菜谱文案 | 措辞润色 |
核心代码如下:
from neo4j import GraphDatabase class RecipeEngine: def __init__(self, uri, user, password, llm_client): self.driver = GraphDatabase.driver(uri, auth=(user, password)) self.llm = llm_client def recommend(self, text: str, user_id: str): intent = parse_intent(text) with self.driver.session() as session: dishes = session.execute_read(self._query_dishes, intent, user_id) return self._render(dishes, intent) @staticmethod def _query_dishes(tx, intent, user_id): cypher = """ MATCH (d:Dish)-[:BELONGS_TO]->(c:Category) WHERE c.name IN $methods AND (d.cooking_time IS NULL OR d.cooking_time <= $max_time) AND NOT EXISTS { MATCH (d)-[:INCLUDES]->(i:Ingredient) WHERE i.name IN $avoid } RETURN d.name AS name, d.cooking_time AS cooking_time, [(d)-[:INCLUDES]->(i) | i.name] AS ingredients ORDER BY d.rating DESC LIMIT 10 """ result = tx.run( cypher, methods=intent.get("methods", []), max_time=intent.get("max_cooking_time", 120), avoid=intent.get("avoid_ingredients", []), ) return [record.data() for record in result]这段代码有四个值得照抄的点。一是查询用execute_read而不是execute_write,Neo4j Python Driver 5.x对读事务压力小,也便于连接池复用。二是NOT EXISTS子查询直接从候选里剔除含忌口食材的菜,反向约束一次完成。三是列表推导式[(d)-[:INCLUDES]->(i) | i.name]一次性取食材名,避免二次查询。四是ORDER BY用图谱里的rating字段,而不是让LLM拍脑袋排序。
3.3 幻觉控制:食材集合必须是图谱事实的子集
生成式AI写“做法步骤”时,最容易凭空加食材或编造用量。如果让LLM自由发挥,校验环节就要兜底。我通常把生成结果和图谱已查到的食材事实做子集比对,再检查步骤数量和营养数值区间:
def validate_recipe(llm_recipe: dict, graph_facts: dict) -> bool: if "ingredients" not in llm_recipe or "steps" not in llm_recipe: return False gen_set = {item["name"] for item in llm_recipe["ingredients"]} graph_set = set(graph_facts["ingredients"]) if not gen_set.issubset(graph_set): missing = gen_set - graph_set raise ValueError(f"生成内容包含图谱外食材: {missing}") if not (3 <= len(llm_recipe["steps"]) <= 8): raise ValueError(f"步骤数异常: {len(llm_recipe['steps'])}") return True这段校验的优点是纯集合运算加长度判断,毫秒级完成。原则就一条:图谱负责事实,生成式AI负责润色,生成结果永远不能超出图谱已知范围。这样即使LLM输出一次跑偏,最多是文案难看,不会出现推荐菜里混入过敏原食材的硬伤。
4. 把推荐引擎调得能扛演示:连接池参数、Cypher 索引和冷启动兜底策略
4.1 项目结构先对齐:Python 工程里图谱与LLM各自分层
一个能交付的智能食谱推荐系统,工程目录不应该只有一个脚本。把图谱访问、LLM调用、推荐编排、接口四层分开,调试时能单独替换任意一层而不会互相牵连:
recipe-ai/ ├── app/ │ ├── main.py # FastAPI 入口 │ ├── graph/driver.py # Neo4j 驱动初始化 │ ├── graph/cypher.py # Cypher 查询语句集中管理 │ ├── llm/parser.py # LLM 意图解析 │ ├── llm/rendering.py # 菜谱文案生成 │ └── services/recommender.py ├── data/*.csv └── tests/在graph/driver.py里,驱动初始化不要写在每次请求里,模块级创建单例即可:
from neo4j import GraphDatabase _driver = None def get_driver(uri="bolt://localhost:7687", user="neo4j", password=None): global _driver if _driver is None: _driver = GraphDatabase.driver( uri, auth=(user, password), max_connection_pool_size=50, connection_acquisition_timeout=60, ) return _driver4.2 3 个必调参数:连接池、事务超时与索引命中
演示现场最怕两种状况:接口卡死、内存不足。所以参数设置要提前做。连接池本身不影响单查询速度,但它决定并发度;事务超时防止某条坏Cypher把整个进程拖住;索引则决定查询从全表扫描变成索引查找。三个必调参数如下:
| 参数 | 建议值 | 配置位置 | 说明 |
|---|---|---|---|
| max_connection_pool_size | 50 | Python驱动 | 并发峰值决定,本地演示50足够 |
| db.transaction.timeout | 10s | neo4j.conf | 超过10秒的事务被终止 |
| 约束/索引 | Dish.name, Ingredient.name | Cypher DDL | MERGE和等值查询命中唯一索引 |
参数调完,还要用EXPLAIN确认执行计划,看是否走了索引:
EXPLAIN MATCH (d:Dish)-[:BELONGS_TO]->(c:Category) WHERE c.name = '蒸菜' RETURN d.name LIMIT 20;看到计划里出现Index seeks而不是NodeByLabelScan,才算真正命中索引。如果查询仍慢,优先检查WHERE条件里的属性是否都建了索引,以及LIMIT是否被放在ORDER BY之前。常见误区是只给Dish.name建索引,却用Category.name过滤,结果全表扫了Category节点。另一个常见误区是急着在Neo4j上跑图神经网络,演示场景的数据量撑不起训练效果,反而把工程复杂度拉高;图谱在这里是存储与查询引擎,不是模型训练场。
4.3 冷启动:没行为数据时用知识图谱和 LLM 兜底
用户第一次进系统,HISTORY关系为空,协同过滤直接失效;知识图谱的兜底逻辑是把意图里的食材做种子,先生成候选再个性化。混合策略一般是这样:
def hybrid_recommend(user_id: str, text: str): if user_has_history(user_id): return recommend_by_history(user_id, text) intents = parse_intent(text) if intents.get("avoid_ingredients"): return recommend_with_dietary_filter(text, intents) return recommend_by_popularity(intents.get("methods", []))这里的分支逻辑排在意图解析之后,因为生成式AI已经把“少油”“清淡”这类语义转成了methods和max_cooking_time,冷启动查询就有了精确入参。注意别在用户没历史时硬套协同过滤,否则返回的是空列表,演示效果会很难看。
5. 排错与数据一致性:Neo4j 连接问题、只显示 25 个标签和知识图谱脏数据
5.1 连接测试:驱动版本、认证和超时排查
Neo4j安装与配置完后,最常见的任务是确认应用能连通。先在Python里做一次最小连通性测试:
from neo4j import GraphDatabase driver = GraphDatabase.driver( "bolt://localhost:7687", auth=("neo4j", "password"), ) try: driver.verify_connectivity() print("connected") finally: driver.close()verify_connectivity会主动发起握手。失败的常规原因不多,按下面的表逐项查:
| 报错形式 | 常见原因 | 先查什么 |
|---|---|---|
| ServiceUnavailable | Neo4j没启动或Bolt端口写错 | 浏览器访问7474是否正常 |
| AuthenticationError | 密码与数据库账号不一致 | NEO4J_AUTH或auth传参 |
| DatabaseNotFound | database名不对 | 驱动里database参数是否写了库名 |
注意驱动版本要和Neo4j服务端匹配:Neo4j 5.x配neo4j Python Driver 5.x,不能用4.x的驱动连5.x的库。端口这里也容易混,7474是HTTP管理端口,驱动走7687的Bolt协议。
5.2 知识图谱只显示 25 个标签:是数据没入库还是浏览器限制
很多人在Neo4j Browser里看到左侧只出现25个标签,就以为建图失败。这不是数据丢失,是Browser的可视化上限:默认只展示前25个标签和相应关系类型,更多标签被折叠。想验证真实数量,用Cypher直接计数:
MATCH (n) RETURN labels(n) AS label, count(*) AS count ORDER BY count DESC;这条查询会列出全部标签及节点数,和可视化面板无关。如果需要调整Browser的显示,在设置里修改Graph Visualization的标签数量上限;但更建议把Browser当调试工具,业务展示用Web端Neo4j JavaScript Driver按需取子图,这样不再受“只显示25个标签”的限制。这个坑对经验者也常见,因为标签数量超过25大概率是节点类型被拆碎,回头检查建模是否合理。
5.3 脏数据校验:孤立节点、重复菜名与单位规范
图谱导入完毕,先跑三组校验再进推荐链路。孤立菜品没有关联食材,会当选入候选后展示空食材列表:
MATCH (d:Dish) WHERE NOT (d)-[:INCLUDES]->(:Ingredient) RETURN d.name AS empty_dish LIMIT 20;重复菜名在约束建好前可能已经混入,Python侧快速查重:
from collections import Counter names = [r["name"] for r in run_query("MATCH (d:Dish) RETURN d.name AS name")] duplicates = [name for name, n in Counter(names).items() if n > 1]单位不统一比菜名重复更隐蔽:同一食材有的存g,有的存克,有的存毫升。导入阶段就要做映射,把“克/公克/g”都归一为g,否则LLM生成文案里的用量会和图谱事实冲突,触发上一章的校验失败。单位映射表建议和CSV放在同一个data目录,清洗脚本和导入脚本分开,方便评委查看数据血缘。
6. 把“高分项目”的演示讲到评委心里:三条链路、Docker 部署和两个扩展方向
6.1 演示链路先排好,再开 Neo4j Browser
毕业设计答辩时,演示顺序比代码更影响观感。我会固定排三条链路:第一,输入“想吃蒸菜、不要花生、30分钟内”,展示LLM解析出的JSON,再展示图谱返回的候选,最后展示生成的完整菜谱,这条链路覆盖标题里三个关键词。第二,点一道推荐菜,切换到图谱子图视角,把“为什么推荐”解释成一条关系路径,这一步是知识图谱项目独有的加分项。第三,换一个没有历史记录的新用户ID重复第一次输入,展示冷启动兜底逻辑。正式演示前把候选结果先跑一遍并缓存到接口层,避免现场等LLM推理耗时;Neo4j Browser则提前执行一次预热查询,后续点击响应会明显变快。
6.2 Docker Compose 一份配置同时启动 Neo4j 和 API
本地环境最好一次拉起,而不是让评委看安装过程。用Compose把Neo4j和FastAPI绑在同一套配置里:
services: neo4j: image: neo4j:5-community ports: - "7474:7474" - "7687:7687" environment: NEO4J_AUTH: neo4j/password NEO4J_PLUGINS: '["apoc"]' volumes: - ./neo4j-data:/data api: build: . depends_on: - neo4j ports: - "8000:8000"NEO4J_AUTH设置初始密码,NEO4J_PLUGINS让社区版启用APOC;API服务的depends_on只保证容器启动顺序,还要在启动脚本里对7687端口做健康检查轮询,防止Neo4j未就绪时API反复重连。
6.3 扩展点:过敏原约束和食材替代
答辩被问“还能做什么”时,不要说空话,直接讲可落地的扩展。一是把“用户对某食材过敏”建模成(User)-[:ALLERGIC_TO]->(Ingredient),在_query_dishes的NOT EXISTS里追加一个条件,剔除任何包含过敏食材的菜,比在应用层过滤更早挡住风险;二是食材替代推荐,用同一Category或营养属性近似的Ingredient做替换候选,查询写成MATCH (i:Ingredient)-[:SUBSTITUTES]-(alt)即可。这两个扩展都建立在已有的INCLUDES、BELONGS_TO结构上,新增的ALLERGIC_TO和SUBSTITUTES两类关系各用一条MERGE,查询端只是给NOT EXISTS子句追加一行,图谱的语义边界却在演示中清楚展示出来了。
本文还有配套的精品资源,点击获取