简介:基于Django与多模态知识图谱的智能旅游推荐系统,是一套面向高校毕设与科研的完整项目方案,适用于计算机科学与技术、数据科学、人工智能等专业方向,解决个性化旅游目的地的智能推荐问题。整套资源以zip压缩包形式提供,共201个文件、201.94MB,包含14个Python核心源码文件、前端HTML/CSS/JS页面与图片素材、zbak数据库备份、xls数据集以及mp4演示视频等,还包含字体、样式等静态资源,目录结构清晰,便于定位和二次开发。平台整合用户偏好、景点特征与时空关系等多源信息,结合多模态知识图谱推理实现推荐,系统经严格测试,模块化设计预留接口,可直接部署,也可基于现有架构扩展功能。源码注释规范并配有技术注解,适合开发者快速掌握Django框架在业务应用中的落地方式,以及知识图谱在复杂推荐场景中的具体实现路径。目前已有63人学习/下载,适合用于课程设计、毕业设计或科研项目开发。
1. 多模态知识图谱旅游推荐:跳出“评分黑匣子”的另一种落地思路
当“智能旅游推荐系统”这类标题出现时,很多人第一反应是协同过滤加深度学习排序,但这类方案在真实旅游场景里有个绕不开的痛点:用户出行决策高度依赖“景点属性、季节、同伴类型、预算、交通便利度、图文观感”等多源信息,纯行为日志很难覆盖冷启动用户。而把Django作为Web框架、把景点数据构建成多模态知识图谱(文本描述、图片特征、价格、时段、地理位置等多类信息融合成图谱的节点与边),再基于图结构做推荐计算,就能给用户一个“为什么推荐这个景点”的明确理由。这套方案同时解决了冷启动和可解释性两个问题,适合正在做毕业设计、旅游平台原型或者想替换传统推荐算法落地的工程师参考。本文要聊的不只是理论,而是一条能从数据库表设计一路跑到接口出数的完整链路。
2. 多模态知识图谱的选型与数据建模:为什么不用表结构硬扛
2.1 旅游数据里的“多模态”到底指什么
先明确一个容易混淆的点:知识图谱不是简单地把数据从关系型数据库搬到图数据库。旅游推荐里真正有价值的信息往往是跨模态的——一个景点不仅有“名称、门票价格、开放时间”这类结构化字段,还有大量的非结构化数据:用户评价里高频词汇“适合亲子”“台阶多不适合老人”、景点图片里提取出的“视野开阔”“绿化覆盖率高”的视觉特征、地理坐标对应的周边业态分布。这些信息在传统SQL表里要么存成臃肿的JSON字段,要么拆成多张关联表后查询复杂度失控。
多模态知识图谱的构建思路是:把每一个景点、城市、用户、标签、图片特征都建模成实体节点,把“位于”“适合”“评分”“拍摄于”等关系建模成边,而模态差异体现在节点属性上——一个景点节点可以同时有文本类的“简介摘要”属性、数值类的“平均游玩时长”属性、向量类的“图片特征向量”属性。图数据库对这类异构属性的包容度远超关系型数据库,不需要为每种数据类型单独建表,也不需要在应用层拼装冗余的多表关联数据。
2.2 选型:Neo4j 负责图计算,MySQL 负责业务流水
实际工程里不需要把所有数据都塞进图数据库。用户账号、订单记录、推荐日志这些事务性强、字段稳定的数据放在MySQL里更合适;而景点之间的关联、景点与标签的归属关系、用户与景点的交互历史这类“多跳才能拿结果”的数据放进Neo4j。这种混合架构的好处是:图计算只在需要推荐解读的路径上执行,常规CRUD操作仍然走关系型数据库,系统整体吞吐压力可控。
具体到版本选型,Neo4j Community Edition 4.x 配上python的py2neo或Neo4j官方Python驱动都成熟稳定。MySQL这边建议直接选8.x,窗口函数和JSON函数在后续处理推荐结果时都能用上。如果机器配置有限,Neo4j单独跑在一台2核4G的云主机上、MySQL跑在另一台,这种拆法完全够课程设计和中小型原型系统使用。
2.3 数据建模实操:用Cypher写实体与关系
拿到一份景区数据后,我一般先按四个实体维度建模:景点(ScenicSpot)、城市(City)、标签(Tag)、用户(User)。关系则包括“位于”(景点到城市)、“包含”(城市到景点)、“适合”(景点到标签)以及后续写入的“收藏”(用户到景点)。下面这段Cypher语句示例展示如何把一份清洗后的景点数据以批量方式加载进Neo4j:
LOAD CSV WITH HEADERS FROM 'file:///scenic_spots.csv' AS row MERGE (c:City {name: row.city_name}) CREATE (s:ScenicSpot { id: toInteger(row.spot_id), name: row.spot_name, description: row.description, price: toFloat(row.price), duration: toInteger(row.duration_minutes) }) CREATE (s)-[:LOCATED_IN]->(c);这里用MERGE而不是CREATE来构建城市节点,是为了避免同一条城市数据在多次导入时生成重复节点。景点的description字段先原文存进去,后面做文本特征提取时再统一处理;price和duration分别用toFloat和toInteger做类型转换,防止CSV里的字符串格式污染后续的数值计算。上面这段命令执行时,文件路径以Neo4j服务端的 import 目录为基准,Windows和Linux环境都要先确认neo4j.conf里的dbms.directories.import配置。混合结构下MySQL里则保留景点基础表、用户表和后续的推荐结果表,两张库之间的同步通过Django的transaction.on_commit钩子或定时任务完成,不需要引入重量级的数据库同步软件。
3. 推荐核心链路:图算法计算相似度与召回候选集
3.1 从用户行为到图谱路径的映射
推荐链路的第一步是把用户行为转换为图谱中可达的路径。用户收藏过杭州西湖,那么用户节点到西湖景点节点之间会产生一条COLLECTED关系;西湖景点节点通过LOCATED_IN关系连接杭州城市节点,再通过城市节点的反向关系可以拿到“杭州所有景点”。这里面的“一跳”不是SQL里的外键连接,而是一条明确的图语义路径。用图遍历替代多表join,代码层面更直观,也方便在后面加“排除已去过”这类过滤条件。
推荐候选集的生成我一般会分两步走:先用图遍历把同一城市、相似标签下的景点粗召回回来,再通过节点相似度计算做精排。粗召回这一步不需要太复杂的算法,Cypher的路径查询本身就够用。
假设某个用户收藏了“西湖”和“灵隐寺”,要找出这两个景点的相似候选,可以执行下面的路径查询:
MATCH (u:User {id: $userId})-[:COLLECTED]->(s:ScenicSpot) MATCH (s)-[:SUITED_FOR]->(t:Tag)<-[:SUITED_FOR]-(candidate:ScenicSpot) WHERE candidate.id <> s.id WITH s, candidate, collect(DISTINCT t.name) AS sharedTags RETURN candidate.id AS candidate_id, candidate.name AS candidate_name, size(sharedTags) AS tag_overlap_score, sum(similarity_func(s.embedding, candidate.embedding)) AS embedding_score ORDER BY tag_overlap_score DESC;这段查询的核心逻辑是“以共同标签为桥梁找出候选景点”,size(sharedTags)是一个简单的共现标签计分,similarity_func是一个占位函数——实际项目中这里通常放余弦相似度或欧氏距离计算,用来比较两个景点节点的图片特征向量或描述文本向量。粗召回阶段不需要把候选集压缩得太小,一般保留30到50个备选就够排序阶段使用了。
3.2 特征向量存储与相似度计算:把图片和文本纳入图谱
多模态这个关键词最直接的体现就是节点上挂载的向量属性。比如景点图片用ResNet或VGG这类卷积网络抽取出一个2048维特征向量后,直接存在Neo4j节点属性里会把图数据库的存储结构搞得很笨重——每个节点都带一个巨大的浮点数组,备份和查询性能都会受影响。我的做法是把向量统一放到独立的向量存储中(开发环境可以用pickle文件加索引,生产环境接Milvus或FAISS),而Neo4j节点上只需要保留一个image_embedding_id字段来引用对应的向量ID。
计算两个景点相似度时,先从向量存储取出向量,再在Python层用NumPy做余弦相似度。这套做法的好处是图谱计算和向量计算解耦,后续如果要把向量维度从512提升到2048,不需要对图数据库做任何迁移。对于文章类数据(用户评价、景点简介)同理,用预训练模型抽句向量后也走同一套机制。
3.3 融合排序:兴趣分与可解释性相结合
候选集有了、相似度分数也有了,最后一步是把这些信号融合成一个可排序的综合分。常见的融合公式是:
综合推荐分 = 0.4 * 交互热度分 + 0.3 * 用户偏好标签匹配度 + 0.2 * 向量相似度分 + 0.1 * 时间衰减因子
热度分用景点历史浏览次数或点评数量降幂归一化得到;用户偏好标签匹配度直接从用户最近收藏过的景点标签中统计高频标签,与候选景点的标签集合做杰卡德相似度;向量相似度就是上一节算出的embedding距离;时间衰减因子用于削弱半年前的偏好对当前推荐结果的干扰。
排序阶段的代码位于Django的视图层还是独立的推荐服务模块,取决于你的并发量——项目实战阶段的建议是写成独立的recommender.py服务层,不直接塞在Django视图中,这样后续如果要把推荐逻辑拆成独立微服务或者用Celery异步化,改动成本很低。
from sklearn.metrics.pairwise import cosine_similarity import numpy as np def fuse_score(overlap_score, hot_score, embedding_score): return 0.4 * hot_score + 0.3 * overlap_score + 0.2 * embedding_score + 0.1 * 0.5上面的代码片段演示了综合分计算的结构,其中overlap_score是标签匹配度、hot_score是旅游热度、embedding_score是向量相似度。实际项目里这些分数需要先从Neo4j查出来再在Python侧融合,二分一行的代码只是示意,真正开发时不建议在数据库里做带权重的复杂运算,因为图数据库的强项是遍历而非计算。
4. Django 工程落地:模型定义、推荐接口与查询性能
4.1 从startapp到数据库模型:表结构怎么设计
Django侧采用startapp创建名为tourism的应用。工程内同时管理SQL数据和图数据,因此ORM只负责MySQL一侧的表结构。设计三个核心模型:用户扩展信息表(Profile)、景点基础表(Spot)、推荐日志表(RecommendationLog)。Profile 表主要存用户的选择偏好标签,Spot 表与 Neo4j 的 ScenicSpot 节点保持id一致,RecommendationLog 表用于记录每次推荐的上下文和被推荐结果,供后续效果评估。
from django.db import models class Spot(models.Model): spot_id = models.IntegerField(primary_key=True) name = models.CharField(max_length=100) description = models.TextField(blank=True, default='') price = models.FloatField(default=0.0) duration_minutes = models.IntegerField(default=60) city_name = models.CharField(max_length=50, db_index=True) created_at = models.DateTimeField(auto_now_add=True) class Meta: db_table = 'spot'这段迁移建表代码中,spot_id直接作为主键与Neo4j的景点节点id保持一致;city_name加上db_index是因为后续按城市筛选推荐结果时会高频查询这一列。Django的ORM优势在同步表结构,配合python manage.py makemigrations && python manage.py migrate两条命令,建表不需要手工编写MySQL DDL,迁移脚本本身也能入库管理。
4.2 推荐接口设计与返回结构
推荐接口设计为/api/recommendations/<user_id>/,返回内容包含推荐景点列表、每个景点的推荐理由以及各维度分数。推荐理由直接由图谱路径生成,比如“因为您收藏的西湖包含标签‘自然风光’,而此景点同样适合‘自然风光’”。图数据库的可解释性优势集中提现在这里——协同过滤模型无法直接告诉你为什么推荐,图谱可以。
from django.http import JsonResponse from django.views.decorators.http import require_GET @require_GET def recommend_for_user(request, user_id): candidates = fetch_candidates_from_graph(user_id) ranked = ranking_service.rank(candidates, user_id) return JsonResponse({ "user_id": user_id, "items": [ { "spot_id": item.spot_id, "name": item.name, "score": item.score, "reason": item.reason } for item in ranked[:10] ] }, safe=False)fetch_candidates_from_graph负责通过Neo4j驱动执行图遍历查询并返回候选集,ranking_service.rank负责上一章的融合排序。这里有一个接口性能的关键点:不要把整条推荐链路放在一次Django请求里同步跑完,特别是图查询涉及的Cypher语句可能毫秒级也正常,但如果Neo4j出现慢查询或连接阻塞,HTTP请求会被拖到超时。常见做法是把结果缓存到Redis,设置5到10分钟的过期时间;或者用Celery异步生成推荐结果、接口只读取最近一次缓存。
4.3 SQL与Cypher的组合查询:里面藏着性能关卡
当Neo4j返回了一组候选景点id后,需要从MySQL中取出景点的名称、价格等展示数据。这一步千万不能循环查库,几十个候选集循环一次相当于几十次磁盘IO往返。
spot_ids = [item['candidate_id'] for item in candidates] spots_batch = Spot.objects.filter(spot_id__in=spot_ids).values('spot_id', 'name', 'price', 'city_name') spot_map = {item['spot_id']: item for item in spots_batch}批量从ORM取出全部候选景点数据然后转成spot_map字典,后续做数据拼装时就是常数级的时间复杂度。MySQL的IN查询在列表长度50以内时性能完全可接受,这也是粗召回阶段把候选集控制在合理范围的原因之一。
结合Django是自带管理后台的框架,后台中注册景点表和推荐日志表后,可以直接操作MySQL数据;Neo4j的数据则单独写一个小工具页面或在工程里留一个同步脚本完成。项目开发阶段这套组合非常好用,生产环境里则需要在 Django Admin 控制发送广播来清理 Redis 中的推荐缓存。
5. 避坑与性能排查:图存储、向量和缓存的五个经典问题
5.1 Neo4j 依赖库版本不匹配导致连接报错
现象:py2neo 连接 Neo4j 报Unauthorized或ConnectionRefused,端口明明已经对外开放。原因:项目中安装的是 py2neo 4.x 或 5.x 版本,但 Neo4j 服务端用的是 3.x。py2neo 4 开始 Web 服务需要认证,默认密码是neo4j/neo4j,第一次登录后强制改密,代码中仍使用旧账号密码时连接失败。解决:先用浏览器登录 Neo4j Browser 确认服务端版本和密码,再根据服务端版本安装匹配的 Python 驱动;Neo4j 4.x 以上官方推荐使用neo4j驱动而不是 py2neo。
5.2 中文标签和属性在 Cypher 查询中的编码问题
现象:查询结果中景点名称乱码或者空值。原因:CSV 导入时 Neo4j 服务端字符集与文件编码不一致,Linux 环境默认 UTF-8 但 Windows 导出的 csv 常是 GBK。解决:导出前统一把数据转为 UTF-8 无 BOM 格式;如果已经有脏数据,在 Cypher 里用trim和replace函数处理后再合并或创建节点。
5.3 把向量存进 Neo4j 导致数据库启动变慢
现象:Neo4j 启动慢、备份文件巨大,而且查询纯属性返回很慢。原因:多个景点节点带着 512 维的浮点数组,Neo4j 存储引擎对这种大属性没有优化,每次 JSON 序列化反序列化开销巨大。解决:向量收敛外部向量数据库,Neo4j 节点只存embedding_id。如果规模小但逼不得已一定要存节点里,至少把它声明为byte[]类型而不是 list,存储体积能压缩一半以上。
5.4 Django ORM 的 N+1 查询拖垮推荐接口
现象:推荐列表接口响应 3 秒以上,查看 SQL Query Log 发现候选景点数量等于 SQL 查询数量。原因:在 Python 循环里对每个候选景点单独执行了一次 ORM 查询,没有用__in批量抓取。解决:所有根据 id 列表取数据的操作一律filter(id__in=...)批量查询,内存中维护spot_map字典。这条原则同样适用于标签、城市等关联信息组装阶段。
5.5 推荐结果陈旧,用户反馈“翻来覆去就这些”
现象:用户连续访问三天,推荐列表几乎没有变化。原因:推荐结果在 Redis 里缓存时间设成 24 小时,用户的实时交互行为没有影响到缓存中的候选集和排序分。解决:缓存过期时间缩短到 5 到 10 分钟;用户触发收藏动作时,把该用户ID的推荐缓存直接删除,下次请求实时生成一次。前者用cache.set(key, val, timeout)控制即可,后者在收藏视图函数中调用cache.delete(f"rec:{user_id}")。
6. 用图数据库原生命令验证推荐效果,并优化候选集质量
部署完成后第一步建议不是看推荐接口返回值,而是到 Neo4j Browser 手动检查几张关键图谱结构图——用户节点与收藏景点的关系是否正确生成,景点与标签的连接是否稀疏。连接极度稀疏通常意味着导入数据时标签实体没有成功匹配,推荐算法会退化成按城市硬捞,效果近似随机推荐。
验证推荐质量的实用方式是抽样对比。拉取 20 个真实用户,每个用户记录下收藏的景点集合;跑一次推荐算法,检查返回的前 10 个景点里与用户已有收藏共享至少两个标签的比例。比例低于 60% 说明标签数据太稀疏或者图谱构建有遗漏,需要回到 Cypher 导入语句检查标签合并逻辑,看是不是导入 CSV 时标签字段里混入了空格或特殊字符。
进阶调优方向有两个。一是把“同时出现在同一篇游记里的两个景点”建模为CO_OCCUR关系,这会显著提升跨城市推荐的相关性。二是对多模态向量做加权融合:用户在看图片为主的景点(如自然风光类)时,提高图片特征向量在排序分中的权重;用户看博物馆类景点时提高文本描述向量的权重。Django项目中加一个请求参数scene就能实现这种场景级的动态调权,接口逻辑不需要大幅变动。如果数据量真的到了百万节点以上,把Neo4j查询和Python计算整合成一个基于文档的推荐日志同步方案,直接落到MySQL的分区表,方便后续做推荐效果的离线评估和排序模型的迭代,这里就不再展开了。
整套方案走下来,我的实际感受是旅游推荐里效果增益的70%并不来自算法模型有多深,而是来自图谱把数据织成了网、Django把流程组织成了完整闭环。真正耗时间的也从来不是写那几个Cypher语句和ORM模型,而是数据清洗和关系梳理——这一步做扎实了,后面所有推荐效果都是水到渠成的结果。我个人的习惯是每次换数据集重新跑一遍导入、验证几个核心查询,而不是着急写代码调参数。希望这些路径和踩坑记录能帮你少走一段弯路,也祝这套框架能成为你后续做推荐系统时的趁手工具。
本文还有配套的精品资源,点击获取