Chat2Chart 架构分析

AI 摘要从主题路由一路追到 Intent:这套 ChatBI 把模型放在边缘,把最难排查的语义解析留给了规则和显式状态。

文章目录6 节
  1. 先确定这句话落在哪份数据上
  2. 输入规整、切词和字段识别
  3. 把字段挖掉,剩下的才是任务
  4. Intent 是真正稳定的接口
  5. 多轮不是拼聊天记录
  6. 模型只守拒识这一关

顺着 chat2chart 的代码往下读,主线比“模型生成 SQL”传统得多:预加载 schema,给主题和模型打分,切词,认字段,抽任务,再把结果装进一个有明确类型的 Intent。模型只负责最后一道拒识。

这篇看的版本是 release 分支的 4.4.0(提交 d26eac9)。这点很重要。它成形时,大模型还没有今天的稳定性,指望模型独自处理多主题路由、字段歧义、口径、相对日期和多轮修改,并不现实。线上服务还要能解释、能排错、能背 SLA,于是代码最终长成了一条很重的规则流水线。

从服务入口也看得出它在解决什么问题。app.py 暴露了 preload、load、create session、cut、intent recognition、intent、dsl2intent、retrieval 和 subject rank 等接口。这里没有一个占据中心位置的“模型对话”接口,反倒是主题生命周期、会话和结构转换占了大半。

启动时要先建两层 parser 缓存:persist_table_parser_map 放预加载、常驻的主题,table_parser_map 放运行期加载并按 LRU 淘汰的主题。Redis 连接、stream watcher、session 状态、ModelHub、Prometheus 指标和内存保护也在这时准备好。被淘汰的 parser 会主动 destroy,不只是从字典里删一个引用。这个启动过程很像一个长期驻留的解析节点,而不是请求来了临时拼一次 prompt。

preload / load 做的工作也不少。字段名、别名、枚举、表与字段的双向映射、模糊匹配字典、slang 和自定义参数都会被整理好,再实例化 GrammarBasedParser。也就是说,一个 parser 更接近“预编译后的主题语义环境”。后面每次 query 能跑得快,是因为 schema 相关的重活已经提前做过。

先放完整链路。后面不按模块念目录,而是跟着一句 query 走一遍。

chat2chart 全链路:一句话怎样被层层拆开,最后收口成一个稳定的 Intent,再分叉到下游;多轮在 Intent 上回流改写,模型只在边界兜底

静态图能看到先后顺序,但看不到一个词怎样改变路由和最终结构。下面这个小沙盘只实现了正文用到的最小规则集,不是原系统的浏览器复刻。它有意把每一层的输出摊开:试着换一句问法,或给它一个上轮主题,就能看到为什么这类系统好排查,也为什么容易积累阈值。

规则沙盘 · 即时拆解

一句问数,怎样收口成 Intent

简化教学模型:只展示路由、字段、任务和结构定型。

01 · Subject rank

先选数据主题

02 · Schema match

业务词与剩余文本

03 · Task merge

计算动作绑到字段

04 · Typed Intent

语言到这里不再含糊


      

parsers/nl2dsl 里的入口叫 NL4DV,下面拆了 QueryGenieDataGenieDateGenieAttributeGenieTaskGenieVisGenie。名字还有点学术项目的味道,边界却很工程化:输入规整、schema 识别、日期、任务和可视化提示各管一段。读这条链路时,最容易犯的错是从 TaskGenie 直接开始;实际上,前面的主题和字段一旦选错,任务层没有机会救回来。

先确定这句话落在哪份数据上

解析之前,系统先要选 subject 和 model。一个会话可以挂多个主题,一个主题里又可能有多张模型表;上下文选错后,后面的字段识别即使每一步都“正确”,结果也没有意义。同一个“金额”,换张表可能就是另一套口径。

SubjectTool.rank_model() 没有调用分类模型,而是给每个候选算分。query 命中的字段、枚举和表名按文字长度累计;前端或上一轮带来的消歧词会加权,模糊命中也记分,历史访问较多的主题略占优势。打平时,上一轮使用过的 subject / model 优先。

这种打分很好排查。路由错了,可以把命中项和分数摊开来看,不需要猜模型当时为何这么判。不过它的局限也同样直接。

按长度计分,本质是拿“字多”近似“信息量”。多数时候这很有效:完整命中“销售区域”当然比只撞上一个“额”更能说明主题。但字面更长也可能只是巧合。这个分数衡量的是命中量,不是语义正确率,两者相关,却不是一回事。

上一轮主题又被给了很高的优先级。打平时偏向它,有些路径甚至直接把它放到第一候选,连续追问就不容易乱跳表。另一面是转场:用户真的换了话题,新主题却没有在字面分数上明显胜出时,历史惯性会把问题留在旧主题。这里没有同时满足两边的办法,代码明确偏向了多轮连贯。

几处分差阈值也是很具体的工程产物。它们按中文字符长度调出来,在当前字段和问法里有用;换成英文、繁体,或者只是换一套更短的字段命名,原来的刻度就会失真。规则系统经常这样:一个阈值在某批回归集上越准,也越说明它吃了那批数据的分布。

模糊匹配的问题更明显。“销售”可以同时接近“销售额”“销售量”“销售区域”。代码会给模糊命中降权,但降权在判定链里偏后,有时前面的规则已经把某个候选顶上去了。编辑距离还只会判断字面,不知道“销量”和“GMV”在具体业务里是什么关系。

最强的信号其实是外部传进来的消歧词,它们通常由前端确认或前几轮积累。等上下文丰富以后,排序会稳很多;首轮这些字段往往是空的,而首轮恰好最容易问得省略、含糊。这是一个很难靠加权彻底解决的冷启动缺口。

把这些放在一起看,就能理解这段代码为何总在加规则。每个信号都能解释,也都只覆盖一部分语境。线上出了错,可以明确落到长度、历史优先级、阈值或模糊候选中的某一项;修复方式通常也是给具体边界补测试、调顺序,而不是重新训练一个黑盒。

输入规整、切词和字段识别

主题定下来,QueryGenie 先处理数字和时间。中文数字转成阿拉伯数字,百分数转小数,“上半年”一类说法换成标准形式。这里有个很典型的小补丁:数字归一会把“半个季度”处理成“0.5 季度”,日期解析却认识“半”而不认识这个结果,于是代码还得把它还原回去。

这种琐碎转换适合集中放在入口。否则“百分之三十”“0.3”“三成”的差异会一路传到字段、任务和日期解析,每层各补一次。代价是预处理映射只会越来越长,源码里那句“后续要映射的都放这”很诚实。

输入里类似的写法很多:“三万”“0.6 亿”“百分之三十”“上半年”“半个月”“月均”各自会影响数值、日期或聚合。预处理并不是做完一张替换表就结束;一条归一规则还可能破坏另一条解析规则,“半个季度”来回转换就是这种耦合的缩影。它看起来不起眼,实际决定了后面几十条正则看到的到底是不是同一种语言。

接下来的顺序不能反:先找字段,再识别任务。“销售额前十”要先知道销售额是 metric,“前十”才有绑定对象。

通用分词器不认识客户 schema 里的“客单价”或“华东大区 GMV”。这里用了两套词典。字段名、别名和枚举值先编进 Aho-Corasick 多模式匹配自动机,直接在原句上扫出已知业务词;剩下的求和、同比、大于、前十、柱状图等操作词再交给 jieba,jieba 也提前装入了任务词典。可以粗略理解为:schema 负责名词,jieba 负责动作和连接词。

这个分工还有一个后续用途:任务抽取会把已经识别的 schema 词从原句里扣掉。自动机如果把业务词切碎了,后面留下的“残句”也会跟着变,任务正则很难再补救。所以这里既是切词,也是给下一层划边界。

自动机命中重叠时取长词,所以“销售额”会盖过“销售”。英文字段检查词边界,避免 salesalesperson 中间被切出来;简繁体写法会提前注册。未登记的别名仍然认不出来,“营收”想映射到“销售额”,要么事先配置别名,要么落到编辑距离的模糊匹配。后者只知道字面相似,不懂业务语义,因此会保留一串候选,留给后续消歧。

用户往往不写表名,代码还要根据已识别字段反推表。它先看已经锁定的线索,再看没有歧义的度量、维度和日期,最后才取第一个能关联到表的字段。这不是多聪明的推断,但次序固定,出了问题能复现。

这条优先链也暴露了一个事实:自然语言问数里,表名经常是系统推出来的,不是用户明确说的。推断无论交给规则还是模型都可能错。这里选择固定退让顺序,至少让同一句话在同一份 schema 上得到稳定结果,也方便把错误归到某个字段候选。

把字段挖掉,剩下的才是任务

TaskGenie 是整条解析链里最厚的一层。前面已经找到了字段、枚举和日期,它先从原句中把这些部分挖掉,只处理剩下的词。比如“销售额前十按地区”,去掉“销售额”和“地区”之后,残留的“前十”“按”才描述了计算动作。

排序、过滤、占比、累计、同比环比、排除条件等任务各有正则,合起来有六十多段。识别出“前十”还没结束,代码会从它所在的位置向两侧找最近的 metric;附近没有 metric,再退到 count 或 dimension。规则数量主要花在语言的换位和省略上。

“前十销售额”和“销售额前十”字面次序相反,实际指向同一个 metric;“按地区看前十”甚至可能把任务词和度量隔开。就近绑定先解决最常见情况,再按 metric、count、dimension 的优先级回退。每多支持一种省略,规则之间的覆盖关系就要重新检查,这也是 TaskGenie 体量明显大于其他 Genie 的原因。

源码中的任务范围比 rank / filter 更宽,还包括 count、expression、vs / compare、conditional filter、totals 和 details。它们有的直接作用于字段,有的要生成新的表达式,有的只是修改展示方式。先用统一中间态承接,能避免每条正则都知道最终 Intent 的全部字段。

解析结果也没有直接写进最终 Intent。它先落到 task_map / expr_tasks / count_tasks / metric_tasks 这一层中间态,再做归并。新增一种说法时,大多只需要改 task 层,最终协议不用跟着抖。占比和同比同时出现时,当前代码会优先同比,二者暂不能共存——支持边界就明写在这里。

归并时,散落的 rank、filter、calculation 和 expression 要重新挂回各自度量。中间态的价值就在这里:前一段可以保留“不完全确定但可能有用”的标记,后一段再按 Intent 的约束做取舍。规则层最常改,协议层相对少动,两边没有直接黏死。

日期另外走一套小流水线。“近三个月”“上半年到现在”“同比上个季度”先标准化,再抽日期范围,最后后处理。相对日期还要保存比较词的位置,并参考上一轮的时间。这块如果硬塞进通用任务规则,维护起来只会更乱。

日期本身就是一门小语言:绝对日期、相对日期、粒度、区间和比较关系会互相影响。“同比上个季度”既有计算任务,也有相对时间,解析顺序错一次,结果就可能差一年。单独的 DateGenie 让这些状态不用散落到普通 filter 和 metric 规则里。

Intent 是真正稳定的接口

任务中间态里可能同时挂着占比、同比、排序和过滤。GrammarBasedParser.dict2intent() 负责最后定型,把它们整理成一组边界明确的对象。

从松散的任务中间态,归并定型成一个带类型的 Intent

最终结构里有 MetricDimensionDateRangeDimensionFilterCalculationFilterExpressionCalculationLimitVisHint 等对象。时间粒度、计算类型和过滤算子都由枚举约束。松散的“占比”会变成 metric 上的 calculation,“前十”变成 Limit。Intent 里甚至还留着已经弃用的占位字段,因为删除会破坏兼容。

例如时间粒度只能落到年、季、月、周、日等已知值,计算类型也被限制在同比、环比、占比等集合里,过滤操作只能选 IN / NOT_IN 之类定义过的枚举。语言层可以模糊,进入 Intent 后就不能继续含糊;实在不能表达的组合,宁可在上一步判成不支持。

我认为这里比前面的六十多条正则更关键。下游不只有 SQL:检索、图表选择以及其他 BI 服务都要消费解析结果。如果各自重新理解自然语言,很快就会出现几套互不兼容的语义。统一 Intent 把脏的语言入口和相对稳定的业务协议隔开了。

dsl2intent 也说明这层协议确实被当成了中心。上游有时直接传 DSL,服务端把它反解到同一套 Intent,之后继续复用校验和多轮处理。这也给后来的混合方案留出了位置:模型可以生成 DSL,规则层负责检查并收敛,不必一上来重写整条链路。

DSL2IntentParser 同时处理普通查询 DSL 和归因分析 DSL,并会选中英文、繁体等 parser 变体。这个接口不是简单把 JSON 字段换个名字,它要重新构造同一组 Table / Metric / Dimension / DateRange 对象。能做反向投影,说明 Intent 至少在几条不同入口间保持了稳定。

多轮不是拼聊天记录

系统在内存和 Redis 中保存最近几轮状态,每个 session 只留三轮,并设置二十分钟窗口。新 query 进来后,DialogManagement 会判断它是在上一轮 Intent 上做哪种修改:加 metric、改日期、换维度、排序、比较、改过滤、子查询等,随后只修改对应的结构。

代码里的类型名很具体:intent_AddMetricintent_Rankintent_Compareintent_ChangeDateintent_ChangeDimintent_ChangeMetintent_ChangeDimFilterintent_ChangeExprintent_Subquery。每个名字都直接指向下一步要改的 Intent 区域,比泛泛的“继续追问”更便于执行。

判断时既看新句子,也看上一轮 Intent。上一轮已经有日期,这轮出现“环比”,就按日期比较处理;上一轮有维度,这轮说“前十”,就补排序;“它”“这个”一类代词则可能进入子查询。状态类型有十来种,policy()post_policy() 分别在解析前后修正结果。

只保留三轮、二十分钟也算是一个产品判断。历史越长,能继承的信息越多,陈旧主题和口径回流的概率也越高。这里主动忘掉老状态,让显式的最近上下文优先。Redis 让多进程之间能共享会话,内存副本则减少每一步都访问远端存储的成本。

相对日期是 case 最密集的地方。用户先问“23 年八月”,再说“7 月呢”,代码要继承年份,补成“23 年 7 月”;接“第三季度呢”也要沿用 2023 年。源码把这些继承关系逐项列出来,末尾留着“其他情况碰到之后再补充”。这是规则系统很真实的一面:它不会自动拥有语言能力,覆盖范围来自一条条回归用例。

不过一旦改动类型判断正确,修改 Intent 很干净。改日期就是换 DateRange,换维度就是动 Dimension,不用回头拼接历史问句再猜一次完整语义。

模型只守拒识这一关

仓库里的 ModelHub 只加载一个 intent_rejection 文本分类器。query 没命中 dimension、metric、enum、table、date dimension 或 slang,黑白名单也没有结论时,才会调用它。分类结果必须是 reject 且分数高于 0.97,服务才真正拒绝;不够确定就继续放行。

这个调用顺序很在意误拒的代价。只要规则识别到一个可信业务线索,就不让分类器抢走决定权;模型自己也要非常确定才拒绝。结果可能是多放过一些无效 query,但不容易把一条本可解析的业务问题挡在入口。

这个模型已经很老,新版依赖库加载它时还要打补丁。但因为它不参与主语义链路,只处理规则完全无命中的边界,升级压力没有那么大。这种安排也反映了当时的风险判断:路由、字段和口径必须能追踪,小模型可以在误判代价较低的位置补洞。

回头看,chat2chart 最值得留下的未必是现有规则。正则会替换,阈值会重调,字段匹配也可以换成更强的模型。真正难动的是 Intent,以及围绕它形成的多轮、DSL 回转和下游协议。

如果今天继续演进,我不会先删掉规则改成端到端。更实际的路径是让模型逐步接手候选生成和覆盖较差的长尾表达,Intent 层继续做校验、兼容和状态修改。旧代码其实已经把这条路留出来了。

我更想先补的是回归资产。对这种系统,单纯追求函数覆盖率意义有限;真正有用的是把 query、切词结果、候选主题、上一轮状态和最终 Intent 一起固化。每改一个阈值或正则,都能知道修好了哪条问法,又打坏了哪些旧 case。源码里的测试目前更像脚本和样例数据,距离完整的 query regression 还有空间。

app.py 也已经承担了生命周期、缓存、session、主题路由、NL2Intent、DSL2Intent、日志和指标。继续加能力之前,把 subject routing、query parsing、dialog management 和 DSL conversion 抽成明确的 service,会比先换模型更能降低日常修改风险。

这套实现有很强的时代痕迹,但不是一堆该被嘲笑的旧规则。它把当时模型做不到的部分拆开,并留下了一份足够稳定的领域协议。模型能力会继续变,Intent 仍然是判断新方案能否平滑接进来的那条边界。