Semantic dbt tests on Databricks: 50,000 real reviews through a Unity Catalog function

·13 min read·

The last post wrote four dbt tests as English sentences and ran them on a local DuckDB. They caught a customer called Mickey Mouse, a return coded late whose comment says the box arrived crushed, and a support ticket with someone's email address spelled out in words. A regex baseline missed most of those.

The obvious question afterwards was whether any of it survives contact with a real warehouse. A Python plugin on a laptop is the easy case. On Databricks the model call has to run somewhere the platform allows, the API key has to live somewhere nobody can read it, and the whole thing has to cope with tens of thousands of rows that nobody hand-crafted.

So demo 05 moves the same tests to Databricks. The schema.yml blocks are byte-for-byte the same, and a test checks that. What changed is everything underneath. Then I ran it on 50,000 real product reviews, and it failed its own gate by one point of recall.

This is the fifth demo in my Jev demo series. Code, answer keys and every logged run are public in jev-demos/05-dbt-databricks↗.

What the dbt user sees#

Nothing new, which was the point. The four yardstick tests are unchanged from the DuckDB demo, and the production run adds a fifth in the same shape, next to the structural tests on a reviews model:

- name: body
  data_tests:
    - not_null
    - jev_expect:
        name: product_reviews_body_matches_stars
        arguments:
          fails_if: "The overall sentiment of the review `body` clearly contradicts its `stars` rating (1 = very bad, 5 = excellent)."
          context: [stars]
          threshold: 0.8
          criteria:
            "true": "A glowing text with 1-2 stars, or an angry/negative text with 4-5 stars"
            "false": "Sentiment roughly matches the stars, including sarcasm that matches a low rating and mixed 3-star reviews"
        config: {severity: warn, store_failures: true, tags: [production]}

One thing did change since the first post: the tests run at severity: warn. Someone suggested it in a comment on LinkedIn, and they were right. A probability should flag a row for a person to review. It shouldn't stop a pipeline. Flagged rows still land in the audit schema with their probability jev_p, so you can query them like any other stored test failure.

Where the model call lives#

On DuckDB the plugin registered a UDF on the connection. Databricks has no equivalent hook for dbt, so the call moved into the platform itself: a Unity Catalog Python function.

jev_demo.jev.noul_pack(records ARRAY<STRING>, question STRING, model STRING)

It's a plain Python UDF with a SECRETS (...) clause, standard library only (urllib, no requests). The TypeSafe↗ API key is a Unity Catalog secret that only this function can read. The key never shows up in dbt, in a profile, in an environment variable or in a notebook. Anyone with EXECUTE on the function can use it, and nobody else needs READ SECRET.

Three platform details cost me time, so here they are:

Judging moved out of the test#

In the DuckDB version the test itself called the model. On Databricks that's the wrong place. A dbt test is a SELECT, it runs every time, and nothing persists between runs. So the architecture flipped:

dbt build
 ├─ model builds → post-hook jev_judge()
 │     states = distinct (tested column + context) values in the model
 │     misses = states with no successful judgment yet
 │     packs  = misses cut by estimated tokens
 │     one function call per pack → INSERT INTO jev.judgments
 └─ jev_expect test → SELECT model rows JOIN judgments WHERE p >= threshold

A post-hook on each model does the judging and appends to a Delta table, jev.judgments. The test is now an ordinary SELECT that joins against it. That one table does two jobs:

The hook and the test build the question, the state and the key from the same macro, so they can't drift apart. And the hook only judges when the jev_expect tests are actually selected in the invocation. A plain dbt run, or a build that excludes tag:semantic, builds the models and never calls the model. That's how you keep a probabilistic, billed step out of every ordinary run without writing a separate job for it.

Two failure modes are loud by design. A tested row with no successful judgment (a pack failed, or you changed the sentence and only ran dbt test) comes back as a failing row with jev_p = NULL, never a silent pass. And every build checks that each missing state was judged exactly once: rows inserted equals states missing, pack sizes sum to rows inserted, no key appears twice. If any of that fails, the run fails.

Packing by tokens, not rows#

The first post ended on a packing lesson: batch rows into one request and you change the question the model answers, unless each record sits in its own question. Demo 05 keeps that nested layout and changes how packs are cut. Instead of a fixed row count, packs fill up to an estimated 48k tokens (the API takes 64k per request), with a cap of 256 rows.

On the yardstick data (demo 04's exact rows, answer key and regex baselines), that's 6 requests for the 1,057 distinct states. Demo 04 needed 18 at 64 rows per request, and 1,057 at one row per request. Four fresh live runs, all of them passed the gate, each for $0.006:

testdefectsJev PJev Rregex Pregex R
customers_full_name_is_a_person250.960.92–0.960.710.60
returns_comment_matches_reason_code121.000.92–1.000.450.75
reviews_body_matches_stars241.001.000.390.92
tickets_body_has_no_pii140.931.000.480.86

The recall ranges are real run-to-run noise, and I'd rather show them than pick the best run. Two golden defects sit right on their thresholds (customer 379 scored 0.69 to 0.75 against a threshold of 0.7, and return 144 scored 0.79 to 0.83 against 0.8), and both dipped under in the first run only. Across the four runs the mean standard deviation of a row's probability was 0.0042. Precision didn't move at all. So a single run's recall is reliable to about one row, and that's worth knowing before you put a hard gate on it.

The production run: 50,000 real reviews#

The yardstick data is synthetic, and I wrote it, so the hard cases are the ones I thought of. For the second run I used real text: a seeded sample of 50,000 reviews from the Amazon Fine Food Reviews dataset (SNAP, Stanford; McAuley and Leskovec, WWW 2013). User ids and profile names are dropped. On about 3% of the reviews that weren't 3 stars, the star rating was flipped (1↔5, 2↔4), which gives 1,386 planted defects with a known answer.

It ran in three logged steps: 47,500 rows, then 2,500 more, then a rerun.

stepwhat happened
part 1, 47,500 rows44,292 distinct states · 277 requests · 10.4M tokens · 72.5 s in Jev · $0.437 · 0 retries
part 2, +2,500 rows2,219 new states judged, nothing else · 14 requests · $0.022
rerun46,511 states, 100% cached · 0 requests · $0.000

The increment is the number I care about most as an engineer. Adding 2,500 rows judged only the 2,219 states it had never seen. Reviews repeat, and a repeated text with the same stars is a cache hit. The rerun made zero requests. That's what turns a per-row model call from a demo into something you could schedule nightly: you pay for new data, not for the table.

The other thing the timings show is that the model wasn't the slow part. In the spike before the build, Jev spent about 0.7 seconds on a full 256-row pack, while each SQL statement carried 13 to 27 seconds of warehouse overhead. Wall time on Databricks is dominated by the warehouse, not the model.

One caveat on cost: the dollar figures are the Jev API cost only. Warehouse DBUs aren't in them, because system.billing wasn't enabled on this workspace, and I'm not going to estimate a number I didn't log.

It failed the gate, and I'm keeping the FAIL#

The gate was set before the run: recall of at least 0.85 on the planted flips. Jev reached 0.84. That's a FAIL, it's logged as a FAIL, and it stays one. The regex baseline on the same data reached recall 0.73 at a raw precision of 0.24 (F1 0.36, against Jev's 0.85), so it's not close. But "beats regex" was never the bar.

What makes this interesting is that on real data, the answer key is wrong too, in both directions. Two blind audits, set up before any labelling, measured how wrong:

So the honest summary is that recall is around 0.84 to 0.86, right at the edge of the gate either way. The corrected number is reported as a separate line next to the raw one, and it doesn't replace it.

A note on how the audits were labelled, because it matters: two independent LLM labellers (Claude Opus and Claude Sonnet), each given a shuffled copy with only the review text and stars. They never saw Jev's probability or whether Jev flagged the row. Where they disagreed, the tie went against Jev. They agreed on 81% of the precision audit (κ 0.60) and 99% of the key audit (κ 0.80). That's a reasonable measurement, but it's an LLM-labelled one, and I've labelled it as such everywhere it appears.

The general lesson here isn't about Jev. On real data, your answer key has error bars. If you score any classifier against planted defects, measure the key before you trust the score.

What I'd still worry about in production#

The demo writes these down rather than hiding them:

What this doesn't show#

What I'd take from it#

If you run dbt on Databricks, the shape is reusable without any of the Jev specifics. Put the model call in a Unity Catalog function so the secret never leaves the platform. Judge in a post-hook, not in the test, and only when the semantic tests are selected. Make a Delta table both the cache and the ledger, so reruns are free and every number has an invocation behind it. Fail loudly when a row wasn't judged. And when you score on real data, audit the answer key too.

It's the same split as the rest of the series and how I build AI-native: code handles everything with a right answer, including the caching, the counting and the gate, and the model only answers the one question code can't. The DuckDB version is still the place to start if you want to see the tests themselves, and its scorecard lets you move the thresholds. The demo folder↗ runs end to end in a clearly labelled simulated mode, without an API key.

Disclosure: I'm an independent user of Jev with no commercial relationship with TypeSafe. Every number here comes from a logged live run in the demo's docs/eval-results.md; the simulated mode's output is never quoted.

Related

Semantic dbt tests: data quality checks in plain English

not_null and accepted_values will happily pass a customer called Mickey Mouse, a return coded late whose comment says the box arrived crushed, and a ticket with someone's IBAN in it. So I wrote four dbt tests as English sentences and scored them against an answer key.

12 min read

Data freshness and schema-drift alerting

A pipeline can run successfully and still be wrong. The two failures that matter most are silent by nature — the data stopped arriving, or it quietly changed shape — and neither shows up in a green DAG. Here's what to measure, and how lineage turns an alarm into an assignment.

7 min read

Sessionization with SQL window functions

A stream of events isn't sessions until you decide where one visit ends and the next begins. You can do that in three window functions — no self-joins, no Python — and the whole thing hinges on the same frame-clause detail that trips people up on running totals.

6 min read