Experience the warehouse-native AI pattern — classify, embed, and vector-search data without leaving SQL — by simulating it locally.
DuckDB Python UDFs stand in for functions like Snowflake Cortex AI_CLASSIFY and BigQuery AI.GENERATE, an in-table FLOAT[384] embedding column plus array_cosine_similarity stands in for warehouse VECTOR_SEARCH, and a cost-model table you compute in SQL compares warehouse-native versus external-API processing at a million-row scale.
You will classify 300 support tickets with a UDF, sample-audit the misclassification rate before trusting a backfill, run semantic search over the same table, and export a cost model whose punchline every data engineer should carry: at millions of rows, model size and token count dominate cost, not vendor branding — and the audit you run on 20 rows is what makes a million-row backfill defensible.
~90 minutes hands-on · Core tier (maps to module M8).
Laptop-only, $0 in dry-run mode. The classifier UDF has two implementations: a keyword stub (default, offline) and a real Claude call (set LAB_DRY_RUN=0 with an API key; 300 short calls cost a few cents). The point of the stub is not realism — it is that the SQL surface (UDF in a SELECT, enriched table, audit query, cost model) is identical whichever backend answers.
mkdir -p ~/labs/de-lab-13 && cd ~/labs/de-lab-13
python3 -m venv .venv && source .venv/bin/activate
pip install duckdb faker sentence-transformers pandas anthropic
Create make_tickets.py — 300 deterministic support tickets with true labels:
import duckdb, random
from faker import Faker
random.seed(13); Faker.seed(13)
fake = Faker()
CATEGORIES = ["billing", "shipping", "product_defect", "account_access", "other"]
TEMPLATES = {
"billing": ["I was charged twice for order {o}", "refund still not processed for {o}",
"invoice amount looks wrong on {o}", "why did my subscription price increase"],
"shipping": ["package {o} stuck in transit for two weeks", "wrong delivery address on {o}",
"courier marked {o} delivered but nothing arrived", "need to expedite shipping for {o}"],
"product_defect": ["the unit arrived with a cracked housing", "device stops working after an hour",
"screen flickers constantly since unboxing", "battery drains in thirty minutes"],
"account_access": ["cannot log in after password reset", "two-factor codes never arrive",
"account locked after too many attempts", "email change did not take effect"],
"other": ["do you have a student discount", "how do I download my data",
"feature request: dark mode please", "is there a reseller program in my region"],
}
con = duckdb.connect("support.duckdb")
con.execute("CREATE OR REPLACE TABLE tickets (ticket_id VARCHAR, body VARCHAR, true_category VARCHAR)")
rows = []
for i in range(300):
cat = random.choice(CATEGORIES)
body = random.choice(TEMPLATES[cat]).format(o=f"ORD-{random.randint(10000,99999)}")
body += " " + fake.sentence(nb_words=8)
rows.append((f"T-{i:04d}", body, cat))
con.executemany("INSERT INTO tickets VALUES (?, ?, ?)", rows)
print("tickets:", con.execute("SELECT count(*) FROM tickets").fetchone()[0])
Run it: python make_tickets.py → tickets: 300.
In production the vendor-neutral menu for this pattern is: Snowflake Cortex AISQL (AI_CLASSIFY, AI_COMPLETE, AI_FILTER, EMBED), BigQuery AI functions (AI.GENERATE, AI.GENERATE_EMBEDDING, VECTOR_SEARCH), and Databricks ai_query() + Vector Search. The shared idea: the model call is a SQL function, data never leaves the governed platform, and you pay per row/token on your warehouse bill. This lab's DuckDB UDF is the same shape with a local engine.
Register the classifier as a SQL function. Create classify.py. con.create_function registers a Python callable as a scalar SQL function — after that line, ai_classify(body) is just SQL.
import duckdb, os
CATEGORIES = ["billing", "shipping", "product_defect", "account_access", "other"]
DRY_RUN = os.environ.get("LAB_DRY_RUN", "1") == "1"
def ai_classify_llm(body: str) -> str:
import anthropic
client = anthropic.Anthropic()
msg = client.messages.create(
model="claude-opus-4-8", max_tokens=16, thinking={"type": "adaptive"},
messages=[{"role": "user", "content":
f"Classify this support ticket into exactly one of {CATEGORIES}. "
f"Reply with the label only.\n\nTicket: {body}"}])
label = "".join(b.text for b in msg.content if b.type == "text").strip().lower()
return label if label in CATEGORIES else "other"
KEYWORDS = {
"billing": ["charged", "refund", "invoice", "price", "subscription"],
"shipping": ["package", "transit", "delivery", "courier", "shipping", "address"],
"product_defect": ["cracked", "stops working", "flickers", "battery", "defect", "broken"],
"account_access": ["log in", "password", "two-factor", "locked", "email change"],
}
def ai_classify_dry(body: str) -> str:
b = body.lower()
for cat, kws in KEYWORDS.items():
if any(k in b for k in kws):
return cat
return "other"
con = duckdb.connect("support.duckdb")
con.create_function("ai_classify", ai_classify_dry if DRY_RUN else ai_classify_llm,
["VARCHAR"], "VARCHAR")
con.execute("""CREATE OR REPLACE TABLE tickets_enriched AS
SELECT ticket_id, body, true_category, ai_classify(body) AS predicted_category
FROM tickets""")
acc = con.execute("""SELECT round(100.0 * count(*) FILTER (WHERE predicted_category = true_category)
/ count(*), 1) FROM tickets_enriched""").fetchone()[0]
print("accuracy vs labels:", acc, "%")
print(con.execute("""SELECT predicted_category, count(*) FROM tickets_enriched
GROUP BY 1 ORDER BY 2 DESC""").fetchall())
Run python classify.py. Dry-run accuracy lands around 99% (the stub's keywords were written against the same templates — an honest label for that is train-test contamination, and it is worth one sentence in your notes about why production accuracy numbers need held-out data).
Sample-audit before you would backfill. The single most important habit with in-SQL AI functions: before running AI_CLASSIFY over 40 million rows, you classify a sample and read it. Do that here:
SELECT ticket_id, body[:60] AS snippet, true_category, predicted_category
FROM tickets_enriched USING SAMPLE 20 ROWS;
(Run via duckdb support.duckdb CLI or con.execute(...).) For each disagreement, decide: model wrong, label wrong, or genuinely ambiguous? Record the observed misclassification rate and the dominant confusion pair in your notes. In a warehouse this audit is the difference between "we enriched the table" and "we quietly wrote 8% wrong labels into every downstream dashboard."
Embed the tickets into a SQL column. Create embed_search.py. The embedding lives in the table like any other column — that locality is the entire argument for warehouse-native vector search.
import duckdb
from sentence_transformers import SentenceTransformer
con = duckdb.connect("support.duckdb")
model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
rows = con.execute("SELECT ticket_id, body FROM tickets").fetchall()
emb = model.encode([r[1] for r in rows], normalize_embeddings=True)
con.execute("CREATE OR REPLACE TABLE ticket_embeddings (ticket_id VARCHAR, embedding FLOAT[384])")
con.executemany("INSERT INTO ticket_embeddings VALUES (?, ?)",
[(r[0], e.tolist()) for r, e in zip(rows, emb)])
q = model.encode(["customer says the parcel never showed up"], normalize_embeddings=True)[0].tolist()
res = con.execute("""
SELECT t.ticket_id, t.body[:60] AS snippet,
round(array_cosine_similarity(e.embedding, ?::FLOAT[384]), 3) AS sim
FROM ticket_embeddings e JOIN tickets t USING (ticket_id)
ORDER BY sim DESC LIMIT 5""", [q]).fetchall()
for r in res: print(r)
Expected: five "courier marked … delivered but nothing arrived" tickets — a query that shares almost no keywords with the results. That is VECTOR_SEARCH semantics in plain SQL.
Add an ANN index. Exact scan is fine at 300 rows and hopeless at 300 million. DuckDB's vss extension provides HNSW (one-time download to install):
con.execute("INSTALL vss; LOAD vss;")
con.execute("SET hnsw_enable_experimental_persistence = true")
con.execute("""CREATE INDEX IF NOT EXISTS emb_idx ON ticket_embeddings
USING HNSW (embedding) WITH (metric = 'cosine')""")
Note the parallel in your notes: this index corresponds to the managed ANN infrastructure behind Cortex Search / BigQuery vector indexes / Databricks Vector Search — in the warehouse you get it as a service; here you see the moving part.
Build the cost model in SQL. Create cost_model.py. Assumptions live in a table so changing a price is an UPDATE, not a code edit — hand this exact pattern to a finance partner.
import duckdb
con = duckdb.connect("support.duckdb")
con.execute("""CREATE OR REPLACE TABLE cost_assumptions (
approach VARCHAR, price_per_m_input DOUBLE, price_per_m_output DOUBLE,
tokens_in_per_row INTEGER, tokens_out_per_row INTEGER, batch_discount DOUBLE)""")
con.execute("""INSERT INTO cost_assumptions VALUES
('warehouse_native_small', 0.20, 0.80, 120, 8, 1.00),
('warehouse_native_large', 3.00, 15.00, 120, 8, 1.00),
('external_api_frontier', 5.00, 25.00, 150, 8, 1.00),
('external_api_frontier_batch', 5.00, 25.00, 150, 8, 0.50),
('external_api_small', 0.25, 1.25, 150, 8, 1.00)""")
df = con.execute("""
SELECT approach,
round((price_per_m_input * tokens_in_per_row + price_per_m_output * tokens_out_per_row)
* batch_discount, 2) AS usd_per_million_rows
FROM cost_assumptions ORDER BY usd_per_million_rows""").df()
print(df.to_string(index=False))
df.to_csv("cost_model.csv", index=False)
With these illustrative rates (verify current vendor pricing before quoting them — they move), the output is:
| approach | USD per 1M rows |
|---|---|
| warehouse_native_small | 30.40 |
| external_api_small | 47.50 |
| external_api_frontier_batch | 475.00 |
| warehouse_native_large | 480.00 |
| external_api_frontier | 950.00 |
Read the ranking, then write the three conclusions it forces: (a) model size dominates — small-vs-frontier is a 15–30x spread that dwarfs the warehouse-vs-external distinction; (b) external tokens also count the ~30 prompt-scaffolding tokens per row that warehouse functions absorb — trim your per-row prompt; (c) batch tiers halve external cost for exactly this kind of never-urgent workload. Then add the costs SQL cannot see: external APIs add egress, secrets management, and a data-residency conversation; warehouse-native adds vendor lock-in and a narrower model menu. For regulated data, "the rows never leave the platform" often decides it before price does.
Close the loop on audit economics. Append to your notes: at external_api_small rates, classifying 40M rows costs ~$1,900. A 500-row human audit at 30 seconds a row costs about four hours of one analyst. The audit is two orders of magnitude cheaper than the backfill — there is never a budget reason to skip it.
Wrap in-SQL AI outputs in the same guardrails as Lab 12 — an allowed values constraint on the label column and a distribution check (alert if any category's share moves more than a few points between runs) turn silent model drift into a data-quality incident you can triage.
tickets_enriched has 300 rows; dry-run accuracy prints ≥ 95%.vss loaded).cost_model.csv exists and reorders correctly when you UPDATE an assumption (test by doubling one price).A de-lab-13/ folder containing make_tickets.py, classify.py, embed_search.py, cost_model.py, cost_model.csv, and sql-ai-notes.md — the accuracy number with its contamination caveat, the sample-audit findings, the top-5 semantic search results, the cost table with your three written conclusions, and one paragraph mapping each local component to its Snowflake/BigQuery/Databricks equivalent. Self-grading rubric (pass = 8/10):
create_function TypeError: signature is (name, fn, [input_types], return_type) — types are strings like "VARCHAR".None or a category with different casing — the stub must return exact members of CATEGORIES.INSTALL vss fails offline: the extension download needs network once; the lab is complete without the index — note the skip.FILTER (WHERE ...) syntax error: you are on an old DuckDB — pip install -U duckdb.Dry-run mode never calls out, so the full lab runs inside any client boundary. The client conversation this lab prepares you for is the architecture recommendation: for regulated Indian clients (BFSI under RBI/SEBI localization, anyone under DPDP), warehouse-native AI functions keep customer text inside the platform tenancy the client already governs — no new data processor, no cross-border transfer analysis for a US-hosted API. Bring the cost-model CSV with the client's actual vendor prices filled in and the sample-audit protocol as the quality gate; the recommendation writes itself, and it is backed by numbers you generated rather than vendor slides.