
做了几年推荐系统我越来越觉得传统协同过滤那套思路在旅游场景里有点使不上劲。用户今天查机票订酒店明天刷短视频看别人拍的旅行vlog过几天又可能因为一部电影的取景地对一座城市产生兴趣——行为数据稀疏到能见底可兴趣线索其实藏在一堆长尾关系里。于是我把目光转向了知识图谱用一张结构化的关系网把用户、景点、城市、美食、酒店串起来再在这张网上做推荐。这篇文章就是我落地一个“基于知识图谱的旅游推荐系统”的完整复盘从数据建模、图谱构建到推荐算法选型、实际踩坑一条线讲清楚。这个项目适合谁手头有推荐系统经验、想试试图模型思路的人正在做毕业设计或实验室项目的同学对知识图谱构建感兴趣、想找个真实场景练手的工程师。我会把每个环节的取舍和动手细节尽量写出来希望你能照着做出一版能跑的系统也能少走我走过的弯路。1. 项目背景为什么旅游推荐这么难又为什么是知识图谱1.1 传统推荐在旅游场景的三大痛点先说痛点。旅游推荐和电商推荐的差别远不是“物品换了一种”那么简单。第一是数据稀疏。像电商、短视频这种场景用户和物品的交互是高频的今天点一个、明天收藏一个数据量堆起来了协同过滤很容易起效。但旅游是典型的低频决策大多数人一年也就出游几次在一个平台上留下的搜索、收藏、预订记录非常有限。我做这个项目时手里的公开数据集行为覆盖不到5%。在这么稀疏的交互矩阵里算用户相似度结果往往是一堆“表面邻居”——两个用户可能仅仅因为都收藏过同一家热门景区就被认为高度相似实际上兴趣方向完全不一样。第二是决策链条长且跨域。旅游不是一个孤立动作而是“想去哪儿→怎么去→住哪儿→吃什么→玩什么”的完整链条。用户订了海景酒店大概率也喜欢沿海骑行用户搜过重庆火锅可能对重庆这座城市的夜景、洪崖洞一类的地点也有兴趣。传统推荐系统如果只盯着“同城用户还去过哪”就丢掉了这些跨实体的关系。而恰恰是这些跨域关系才是旅游中最有价值的推荐线索。第三是可解释性差。旅游推荐的决策成本比刷短视频高太多。用户收到一个“猜你喜欢”的酒店推荐如果系统只说“和你有类似行为的人也喜欢这家”说服力非常弱。用户会追问为什么推荐这家它离我要去的景点近吗这个季节适合去吗协同过滤给不出这样的解释路径游客也不会为一个“算法说合适”的结论买单。1.2 知识图谱究竟补上了什么知识图谱本质上是把实体和关系变成一张图。对旅游推荐来说实体可以是景点、城市、美食、酒店、用户、标签关系可以是“位于”“主打菜系”“适合季节”“被用户收藏”。有了这张图推荐就不再局限于用户行为相似度这一个维度。我真正觉得知识图谱有价值是在三个点上一是多跳推理。传统协同过滤是“物以类聚人以群分”的一阶相似。知识图谱可以做到多跳用户甲收藏了景点A景点A位于城市C城市C里有另一个景点B景点B的口味与用户甲过去搜索过的关键词“亲子游”匹配。这个三跳路径传统基于行为的模型很难捕捉到而在图上一句Cypher就能查出来。二是语义化关联。知识图谱让推荐系统“理解”实体背后的属性。比如“50元以内”“适合亲子”“带老人不累”这些约束图谱可以用属性或标签来承载。推荐时不再只按行为打分而是结合属性匹配来过滤。三是可解释性。图谱上的推荐结果天然带着一条或多条路径你可以把这条路径作为推荐理由“因为您收藏过鼓浪屿而它和厦门大学都在厦门且同在‘山海徒步’主题标签下所以推荐您关注厦门园林植物园。”这种解释是电商协同过滤很难做到的。说到底知识图谱给推荐系统补的是“语义关系”和“路径解释”两件事。它不替代行为数据而是把行为数据融进一张更大的关系网里让推荐有更丰富的上下文。解决了这些痛点之后项目整体方向就很明确了先构建一张旅游知识图谱再在图上做召回和排序。2. 项目整体设计技术选型与图谱建模2.1 技术栈选型满足场景、够用就好技术选型这事我一直的原则是“够用、能跑、好迭代”不为炫技去堆组件。这个项目最终选型如下图数据库Neo4j 4.4社区版加APOC插件做路径扩展和算法辅助。数据处理与图谱构建Python 3.9、pandas、spaCy、jieba、SimHash库。推荐模型先做基于元路径的召回和排序再用TransE做知识图谱嵌入最后试了GraphSAGE做图神经网络融合特征。服务层FastAPIRedis做候选结果缓存。评估模块自己写PrecisionK、RecallK、NDCGK、多样性指标计算脚本。为什么选Neo4j社区版主要看数据规模。这个项目的完整图谱跑下来大概是120万节点、400万条关系这个量级在Neo4j社区版上完全没问题而且它的Cypher查询语言非常直观开发和调试效率比分布式图数据库高太多。如果哪天数据涨到上亿节点需要分布式存储和计算再迁到JanusGraph或者NebulaGraph也不迟。项目初期最重要的是快速验证效果而不是一开始就上重家伙。2.2 本体设计把旅游场景抽象成一张图本体设计是整个系统最关键的一步也是后来改得最多的一步。一个不合理的本体后面跑推荐算法时会处处难受。我最初的思路是“实体类型越少越好”但实践下来发现不行然后把类型细化到9种、关系收敛到8类最终定型如下实体User、City、ScenicSpot、Cuisine、Hotel、Activity、TimeTag、ThemeTag、Review。关系LocatedIn位于、NearBy附近、Serves提供菜系、GoodFor适合主题、HasReview有评论、Favorite收藏、VisitHistory到访过、SuitableSeason适合季节。这里有个关键设计把“评论”设计成一种实体而不是挂在景点下面的属性。原因很简单——一条评论既是用户偏好信号又能做自然语言处理的语料后面提取主题标签、情感倾向都靠它作为一个节点反而更灵活。主题和时间也单独抽成实体而不是做成普通属性这样“主题”和“季节”可以通过关系挂到大量景点上便于后续做路径查找。比如我要找“适合秋天、适合亲子的景点”只需要从TimeTag和ThemeTag出发找连接路径。本体的约束也要在入库前想清楚。比如“景点属于城市”、“城市属于省份或国家”、“菜系是城市的美食名片”这类逻辑能通过唯一约束和关系方向约束来兜底。举个例子我用下面这段Cypher加了约束CREATE CONSTRAINT scenic_name_unique IF NOT EXISTS ON (s:ScenicSpot) ASSERT s.name IS UNIQUE; CREATE CONSTRAINT city_name_unique IF NOT EXISTS ON (c:City) ASSERT c.name IS UNIQUE;加了唯一约束之后重复导入同名景点会直接报错或合并省了很多后续清洗的麻烦。实测下来在导入前先把约束建好比导入后再去重效率高一个数量级。2.3 数据来源与预处理保证合规、干净、可复用数据来源是这类项目最容易踩坑的地方也是合规风险高发区。我的做法是公开数据集使用某旅游网站脱敏后的公开数据集以及部分开放获取的城市POI数据。这些都是明确可用的公开数据。景点、酒店、美食POI数据通过公开API获取严格遵守接口调用频率和授权协议只抓公开发布的展示信息不采集用户隐私数据。用户行为数据项目初期没有也没有必要用真实用户隐私数据而是用数据集自带的脱敏收藏、评分记录。如果要验证冷启动我还自己构造了模拟用户画像数据。预处理阶段有两个脏活一是清洗字段去掉空格、统一全半角、修正乱码二是填充缺失属性比如景点经纬度、门票价格、开放时间。前者用pandas跑一遍常规清洗就够后者需要调用地理逆编码接口把缺失坐标的POI补上经纬度。一个我印象很深的坑公开数据集里同一个景点可能出现多个名字比如“西湖”和“杭州西湖风景名胜区”还有“灵隐寺”和“灵隐飞来峰景区”。如果不做实体对齐图谱里会同时存在好几个实际上是同一个景点的节点后面推荐时相似度计算会被这类噪音严重干扰。这个问题我在第5章会展开讲。3. 核心环节实现从零构建旅游知识图谱3.1 知识抽取先解决“谁是实体谁有关系”构建知识图谱的第一步是知识抽取也就是从非结构化文本和数据表里识别出实体和关系。旅游领域的命名实体比较集中主要就是地名、景点名、菜品名、酒店品牌但难在叫法多样。我最终用“规则词典jieba分词spaCy微调NER模型”的组合方案。规则部分处理的是数据表里的结构化字段。比如数据表里已经有“景点名称”“所在城市”这种字段直接映射成本体里的关系就行不需要走NER。真正需要NER的是评论和游记这类非结构化文本。我在spaCy基础上用标注好的旅游语料做了微调同时加了自建词典。自建词典的威力比想象中大用几百条景点名、菜名、地名词条挡住了一大半“灵隐寺被拆成灵隐和寺”这类分词错误。做个简单的示意抽取评论实体和关系时核心逻辑类似下面的流程import spacy import jieba # 加载微调后的模型 nlp spacy.load(models/travel_ner) def extract_entities_from_review(text): doc nlp(text) entities [(ent.text, ent.label_) for ent in doc.ents] # 用词典补召回 for word in place_dictionary: if word in text and word not in [e[0] for e in entities]: entities.append((word, SCENIC)) return entities这里纯粹是演示思路。实际项目里抽取出来的实体还要和组织本体做匹配。比如NER识别出“厦门大学”需要和知识图谱里已有的ScenicSpot节点关联起来如果图谱里不存在就要决定是新建节点还是丢弃。我的规则是在POI主数据表里出现的实体必须入库只在评论里出现但查不到任何背景信息的暂时挂在Review实体下不单独成景点节点。这个策略有效控制了垃圾节点数量。3.2 实体对齐“西湖”和“杭州西湖风景名胜区”不该是两个节点实体对齐放到图谱构建里做比建完图再做要省事得多。对齐的第一个问题是归一化。大小写、全半角、繁简体、连续空格都要先统一。比如“Hangzhou”和“hangzhou”、“小南国”和“小南国静安店”不归一化后面根本没法比。对齐的第二个问题是别名处理。旅游POI的别名太多比如“故宫”又叫“故宫博物院”、“紫禁城”“外滩”也有人写“上海外滩”。别名表是必须维护的但靠手工维护不现实。我补充了两套自动对齐手段地理坐标去重如果两个候选实体的经纬度距离小于50米且名称相似度超过0.7就合并成一个节点。这个方法对“同一个景点两个叫法”的情况非常有效。字符串相似度语义相似度先用SimHash做候选集召回再用预训练句向量模型对名称向量化计算余弦相似度超过阈值再结合上下文判断是否合并。这里我想多说一句“为什么用SimHash而不是直接用编辑距离”——因为图谱里几十万个景点名两两算编辑距离是不可接受的。SimHash能把每个名称压缩成64位指纹通过海明距离快速找到候选相似项量级可以从O(n²)降到近似O(n)。这也是工程上常用的加速技巧。3.3 图谱入库批量导入与索引优化是关键知识抽取和对齐做完就得到实体表和关系表。入库这一步看似简单实际坑不少。我的入库方案是Neo4j的LOAD CSV而不是逐条CREATE。先把实体和关系分别导出成CSV文件再执行Cypher导入。比如导入景点实体的核心语句是LOAD CSV WITH HEADERS FROM file:///scenic_spots.csv AS row MERGE (s:ScenicSpot {id: row.id}) SET s.name row.name, s.lng toFloat(row.lng), s.lat toFloat(row.lat), s.price toFloat(row.price), s.open_time row.open_time;关系导入也是类似逻辑。注意这里用了MERGE而不是CREATE配合前面建好的唯一约束可以有效防止重复节点。性能经验我总结三条第一导入前先建好索引和约束。没有唯一约束的情况下用MERGENeo4j会全表扫描来判断节点是否存在百万级数据能跑到让人怀疑人生。建了约束之后MERGE会走索引速度提升几十倍。第二分批提交。LOAD CSV虽然是在服务端执行但数据量大的时候建议按文件或按行数分批导入比如一个城市一个文件避免单个事务过大。我一开始图省事一次性导入400万条关系结果跑了一个多小时还没结束改用按城市分批后全部导入只用了二十多分钟。第三关系导入时先把节点全部准备好再导关系。如果节点没导完就导关系MERGE会在每个关系导入时都尝试匹配两侧节点做了大量无效的节点查找。这个顺序问题很多人容易忽略。3.4 推荐算法选型从路径规则到图神经网络图谱构建只是手段最终目标还是推荐。推荐算法我分了三代迭代。第一代基于元路径的推荐。元路径是两个实体之间的关系序列比如“User-Favorite-ScenicSpot-LocatedIn-City-LocatedIn-ScenicSpot”表达的意思是“找到与用户收藏景点同城的其他景点”。实现上就是写Cypher查询控制路径长度不超过4跳把命中的节点作为召回集。这种方法最直观、最容易解释也是我上线第一个版本用的主力方案。缺点是依赖人工设计路径覆盖度有限。第二代TransE知识图谱嵌入。TransE把图中的实体和关系映射到低维向量空间核心思想是让“头实体向量关系向量≈尾实体向量”。这样推荐问题可以转化为向量相似度计算。我用的是DGL框架自带的TransE实现嵌入维度设成128训练50个epoch。相比元路径的精确匹配TransE的召回更灵活能够捕捉到没有显式路径但语义相近的实体。第三代GraphSAGE图神经网络。我在图谱结构信息的基础上把用户的交互行为、景点的属性特征都融入到节点表示里用GraphSAGE做归纳式学习。为什么选GraphSAGE而不是GCN或GAT因为GraphSAGE支持归纳式学习——新用户、新景点加入时不用重新训练整个图模型对旅游这种频繁有新POI进入的场景更实用。后面评估也证实GraphSAGE在Precision10和NDCG两项指标上都优于前两代。但这三代算法不是互相替代的。我在最终版本里把它们做成级联结构先用元路径和TransE做召回再用GraphSAGE对召回结果做排序既控制计算量又兼顾召回率和精度。4. 实操过程从数据到接口的一次完整演示4.1 数据准备以“厦门之行”为测试案例为了把整个流程串起来我用一个具体的测试用例来说明。假设系统里有一个用户历史行为是收藏了“厦门大学”和“鼓浪屿”在知识图谱里对应两条Favorite边。现在要给这个用户推荐新的景点。首先数据准备阶段要做的是把用户行为数据转换成图谱实体。用户和景点的交互数据来自脱敏公开数据集格式是“用户ID, 景点ID, 行为类型, 时间戳”。清洗时注意去重和过滤无效行为比如把浏览、点击这类弱信号行为单独存不直接当收藏处理。我这里特别说一句不要把所有行为一视同仁。收藏和评分是强信号浏览和搜索是弱信号。强信号构建Favorite关系弱信号可以作为辅助特征输入GraphSAGE但不要都建成同一种关系。否则图谱里关系的语义纯度会被稀释后续基于“Favorite”路径的推荐就会掺进大量噪音。4.2 从文本到三元组评论数据这样变成图评论数据处理的流程是评论文本→实体抽取→情感判断→三元组生成。比如一条评论“鼓浪屿的建筑很有特色适合拍照但夏天人太多”经过NER、情感分析后会生成如下三元组(鼓浪屿, GoodFor, 拍照遛弯)(鼓浪屿, SuitableSeason, 春季)(鼓浪屿, SuitableSeason, 秋季)注意“夏天人太多”这句话经过情感判断后我没有生成“适合夏天”的关系而是生成了一条“不适合夏天”的负向规则存到单独的表中。负向信息在推荐里很有用可以避免在7月给用户推荐暴晒的海滨浴场。这个环节的产出是一张标准的三元组表包括头实体、尾实体、关系、证据来源、置信度。置信度字段是我后期加上的因为自动抽取的关系难免有错推荐时要能根据置信度做加权低置信度的关系对最终打分贡献要调低。4.3 图谱构建与Neo4j导入实录把三元组整理成实体表entities.csv和关系表relations.csv后进入导入环节。我真实执行的步骤是第一步清空旧数据、建约束MATCH (n) DETACH DELETE n; CREATE CONSTRAINT scenic_name_unique IF NOT EXISTS ON (s:ScenicSpot) ASSERT s.name IS UNIQUE; CREATE CONSTRAINT city_name_unique IF NOT EXISTS ON (c:City) ASSERT c.name IS UNIQUE; CREATE CONSTRAINT user_id_unique IF NOT EXISTS ON (u:User) ASSERT u.id IS UNIQUE;第二步分批导入城市/景点/酒店等实体再导入关系。关系导入用类似下面的语句LOAD CSV WITH HEADERS FROM file:///relations.csv AS row MATCH (s:ScenicSpot {id: row.src_id}) MATCH (c:City {id: row.dst_id}) MERGE (s)-[r:LOCATED_IN]-(c) SET r.source row.source, r.confidence toFloat(row.confidence);这里有个细节关系导入前一定要先确认源节点和目标节点的MATCH能命中。我之前踩过坑实体表没导入完就导关系结果大量关系因为匹配不到节点而被静默跳过。检查方法很简单导入前先数一下实体数量对得上再导关系。第三步验证图谱基本盘。我会跑几个统计查询比如“每个城市的景点数量分布”“评论数最多的前10个景点”“用户收藏关系总量”这些查询能快速发现数据有没有明显异常。一个常见异常是某些城市的景点数量特别多多到超出合理范围多半是实体对齐出了问题——同一个景点被重复建了很多节点。4.4 推荐接口实现与效果评估图谱和推荐模型就绪后我用FastAPI封装了一个推荐接口。输入是用户ID和Top-K数量输出是推荐景点列表及推荐理由路径。核心逻辑是三段式伪代码如下from fastapi import FastAPI app FastAPI() app.get(/recommend/{user_id}) def recommend(user_id: str, k: int 10): cands metapath_recall(user_id, max_hops4) # 元路径召回 cands | transe_recall(user_id, top_n300) # TransE召回 ranked graphsage_rank(user_id, cands) # GraphSAGE排序 return build_response(ranked[:k])返回结果里除了景点信息还带有推荐理由。比如测试用户收藏过“厦门大学”和“鼓浪屿”元路径召回会找到“厦门园林植物园”“沙坡尾艺术西区”等景点理由是“位于厦门市且与收藏景点同在‘文青打卡’主题标签下”。这种解释在界面上呈现给用户点击率明显高于纯原因的无解释推荐。离线评估我用了一组测试集指标包括Precision10、Recall10、NDCG10和多样性。多样性我用的指标是推荐列表中不同主题标签的覆盖数。对比数据大致如下模型Precision10Recall10NDCG10多样性(主题标签数)流行度基线0.0820.1170.1533ItemCF0.1140.1690.2175元路径MPR0.1530.2060.2646TransE0.1610.2240.2816GraphSAGEKG0.1870.2530.3127虽然这个结果是特定数据集上的离线表现但趋势很稳定引入知识图谱结构信息后推荐精度和多样性都有可感知的提升。尤其多样性的提升在旅游场景里意义很大因为用户出去旅游往往希望体验不同类型的景点而不是被推十个同质化的“网红打卡地”。5. 常见问题与排障实录5.1 实体对齐准确率上不去卡在别名和“父子景点”上我第一版实体对齐在测试集上准确率只有82%左右。排查后发现主要卡在两类问题上。一类是别名覆盖不足。“周庄”和“周庄古镇”、“平遥古城”和“平遥古城墙”这类叫法SimHash相似度不算特别高单纯靠文本相似度很容易漏。解决方法是引入地理坐标约束同一坐标附近的候选实体即使文本不太像也有很大概率是同一个景点。给归一化文本坐标匹配加高权重后准确率涨到91%。另一类是“父子景点”问题。“灵隐寺”和“灵隐飞来峰景区”地理坐标距离很近名称也有一定相似度但实际上是包含关系而不是同一个实体。如果直接合并会把“灵隐飞来峰景区”的评论错误地挂到“灵隐寺”节点下。我的处理办法是当两个实体坐标重叠但名称差异较大时先判断是否属于“景区包含子景点”模式是则保留两个节点额外增加一条“CONTAINS”关系只有名称足够相似且属性高度重合时才真正合并。5.2 Neo4j查询性能一条Cypher跑了十几秒第二个让我头大的问题是推荐接口的元路径查询太慢。最开始版本一次四跳路径查询要跑12秒以上这显然没法做在线推荐。排查过程有两个重点。第一看执行计划。用EXPLAIN或PROFILE看查询计划发现大量的NodeByLabelScan这意味着Neo4j在按标签全表扫描没有走索引。给查询涉及的实体字段补上索引后性能立刻提升到2秒内。第二路径爆炸问题。元路径设计为4跳中间节点如果度极高比如北京市这种节点连接了几万个POI展开路径会指数级增长。我的办法是限制中间节点的度比如只在关系数少于2000的中间节点上做展开或者先从候选城市维度裁剪图数据再跑路径查询。如果你的图谱数据和查询也慢建议从两个方向去查先看有没有走索引再看路径是否展开过于宽泛。前者一般靠加索引和约束解决后者需要从算法层面上限制搜索空间。5.3 推荐结果“看起来都一样”多样性怎么救GraphSAGE模型上线后Precision倒是上去了但用户反馈“推荐来推荐去都是同一类景点”。分析之后发现是标签分布倾斜的锅图谱里“网红打卡”“摄影”标签的景点数量是“文化古迹”的5倍以上模型为了推高Precision把概率都给了热门标签下的景点多样性自然被牺牲了。解法是用MMR最大边际相关性做结果重排。MMR的思路是每选一个结果时既要看它和用户的相关性又要看它和已选结果的差异性用一个参数lambda控制两者权重。具体实现是算推荐候选之间的主题向量相似度或者图路径重合度迭代替换结果列表里的低分歧项。这个改动之后多样性指标从6涨到了7同时Precision只掉了0.3%几乎可以忽略。5.4 冷启动用户没有任何行为也能推荐最后是冷启动问题。旅游场景里新用户注册后没收藏、没搜索传统协同过滤完全失效。知识图谱在这里的一大优势是用户虽然没有行为但可以收集到注册时的少量偏好信息比如选择了“亲子游”“海岛度假”标签。这些标签可以直接连接到图谱中的ThemeTag实体再通过“ThemeTag-GoodFor-ScenicSpot”这类元路径给出第一波推荐。如果连偏好标签都没有退一步用城市维度做兜底根据用户的IP归属地或常用城市先推荐所在城市周边高置信度、全季节适宜的景点。实践下来冷启动推荐结果的点击率虽然比老用户低但比纯热门推荐高58%。图谱把“有限的信息”转化为“可推理的路径”这正是它适合冷启动场景的原因。做这个项目的过程中我最深的体会是知识图谱不是银弹它不能解决推荐系统的所有问题但它把推荐从一个“算相似度”的问题变成了一张“找路径”的问题。旅游这种低频、跨域、高解释需求的场景恰好能发挥图谱的多跳语义优势。如果你也在做类似的推荐系统我的建议是先别急着上大模型和图神经网络用元路径跑通一版把图谱构建和对齐的功夫下足——数据质量上去了后面用再简单的算法效果也不会差。最后再分享一个小技巧知识图谱的价值不只体现在推荐精度提升上那个“推荐理由”的附加能力在真实产品里往往比模型指标更有说服力。