ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

知识图谱+生成式AI食谱推荐系统:Python毕设落地与避坑指南

知识图谱+生成式AI食谱推荐系统:Python毕设落地与避坑指南

简介:这份资源是面向计算机相关专业毕业设计与项目实战学习者的智能食谱推荐系统源码包,以知识图谱结合生成式AI为核心思路,适合作为毕设、课程设计或期末大作业的参考方案。项目经导师指导并通过评审,源码在本地编译运行无误,难度适中,便于在此基础上二次开发或撰写论文。压缩包共42个文件,约681KB,以19个tsx与9个less构成前端页面与样式,配合3个ts、2个json及css完成配置与逻辑,另有1个py脚本、2个sh部署脚本和yaml等辅助文件,整体结构清晰、模块划分明确。内容涵盖食谱知识图谱构建、生成式AI推荐逻辑与前端交互展示等环节,读者可据此理解推荐系统的完整实现路径,掌握图谱数据组织、模型调用与界面联调的方法,并借鉴目录组织与部署脚本的写法。目前已有403人学习,可作为同类选题的实用参考。

1. 从一份「Python毕业设计」源码说起:知识图谱加生成式AI的食谱推荐到底怎么落地

很多同学拿到「Python毕业设计基于知识图谱和生成式AI的智能食谱推荐系统源码.zip」这类标题时,第一反应是去搜 python 安装教程、python 入门、python 教程,把环境跑起来再说。但真正卡住人的从来不是 python 安装,而是这套系统里两个核心模块怎么协同:知识图谱负责「确定性推理」,生成式 AI 负责「自然语言表达」,两者边界划不清,推荐结果就会又慢又飘。我见过太多毕设把图谱当装饰、把大模型当万能钥匙,最后答辩时被问一句「你的图谱到底约束了什么」就答不上来。

这篇笔记面向三类人:正在做食谱推荐毕设、想用知识图谱构建真实可查的食材-菜系-营养关系、又希望接入生成式 AI 做个性化文案的开发者。我会按「图谱怎么建 → 推荐怎么算 → 生成式 AI 怎么接 → 坑在哪」的顺序,把一份可复现的最小方案讲透。你不需要先看完《知识图谱应用实践指南》,但需要能写 python 定义函数、会装 numpy 这类基础库。下面所有代码都是本地可跑的最小片段,不依赖任何不存在的仓库地址。

2. 食谱知识图谱构建:从本体建模到 Neo4j 入库的完整链路

2.1 为什么食谱场景适合用知识图谱而不是纯向量检索

食谱推荐有一个天然优势:它的实体和关系高度结构化。一道「番茄炒蛋」可以拆成食材(番茄、鸡蛋)、口味(咸鲜)、烹饪方式(炒)、营养(维生素C、蛋白质)、适宜人群(普通成人)、禁忌(鸡蛋过敏者)。这些关系是确定性的,用向量检索反而会把「番茄」和「西红柿」当成两个东西,或者把「不放糖」和「少放糖」混为一谈。

知识图谱在这里承担三个不可替代的职责:第一,实体对齐,把「西红柿/番茄/洋柿子」归一成同一个节点;第二,多跳推理,比如用户说「我想吃补铁的」,图谱可以走「补铁 → 含铁食材 → 对应菜谱」这条路径;第三,约束生成式 AI 的输出,防止它编造不存在的菜或危险搭配。常见做法是先用本体建模工具(比如 Protégé 或轻量级的 Python 字典)定义 schema,再批量导入图数据库。

我一般会把本体设计成四层:食材层、菜谱层、营养层、用户画像层。食材层包含名称、别名、类别、每100g营养值;菜谱层包含菜名、步骤、口味、烹饪方式、耗时;营养层包含营养素名称和功能;用户画像层包含年龄、忌口、目标(减脂/增肌/补铁)。层与层之间用「包含」「适宜」「禁忌」「提供」这类关系连接。

2.2 用 Python 构建邻接矩阵并导出 CSV 导入 Neo4j

如果你不想装 Neo4j,也可以用 networkx 加邻接矩阵在本地跑通。但毕设答辩时图数据库的可视化更占优势,所以我建议用 Neo4j Community 版。下面这段代码做两件事:把食材和菜谱的关系整理成三元组,并生成 Neo4j 可直接 LOAD CSV 的两个文件。

import csv import numpy as np # 食材-菜谱三元组:(菜谱, 关系, 食材) triples = [ ("番茄炒蛋", "包含食材", "番茄"), ("番茄炒蛋", "包含食材", "鸡蛋"), ("番茄炒蛋", "烹饪方式", "炒"), ("番茄炒蛋", "口味", "咸鲜"), ("菠菜猪肝汤", "包含食材", "菠菜"), ("菠菜猪肝汤", "包含食材", "猪肝"), ("菠菜猪肝汤", "功效", "补铁"), ("猪肝", "富含", "铁"), ("菠菜", "富含", "铁"), ("鸡蛋", "禁忌人群", "鸡蛋过敏者"), ] # 写出节点文件 nodes = set() for h, r, t in triples: nodes.add(h) nodes.add(t) with open("nodes.csv", "w", newline="", encoding="utf-8") as f: writer = csv.writer(f) writer.writerow(["name"]) for n in nodes: writer.writerow([n]) # 写出关系文件 with open("relations.csv", "w", newline="", encoding="utf-8") as f: writer = csv.writer(f) writer.writerow(["source", "relation", "target"]) for h, r, t in triples: writer.writerow([h, r, t]) # 构建邻接矩阵,用于后续相似度计算 node_list = sorted(nodes) idx = {n: i for i, n in enumerate(node_list)} adj = np.zeros((len(node_list), len(node_list))) for h, r, t in triples: adj[idx[h]][idx[t]] = 1 print("节点数:", len(node_list), "邻接矩阵形状:", adj.shape)

逻辑说明:triples 列表是手工整理的最小知识集,实际项目中可以从公开食谱数据集抽取。nodes.csv 和 relations.csv 是 Neo4j 批量导入的标准格式。邻接矩阵用 numpy 构建,后续做基于图的相似度时可以直接用矩阵乘法。参数说明:encoding 必须用 utf-8,否则中文节点名会乱码;邻接矩阵这里存的是 0/1 关系,如果要加权可以把 1 换成关系权重。

导入 Neo4j 的 Cypher 语句如下,注意先建约束再导数据,否则重复跑会生成重复节点:

CREATE CONSTRAINT IF NOT EXISTS FOR (n:Entity) REQUIRE n.name IS UNIQUE; LOAD CSV WITH HEADERS FROM 'file:///nodes.csv' AS row MERGE (n:Entity {name: row.name}); LOAD CSV WITH HEADERS FROM 'file:///relations.csv' AS row MATCH (a:Entity {name: row.source}) MATCH (b:Entity {name: row.target}) CALL apoc.merge.relationship(a, row.relation, {}, {}, b) YIELD rel RETURN count(rel);

这里用了 apoc.merge.relationship 动态创建关系类型,因为关系名存在 CSV 里。如果你的 Neo4j 没装 APOC,就改成固定关系类型,或者用 Python 的 neo4j 驱动逐条写入。参数上,file:/// 指向 Neo4j 安装目录下的 import 文件夹,CSV 必须放那里才能被 LOAD CSV 读到。

2.3 实体对齐与别名归一:别让「西红柿」和「番茄」变成两个节点

实体对齐是图谱质量的分水岭。我一般用「别名词典 + 编辑距离」双保险。先维护一个 aliases 字典,覆盖常见食材别名;再用 difflib 对未登录词做相似度兜底,阈值设 0.85 以上才合并,避免把「白菜」和「白萝卜」误合。

import difflib aliases = { "番茄": ["西红柿", "洋柿子"], "鸡蛋": ["鸡子", "蛋"], "猪肝": ["猪肝脏"], } def normalize(name, existing_nodes): for std, alias_list in aliases.items(): if name == std or name in alias_list: return std # 编辑距离兜底 matches = difflib.get_close_matches(name, existing_nodes, n=1, cutoff=0.85) return matches[0] if matches else name existing = ["番茄", "鸡蛋", "猪肝", "菠菜"] print(normalize("西红柿", existing)) # 输出 番茄 print(normalize("鸡子", existing)) # 输出 鸡蛋

参数说明:cutoff 是相似度阈值,设太高会漏合并,设太低会误合并。食谱场景我建议 0.85,因为食材名普遍较短,0.8 以下容易把「土豆」和「土鸡」这种误判。别名归一后要回写图谱,把旧节点的关系迁移到标准节点再删除旧节点,否则图谱里会残留孤立点。

3. 推荐算法层:图谱推理与协同过滤怎么融合

3.1 基于图谱路径的规则推荐:先保证「不推错」

生成式 AI 最大的风险是幻觉,所以推荐链路的第一层必须是确定性规则。我的做法是:用户输入忌口或目标后,先在图谱里做路径查询,把候选菜谱限制在安全集合内。比如用户对鸡蛋过敏,就先排除所有包含鸡蛋的菜谱;用户要补铁,就保留能走到「铁」节点的菜谱。

# 模拟图谱查询:找出所有能补铁的菜谱 def recommend_by_nutrient(graph_triples, nutrient): recipes = set() iron_foods = set() for h, r, t in graph_triples: if r == "富含" and t == nutrient: iron_foods.add(h) for h, r, t in graph_triples: if r == "包含食材" and t in iron_foods: recipes.add(h) return recipes triples = [ ("番茄炒蛋", "包含食材", "番茄"), ("番茄炒蛋", "包含食材", "鸡蛋"), ("菠菜猪肝汤", "包含食材", "菠菜"), ("菠菜猪肝汤", "包含食材", "猪肝"), ("猪肝", "富含", "铁"), ("菠菜", "富含", "铁"), ] print(recommend_by_nutrient(triples, "铁")) # {'菠菜猪肝汤'}

逻辑说明:先找富含目标营养素的食材,再反查包含这些食材的菜谱。这是两跳推理,实际图谱里可以扩展到三跳,比如「营养素 → 食材 → 菜谱 → 适宜人群」。参数上,nutrient 用中文标准名,避免「铁元素」「铁质」混用,这又回到实体对齐的问题。

3.2 协同过滤做排序:用共现矩阵给候选菜谱打分

规则层筛出安全候选后,用协同过滤排序。食谱场景的用户-菜谱交互数据通常很稀疏,所以用 item-based 比 user-based 稳。核心是算菜谱之间的相似度:如果很多用户同时喜欢 A 和 B,那喜欢 A 的人大概率也喜欢 B。

import numpy as np from sklearn.metrics.pairwise import cosine_similarity # 行是用户,列是菜谱,1 表示喜欢 interaction = np.array([ [1, 1, 0, 0], [1, 0, 1, 0], [0, 1, 1, 1], [0, 0, 1, 1], ]) recipes = ["番茄炒蛋", "菠菜猪肝汤", "清蒸鲈鱼", "凉拌黄瓜"] item_sim = cosine_similarity(interaction.T) print("菜谱相似度矩阵:\n", np.round(item_sim, 2)) def score_candidates(user_vec, item_sim, recipes): scores = user_vec @ item_sim return sorted(zip(recipes, scores), key=lambda x: -x[1]) user = np.array([1, 1, 0, 0]) # 该用户喜欢前两道 print(score_candidates(user, item_sim, recipes))

参数说明:interaction 矩阵里 1 代表正向交互,实际项目可以用评分或点击次数加权。cosine_similarity 对稀疏矩阵也适用,但如果菜谱数上万,建议改用 TruncatedSVD 降维后再算。排序结果要跟规则层的安全集合取交集,不能直接输出,否则可能把过敏菜谱排到前面。

3.3 两路融合的权重怎么定:别拍脑袋,用验证集调

规则层和协同过滤层的融合方式常见有三种:加权求和、级联过滤、学习排序。毕设场景我推荐级联过滤加加权求和:规则层先硬过滤,协同过滤层再打分,最后按 0.6 规则分 + 0.4 协同分排序。规则分可以定义为「命中营养素路径数 / 总路径数」,协同分归一化到 0-1。

权重不是固定的,要用留出法调。把用户交互数据按 8:2 拆训练和验证,在验证集上网格搜索权重组合,看 Recall@10 哪个最高。我踩过的坑是直接用 0.5:0.5,结果补铁场景下协同过滤把高热量菜谱排前面,因为交互数据里高热量菜更受欢迎。后来把规则权重提到 0.7 才压住。

4. 生成式 AI 接入:让推荐理由说人话但不胡说

4.1 提示词模板设计:把图谱三元组塞进上下文

生成式 AI 在这里只做一件事:把结构化的推荐结果翻译成自然语言理由。关键是提示词里必须带图谱证据,并要求模型「只能基于给定三元组生成,不得引入外部知识」。下面是一个可复用的模板。

def build_prompt(user_profile, recipe, evidence_triples): evidence_text = "\n".join([f"{h} - {r} - {t}" for h, r, t in evidence_triples]) prompt = f"""你是一个食谱推荐助手。请根据以下用户画像和知识图谱证据,为推荐菜谱写一段推荐理由。 用户画像:{user_profile} 推荐菜谱:{recipe} 图谱证据: {evidence_text} 要求: 1. 只能使用上述证据中的信息,不得编造。 2. 理由控制在 80 字以内。 3. 如果证据不足以支撑推荐,直接说"证据不足"。 """ return prompt profile = "28岁女性,目标补铁,对鸡蛋过敏" evidence = [("菠菜猪肝汤", "包含食材", "猪肝"), ("猪肝", "富含", "铁")] print(build_prompt(profile, "菠菜猪肝汤", evidence))

逻辑说明:evidence_text 把三元组转成文本,模型不需要理解图结构,只需要做语言组织。要求里明确「不得编造」和「证据不足就说」,这是防幻觉的关键。参数上,温度建议设 0.3 以下,太高会自由发挥;max_tokens 设 200 足够,理由不需要长。

4.2 用本地小模型还是 API:毕设场景的取舍

毕设答辩环境通常没有稳定外网,所以本地部署小模型更稳妥。常见做法是用 Ollama 跑一个 7B 级别的中文模型,或者用 transformers 加载 ChatGLM3-6B。如果机器只有 8G 显存,可以用 4bit 量化。API 方案适合演示但答辩时网络一断就翻车,我建议至少准备本地兜底。

# 用 Ollama 拉取并运行一个中文小模型 ollama pull qwen2:7b ollama run qwen2:7b "根据证据:猪肝富含铁,菠菜猪肝汤包含猪肝。为补铁用户写一句推荐理由。"

参数说明:qwen2:7b 是模型名,实际可用模型以本地 Ollama 列表为准。运行后如果显存不够,Ollama 会自动降级到 CPU,但速度会慢。毕设演示时建议提前把模型加载好,避免现场等待。

4.3 生成结果的校验:用规则兜底防幻觉

生成式 AI 的输出不能直接展示,必须过一遍校验。校验规则包括:推荐理由里提到的食材必须在图谱中存在;提到的功效必须能通过图谱路径走到;不能出现「治疗」「治愈」这类医疗宣称。下面是一个简单的校验函数。

def validate_reason(reason, recipe, graph_triples): # 检查是否出现图谱外的食材名 known_entities = set() for h, r, t in graph_triples: known_entities.add(h) known_entities.add(t) # 简单分词后检查(实际可用 jieba) for word in ["番茄", "鸡蛋", "猪肝", "菠菜", "铁"]: if word in reason and word not in known_entities: return False, f"出现图谱外实体: {word}" # 检查医疗宣称 for banned in ["治疗", "治愈", "药效"]: if banned in reason: return False, f"出现违规词: {banned}" return True, "通过" triples = [("菠菜猪肝汤", "包含食材", "猪肝"), ("猪肝", "富含", "铁")] print(validate_reason("猪肝富含铁,适合补铁。", "菠菜猪肝汤", triples)) print(validate_reason("这道菜可以治疗贫血。", "菠菜猪肝汤", triples))

逻辑说明:known_entities 从图谱三元组收集,校验时检查生成文本里的关键词是否都在图谱内。实际项目建议用 jieba 分词后做实体链接,比硬编码词表更稳。参数上,banned 列表要根据平台规范维护,医疗宣称是红线。

5. 避坑与排查:食谱推荐系统最容易翻车的 5 个地方

5.1 图谱导入后中文乱码,节点名变成问号

现象:LOAD CSV 导入 Neo4j 后,节点名显示为乱码或问号。原因:CSV 文件编码不是 UTF-8,或者 Neo4j 的 import 目录权限不对。解决:用 Python 写 CSV 时显式指定 encoding="utf-8",并在 Neo4j 配置文件里确认 dbms.import.csv.legacy_encoding 没被改成非 UTF-8。导入前用文本编辑器打开 CSV 确认中文正常。

5.2 协同过滤冷启动,新用户推荐全是热门菜

现象:新注册用户没有交互记录,协同过滤打分全为 0,结果只推热门菜。原因:item-based 协同过滤依赖用户历史,冷启动时无信号。解决:冷启动阶段走纯规则推荐,用用户填写的画像(目标、忌口)做图谱路径查询;等交互数据积累到 10 条以上再启用协同过滤。也可以在注册时让用户勾选偏好口味,作为初始向量。

5.3 生成式 AI 把「补铁」说成「补血」,语义漂移

现象:模型把图谱里的「富含铁」自由发挥成「补血养颜」,虽然不算错但偏离了证据。原因:提示词约束不够强,或者温度设太高。解决:在提示词里加「只能使用证据中的原词,不得替换为近义词」,温度降到 0.2。如果还漂移,就在校验层加同义词黑名单,把「补血」「养颜」这类未在证据中出现的词拦掉。

5.4 邻接矩阵太大导致内存溢出

现象:食材和菜谱节点上万后,numpy 邻接矩阵占几百 MB 甚至上 G,跑相似度时内存爆掉。原因:稠密矩阵存储稀疏关系,浪费空间。解决:改用 scipy.sparse 的 csr_matrix,或者直接用 Neo4j 的图算法库(GDS)在数据库内计算。如果坚持用 numpy,先做节点过滤,只保留与目标用户相关的子图。

5.5 推荐结果重复,同一食材的菜谱扎堆

现象:用户要补铁,结果推了五道猪肝菜,体验很差。原因:规则层只按营养素路径筛选,没有做多样性控制。解决:在排序后加一层 MMR(最大边际相关性)重排,惩罚与已选菜谱食材重叠度高的候选。简单做法是统计候选菜谱的食材集合,每选一道就把包含相同主食材的候选降权。

6. 进阶技巧:用图谱嵌入加向量召回做混合推荐

前面讲的规则加协同过滤是毕设保底方案,如果你想让系统更有竞争力,可以加一层图谱嵌入。核心思路是用 TransE 或 RotatE 把图谱里的实体和关系映射成向量,然后用户画像向量和菜谱向量做相似度召回。这样既能利用图谱结构,又能处理「用户没说但可能喜欢」的隐式偏好。

# 用 pykeen 训练一个最小 TransE 模型(需先 pip install pykeen) from pykeen.pipeline import pipeline from pykeen.datasets import Nations # 这里用内置数据集演示流程 result = pipeline( dataset=Nations, model="TransE", model_kwargs=dict(embedding_dim=50), training_kwargs=dict(num_epochs=20), ) entity_embeddings = result.model.entity_embeddings.weight.detach().numpy() print("实体嵌入形状:", entity_embeddings.shape)

逻辑说明:pykeen 是知识图谱嵌入的常用库,pipeline 封装了训练和评估。实际项目要把 Nations 换成自己的食谱三元组文件,格式是 TSV 的 head\trelation\ttail。参数上,embedding_dim 设 50-200,太小欠拟合,太大过拟合且慢;num_epochs 看损失曲线,一般 100-500 轮。训练完后,菜谱向量和用户偏好向量做余弦相似度,取 Top-K 作为召回结果,再和规则层、协同层融合。

融合时我一般用三层加权:规则层 0.5、协同层 0.3、嵌入层 0.2。这个权重同样要在验证集上调。嵌入层的好处是能发现「番茄炒蛋」和「番茄牛腩」的相似性,即使没有用户同时交互过这两道菜。但要注意,图谱嵌入对数据量敏感,三元组少于 500 条时效果不稳定,毕设如果数据少,建议只作为加分项演示,不作为主排序依据。

最后说个我自己的习惯:每次改完推荐权重,我都会固定一组测试用户跑一遍,把推荐结果和理由打印出来人工看一遍。机器指标再高,推荐理由读起来别扭就是不行。这个人工检查环节帮我拦下过好几次「补铁推冰淇淋」的翻车。希望帮到你。

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

RELATED READING

延伸阅读

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