语义契约决定检索准不准:术语到物理字段的映射该怎么设计
企业落地 Text-to-SQL 的失败点,很少出在 SQL 方言或模型能力上。更常见的情况是:用户问"上月净收入",系统检索到了 orders.total_amount,SQL 跑通、图表也画出来了,但业务方一眼看出这个字段含税、含运费、还把退款算作收入。数字错了,链路再快也没有意义。
NVIDIA Auto Ontology 把这个问题归到"上下文层":它不做通用的本体自动生成,而是围绕企业已有元数据,建立"业务概念—物理字段—派生 SQL—责任人"的可验证映射,再把这层上下文通过 MCP、CLI 与 API 暴露给任意 Agent。社区里有分析把它概括为"聚焦可信语义层,但结构化治理与发布一致性仍存工程缺口",这个判断基本准确。本文不谈概念,直接拆它的语义契约在代码里长什么样、哪些约束真的能兜住检索准确率、哪些地方仍然靠人。
一、契约不是一段描述,而是四组可查询的字段
很多团队所谓"语义层",本质是一份 Excel 词表:术语名加一段中文解释。Auto Ontology 的数据模型把这份词表拆成可关联的对象,关键表定义在 schema 定义 中:
term = Table(
"term",
METADATA,
_id(),
Column("name", Text, nullable=False),
Column("description", Text, nullable=True),
Column("source", Text, nullable=False, server_default=text("'semantic'")),
Column("synonyms", ARRAY(Text), nullable=True),
_certified("name_certified"),
_certified("description_certified"),
_owner_id(),
*_edited(),
_imported_id(),
UniqueConstraint("name", "source", name="uq_term_name_source"),
)
这一个表就把契约的四个维度立住了:
- 术语身份:
name是稳定主键之外的唯一业务名,UniqueConstraint("name", "source")保证同一来源下不会出现两个完全同名的 Term。 - 同义词:
synonyms是数组而不是文本,检索时可以按词匹配而不是把整串当子串。 - 可采信程度:
name_certified与description_certified分开两个布尔值,意味着"名字已被业务确认"和"解释已被业务确认"是两件事,不打包认证。 - 责任人:
owner_id指向账号或用户组,catalog:edit权限与 owner 的校验逻辑在 owners.py 里,明确区分"哪些行靠 owner 判定"与"哪些行只要求权限"。
字段映射落在 column_attribute 上,它的唯一约束才是整个设计最值得抄的一处:
UniqueConstraint(
"name",
"source_column",
"term_name",
"table_id",
"source",
name="uq_column_attribute_merge_key",
)
五元组合并键的含义是:同一个 Term 下,同一个物理列只能有一条属性记录;反过来,不同物理列可以映射到同一个业务属性名。这条看似宽松的约束,直接决定了后面的"同名不同义"处理方式——冲突不是靠数据库拒绝,而是靠检索阶段消歧。
派生逻辑单独建表。sql_attribute 有 expression、certified、source(取值含 manual、table、sql、bridge_table),通过 sql_attribute__sql 与 sql_attribute__term 两张关联表挂到语句和术语上。这里有个容易被忽略的细节:sql_attributes.py 里 _SQL_TEXT 对同一属性的多条语句取了 ORDER BY id LIMIT 1,注释写的理由很实在——不折叠就会让行数多于分页计数,导致某个 Term 的最后几条属性永远翻不到。
至于"粒度、单位、分母、有效期"这类度量契约,仓库里没有对应的列,而是以文字形式写进 skill 的建模规范:modeling.md 要求为每个可复用度量记录表达式、行粒度、允许的分组粒度、物理单位与时间粒度分离、可加维度与禁止的二次聚合、分子分母与总体过滤、空集与零分母行为。这是文档级契约,不是机器可校验的 schema。理解这一点很重要:它能规范人的行为,但拦不住错误的发布。
二、映射怎么生成:先确定性排除,再让模型命名
Auto Ontology 的自动编译不是"让大模型读一遍库然后自由发挥"。真正的映射骨架是确定性的,在 deterministic.py 中:
def column_attribute_specs(columns, fks, suggested_fk_columns=None, ...):
"""Non-FK columns mapped 1:1 to ColumnAttribute candidates."""
fk_cols = fk_source_columns(fks)
if suggested_fk_columns:
fk_cols |= suggested_fk_columns
candidates = [
col for col in columns if (name := col.get("name", "")) and name not in fk_cols
]
外键列被排除,剩下的非外键列以 1:1 方式成为 ColumnAttribute 候选。这一步把"物理字段"这一端钉死了:属性不是模型编出来的,而是列的子集。模型只负责两件事——给 Term 起业务名、给每个属性起展示名——而且输出还要经过 _sanitize_result 过滤:列名必须在允许集合内、一个列只能被分配一次、Term 名不合法时回落到表名派生的名字。
同一文件里的 column_attribute_specs 还给描述加了两类证据:样本值后缀 — samples: v1, v2,以及日期列的 — format: ... 记号。这两种后缀最终会进入向量文本,是"字段能被人话检索到"的主要来源之一。
Term 的归属规则同样克制。term_extractor.py 的系统提示明确要求"强偏好一张表对应一个 Term",只有在真正的连接表场景才拆第二个,并且禁止拆出"核心字段 vs 审计字段"这种伪实体。这条规则的价值在于:Term 数量一旦膨胀,检索的候选空间随之膨胀,歧义率上升的速度远快于召回率的提升。
名称冲突的处理在 terms.py 的 upsert_table_term 里,逻辑值得完整看一眼:
candidate_names = (
name,
f"{name} ({qualified_table})",
f"{name} ({table_id})",
)
for candidate_name in candidate_names:
existing = store().query_read(...)
represented_tables = {row["table_id"] for row in existing if row.get("table_id")}
if existing and represented_tables == {table_id}:
# 同一张表重复编译:更新描述与同义词,复用原 Term
...
if existing:
continue
# 用 on_conflict_do_nothing 插入,抢名失败则尝试下一个候选名
也就是三级降级:原名 → 加 库.模式.表 限定 → 加 table_id 限定。冲突不是报错,而是改名。这一招让自动编译在跨库同名表上可以无人值守地跑完,但代价是词表里会逐渐积累 Order (sales.public.orders) 这种机器可读、人不爱念的术语。后续这类术语能不能被用户搜到,就完全取决于描述和同义词写得好不好。
映射生成后写进向量库的文本结构在 embed.py:
text = f"Term: {term_name}. {term.get('description') or ''}".strip()
text = (
f"ColumnAttribute: {attr_name} of Term {owner}{synonym_suffix}. "
f"{a.get('description') or ''}"
f"{sample_block}"
).strip()
注意 synonym_suffix 只拼在属性行上,Term 行不带同义词。结合 terms.py 中 _semantic_terms 的过滤条件(source = 'semantic' 且至少有一张表代表它),可以推论出这个设计的隐含前提:一个"没人代表"的 Term 在列表与检索里都不存在,而它真正被检索到的入口是属性行。因此如果你的同义词只写在 Term 上而属性描述很空,向量召回率会明显低于预期。
三、一致性靠什么保:四条硬约束 + 一条事务边界
语义层的"一致"有两层含义:写入时不产生自相矛盾的数据,发布后能证明线上就是评审过的那份。Auto Ontology 在第一层做得比较扎实,第二层主要靠流程和外部 skill 兜。
写入侧有四条硬约束:
- 合并键幂等。
merge_column_attribute用on_conflict_do_update更新datatype,但对description使用func.coalesce(excluded.description, 现有描述)。注释写得很清楚:一次没有话可说的合并,不允许擦掉人工精修过的描述。这是语义层最常见的退化方式——重跑一次编译,所有人工润色归零。 - 属性与两条边同事务。attributes.py 把
column_attribute写入与column__has_attribute、column_attribute__term两个链接放在一个write_transaction()里,避免出现"属性在列表里可见、却不在认证统计里"的半成品。 - 一个属性只能属于一个 Term。
column_attribute__term上有UniqueConstraint("attribute_id"),注释直言这条规则过去只活在link_to_term的实现里,任何忘记删旧链接的写入路径都会破坏它。 - 派生表达式去重。sql_attributes.py 的
find_attr_by_expression在 Python 侧做" ".join(expression.split()).lower()归一化,注释解释了为什么不在 SQL 里比:regexp_replace对 Unicode 空白的处理与str.split不一致。而名称冲突用find_attr_by_name(name, exclude_id)做全局唯一检查——SqlAttribute 的名字没有 Term 作用域。
第四点是一把双刃剑,后文会展开。
发布侧,仓库 官方 skill 把流程写成了强制步骤,见 SKILL.md 与 publication.md:
- 改语义前先记录"问题契约":代表性业务问题及其总体、度量、分组、时间范围、比较与排除条件;
- SQL 属性必须先过
POST /api/sql-attributes/validate,并且明确"解析失败是 422,而不是valid: false"; - 发布不是请求成功,而是导出同范围、逐字段比对回读;
POST /api/semantic-compilation/reset是异步、破坏性的全库重建,不能被当成"改完清一下缓存";- 导入默认
replace=true,覆盖前必须与用户确认。
模型交换的幂等机制在 model_interchange.py:实体按 YAML 里的 id 去匹配数据库的 imported_id 或活动 id,所以重复导入是 no-op,导入到已有 catalog 上是"采纳"而不是"复制"。整个导入是一个事务,中途失败不会留下半套语义。这几点确实解决了"发布一致性"里最难的一环——重复执行的安全性。
但也要看到它没有解决的:导入没有原子晋升证明,skill 里明说"current model import and certification APIs do not prove atomic promotion",use_in_join_paths 这类开关还依赖迁移脚本里的一次性 rank 计算(见 99d4d343dd0b),按 lower(source_column.name), id 排序只保留每个属性的一条 join 路径。这类规则是合理的默认值,但它不是版本化的契约,改判据就等于静默改变线上语义。
四、失效场景一:同名不同义,冲突被推迟到了检索期
第一类高频失效是"两个业务概念共用一个词"。Auto Ontology 有三级防御,但每一级都有代价。
第一级:Term 名全局唯一 + 自动限定。 前文的 upsert_table_term 会把冲突的 Term 改成 Order (sales.public.orders)。好处是编译不失败,坏处是词表可读性下降,而且用户的提问方式("orders 表里的订单")与限定名之间的对齐,仍然要靠描述和同义词。
第二级:属性名允许重复,由消歧流程裁决。 五元组合并键允许 sales.orders.amount 与 billing.invoices.amount 都叫 Amount。检索到两个候选时,entity_resolution.py 用双阈值判定歧义:
max_distance = 0.65 if INTERACTIVE else DEFAULT_MAX_DISTANCE # 0.75
_AMBIGUITY_MAX_GAP: float = 0.25
ambiguous = {
e for e, s in second.items()
if s <= CLARIFY_MAX_DISTANCE and (s - best[e]) < _AMBIGUITY_MAX_GAP
}
只有"两个命中都在阈值内,且彼此分差小于 0.25"才算真歧义。注释里举的反例很到位:0.10 与 0.64 两个命中各自都合格,但显然不是平局;早期版本会把它误判成歧义,白白多问用户一轮。判定为歧义后,再取 top-5 候选交给小模型从候选 id 中选一个,选不出来才向用户提问。
这个设计的关键认知是:语义层的冲突检测发生在查询时,而不是写入时。它换来了写入的顺畅(自动编译不需要人值守),代价是把不确定性推给了运行时,且依赖一个距离阈值——而阈值与 embedding 模型强绑定。换模型而不重新校准 CLARIFY_MAX_DISTANCE,歧义率会整体漂移。
第三级:全局唯一的 SqlAttribute 名。 这一层反而是最硬的。sql_attribute.name 在表上就是 unique=True,创建时 find_attr_by_name 也是全表扫描。结果是:两个 Term 想各自定义一个叫 Revenue 的度量,第二个直接 409。而表达式去重是按 Term 作用域的(find_attr_by_expression 带 term_id),所以同一个表达式可以在两个 Term 下用两个不同名字各存一份。两条规则叠在一起,形成一个略反直觉的组合:名字不能重,语义可以重。对治理而言这是隐患——两份口径不同的"净收入"只要名字不同就能共存,检索阶段再把选择权交给相似度。
五、失效场景二:同义不同名,别指望模型替你补词表
第二类失效是用户说的词与字段名毫无字面交集。这里的机制分三层,逐层看它的能力边界。
检索层:同义词整词匹配,其余字段子串匹配。 search.py 的模块注释把三条决定写死了:全局搜索只提供 contains,所以索引用 pg_trgm GIN 而不是 tsvector(全文检索只能命中词首,mount 找不到 total_amount);每个字段独立匹配,不允许跨字段拼词(否则 "revenue report" 会命中名为 revenue 的列加描述里带 report 的表);同义词走整词匹配,unit 能命中 Business Unit,uni 不能。理由也很务实:子串匹配别名列表会产生用户看不见原因的噪声,因为命中的那个别名并不在屏幕上。
生成层:同义词只从描述里"抄",不许推断。 term_extractor.py 的第二段提示专门做同义词抽取,规则是只接收描述中明确写出的缩写或别名("also known as"、"abbreviated as"、括号注释),并明确要求 Do NOT infer, guess, or add any name that is not directly written in the description。这意味着:如果表注释里没写 "GMV (Gross Merchandise Value)",词表里就永远不会有 GMV。自动化的上限被卡在源系统的注释质量上,这是设计选择,不是缺陷,但必须让使用方知道。
问题改写层:术语表与自定义分析一起进提示。 entity_coverage/main.py 把 fetch_custom_analyses() 与 acronyms 合并成 domain_rules 和 glossary 注入状态;QuestionExtractionModel 里 used_glossary_names 要求"按术语表原文返回用到的条目名"。也就是说,缩写展开与口径解释是在抽取阶段完成的,而不是靠向量相似度撞运气。
三层合起来的现实结论:同义词是检索准确率里性价比最高、也最容易被忽略的一块。加一条同义词不需要重训模型、不需要改 SQL,只需要一次 PATCH(catalog:edit),而它对召回的影响是立竿见影的。
六、失效场景三:派生逻辑与映射会"过期"
第三类失效不发生在写入当天,而是在系统演化几周之后。仓库里有两处代码直接把这种"过期"写在了注释里。
向量行缺字段。 _build_rows 里 data_type 的注释说得很直白:这个字段只存在于"在该字段加入之后嵌入的行",老的嵌入行只能靠描述文本兜底,直到被重新嵌入。同理,任何新增的元数据都不会自动出现在已写入的向量里。所以"改了 embedding 文本模板"这件事,在语义层等价于一次数据迁移,必须显式重嵌。
建议排序实际是静止的。 sql_attribute_suggester.py 的 _usage_score 直接 return 0.0,注释里保留了完整的故障说明:原本要按近三个月计数排序,正则匹配 count_monthly_YYYY_MM,而写入方生成的是 count_8_2026,所以从来没有一个名字能匹配上;加上排序键把 month_year 当成 (year, month) 读,即使名字对上了,2025 年 12 月也会排在 2026 年 1 月前面。修复方向也给了——用 total_counter,或用 last_query_timestamp 换新近度。
这段注释值得所有做语义层的人读一遍,因为它揭示了一个常见现象:"从查询历史里学习"这类能力,一旦指标管道对不上,就会安静地退化成随机排序,而且产品表面上完全正常。文档里"top 10 表达式按使用频次排序"与实际"按字典插入顺序取前 10"之间的差距,没有任何报错会告诉你。
改名会过期的是缓存,不是语义。 write-api.md 里注明:重命名 Term 会使缓存的 SQL 属性描述建议失效。这算是处理得干净的一处。但 Term 表本身没有版本列,只有 created / modified / _edited() 的审计字段,也没有 supersede 关系。想回答"这条映射三个月前是什么样、谁改的、影响哪些查询",只能靠外部导出快照对比——这也是社区里"Terraform 式 plan/apply/drift/rollback"话题会流行的原因:本体的变更管理需要影响面报告与可回滚,而不是一次 POST 成功。
七、一套可落地的映射设计清单
把上面的机制抽象成通用规则,可以直接用于自建语义层:
- 物理端确定性,语义端可生成。属性集合由列集合确定性推导(排除外键、排除系统列),模型只在允许集合内命名。不要让模型决定"哪些字段存在"。
- 合并键要能表达"同一概念的多个物理落点"。用
(属性名, 物理列, 术语, 表, 来源)这类多元组做幂等键,而不是给属性名加全局唯一。 - 人工精修字段用 coalesce 而非覆盖。所有重跑型任务(编译、导入、同步)都必须遵守同一条规则:
description = COALESCE(new, existing),certified永不因重跑而重置。 - 认证拆维度。名称认证、描述认证、属性认证、派生 SQL 认证分开存,汇总逻辑只允许有一份实现。
terms.py的_certification注释强调三个入口(列表卡片、详情页、认证写入)必须投影同一个汇总,否则同一术语在三处显示不同徽章。 - 同义词必须整词匹配,且只写进描述里明确存在的别名。推断式别名会让召回变得不可解释。
- 歧义判定要双阈值:绝对距离卡"够不够相关",相对分差卡"是不是真平局"。缺一个就会出现大量无意义的澄清追问。
- 越权与语义可见性都用 all-or-nothing。
_in_scope与_out_of_zone都刻意写成否定式("不存在一张不在范围内的代表表"/"不存在一张不在范围内的被引用表"),因为在肯定式下,一个跨区 join 的派生属性会把用户无权查看的表渲染进 SQL。 - 发布等于回读,不等于 201。导出同范围 → 逐字段比对 → 再用
check_answerable跑正反例。Auto Ontology 的 MCP 工具把check_answerable定位成"付费跑ask_question之前的廉价预检",这个用法值得照搬:语义契约的回归测试,本质是"该答的能答、不该答的明确覆盖不到"。
八、仍然存在的工程缺口
基于源码,至少有三处需要使用者自己补:
其一,度量契约不可机检。 粒度、单位、分母、可加性全部写在 description 或外部规范里。同一条 Revenue 可以是"含税总额"也可以是"确认收入",两者在库里都是合法记录,只在文字上区分。想要强约束,只能自建字段并写校验器,或者把度量定义收敛到少量 certified 的 SqlAttribute 上,用认证标记充当准入门槛。
其二,全局搜索没有纳入分区过滤。 search.py 的模块注释自己承认:auto_ontology/dal 里其它读路径都带 zone_ids,唯独这里没有;今天 router 全传 None,过滤是全局关闭的,所以不是泄漏,但一旦打开分区,这个模块"没有地方可以传"。这是典型的"功能开关打开前必须先补的债"。
其三,结构变更没有公共写路径。 skill 里明确写了:没有"创建空 Term"的 POST,也没有完整的结构性关系变更流程,"如果请求的设计无法用当前发行版的公共 API 表达,请返回模型缺口,而不是弱化设计"。这句话既是诚实,也是约束——意味着关系的增删只能走整包导出/导入,粒度粗、影响面大,必须配合前文那套回读流程使用。
结语
回到标题的问题:检索准不准,取决于契约是否把"一个词"钉在"一个物理事实"上,并且让这个绑定可以被查询、被认证、被追责。Auto Ontology 给出的答案并不炫技:用唯一约束保证身份、用确定性映射保证物理端不漂、用 coalesce 保证人工成果不被重跑覆盖、用双阈值消歧把不确定性显式暴露出来、用导出回读把发布变成可验证的动作。
它没解决的部分同样清楚:度量语义仍在文字里,变更没有版本与影响面分析,跨区可见性与全局检索还没对齐。这些不是某个项目的短板,而是整个"Agent 上下文层"正在补的课。对工程团队来说,先把自己词表里那几十个高频术语的同义词、粒度、分母和责任人补齐,收益远大于再换一个更强的模型。
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考




