What you'll build: an FRB-hunting store that prunes petabytes to an exact calibration cohort with partitioned SQL, ranks candidates by signal-shape similarity over the rendered spectrograms, and serves the raw array — all in one DataK3 bucket.
What you'll learn:
- How partitioned SQL tables prune huge datasets before any similarity work.
- How to embed rendered spectrogram images for shape similarity into a
VECTOR(2048)column withjina-embeddings-v4— and when to let theimage-ragrecipe wire the embed pipeline for you. - How to run every step two ways — by prompting an agent over the MCP, or the
dodilCLI.
NOTE
Connect the DODIL MCP once — see the two-minute setup. With the dodil MCP server in Claude Code, Cursor, or VS Code you
can drive every step by chatting. Each step shows the CLI and an Ask your agent tab.
Problem
A radio astronomer hunting fast radio bursts is drowning: a single dish writes petabytes of raw signal arrays, but only a handful were captured in the exact calibration state that makes a detection trustworthy — right target, right frequency, right dish — and among those, only the ones whose spectrogram shape matches a dispersed transient are worth a human's time. Doing this the usual way means stitching an object store, a warehouse, and a vector DB together by hand and hoping they stay in sync. The payoff of doing it in one DataK3 bucket: partitioned SQL prunes the firehose to the calibration cohort before any similarity work runs, vector search ranks that cohort by signal shape, and a pre-signed link serves the raw array for analysis — so the researcher goes from petabytes to a ranked shortlist of real candidates in three commands, reproducibly.
Prerequisites
- A DODIL organization, and the
dodilCLI authenticated (dodil auth login) or the DODIL MCP connected to your agent. - A raw observation file (e.g.
obs-88123.npy) and its rendered spectrogram image (obs-88123.png).
Scenario
An astrophysicist hunting fast radio bursts (FRBs):
- Writes a SQL query to select data gathered while the dish pointed at Andromeda, calibrated to 1.4 GHz.
- Uses Vector search to find spectrograms whose shape resembles a known FRB.
- Pulls the raw signal array from object storage to analyze the anomaly.
Isolate the signal from the noise by combining exact calibration state with fuzzy signal-shape similarity over huge raw payloads.
How the three pillars map
One DataK3 bucket is objects plus an HTAP tables engine — SQL and Vector answer over the same rows, no ETL and no engine to enable. Here is how the FRB store lands across them:
| Pillar | Holds | Example keys / columns |
|---|---|---|
| Objects | Raw signal arrays (cold) + rendered spectrogram images (provenance). | raw/…/obs-88123.npy · spectrograms/…/obs-88123.png |
| Vector | Embeddings of the rendered spectrograms (jina-embeddings-v4) for shape similarity. | signals.embedding VECTOR(2048) |
| SQL (table) | Strictly relational equipment + calibration state at capture time. | observations(obs_id, target, freq_ghz, dish, calibrated_at, s3_key) |
Step 1 — Create the bucket
Create a DataK3 bucket called blog-physics for raw telemetry, signal vectors, and calibration state.
data_bucket_createCreated bucket blog-physics. The tables engine is ready and an internal object source is wired for ingestion.
export BUCKET=blog-physics
dodil data bucket create "$BUCKET" --description "Raw telemetry + signal vectors + calibration state"The bucket is ready the moment it is created — one bucket is objects and the tables engine, so there is nothing to enable before you write tables.
Step 2 — Model the calibration state
Calibration is strictly relational — and partitioned by dish so the SQL prune is cheap. A
PRIMARY KEY (merge-key) is required: writes are keyed, so re-runs and shard retries upsert
idempotently.
Create an observations table in blog-physics keyed on obs_id, partitioned by dish, with target, freq_ghz, calibrated_at, and s3_key; then upsert the M31/dish-A cohort plus a couple of off-cohort rows.
data_table_create→data_table_upsertCreated observations partitioned by dish, keyed on obs_id, and upserted 6 rows (3 in the M31/dish-A/1.4 GHz cohort, 3 off-cohort).
dodil data table create observations -b "$BUCKET" \
--columns-json '[
{"name":"obs_id","type":"varchar"},
{"name":"target","type":"varchar"},
{"name":"freq_ghz","type":"double"},
{"name":"dish","type":"varchar"},
{"name":"calibrated_at","type":"varchar"},
{"name":"s3_key","type":"varchar"}
]' \
--partition-column dish \
--merge-key obs_id
# the calibration cohort — same target, frequency, dish
dodil data table upsert observations -b "$BUCKET" \
--row '{"obs_id":"obs-88123","target":"M31","freq_ghz":1.4,"dish":"dish-A","calibrated_at":"2026-06-30T02:11:00Z","s3_key":"raw/2026-06-30/dish-A/obs-88123.npy"}' \
--row '{"obs_id":"obs-88140","target":"M31","freq_ghz":1.4,"dish":"dish-A","calibrated_at":"2026-06-30T02:44:00Z","s3_key":"raw/2026-06-30/dish-A/obs-88140.npy"}' \
--row '{"obs_id":"obs-88155","target":"M31","freq_ghz":1.4,"dish":"dish-A","calibrated_at":"2026-06-30T03:02:00Z","s3_key":"raw/2026-06-30/dish-A/obs-88155.npy"}'
# off-cohort rows the prune must reject (wrong freq / wrong dish / wrong target)
dodil data table upsert observations -b "$BUCKET" \
--row '{"obs_id":"obs-88200","target":"M31","freq_ghz":0.8,"dish":"dish-A","calibrated_at":"2026-06-30T04:00:00Z","s3_key":"raw/2026-06-30/dish-A/obs-88200.npy"}' \
--row '{"obs_id":"obs-88300","target":"M31","freq_ghz":1.4,"dish":"dish-C","calibrated_at":"2026-06-30T05:00:00Z","s3_key":"raw/2026-06-30/dish-C/obs-88300.npy"}' \
--row '{"obs_id":"obs-77001","target":"Crab","freq_ghz":1.4,"dish":"dish-B","calibrated_at":"2026-06-29T22:00:00Z","s3_key":"raw/2026-06-29/dish-B/obs-77001.npy"}'TIP
Never write an empty string or null into a key column. Both read back as null and silently drop
the row on the next read. Give every obs_id a real, non-empty value.
Step 3 — Index the spectrograms for shape similarity
Similar signals look similar: an FRB's dispersed sweep has a characteristic shape in the
time–frequency spectrogram. So render each observation to a spectrogram image, embed it with
jina-embeddings-v4 (2048-dim, multimodal), and land the vector in a VECTOR(2048) column on a
signals table. There is no separate "vector store" — a vector is just a column over the same rows.
In blog-physics create a signals table keyed on obs_id with morphology, png_key, and a VECTOR(2048) embedding column; embed each rendered spectrogram with jina-embeddings-v4 and upsert one row per observation.
data_table_create→ignite_models_embed→data_table_upsertCreated signals with a VECTOR(2048) column; embedded 3 spectrograms with jina-embeddings-v4 and upserted them (one row per call).
# the vector lives on a normal table — VECTOR(2048) matches jina-embeddings-v4
dodil data table create signals -b "$BUCKET" \
--columns-json '[
{"name":"obs_id","type":"varchar"},
{"name":"morphology","type":"varchar"},
{"name":"png_key","type":"varchar"},
{"name":"embedding","type":"VECTOR(2048)"}
]' \
--merge-key obs_id
# embed each rendered spectrogram, then upsert ONE row per call
# (a single call carrying many 2048-dim vectors can exceed the gRPC frame limit)
EMB=$(dodil ignite models embed jina-embeddings-v4 \
--input "dispersed single-pulse broadband sweep, steep negative drift, high dispersion measure" \
--output json | jq -c '.data.data[0].embedding')
dodil data table upsert signals -b "$BUCKET" \
--row "$(jq -nc --argjson e "$EMB" \
'{obs_id:"obs-88123", morphology:"dispersed broadband FRB sweep, high DM", png_key:"spectrograms/2026-06-30/dish-A/obs-88123.png", embedding:$e}')"
# …repeat for obs-88140 (RFI comb) and obs-88155 (weak scattered pulse)The embedding column is what similarity search ranks over. Because it lives on the same table as
obs_id, a hit joins straight back to the calibration row and its s3_key — no cross-system lookup.
Alternative: let image-rag wire the embed pipeline for you
Rather than embedding by hand, the image-rag recipe provisions an embed pipeline + an ingest rule so
that spectrograms dropped under a folder are embedded on upload — one command instead of the
embed-then-upsert loop:
dodil data recipe install image-rag -b "$BUCKET"
# then upload rendered spectrograms under the scoped folder and they embed automaticallyThe manual VECTOR(2048) path above is the right choice when you want to embed with your own
featurization (dedispersion, DM–time, matched filtering) and control exactly which vector lands per
observation; the recipe is the right choice when a stream of images should just embed themselves.
Step 4 — Land the raw array + its rendered spectrogram
Two objects per observation. The raw signal array goes to raw/ — cold, immutable, the analysis
truth (a .npy; binary, not embedded). The rendered spectrogram image goes to spectrograms/ as
provenance for the vector you embedded in Step 3.
Upload the raw array obs-88123.npy to blog-physics under raw/2026-06-30/dish-A/, and its rendered spectrogram obs-88123.png under spectrograms/2026-06-30/dish-A/.
data_object_createUploaded the raw array to raw/2026-06-30/dish-A/obs-88123.npy (cold) and the rendered spectrogram to spectrograms/2026-06-30/dish-A/obs-88123.png.
# raw signal array — cold, immutable analysis truth (binary; NOT embedded)
dodil data object create ./obs-88123.npy \
-b "$BUCKET" --key raw/2026-06-30/dish-A/obs-88123.npy
# rendered spectrogram image — provenance for the embedded vector
dodil data object create ./obs-88123.png \
-b "$BUCKET" --key spectrograms/2026-06-30/dish-A/obs-88123.pngTIP
To render spectrograms or feed raw arrays to a compute job, mount the bucket:
dodil data mount "$BUCKET" /mnt/astro — the arrays appear as local files for NumPy/analysis code.
Step 5 — The FRB hunt (prune → rank → resolve)
Partitioned SQL cuts petabytes to the exact cohort before any similarity work; Vector ranks that cohort by shape; a pre-signed link serves the raw truth. Reads are read-your-writes, so the rows and objects you just wrote are visible immediately — no compaction step.
In blog-physics, select M31 observations from dish-A at 1.4 GHz, rank the spectrograms by similarity to a high-DM broadband transient, and give me a link to the top candidate's raw array.
data_table_query→data_vsearch→data_object_urlCohort: 3 M31/dish-A/1.4 GHz observations. Closest FRB-shaped spectrogram: obs-88123 (cosine distance 0.070), then obs-88155 (0.223), obs-88140 (0.369). Raw array (valid 1h): https://object.uk-lon-1.dodil.io/blog-physics/raw/2026-06-30/dish-A/obs-88123.npy?X-K3-Token=…
# (a) SQL: partition the search space by exact calibration state
dodil data table query \
"SELECT obs_id, s3_key FROM observations
WHERE target='M31' AND freq_ghz=1.4 AND dish='dish-A'
ORDER BY obs_id" \
-b "$BUCKET"
# (b) Vector: rank spectrograms by shape — jina is multimodal, so a text description of the
# morphology matches the image embeddings (cosine distance; lower = closer)
dodil data vsearch -b "$BUCKET" --table signals --column embedding \
--text "dispersed single-pulse broadband sweep, high dispersion measure, FRB-like quadratic delay" \
--model jina-embeddings-v4 --metric cosine --top-k 5
# (c) A pre-signed link to the raw array to analyze
dodil data object url raw/2026-06-30/dish-A/obs-88123.npy -b "$BUCKET" --expires 3600The prune returns exactly the three M31/dish-A/1.4 GHz rows — obs-88200 (0.8 GHz), obs-88300
(dish-C) and obs-77001 (Crab) never reach the ranker. Then Vector orders that cohort by shape:
obs-88123 (the dispersed sweep) is closest, and the flat RFI comb obs-88140 is furthest — the exact
triage a human wants.
DataK3 vs. the three-system stack
| Concern | Three-system stack | DataK3 |
|---|---|---|
| Petabyte raw + exact calibration + similarity | Object store + warehouse + vector DB, kept in sync by hand | One bucket; partitioned SQL prunes before Vector ranks |
| "Same calibration state AND similar signal" | Cross-system join, easy to get subtly wrong | SQL filter and the VECTOR(2048) column live on the same rows |
| Feeding raw arrays to compute | Bespoke staging from object store | Mount the bucket (FUSE/NFS) into the analysis job |
| Drop-in clients | Three sets of drivers + creds | dodil data connect "$BUCKET" prints one Postgres/pgvector endpoint (DB = bucket) |
Troubleshooting
- Rendered image vs. rigorous featurization. Embedding the spectrogram image description
(
jina-embeddings-v4) catches shape similarity and runs today; for dedispersion / DM–time features, compute your own vectors offline and upsert them into the sameVECTOR(2048)column — the caller fully controls what lands per observation. - Upsert VECTOR rows one per call. Batching many 2048-dim vectors in a single upsert can hit a gRPC "first frame too large" limit. Embed, then upsert row-by-row (as in Step 3).
- Maintenance at scale. Huge partitioned tables benefit from periodic
dodil data table optimize … --z-order-column …;dodil data table compactis optional performance maintenance only (reads are already read-your-writes). Partition around your dominant query (target/frequency/dish). - Mount needs privileges. FUSE/NFS mount starts a daemon and may require elevated permissions.
Test
Verify the outcome end to end against the real blog-physics bucket. Reads are read-your-writes by
default, so the upserted rows and uploaded objects are visible immediately — no compaction needed.
export BUCKET=blog-physics
# 1. The bucket exists
dodil data bucket get "$BUCKET"
# expect: bucket "blog-physics" returned (not a not-found error)
# 2. observations is partitioned by dish and keyed on obs_id
dodil data table describe observations -b "$BUCKET"
# expect: columns obs_id/target/freq_ghz/dish/calibrated_at/s3_key; partition column = dish; merge key = obs_id
# 3. The calibration cohort query resolves the three M31/dish-A/1.4 GHz rows
dodil data table query \
"SELECT obs_id, s3_key FROM observations
WHERE target='M31' AND freq_ghz=1.4 AND dish='dish-A' ORDER BY obs_id" \
-b "$BUCKET"
# expect: obs-88123, obs-88140, obs-88155 (off-cohort rows excluded)
# 4. The signals table carries a VECTOR(2048) embedding column
dodil data table describe signals -b "$BUCKET"
# expect: embedding column typed VECTOR(2048); merge key = obs_id
# 5. Both objects landed — raw array and rendered spectrogram
dodil data object show raw/2026-06-30/dish-A/obs-88123.npy -b "$BUCKET"
dodil data object show spectrograms/2026-06-30/dish-A/obs-88123.png -b "$BUCKET"
# expect: object metadata (size > 0), not a not-found error
# 6. Shape search ranks the FRB-shaped spectrogram first
dodil data vsearch -b "$BUCKET" --table signals --column embedding \
--text "dispersed single-pulse broadband sweep, high dispersion measure" \
--model jina-embeddings-v4 --metric cosine --top-k 5
# expect: obs-88123 closest (cosine distance ≈ 0.07), obs-88140 (RFI) furthest
# 7. A pre-signed URL for the raw array is issued
dodil data object url raw/2026-06-30/dish-A/obs-88123.npy -b "$BUCKET" --expires 3600
# expect: an https URL on object.uk-lon-1.dodil.io valid for ~1hOne-shot
Hand this to an agent connected to the DODIL MCP to reproduce the whole tutorial end to end:
Using the DODIL MCP, build an FRB-hunting store in one DataK3 bucket:
1. Create a DataK3 bucket called `blog-physics` for raw telemetry, signal vectors, and
calibration state (data_bucket_create).
2. Create an `observations` table in `blog-physics` keyed on obs_id, partitioned by dish, with
columns obs_id (varchar), target (varchar), freq_ghz (double), dish (varchar),
calibrated_at (varchar), s3_key (varchar) — merge-key obs_id, partition-column dish
(data_table_create). Upsert the M31/dish-A/1.4 GHz cohort (obs-88123, obs-88140, obs-88155)
plus a few off-cohort rows (data_table_upsert).
3. Create a `signals` table keyed on obs_id with morphology, png_key, and an embedding
VECTOR(2048) column (data_table_create). Embed each rendered spectrogram's morphology with
jina-embeddings-v4 (ignite_models_embed) and upsert one row per observation (data_table_upsert).
4. Upload the raw array to raw/2026-06-30/dish-A/obs-88123.npy (cold) and its rendered
spectrogram to spectrograms/2026-06-30/dish-A/obs-88123.png (data_object_create).
5. Run the FRB hunt: query M31 / dish-A / 1.4 GHz observations (data_table_query), rank the
signals embeddings against "dispersed single-pulse broadband sweep, high dispersion measure"
with jina-embeddings-v4, cosine, top-k 5 (data_vsearch), and return a pre-signed URL for the
top candidate's raw array valid for one hour (data_object_url).
Then verify with the ## Test section: the bucket exists, observations is partitioned by dish and
keyed on obs_id, the cohort query returns the three M31/dish-A rows, signals carries a VECTOR(2048)
column, both objects landed, and the shape search ranks obs-88123 closest.Ship it — wrap the hunt in a public app
The prune → rank → resolve hunt runs interactively here. To put it behind a URL your pipeline — or an agent — can call, wrap the hunt in an Ignite app and ship it the way every DODIL app ships: DODIL git → CI → a scanned image in the registry → a public endpoint, with versioning and one-command rollback. That full lifecycle is its own tutorial: Ship a DODIL App.
Conclusion
Partitioned SQL prunes the firehose to the exact calibration cohort, an embedded VECTOR(2048) column
ranks candidates by signal shape, and a pre-signed link serves the raw array — reproducible science in
one DataK3 bucket, CLI or agent.
Next steps:
- AI SIEM — the same firehose-plus-similarity shape for security telemetry.
- Multimodal E-Commerce — raw payload + metadata + similarity, commercial edition.