What you'll build: a SIEM that keeps petabytes of raw capture cheap in S3, tracks incidents in SQL, catches signature-evading malware by behavioral similarity in the vector pillar, and triages each incident with an Ignite model — writing the verdict back to SQL. One DataK3 bucket, no ETL.
Why it matters. A signature SIEM fights two losing battles this design wins. Cost: indexing petabytes of capture is ruinous, but raw capture sits cold and cheap in S3. Evasion: polymorphic malware changes its signature every run but not its behavior, so a behavioral-similarity search catches the variants a rule DB misses. And instead of a human reading every alert, an Ignite model grades each one — threat, blast radius, priority, next action — so analysts spend their hours on the P1s, not the queue.
What you'll learn:
- How a
VECTOR(2048)column embedded withjina-embeddings-v4turns behavioral logs into searchable meaning. - The alert → evidence → triage → correlate loop across all three pillars plus a model.
- How to triage one incident by prompting an agent, or a firehose of alerts with an Ignite batch.
- 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 SOC analyst drowns in a signature SIEM's queue: polymorphic malware rotates its signature every run, so rule-DB alerts fire late, in isolation, and never connect the three hosts running the same C2 beacon under three different hashes — while the raw capture needed to confirm it is either priced out of retention or scattered across a separate object store. The payoff of this build is that the analyst opens one alert and immediately sees the fleet-wide behavioral cluster, a link to the exact raw PCAP, and a model-graded verdict (threat type, blast radius, P1/P2/P3), so their hours go to the real P1s instead of triaging noise by hand.
Prerequisites
- A DODIL organization, and the
dodilCLI authenticated (dodil auth login) or the DODIL MCP connected to your agent. - A sample capture/log file to ingest.
export BUCKET=siem— one bucket is the whole system's data plane.
Scenario
An automated security bot notices an alert in the SQL incident table. It extracts the raw logs from S3, runs a vector search for any other host showing semantically identical (but rule-evading) behavior, has an Ignite model triage the cluster (threat, blast radius, priority, action), and correlates the linked incidents back in SQL — catching obfuscated or polymorphic malware that signature rules miss, and grading it so analysts hit the P1s first.
One bucket, three pillars
DataK3 is one bucket: objects (S3) plus one HTAP tables engine that answers over the same rows by
content (SQL, DuckDB dialect) and by meaning (Vector, a VECTOR(dim) column). There is no ETL
and no engine to enable — the bucket is ready the moment you create it, and any Postgres/pgvector client
can point straight at it (dodil data connect).
| Pillar | Holds | Example keys / columns |
|---|---|---|
| S3 (object store) | Raw PCAPs (binary evidence) + extracted behavioral logs (text). | pcap/…/14-22-09.pcap · logs/…/14-22-09.txt |
| Vector | jina-embeddings-v4 embeddings of the behavioral log text for similarity-based anomaly detection. | incidents.embedding VECTOR(2048) |
| SQL (table) | Incident tickets + the model's triage verdict. | incidents(id, host, severity, threat_type, priority, blast_radius, related_to) |
Step 1 — Create the bucket
Create a DataK3 bucket called siem for raw captures, anomaly vectors, and incidents.
data_bucket_createCreated bucket siem. Its objects (S3) and HTAP tables engine — SQL + vector over the same rows — are ready immediately; no engine to enable.
export BUCKET=siem
dodil data bucket create "$BUCKET" --description "Raw captures + anomaly vectors + incidents"That's it — the bucket carries both the object store and the tables engine, so SQL tables and vector columns are ready the moment it exists. There is nothing else to provision before Step 2.
Step 2 — Choose the behavioral-indexing path
The vector pillar is not a separate store — it's a VECTOR(2048) column living right on the incidents
table, one embedding per behavioral log. We embed the log text with jina-embeddings-v4 (2048-dim);
similarity search (dodil data vsearch) then ranks hosts by behavior, catching the variants a signature
DB misses. Because the vector sits on the incident row, a look-alike hit is the correlated incident — no
join back to a collection.
That is the path this tutorial builds. If instead your logs arrive as a stream of files, the
document-rag recipe wires an auto-embed pipeline (embed model + ingest rule) so anything you land
under a prefix embeds on its own into a chunks table:
Prefer auto-embed from landed files? Show the recipe path
On siem, install the document-rag recipe scoped to the logs/ folder so log text lands and embeds automatically into a searchable table.
data_recipe_installInstalled document-rag on siem: provisioned the jina-embeddings-v4 embed pipeline, a logs_chunks table with a VECTOR column, and an ingest rule scoped to logs/. New log files there embed automatically.
dodil data recipe install document-rag -b "$BUCKET" --folder logs/
dodil data recipe show document-ragThe recipe is the "files land, vectors appear" convenience; the manual VECTOR(2048) column below is the
explicit, keyed-to-the-incident path — either way the search is the same dodil data vsearch.
Step 3 — Create the incidents table
One table is the transactional core and the vector index: SQL columns for the ticket, a log_text
column for the behavioral summary, an embedding VECTOR(2048) column for its jina-embeddings-v4 vector,
and the triage columns the model fills later. It's merge-keyed on id — a PRIMARY KEY is required, so
every write is an idempotent upsert.
Create an incidents table in siem merge-keyed on id with host, severity, opened_at, status, payload_key, log_text, an embedding VECTOR(2048) column, and triage fields (threat_type, priority, recommended_action, blast_radius, related_to), then open a high-severity incident for host-17.
data_table_create→data_table_upsertCreated the incidents table keyed on id (VECTOR(2048) embedding column + the triage columns the model fills later) and opened incident 4471 (host-17, high, open) pointing at its raw capture key.
dodil data table create incidents -b "$BUCKET" \
--columns-json '[
{"name":"id","type":"long","nullable":false},
{"name":"host","type":"string","nullable":true},
{"name":"severity","type":"string","nullable":true},
{"name":"opened_at","type":"string","nullable":true},
{"name":"status","type":"string","nullable":true},
{"name":"payload_key","type":"string","nullable":true},
{"name":"log_text","type":"string","nullable":true},
{"name":"embedding","type":"VECTOR(2048)","nullable":true},
{"name":"threat_type","type":"string","nullable":true},
{"name":"priority","type":"string","nullable":true},
{"name":"recommended_action","type":"string","nullable":true},
{"name":"blast_radius","type":"int","nullable":true},
{"name":"related_to","type":"long","nullable":true}
]' \
--merge-key id
dodil data table upsert incidents -b "$BUCKET" \
--row '{"id":4471,"host":"host-17","severity":"high","opened_at":"2026-06-30T14:22:11Z","status":"open","payload_key":"pcap/2026/06/30/host-17/14-22-09.pcap"}'TIP
Never write an empty string or null into the key column. A keyed upsert reads ""/null in id
back as null and silently drops the row on the next read. Always give id a real value (here, 4471).
Step 4 — Land the capture, its behavioral log, and the embedding
Two objects per capture, one embedded row. The raw PCAP goes to pcap/ — cold, immutable, cheap
binary evidence (it's bytes, so it isn't embedded). The extracted behavioral log — a text summary of
the flows (Zeek/Suricata notes: connections, DNS queries, beacon timing) — goes to logs/ as cold
evidence and is embedded with jina-embeddings-v4 into the incident's embedding column. That split is
what makes similarity search work on behavior, not bytes. A date/host key prefix keeps time-window
retrieval a cheap prefix object list.
Upload the raw capture 14-22-09.pcap to siem under pcap/2026/06/30/host-17/, its behavioral log 14-22-09.txt under logs/2026/06/30/host-17/, then embed the log text with jina-embeddings-v4 and upsert it onto incident 4471's log_text + embedding columns.
data_object_create→data_table_upsertUploaded the PCAP (cold evidence) and the behavioral log, embedded the log with jina-embeddings-v4, and merged log_text + the VECTOR(2048) embedding onto incident 4471 — ready for similarity search.
# (a) the raw capture — cold, immutable evidence (binary; NOT embedded)
dodil data object create ./14-22-09.pcap \
-b "$BUCKET" --key pcap/2026/06/30/host-17/14-22-09.pcap
# (b) the extracted behavioral log — TEXT (Zeek/Suricata flow notes)
dodil data object create ./14-22-09.txt \
-b "$BUCKET" --key logs/2026/06/30/host-17/14-22-09.txt
# (c) embed the behavioral log with jina-embeddings-v4 and merge it onto the incident row.
# (partial-column --merge write-back keeps the other columns intact)
LOG='outbound TCP beacon every ~57s with 12% jitter to rotating domains a3f9.dyndns-c2.net then b7c1.dyndns-c2.net; small 148-byte POST bodies, TLS SNI mismatch, no DNS cache reuse'
VEC=$(dodil ignite models embed jina-embeddings-v4 --input "$LOG" -o json \
| python3 -c 'import sys,json;print("["+",".join(f"{x:.6f}" for x in json.load(sys.stdin)["data"]["data"][0]["embedding"])+"]")')
dodil data table upsert incidents -b "$BUCKET" --merge \
--row "{\"id\":4471,\"log_text\":\"$LOG\",\"embedding\":\"$VEC\"}"Seed a few more embedded rows the same way — the look-alikes host-24/host-09 and some benign traffic
(web browsing, a nightly backup) — so the search has a fleet to rank against.
Step 5 — The threat-hunt loop
SQL surfaces the alert and its capture key, S3 serves the raw evidence, and Vector finds the same
behavior elsewhere. dodil data vsearch embeds the query text with the same jina-embeddings-v4
model, then ranks by cosine distance (lower = more similar).
In siem, find the newest open high-severity incident, give me a link to its raw capture, and vector-search the incidents' behavioral embeddings for other hosts showing semantically identical beaconing.
data_table_query→data_object_url→data_vsearchNewest open high-sev: #4471 on host-17. Raw capture (valid 1h): https://object.uk-lon-1.dodil.io/siem/pcap/2026/06/30/host-17/14-22-09.pcap?X-K3-Token=…&X-K3-Expires=… Nearest by behavior: #4473 host-09 (0.28), #4471 host-17 (0.33), #4472 host-24 (0.43) — same jittered C2 beaconing, different signatures — while benign backup/browsing sit far off at 0.54/0.55.
# (a) SQL: newest open high-severity alert + its raw-capture key
dodil data sql -b "$BUCKET" \
"SELECT id, host, payload_key FROM incidents
WHERE status='open' AND severity='high' ORDER BY opened_at DESC LIMIT 1"
# (b) S3: a pre-signed link to the exact raw capture
dodil data object url pcap/2026/06/30/host-17/14-22-09.pcap -b "$BUCKET" --expires 3600
# (c) Vector: find semantically identical behavior elsewhere in the fleet
dodil data vsearch -b "$BUCKET" -t incidents --column embedding \
--text "beaconing to rotating C2 domains with jittered intervals" \
--model jina-embeddings-v4 --metric cosine --top-k 5The three beaconing hosts cluster at the top (cosine distance ~0.28–0.43); the benign backup and browsing rows fall away at ~0.54–0.55. That gap is the anomaly signal — no rule matched, yet the behavior grouped itself.
Step 6 — Triage with a model, then correlate back to SQL
The hunt found the neighbors; now close the loop. Have an Ignite model judge the incident — threat type, blast radius, priority, next action — then write the verdict and the linked hosts back to SQL. Two ways, by volume:
- One incident, mid-hunt → ask your agent: it calls the model over MCP and updates the rows.
- The alert firehose → an Ignite Python function that HTTP-requests the Models endpoint and triages at scale — the same batch shape as the leads and trading warehouses.
Triage incident #4471 in siem from its behavioral log and the look-alike hosts host-24/host-09 using kimi-k2.6 — set threat_type, severity, priority, blast_radius, recommended_action. Update #4471 with the verdict, and open a linked incident for a look-alike host (related_to=4471).
ignite_models_chat→data_table_upsertTriage: C2 Beaconing (signature-evading), severity high, P1, blast_radius 3, action 'isolate hosts + block rotating C2 domains'. Updated #4471 with the verdict, and opened #4472 (host-24) linked via related_to=4471. The queue now reads as one P1 cluster, not three unlabelled alerts.
# (a) model: triage the incident from its behavioral log + the look-alike hosts
dodil ignite models chat kimi-k2.6 \
--system 'You are a SOC triage analyst. Return ONLY JSON with keys threat_type, severity (low|medium|high|critical), blast_radius (int), priority (P1|P2|P3), recommended_action (<=12 words), summary (<=25 words).' \
--message 'Incident #4471 host-17: jittered C2 beaconing to rotating domains. Look-alikes: host-24 (0.43), host-09 (0.28) — same behavior, different signatures.'
# (b) SQL: write the verdict onto the incident. The table has a VECTOR column, so use
# the partial-column upsert --merge (a plain UPDATE can trip on the vector PK).
dodil data table upsert incidents -b "$BUCKET" --merge \
--row '{"id":4471,"threat_type":"c2_beaconing","severity":"critical","priority":"P1","blast_radius":3,"recommended_action":"isolate hosts + block rotating C2 domains"}'
# (c) SQL: open a linked incident for a look-alike host, pointing back at the origin
dodil data table upsert incidents -b "$BUCKET" --merge \
--row '{"id":4472,"host":"host-24","severity":"high","status":"open","threat_type":"c2_beaconing","related_to":4471}'For the alert firehose, wrap the same three moves — hunt neighbors → model triage → write back — in
an Ignite function and fan it out (with a service account granted ignite.developer + k3.editor, just
like the leads classifier):
# triage.py (essence) — an Ignite function; triage a batch of open incidents.
# Same OIDC token -> OpenAI-compatible Models endpoint as the leads/trading batches.
client = OpenAI(base_url="https://api.dodil.io/v1", api_key=dodil_token())
def handle(shard): # shard payload: {"incident_ids": [4471, 4472, …]}
for iid in shard["incident_ids"]:
inc = sql_one(f"SELECT host, log_text FROM incidents WHERE id={iid}")
peers = vsearch("incidents", "embedding", inc["log_text"], top_k=20) # fleet look-alikes
v = triage(inc["log_text"], peers) # model -> verdict JSON
upsert_merge("incidents", {"id": iid, **v, "blast_radius": len(peers)})
for p in peers:
open_linked_incident(p, related_to=iid) # correlate back to SQL
return {"triaged": len(shard["incident_ids"])}NOTE
Single vs. firehose — same right-cost logic. For an ad-hoc hunt the agent path is instant; for a
stream of alerts, deploy triage.py on Ignite and fan it out (request-invoked, scale-to-zero, with a
small always-warm pool if the alert rate is steady). Run the bulk on a cheap model and escalate the
severity: critical ones to kimi-k2.6 — spend the good model only where it changes the response.
DataK3 vs. the three-system stack
| Concern | Three-system stack | DataK3 |
|---|---|---|
| Petabyte raw retention | Object store separate from analytics; lifecycle drift | S3 tier in the same bucket as incidents + vectors |
| Signature-evading malware | Rigid rule DB; misses polymorphic variants | VECTOR(2048) similarity finds "looks like this" across the fleet |
| Alert → raw evidence → correlation | Three systems (Postgres + Pinecone + object store), three auth contexts | One bucket: SQL alert → S3 capture → vector neighbors, one login token |
Troubleshooting
- Embed the log text, not the PCAP bytes. The
embeddingcolumn holdsjina-embeddings-v4vectors of the behavioral logs; the raw captures underpcap/stay as cold binary evidence. To embed raw payload vectors directly, compute them yourself and upsert them into the same column. - Same model on both sides.
data vsearch --textmust use the same--model jina-embeddings-v4that embedded the stored vectors, or the distances are meaningless. - Write-backs on a VECTOR table. A plain
data table update/ SQLUPDATEcan fail withpoint key missing PK columnwhen the table carries a vector. Usedata table upsert --merge(partial-column) for the verdict write-back, as in Step 6. - Threshold tuning. The cosine-distance cut-off is your false-positive/false-negative dial; tune it against labeled incidents before trusting auto-correlation.
Test
Verify the full loop landed — bucket, embedded logs, the raw evidence, and the triaged incident cluster in SQL. Reads are read-your-writes, so the merges and linked inserts from Step 6 are visible immediately (no compaction step).
export BUCKET=siem
# 1. Bucket exists
dodil data bucket get "$BUCKET"
# expect: Name: siem
# 2. The behavioral log and raw capture both landed under their prefixes
dodil data object list -b "$BUCKET"
# expect: logs/2026/06/30/host-17/14-22-09.txt and pcap/2026/06/30/host-17/14-22-09.pcap
# 3. Vector similarity finds the fleet look-alikes for the beaconing behavior
dodil data vsearch -b "$BUCKET" -t incidents --column embedding \
--text "beaconing to rotating C2 domains with jittered intervals" \
--model jina-embeddings-v4 --metric cosine --top-k 5
# expect: the beaconing hosts (4471/4472/4473) rank ahead of benign traffic (distance ~0.28–0.43 vs ~0.55)
# 4. Incident #4471 carries the model's triage verdict
dodil data sql -b "$BUCKET" \
"SELECT id, threat_type, priority, blast_radius FROM incidents WHERE id=4471"
# expect: one row — threat_type=c2_beaconing, priority=P1, blast_radius=3
# 5. The look-alike host is opened as a linked incident, correlated back to the origin
dodil data sql -b "$BUCKET" \
"SELECT id, host, related_to FROM incidents WHERE related_to=4471"
# expect: >=1 row — e.g. id=4472 host-24 related_to=4471One-shot
Hand this to an agent with the dodil MCP connected to reproduce the whole tutorial end-to-end:
Build a one-bucket SIEM on DataK3 and prove it catches signature-evading malware:
1. Create a DataK3 bucket `siem` (data_bucket_create).
2. Create an `incidents` table in `siem` merge-keyed on `id` with columns id (long),
host, severity, opened_at, status, payload_key, log_text, embedding VECTOR(2048),
threat_type, priority, recommended_action, blast_radius (int), related_to (long) —
then open incident 4471 for host-17, high, open, payload_key
pcap/2026/06/30/host-17/14-22-09.pcap (data_table_create, data_table_upsert).
3. Upload the raw capture to pcap/2026/06/30/host-17/14-22-09.pcap and its behavioral
log to logs/2026/06/30/host-17/14-22-09.txt; embed the log text with
jina-embeddings-v4 and merge log_text + the embedding onto incident 4471. Seed a
few more embedded rows (the look-alikes host-24/host-09 plus some benign traffic)
(data_object_create, ignite_models_embed, data_table_upsert).
4. Run the hunt loop: SQL for the newest open high-severity incident, a pre-signed URL
for its capture, and data vsearch over incidents.embedding (model jina-embeddings-v4)
for other hosts showing the same jittered C2 beaconing (data_table_query,
data_object_url, data_vsearch).
5. Triage #4471 with kimi-k2.6 (threat_type, severity, priority, blast_radius,
recommended_action), merge the verdict onto #4471, and open a linked incident for a
look-alike host with related_to=4471 (ignite_models_chat, data_table_upsert).
Finish with SQL over incidents where id=4471 and where related_to=4471 to confirm the
triaged P1 cluster reads as one correlated group.Ship it — wrap the loop in a public app
The hunt loop and its model triage step run interactively here. To put them behind a URL your SOC tools — or an agent — can call, wrap the loop 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
One DataK3 bucket takes a security bot from a SQL alert to raw S3 evidence to fleet-wide behavioral
neighbors — and a single VECTOR(2048) column, embedded with jina-embeddings-v4, stood up the anomaly
search with no separate vector store, CLI or agent.
Next steps:
- Physics & Astronomy — raw firehose + similarity, scientific edition.
- AI Research Agent — the narrow → rank → resolve loop in depth.