假设知识图谱里有一条边:球员E → 效力于 → 示例队丙。后来球员E转会到示例队丁,最省事的更新办法是删掉旧边、加上新边。一秒钟后,图谱变得“干净”了,也同时忘记了球员E曾经在示例队丙踢过球。三年前那场比赛的技术统计还在,但它挂靠的球队关系没了;有人问“球员E在示例队丙期间的进球数”,系统连“期间”是哪一段都答不上来。知识图谱在体育里的第一个考验,不是能存多少实体,而是当关系发生变化时,旧的事实要不要保留、怎么保留。
三种更新办法,三种后果
面对转会这种事,图谱里的处理大致有三种做法,可以放在一起对比。
做法一:覆盖旧边
直接把“效力于”指向的新球队替换掉。优点是简单,查询“球员E现在在哪队”永远只有一个答案。缺点是彻底丢失历史:所有依赖“当时属于哪队”的问题都无法回答,包括“示例队丙2023—2025年的阵容”“球员E转会前后的数据变化”。
做法二:并存多条边,不标时间
保留两条边,球员E既效力于示例队丙,也效力于示例队丁。历史留下来了,但“现在”丢了:查询“球员E所属球队”会得到两个结果,而且没有办法判断哪个是当前的。更麻烦的是,之后再回到示例队丙(回流),图谱里会出现两条完全一样的边,无法区分两段不同的经历。
做法三:边上带有效时间区间
每一条关系都附带“从何时开始、到何时结束”。球员E → 示例队丙,2023-07-01 至 2025-06-30;球员E → 示例队丁,2025-07-01 起,结束时间为空。这样,历史保留了,“现在”也能定位(结束时间为空、或晚于今天),回流就是同一对节点之间出现第二段独立的区间。体育场景里的时间知识图谱,基本都是围绕这个做法展开的。
需要说明,上面的球员、球队和年份全部是虚构的示例,只用来说明数据结构。
关系不是“边”,而是带时间的“事实”
做法三隐含了一个观念上的转变:图里的一条关系不再是“A与B相连”,而是“在某段时间内,A与B之间存在某种关系”。在实现上有两种常见方式。
第一种是把时间作为边的属性,也就是在关系上写 valid_from 和 valid_to。查询直接过滤属性,读起来直观,适合关系类型稳定、每条关系只需要一段区间的场景。
第二种是把关系节点化,即引入一个“效力记录”节点,它分别连向球员、球队,并携带起止时间、合同类型、是否租借、球衣号码等更多信息。代价是图变复杂了一步,收益是这段关系本身可以被继续描述,比如“这段效力经历中间发生过一次租借”,可以再挂一条子关系。
半岛体育在设计球员归属关系时,更倾向于第二种:球员到球队的归属附带的信息太多(合同、租借、号码、位置登记),放在边上属性会越堆越乱。而对于“球队在某个赛季属于哪个联赛”这类信息量少的关系,属性方式就足够了。
核心查询:某个时刻,他属于哪支球队?
有了区间之后,最基础的查询是“在某一天,球员E属于哪支球队”。判断的逻辑是:查询日期落在区间内。下面用简化的图查询风格来写,不使用任何花括号。
MATCH (p:Player)-[r:MEMBER_OF]->(t:Team)
WHERE p.player_id = 'P_0555'
AND r.valid_from <= date('2024-03-15')
AND (r.valid_to IS NULL OR r.valid_to >= date('2024-03-15'))
RETURN t.name, r.valid_from, r.valid_to;
三个细节容易被忽略:其一,valid_to IS NULL 表示“尚未结束”,必须单独处理,直接用大小比较会把当前关系漏掉;其二,区间端点是包含还是不包含,要在整个数据模型里统一,否则转会当天会同时属于两支球队,或者同时不属于任何球队;其三,如果日期是比赛日,还要考虑时区,通常以比赛所在地的日期或统一的标准日期入库,二者不能混用。
把历史当成一张“时间线”来问
区间存在之后,很多问题才变得可以回答。举几个示例,并注明它们各自需要怎样的区间逻辑:
| 问题(示例) | 需要的区间逻辑 |
|---|---|
| 2024年3月,球员E在哪支队 | 单点落在区间内 |
| 球员E在示例队丙期间共出场多少场 | 比赛日期落在归属区间内,对比赛做聚合 |
| 示例队丙2023年的阵容有哪些人 | 球队反向查询,区间与2023年整年有交集 |
| 谁曾同时和球员E在示例队丙效力 | 两名球员的区间有重叠 |
| 球员E转会前后各三个月的数据变化 | 以转会日期为分界,分别取前后窗口 |
其中“区间重叠”最有代表性。判断两段区间是否重叠,条件是:一方的开始不晚于另一方的结束,同时另一方的开始不晚于这一方的结束。这个简单的判断,就是很多“队友”“同时期”“同一位教练手下”类问题的共同底层。这类多跳加时间的查询,是半岛体育知识图谱方向里最有价值、也最容易出错的部分。
落到存储上:一张归属记录表长什么样
无论底层用图数据库还是关系数据库,归属记录的字段设计都大同小异。下面是一份示例字段清单,可以看到哪些信息值得存:
-
membership_id:这一段归属经历的唯一标识,回流时会产生新的编号。 -
player_id、team_id:连接的两个实体,使用稳定的编号而不是名称。 valid_from、valid_to:有效时间区间,结束为空表示尚未结束。-
membership_type:正式注册、租借、试训等类型,用于区分“归属”和“实际效力”。 -
source_id:这条记录来自哪个数据源,方便日后核对。 -
recorded_at:系统写入的时间,用于前面提到的“当时已知信息”问题。 -
supersedes_id:如果这条记录修正了另一条错误记录,指向被修正的那条,而不是直接覆盖。
最后一个字段体现了时间图谱的一个纪律:修正也是历史的一部分。发现某条区间起点录错了,不应静悄悄改掉,而是新增一条修正记录并指向旧记录。这样至少能回答“这个数字之前为什么不同”,也能在展示答案时告诉用户数据经过了修订。这类思路与半岛体育在答案溯源上的要求是一致的:任何一个结论,都应该能往回追到原始记录。
再补一个实践中常见的取舍:区间的粒度到底取日、月还是赛季?粒度越粗越容易维护,但越无法回答“转会窗口当周”这类细问题;粒度越细,越依赖数据源提供精确日期,而不少来源只给到月份。比较务实的做法是入库时保留数据源给出的原始精度,并额外标记这个日期是“精确到日”还是“仅精确到月”,查询遇到模糊精度时在答案里加以说明,而不是默认补成月初或月末。
转会之后,旧数据最容易出的四种错
赛季中途转会
同一个赛季里球员E先后属于示例队丙和示例队丁。如果按“赛季”这个粒度记录归属,他会被算成两队都全程效力,赛季统计就会重复;按单场比赛记录他当场属于哪队,才不会出错。所以比赛记录里应该直接保存当场所属球队,而不是事后通过查询归属关系去推。
租借与回流
租借是主队归属与临时效力同时存在。设计上应区分“注册归属”和“实际效力”,否则一个租借球员在两支球队的数据里都可能被算作正式成员。回流则是同一对实体的第二段区间,图里必须允许同一对节点之间存在多条带不同区间的边。
名称变更与合并
球队改名、俱乐部合并、主场迁移,这些变化不应该新建节点、切断历史。更稳妥的做法是保持球队的唯一标识不变,把名称当成一个带有效期的属性,这样“示例队丙旧名时期”的数据仍然连在同一个球队身上。
数据晚到与追补
转会消息经常在正式生效之后才入库,此时关系记录的“有效时间”与“入库时间”是两回事。严谨的做法是同时记录两者:有效时间说明事实在现实里何时成立,入库时间说明系统何时知道。这样才可能回答“如果只用上周五之前已经入库的信息,当时的答案是什么”,也便于追查一条记录为何在某天前后不一致。
图谱不该只记住“现在”
回头看开头那个覆盖旧边的场景:让图谱保持“干净”的代价,是把所有需要时间的问题都变成无解。体育数据里几乎每一个有趣的问题——换帅之后、转会之后、伤愈之后——都是在问“某段时间内”。一个只记得当前状态的知识图谱,在这些问题面前和一张当前阵容表没有区别。
当然,时间区间也不是万能的。它带来了更复杂的写入逻辑、必须持续维护的边界一致性,以及更容易出错的查询;它提高的是关系类问题回答的可靠性,而不能自动保证数据本身准确,仍需要与比赛记录做交叉校验。这也是半岛体育在讲体育查询的时间问题时反复强调的:时间不是一个过滤字段,而是数据模型从一开始就要考虑的维度。