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 plus jina-embeddings-v4 stands 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 products table 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 dodil CLI.

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 dodil CLI 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:

PillarHoldsExample keys / columns
ObjectsProduct images, catalog assets, PDF manuals.products/lamp-042/hero.jpg, manuals/lamp-042.pdf
VectorImage/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

You

Create a DataK3 bucket called shop-catalog for product media, search, and inventory.

DODIL MCP tools called
data_bucket_create
Agent

Created bucket shop-catalog. The tables engine is implicit per bucket, and an internal object source is wired for ingestion — no engine to enable.

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.

You

In shop-catalog, create a catalog_images table keyed on sku with image_key and a VECTOR(2048) embedding column for visual search.

DODIL MCP tools called
data_table_create
Agent

Created catalog_images keyed on sku with an embedding VECTOR(2048) column — ready for jina-embeddings-v4 image/text embeddings and pgvector KNN.

Prefer 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.

You

On shop-catalog, install the image-rag recipe with prefix catalog scoped to the products/ folder so product images get indexed for visual search.

DODIL MCP tools called
data_recipe_install
Agent

Installed 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.

Step 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.

You

Create an inventory table in shop-catalog keyed on sku with warehouse, qty, price_cents, and updated_at.

DODIL MCP tools called
data_table_create
Agent

Created the inventory table keyed on sku — upserts replace one row per SKU rather than duplicating stock rows.

Step 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.

You

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.

DODIL MCP tools called
data_object_createignite_models_embeddata_table_upsert
Agent

Uploaded 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).

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, interactivelyask 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:

You

Create a products table in shop-catalog keyed on sku with title, category, subcategory, material, style, color, tags, and description.

DODIL MCP tools called
data_table_create
Agent

Created 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.

Now enrich lamp-042 — one SKU. Hand the raw seller blurb to kimi-k2.6, ask for JSON, and upsert the result into products:

You

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.

DODIL MCP tools called
ignite_models_chatdata_table_upsert
Agent

From 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).

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 3600object.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.

You

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.

DODIL MCP tools called
data_vsearchdata_table_querydata_object_url
Agent

Nearest 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.

NOTE

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.

You

Print the drop-in Postgres endpoint for shop-catalog so I can point psql at it.

DODIL MCP tools called
data_connect
Agent

pg 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

DataK3 vs. the three-system stack

ConcernThree-system stackDataK3
Media ↔ embeddings ↔ inventory consistencyThree datastores, reconciliation jobsOne bucket; media, vectors, and tables share lifecycle
Catalog attributes from a listingA PIM + manual data entry, or a separate enrichment SaaSA 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 appVector result + SQL stock check in the same API
Real-time price/stockWarehouse DB; object store oblivioustable 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-v4 space; to rank by a raw photo, embed it with the same model and pass data vsearch --vector … against the same embedding column.
  • High write rate on inventory. Frequent upserts create small files — optionally schedule dodil data table optimize inventory --z-order-column sku and table vacuum (perf only, not needed for correctness).
  • Embedding model is a commitment. The VECTOR(2048) column is dimensioned for jina-embeddings-v4; switching models means a new column (new dim) + re-embed.
  • Dropped rows after an upsert? A null or empty-string key column reads back as null and vanishes. Always write a real sku; 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: