
简介面向计算机及相关专业学生的毕业设计源码基于Python、Neo4j知识图谱与生成式AI构建智能食谱推荐系统可满足毕业设计、期末大作业与课程设计等场景。项目以Python为主要开发语言前端界面采用tsx/less等文件实现main.py作为程序入口并配有配置文件、说明文档及sh发布脚本共52个文件压缩包约686KB结构清晰便于直接运行与二次开发。已有89人浏览学习源码通过本地编译评审分达98分内容经助教审定难度适中适合正在学习Python编程、知识图谱及生成式AI应用的开发者。通过运行与分析该项目可深入理解推荐系统中知识图谱的构建方法、生成式AI的个性化预测逻辑并掌握从依赖管理、环境配置到自动发布的完整工程实践是一份兼具教学价值与参考意义的优质毕设资料。1. 智能食谱推荐系统为什么知识图谱和生成式AI必须同时上做毕设想拿高分光靠一个协同过滤推荐器已经不够看了。一个反复被问的问题食谱推荐用UserCF或者ItemCF不就行了吗实际跑一遍会发现用户行为数据稀疏得可怜评分矩阵90%以上是空的协同过滤直接失去统计意义。这正是知识图谱派上用场的地方——把食材、菜品、营养元素、菜系这些实体之间的关系显式建出来推荐就从猜你喜欢变成按路径推理。而生成式AI解决的是另一头的问题推荐列表出来之后用户看不懂为什么推荐这个菜更没法用自然语言调整口味。把两者接在一起系统才能做到根据冰箱里剩的食材推荐三道菜并解释清楚为什么这三道菜适合你现在的需求。这套方案适合正在选毕设题目的学生也适合想把推荐系统落地到垂直领域的工程师技术栈统一落在Python上图谱存储用Neo4j生成式AI部分走大模型API本地能跑通也禁得住答辩追问。2. 搭建食谱知识图谱本体设计、数据清洗与Neo4j导入2.1 食谱领域本体建模五类实体与四类关系的取舍知识图谱的质量在源头——本体设计。食谱领域的本体不复杂常见做法是定义五类核心实体菜品Dish、食材Ingredient、营养元素Nutrient、菜系Cuisine、烹饪方式Method。有些方案还会加口味Taste和季节Season但对毕设而言五类实体足够支撑推荐逻辑加太多反而让数据稀疏问题更严重。实体有了关系是图谱的骨架。我最常用的四类关系设计如下关系起点终点含义CONTAINSDishIngredient菜品包含某食材HAS_NUTRIENTIngredientNutrient食材富含某营养元素BELONGS_TODishCuisine菜品属于某菜系USES_METHODDishMethod菜品采用某烹饪方式这个设计的取舍点在于为什么把营养元素挂在食材而不是菜品上因为同一道菜的做法不同营养差异很大而食材的营养属性相对稳定。比如西红柿炒蛋和西红柿蛋汤挂到食材层可以共享营养数据不用为每个菜品单独标注。推荐时想表达这道菜富含蛋白质查询路径就是 Dish-CONTAINS-Ingredient-HAS_NUTRIENT-Nutrient三级跳就能解释。图谱模式定义好之后用Cypher在Neo4j里建约束和索引。索引这一步特别重要没有索引的图谱在数据量超过几千节点后查询会慢到让人怀疑Neo4j是不是坏了。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 cuisine_name_index IF NOT EXISTS FOR (c:Cuisine) ON (c.name); CREATE INDEX method_name_index IF NOT EXISTS FOR (m:Method) ON (m.name);这段Cypher做的事是给Dish和Ingredient的name属性建唯一约束防止重复节点给Cuisine和Method的name建普通索引加速后续按名字匹配的查询。Neo4j 5.x的语法用CREATE CONSTRAINT ... IF NOT EXISTS旧版本是CREATE CONSTRAINT ON (d:Dish) ASSERT d.name IS UNIQUE如果用的是4.x版本语法不同这是第一个容易踩的坑。约束和索引建好后可以用SHOW INDEXES验证。2.2 从爬虫和公开数据集到图谱数据清洗与批量导入本体定好接下来就是填数据。食谱数据来源通常两条路一是用公开的菜谱数据集比如本地导出的CSV或JSON二是写爬虫抓食谱网站。爬虫这条路对毕设来说工作量可控但要注意遵守网站的robots协议并且控制抓取频率别把对方服务器打崩。我更推荐先找结构化程度高的数据集实在不够再补爬虫。数据清洗里最大的坑是食材名不统一。西红柿和番茄是同一个东西五花肉和猪五花也应该归一化。不处理的话图谱里会出现两个意思完全相同的食材节点推荐路径被切碎原本能关联的菜品连不上。我的处理方式是用一个食材别名表做映射代码示例import pandas as pd # 原始数据菜品、配料列是逗号分隔的字符串 raw pd.read_csv(recipes.csv) print(raw.head()) # 别名映射表把同义词归一到标准名 alias_map { 番茄: 西红柿, 西红柿: 西红柿, 土豆: 马铃薯, 洋芋: 马铃薯, 五花肉: 猪五花, 猪五花肉: 猪五花, } def normalize_ingredient(name: str) - str: name name.strip().lower() return alias_map.get(name, name) # 把配料的字符串拆成列表并做归一化 raw[ingredients] raw[ingredients].apply( lambda s: [normalize_ingredient(x) for x in s.split(,) if x.strip()] ) # 去重同一道菜在不同数据源里可能出现多次 raw raw.drop_duplicates(subset[dish_name]) raw.to_json(recipes_clean.json, orientrecords, force_asciiFalse)这段代码的核心逻辑是读原始数据、按别名表归一化食材名、按菜名去重。alias_map是维护成本最低的手段毕设阶段没必要上实体链接模型。注意normalize_ingredient里先strip()去空格再lower()转小写这一步能消掉相当一部分看起来不一样其实是同一个的脏数据。数据清洗完成后批量导入Neo4j。数据量在万级以下时我一般用py2neo的merge逐条写入逻辑清晰也方便断点续传。from py2neo import Graph, Node, Relationship graph Graph(bolt://localhost:7687, auth(neo4j, password)) data load_clean_data(recipes_clean.json) # 自定义加载函数 for dish in data: d_node Node(Dish, namedish[name]) graph.merge(d_node, Dish, name) for ing_name in dish[ingredients]: i_node Node(Ingredient, nameing_name) graph.merge(i_node, Ingredient, name) graph.merge(Relationship(d_node, CONTAINS, i_node)) cuisine_node Node(Cuisine, namedish[cuisine]) graph.merge(cuisine_node, Cuisine, name) graph.merge(Relationship(d_node, BELONGS_TO, cuisine_node))merge而不是create是这里不能省的关键。merge按指定的属性查找节点存在就返回不存在才创建。如果换成create同一份数据跑两遍图谱里就会出现大量重复节点。参数上Graph()的地址要和Neo4j实际开启的bolt端口一致默认是7687如果改了配置要对应调整。写入速度不算快一万道菜大约需要几分钟毕设完全能接受。2.3 用Cypher把食材→菜品→营养串成推荐路径图谱建好后的第一个价值是能直接回答推荐场景里的具体问题。比如家里有鸡蛋和西红柿能做什么菜一个Cypher查出来MATCH (d:Dish)-[:CONTAINS]-(i:Ingredient) WHERE i.name IN [鸡蛋, 西红柿] WITH d, count(DISTINCT i) AS matched MATCH (d)-[:CONTAINS]-(total:Ingredient) WITH d, matched, count(DISTINCT total) AS total_ing WHERE matched 2 AND matched * 1.0 / total_ing 0.5 RETURN d.name, matched, total_ing ORDER BY matched DESC, total_ing ASC LIMIT 10;这段查询做了两件事先统计候选菜品覆盖了目标食材中的几个再算覆盖率覆盖数占总食材数的比例。过滤条件是覆盖数至少2个、覆盖率不低于50%然后按覆盖数降序、总食材数升序排序。排序的含义是覆盖了更多目标食材的菜排前面在覆盖一样多的情况下食材总量少的菜更纯粹因为额外需要购买的食材更少。顺着这条路径继续延伸就能做营养维度推荐。比如用户刚运动完需要补充蛋白质MATCH (n:Nutrient {name: 蛋白质}) MATCH (i:Ingredient)-[:HAS_NUTRIENT]-(n) MATCH (d:Dish)-[:CONTAINS]-(i) RETURN d.name, collect(DISTINCT i.name) AS sources LIMIT 15;注意这里MATCH是逐条匹配的collect(DISTINCT i.name)把食材汇总到数组里查询结果直接就是推荐理由的原材料。这正是知识图谱相对协同过滤的核心优势推荐结果自带解释路径不依赖用户历史行为冷启动问题天然被绕开。3. 让知识图谱真正参与推荐路径特征、图嵌入与混合召回3.1 基于路径的推荐为什么适合食谱这种低度数场景图谱建完如果只拿来做查询那它只是一个数据库。推荐系统里要用图谱核心手段是把它变成特征。食谱场景的特殊性在于节点数量不大万级但语义关系密集鸡蛋能连到几十道菜每道菜又连着一堆食材。这种低度数但高密度的结构最适合用基于路径的推荐思路。路径推荐的思路是用户对某道菜感兴趣往往是因为这道菜和用户历史喜欢的菜之间存在特定的元路径。比如用户爱吃宫保鸡丁发现鱼香肉丝也属于川菜而且都用了花生和鸡肉那么鱼香肉丝被推荐的概率就高。元路径就是描述这种关系的模板Dish-BELONGS_TO-Cuisine-BELONGS_TO-Dish或者 Dish-CONTAINS-Ingredient-CONTAINS-Dish。使用元路径做推荐代码上不需要复杂的图计算框架一条Cypher就能算出候选菜和种子菜之间的路径特征MATCH (seed:Dish {name: 宫保鸡丁}) MATCH (seed)-[:BELONGS_TO]-(cuisine:Cuisine)-[:BELONGS_TO]-(candidate:Dish) WHERE candidate seed WITH candidate, count(*) AS cuisine_similarity OPTIONAL MATCH (seed)-[:CONTAINS]-(ing:Ingredient)-[:CONTAINS]-(candidate) WITH candidate, cuisine_similarity, count(ing) AS ingredient_overlap RETURN candidate.name, cuisine_similarity, ingredient_overlap ORDER BY cuisine_similarity ingredient_overlap DESC LIMIT 20;这段查询把菜系相似度和食材重合度两个维度算出来最后简单求和作为排序分数。OPTIONAL MATCH很关键它保证即使两个维度里有一个没有匹配到候选菜也不会被过滤掉。实际项目中我会把cuisine_similarity和ingredient_overlap做归一化再加权权重分别是0.4和0.6食材重合度的价值更高因为用户选菜时食材是硬约束、菜系是软偏好。这种方式的优点是完全可解释答辩的时候拿着图谱路径讲推荐理由没有任何黑匣子。缺点则是元路径需要手工设计如果只设计了两三条路径推荐出来的结果会比较局限。3.2 用Node2Vec和TransE把图谱变成向量特征要覆盖手工路径设计不到的关系组合就需要图嵌入。图嵌入把节点映射到低维向量空间让相似的节点在向量空间里距离也近。食谱图谱里常用的有两种Node2Vec基于随机游走的图嵌入和知识图谱嵌入比如TransE。Node2Vec在Python里的实现很成熟常见做法是先用networkx把Neo4j里的数据导出来再训练from py2neo import Graph import networkx as nx from node2vec import Node2Vec graph Graph(bolt://localhost:7687, auth(neo4j, password)) # 拉取全部带关系的节点对 query MATCH (a)-[r]-(b) RETURN elementId(a) AS source, elementId(b) AS target, type(r) AS rel_type pairs graph.run(query).to_data_frame() G nx.Graph() G.add_edges_from(pairs[[source, target]].values) # 训练嵌入 node2vec Node2Vec(G, dimensions64, walk_length20, num_walks10, workers4) model node2vec.fit(window5, min_count1, epochs30) model.wv.save_word2vec_format(dish_embeddings.vec)参数选择上dimensions64对万级节点的图谱足够别用128或256向量维度越高训练时间越长效果提升却很小。walk_length20意味着随机游走一次走20步能覆盖三层关系比如 Dish-Ingredient-Nutrient太短只能学到一跳关系。num_walks10是每个节点游走的次数次数太少向量不稳定太多则训练时间线性增长。得到向量之后推荐就变成向量检索。用菜品向量算余弦相似度找最相近的候选菜from gensim.models import KeyedVectors wv KeyedVectors.load_word2vec_format(dish_embeddings.vec) def get_recommendations(seed_dish: str, top_k: int 10): if seed_dish not in wv.key_to_index: return [] similar wv.most_similar(seed_dish, topntop_k) return [dish for dish, score in similar]most_similar返回的实际上是余弦相似度排序。这里有个隐蔽的坑Node2Vec向量里包含所有节点食材、菜系、营养元素如果用食谱数据生成的向量里有Ingredient节点的向量most_similar可能返回一堆食材而不是菜品。解决方式是在训练前把节点ID做前缀区分或者训练后只过滤Dish节点。TransE是另一条路线它把关系也嵌入到向量空间能推断相似食材这类多跳关系但训练和调参比Node2Vec复杂毕设阶段如果时间紧优先做Node2Vec就好。3.3 分层召回图谱排序一个能解释的混合推荐流程图嵌入虽然能捕捉复杂关系但可解释性差——向量算出来的相似菜说不清为什么相似。所以生产里常用的做法是分层召回阶段用图嵌入做宽召回排序阶段用图谱路径特征做精确排序最后配上生成式AI的解释。推荐流程拆成三步召回层Node2Vec向量取Top 50候选。这个阶段宁可多召回别漏掉真正合适的菜。排序层对50个候选分别算图谱路径特征——食材重合度、菜系相似度、包含用户指定营养素的个数然后加权打分。解释层把排序时的路径特征作为素材交给生成式AI生成推荐理由。排序层的伪代码长这样def rank_candidates(seed_dish: str, candidates: list[str]) - list[tuple[str, float]]: seed_vec wv[seed_dish] scores [] for cand in candidates: # 图谱路径特征 path_score get_path_features(seed_dish, cand) # 包含食材重合数、菜系是否相同 # 向量相似度 vec_score cosine_similarity(seed_vec, wv[cand]) # 综合打分路径特征权重0.6向量相似度0.4 total 0.6 * path_score 0.4 * vec_score scores.append((cand, total)) return sorted(scores, keylambda x: x[1], reverseTrue)[:10]参数上0.6和0.4是我测试下来比较稳的比例路径特征主导排序保证推荐可解释向量相似度做微调避免排序结果局限在手工路径里。这两个权重可以通过小规模的离线评测调整后面第6章会说具体怎么做。4. 生成式AI接入推荐链路说人话的推荐解释与对话式点菜4.1 生成式AI在食谱推荐里的三个落点生成式AI在这个系统里不是用来替代推荐算法的而是补上推荐系统最后一段体验把图谱里的结构化关系转化成用户能直接读的话。根据毕设的验收点一般做这三个落点就够了第一推荐解释生成。排序完成之后图谱路径特征食材重合、菜系相同、营养匹配是结构化的键值对直接展示出来像电报一样生硬。生成式AI负责把这些特征组织成通顺的推荐语。第二动态菜单生成。用户给定食材清单和口味偏好推荐系统先按规则筛选候选再让生成式AI把候选组合成一份完整的每日菜单包括菜名、做法的简要步骤和搭配理由。第三对话式调整。用户说这道菜能不能少放辣系统先把意图映射成图谱过滤条件辣度属性、去掉辣椒食材再调用生成式AI把调整后的结果用自然语言回复给用户。这三个落点的共同逻辑是知识图谱负责确定性推理生成式AI负责表达和扩展。两者各管一段不会出现大模型胡说八道导致推荐错误的问题因为推荐候选的过滤条件全在图谱里定死了。这里必须强调一句不要让生成式AI自己决定推荐什么菜这会给答辩证伪留下致命的把柄。4.2 提示词设计与流式输出让大模型说人话选模型服务的时候尽量用支持OpenAI接口格式的国内大模型API这样代码可以统一用一套调用逻辑。以推荐解释生成任务为例提示词必须把图谱给的结构化依据和要生成的文本分开防止模型自由发挥过度from openai import OpenAI client OpenAI( base_urlhttps://your-model-service.example.com/v1, # 换成实际服务地址 api_keyyour-api-key ) def generate_recommendation_reason(dish_name: str, features: dict) - str: prompt f 你是一个美食推荐助手。根据以下图谱证据用一句自然的话向用户解释为什么推荐这道菜。 菜品{dish_name} 证据 - 与用户常吃的菜属于同一菜系{features[cuisine]} - 包含用户冰箱里的食材{, .join(features[matched_ingredients])} - 额外需要采购的食材{, .join(features[missing_ingredients])} 要求 1. 只描述上面给出的证据不要编造其他理由。 2. 语气自然像朋友推荐不要用本助手之类的称呼。 3. 如果额外需要采购的食材较多直接说明。 response client.chat.completions.create( modelyour-model-name, messages[{role: user, content: prompt}], temperature0.3, max_tokens120 ) return response.choices[0].message.contenttemperature0.3是这里最重要的参数。推荐解释是事实性文本温度高了模型会自由发挥编出图谱里不存在的理由温度太低又显得死板。0.3左右是一个平衡点。max_tokens120限制输出长度推荐解释一两句话就够了给太长用户根本不看还浪费token。如果希望体验更流畅可以做流式输出。streamTrue参数让token 边生成边返回用户不用盯着光标等两三秒。毕设演示时流式输出在视觉效果上比一次性输出专业得多def stream_reasoning(dish_name: str, features: dict): prompt build_prompt(dish_name, features) stream client.chat.completions.create( modelyour-model-name, messages[{role: user, content: prompt}], temperature0.3, max_tokens120, streamTrue ) for chunk in stream: if chunk.choices[0].delta.content: yield chunk.choices[0].delta.content后端接到yield后用SSE或WebSocket把内容推给前端打字机的效果就出来了。注意流式输出时前端要处理半截内容的情况不能等到全部接收完再渲染否则流式就失去意义了。4.3 图谱数据和生成内容的校验把答辩证伪的风险堵住大模型的幻觉问题在毕设答辩里是攻击重点。评委最容易问的一句话是你让AI自己发挥推荐理由里写了这道菜含有什么营养这是真的吗所以接入生成式AI的同时必须做一层反向校验。我常用的校验方案是把生成后的文本重新解析提取里面的食材名和营养词回到Neo4j做存在性检查。import re def verify_generated_reason(text: str, dish_name: str) - tuple[bool, str]: # 从生成的文本里提取食材词 known_ingredients load_known_ingredients() # 从图谱缓存 mentioned [w for w in known_ingredients if w in text] # 回查图谱确认这些食材真的属于这道菜 query MATCH (d:Dish {name: $dish})-[:CONTAINS]-(i:Ingredient) RETURN collect(i.name) AS real_ingredients result graph.run(query, dishdish_name).data() real_set set(result[0][real_ingredients]) fake [m for m in mentioned if m not in real_set] if fake: return False, f生成的推荐理由包含图谱中不存在的关系: {fake} return True, 通过这个校验函数每次生成后都跑一遍假的信息直接被拦截。与此同时提示词里明确写了只描述上面给出的证据双保险之下基本能挡住幻觉问题。答辩被问到时直接展示校验函数的日志和通过率比口头解释有说服力得多。生成式AI相关内容的另一个注意点是不要做无限制无审核的生成食谱领域虽然风险低但生成了让过敏体质用户吃出问题的内容责任是实打实的。校验逻辑里应该加上过敏原过滤如果生成的推荐里包含花生、海鲜等常见过敏原而用户资料里标记了过敏直接拦截。5. 避坑从环境配置到推荐效果模糊的5条踩坑记录5.1 Neo4j版本与Python驱动不匹配连接一脸报错现象Graph(bolt://localhost:7687)抛ConnectionRefusedError或者ProtocolError但Neo4j浏览器明明能打开。原因Neo4j 5.x 和 4.x 使用的Bolt协议版本不同旧版py2neo库4.x及以前的握手协议跟 Neo4j 5.x 不兼容。解决要么把Neo4j降到4.4 LTS版本要么换用官方驱动neo4j包。我推荐后者官方驱动维护更活跃。from neo4j import GraphDatabase driver GraphDatabase.driver( bolt://localhost:7687, auth(neo4j, your-password), connection_timeout10 ) with driver.session() as session: result session.run(RETURN 1 AS answer) print(result.single()[answer])connection_timeout10是必加的默认值比较短机器性能差的时候容易误报连接失败。注意官方驱动的会话是一次性的超出with块就不能再用了和py2neo的使用习惯不一样很多人第一次切换时在这里翻车。5.2 Cypher查询跑几秒才返回内存占用飙升现象图谱节点不多一万左右但一个看似简单的MATCH (a)-[]-(b)查询要好几秒。原因查询没有走索引Neo4j做了全库扫描。尤其是用WHERE i.name 鸡蛋这类条件时如果Ingredient的name没建索引每次查询都把整张表过一遍。解决按 2.1 节的代码提前建约束和索引然后用EXPLAIN命令验证查询计划。EXPLAIN MATCH (d:Dish)-[:CONTAINS]-(i:Ingredient) WHERE i.name 鸡蛋 RETURN d.name;如果执行计划里出现NodeIndexSeek说明索引生效了出现NodeByLabelScan就说明还在全表扫。还有个经验关系类型不要设计得太细比如HAS_NUTRIENT和IS_RICH_IN同时存在会让图变大查询规划器也要花更多时间选路径语义上用同一个关系类型加属性区分就够了。5.3 食材别名没归一化图谱被挤成两半现象推荐西红柿炒蛋相关的菜结果里永远缺番茄炒蛋。原因建图时没做食材归一化西红柿和番茄建了两个节点没有关系相连基于路径的推荐当然找不到另一边的菜。解决数据导入前跑 2.2 节的别名映射同时用合并逻辑做兜底。MATCH (a:Ingredient {name: 番茄}) MATCH (b:Ingredient {name: 西红柿}) MATCH (a)-[:CONTAINS]-(d:Dish) MERGE (d)-[:CONTAINS]-(b) DETACH DELETE a;这段Cypher把番茄节点上的所有CONTAINS关系迁移到西红柿节点上然后删除空节点。但这是治标清洗阶段处理好才是治本。还有一点食材名里的空格和大小写也要处理我见过Pork和pork并存的情况Python清洗时统一lower()能规避掉一批。5.4 Node2Vec向量里混进了非目标节点现象most_similar(宫保鸡丁)返回的结果里混着川菜花生这种根本不是菜品的节点。原因训练时没有把节点类型区分开Node2Vec 对所有节点一视同仁。解决训练前给节点ID加前缀。# 导出数据时把节点类型拼进ID query MATCH (a)-[r]-(b) RETURN Dish_ elementId(a) AS source, type(r) AS rel_type, CASE WHEN startNode(r):Dish THEN Dish_ WHEN startNode(r):Ingredient THEN Ing_ ELSE Other_ END elementId(b) AS target 或者更简单训练完过滤结果。用wv.most_similar返回的词列表只保留图谱里确实存在的菜品名。dish_set load_dish_names() results wv.most_similar(宫保鸡丁, topn50) recommendations [word for word, score in results if word in dish_set][:10]第二种方案实现成本低毕设够用。实际生产里会用前缀方案省计算量毕竟拉回50个词再过滤在万级图谱上也是毫秒级差别不大。5.5 生成式AI调用超时推荐流程卡死现象推荐解释生成时如果API服务响应慢整个推荐链路被阻塞用户端表现为点击推荐后长时间无响应。原因同步调用了外部API没有设置超时也没做降级。解决加超时控制超时后走模板兜底——直接用图谱特征拼一句话。import httpx try: response client.chat.completions.create( modelyour-model-name, messages[{role: user, content: prompt}], temperature0.3, max_tokens120, timeout5 # 5秒超时 ) return response.choices[0].message.content except Exception: # 降级方案图谱特征拼成一句话 return (f推荐{dish_name} f它属于{features[cuisine]}菜系 f包含{len(features[matched_ingredients])}种你冰箱里的食材。)timeout5是经验值模型服务一般2秒内返回超过5秒再等下去意义不大。降级方案必须做因为毕设演示现场网络情况不可控一大段卡死的页面比一句不那么流畅的话糟糕得多。6. 离线验证推荐效果一套能写进论文里的实验方案推荐系统的效果验证是毕设答辩里的硬指标。演示完功能评委一定会问你怎么证明你的推荐比别的方法好这个问题需要提前准备一套离线实验方案。实验设计参考这个框架选择100个种子菜品分别用协同过滤、纯图谱路径、Node2Vec、混合推荐图谱Node2Vec四种方法生成Top 10推荐列表。评估指标用两个——Recall10和NDCG10。Recall10 衡量的是图谱里真实的关联食材关系被推荐出来的比例。做法是把每道菜的关系对拆出来比如宫保鸡丁关联鸡肉和花生看推荐列表里有多少道菜也关联了这两个食材。NDCG10 则额外考虑了排序位置正确结果排得越靠前分数越高。评估代码骨架def evaluate_recall_at_k(predictions: dict[str, list[str]], ground_truth: dict[str, set[str]], k: int 10): recalls [] for seed, rec_list in predictions.items(): rec_set set(rec_list[:k]) hits len(rec_set ground_truth[seed]) recalls.append(hits / len(ground_truth[seed])) return sum(recalls) / len(recalls)ground_truth的构建方式很关键不是用用户评分食谱场景根本没有多少真实评分而是用图谱里的关系做弱标签。比如和种子菜共享两种以上食材、属于同一个菜系的其他菜就认为是真实相关的菜。这个设计在论文里要写清楚因为它定义了评估的合理性边界。生成式AI部分的评估建议做两个维度一是生成的推荐理由里提到的食材和营养词回查图谱验证真实存在统计通过率二是找20个人做小规模主观评分1到5分打分标准是解释是否清楚、理由是否充分。主观评分样本不大但能说明生成文本的可用性。最后说一个我自己的交付习惯把所有实验配置、评估脚本、原文数据和清洗后的数据文件放在一个目录里README写清楚运行顺序。答辩时老师要求现场跑一遍评估脚本能直接出结果和图表这套系统的可信度就立住了。如果跑出来混合推荐并没有比纯图谱路径好也别慌——调一下排序权重或者换一组种子菜品再看推荐系统里效果波动是常事关键是你能说清楚为什么波动这本身就是加分项。另外一个让我吃过亏的教训生成式AI的模型名称和接口参数别写死在代码里用配置文件管理。答辩前一晚如果API服务商调整了参数改一行配置就能恢复不用到处找硬编码位置。希望这些经验和踩坑能帮你在毕设和推荐系统的路上少绕几个弯。本文还有配套的精品资源点击获取