
简介这套基于Django框架的多模态知识图谱智能旅游推荐系统以Python为开发语言融合餐饮、旅游等场景数据适合计算机相关专业学生用于毕业设计、课程设计或项目实训。资源包共336个文件包含28个Python核心源码、SQL数据库脚本、前端HTML/CSS/JS页面以及图片素材、Excel数据表、mp4演示录屏等压缩包体积约401.98MB内容覆盖后端逻辑、知识图谱构建、前端展示与数据初始化完整链路。已有288人学习下载。代码经过验证可稳定运行并配有详细注释便于理解推荐算法与图谱实体关系的设计思路数据库脚本与演示视频能帮助快速搭建环境、复现项目效果。项目既适合Django入门进阶也可直接作为毕业设计立项演示或在此基础上扩展多模态数据与个性化推荐功能实用价值较高。1. 多模态知识图谱的旅游推荐系统Django 项目设计前先想清楚的三件事你打开这套基于 Django 的多模态知识图谱旅游推荐系统源码时第一眼看到的不只是模型文件和推荐算法还有 font-awesome.min.css、iconfont.css、demo.css、public.css、style.css 这一套完整的前端资源以及 SQL 数据库初始化脚本。整套代码的目标很明确把景点、餐饮、酒店、用户行为、图片描述、地理位置这些不同模态的数据统一塞进一张可查询的知识图谱里再用推荐算法把这些关系转成个性化排序。为什么不用一张大宽表直接做推荐因为旅游场景里“用户喜欢某个景点”往往不是孤立事件而是“成都”“川菜”“亲子游”“夏天避暑”这些实体之间的路径。知识图谱能用边把用户、地点、标签、图片特征连起来协同过滤解决“和你相似的人还去过哪”图谱遍历解决“你喜欢的景点周边有什么”两者混合后才不会只推同质化内容。这套源码适合两类人做毕业设计的学生可以直接把知识图谱建表、Django 接口、协同过滤召回三段串成完整项目已经工作的开发则可以把里面的图关系建模和慢 SQL 处理思路抽出来用在自己的推荐业务里。下面按“数据建模 → 服务实现 → 排错优化 → 部署二次开发”四层拆开说。2. 知识图谱建模与 SQL 表设计把多模态数据拆成可查询的实体和关系2.1 为什么旅游推荐要用多模态知识图谱而不是标签表常见做法是把景点打上“休闲”“美食”“历史”标签再用标签做相似度。问题在于标签之间是平行的没有“距离”和“方向”。知识图谱把每个标签、城市、图片、用户都当成实体实体之间用有向边连接查询时就能顺着边走到更深一层。多模态在这里不是营销词而是指实体本身的属性来源不同。景区介绍是文本模态封面图是图像模态经纬度是空间模态开放时间是时间模态用户评分是行为模态。图谱建模时把这些模态统一成实体的属性或附属节点比如图像单独建表存 URL 和特征向量再用poi_id关联到景点实体。这样推荐器既可以用文本标签算相似也可以用经纬度算周边还能在 SQL 里按时间过滤“近期热门”。用图建模的另一个好处是冷启动时能走规则路径。新用户没有任何行为但只要有城市实体就能从city - located_in - poi这条边拉出候选集不用等用户产生评分数据。下面这组建表语句就是为这种查询准备的。2.2 SQL 库表结构实体表、关系边表和行为表在 MySQL 里先建库字符集必须用 utf8mb4否则 emoji 和生僻字会写入失败。CREATE DATABASE travel_kg DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE TABLE kg_entity ( id BIGINT PRIMARY KEY AUTO_INCREMENT, entity_type VARCHAR(32) NOT NULL COMMENT poi/city/user/tag/category/image, name VARCHAR(128) NOT NULL, normalized_name VARCHAR(128) NULL COMMENT 去重用的标准名, category VARCHAR(64) NULL, city VARCHAR(64) NULL, address VARCHAR(255) NULL, longitude DOUBLE NULL, latitude DOUBLE NULL, star_rating DECIMAL(2,1) NULL, price_level TINYINT NULL, open_time VARCHAR(64) NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_entity_type (entity_type), KEY idx_entity_city (city), KEY idx_entity_category (category) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE kg_relation ( id BIGINT PRIMARY KEY AUTO_INCREMENT, head_entity_id BIGINT NOT NULL, tail_entity_id BIGINT NOT NULL, relation_type VARCHAR(32) NOT NULL COMMENT located_in/nearby/same_tag/likes, weight DECIMAL(4,3) NOT NULL DEFAULT 1.000, source VARCHAR(16) NOT NULL DEFAULT manual, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_head_rel (head_entity_id, relation_type), KEY idx_tail_rel (tail_entity_id, relation_type), CONSTRAINT fk_kg_rel_head FOREIGN KEY (head_entity_id) REFERENCES kg_entity(id), CONSTRAINT fk_kg_rel_tail FOREIGN KEY (tail_entity_id) REFERENCES kg_entity(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE poi_image ( id BIGINT PRIMARY KEY AUTO_INCREMENT, poi_id BIGINT NOT NULL, img_url VARCHAR(500) NOT NULL, feature_vector VARCHAR(2048) NULL COMMENT 128维降维后的向量逗号分隔, shot_time DATETIME NULL, KEY idx_poi_image (poi_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE user_behavior ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, poi_id BIGINT NOT NULL, behavior_type VARCHAR(16) NOT NULL COMMENT click/collect/order, score TINYINT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_user_time (user_id, created_at), KEY idx_poi_time (poi_id, created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里的核心是kg_relation边表。head_entity_id和tail_entity_id都指向kg_entity.idrelation_type决定了边的语义。比如“宽窄巷子 located_in 成都”和“张三 likes 宽窄巷子”在结构上完全一样差异只体现在relation_type上。weight字段给边一个可信度手工整理的数据给 1.000算法抽取的给 0.700 左右排序时可以乘进去。poi_image.feature_vector是图像模态的关键。毕设阶段不建议直接存高维向量先用 OpenCV 或预训练模型提一个 128 维向量再转成逗号分隔字符串存进VARCHAR(2048)。真正上线再考虑向量数据库Django 项目里先保证逻辑完整。2.3 边表 JOIN 的索引设计和最容易踩的坑知识图谱查询最常见的写法是kg_relation JOIN kg_relation也就是从用户喜欢的实体出发走一跳再从这一跳的落地实体继续走第二跳。这种自连接对索引顺序很敏感。字段/约定常见翻车点建议head_entity_id索引只建单列索引relation_type过滤失效建(head_entity_id, relation_type)联合索引relation_type枚举值库里出现nearby和near_by两种写法用source字段标记来源统一小写下划线图片向量把 base64 图片塞进 MySQL只存 URL 和降维向量原始文件走对象存储城市名称“成都”和“成都市”被当成两个实体入库存normalized_name置换成标准 ID我一般不会在 SQL 里写WHERE name LIKE %成都%来做城市过滤因为左模糊匹配会把联合索引废掉。正确做法是先把用户所在城市映射成city_id再直接查kg_relation里relation_typelocated_in的边。3. Django 推荐服务实现协同过滤召回、图路径遍历与 REST 接口3.1 创建 Django App 并接上 MySQL拿到源码后先在项目根目录确认虚拟环境然后按下面顺序建立工程结构。这里直接用django-admin和manage.py创建 App命令本身没有特殊之处关键是 App 的职责边界要切清楚。django-admin startproject travel_kg cd travel_kg python manage.py startapp recommend python manage.py startapp apirecommend只放推荐算法和知识图谱查询服务api只放 DRF 视图和序列化器。以后如果要从 Django 切到 FastAPI只需要把recommend/services.py原样搬走。settings.py里数据库配置改成 MySQLDATABASES { default: { ENGINE: django.db.backends.mysql, NAME: travel_kg, USER: root, PASSWORD: your_password, HOST: 127.0.0.1, PORT: 3306, OPTIONS: { charset: utf8mb4, }, } }代码里的ENGINE必须写成django.db.backends.mysql不能写成mysql否则 Django 找不到后端。OPTIONS里指定utf8mb4是为了和建表语句保持一致。表结构已经由 SQL 脚本建好的话可以先在模型里把Meta.db_table指到现有表再手工维护模型字段这样不用频繁迁移。如果数据库里表是新的则正常执行makemigrations后用migrate生成。3.2 协同过滤召回用关系表算相似用户推荐器不能每次都对全量用户做矩阵分解毕设数据量也没到那个程度。我用的是“共同点击 共同收藏”的近似协同过滤逻辑是找到和当前用户点击过同一批景点的用户再把这些用户收藏过但当前用户没见过的景点按次数排序。# recommend/services.py from django.db.models import Count def recall_collaborative(user_id, top_k50): my_pois ( UserBehavior.objects .filter(user_iduser_id, behavior_typeclick) .values_list(poi_id, flatTrue) .distinct() ) similar_users ( UserBehavior.objects .filter(behavior_typeclick, poi_id__inmy_pois) .exclude(user_iduser_id) .values(user_id) .annotate(overlapCount(poi_id, distinctTrue)) .order_by(-overlap) .values_list(user_id, flatTrue)[:20] ) candidates ( UserBehavior.objects .filter(user_id__insimilar_users, behavior_typecollect) .exclude(poi_id__inmy_pois) .values(poi_id) .annotate(freqCount(id)) .order_by(-freq)[:top_k] ) return [row[poi_id] for row in candidates]代码里先取当前用户点击过的景点集合my_pois再反查点击过这些景点的其他用户用annotate(overlapCount(...))计算共同点击数。排除掉自己之后再从相似用户的收藏记录里取候选。Count(poi_id, distinctTrue)防止同一条行为被重复计算。这个写法的执行计划取决于user_behavior上有没有idx_user_time和idx_poi_time没有索引时子查询会做全表扫描。3.3 图路径遍历两跳查询补上协同过滤看不到的关联协同过滤只依赖行为重叠对“成都用户偏好川菜”这类语义关系无能为力。图召回通过kg_relation自连接实现两跳遍历第一跳从用户收藏的实体到标签、城市、菜系第二跳再从这些中间实体回到候选景点。def recall_by_graph(user_id, limit30): seed_pois Favorite.objects.filter(user_iduser_id).values_list(poi_id, flatTrue)[:50] seed_pois list(seed_pois) if not seed_pois: return [] placeholders , .join([%s] * len(seed_pois)) params seed_pois [limit] sql f SELECT t.tail_entity_id AS poi_id, COUNT(*) AS path_score FROM kg_relation AS r1 JOIN kg_relation AS t ON t.head_entity_id r1.tail_entity_id WHERE r1.head_entity_id IN ({placeholders}) AND r1.relation_type IN (like, visit) AND t.relation_type IN (nearby, same_tag) GROUP BY t.tail_entity_id ORDER BY path_score DESC, MAX(t.weight) DESC LIMIT %s with connection.cursor() as cur: cur.execute(sql, params) return [row[0] for row in cur.fetchall()]这里r1是第一跳边表示用户与景点之间的行为关系t是第二跳边表示景点与候选景点之间的图谱关系。COUNT(*)统计有多少条路径到达同一个候选点路径越多分数越高。MAX(t.weight)作为次级排序让强关系边排在前面。placeholders用参数化占位符拼接避免把seed_pois直接拼进 SQL 造成注入风险。需要说明的是nearby和same_tag都是建库时手工或半自动导入的关系导入脚本里必须保证两个实体都存在。否则 JOIN 出来会有空节点推荐接口返回null前端拿到后就容易白屏。两跳查询的代价是kg_relation自连接数据涨到十万条以上时EXPLAIN会开始出现临时表后面第 4 章会专门说优化点。3.4 DRF 接口输出和参数透传推荐接口用 Django REST Framework 写接收user_id和top_k两个参数。返回结果要把召回来源也带出来方便前端展示“因为你看过宽窄巷子所以推荐锦里”。# api/views.py from rest_framework.decorators import api_view from rest_framework.response import Response api_view([GET]) def recommend(request, user_id): top_k int(request.GET.get(top_k, 20)) recall_list recall_collaborative(user_id, top_ktop_k) graph_list recall_by_graph(user_id, limittop_k) merged [] seen set() for poi_id in graph_list recall_list: if poi_id not in seen: merged.append(poi_id) seen.add(poi_id) if len(merged) top_k: break return Response({user_id: user_id, items: merged})当协同过滤和图召回重叠时图召回排前面因为图召回带语义解释。top_k通过 GET 参数传入并强制转成int如果用户传abcDjango 会直接抛 400。生产环境建议再包一层try/except或者用 DRF 的serializers.IntegerField做参数校验。4. 慢 SQL 优化与冷启动处理从 EXPLAIN 到替代召回的实际排查4.1 用 EXPLAIN 看图谱自连接慢在哪两跳查询在数据量小的时候感觉不到慢一旦kg_relation到 10 万行排序和分组就会让响应时间从几十毫秒涨到几百毫秒。排查时先拿真实 SQL 跑 EXPLAIN看几个关键列的输出。EXPLAIN SELECT t.tail_entity_id, COUNT(*) FROM kg_relation AS r1 JOIN kg_relation AS t ON t.head_entity_id r1.tail_entity_id WHERE r1.head_entity_id IN (1, 2, 3) AND r1.relation_type IN (like, visit) AND t.relation_type IN (nearby, same_tag) GROUP BY t.tail_entity_id ORDER BY COUNT(*) DESC LIMIT 30;输出列看到什么怎么处理typeALL或index说明没有走索引优先建(head_entity_id, relation_type)联合索引rows明显超过实际命中行数检查relation_type过滤条件是否被前缀截断ExtraUsing temporary; Using filesortGROUP BY和ORDER BY字段不一致导致临时表key_len长度过短索引里没有覆盖relation_type只用到head_entity_id最容易被忽略的是relation_type的过滤条件。只要联合索引写成(head_entity_id, relation_type)where r1.head_entity_id in (...) and r1.relation_type in (...)就可以完全命中索引。如果把relation_type条件从 SQL 里去掉索引就只剩前缀head_entity_id第二跳边t的查询又会退化成全表扫。如果GROUP BY的临时表依然存在可以用窗口函数让数据库只扫描一次。MySQL 8.0 和 SQL Server 都支持ROW_NUMBER() OVER (PARTITION BY ...)把分组排序放到窗口里避免先建临时表再排序。SELECT poi_id, path_score FROM ( SELECT t.tail_entity_id AS poi_id, COUNT(*) AS path_score, ROW_NUMBER() OVER (ORDER BY COUNT(*) DESC) AS rn FROM kg_relation AS r1 JOIN kg_relation AS t ON t.head_entity_id r1.tail_entity_id WHERE r1.head_entity_id IN (1, 2, 3) GROUP BY t.tail_entity_id ) tmp WHERE rn 30;这里的rn是窗口内排序后的行号。PARTITION BY不加列时相当于全表排序加上category之类的列就能做每个类别取 Top N这是推荐接口里做类目打散最常用的写法。4.2 多模态数据入库时的字段漂移问题知识图谱数据经常来自多个渠道有的地方写“成都”有的写“成都市”有的图片 URL 是 HTTP有的是 HTTPS。这种字段漂移在 JOIN 时不会报错但会导致两个相同实体在图上变成两个孤立节点。我一般会在实体表里单独维护normalized_name字段导入数据时先用一个映射字典做归一化。比如“成都”“成都市”“蓉城”都映射到同一个城市 ID映射规则放在management/commands/merge_entity.py里方便重复执行。CITY_ALIAS { 成都: chengdu, 成都市: chengdu, 蓉城: chengdu, } def normalize_city(name): return CITY_ALIAS.get(name, name)写入kg_entity时同时写两份name保留原始展示名normalized_name保存归一化后的标准名。推荐查询里只用normalized_name做条件。这个动作顺便解决了多模态数据里文本和图片同时对不上号的问题图片特征向量再准实体 ID 对不上也是白搭。4.3 冷启动用户的替代召回策略新注册用户没有点击和收藏记录协同过滤和图召回都返回空。这时不能直接给空列表而是退化成两个规则城市热门和知识图谱种子扩展。SELECT b.poi_id, SUM(b.score) AS hot_score FROM user_behavior b JOIN kg_entity e ON e.id b.poi_id WHERE e.city 成都 AND b.created_at NOW() - INTERVAL 7 DAY GROUP BY b.poi_id ORDER BY hot_score DESC LIMIT 20;SQL 里的SUM(b.score)把用户行为分数加起来score字段在点赞、收藏、下单场景下分别给 1、2、3。这样即使用户没有个性化标签也能拿到“最近一周成都用户都在看什么”的结果。等到用户产生第一条点击协同过滤的my_pois就有值系统会自动从冷启动切换到正常推荐。另一种做法是给每个城市实体找三条belongs_city边再从这些边的终点里挑选star_rating 4的景点做规则兜底。这种方式速度快不依赖行为表适合接口压测时做 fallback 分支。5. 宝塔部署 Django 项目后的二次开发要点Admin 配置、前后端分离与效果验证5.1 在宝塔上把服务跑起来部署前先在项目目录生成依赖清单然后把整个目录上传到服务器。宝塔面板里的 Python 项目管理器会自动识别虚拟环境但入口命令要改成gunicorn travel_kg.wsgi:application不能直接用runserver。python -m venv venv source venv/bin/activate pip install -r requirements.txt python manage.py collectstatic --noinput python manage.py migrate python manage.py createsuperusercollectstatic会把font-awesome.min.css、iconfont.css、demo.css、public.css、style.css这些静态资源统一收集到STATIC_ROOTNginx 再通过location /static/映射过去。如果只配了 Django 没配 Nginx页面能打开但所有样式全丢。宝塔的项目目录里不要用 root 权限运行 gunicorn创建独立系统用户更安全。5.2 Django Admin 后台配置和前后端分离注意事项刚拿到源码时后台默认界面很简陋但不需要额外引入前端框架。在recommend/admin.py里注册KgEntity和UserBehavior把列表页、搜索框、筛选器配置完整后台就能直接维护知识图谱实体。# recommend/admin.py from django.contrib import admin from .models import KgEntity, UserBehavior admin.register(KgEntity) class KgEntityAdmin(admin.ModelAdmin): list_display (id, name, category, city, star_rating) search_fields (name, city, category) list_filter (category, city) list_per_page 20 admin.site.site_header 旅游推荐系统后台 admin.site.site_title 知识图谱管理search_fields指定后后台搜索框会生成LIKE %关键词%查询字段少时问题不大但name和city同时搜索时尽量保证这两个字段都有索引。Admin 只是运营端推荐接口要给前端 App 或小程序用的话需要处理跨域。在settings.py里注册corsheaders中间件否则浏览器里从 8080 端口访问 8000 的接口会被 CORS 拦掉。前后端分离后前端路由如果是 history 模式还要在 Nginx 配置里加try_files $uri $uri/ /index.html;否则刷新二级页面会 404。静态资源方面demo.css和public.css是从模板还原出来的改之前先复制一份避免误改原始样式。5.3 推荐效果验证和调参技巧接口上线后最好先离线算一眼命中率。把最近两周的行为数据切一半做训练另一半做验证统计推荐列表里有多少条出现在真实行为里。# scripts/eval_recall.py hit 0 total 0 for uid in test_users: rec_items recommender.run(uid, top_k20) real_items set(UserBehavior.objects.filter(user_iduid).values_list(poi_id, flatTrue)) hit len(real_items set(rec_items)) total min(len(real_items), 20) print(hit_rate20 , hit / total)这里的recommender.run需要你自己在服务类里包一层混合排序把协同过滤和图召回的结果合并后再做类目打散。调参时先固定top_k20观察hit_rate是否随similar_users的用户数上升而下降一般取 20 到 50 之间最稳。graph_list占比超过一半时推荐结果会偏“周边游”用户会觉得重复协同过滤占比高时冷门景点出不来需要把COUNT(*) AS path_score的排序权重调低或者在合并阶段给图召回结果乘一个 0.7 的降权系数。本文还有配套的精品资源点击获取