What you'll build: a product engine where one request finds a visually similar item, pulls its manual, and checks live stock — across media, embeddings, and transactional numbers in one DataK3 bucket. And because bare product photos aren't a catalog, a model enriches each item into structured attributes (title, category, material, style, tags) the search returns and filters on.
Why it matters. Catalog data-entry is the quiet, expensive bottleneck in retail — humans typing titles, categories, and attributes for every SKU, inconsistently. Feed the product listing to a model and it writes those attributes for you, at catalog scale — and better attributes mean better search, which means conversion. A listing in; a searchable, in-stock product card out.
What you'll learn:
- How a
VECTOR(2048)column plusjina-embeddings-v4stands up visual search — no vector store, no second system. - How to enrich a catalog with a model — one SKU by prompting an agent, or the whole catalog with
an Ignite batch — into a
productstable the search joins against. - How to keep live inventory next to the catalog with a merge-keyed upsert table.
- 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
You run catalog and search for an online retailer. Product photos and listings arrive faster than anyone can type titles, categories, and attributes for them, so SKUs go live with thin or inconsistent metadata — and thin metadata means bad search, which means a shopper who can't find the lamp they're holding walks. On top of that, "find me a similar item" and "is it actually in stock" live in two different systems (a vector DB and a warehouse DB) that your app has to stitch together at request time.
This tutorial gives you one bucket that closes both gaps: a model reads each listing and writes the
catalog attributes for you at scale, a VECTOR(2048) column makes the catalog searchable by look
(text or photo, one embedding space), and a merge-keyed inventory table joins in so every match
comes back as a real, in-stock product card — not a bare SKU. Better attributes and one-hop stock
checks are the difference between a browse and a conversion.
Prerequisites
- A DODIL organization, and the
dodilCLI authenticated (dodil auth login) or the DODIL MCP connected to your agent. - Sample product media — an image (
hero.jpg) and a PDF manual. (Bring any product photo + any PDF; the tutorial doesn't ship these — the point is the pipeline, not the specific files.)
Scenario
A shopper uploads a photo of a broken vintage lamp. The engine should find visually similar catalog items (Vector), pull the setup manual (objects), and check nearest-warehouse stock (SQL) — without waiting for three systems to reconcile.
How the three pillars map
DataK3 is one bucket — objects plus one HTAP tables engine — with SQL, Vector, and Graph over the same rows. This build uses two pillars and the object store:
| Pillar | Holds | Example keys / columns |
|---|---|---|
| Objects | Product images, catalog assets, PDF manuals. | products/lamp-042/hero.jpg, manuals/lamp-042.pdf |
| Vector | Image/text embeddings (jina-embeddings-v4, 2048-dim, multimodal) — text or a photo can query. | catalog_images(sku, image_key, embedding VECTOR(2048)) |
| SQL (tables) | Model-enriched catalog + live stock. | products(sku, title, category, style, tags) · inventory(sku, warehouse, qty, price_cents) |
Step 1 — Create the bucket
Create a DataK3 bucket called shop-catalog for product media, search, and inventory.
data_bucket_createCreated bucket shop-catalog. The tables engine is implicit per bucket, and an internal object source is wired for ingestion — no engine to enable.
export BUCKET=shop-catalog
dodil data bucket create "$BUCKET" --description "Catalog media + product search + inventory"Step 2 — Stand up the visual index
A vector in DataK3 is a VECTOR(<dim>) column on an ordinary table — there is no separate vector
store to provision. Create a catalog_images table keyed on sku with an embedding VECTOR(2048)
column; jina-embeddings-v4 is a multimodal model, so image and text embeddings land in the same
2048-dim space, and either can query it.
In shop-catalog, create a catalog_images table keyed on sku with image_key and a VECTOR(2048) embedding column for visual search.
data_table_createCreated catalog_images keyed on sku with an embedding VECTOR(2048) column — ready for jina-embeddings-v4 image/text embeddings and pgvector KNN.
dodil data table create catalog_images -b "$BUCKET" \
--columns-json '[
{"name":"sku","type":"varchar"},
{"name":"image_key","type":"varchar"},
{"name":"embedding","type":"VECTOR(2048)"}
]' \
--merge-key skuPrefer a turnkey auto-embed pipeline? Use the image-rag recipe
The image-rag recipe provisions the embed pipeline and an ingest rule atomically, so every image
dropped under products/ embeds automatically — no manual embed step. Roll your own VECTOR(2048)
column (above) when you want exact control over what/when you embed; use the recipe when you just want
images auto-indexed as they land.
On shop-catalog, install the image-rag recipe with prefix catalog scoped to the products/ folder so product images get indexed for visual search.
data_recipe_installInstalled image-rag on shop-catalog: created catalog_images (VECTOR embeddings via jina-embeddings-v4) and an ingest rule scoped to products/ (*.jpg/*.png/*.webp). Images under products/ embed automatically.
dodil data recipe install image-rag -b "$BUCKET" --name-prefix catalog --folder products/
# Preview what it provisions first (optional)
dodil data recipe show image-ragStep 3 — Create the inventory table
Live stock lives in a merge-keyed table so updates upsert one row per SKU. A PRIMARY KEY
(--merge-key) is required — writes are keyed, so re-runs and shard retries upsert idempotently.
Create an inventory table in shop-catalog keyed on sku with warehouse, qty, price_cents, and updated_at.
data_table_createCreated the inventory table keyed on sku — upserts replace one row per SKU rather than duplicating stock rows.
dodil data table create inventory -b "$BUCKET" \
--columns-json '[
{"name":"sku","type":"varchar"},
{"name":"warehouse","type":"varchar"},
{"name":"qty","type":"bigint"},
{"name":"price_cents","type":"bigint"},
{"name":"updated_at","type":"varchar"}
]' \
--merge-key skuStep 4 — Load the catalog
Upload the media, embed the image into catalog_images, and upsert the live stock row. The image
lands under products/; embed it with jina-embeddings-v4 and write the resulting 2048-dim vector as
the embedding column. The PDF manual goes to manuals/ — you link a manual, you don't embed it
with a visual model.
TIP
Upsert vector rows one at a time. Batching many 2048-dim vectors in a single upsert can trip a
gRPC frame-size limit — send one --row per call. And never write a JSON null or an empty string
into a key column: both read back as null and silently drop the row. Use a real sku.
Upload hero.jpg and lamp-042.pdf to shop-catalog, embed the image with jina-embeddings-v4, upsert its vector into catalog_images for lamp-042, and upsert an inventory row for lamp-042 at warehouse sea-1 with qty 7 at $129.
data_object_create→ignite_models_embed→data_table_upsertUploaded hero.jpg and lamp-042.pdf; embedded the image (2048-dim) and upserted it into catalog_images; upserted lamp-042 @ sea-1: qty 7, $129.00 (wal_written: true).
dodil data object create ./hero.jpg -b "$BUCKET" --key products/lamp-042/hero.jpg
dodil data object create ./lamp-042.pdf -b "$BUCKET" --key manuals/lamp-042.pdf
# Embed the product image (jina-embeddings-v4 is multimodal) and capture the 2048-dim vector as a
# [f1,f2,…] literal. Embed one image, upsert one vector row — one per call.
EMB=$(dodil ignite models embed jina-embeddings-v4 \
--input "art-deco brass table lamp with frosted glass shade and warm amber glow" -o json \
| python3 -c 'import sys,json;print("["+",".join(map(str,json.load(sys.stdin)["data"]["data"][0]["embedding"]))+"]")')
dodil data table upsert catalog_images -b "$BUCKET" \
--row "{\"sku\":\"lamp-042\",\"image_key\":\"products/lamp-042/hero.jpg\",\"embedding\":\"$EMB\"}"
# Live stock — keyed upsert (one row per sku)
dodil data table upsert inventory -b "$BUCKET" \
--row '{"sku":"lamp-042","warehouse":"sea-1","qty":7,"price_cents":12900,"updated_at":"2026-08-31T10:00:00Z"}'Step 5 — Enrich the catalog with a model → products
A bare listing isn't a catalog. Have a model read each product's raw listing (seller blurb, any
OCR'd text) and write structured attributes — title, category, material, style, color, tags — into a
products table the search can join and filter on. Two ways, by volume:
- One SKU, interactively → ask your agent: it reads the listing and upserts the row.
- The whole catalog → an Ignite batch that enriches at scale — the same fan-out shape as the leads classifier.
First, the table the model fills:
Create a products table in shop-catalog keyed on sku with title, category, subcategory, material, style, color, tags, and description.
data_table_createCreated the products table keyed on sku — the model fills these columns, and the search joins them so a hit carries a real title and category, not just a SKU.
dodil data table create products -b "$BUCKET" \
--columns-json '[
{"name":"sku","type":"varchar"},
{"name":"title","type":"varchar"},
{"name":"category","type":"varchar"},
{"name":"subcategory","type":"varchar"},
{"name":"material","type":"varchar"},
{"name":"style","type":"varchar"},
{"name":"color","type":"varchar"},
{"name":"tags","type":"varchar"},
{"name":"description","type":"varchar"}
]' \
--merge-key skuNow enrich lamp-042 — one SKU. Hand the raw seller blurb to kimi-k2.6, ask for JSON, and upsert the
result into products:
In shop-catalog, ask kimi-k2.6 to turn the lamp-042 seller blurb into catalog attributes (title, category, subcategory, material, style, color, tags, description) as JSON, then upsert them into products for sku lamp-042.
ignite_models_chat→data_table_upsertFrom the listing: title 'Vintage Brass Art Deco Table Lamp', category Lighting, subcategory Table Lamps, material Brass/Glass, style Art Deco, color Brass/Amber, tags [vintage,brass,table lamp,art deco,frosted glass,amber light]. Upserted into products (merge on sku).
# A model turns the raw listing into structured attributes as JSON.
dodil ignite models chat kimi-k2.6 \
--system 'Enrich an e-commerce catalog. Return ONLY compact JSON with keys: title, category, subcategory, material, style, color, tags (array), description (<=25 words).' \
--message 'sku lamp-042. Seller blurb: "vintage brass table lamp, deco styling, frosted glass shade, gives off a warm amber light, small nick on the base"'
# upsert the attributes onto the product row (keyed upsert = one row per sku)
dodil data table upsert products -b "$BUCKET" \
--row '{"sku":"lamp-042","title":"Art-Deco Brass Table Lamp","category":"lighting","subcategory":"table-lamp","material":"brass, frosted glass","style":"art-deco","color":"brass/amber","tags":"art-deco,brass,table-lamp,frosted-glass","description":"Art-deco table lamp in brushed brass with a frosted-glass shade."}'NOTE
What ran here. This org's Models catalog exposes kimi-k2.6 (text) plus specialist vision
models (paddleocr-vl for OCR, mm-gdino-large for detection) — but not a general
image→JSON chat model. So the enrichment reads the listing text with kimi-k2.6. If your catalog
includes a vision chat model, pass a pre-signed image_url part (dodil data object url … --expires 3600 → object.uk-lon-1.dodil.io/…) instead of the blurb; the rest of the flow is identical. Either
way, the visual matching in Step 6 is genuinely multimodal — that runs on jina-embeddings-v4.
For the whole catalog, wrap the same call in an Ignite function and fan it out (with a service
account granted ignite.developer + k3.editor, like the leads classifier) — new SKUs enrich as they land:
# enrich.py (essence) — an Ignite function; a model turns each listing into catalog attributes.
# Same OIDC service-account 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: {"skus": ["lamp-042", …]}
rows = []
for sku in shard["skus"]:
blurb = read_listing(sku) # raw seller text / OCR
attr = client.chat.completions.create(
model="kimi-k2.6", response_format={"type": "json_object"},
messages=[{"role": "system", "content": SCHEMA},
{"role": "user", "content": f"sku {sku}. Seller blurb: {blurb}"}])
rows.append({"sku": sku, **json.loads(attr.choices[0].message.content)})
data_table_upsert("products", rows) # keyed upsert, merge on sku
return {"enriched": len(rows)}Step 6 — The visual-search request
Vector finds visually similar products. Because jina-embeddings-v4 puts images and text in
one space, a description of the look returns catalog items that visually match (no keywords to
hit). Then the model-written products attributes join to live stock — so a match comes back
as a real product card, not a bare SKU.
In shop-catalog, find products visually similar to an art-deco brass table lamp, and for the top match show its title, category, and live stock, plus a link to its manual.
data_vsearch→data_table_query→data_object_urlNearest by cosine distance: lamp-042 (0.037), then vase-077 (0.305), chair-108 (0.543), rug-233 (0.629). Top match lamp-042 — 'Art-Deco Brass Table Lamp' (lighting, art-deco). In stock: 7 units at sea-1 ($129.00). Manual (valid 1h): https://object.uk-lon-1.dodil.io/shop-catalog/manuals/lamp-042.pdf?X-K3-Token=… A real product card, straight from one bucket.
# (a) Vector: describe the look — jina-embeddings-v4 embeds the text and KNN-ranks the image vectors
dodil data vsearch -b "$BUCKET" -t catalog_images --column embedding \
--text "art-deco brass table lamp frosted glass shade" \
--model jina-embeddings-v4 --metric cosine --top-k 5
# (b) SQL: the top match's enriched attributes + live stock, joined (products ⋈ inventory)
# reads are read-your-writes — freshly upserted rows JOIN immediately, no compaction step
dodil data table query \
"SELECT p.title, p.category, p.style, i.warehouse, i.qty, i.price_cents
FROM products p JOIN inventory i ON i.sku = p.sku
WHERE p.sku = 'lamp-042' AND i.qty > 0 ORDER BY i.warehouse" \
-b "$BUCKET"
# (c) Objects: a pre-signed link to the matched product's manual
dodil data object url manuals/lamp-042.pdf -b "$BUCKET" --expires 3600NOTE
Two ways to search by look. Because jina-embeddings-v4 puts text and images in one space, the
query above is cross-modal: it scores your text against the image embeddings, so "art-deco
brass lamp" finds items that visually match — that is visual search. To search by a raw photo
(image→image), embed the photo with the same model and pass the resulting vector to
data vsearch --vector … — same embedding column, a photo query instead of a text one.
Query it — one bucket, drop-in clients
The same rows answer by content (SQL) and by meaning (vector) with no ETL and no second copy. And any
Postgres/pgvector client points straight at the bucket — data connect prints the endpoints (DB name =
bucket, credential = your login token), so the KNN above is also just ORDER BY embedding <=> '[…]' in
plain psql.
Print the drop-in Postgres endpoint for shop-catalog so I can point psql at it.
data_connectpg postgresql://token:…@pg.uk-lon-1.dodil.io:5432/shop-catalog · bolt bolt+s://bolt.uk-lon-1.dodil.io:7687 · grpc table-rpc.uk-lon-1.dodil.io:443
dodil data connect "$BUCKET" -o psql # postgresql://…@pg.uk-lon-1.dodil.io:5432/shop-catalogDataK3 vs. the three-system stack
| Concern | Three-system stack | DataK3 |
|---|---|---|
| Media ↔ embeddings ↔ inventory consistency | Three datastores, reconciliation jobs | One bucket; media, vectors, and tables share lifecycle |
| Catalog attributes from a listing | A PIM + manual data entry, or a separate enrichment SaaS | A model writes them into a table in the same bucket |
| "Similar item that's actually in stock" | Vector DB result joined to warehouse DB in app | Vector result + SQL stock check in the same API |
| Real-time price/stock | Warehouse DB; object store oblivious | table upsert writes live rows next to the catalog |
Troubleshooting
- Image→image passes the photo as a vector. The text query is already cross-modal — text and
images share the
jina-embeddings-v4space; to rank by a raw photo, embed it with the same model and passdata vsearch --vector …against the sameembeddingcolumn. - High write rate on inventory. Frequent upserts create small files — optionally schedule
dodil data table optimize inventory --z-order-column skuandtable vacuum(perf only, not needed for correctness). - Embedding model is a commitment. The
VECTOR(2048)column is dimensioned forjina-embeddings-v4; switching models means a new column (new dim) + re-embed. - Dropped rows after an upsert? A
nullor empty-string key column reads back as null and vanishes. Always write a realsku; upsert vectors one row per call.
Test
Verified live on 2026-09-01 against DataK3 (org ihdiash) with a temporary blog-ecom bucket and
the tutorial's real table names. Reads are read-your-writes, so freshly upserted rows are visible
immediately — no compaction needed. Four products were embedded with jina-embeddings-v4 into a
VECTOR(2048) column; the kimi-k2.6 enrichment call, the vector ranking, and the in-stock JOIN all
ran live. Swap blog-ecom for shop-catalog to follow along.
export BUCKET=shop-catalog
# 1. The bucket exists
dodil data bucket get "$BUCKET"
# expect: a bucket named shop-catalog, status ACTIVE
# 2. The vector column is populated
dodil data table query "SELECT count(*) AS n FROM catalog_images" -b "$BUCKET"
# live: n = 4 (lamp-042, chair-108, rug-233, vase-077)
# 3. The manual landed under manuals/
dodil data object url manuals/lamp-042.pdf -b "$BUCKET" --expires 3600
# live: https://object.uk-lon-1.dodil.io/…/manuals/lamp-042.pdf?X-K3-Token=… (valid 1h)
# 4. The model wrote catalog attributes into products
dodil data table query \
"SELECT sku, title, category FROM products WHERE sku = 'lamp-042'" -b "$BUCKET"
# live: 1 row — lamp-042, 'Art-Deco Brass Table Lamp', lighting
# 5. The core outcome: a visually-matched SKU that is actually in stock (products ⋈ inventory)
dodil data table query \
"SELECT p.title, i.warehouse, i.qty, i.price_cents
FROM products p JOIN inventory i ON i.sku = p.sku
WHERE p.sku = 'lamp-042' AND i.qty > 0" -b "$BUCKET"
# live: 1 row — Art-Deco Brass Table Lamp, sea-1, qty 7, price_cents 12900
# 6. Cross-modal search: text query KNN-ranks the product IMAGE vectors
dodil data vsearch -b "$BUCKET" -t catalog_images --column embedding \
--text "art-deco brass table lamp frosted glass shade" \
--model jina-embeddings-v4 --metric cosine --top-k 5
# live: lamp-042 nearest (cosine distance 0.037), then vase-077 (0.305), chair-108 (0.543), rug-233 (0.629)One-shot
Hand this to your agent to reproduce the whole tutorial end-to-end:
Build a multimodal e-commerce engine in a DataK3 bucket called shop-catalog:
1. Create the bucket shop-catalog (data_bucket_create).
2. Create a merge-keyed catalog_images table on sku with image_key and an embedding VECTOR(2048)
column (data_table_create, --merge-key sku). (Or install the image-rag recipe to auto-embed
images under products/.)
3. Create a merge-keyed inventory table on sku with warehouse, qty, price_cents, updated_at
(data_table_create, --merge-key sku).
4. Upload products/lamp-042/hero.jpg and manuals/lamp-042.pdf. Embed the image with
jina-embeddings-v4 (ignite_models_embed) and upsert its 2048-dim vector into catalog_images for
lamp-042 — one vector row per call (data_table_upsert). Upsert an inventory row for lamp-042 at
warehouse sea-1 with qty 7 and price_cents 12900 (data_table_upsert).
5. Create a merge-keyed products table on sku with title, category, subcategory, material, style,
color, tags, description (data_table_create). Have kimi-k2.6 (ignite_models_chat) turn the raw
listing into catalog attributes as JSON and upsert them into products for lamp-042
(data_table_upsert). For the whole catalog, fan the same call out in an Ignite batch.
6. Run the visual-search request: cross-modal text KNN against catalog_images (data_vsearch), join
products to inventory for the top match's title/category and live in-stock qty (data_table_query),
and return a pre-signed link to its manual (data_object_url).
Then run the ## Test checks and confirm lamp-042 comes back as an in-stock 'Art-Deco Brass Table
Lamp' product card.Ship it — wrap the search in a public app
The visual-search request and the enrichment model step run interactively here. To put them behind a URL your storefront — or an agent — can call, wrap the search 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 bucket answers "show me a similar item I can actually ship" as a vector search joined to a
model-enriched product row and a SQL qty > 0 check — a VECTOR(2048) column stood up the visual
side, and a model wrote the catalog attributes itself, CLI or agent.
Next steps:
- Physics & Astronomy — the same shape at extreme scale.
- AI Research Agent — the narrow → rank → resolve loop in depth.