
简介Python协同过滤旅游推荐系统文档是一份面向计算机科学与技术等专业本科毕业设计的完整论文资料针对旅游信息过载问题围绕个性化推荐需求展开。系统以Python爬虫采集旅游数据借助Kettle完成预处理并存入MySQL利用协同过滤算法实现热门景点、用户行为、途径景点与乘车方式推荐同时设计可视化大屏兼顾数据管理与用户交互。压缩包内共有1个docx文档大小约8.64MB说明书全文约48页文档结构完整含中英文摘要、目录、正文及结论等毕业设计说明书必备模块便于直接参考格式与撰写思路。资源适合正在筹备旅游推荐类毕业设计或想了解推荐系统整体架构的学生已有547人学习下载内容兼具系统设计与论文写作双重参考价值。1. 旅游推荐系统一套毕设级的 Python 协同过滤落地参考先把话撂在前面旅游推荐系统这类毕业设计市面上能搜到的源码包不少但大多数打开之后只有几个 Python 文件加一张 ER 图连数据库脚本都缺。这份资源不一样的地方在于它把完整链路串起来了——Python 爬虫采集旅游数据、Kettle 做预处理、MySQL 存数据、Django 做 Web 层最后用协同过滤算法实现“热门景点推荐”“为你推荐”“猜你喜欢”还带一个基于 Echarts 的可视化大屏。这意味着你拿到的不是一段孤立的算法代码而是一条能从原始数据一路跑到界面展示的完整流水线对做毕设或者练手项目来说参考价值比纯算法 demo 高得多。这套东西比较适合正在选题或中期改题的计算机专业学生也适合想快速搭一个推荐系统雏形的开发者。下面我按落地顺序拆开讲架构、算法、数据链路、踩坑和验证方法。2. 系统架构与技术选型Django MySQL Kettle 的分工逻辑2.1 总体分层数据层、算法层、展示层各管什么这套系统的技术栈并不是随便凑的每一层解决一个具体问题。数据层解决的是“数据从哪来、怎么变成结构化数据”算法层解决的是“怎么从用户行为里算出推荐结果”展示层解决的是“推荐结果和数据指标怎么呈现给用户”。三层之间通过 MySQL 数据库衔接Django 作为 Web 框架把三层串起来对外提供服务。从资源里给出的目录结构看它的分层方式和主流毕设系统基本一致。用户通过浏览器访问 Django 渲染的页面页面上的景点搜索、收藏、评论操作会写入 MySQL推荐模块读取用户行为表和景点数据表用协同过滤算出候选集再渲染到“为你推荐”和“猜你喜欢”等区域可视化大屏单独走数据聚合查询把统计指标交给 Echarts 绘制成图表。这种分层的好处是每一块都可以独立替换——比如你觉得 Kettle 太重完全可以换成 pandas 脚本做预处理不影响其他模块。2.2 为什么选 Django自带 admin 和 ORM 的省事方案Django 在这套系统里承担的是 MVC 框架职责但真正让开发省力的是两个特性内置 admin 后台和 ORM 数据库操作。对于毕设场景你需要快速演示“管理员能管理景点数据”这类功能Django admin 几行配置就能生成增删改查界面不用手写一堆视图函数。ORM 则让你不用直接拼 SQL用 Python 对象操作 MySQL 表即可代码里也能看到清晰的模型定义。使用 Django 时要注意版本差异。老项目里常见的 Django 2.x 和新手现在安装的 Django 4.x 在 urls 配置、中间件写法和一些模板标签上有不小差别。如果你照着资源里的代码跑不起来优先检查 settings.py 里的ROOT_URLCONF、TEMPLATES目录配置和python manage.py migrate是否执行过。这三个位置是 Django 项目启动失败的高发区。2.3 MySQL 与 Kettle数据落库和清洗的配合方式MySQL 在这套系统里存储的是两类数据一类是景点基础信息包括景点名称、所在城市、热度值、评分、图片路径等另一类是用户行为数据主要是用户对景点的收藏记录和评论记录。这两类数据是协同过滤的输入原料——没有行为数据算法就无从谈起。Kettle 的角色是 ETL 工具负责把爬虫采集到的半结构化数据比如 JSON、HTML 解析结果转换成符合 MySQL 表结构的格式。它的核心操作是“输入 → 转换 → 输出”三步输入步骤读取原始文件或数据库表转换步骤做字段映射、去重、类型转换输出步骤写入目标表。资源里用 Kettle 而不是直接用 Python 做预处理可能是出于论文写作时“使用了专业 ETL 工具”的考虑实际复现时如果你对 Kettle 不熟用 pandas 代替也完全合理但要注意论文里写了什么就尽量保持一致答辩才不会被追问。2.4 可视化大屏的选型Echarts 的数据呈现方式大屏模块的技术选型是 Echarts这是一个纯前端的图表库通过 JavaScript 调用。在 Django 模板里引入 Echarts 有两种常见做法一种是在 HTML 模板里直接写script标签引入 Echarts 的 CDN 文件然后通过 Django 模板变量把数据塞进图表配置另一种是用 Ajax 异步请求后端接口获取 JSON 数据再在前端渲染。毕设场景下推荐用后者因为答辩时展示“前后端数据交互”比静态渲染更能体现工作量。大屏通常展示的指标包括景点总数、用户总数、收藏总数、热门景点 Top10、各省份景点分布等。这些统计在 Django 视图里用 ORM 的aggregate和annotate方法就能算出来不需要额外写复杂 SQL。3. 协同过滤算法实现核心公式、Python 代码与参数调优3.1 基于用户的协同过滤原理与适用边界这套系统的推荐核心是基于用户的协同过滤User-Based Collaborative Filtering它的核心假设是跟你兴趣相似的用户喜欢的东西你也大概率喜欢。算法分三步走第一步建立“用户—景点”的评分矩阵或行为矩阵第二步计算用户之间的相似度找出当前用户的 K 个最近邻第三步根据近邻用户对景点的行为预测当前用户对未交互景点的兴趣度取 Top-N 生成推荐列表。这套算法在旅游场景下有一个天然优势用户行为数据相对稀疏但语义明确。收藏一个景点说明用户想去或去过评论说明用户有表达意愿这些行为比隐式反馈比如浏览时长更容易转换成评分。但它也有明显的边界——新用户没有历史行为算不出相似用户这就是所谓的冷启动问题。这套系统里的“热门景点推荐”实际上就是冷启动的兜底方案没有个性化数据时先推全局热门的。了解这个边界对后续调参很重要。3.2 相似度计算从余弦相似度到皮尔逊系数协同过滤的相似度计算有两个常用公式。第一个是余弦相似度它把每个用户的评分向量看作多维空间里的一个点计算两个向量之间夹角的余弦值。值越接近 1说明两个用户的兴趣方向越一致。第二个是皮尔逊相关系数它在余弦相似度的基础上减去了用户的平均评分消除了用户打分尺度不同带来的偏差——有的用户习惯打高分有的用户习惯打低分皮尔逊系数能校正这种系统性偏差。在旅游推荐场景里如果行为数据是“是否收藏”这类 0/1 值余弦相似度就够了如果行为数据有明确的评分比如用户给景点打 1~5 星优先用皮尔逊系数。下面这段代码实现了皮尔逊相似度和基于它的推荐预测import numpy as np import pandas as pd from collections import defaultdict def pearson_similarity(user_ratings, other_user_ratings): 计算两个用户评分向量的皮尔逊相关系数 user_ratings: dict, 格式 {item_id: rating} other_user_ratings: dict, 格式 {item_id: rating} # 取两个用户都评过分/收藏过的景点 common_items set(user_ratings.keys()) set(other_user_ratings.keys()) if len(common_items) 2: return 0.0 # 转成列表方便计算 items list(common_items) vec1 np.array([user_ratings[item] for item in items], dtypenp.float64) vec2 np.array([other_user_ratings[item] for item in items], dtypenp.float64) # 减去均值消除打分尺度差异 vec1_centered vec1 - np.mean(vec1) vec2_centered vec2 - np.mean(vec2) # 计算皮尔逊相关系数 numerator np.sum(vec1_centered * vec2_centered) denom np.sqrt(np.sum(vec1_centered ** 2) * np.sum(vec2_centered ** 2)) if denom 0: return 0.0 return numerator / denom def recommend_top_n(target_user_id, user_item_matrix, n5, k10): 基于用户的协同过滤推荐 target_user_id: 目标用户ID user_item_matrix: dict, 格式 {user_id: {item_id: rating}} n: 推荐景点数量 k: 近邻用户数量 target_ratings user_item_matrix[target_user_id] if not target_ratings: return [] # 新用户无法推荐交给热门景点兜底 # 第一步遍历所有其他用户计算相似度 similarity_scores [] for other_user_id, other_ratings in user_item_matrix.items(): if other_user_id target_user_id: continue sim pearson_similarity(target_ratings, other_ratings) if sim 0: # 只保留正相关用户 similarity_scores.append((other_user_id, sim)) # 第二步取相似度最高的 K 个用户 similarity_scores.sort(keylambda x: x[1], reverseTrue) top_k_users similarity_scores[:k] if not top_k_users: return [] # 第三步从近邻用户行为中收集目标用户没交互过的景点 score_dict defaultdict(float) for other_user_id, sim in top_k_users: for item_id, rating in user_item_matrix[other_user_id].items(): if item_id in target_ratings: continue # 跳过用户已经收藏/评分的景点 # 用相似度加权累计预测分数 score_dict[item_id] sim * rating # 按预测分数排序取前 N 个 sorted_items sorted(score_dict.items(), keylambda x: x[1], reverseTrue) return [item_id for item_id, _ in sorted_items[:n]]这段代码的逻辑分三块。相似度计算函数处理的是两个用户的公共景点集合公共景点少于 2 个时直接返回 0避免在小样本上算出虚假的高相似度。推荐函数里有两个关键参数k10控制近邻数量n5控制推荐结果数量。sim 0这个过滤条件是个细节——只保留正相关的用户能有效避免负相似度用户把推荐结果带偏。3.3 参数调优K 值、相似度阈值和置信度加权协同过滤里最核心的参数是近邻数 K。K 太小推荐结果受单个用户行为影响太大容易走偏K 太大会把相似度很低的用户也拉进来推荐结果趋近于热门榜失去个性化。一般经验是 K 取 10~30 之间具体数值取决于用户总量和数据的稀疏程度。在这套系统里如果用户量只有几十个K 超过 10 就会明显感觉推荐结果变得“大众化”。相似度阈值也需要设置。代码里sim 0是一个最小阈值实际使用中可以根据数据情况调高到 0.3 或 0.5过滤掉弱相关用户提升推荐精度。另一个实用技巧是给高频行为降权如果一个用户收藏了几百个景点他的相似度信号会被稀释如果一个用户只收藏了 1 个景点他在共同景点上的相似度可能虚高。常见的做法是在相似度计算时对公共景点数量做惩罚比如乘以min(len(common_items), 50) / 50这样的系数。运行推荐模块时第一次跑完建议打印几行中间结果来看推荐质量。我一般会这样验证选一个已知收藏了“故宫”的用户看推荐结果里前 5 个景点是否包含其他北京景点或者其他热门人文景点。如果推荐结果是明显不相干的乱入景点先查相似度计算环节看看是不是公共景点太少或评分矩阵没有正确构建。4. 数据链路搭建爬虫采集、Kettle 预处理到 MySQL 落库4.1 Python 爬虫采集requests BeautifulSoup 的基础组合数据采集用的是 Python 爬虫这在旅游推荐系统里是标准做法——景点名称、所在城市、门票价格、热度评分、图片地址这些字段从旅游网站或公开数据源采集后落库。常见做法是用requests库发送 HTTP 请求获取页面内容再用BeautifulSoup解析 HTML 结构提取目标字段。import requests from bs4 import BeautifulSoup import pymysql import time def fetch_scenic_spots(city, page1): 采集某城市的景点列表数据 city: 城市名称 page: 页码 url fhttps://example-travel-site.com/scenic?city{city}page{page} headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Referer: https://example-travel-site.com/ } resp requests.get(url, headersheaders, timeout10) if resp.status_code ! 200: print(f[ERROR] 请求失败: {resp.status_code}) return [] soup BeautifulSoup(resp.text, html.parser) spots [] for item in soup.select(.scenic-item): # 根据实际页面结构调整选择器 name item.select_one(.name).text.strip() if item.select_one(.name) else city_text item.select_one(.city).text.strip() if item.select_one(.city) else city score item.select_one(.score).text.strip() if item.select_one(.score) else 0 spots.append({ name: name, city: city_text, score: float(score) if score else 0.0, source_url: url }) time.sleep(0.5) # 控制请求频率避免被封 return spots # 将采集数据写入MySQL def save_to_mysql(spots): conn pymysql.connect(hostlocalhost, userroot, password123456, databasetravel_recommend, charsetutf8mb4) cursor conn.cursor() sql INSERT INTO scenic_spot (name, city, score, source_url) VALUES (%s, %s, %s, %s) cursor.executemany(sql, [(s[name], s[city], s[score], s[source_url]) for s in spots]) conn.commit() conn.close()这里有个爬虫的通用细节time.sleep(0.5)做请求限速headers里带上User-Agent和Referer模拟真实浏览器。这套组合能应付大部分静态页面的采集需求但遇到动态渲染页面数据通过 Ajax 加载时需要改用selenium或直接分析 XHR 接口返回的 JSON。论文里写的是“Python 爬虫技术”具体用了什么库要看资源里的代码——如果是静态页面上面这套基础方案基本就够了。4.2 Kettle 数据预处理字段映射、去重和类型转换Kettle 在这条链路里做的是数据预处理作用位置在爬虫采集之后、MySQL 入库之前。爬虫拿到的是原始 HTML 解析结果可能包含空字段、重复记录、格式不统一的评分文本比如“4.5分”和“好评率95%”混在一起这些脏数据直接入库会污染后续的协同过滤计算。Kettle 的典型处理流程是用“表输入”或“CSV 文件输入”步骤读取原始数据 → 用“字段选择”步骤做字段映射 → 用“排序记录”加“去除重复记录”步骤去重 → 用“字符串替换”或“JavaScript 代码”步骤清洗文本字段 → 用“表输出”步骤写入 MySQL。关键转换都发生在中间几个步骤特别是字段名不一致的问题——爬虫代码里叫score数据库表里叫scenic_score需要明确映射关系。如果你不打算装 Kettle 图形界面也可以用 pandas 完成同样的事情但要注意论文里的技术方案写的是 Kettle答辩时如果被问到“预处理是怎么做的”你得能讲清楚 Kettle 的作业和转换概念至少说出“字段映射、去重、类型转换”这三个步骤。4.3 MySQL 表结构设计用户表、景点表和行为表数据落库的核心是表结构设计这套系统的数据库至少包含三张核心表用户表、景点表、用户行为表。用户表存账号密码和基本信息景点表存爬虫采集到的景点数据用户行为表存收藏和评论记录是协同过滤的直接输入。下面是按毕设常见设计整理的建表 SQL-- 用户表 CREATE TABLE t_user ( user_id INT NOT NULL AUTO_INCREMENT COMMENT 用户ID, username VARCHAR(50) NOT NULL COMMENT 用户名, password VARCHAR(100) NOT NULL COMMENT 密码建议存哈希值, nickname VARCHAR(50) DEFAULT NULL COMMENT 昵称, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 注册时间, PRIMARY KEY (user_id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表; -- 景点表 CREATE TABLE t_scenic_spot ( spot_id INT NOT NULL AUTO_INCREMENT COMMENT 景点ID, name VARCHAR(100) NOT NULL COMMENT 景点名称, city VARCHAR(50) DEFAULT NULL COMMENT 所在城市, province VARCHAR(50) DEFAULT NULL COMMENT 所在省份, score DECIMAL(3,1) DEFAULT 0.0 COMMENT 评分, heat INT DEFAULT 0 COMMENT 热度值, image_url VARCHAR(255) DEFAULT NULL COMMENT 图片地址, description TEXT COMMENT 景点简介, PRIMARY KEY (spot_id), KEY idx_city (city) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT景点表; -- 用户收藏行为表 CREATE TABLE t_user_favorite ( id INT NOT NULL AUTO_INCREMENT COMMENT 记录ID, user_id INT NOT NULL COMMENT 用户ID, spot_id INT NOT NULL COMMENT 景点ID, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 收藏时间, PRIMARY KEY (id), KEY idx_user_id (user_id), KEY idx_spot_id (spot_id), CONSTRAINT fk_fav_user FOREIGN KEY (user_id) REFERENCES t_user (user_id), CONSTRAINT fk_fav_spot FOREIGN KEY (spot_id) REFERENCES t_scenic_spot (spot_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户收藏表;建表时有几个实际考虑。用户名加了唯一索引避免重复注册收藏表设计成复合外键关联用户表和景点表确保数据完整性utf8mb4字符集是为了兼容中文和 emoji 表情老项目用utf8会出现“存储中文正常、存储特殊字符报错”的问题。收藏时间字段create_time很重要协同过滤可以结合时间衰减——用户最近的收藏行为权重应该高于很久以前的行为。4.4 爬虫数据往 MySQL 里灌的避坑字符集和重复数据爬虫采集的数据往 MySQL 写入时最大的坑是字符集不一致。MySQL 连接配置里必须使用charsetutf8mb4数据库本身也要设置成utf8mb4否则遇到景点介绍里的特殊符号会直接报错或变成乱码。其次是重复数据问题——爬虫多跑几次同一景点可能被重复插入需要在景点表加唯一索引或者在 Kettle 预处理阶段做去重。最简单的方式是在t_scenic_spot上给name city加联合唯一索引然后插入时使用INSERT IGNORE或ON DUPLICATE KEY UPDATE跳过重复记录。5. 避坑指南协同过滤与毕设系统落地中的高频翻车点5.1 冷启动新用户没有行为数据算法直接返回空列表现象刚注册的用户登录系统后“为你推荐”区域一片空白或者只显示“猜你喜欢”但没有内容。原因基于用户的协同过滤需要用户行为矩阵作为输入新用户没有收藏、没有评论、没有评分user_item_matrix[target_user_id]是空字典算法无数据可用。解决在推荐逻辑里加一个兜底分支。判断用户行为数据是否为空为空时直接返回热门景点 Top-N。热门榜的计算可以基于景点表的heat字段也可以基于收藏表的COUNT(*)聚合结果。这套系统实际上已经设计了“热门景点推荐”模块正好可以承担这个兜底任务。实现时注意热门的计算要加时间范围限制比如统计最近 30 天的收藏数避免历史累积数据让老景点永远霸榜。5.2 数据稀疏用户只收藏了两三个景点推荐结果不可用现象系统用户量有几百个但大部分用户只有个位数的收藏记录推荐结果五花八门看不出和用户兴趣有任何关联。原因协同过滤依赖用户之间的共同行为数据越稀疏能找到的相似用户越少相似度计算也越不稳定。日志里常见的表现是相似度较高的近邻用户不足 K 个。解决从两个方向入手。其一在相似度计算里把“只有个位数收藏记录”的用户从近邻候选集中剔除用min_n_ratings参数控制比如用户至少要有 5 条行为记录才参与相似度计算。其二给相似度加置信度权重用公共景点数量做惩罚项公共景点越少相似度打折越狠。代码实现时可以在pearson_similarity返回值上乘一个系数confidence min(len(common_items), 20) / 20。5.3 Django 项目跑不起来版本差异和数据库迁移是最常见原因现象python manage.py runserver启动报错常见错误包括ModuleNotFoundError: No module named django.urls、TypeError: __init__() got an unexpected keyword argument use_require这类。原因资源里的代码基于老版本 Django 2.x 编写而新环境安装的是 Django 4.x。urls.py里的url()函数写法到 Django 4.x 已经移除模板层的一些标签也做了调整。另外数据库表没迁移时启动后访问页面会报Table xxx doesnt exist。解决建一个独立的虚拟环境安装项目指定的 Django 版本不要用全局环境。如果资源里有requirements.txt直接按pip install -r requirements.txt安装如果没有先查看settings.py里的 Django 配置线索再手动指定版本安装。启动后一定要先执行两步python manage.py makemigrations和python manage.py migrate让 Django 根据模型自动创建数据库表结构。5.4 MySQL 中文乱码连接字符集没对齐现象景点名称和用户昵称在 MySQL 里显示正常通过 Django 页面展示时却出现问号或乱码。原因Django 的连接配置里没有指定字符集或者 MySQL 数据库、表的字符集不是utf8mb4。爬虫写入时用的连接是utf8mb4Django 读数据时用的连接是utf8或默认的latin1两边不对齐就会出现乱码。解决在 Django 的settings.py里数据库配置的OPTIONS中加上charset: utf8mb4同时检查 MySQL 库和表的字符集。一条 SQL 就能查清楚SHOW CREATE TABLE t_scenic_spot;看到DEFAULT CHARSETutf8mb4就是对的。老库已经是utf8的话用ALTER TABLE ... CONVERT TO CHARACTER SET utf8mb4;转一下即可。5.5 爬虫被封请求频率太高或没有模拟浏览器环境现象爬虫跑了一会儿后请求返回 403 或验证码页面采集到的数据量远低于预期。原因目标站点检测到高频请求或带有明显爬虫特征的请求头。贴代码时只写了User-Agent没写Referer或time.sleep时间太短都容易被封。解决在headers里补全浏览器常见字段包括Accept、Accept-Language、Referer把请求间隔从 0.5 秒调到 2~3 秒并随机化延迟time.sleep(random.uniform(1, 3))。如果目标站点需要登录才能看到景点数据还需要在爬虫里维护登录状态Cookie 或 Session。这条经验是通用做法不针对任何具体站点——写毕设时数据量不需要很大几百条记录完全够演示没必要追求全量采集。6. 推荐质量验证与调优技巧让算法结果可演示、可解释推荐模块跑通不难难的是推荐结果在演示时能被看懂。我的习惯是把验证拆成两层离线指标验证和在线样例验证。离线指标上把用户行为数据按 8:2 切分成训练集和测试集用训练集预测测试集中的收藏行为计算准确率PrecisionN和召回率RecallN。下面这段代码演示了评测逻辑的基础写法from sklearn.model_selection import train_test_split def split_and_evaluate(user_item_matrix, n5, k10): 切分数据并计算推荐结果的准确率和召回率 all_precision [] all_recall [] for user_id, ratings in user_item_matrix.items(): if len(ratings) 10: continue # 数据量太少无法评测 # 按用户切分80%训练20%测试 items list(ratings.keys()) train_items, test_items train_test_split(items, test_size0.2, random_state42) # 用训练数据构造该用户的评分向量 train_ratings {item: ratings[item] for item in train_items} temp_matrix {user_id: train_ratings} # 为了演示这里只取一个用户做样例实际评测应遍历全部用户 # 其他用户同样切分后放入 temp_matrix # 调用协同过滤推荐 recommendations recommend_top_n(user_id, temp_matrix, nn, kk) if not recommendations: continue # 计算命中率 hits len(set(recommendations) set(test_items)) precision hits / n recall hits / len(test_items) if test_items else 0 all_precision.append(precision) all_recall.append(recall) avg_precision sum(all_precision) / len(all_precision) if all_precision else 0 avg_recall sum(all_recall) / len(all_recall) if all_recall else 0 print(fPrecision{n}: {avg_precision:.3f}, Recall{n}: {avg_recall:.3f}) return avg_precision, avg_recall评测代码里的hits变量是关键——它统计推荐结果中命中了测试集行为的数量。如果准确率低于 0.05说明推荐结果基本是随机猜测水平需要回头检查相似度计算或增大行为数据的采集量。召回率在旅游场景下通常不高用户收藏的景点本来就不多我们更关注准确率。在线样例验证是另一个维度。我会挑三个不同画像的用户来验证推荐效果一个收藏了大量北京人文景点的用户、一个收藏了自然风光景点的用户、一个刚注册没有行为的新用户。前两个用户应看到明显不同的推荐列表新用户则应看到热门榜单。如果两个老用户的推荐结果几乎一样说明个性化失效了大概率是 K 值太大或相似度区分度不够。最后再给一个实用性建议推荐模块的日志要打印中间结果。在相似度计算处加一行日志输出目标用户找到的近邻用户 ID 和对应相似度值这样演示时如果推荐结果不对可以直观看到是“没找到相似用户”还是“找到了但相似度太低”排错效率会高很多。我一开始跑这套系统时看到推荐结果里出现了一个明显不相关的景点排查到最后发现是景点表里有一条脏数据城市字段为空导致相似度计算把空城市的记录算成了公共特征——从那以后我每次往 MySQL 灌数据都会先跑一遍完整性检查确认关键字段没有空值再继续。希望这份踩坑记录能帮你在复现这套旅游推荐系统时少走几步弯路。本文还有配套的精品资源点击获取