简介:一份基于知识图谱与生成式AI的智能食谱推荐系统毕业设计项目源码,面向计算机相关专业筹备毕业设计的学生及需要完整项目实践的学习者,覆盖前端交互与后端处理逻辑,适合作为毕业设计、课程设计或期末大作业参考。源码包共42个文件,整体大小681KB,以tsx、less、ts等前端文件为主,另含Python主程序、yaml配置和shell脚本用于服务运行与部署,json存放项目配置数据;项目经导师指导并认可,评审得分98分,源码已本地编译通过并调试完成。目前已有404人学习下载,借助源码可快速理解知识图谱构建、生成式AI接入与前后端联调等关键环节。目录结构与接口设计可直接迁移到个人方案中,显著节省从零搭建的时间。
1. 智能食谱推荐系统遇上Python毕设:知识图谱和生成式AI到底补了什么
同样是“推荐一道菜”,传统协同过滤只会告诉你“和你口味相似的人都在吃剁椒鱼头”;可如果用户对花生过敏、最近在控糖,协同过滤拿不出任何依据。基于知识图谱和生成式AI的智能食谱推荐系统源码,核心思路是先造一张“菜谱知识图谱”:菜品连接食材,食材带替代关系,菜系、口味、烹饪方式、营养标签全部显式建模,让推荐系统能沿着关系推理;再由生成式AI把推理结果翻译成“少油、无花生、补蛋白质”的人类语言。对Python毕业设计而言,这套源码把爬虫、数据处理、图存储、推荐算法、大模型API、Web展示串成一条完整链路,知识图谱和生成式AI各承担一半工作量,既不会变成纯调包,也不会变成纯调Prompt。
2. 菜谱知识图谱怎么搭:三元组设计、数据清洗与入库实现
很多人拿到食谱源码第一件事就是跑数据库,结果发现图谱里一堆“菜”和“食材”节点但不知道怎么查。这是本末倒置。图谱必须先设计关系,再谈入库。推荐系统的召回、过滤、解释全靠关系边撑着,边没建对,后面所有代码都在补窟窿。
2.1 菜谱图谱的实体清单和关系定义:为什么存这些边
我把实体控制在六个:菜品、食材、菜系、口味、烹饪方式、营养标签,外加一个用户实体用于构建用户画像。关系上,这一套是经过验证的最小边集:
| 关系 | 示例 | 推荐里干什么用 |
|---|---|---|
| 菜品-CONTAINS-食材 | 宫保鸡丁-CONTAINS-花生 | 用户忌口过滤、食材相似召回 |
| 食材-SUBSTITUTE-食材 | 花生-SUBSTITUTE-腰果 | 过敏时做替代推荐 |
| 菜品-BELONGS_TO-菜系 | 宫保鸡丁-BELONGS_TO-川菜 | 菜系偏好召回 |
| 菜品-HAS_TASTE-口味 | 宫保鸡丁-HAS_TASTE-辣 | 口味维度打分 |
| 菜品-USES_METHOD-烹饪方式 | 清蒸鲈鱼-USES_METHOD-蒸 | 健康筛选 |
| 菜品-HAS_NUTRITION-营养标签 | 鸡胸肉沙拉-HAS_NUTRITION-高蛋白 | 控糖、减脂场景 |
| 用户-ALLERGIC_TO-食材 | 用户A-ALLERGIC_TO-花生 | 硬过滤,一票否决 |
| 用户-PREFERS-口味/菜系 | 用户A-PREFERS-川菜 | 召回和排序特征 |
设计原则只有一个:推荐计算需要查什么,就存什么。食材替代关系是亮点也是门槛,很多毕设源码栽在这条边上——没有SUBSTITUTE边,“对花生过敏”就只能过滤菜品,无法生成替代方案,生成式AI再会写文案也救不回来。
2.2 食材归一化:中文同名异义的第一道坎
另一个高频翻车点是食材名不统一。“土豆”和“马铃薯”、“番茄”和“西红柿”、“花生米”和“花生仁”,在原始数据里是不同字符串,没归一化就会在图谱里变成两个节点,导致同一道菜被切到两套互不相连的子图里。我一般先维护同义词字典,再配合正则做单位清洗。
import re # 同义词统一指向一个标准名 SYNONYM_DICT = { "土豆": "马铃薯", "洋芋": "马铃薯", "potato": "马铃薯", "番茄": "西红柿", "tomato": "西红柿", "花生米": "花生", "花生仁": "花生", "peanut": "花生", "鸡胸": "鸡胸肉", "鸡脯肉": "鸡胸肉", } STOP_WORDS = {"适量", "少许", "若干", "少量"} # 非实体,直接丢弃 def normalize_ingredient(raw: str) -> str: name = raw.strip().lower() name = re.sub(r"约\d+克|约\d+g|\d+克|\d+g", "", name) # 去掉数量 name = re.sub(r"[((].*?[))]", "", name) # 去掉括号备注 name = SYNONYM_DICT.get(name, name) return name if name not in STOP_WORDS else ""规范化里最关键的参数是“去数量”正则:原始菜谱的食材列经常是“土豆300克”“花生米50g”,如果不剥掉数量,图谱里会出现“土豆300克”“花生米50g”这种看似唯一、实际无法匹配的节点。这个函数清洗后,把空串过滤掉再进三元组生成。
2.3 用pandas把菜谱表拆成三元组并落盘CSV
拿到已归一化的菜谱表后,把它转成知识图谱标准的三元组表:subject、predicate、object。这里用pandas逐行拆分会直观很多,也方便后面做去重和统计。
import pandas as pd def build_triples(df: pd.DataFrame) -> pd.DataFrame: triples = [] for _, row in df.iterrows(): dish = row["dish_name"] # 食材经过 2.2 节的 normalize_ingredient 清洗,逗号分隔 for ing in str(row["ingredients"]).split(","): ing = normalize_ingredient(ing) if not ing: continue triples.append((dish, "CONTAINS", ing)) # 菜系、口味、烹饪方式、营养标签直接做静态映射 if pd.notna(row.get("cuisine")): triples.append((dish, "BELONGS_TO", row["cuisine"])) if pd.notna(row.get("taste")): for t in str(row["taste"]).split("/"): # 口味可多值,如 辣/酸 triples.append((dish, "HAS_TASTE", t.strip())) if pd.notna(row.get("method")): triples.append((dish, "USES_METHOD", row["method"])) if pd.notna(row.get("nutrition")): for n in str(row["nutrition"]).split("/"): triples.append((dish, "HAS_NUTRITION", n.strip())) triple_df = pd.DataFrame(triples, columns=["subject", "predicate", "object"]) return triple_df.drop_duplicates() recipe_df = pd.read_csv("recipes_raw.csv", encoding="utf-8-sig") triple_df = build_triples(recipe_df) triple_df.to_csv("knowledge_graph_triples.csv", index=False)这个函数里最容易被忽略的是drop_duplicates()。原始数据里同一道菜可能出现在多个分类下,不整体去重,后面建图时NetworkX会叠加重边,统计度数和路径游走都会偏大。另一个注意点是predicate列不要用数字或缩写,Cypher查询时“CONTAINS”这种驼峰写法最顺手。
2.4 从CSV到Neo4j:py2neo批量导入与查询验证
三元组落盘后,可以导入图数据库。毕业设计里用Neo4j做展示效果最好,Cypher查询一两行就能画出“宫保鸡丁周围都有什么”。导入常用py2neo,但要注意版本兼容,后面避坑章节会细说。
from py2neo import Graph, Node, Relationship # 连接 Neo4j,默认 bolt 端口 7687 graph = Graph("bolt://localhost:7687", auth=("neo4j", "your_password")) # 1. 先建去重后的实体集合,用 merge 避免重复创建同名节点 entities = set() for s, _, o in triple_df.itertuples(index=False): entities.add(s) entities.add(o) for name in entities: graph.merge(Node("Entity", name=name), "Entity", "name") # 2. 分批写入关系,batch_size 控制事务大小 batch_size = 500 batch = [] for s, p, o in triple_df.itertuples(index=False): head = graph.nodes.match("Entity", name=s).first() tail = graph.nodes.match("Entity", name=o).first() if head and tail: batch.append(Relationship(head, p, tail)) if len(batch) >= batch_size: graph.create_all(batch) batch.clear() if batch: graph.create_all(batch)关键参数是batch_size:500条关系一批比较稳,一次塞几千条容易触发Neo4j事务内存上限。graph.merge按“name”属性做幂等,重复跑导入脚本不会产生重复节点。写入后先在浏览器里跑一句MATCH (n:Entity {name:'花生'})-[r]-(m) RETURN n,r,m,看替代关系有没有进来。
2.5 不装Neo4j的替代方案:networkx做小规模图谱
如果你在Windows上装Neo4j老出问题,或者学校机器没有图形化界面,直接用networkx也能做图谱和推荐。三元组CSV本身就是一个有向边表,nx.read_edgelist读进来就能做图谱查询、游走和可视化,数据规模在几千道菜时性能完全够。我见过不少毕业设计源码就是不依赖Neo4j、纯CSV加networkx跑的,答辩演示用pyecharts画图效果也不差,还能避开图数据库的环境坑。知识图谱构建的本质是三元组建模,存储介质是CSV还是Neo4j只是工程选型,不影响设计思路。
3. 生成式AI接进推荐链路:从食材归一、属性补全到推荐理由生成
3.1 生成式AI在图谱构建期就介入:属性补全
很多菜谱原始数据缺营养标签,缺了它“控糖推荐”就做不了。与其手工标注几千道菜,不如让大模型批量补全,这也是生成式AI在整套系统里最实的一步。
import requests, json def llm_fill_nutrition(dish_name: str, ingredients: list[str]) -> dict: prompt = { "model": "your_model_name", "messages": [ {"role": "system", "content": "你是营养师,只输出JSON,不要多余文字。"}, {"role": "user", "content": ( f"菜名:{dish_name}\n" f"主要食材:{'、'.join(ingredients)}\n" "请估算这份菜的热量区间、蛋白质、脂肪、碳水,并给出三个营养标签," "输出格式:{\"calories\": \"低/中/高\", \"protein\": \"高/中/低\", " "\"fat\": \"高/中/低\", \"carbs\": \"高/中/低\", \"tags\": [\"高蛋白\", ...]}" )} ], "temperature": 0.2, "response_format": {"type": "json_object"} } resp = requests.post( "https://your_api_host/v1/chat/completions", headers={"Authorization": "Bearer YOUR_API_KEY"}, json=prompt, timeout=15 ) return json.loads(resp.json()["choices"][0]["message"]["content"])这个场景temperature一定要压低到0.2左右,因为属性补全要确定性而不是发散;response_format锁定JSON返回,否则后面解析字符串能把你逼疯。补全结果合并回原菜谱表再走2.3节的三元组生成逻辑,营养标签就作为实体节点进入图谱。
3.2 prompt设计:把图谱检索结果注入上下文
生成式AI最容易犯的是幻觉,它不知道用户对什么过敏就会编出“花生碎增添口感”。所以做推荐文案时,不能把问题直接丢给大模型,而是先从图谱里检索出硬约束和候选菜品的图谱邻居,把它们作为上下文拼进prompt。
def build_recommend_context(user_profile: dict, dish_name: str, graph) -> str: # 从 Neo4j 查出这道菜的食材、口味、营养标签、菜系 cypher = """ MATCH (d:Entity {name:$dish})-[r]->(v) RETURN type(r) AS rel, v.name AS value """ records = graph.run(cypher, dish=dish_name).data() evidence = {} for rec in records: evidence.setdefault(rec["rel"], []).append(rec["value"]) # 查用户禁忌是否与菜品食材碰撞 allergies = user_profile.get("allergies", []) ingredients = evidence.get("CONTAINS", []) conflicts = [a for a in allergies if a in ingredients] return { "evidence": evidence, "conflicts": conflicts, "user_prefs": { "tastes": user_profile.get("tastes", []), "nutrition": user_profile.get("nutrition_targets", []), } }核心参数是evidence这个结构化上下文。把{CONTAINS, HAS_TASTE, HAS_NUTRITION}三类关系塞给大模型,它就有锚点了——推荐理由里只会引用evidence里出现的食材,不会自由发挥。这也是“图谱管事实、生成式AI管表达”的典型分工。
3.3 推荐理由生成:一次完整的接口调用
召回排序完成后,对Top N菜品逐个生成推荐理由。这里不能漏掉候选菜品的排序名次信息,比如“为什么这道菜排第一”本身也是最好的解释文本。
def generate_reason(user_profile: dict, dish_name: str, rank: int, ctx: dict) -> str: conflict_text = "" if ctx["conflicts"]: conflict_text = f"注意:它包含用户过敏食材{'、'.join(ctx['conflicts'])},已跳过。" else: conflict_text = "未命中任何用户过敏食材,可以放心推荐。" messages = [ {"role": "system", "content": ( "你是智能食谱推荐系统的讲解员,说话口语化,2到3句话讲清楚" "为什么推荐这道菜,必须引用提供的食材、口味、营养信息。" )}, {"role": "user", "content": ( f"用户口味偏好:{'、'.join(user_profile.get('tastes', []))}\n" f"用户营养目标:{'、'.join(user_profile.get('nutrition_targets', []))}\n" f"菜品:{dish_name},当前排序第{rank}位\n" f"图谱证据:{json.dumps(ctx['evidence'], ensure_ascii=False)}\n" f"过敏检查:{conflict_text}\n" "请输出推荐理由:" )} ] resp = requests.post( "https://your_api_host/v1/chat/completions", headers={"Authorization": "Bearer YOUR_API_KEY"}, json={"model": "your_model_name", "messages": messages, "temperature": 0.7}, timeout=12 ) content = resp.json()["choices"][0]["message"]["content"] return content.strip() # 调用示例 ctx = build_recommend_context(user_profile, "鸡胸肉沙拉", graph) reason = generate_reason(user_profile, "鸡胸肉沙拉", rank=1, ctx=ctx) print(reason)temperature=0.7是推荐文案的合适区间,既保留口语感又不过度发散。排名信息rank看似不起眼,实际能大幅提升文案可信度——“这是你当前列表里最接近高蛋白目标的一道菜”比“这道菜很好”说服力强得多。
3.4 生成式AI的边界:超时、幻觉与成本控制
大模型接口在答辩演示时大概率会偶发超时或限流,一定要有降级策略。我一般规定:任何推荐理由生成超过2次重试就直接返回模板文本,模板从图谱证据里拼出来——“宫保鸡丁:川菜,含花生,口味辣,蛋白质含量高”。这样推荐接口永不失败,顶多文案变得朴素。
成本上也要注意,不要对Top 20每道菜都调一次大模型接口,那既慢又烧额度。常见做法是只对最终Top 3生成个性化理由,其余返回模板。想让生成式AI在毕设里作为亮点展示,重点讲“AI依据图谱证据生成解释”这个链路,比堆调用次数有价值得多。
4. 推荐链路怎么跑通:图谱召回、排序过滤与冷启动的Python实现
知识图谱建好、生成式AI就位,接下来是推荐系统的主体:把用户画像送进去,先召回一批候选菜品,再按特征排序,最后做硬过滤和冷启动兜底。这一章是源码里最厚的部分。
4.1 图谱节点向量化:node2vec还是直接邻居特征
推荐系统要计算“菜品相似度”,最直接的办法是把图谱节点转成向量。常见做法是用node2vec在图上做随机游走,再把游走序列喂给Word2Vec,得到每个实体节点的稠密向量。
import networkx as nx from node2vec import Node2Vec # 读取三元组CSV构建无向图(关系方向对相似度计算不敏感) G = nx.read_edgelist( "knowledge_graph_triples.csv", delimiter=",", data=False, create_using=nx.Graph() ) # 随机游走参数:dimensions嵌入维度,walk_length游走步长,num_walks每节点游走次数 node2vec_model = Node2Vec( G, dimensions=128, walk_length=20, num_walks=10, workers=4, seed=42 ) model = node2vec_model.fit(window=8, min_count=1, epochs=5) # 保存向量供排序和召回使用 model.wv.save_word2vec_format("graph_embeddings.bin", binary=True)dimensions=128是性价比很高的维度,低于64表达力不够,高于256训练慢且容易过拟合;walk_length=20能覆盖到二阶邻居(菜品→食材→菜品),太短学不到跨菜品的相似性。seed=42很重要,node2vec的游走有随机性,不固定随机种子,每次跑出的向量都不同,排序结果跟着抖,答辩演示时容易翻车。若机器压力大,可以只对菜品节点做游走,食材节点用邻接向量代替。
4.2 召回阶段:两跳邻居游走加协同过滤的混合策略
图谱召回的核心是“用户吃过的菜→共享食材→邻居菜品”这条两跳路径。用户在简餐场景点赞了“鸡胸肉沙拉”,通过共享食材“鸡胸肉”能召回到“黑椒鸡胸意面”;协同过滤则负责捕捉“相似用户喜欢的菜”。
def recall_candidates(user_liked: list[str], G: nx.Graph, top_k=50) -> dict: scores = {} # 图谱两跳召回:菜品一食材一菜品 for dish in user_liked: if dish not in G: continue for ingredient in G.neighbors(dish): # 一跳:菜品到食材 for neighbor_dish in G.neighbors(ingredient): # 二跳:食材到菜品 if neighbor_dish == dish: continue # 共享食材越多分越高 scores[neighbor_dish] = scores.get(neighbor_dish, 0) + 1 # 协同过滤召回:按用户相似度加权,这里简化为按历史评分累加 for sim_user, sim_weight in get_similar_users(user_liked, top_n=5): for dish, rating in sim_user["ratings"].items(): if dish not in user_liked: scores[dish] = scores.get(dish, 0) + sim_weight * rating # 按分数取前 top_k return dict(sorted(scores.items(), key=lambda x: x[1], reverse=True)[:top_k])两个召回源的权重直接体现在累加分数里。图谱每贡献一次“共享食材”加1分,协同过滤则按相似用户权重乘rating累加。这样图谱召回和协同过滤不必分开算再合并,分数天然可对比。硬过滤在召回之后立刻做:把命中了用户ALLERGIC_TO食材的菜品直接弹掉,不进入排序,因为禁忌没有商量余地。
4.3 排序阶段:手工特征加LightGBM的完整样例
召回候选集一般有几十道菜,排序层负责把最合适的提到最前面。轻量做法是构造特征表,用LightGBM或直接规则打分。毕设源码里LightGBM排名模型比纯规则好看,调试也简单——瓶颈永远是特征。
import lightgbm as lgb def build_ranking_features(dish: str, user_profile: dict, G: nx.Graph, dish_vector: dict) -> dict: ingredients = set(G.neighbors(dish)) if dish in G else set() dish_cuisines = get_cuisines(dish, G) dish_tastes = get_tastes(dish, G) # 过敏硬过滤已在召回阶段处理,这里只算风险碰撞数量 allergy_hits = len(ingredients & set(user_profile["allergies"])) # 口味重合度:用户偏好口味里有几个出现在菜品口味中 taste_overlap = len(set(user_profile["tastes"]) & set(dish_tastes)) # 菜系命中 cuisine_overlap = len(dish_cuisines & set(user_profile["prefer_cuisines"])) # 营养目标命中 nutrition_hits = len(set(user_profile["nutrition_targets"]) & set(get_nutrition(dish, G))) return { "allergy_hits": allergy_hits, "taste_overlap": taste_overlap, "cuisine_overlap": cuisine_overlap, "nutrition_hits": nutrition_hits, "ingredient_count": len(ingredients), "user_avg_rating": get_user_avg_rating(dish), # 该菜历史平均评分 "is_user_operation": 1 if user_profile["user_id"] in get_recent_users(dish) else 0, } # 组装训练集:特征 + 用户点击/收藏作为label X = [build_ranking_features(...) for ...] y = [1 if clicked else 0 for ...] train_data = lgb.Dataset(X, label=y) params = { "objective": "binary", "metric": "auc", "learning_rate": 0.05, "num_leaves": 31, "max_depth": -1, "verbose": -1, "seed": 42, } booster = lgb.train(params, train_data, num_boost_round=200) # 推理时按预测概率排序 pred_scores = booster.predict([build_ranking_features(d, ...) for d in candidates])特征里allergy_hits理论上召回后已经是0,但保留这个特征是为了让模型学到“即使漏过硬过滤,过敏风险也是最大惩罚项”。num_leaves=31是LightGBM在小数据集上的稳妥起点,毕设样本量通常只有几万条,树太深会过拟合;learning_rate=0.05配合200轮足够收敛。如果不想引入训练环节,直接用这四个重叠度特征做加权和也能跑通,效果差不了太多。
4.4 冷启动:新用户偏好表单与新菜品图谱传播
推荐系统最怕新用户、新菜品没有历史数据。常见做法是让新用户进入系统时先选几个偏好标签——爱吃的菜系、口味、忌口、营养目标,用这些标签直接跑图谱召回:偏好“川菜”就把图谱里BELONGS_TO川菜的菜品拉进来;忌口“花生”就把CONTAINS花生的菜品过滤掉。新菜品入图谱后没有任何交互记录,但通过CONTAINS连到已有食材节点,就能被“共享食材”路径召回,天然避开了冷启动问题。
def cold_start_for_new_user(preferences: dict, G: nx.Graph, top_k=20): score = {} # 按喜好的菜系、口味扩散 for cuisine in preferences.get("prefer_cuisines", []): for dish, attrs in get_dishes_by_cuisine(G, cuisine): score[dish] = score.get(dish, 0) + 2 for taste in preferences.get("tastes", []): for dish in get_dishes_by_taste(G, taste): score[dish] = score.get(dish, 0) + 1 # 一抹到底:硬过滤忌口 for dish in list(score): dish_ings = set(G.neighbors(dish)) if dish_ings & set(preferences.get("allergies", [])): del score[dish] return sorted(score, key=score.get, reverse=True)[:top_k]权重设计上菜系加2分、口味加1分,因为菜系是比口味更稳定的用户先验。硬过滤仍放在最后,避免过敏食材的菜品混进Top列表。这里想强调的是:冷启动不是靠某一个算法解决的,而是靠图谱关系的传播能力,这也是知识图谱方法相比纯协同过滤在毕设答辩里最好讲的一点。
5. 跑源码和二次开发最容易翻车的坑:从数据到部署的排查手册
5.1 Neo4j 5.x与py2neo版本不兼容
现象:安装源码依赖后启动项目,调用from py2neo import Graph就报ImportError或者连不上bolt://localhost:7687。原因:Neo4j 4.x之后的驱动接口改动较大,py2neo也分多个大版本,很多毕设源码是按旧版环境写的,pip默认装到的py2neo 2021.2.3与Neo4j 5.x不兼容。解决:先统一版本再谈其他。我的建议是降级数据库:安装Neo4j 4.4版本(Community版),同时固定py2neo==2021.2.3,这是最稳的组合。如果坚持用Neo4j 5.x,就要把源码里的graph.run调用换成官方neo4j驱动。
5.2 LLM接口偶发超时导致推荐页整体报错
现象:推荐接口偶尔要等十几秒才返回,甚至直接500;重试几次又好了,完全不稳定。原因:开源代码里大模型调用没有超时控制和降级,一个请求卡死,整个Flask接口跟着卡住。解决:所有LLM请求加timeout=10,封装一层重试函数,重试两次仍失败就走模板文案。更稳的办法是把推荐理由生成拆到独立的线程里,页面先返回菜品列表,理由异步填充,体验上不会卡首屏。这是我踩过最痛的坑——答辩演示时网络抖动,页面转圈转了三分钟,评委印象分直接打折。
5.3 土豆与马铃薯不是两个节点:食材归一化缺失
现象:知识图谱可视化里“土豆烧牛肉”和“马铃薯炖排骨”各自成簇,明明共享“土豆/马铃薯”食材却召回不到对方。原因:原始数据里土豆、马铃薯、洋芋、potato混用,建图时没有做同义词归并,图谱被切成分裂的小子图。解决:在2.2节基础上再加一层兜底——把所有食材名做一次别名表全量替换后再生成三元组;另外用nx.connected_components(G)检查最大连通子图是否覆盖了全部节点,如果连通分量超过几十个,基本就是归一化没做干净。
5.4 Windows下CSV中文乱码与特殊字符
现象:在Windows上用Excel打开三元组CSV全是乱码,或者读入pandas时“菜名”列出现é这类字符。原因:CSV写入时用了encoding="utf-8",Windows默认的GBK编码不认UTF-8,Excel打开自然乱码;有些菜名还带emoji或特殊空格。解决:写文件统一用encoding="utf-8-sig",这个编码带BOM头,Excel能正确识别;读取时也用同样的编码,读取端和写入端保持一致。代码里再对菜名做一次str.strip(),别小看首尾空格,它就是“宫保鸡丁”和“宫保鸡丁 ”两个节点的元凶之一。
5.5 图嵌入训练不稳定导致排序过拟合
现象:同一套代码跑两次,推荐结果Top 5完全不同;训练排序模型时测试集AUC很高但实际推荐效果很差。原因:node2vec的随机游走没有固定随机种子,每次生成的节点向量都不一样;同时图嵌入维度过高,在小样本上把噪声也学进去了。解决:Node2Vec构造时加seed=42,Word2Vec训练时也在model.fit里固定seed参数;嵌入维度不要追求高,128就够了。排序模型这边,特征里不要直接用128维向量拼进去,而是用“菜品味型重合度”“食材重合数”这种聚合特征,模型更稳也更好解释。
6. 把毕设做出可演示的完整度:离线评估、图谱可视化与部署收尾
6.1 离线评估脚本:precision@k、recall@k与NDCG
推荐系统不评估就是玄学。把用户历史行为按时间切分,前70%做训练、后30%做测试,就能算出一组可信指标。
import math def evaluate(rec_list, ground_truth, k=10): rec_top_k = rec_list[:k] hits = set(rec_top_k) & set(ground_truth) precision = len(hits) / k recall = len(hits) / len(ground_truth) if ground_truth else 0 dcg = 0.0 for i, item in enumerate(rec_top_k): if item in ground_truth: dcg += 1 / math.log2(i + 2) idcg = sum(1 / math.log2(i + 2) for i in range(min(len(ground_truth), k))) ndcg = dcg / idcg if idcg > 0 else 0 return {"precision@k": precision, "recall@k": recall, "ndcg@k": ndcg} # rec_list 是推荐系统返回的排序结果,ground_truth 是用户测试期真正吃过的菜 print(evaluate(["鸡胸肉沙拉", "黑椒鸡胸意面", ...], ["鸡胸肉沙拉", "番茄蛋汤"], k=10))NDCG的代码要仔细看:位置i的折扣是1/log2(i+2),因为Python索引从0开始,所以要加2才能让第一条位置权重为1。答辩时把这个指标打印出来,比讲一百句“效果不错”都有说服力。
6.2 图谱可视化:答辩现场最值钱的十分钟
Neo4j浏览器自带图可视化,但我更推荐用pyecharts画一张静态图谱图放进答辩PPT,不用现场连数据库,避免演示翻车。选几个核心节点,把两跳以内关系画成力导向图就行:
from pyecharts import options as opts from pyecharts.charts import Graph nodes = [{"name": "宫保鸡丁"}, {"name": "花生"}, {"name": "川菜"}, {"name": "辣"}] links = [ {"source": "宫保鸡丁", "target": "花生"}, {"source": "宫保鸡丁", "target": "川菜"}, {"source": "宫保鸡丁", "target": "辣"}, ] graph = ( Graph(init_opts=opts.InitOpts(width="900px", height="600px")) .add("", nodes, links, repulsion=800, edge_length=100) ) graph.render("knowledge_graph.html")repulsion=800控制节点间斥力,值太小时图谱挤成一团,太大道散架;edge_length=100是边的基础长度。这张图配合一句“图谱里每个节点都是可查询的实体,每条边都是推荐系统的推理依据”,演示效果直接上一个档次。
6.3 Flask API部署与LLM结果缓存
把推荐服务封装成接口,用Flask跑起来,前端页面就通过这个接口拿数据。我在做这类毕设时的习惯是:推荐核心逻辑和Web框架解耦,接口只做请求解析和结果返回。
@app.post("/api/recommend") def recommend_api(): user_id = request.json.get("user_id") user_profile = load_user_profile(user_id) # 从数据库/缓存取画像 recalls = recall_candidates(user_profile["liked_dishes"], G) ranked = rank_candidates(recalls, user_profile) # LightGBM排序,返回list[(dish, score)] # 只看Top 3,避免为所有候选菜品调大模型 top3 = ranked[:3] results = [] for rank, (dish, score) in enumerate(top3, start=1): ctx = build_recommend_context(user_profile, dish, graph) reason = cached_reason(dish, user_id) or generate_reason(user_profile, dish, rank, ctx) results.append({"dish": dish, "score": round(score, 4), "reason": reason}) return jsonify({"code": 0, "data": results})cached_reason是用Redis或内存字典做的一层结果缓存:同一个用户对同一道菜只生成一次推荐理由,命中缓存直接返回,既省钱又稳。接口里只对Top 3调用生成式AI,这一点在章节3.4说过,现在落地在代码上。最后提醒一句:API密钥一定不要写死在前端代码里,放到服务端环境变量或配置文件中,答辩现场演示时被评委看到密钥泄露就尴尬了。
说实话,我做这套系统时最大的体会是:知识图谱的质量决定了推荐系统的天花板,生成式AI只是那个把话说漂亮的包装层。食材不归一化、关系边不完整,大模型再努力生成的也是漂亮废话;图谱干净了,最简单的两跳召回就能给出靠谱答案。希望这个从三元组设计到线上部署的完整链路能帮你少走我走过的弯路,把毕设源码改造成真正敢拿出来演示的作品。希望帮到你。
本文还有配套的精品资源,点击获取