Run production batch enrichment from S3 or Parquet
Use this cookbook when source rows already live in object storage and the output should be written back as durable files. The pattern is:
- Read a bounded Parquet slice.
- Estimate the batch size.
- Call the provider with row-level failure capture.
- Split successful rows from rejected rows.
- Persist outputs and usage data.
Prerequisites
- Set
OPENAI_API_KEYin the environment before starting DuckDB. Theopenai_aisecret below stores the provider and model, not the key. See the provider guides. - Configure object-storage credentials with DuckDB secrets.
- Use a stable
run_idfor every production job attempt. - Check the prompt and model on a labeled sample first, as in evaluate before a batch run.
INSTALL ai FROM community;
LOAD ai;
INSTALL json;
LOAD json;
INSTALL httpfs;
LOAD httpfs;
INSTALL aws;
LOAD aws;
CREATE OR REPLACE SECRET s3_prod (
TYPE s3,
PROVIDER credential_chain,
REGION 'us-east-1'
);
CREATE OR REPLACE SECRET openai_ai (
TYPE duckdb_ai,
AI_PROVIDER 'openai',
MODEL 'gpt-5.6-luna'
);
SET VARIABLE run_id = 'support-triage-2026-07-07T100000Z';
-- Start the job with an empty usage buffer, so the usage snapshot covers only this run.
SELECT * FROM ai_clear_usage();
For S3-compatible storage such as R2, GCS interoperability, MinIO, or lakeFS, use the matching DuckDB secret type or endpoint options for your environment.
Read a bounded input slice
Read Parquet files by glob, keep the input filename for lineage, and materialize
the job input locally before calling the provider. This prevents repeated scans
of the remote object store if you rerun downstream steps. The LIMIT caps how
many rows, and so how many provider calls, one run can make.
CREATE OR REPLACE TEMP TABLE job_input AS
SELECT
ticket_id,
customer_id,
priority,
status,
subject,
body,
filename AS source_file,
getvariable('run_id') AS run_id
FROM read_parquet(
's3://support-prod/tickets/dt=2026-07-07/*.parquet',
union_by_name = true,
filename = true,
hive_partitioning = true
)
WHERE status = 'open'
AND priority IN ('high', 'urgent')
ORDER BY ticket_id
LIMIT 5000;
Check the row count before provider calls:
SELECT count(*) AS input_rows
FROM job_input;
Estimate a starting batch size
Use the local token estimator before a large run. Replace the limit values with the provider limits you operate under.
WITH prompt_stats AS (
SELECT
avg(ai_count_tokens(subject || chr(10) || body)) AS input_tokens_per_row
FROM job_input
)
SELECT ai_recommended_batch_size(
input_tokens_per_row,
250,
200000,
500
) AS recommended_rows_per_batch
FROM prompt_stats;
Use the result to adjust the LIMIT for a first production run, or to split
the input into multiple scheduled chunks.
Enrich rows with failure capture
Use ai_try_complete so one bad row does not fail the whole query. Ask for JSON
so the successful rows can be parsed and validated downstream. result is a
STRUCT with response and error.
CREATE OR REPLACE TEMP TABLE ai_attempts AS
SELECT
run_id,
ticket_id,
customer_id,
priority,
source_file,
ai_try_complete(
'Return compact JSON with keys summary, product_area, urgency_score. '
|| 'Use urgency_score from 1 to 5.'
|| chr(10) || 'Subject: ' || subject
|| chr(10) || 'Body: ' || body,
secret := 'openai_ai',
response_format := 'json_object',
max_tokens := 250,
retry_count := 2,
retry_backoff_ms := 1000,
max_concurrent_requests := 4,
min_request_interval_ms := 100,
token_limit_per_minute := 200000
) AS result
FROM job_input;
urgency_score is a fixed scale. With a decision provider, you can skip the
JSON parsing for that field:
ai_decide(body, {urgency_score: ['Routine', 'Minor', 'Degraded', 'Severe', 'Production down']}, provider := 'ollama', on_error := 'null')
returns urgency_score DOUBLE (a level from 0 to 4, so add 1 for the 1 to 5
scale) and urgency_score_confidence.
ai_decide has no capture mode, so treat a NULL result as rejected. See
route rows by decision confidence. Keep ai_try_complete
for free-text fields such as summary.
Split successes and rejected rows
Keep successful rows and rejected rows as separate datasets. Rejected rows should retain the provider error and enough lineage to replay only those rows.
response_format := 'json_object' asks for JSON but does not check the reply.
A reply that is not valid JSON has error = NULL, and casting it to JSON
fails the whole statement. Filter with json_valid so such rows land in the
rejected table instead.
CREATE OR REPLACE TEMP TABLE ai_successes AS
SELECT
run_id,
ticket_id,
customer_id,
priority,
source_file,
result.response::JSON AS response_json,
now() AS loaded_at
FROM ai_attempts
WHERE result.error IS NULL
AND json_valid(result.response);
CREATE OR REPLACE TEMP TABLE ai_rejected_rows AS
SELECT
run_id,
ticket_id,
customer_id,
priority,
source_file,
coalesce(result.error, 'missing or invalid JSON response') AS error,
now() AS loaded_at
FROM ai_attempts
WHERE result.error IS NOT NULL
OR NOT coalesce(json_valid(result.response), false);
Persist outputs
Write successful rows, rejected rows, and usage events to separate locations.
PARTITION_BY (run_id) writes each run to its own run_id=<value>/ folder, so
a retry of one run does not touch the files of other runs. Read the folders
back with read_parquet('.../run_id=*/*.parquet', hive_partitioning = true).
COPY ai_successes
TO 's3://support-prod/ai/ticket_triage'
(
FORMAT parquet,
COMPRESSION zstd,
ROW_GROUP_SIZE 100000,
PARTITION_BY (run_id),
OVERWRITE_OR_IGNORE true
);
COPY ai_rejected_rows
TO 's3://support-prod/ai/ticket_triage_rejected'
(
FORMAT parquet,
COMPRESSION zstd,
ROW_GROUP_SIZE 100000,
PARTITION_BY (run_id),
OVERWRITE_OR_IGNORE true
);
ai_usage() keeps only the latest 1,024 events. Check that none were dropped
before you save them. If dropped_events is above 0, the usage file is
incomplete: run smaller batches and snapshot after each one. ai_usage_totals()
keeps its counters even when events are dropped, so save it with the run:
SELECT max(dropped_events) AS dropped_events
FROM ai_usage_summary();
COPY (
SELECT getvariable('run_id') AS run_id, now() AS captured_at, *
FROM ai_usage()
)
TO 's3://support-prod/ai/usage'
(
FORMAT parquet,
COMPRESSION zstd,
PARTITION_BY (run_id),
OVERWRITE_OR_IGNORE true
);
COPY (
SELECT getvariable('run_id') AS run_id, now() AS captured_at, *
FROM ai_usage_totals()
)
TO 's3://support-prod/ai/usage_totals'
(
FORMAT parquet,
PARTITION_BY (run_id),
OVERWRITE_OR_IGNORE true
);
After the run is durably captured, clear the in-process usage buffer if the same DuckDB process will run another job:
SELECT * FROM ai_clear_usage();
Resume after a process restart
The temporary tables above live only for the current connection. For a local, single-writer job that skips previously successful rows after reopening DuckDB, use the resumable enrichment example.
Related cookbooks
- Enrich rows from Postgres or MySQL
- Write audited AI outputs to lakehouse tables
- Monitor AI usage, failures, and cost
- Normalize messy documents