
简介这份资源面向计算机相关专业学生与知识图谱入门开发者围绕OSM城市数据展开知识图谱构建的完整实践可作为课程大作业或自学项目的参考方案。压缩包共27个文件、约26.25MB以xml、json、csv等数据文件为主配合py脚本、ipynb笔记本与xlsx表格覆盖从原始数据到图谱落地的各环节另有md说明文档与工程配置文件辅助理解项目结构。资源中提供了POI、AOI、标签等城市要素的样例数据以及build、tool、config等模块化脚本便于读者直接运行、调试并二次修改快速掌握实体识别、关系抽取与知识融合的基本流程。目前已有334人学习下载适合希望用Python完成一个可展示、可复现的知识图谱项目的读者参考借鉴。1. 从 OSM 原始数据到城市知识图谱这份大作业资源能跑通什么如果你做过知识图谱相关的课程设计大概率经历过这个场景数据源翻来覆去就是那几套公开数据集实体类型少得可怜关系定义全靠拍脑袋最后交上去的图谱一共几百个节点答辩时被问一句“你这图谱能回答什么实际问题”就卡住了。OSMOpenStreetMap城市数据其实是一个被低估的替代方案——它天然包含道路、建筑、POI、行政区划、公共交通等多层结构实体类型丰富关系语义清晰而且全球任意城市都能取到。这份「OSM城市知识图谱构建技术.zip」围绕的就是这条路线从 OSM 的 XML/PBF 原始数据出发经过解析、清洗、实体对齐最终导入 Neo4j 形成可查询的城市知识图谱。它适合正在找知识图谱大作业选题的学生也适合想快速搭一个城市数据原型验证查询逻辑的开发者。资源本身是一套完整的构建流程代码不是只给一个最终图谱让你看效果——这一点对交作业来说很关键因为你需要能讲清楚每一步在做什么。2. OSM 数据模型与知识图谱本体设计先想清楚节点和边2.1 OSM 的三种基本元素与标签体系OSM 的数据结构比很多人第一次接触时想象的要简单核心只有三种元素Node节点、Way路径、Relation关系。Node 是一个带经纬度的点Way 是一串有序 Node 组成的折线或闭合多边形Relation 则是多个元素之间的组合关系。真正让 OSM 有语义的是挂在每种元素上的 tags——键值对形式的标签。比如一个 Node 上挂着amenityschool它就是一个学校一条 Way 上挂着highwayprimary它就是一条主干道一个 Relation 上挂着typemultipolygon和buildingyes它可能是一栋带内庭的建筑轮廓。理解这一点之后知识图谱的本体设计就有了依据。常见做法是把 OSM 元素映射为图谱节点把 tags 映射为节点属性把元素之间的引用关系映射为边。但这里有个容易翻车的地方不能把所有 tags 都塞成属性。OSM 的 tag 数量极其庞大一个城市动辄几百万个不同的 key全展开会导致 Neo4j 的属性列爆炸式增长查询性能断崖式下降。我一般会先做一轮 tag 筛选只保留与城市知识图谱目标相关的类别比如highway、amenity、building、landuse、public_transport、admin_level这几大类其余的一律丢弃或合并到一个other_tags字段里。2.2 本体设计实体类型与关系定义本体设计决定了后续所有查询能回答什么问题。以城市知识图谱为目标实体类型至少应该覆盖以下几类实体类型对应 OSM 标签示例说明道路highwayprimary/secondary/residential按等级分层建筑buildingyes/apartments/commercial含轮廓多边形POIamenityschool/hospital/restaurant点状设施行政区admin_level6/8/9/10省市区县街道交通站点public_transportstation公交地铁站绿地leisurepark/landusegrass公园与绿地关系类型则围绕空间关系和语义关系展开。空间关系包括“道路连接道路”“建筑位于行政区”“POI 邻近道路”语义关系包括“行政区包含行政区”“道路属于道路等级分类”。在 Neo4j 中这些关系用不同的边类型表示比如CONNECTS_TO、LOCATED_IN、NEAR、BELONGS_TO。注意OSM 的 admin_level 在不同国家的含义不完全一致国内一般 6 对应地级市、8 对应区县、9/10 对应街道社区。如果你的城市数据跨区域建议先确认边界关系再批量导入。2.3 用 Python 解析 OSM 数据解析 OSM 数据最常用的库是osmnx和pyosmium。osmnx适合按城市范围拉取路网和 POI底层封装了 Overpass APIpyosmium适合直接处理本地.osm.pbf文件速度快、内存可控。下面这段代码用pyosmium读取 PBF 文件并提取 Node 和 Way 的基本信息import osmium import csv class OSMHandler(osmium.SimpleHandler): def __init__(self): super().__init__() self.nodes [] self.ways [] def node(self, n): # 只保留带有关键标签的节点减少数据量 tags {t.k: t.v for t in n.tags} if any(k in tags for k in [amenity, public_transport, place]): self.nodes.append({ id: n.id, lat: n.location.lat, lon: n.location.lon, tags: tags }) def way(self, w): tags {t.k: t.v for t in w.tags} if highway in tags or building in tags: self.ways.append({ id: w.id, nodes: [n.ref for n in w.nodes], tags: tags }) handler OSMHandler() handler.apply_file(city.osm.pbf) # 输出为 CSV 供后续导入 Neo4j 使用 with open(nodes.csv, w, newline, encodingutf-8) as f: writer csv.DictWriter(f, fieldnames[id, lat, lon, tags]) writer.writeheader() for n in handler.nodes: writer.writerow(n)这段代码的逻辑很直接node()和way()是 pyosmium 的回调方法每读到一个元素就触发一次。n.tags返回的是一个迭代器转成字典方便判断。筛选条件写在回调里只保留带有关键标签的元素这样能把原始数据量压缩到十分之一甚至更少。handler.apply_file()是入口传入 PBF 文件路径即可。输出 CSV 是为了下一步用LOAD CSV导入 Neo4j也可以直接写进 SQLite 或 PostgreSQL 做中间存储。参数方面apply_file支持locationsTrue参数来保留 Way 的几何信息但会显著增加内存占用。如果只需要拓扑关系不需要精确几何可以不开启。另外注意 PBF 文件最好从 Geofabrik 下载对应区域不要用全球数据否则解析时间会从几分钟变成几小时。3. 从 CSV 到 Neo4j批量导入与索引优化3.1 Neo4j 导入前的数据准备上一章输出的 CSV 是原始解析结果直接往 Neo4j 里灌会出问题。主要原因是 tags 字段是一个嵌套字典CSV 里存的是字符串形式Neo4j 的LOAD CSV不认。所以中间需要一步转换把 tags 拆成扁平的列或者转成 JSON 字符串后用apoc.convert.fromJsonMap解析。我一般会做两层处理。第一层是把实体按类型拆成多个 CSV 文件比如roads.csv、buildings.csv、pois.csv、districts.csv每个文件只包含该类型的节点和必要属性。第二层是把关系单独导出比如road_connections.csv存道路之间的连接关系poi_in_district.csv存 POI 与行政区的归属关系。这样导入时逻辑清晰也方便后续单独更新某一类数据。import json import csv def split_by_type(input_csv, output_dir): roads, buildings, pois [], [], [] with open(input_csv, r, encodingutf-8) as f: reader csv.DictReader(f) for row in reader: tags eval(row[tags]) # 实际使用时建议用 json.loads if highway in tags: roads.append({ id: row[id], name: tags.get(name, ), highway_type: tags[highway], lat: row[lat], lon: row[lon] }) elif building in tags: buildings.append({ id: row[id], building_type: tags.get(building, yes), levels: tags.get(building:levels, ), lat: row[lat], lon: row[lon] }) elif amenity in tags: pois.append({ id: row[id], amenity_type: tags[amenity], name: tags.get(name, ), lat: row[lat], lon: row[lon] }) # 分别写入 CSV for data, name in [(roads, roads), (buildings, buildings), (pois, pois)]: if data: with open(f{output_dir}/{name}.csv, w, newline, encodingutf-8) as f: writer csv.DictWriter(f, fieldnamesdata[0].keys()) writer.writeheader() writer.writerows(data)这段代码的核心思路是按标签类型分流。eval在这里只是示意实际项目中应该用json.loads并做好异常处理因为 OSM 的 tag 值里可能包含引号或特殊字符。分流之后每个 CSV 的列是固定的导入时不需要动态解析速度快很多。3.2 Neo4j 批量导入与索引建立Neo4j 导入 CSV 有两种方式LOAD CSV适合小批量、可增量neo4j-admin import适合首次全量导入速度快但不支持增量。对于城市级数据我建议首次用neo4j-admin import后续更新用LOAD CSV。// 建立道路节点约束和索引 CREATE CONSTRAINT road_id IF NOT EXISTS FOR (r:Road) REQUIRE r.id IS UNIQUE; CREATE INDEX road_name IF NOT EXISTS FOR (r:Road) ON (r.name); // 用 LOAD CSV 导入道路节点 LOAD CSV WITH HEADERS FROM file:///roads.csv AS row MERGE (r:Road {id: row.id}) SET r.name row.name, r.highway_type row.highway_type, r.lat toFloat(row.lat), r.lon toFloat(row.lon); // 导入 POI 节点 LOAD CSV WITH HEADERS FROM file:///pois.csv AS row MERGE (p:POI {id: row.id}) SET p.amenity_type row.amenity_type, p.name row.name, p.lat toFloat(row.lat), p.lon toFloat(row.lon);MERGE而不是CREATE是为了避免重复导入时产生重复节点。SET后面逐个赋值注意toFloat转换因为 CSV 读进来默认是字符串。索引和约束一定要在导入前建好否则数据量上去之后MERGE会慢到无法接受。关系导入稍微复杂一些因为需要先匹配到两端的节点// 导入道路连接关系 LOAD CSV WITH HEADERS FROM file:///road_connections.csv AS row MATCH (a:Road {id: row.from_id}) MATCH (b:Road {id: row.to_id}) MERGE (a)-[:CONNECTS_TO]-(b);这里MATCH两次分别找到起点和终点然后建边。如果关系数据量很大建议先用neo4j-admin import的 relationships 模式或者用 APOC 的apoc.periodic.iterate分批提交避免单事务过大导致内存溢出。提示Neo4j 默认的dbms.memory.heap.max_size和dbms.memory.pagecache.size需要根据机器配置调整。城市级数据建议堆内存至少 4G页缓存至少 2G否则导入过程中会频繁 GC。3.3 空间查询与常用 Cypher 模式图谱建好之后真正体现价值的是查询。举几个城市知识图谱里常见的查询模式// 查询某条道路连接的所有 POI MATCH (r:Road {name: 中山路})-[:NEAR]-(p:POI) RETURN p.name, p.amenity_type; // 查询某行政区内的所有学校 MATCH (d:District {name: 海淀区})-[:LOCATED_IN]-(p:POI {amenity_type: school}) RETURN p.name; // 查询两个 POI 之间的最短路径经过道路 MATCH (a:POI {name: A}), (b:POI {name: B}) MATCH path shortestPath((a)-[:NEAR|CONNECTS_TO*..10]-(b)) RETURN path;这些查询依赖前面建立的关系类型。NEAR关系需要在导入后额外计算通常用经纬度距离做阈值判断比如两点距离小于 50 米就建一条NEAR边。这一步可以用 Python 脚本批量计算也可以用 Neo4j 的 APOC 空间函数。4. 避坑与排查OSM 知识图谱构建中的五个血泪教训4.1 现象导入后节点数量对不上少了将近一半原因OSM 的 Way 在解析时如果引用了不存在的 Node比如该 Node 被过滤掉了pyosmium 默认会跳过整条 Way。如果你的筛选条件把某些 Node 排除了依赖这些 Node 的 Way 也会跟着消失。解决解析时先完整读一遍所有 Node 的 ID 集合再解析 Way 时检查引用的 Node 是否都在集合里。或者干脆不筛选 Node只筛选 Way 和 RelationNode 全量保留但只给关键 Node 建属性。4.2 现象Neo4j 导入速度越来越慢最后卡死原因没有建索引就执行MERGE每插入一个节点都要全图扫描一遍。数据量到十万级之后单条插入耗时从毫秒级变成秒级。解决导入前先建唯一约束和索引。如果已经卡住了停掉导入建好索引再重来。另外用apoc.periodic.iterate分批提交每批 5000 到 10000 条避免单事务过大。4.3 现象中文名称显示为乱码或问号原因CSV 文件编码不是 UTF-8或者 Neo4j 导入时没有指定编码。OSM 的 name 标签里中文很常见编码问题一旦出现就是全量性的。解决Python 写 CSV 时显式指定encodingutf-8Neo4j 的LOAD CSV加上WITH HEADERS FROM file:///xxx.csv AS row时默认按 UTF-8 解析但如果文件是 GBK 就会乱码。用file命令或文本编辑器确认编码后再导入。4.4 现象空间关系计算耗时过长几万个 POI 跑了一整夜原因用双重循环计算所有 POI 之间的距离时间复杂度 O(n²)。几万个点就是几亿次计算Python 纯循环扛不住。解决先用网格法或 R 树做空间索引只计算同一网格内或邻近网格的点对。Python 可以用scipy.spatial.cKDTree或者用 PostGIS 先做空间连接再导出关系 CSV。4.5 现象图谱查出来的结果和预期不符关系方向反了原因OSM 的 Way 是有方向的Node 的排列顺序决定了道路的走向。如果建CONNECTS_TO关系时没有考虑方向或者双向道路只建了单向边查询最短路径时就会出问题。解决对于双向道路建两条方向相反的边或者用无向关系类型。在 Cypher 查询时用-[:CONNECTS_TO]-而不是-[:CONNECTS_TO]-来忽略方向。如果确实需要方向语义在导入时根据 Way 的 Node 顺序确定 from 和 to。5. 进阶技巧用 APOC 和 Graph Data Science 做社区发现图谱建好之后如果只用来做简单的匹配查询其实有点浪费。Neo4j 的 APOC 插件和 Graph Data ScienceGDS库可以让这份城市知识图谱回答更复杂的问题比如“找出城市中 POI 密度最高的区域”“识别道路网络中的关键枢纽”“对行政区做功能聚类”。这些分析结果可以直接写进大作业的“实验与分析”章节比单纯展示查询结果有说服力得多。先看 APOC 的用法。APOC 提供了大量过程procedure其中apoc.periodic.iterate在批量操作时非常实用apoc.convert.fromJsonMap可以解析 JSON 属性apoc.spatial系列可以做距离计算。下面这段代码用 APOC 批量计算 POI 与最近道路的距离并建立NEAR关系CALL apoc.periodic.iterate( MATCH (p:POI), (r:Road) RETURN p, r, WITH p, r WHERE point.distance( point({latitude: toFloat(p.lat), longitude: toFloat(p.lon)}), point({latitude: toFloat(r.lat), longitude: toFloat(r.lon)}) ) 50 MERGE (p)-[:NEAR]-(r), {batchSize: 5000, parallel: false} );apoc.periodic.iterate的第一个参数是驱动查询第二个参数是对每一批结果执行的操作。batchSize控制每批处理多少条parallel设为 false 是因为建关系涉及写锁并行反而容易冲突。point.distance是 Neo4j 内置的空间函数返回米为单位的距离。50 米这个阈值可以根据城市密度调整密集城区可以设 30 米郊区可以放宽到 100 米。GDS 的用法更偏向分析。以社区发现为例可以用 Louvain 算法对道路网络做聚类找出连接紧密的路网子团// 在内存中投影道路网络 CALL gds.graph.project( roadGraph, Road, CONNECTS_TO ); // 运行 Louvain 社区发现 CALL gds.louvain.stream(roadGraph) YIELD nodeId, communityId RETURN gds.util.asNode(nodeId).name AS roadName, communityId ORDER BY communityId, roadName;gds.graph.project把 Neo4j 中的子图加载到内存第一个参数是投影名称第二个是节点标签第三个是关系类型。gds.louvain.stream返回每个节点所属的社区 ID。拿到社区 ID 之后可以进一步统计每个社区的 POI 数量、道路总长度等指标用来做城市功能分区分析。还有一个很实用的技巧是用 GDS 的 PageRank 找道路网络中的关键节点。PageRank 原本是网页排序算法但在图结构里它衡量的是一个节点被其他节点“引用”的程度。在道路网络中PageRank 高的节点往往是交通枢纽或主干道交汇处CALL gds.pageRank.stream(roadGraph) YIELD nodeId, score RETURN gds.util.asNode(nodeId).name AS roadName, score ORDER BY score DESC LIMIT 20;这个结果可以直接用来回答“城市中哪些道路最重要”这类问题比单纯按道路等级筛选更有数据支撑。最后说一个我踩过的坑GDS 的图投影是加载到内存中的投影名称在同一个数据库中必须唯一。如果重复执行gds.graph.project会报错需要先gds.graph.drop(roadGraph)再重新投影。另外投影不会自动同步 Neo4j 中的最新数据如果图谱更新了需要重新投影才能反映到分析结果里。从那以后我每次跑 GDS 分析之前都会先 drop 再 project确保内存里的图和数据库一致。希望帮到你。本文还有配套的精品资源点击获取