先看一句很普通的问话:“示例队甲上个月那个新来的中锋,跟示例队乙交手时是不是比平时更能拿分?”这一句里藏着一个球队、一个需要靠“新来的”“中锋”“上个月”才能锁定的球员、一个“交手时”的对手过滤、一个“平时”的比较基线,以及一个模糊的“更能拿分”。人读起来毫不费力,因为我们自动补全了所有缺失的东西。系统却必须把每一处补全都变成明确的、可执行的条件。半岛体育AI对复杂体育搜索的处理,核心就是这个动作:不要急着查,先把一句话拆成一份可以检查的查询计划。
第一步:把长句切成“角色”,而不是切成词
很多人以为拆解问题就是分词加关键词。但体育问句里,同一个词在不同位置扮演的角色完全不同。“示例队乙”出现在“交手时”后面,它是对手过滤条件;如果它出现在句首,可能就是被查询的主体。所以拆解的第一步是给每个片段分配角色:
- 查询主体:被统计的对象,这里是某个球员。
- 范围限定:主体所在的球队、位置、加入时间。
- 时间条件:上个月、最近几场、本赛季。
- 场景过滤:对阵谁、主场还是客场、是否领先。
- 指标:得分、效率、出场时间。
- 比较基线:跟什么比,跟自己平时比,还是跟联盟均值比。
这六个角色并不是每个问题都齐全。缺哪一个,就是系统应该关注的地方。上面这句话里,“指标”是“拿分”,看起来明确;但“比较基线”是“平时”,这个词没有边界,是最需要澄清的一处。
第二步:写出查询计划(示例)
下面是把上面那句话初步解析后的计划,用缩进文本表示,避免任何程序语法。所有名称和数字均为示例。
plan_id: Q_0017
raw_question: 示例队甲上个月那个新来的中锋,跟示例队乙交手时是不是比平时更能拿分?
subject:
type: player
hint_team: 示例队甲
hint_position: 中锋
hint_join_time: 上个月加入(相对日期,需换算)
candidates: 球员C(P_0873,置信度较高), 球员D(P_0311,置信度低)
status: 需要确认
conditions:
scene_filter:
opponent: 示例队乙
matches_definition: 仅常规比赛,不含友谊赛(默认口径)
baseline:
text: 平时
status: 含糊,需要澄清
metric:
name: 场均得分
aggregation: 按场平均(不按每分钟折算)
time_window:
target: 该球员在示例队甲的全部出场
today: 2026-09-21
data_cutoff: 以数据库最新入库日期为准
outputs:
- 对阵示例队乙的场均得分及场次
- 其余场次的场均得分及场次
- 样本量提示
这份计划比原句啰嗦得多,但它有两个好处。其一,每一个不确定的地方都被明确标成“需要确认”或“含糊”,而不是被悄悄消化掉。其二,它可以被人检查,也可以被程序检查:比如“球员候选置信度低”会触发一次确认,“baseline 含糊”会触发一次反问,而不是直接进入 SQL。
第三步:从计划到查询,不能一步跳完
计划确认之后,才生成查询。把计划翻译成 SQL 时,各个角色分别落到不同的子句里:主体落在 WHERE player_id,场景过滤落在对手条件,指标落在聚合表达式,基线落在分组字段。
SELECT CASE WHEN g.opponent_id = 'T_0092' THEN 'vs示例队乙' ELSE '其他' END AS grp,
COUNT(*) AS games,
ROUND(AVG(s.points), 1) AS avg_points
FROM player_game_stats s
JOIN games g ON g.game_id = s.game_id
WHERE s.player_id = 'P_0873'
AND s.team_id = 'T_0031'
AND s.minutes > 0
AND g.game_type = 'regular'
GROUP BY grp;
注意这里的分组:把对阵示例队乙的场次和其他场次放进同一次聚合里,再对比两组。如果拆成两条独立的查询,很容易出现两条查询时间截止点不一致、口径不一致的问题。一条查询同时产出两组数字,比较才公平。
再看一眼返回结果(示例数据):
| 分组 | 场次 | 场均得分 |
|---|---|---|
| vs示例队乙 | 2 | 18.5 |
| 其他 | 9 | 14.2 |
看起来“更能拿分”。但样本只有2场,系统在展示这个结果时必须同时说明:对阵示例队乙仅有2场记录,差距不足以支撑“明显更能拿分”的结论。这是拆解之后才有可能做到的事:因为计划里有“样本量提示”这一项输出,答案里才会自然出现这句提醒。
“最近状态最好”:AI首先应该反问什么
再换一个更极端的问题:“谁最近状态最好?”这句话连查询主体的范围都没有:是全联盟,还是某支球队内部,还是某个位置?“最近”没有窗口,“状态”没有指标。它几乎每个角色都是缺失的。
一个稳健的系统在这里不应该硬猜,也不应该甩出一个笼统的名单。合理的反问可以按三层来问,并且每层给出可点选的默认项,降低用户的回答成本:
- 范围:在哪个范围里比?本队内部、同位置球员、还是全部球员?
- 时间:“最近”按什么算?最近5场、最近10场,还是最近30天?两种窗口的差别在于:按场次,休战期会被忽略;按天数,某些球员可能只有一两场记录。
- 指标:“状态”看什么?得分、投篮或射门效率、综合评价,还是出场时间与参与度?
如果用户不想回答,系统也应该有一套透明的默认口径,比如“同队同位置、最近5场、综合场均产出”,并在答案顶端把这套默认写出来,附上“更改口径”的入口。关键在于默认口径必须公开,而不是暗中决定。更完整的关于时间口径的讨论,可以看半岛体育关于体育查询中时间理解的那篇文章。
拆解中最容易出错的四个地方
相对时间的换算
“上个月”“前阵子”“最近”都依赖“今天”是哪一天,以及数据更新到哪一天。这两者不一样:用户在9月21日问“上个月”,指8月;但如果数据库还停留在9月10日,那么“最近几场”的最后一场也就是9月10日之前。计划里必须同时记录“今天”和“数据截止日期”。
实体的相对描述
“那个新来的中锋”不是名字,而是一组属性:球队、位置、加入时间。这类描述需要在球员表里做属性匹配,可能返回零个、一个或多个候选。零个说明描述有误,多个就必须让用户选。这一步和实体识别是同一类问题,只是线索从名字换成了属性。
省略的比较基线
“比平时更能”“比以前强”,这里的“平时”“以前”都是隐含的基线。通用做法是把它翻译成“该球员在其他所有场次”或“该球员上赛季同期”,但必须让用户知道系统选了哪一种。
多个条件之间的顺序
“最近10场里对阵强队的表现”与“对阵强队的最近10场”不是一回事。前者先取10场再筛强队,后者先筛强队再取10场。拆解时要保留条件的先后关系,不能把它们当成一袋无序的标签。
计划生成之后,还要过三道检查
查询计划由语言模型生成,并不意味着可以直接信任。一份计划在进入数据库之前,通常应经过几道相对机械的检查,这些检查不依赖模型的“感觉”,而是对照数据字典和规则表:
- 字段存在性检查。计划里的指标必须对应真实的字段或可推导的表达式。如果模型写出了一个数据库里没有的“影响力指数”,这一步就会拦下来,而不是让它悄悄编一个公式。
- 时间合法性检查。时间窗口不能晚于数据截止日期,也不能早于该球员在该队的有效记录起点。比如球员C上个月才加入示例队甲,那么“他在示例队甲整个赛季的表现”这样的说法本身就需要被提示。
- 实体一致性检查。球员、球队、对手三个实体必须能在同一个赛季里互相对上:球员在那段时间确实属于这支球队,对手确实与这支球队有过交手。不一致时,返回的应该是“没有这样的比赛记录”,而不是一个空表格或一个凑出来的数字。
这三道检查都很朴素,但正是它们把“语言模型的理解”与“数据库里的事实”隔开了一层缓冲。理解错了,最坏的结果应该是被检查拦下并提示用户,而不是带着错误的条件跑完整个查询,最后交出一个看起来很合理的错误答案。
另外值得一提的是,拆解并不总是一次成功。第一次拆解出的计划里如果有“需要确认”的项,用户确认之后系统要更新计划而不是重新开始。例如用户回答“我说的是球员C”,计划里的 subject 状态变成已确认,其余条件保持不变,后续查询沿用同一份计划。这样整个对话中的每一次补充,都会在一份可追踪的计划上留下痕迹,而不是每一轮都从头解读一遍,也就避免了同一个问题在两轮里被解读成两种意思。
什么时候应该拒绝拆解
不是所有问句都值得拆。如果用户问的是“这个赛季有什么新规则”,它根本不是数据查询,应该转给文档检索。如果一句话里有三四个球员、五六个条件,拆出来的计划复杂到连人也难以核对,此时更好的做法是引导用户分几次问,或者提示这属于需要分层查询的复杂问题,而不是硬生成一条几十行的 SQL。查询计划越长,出错的位置就越多,也越难让用户确认。
半岛体育在设计上把这类情况看作合理的边界:系统能拆的就拆并展示计划,拆不清的就承认拆不清。至于拆好之后怎么一步步走到最终答案,半岛体育官网的数据问答流程一篇按步骤做了完整的展开。
好的拆解,让“问不清楚”也能被看见
回头看开头那句话,它的问题不是复杂,而是“隐含了太多没说出口的东西”。系统的价值不在于把这些隐含的东西全部猜对,而在于把它们一条条摆到台面上,让用户在一眼之内就能判断:主体对不对,时间对不对,口径对不对。用户不需要懂 SQL,但需要看得懂计划。当一个系统愿意把自己的理解展示出来,即使理解有偏差,错误也是可见、可纠正的。相反,一个只交出一句流畅结论的系统,错了你也不知道错在哪。