文章 Lilian Huang · 七月 10 8m read

IRIS 的“%Embedding.Config”如何将我的向量搜索转化为一个普通的 SQL 查询

大多数“AI 代理 + FHIR”项目最终都呈现出相同的架构:这边是一个 FHIR 服务器,那边是一个向量数据库,中间则是一个 Python 服务,其职责是调用嵌入 API、在两端之间转换浮点数数组,并保持两个数据存储库的同步。 三个活动组件、两次网络跳转,以及一个你从此永久拥有的嵌入式客户端。

Triage Park:我们 提交给InterSystems 编程大赛:FHIR AI Agent的参赛作品完全没有这些复杂结构。该代理从不计算嵌入向量,也从未导入过 OpenAI 嵌入向量客户端。 没有向量数据库。它将原始文本发送给 IRIS,剩下的工作由 IRIS 完成:它在服务器端计算嵌入向量,并通过针对与 FHIR 存储库位于同一数据库*中的表执行单条 VECTOR_COSINE SQL 语句来响应检索请求。

这就是 IRIS for Health 的“AI Hub”模式,一旦我采用该模式,我自己的代码中整整一层就消失了。以下是它的具体工作原理。

问题在于:分诊人员需要快速获得指导方针

Triage Park 是一款对话式分诊助手。患者描述症状(“我上楼梯时胸口发紧”),由大型语言模型(LLM)支持的助手会读取患者的真实 FHIR 病历,并生成分诊决策:自我护理 / 看全科医生 / 急诊护理 / 急诊科,同时附上依据说明。

“引用依据”(Cited rationale)是关键所在。该助手不能凭空捏造临床推理;它会从经过精心筛选的语料库中检索出匹配的分诊指南,并据此进行推理。 这种检索本质上是一个语义搜索问题:“劳力性胸闷”需要找到关于劳力性胸痛和急性冠状动脉综合征的指南片段,尽管这些词汇之间并无重叠。这就是嵌入向量的作用所在。

构建此模型的经典方法是:

  1. 应用程序在初始化时调用嵌入式API,将每条指南向量化。
  2. 应用将这些向量存储在专用的向量数据库中。
  3. 在查询时,应用再次调用嵌入 API 将查询向量化。
  4. 应用向向量数据库查询最近邻。

共四个步骤,其中两个步骤涉及与嵌入提供商的网络往返通信,应用程序必须对此进行身份验证、重试和版本控制。IRIS 让我能够将所有这些步骤合并为一步。

配置方法:只需声明一次嵌入配置

整个模式的核心在于一个 IRIS 对象:一行 %Embedding.Config。您只需声明要使用的嵌入式模型及其 访问方式,此后 IRIS 便会自动处理相关调用。在 Triage Park 中,该对象由 ObjectScript 安装程序在启动时创建:

set jsonCfg = {}
set jsonCfg."modelName" = "text-embedding-3-small"
set jsonCfg."apiKey"    = apiKey            // read from OPENAI_API_KEY
set jsonCfg."sslConfig" = "openai-ssl"      // outbound TLS config
set jsonCfg."checkTokenCount" = 0

set cfg = ##class(%Embedding.Config).%New()
set cfg.Name           = "central-park-openai"
set cfg.EmbeddingClass = "%Embedding.OpenAI"   // the AI Hub provider
set cfg.Configuration  = jsonCfg.%ToJSON()
set cfg.VectorLength   = 1536
do cfg.%Save()

这就是全部的集成过程。%Embedding.OpenAI 是平台自带的提供程序类;将 EmbeddingClass 指向它,意味着 IRIS 知道如何将文本转换为 1536 维 text-embedding-3-small向量,而我这边无需编写任何嵌入式客户端代码。该命名配置——central-park-openai——是其他所有内容引用的核心。

值得特别指出的一处实际应用中的细节:API 密钥必须在运行时(而非镜像构建时)才能写入该行,因为该密钥存储在 .env 中,而在构建 Docker 镜像时该密钥尚不存在。 因此,代理在启动时会重新向 /install/embedding-config 发送 POST 请求,将实时密钥写入配置行中。虽然只是个小细节,但如果嵌入步骤在密钥为空时悄无声息地执行了空操作,这种问题就会给你带来麻烦。

存储方式:与其他所有内容并列的一个向量列

指南语料库存储在一个普通的持久化类中,该类包含一个特殊列:

Class CentralPark.Data.Guideline Extends %Persistent
{
Property Slug    As %String(MAXLEN = 128) [ Required ];
Property Source  As %String(MAXLEN = 256);
Property Snippet As %String(MAXLEN = 4000);
Property Embedding As %Vector(DATATYPE = "float", LEN = 1536);

Index SlugIdx On Slug [ Unique ];
}

Embedding 列即为 %Vector(DATATYPE = "float", LEN = 1536)。 Float 是 IRIS 的默认向量元素类型,也是存储效率最高的选择——因为单精度对于嵌入相似性而言绰绰有余,而在 1536 维的情况下,与 double 相比节省的空间相当可观。 真正重要的是类型一致性:存储的向量与传递给 VECTOR_COSINE 的查询向量必须采用同一类型,因此我将整个处理流程统一设为 float 类型。(正如我们稍后将看到的,在系统扩展时,这一选择会带来一个值得特别注意的后果。)

请注意这里没有出现的内容:没有任何关于FHIR的内容,没有单独的连接,也没有第二台服务器。该表位于与FHIR R4存储库和互操作性生产环境相同的IRIS命名空间中。一个数据库同时承载了系统记录、向量存储和集成引擎。

初始化:发送文本,获取向量——服务器端

这就是导致我的 Python 代码失效的部分。当代理对语料库进行初始化时,它发送文本——30 个指南片段,不包含任何嵌入向量:

# central_park/seed_module.py
payload = [
    {"slug": row["slug"], "source": row["source"], "snippet": row["snippet"]}
    for row in corpus
]
httpx.post(f"{iris_rest_base_url}/vector/seed", json=payload, ...)

在 IRIS 端,嵌入操作通过一次针对命名配置的调用即可完成,生成的向量会直接绑定到 SQL 中——无需进行浮点数数组转字符串的往返操作:

// CentralPark.REST.Dispatch : PostVectorSeed
set vec = ##class(%Embedding.OpenAI).Embedding(snippet, ..GetEmbedConfigJson())

set rs = ##class(%SQL.Statement).%ExecDirect(,
    "UPDATE CentralPark_Data.Guideline SET Source=?, Snippet=?, Embedding=? WHERE Slug=?",
    source, snippet, vec, slug)
// ...falls back to INSERT if no row matched the slug (idempotent upsert)

%Embedding.OpenAI.Embedding(text, config)``%Embedding.OpenAI.Embedding(text, config) 就是整个嵌入层。它读取 central-park-openai 配置,通过配置好的 TLS 路径调用 OpenAI,并返回一个向量对象,我可以直接将其放入参数化语句中。 该代理的唯一任务就是传递文本。

成效:检索仅需一条 SQL 语句

在查询时,代理执行相同的操作——它将原始查询文本通过 POST 请求发送至 /vector/search。IRIS 使用相同的配置对查询进行嵌入,然后执行搜索:

// CentralPark.REST.Dispatch : PostVectorSearch
set queryVec = ##class(%Embedding.OpenAI).Embedding(query, ..GetEmbedConfigJson())

set sql = "SELECT TOP ? Slug, Source, Snippet, "
        _ "VECTOR_COSINE(Embedding, ?) AS Score "
        _ "FROM CentralPark_Data.Guideline ORDER BY Score DESC"
set rs = ##class(%SQL.Statement).%ExecDirect(, sql, k, queryVec)

这就是整个语义搜索的过程。VECTOR_COSINE(Embedding, ?) 计算每个存储的指南向量与查询向量之间的余弦相似度; ORDER BY Score DESC 对它们进行排序;TOP ? 选取最佳的k 个(代理请求 5 个)。另一端的 Python 工具简单得近乎可笑:

def search_guidelines(query: str, k: int = 5) -> list[GuidelineHit]:
    resp = httpx.post(f"{iris_rest_base_url}/vector/search",
                      json={"query": query, "k": k}, ...)
    return [{"source": h["source"], "snippet": h["snippet"], "score": h["score"]}
            for h in resp.json().get("hits", [])]

客户端没有嵌入模型。没有向量数据库 SDK。没有维度管理。代理用英语提出问题,便能获得按优先级排序且附有引用来源的指南。

关于诚实度的说明:这实际上是全表扫描(但这没问题)

我想明确说明规模问题,因为在此很容易夸大其词。语料库包含30个片段,搜索采用全表余弦扫描——目前该列上没有近似最近邻索引。 对于 30 行数据,对表进行穷举 VECTOR_COSINE 操作耗时不到 1 毫秒,因此 HNSW 索引纯属多此一举。

IRIS确实提供了 %SQL.Index.HNSW 功能,对于包含数千至数百万条指南片段的生产级语料库,这正是你应该采用的方案。 不过,这里有一个关键问题:HNSW索引要求该列必须是 doubledecimal,而非 float。 因此,扩展并非单纯“添加一个索引”——我建议先将 Embedding 列从 float 迁移到 double,然后在此列上创建 HNSW 索引,之后相同的 VECTOR_COSINE 查询将透明地切换为近似搜索。 我上面编写的查询不会改变;列类型和索引都会发生变化。这很好地说明了为什么前面提到的 float 与 double 的选择并非没有代价:虽然目前 float 在存储空间上更占优势,但一旦需要使用 HNSW,double 就是必须付出的代价。

这为何对“附加式分析”模式至关重要

许多 FHIR 加 AI 项目将 FHIR 服务器视为一个“哑”文档存储库,并将所有智能功能构建*在服务器之外——*一个用于检索的独立向量数据库、一个用于评分分析的独立分析引擎,以及一个将它们粘合在一起的独立服务。这些边界中的每一个都意味着一次网络跳转、一个同步问题,以及一个需要运维的额外系统。

AI Hub 模式消除了这些隔阂。在 Triage Park 中:

  • **嵌入模型是通过声明实现的,而非编码实现。**将 text-embedding-3-small 替换为另一个模型只需修改配置,无需更改代码。
  • **向量与 FHIR 数据并存。**检索操作 VECTOR_COSINE 通过 SQL 实现,位于同一数据库中,并采用与患者病历相同的备份和安全边界。
  • **应用程序保持轻量级。**我的“向量工具”是一个仅6行的HTTP封装器。嵌入层只需一次ObjectScript方法调用即可实现。

我在这个平台上不断重温的教训是:*让数据库来处理 AI 的基础架构工作。*通过 %Embedding.Config 将嵌入向量迁移到 IRIS 中,不仅节省了代码行数,更彻底消除了我原本必须运行、保障安全并保持同步的整个系统类别。向量搜索不再是一种架构,而变成了一条 SQL 查询。


Triage Park 是开源项目。本文所述的向量层位于 CentralPark.REST.Dispatch (通过 PostVectorSeed / ---- -132----- 路径)、CentralPark.Data.Guideline (存储类)以及 central_park/tools/vector.py (客户端)中。

我很想了解其他人是如何使用 %Embedding.Config 的——特别是那些将其应用于大规模 HNSW 索引语料库的用户。