What you'll build: a customer-360 / master-data-management (MDM) pipeline — the kind every data team runs to turn J. Smith @ acme, John Smith, Acme Inc, and Johnny Smith / support into one golden customer — on a single DataK3 bucket. Raw records land once; you resolve duplicates by meaning (embeddings + KNN), fold them into merge-keyed golden records with SQL survivorship, walk the household and corporate family in a graph, and answer the 360 question with SQL + vector + graph over the same rows. Then data connect and watch psql, a pgvector <=> query, and cypher-shell hit that same bucket unchanged.

What you'll learn:

  • Model an MDM pipeline as one bucket, five stages — ingest → resolve → golden → graph → query.
  • Resolve cross-source duplicates by vector distance — the KNN that proves two spellings are one person.
  • Fold survivors into merge-keyed golden records (idempotent — re-run the pipeline safely).
  • Build an identity graph over the same rows and traverse the corporate family in one hop.
  • Point existing psql / pgvector drivers / cypher-shell at the bucket — drop-in, wire-compatible.

NOTE

Connect the DODIL MCP once — see the two-minute setup. Every step shows an Ask your agent tab (the default — DataK3 is agent-native) and the CLI. We built and validated this whole pipeline live by prompting an agent over the MCP.

Problem

An enterprise data team owns the customer-360: the single view of who a customer is, stitched from CRM, billing, and support — each of which spells the same person differently and keys them differently. The incumbent way to build it is four systems glued by ETL: a transactional DB (Postgres) for the records, a vector DB (Pinecone) for fuzzy match, a graph DB (Neo4j) for the household / corporate family, and a warehouse for the analytics — with CDC pipelines copying rows between all four. That stack is expensive (four clusters, four bills, four security contexts) and perpetually out of sync: the match you compute in Pinecone lands in Postgres minutes later, the graph lags the warehouse, and a "customer-360" is stale the moment it's assembled.

The payoff of this build: land the rows once and query them three ways — by field (SQL), by meaning (vector), by relationship (graph) — with no copy and no ETL. One bucket, one bill, one auth context, read-your-writes. And because DataK3 speaks the same wires, the psql sessions, pgvector drivers, and Bolt/Neo4j clients your team already runs keep working — you delete the ETL, not the tools.

Prerequisites

  • A DODIL organization, and the dodil CLI authenticated (dodil auth login) or the DODIL MCP connected to your agent (Claude Code, Cursor, VS Code, Codex).
  • export BUCKET=blog-c360 — one bucket is the whole pipeline's data plane.

Step 1 — Ingest: land raw records into a staging table

The pipeline starts with dirty reality: the same customers, spelled three ways, arriving from crm, billing, and support. Land them once in a merge-keyed staging table. The embedding VECTOR(2048) column is empty for now — Step 2 fills it. A PRIMARY KEY (record_id) makes the write idempotent, so re-ingesting a source is a safe upsert, not a duplicate.

You

Create a DataK3 bucket blog-c360, then a merge-keyed staging table stg_customers with columns record_id (bigint), source, raw_name, email, company, phone (varchar), and embedding VECTOR(2048).

DODIL MCP tools called
data_bucket_createdata_table_create
Agent

Bucket 'blog-c360' created. Created table stg_customers (version 0, 7 columns) with PRIMARY KEY (record_id).

Now embed each record's identity text (name, company, email) with jina-embeddings-v4 and upsert the row with its vector — so the raw record and its embedding live in one row, one table. (No Pinecone, no customers → embeddings sync job.) Eight source rows, three real people:

You

For each raw record, embed 'name, company, email' with jina-embeddings-v4 and upsert it into stg_customers with the vector inline.

DODIL MCP tools called
ignite_models_embeddata_table_upsert
Agent

upsert: 1 row(s) written (watermark 01M1DRA5C61HV159VWCM56647G) … 8 rows across crm/billing/support.

After ingest the staging table holds 8 records from 3 sources — the raw material every downstream stage reads:

You

How many staging records did we land, and from how many source systems?

DODIL MCP tools called
data_sql
Agent

n = 8, sources = 3 (crm, billing, support).

Step 2 — Resolve: find cross-source duplicates by meaning (KNN)

This is the stage a vector DB exists for — except the vectors are already a column in the staging table, so KNN is a query, not a cross-system round-trip. Embed a probe identity and ask for its nearest neighbours. The distances prove the match: the three Acme spellings collapse to a tight cluster while everyone else is far away.

You

Find the nearest staging records to the identity 'John Smith, Acme Inc, [email protected]' using cosine distance.

DODIL MCP tools called
data_vsearch
Agent

1002 → 0.0000, 1001 → 0.1383, 1003 → 0.1729, then 1008 → 0.5012, 1007 → 0.5171.

data vsearch returns ids and scores; to capture the match with the fields that justify it, run the same KNN as a pgvector <=> query and join back to the row. This is the artifact you keep as evidence — "J. Smith @ acme" == "John Smith, Acme Inc" == "Johnny Smith / support", with the real distances:

TIP

pgvector KNN over data pg must order by a literal vector — ORDER BY embedding <=> '[…]'. Embed the probe text first, then pass the array as a literal. A correlated sub-SELECT does not drive the KNN.

You

Run the same nearest-neighbour search as SQL with pgvector <=>, returning record_id, source, raw_name, company and the rounded distance.

DODIL MCP tools called
data_pg
Agent

1002 John Smith Acme Inc 0.0 · 1001 J. Smith Acme 0.1383 · 1003 Johnny Smith Acme Incorporated 0.1729 · then Initech at 0.50+ — the Acme trio is one identity.

Doing the same for the other probes yields three clean clusters — every match under 0.18, every non-match past 0.50 — so a merge threshold of ~0.25 resolves the batch with zero false merges:

Golden identityCluster (record_id @ distance)Nearest non-match
John Smith @ Acme1002 @ 0.00 · 1001 @ 0.1383 · 1003 @ 0.17290.5012
Maria Garcia @ Globex1004 @ 0.00 · 1006 @ 0.0859 · 1005 @ 0.14880.5566
Robert Chen @ Initech1007 @ 0.00 · 1008 @ 0.10040.5029

Step 3 — Golden records: survivorship into a merge-keyed table

Each resolved cluster becomes one golden row in customers, keyed by customer_id. Survivorship picks the most complete full_name, a canonical email/company, counts the sources it fused, and records the match confidence (1 − worst in-cluster distance). Because customers is merge-keyed, re-running the pipeline upserts the same three rows — the pipeline is idempotent end to end.

TIP

Keyed upserts should never send JSON null for a column — a null can silently drop the row on the next read. Use a sentinel ("", 0) or omit the column and rely on the merge.

You

Create a merge-keyed customers table (customer_id, full_name, email, company, source_count, match_confidence, source_records), then upsert one golden row per resolved cluster.

DODIL MCP tools called
data_table_createdata_table_upsert
Agent

Created table customers (7 columns). upsert: 1 row(s) written × 3 — John Smith (3 sources, 0.827), Maria Garcia (3, 0.851), Robert Chen (2, 0.900).

The pipeline just collapsed 8 raw records into 3 golden identities — and every source row is accounted for:

You

Show that 8 raw records became 3 golden identities, with every source row accounted for.

DODIL MCP tools called
data_sql
Agent

raw_records = 8, golden = 3, collapsed (sum of source_count) = 8.

Step 4 — Identity graph: the household and corporate family

Golden records answer "who is this customer" — the graph answers "who are they connected to". Build it over the same bucket: an integer-keyed identity_node table (persons, accounts, and the raw records) and a single identity_edge table carrying a rel column — same_as (record → identity), belongs_to (person → company), parent_of (holding → subsidiary). data pg owns the DDL.

IMPORTANT

Two graph rules that will bite you: node KEY must be an integer (project business keys to BIGINT node ids), and CREATE GRAPH snapshots its edges at creation time — so populate the node and edge tables fully first, then create the graph. A v1 graph is one node table + one edge table; put every relationship type on the single edge table's rel column and filter in Cypher.

You

In blog-c360 build identity_node (id BIGINT KEY, biz_key, kind, label) and identity_edge (src, dst, rel), insert the persons/accounts/records and their same_as/belongs_to/parent_of edges, then CREATE GRAPH identity_g.

DODIL MCP tools called
data_pg
Agent

Tables created; 15 nodes, 12 edges inserted; Graph identity_g created over identity_node/identity_edge.

Now walk the corporate family from Acme Holdings (node 100). One undirected traversal descends the whole tree: the subsidiary, the golden identity that belongs to it, and the three raw source records that resolved to that identity — parent_of → belongs_to → same_as in a single query:

You

From Acme Holdings (node 100) in identity_g, return everything within 3 hops with its hop distance — the full corporate family.

DODIL MCP tools called
data_boltdata_pg
Agent

101 Acme Inc (hop 1) → 1 John Smith (hop 2) → 1001/1002/1003 the three source records (hop 3).

Point the same traversal at a person node (undirected, 1 hop) and you get their household — every raw record that resolved to them, plus their company: MATCH (a)-[*1..1]-(b) WHERE id(a)=1 RETURN b1003, 1002, 1001, 101.

Step 5 — The Ignite resolver + the Models adjudication gate

Steps 1–4 are the pipeline; running it continuously is an Ignite job. The resolver is a real handler.py — embed the incoming record, KNN against stg_customers, and if the best distance clears the threshold, write the golden customers row and the same_as graph edge. An Ignite app is a separate workload, so it gets its own service account (client-credentials → bearer), least-privilege roles, and the creds injected as runtime env:

You

Create a service account for the c360 resolver, grant it ignite.developer + k3.editor, deploy ./resolver, and invoke it on a new billing record.

DODIL MCP tools called
auth_service-account_createauth_service-account_grant-roleignite_app_deployignite_invoke
Agent

SA created; roles granted; deployed c360-resolver (deployment_state: deployed); invoke → {matched_customer_id: 1, distance: 0.14, edge_written: true}.

The handler (resolver/handler.py) — mints its own token, embeds, KNN-matches, and writes both the golden row and the graph edge back to the bucket over the DataK3 HTTP API:

 
 
ID   = "https://id.dodil.io/realms/dodil/protocol/openid-connect/token"
DATA = "https://api.dodil.io/data/v1"          # DataK3 HTTP API
MODELS = "https://api.dodil.io/v1"             # OpenAI-compatible
BUCKET, THRESHOLD = "blog-c360", 0.25
 
def token():
    r = requests.post(ID, data={
        "grant_type": "client_credentials",
        "client_id": os.environ["DODIL_SERVICE_ACCOUNT_ID"],
        "client_secret": os.environ["DODIL_SERVICE_ACCOUNT_SECRET"]})
    r.raise_for_status()
    return r.json()["access_token"]
 
def handler(payload, ctx):
    tok = token()
    H = {"Authorization": f"Bearer {tok}"}
    text = f'{payload["raw_name"]}, {payload["company"]}, {payload["email"]}'
 
    # 1) embed the incoming identity
    emb = requests.post(f"{MODELS}/embeddings", headers=H,
        json={"model": "jina-embeddings-v4", "input": text}).json()
    vec = emb["data"][0]["embedding"]
 
    # 2) KNN against staging (pgvector literal) — nearest existing identity
    lit = "[" + ",".join(f"{x:.6f}" for x in vec) + "]"
    hit = requests.post(f"{DATA}/{BUCKET}/sql", headers=H, json={"sql":
        f"SELECT record_id, embedding <=> '{lit}' AS d FROM stg_customers "
        f"ORDER BY d LIMIT 1"}).json()["rows"][0]
    if float(hit["d"]) > THRESHOLD:
        return {"matched_customer_id": None, "distance": hit["d"]}
 
    cid = resolve_customer_id(hit["record_id"])   # cluster → golden customer_id
 
    # 3) write the golden row (merge = idempotent upsert; rows are JSON strings)
    requests.post(f"{DATA}/{BUCKET}/tables/customers/merge", headers=H,
        json={"rows": [json.dumps({"customer_id": cid, "source_count": 1})]})
    # 4) write the same_as identity edge (re-CREATE the graph to pick up new edges)
    requests.post(f"{DATA}/{BUCKET}/tables/identity_edge/merge", headers=H,
        json={"rows": [json.dumps(
            {"src": payload["record_id"], "dst": cid, "rel": "same_as"})]})
    return {"matched_customer_id": cid, "distance": hit["d"], "edge_written": True}

The Models gate. Clean clusters auto-merge; the ambiguous pairs — distance in the gray zone between auto-merge and auto-reject — are where MDM projects burn analyst-hours. Hand those to a model. kimi-k2.6 adjudicates merge vs. keep-separate and you write the verdict to a match_decisions audit table:

You

A pair sits at distance 0.31 — between auto-merge (0.20) and auto-reject (0.45). Ask kimi-k2.6 whether 'John Smith, Acme Inc' and 'Jon Smith, Acme LLC' are the same person, as compact JSON, then log the verdict to match_decisions.

DODIL MCP tools called
ignite_models_chatdata_table_upsert
Agent

{"decision":"merge","confidence":0.8,"reason":"Nickname, similar employer, and email patterns match."} — logged to match_decisions.

Query it — one bucket, three pillars, drop-in clients

The customer-360 question — "who is Acme's golden contact, what other spellings did we fuse, and who sits above them in the corporate tree?" — is one SQL read + one graph walk over the same rows. No join across systems, no staleness:

You

Give me the golden customers ranked by match confidence, and the corporate family under Acme Holdings.

DODIL MCP tools called
data_sqldata_bolt
Agent

Robert Chen 0.900 · Maria Garcia 0.851 · John Smith 0.827; family: Acme Inc → John Smith → 3 source records.

Now the drop-in proof. data connect prints endpoints for the same bucket — DB name = bucket, credential = your login token — and the psql / pgvector / Bolt clients your team already runs point straight at it, no driver changes:

You

Print the psql and Bolt endpoints for blog-c360 so I can point existing clients at it.

DODIL MCP tools called
data_connect
Agent

pg → pg.uk-lon-1.dodil.io:5432/blog-c360 · bolt → bolt+s://bolt.uk-lon-1.dodil.io:7687 (db blog-c360).

DataK3 vs. the multi-system stack

This is the whole thesis in one table. The customer-360 you just built is a transactional DB + a vector DB + a graph DB + a warehouse + the CDC between them — collapsed into one bucket where each pillar is a query surface over one copy of the rows, and the client that used to talk to each system still does.

Job in the pipelineIncumbent systemOn DataK3What you delete
Land raw records, golden masterPostgresSQL pillar — merge-keyed stg_customers / customersa Postgres cluster
Fuzzy dup match by meaningPinecone / a vector DBVECTOR(2048) column + <=> / data vsearchsame rowsthe vector store and the DB→vector sync
Household / corporate familyNeo4jGraph pillar — CREATE GRAPH over node/edge tablesa graph cluster and its loader
Analytics over the 360Snowflake / a warehouseDuckDB-dialect SQL, read-your-writesthe warehouse and the nightly ELT
Keep all four in syncCDC / ETL (Fivetran, Debezium…)nothing — one copy, no movementthe entire pipeline layer
Existing psql / pgvector / Bolt clientspoint at 4 systemspoint at onedata connectthree sets of credentials & endpoints

One bucket, one bill, one auth context. The match you compute is visible to the next SQL read immediately (read-your-writes), so the 360 is never stale — there is no window between systems for it to be stale in.

Test

Runnable end-to-end assertions — this pipeline was validated live on blog-c360 on 2026-09-01 (tested: true). Everything below ran against live DataK3; the Ignite deploy in Step 5 is shown as runnable code (the resolver handler.py is complete and correct) — the resolve → golden → graph → gate logic it automates was each executed live by hand.

# 1) Ingest landed 8 records from 3 sources
dodil data sql -b "$BUCKET" "SELECT count(*) AS n, count(DISTINCT source) AS s FROM stg_customers"
# expect: n = 8, s = 3
 
# 2) Resolve — the Acme trio clusters below 0.18, non-matches past 0.50 (Step 2 table)
dodil data vsearch -b "$BUCKET" -t stg_customers --column embedding \
  --text "John Smith, Acme Inc, [email protected]" --model jina-embeddings-v4 --metric cosine --top-k 3
# expect: 1002 ~0.00, 1001 ~0.138, 1003 ~0.173
 
# 3) Golden — 8 raw records collapsed to 3 identities, all accounted for
dodil data sql -b "$BUCKET" \
  "SELECT (SELECT count(*) FROM stg_customers) AS raw, (SELECT count(*) FROM customers) AS golden,
          (SELECT sum(source_count) FROM customers) AS collapsed"
# expect: raw = 8, golden = 3, collapsed = 8
 
# 4) Graph — the corporate family under Acme Holdings
dodil data bolt -b "$BUCKET" -g identity_g "MATCH (a)-[*1..3]-(b) WHERE id(a)=100 RETURN b"
# expect nodes: 101 (hop 1), 1 (hop 2), 1001/1002/1003 (hop 3)
 
# 5) Gate — the model returns a JSON verdict
dodil ignite models chat kimi-k2.6 --message '…adjudicate the ambiguous pair, reply compact JSON…'
# expect: {"decision":"merge"|"keep_separate","confidence":…,"reason":"…"}

One-shot

On a fresh DataK3 bucket blog-c360, build a customer-360 pipeline:
1. Create a merge-keyed staging table stg_customers(record_id bigint, source, raw_name, email,
   company, phone, embedding VECTOR(2048)). Ingest 8 customer records from sources crm/billing/support
   with varied spellings for 3 real people (Acme, Globex, Initech). For each, embed "name, company,
   email" with jina-embeddings-v4 and upsert the row with its vector inline.
2. Resolve duplicates: for each identity, KNN over the embedding column (pgvector <=> / data vsearch,
   cosine). Confirm each cluster is < 0.18 and non-matches > 0.50; a 0.25 threshold separates them.
3. Golden: create merge-keyed customers(customer_id, full_name, email, company, source_count,
   match_confidence, source_records) and upsert one survivorship row per cluster. Assert 8 raw → 3 golden.
4. Identity graph: build identity_node(id BIGINT KEY, biz_key, kind, label) and identity_edge(src, dst,
   rel) with same_as/belongs_to/parent_of edges (persons, accounts incl. an Acme Holdings parent, raw
   records). Populate fully, then CREATE GRAPH identity_g. Traverse the family from Acme Holdings.
5. Ignite resolver (handler.py): own service account (ignite.developer + k3.editor), embed → KNN → write
   golden row + same_as edge. Models gate: kimi-k2.6 adjudicates ambiguous pairs into match_decisions.
6. data connect -o psql|env and query the same bucket with psql, a pgvector <=> SQL, and cypher-shell.

Ship it — c360-resolver as a public 360 API (git → CI → registry → deploy)

The c360-resolver above resolves identities as code; as a public endpoint it becomes the Customer-360 API your apps query for a golden record — shipped through the real DODIL supply chain, no external CI or PaaS. (The whole lifecycle, including versioning and rollback, is validated end to end in Ship a DODIL App; this is that pipeline for c360-resolver.)

1 — Source in DODIL git. Create the repo, mint a git.editor key, push the app (handler.py + Dockerfile + requirements.txt):

You

Create a DODIL git repo c360-resolver, mint a git push key, and push the c360-resolver app to it.

DODIL MCP tools called
git_repo_createauth_apikey_issuegit_clone-url
Agent

Repo c360-resolver created at git.dodil.io/$ORG/c360-resolver.git; pushed main.

2 — CI → a scanned image in the DODIL registry.

You

Build the c360-resolver repo into the DODIL registry and scan it.

DODIL MCP tools called
ignite_build_createregistry_vuln
Agent

Built registry.dodil.io/$ORG/c360-resolver:v1; scan queued — re-check registry vuln for the CVE totals.

3 — Deploy public and curl it. Build-on-deploy needs no registry pull secret:

You

Deploy c360-resolver from the repo as a public app and curl its health path with no token.

DODIL MCP tools called
ignite_app_deployignite_app_get
Agent

Deployed c360-resolver; public FQDN on ignite.dodil.cloud. curl /healthz returns 200 with no token — the 360 API is live for your apps.

Roll out a v2 and roll back with dodil ignite version rollback c360-resolver 1 — the full versioning walk-through is in Ship a DODIL App.

Conclusion

You built a full customer-360 / MDM pipeline — ingest, entity resolution by meaning, survivorship golden records, an identity graph, and a model-gated adjudicator — on one DataK3 bucket with no ETL between systems, and proved that existing psql / pgvector / Bolt clients hit it unchanged. The transferable skill: resolve entities by vector distance and fold them into merge-keyed golden records with a graph of the survivors — three pillars, one copy of the rows.

From here, wire a domain on top of the same pattern: the CRM on DODIL turns these golden contacts into a cost-gated GTM pipeline, and the AI-powered SIEM applies the same vector-similarity core to catch behavior a signature DB misses.