Blog / Recipes / FIG. 86
Materialize Verdicts as Warehouse Columns
Build an LLM enrichment pipeline that writes verdicts into warehouse columns once, so AI columns in dbt get queried like any other field.
Asking a model a question about a row at query time is a great demo and a strange production habit. Every dashboard refresh pays again, every analyst gets slightly different answers, and nobody can say what the column meant last Tuesday. The fix is old and unglamorous: materialize. An LLM enrichment pipeline judges each row once, writes the answer into a column, and from then on it's just data.
Semantic filters at query time are the natural language database queries page's territory. This recipe is the other half: when the answer should be stored, not recomputed. The judge is Jev, the decision model from TypeSafe AI (primer), because a column needs a closed type, and closed types are its only output.
Materialize when the question is stable and the rows are read far more often than they change: ticket category, "mentions a competitor," "is this a real company address," review sentiment. Query-time judging still wins for one-off exploration and questions you'll never ask twice.
A decent rule: if the question has earned a name, it's earned a column.
Step 1: define the column like a schema, not a prompt
Each verdict column gets a spec before it gets code:
- Name:
mentions_competitor - Type: boolean, or an enum like
billing / bug / feature_request / other - Question text: the exact wording, versioned
- Source fields: which columns go into the judged text
- Confidence column:
mentions_competitor_p, the probability of the chosen answer
Store the probability. Per ecosystem documentation, choice answers come back with probabilities, and a column you can filter by confidence ("only rows above 0.9") is worth far more than a bare label. Wording matters more than anything else here; the data labeling page covers why model-first labels need a human-checked sample.
Step 2: the LLM enrichment pipeline job
The job reads new or changed rows, judges them, and writes results to a side table keyed by row id and question version. Pseudocode only; check docs.typesafe.ai for the real client.
# pseudocode, not real API syntax
rows = select id, body, updated_at from tickets
where id not in (select id from ticket_verdicts where qv = 'v3')
or updated_at after last_judged_at
for batch in chunks(rows, 500):
results = judge_batch(batch.body, QUESTIONS_V3)
upsert ticket_verdicts (id, qv, category, category_p, mentions_competitor, mentions_competitor_p, judged_at)
Three details keep this sane:
- Incremental only: judge new and changed rows, never the whole table on a schedule.
- Version in the key: when you reword a question, it's a new version, and old answers stay queryable until you backfill.
- Side table, not in-place: keep verdicts next to source data, joined by id, so a bad run can be dropped without touching the source.
Step 3: AI columns in dbt (or whatever you model with)
Once verdicts live in a table, the modeling layer treats them like any other source. A dbt model, or a plain view, joins ticket_verdicts to tickets for the current question version and exposes clean columns. The verdict job runs before the model build, as an upstream step in your orchestrator.
-- pseudocode model, not a real dbt package
select t.*, v.category, v.category_p, v.mentions_competitor
from tickets t
left join ticket_verdicts v on v.id = t.id and v.qv = 'v3'
We're not aware of an official dbt integration for Jev; this pattern needs none. The model call happens in the job, and dbt only sees a table.
Step 4: test the column like you'd test any column
Add tests your warehouse already understands:
- accepted values for enum columns,
- not-null for rows older than one job run,
- a freshness check on
judged_at, - a weekly sample of 50 rows labeled by a human, compared to the stored verdict.
That last one is the real test. There are no official Jev benchmarks, so agreement with your own reviewers is the number to track.
What builders have shipped
The ecosystem already has the pieces. pg-jev is a PostgreSQL extension for semantic questions over table rows, and JevQL offers semantic WHERE clauses for vanilla Postgres. On the analytics side, Hamilton Ulmer's DuckDB extension classifies rows in CSV, Parquet, or DuckDB tables at about ten seconds per thousand rows, as reported (build). Any of them can be the judging step; the materialization pattern is what turns their output into a durable column.
Frequently asked questions
What is an LLM enrichment pipeline?
A job that runs a model over rows once and stores its answers as new columns. Downstream queries read the stored values instead of calling the model again.
How do AI columns work with dbt?
The model calls happen in an upstream job that writes a verdict table, and dbt models join that table like any other source. No special dbt support is needed.
What happens when I change the question?
Treat it as a new version: judge rows under the new version key, compare against the old one, then switch the model. Old answers stay available until you retire them.
Should I judge at query time instead?
For exploration and rare questions, yes. For stable questions read often, materialize; the natural language queries page covers the query-time side.
Numbers throughout are as reported by the build authors, not verified by shipwithjev. Code-shaped examples are pseudocode; the official docs live at docs.typesafe.ai.