Skip to content
New

Introducing Scale: SurrealDB Cloud for high availability and scale

Learn more

1/4

One graph for the whole maison: modelling luxury retail on SurrealDB

Tutorial

Jul 23, 20268 min read

Martin Schaer

Martin Schaer

Show all posts

One graph for the whole maison: modelling luxury retail on SurrealDB

Our newsletter

Get tutorials, AI agent recipes, webinars, and early product updates in your inbox every two weeks

A luxury maison does not have a big-data problem. It has a relationships problem.

The volumes are tiny by e-commerce standards: a few thousand pieces a season, a clientele measured in the low hundreds of thousands, a handful of flagship boutiques. What is hard is everything between those records — which artisan signed a piece, which client has been waiting fourteen months for an allocation, which associate knows that a client only ever buys in a particular leather. That is graph-shaped data, and most retail stacks flatten it into join tables until nobody can answer a simple question without a six-way JOIN.

This post builds a complete data model for a fictional house — call it the maison — on SurrealDB. One engine, one query language, and three things that usually need three separate systems: a graph of relationships, vector recommendations, and full-text search.

TL;DR

  • Luxury retail is relationship-heavy and scarcity-driven; the schema should treat clients, artisans, products, and waitlists as first-class connected entities, not foreign keys.

  • We model the whole house as a graph with RELATE: who crafted a piece, who purchasedˆ it, who is waitlisted, which associate serves a client.

  • The same database does vector similarity ("pieces in the spirit of ones this client loves") with an HNSW index — no second vector store.

  • It also does full-text search over craftsmanship notes with a BM25 index — no Elasticsearch alongside it.

  • Everything here is copy-paste SurrealQL. Spin up an instance and follow along.

Picture the questions a maison actually needs to answer:

  • A new seasonal tote drops in three colorways. Who on the waitlist should be offered an allocation first, and which of them have bought from this line before?

  • A client walks into a boutique. What has she bought, what is she waiting for, and what would her associate suggest she has never seen?

  • A piece comes back for repair. Who crafted it, in which atelier, and when?

In a relational schema, each of these spans several tables stitched together with join tables: client_product, product_artisan, waitlist_entry, client_associate. The questions are all about paths between entities, but the storage is all about tables, so every answer is a query that reassembles the graph at read time.

A graph database stores the paths directly. In SurrealDB those paths are edges you can define once and traverse in either direction, and because it is multi-model, the product catalog that powers those traversals can also carry an embedding vector and a full-text index without leaving the database.

Five core entities and the edges between them:

        crafted                 purchased
artisan ─────────▶ product ◀───────────── client
                     ▲   ▲                  │
          waitlisted │   │ stocked_at       │ served_by
                     │   │                  ▼
                  client  boutique ◀──── associate
  • client— the people the house exists for.

  • product— a style/colorway, carrying description, price, an embedding, and a search index.

  • artisan— the maker; provenance lives on the crafted edge.

  • boutique— physical locations; products are stocked_at them.

  • associate— boutique staff; a client is served_by one.

Edges (crafted, purchased, waitlisted, served_by, stocked_at) are their own tables, so they can carry data — a purchase price, a waitlist date, an allocation priority.

We use SCHEMAFULL tables for the core entities so the shape is enforced, and lean on typed fields, assertions, and auto-maintained timestamps.

-- Clients
DEFINE TABLE client SCHEMAFULL;
DEFINE FIELD name        ON client TYPE string;
DEFINE FIELD email       ON client TYPE string
  ASSERT string::is_email($value);
DEFINE FIELD tier        ON client TYPE string
  ASSERT $value IN ['standard', 'private', 'vip']
  DEFAULT 'standard';
DEFINE FIELD joined_at   ON client TYPE datetime
  VALUE time::now() READONLY;

DEFINE INDEX idx_client_email ON client FIELDS email UNIQUE;

-- Artisans
DEFINE TABLE artisan SCHEMAFULL;
DEFINE FIELD name     ON artisan TYPE string;
DEFINE FIELD atelier  ON artisan TYPE string;     -- workshop name
DEFINE FIELD métier   ON artisan TYPE string;     -- e.g. 'leather', 'silk', 'jewelry'

-- Boutiques
DEFINE TABLE boutique SCHEMAFULL;
DEFINE FIELD name  ON boutique TYPE string;
DEFINE FIELD city  ON boutique TYPE string;

-- Sales associates
DEFINE TABLE associate SCHEMAFULL;
DEFINE FIELD name      ON associate TYPE string;
DEFINE FIELD boutique  ON associate TYPE record<boutique>;

-- Products (the catalog)
DEFINE TABLE product SCHEMAFULL;
DEFINE FIELD name        ON product TYPE string;
DEFINE FIELD line        ON product TYPE string;     -- product line / collection
DEFINE FIELD material    ON product TYPE string;
DEFINE FIELD colorway    ON product TYPE string;
DEFINE FIELD price       ON product TYPE decimal;
DEFINE FIELD description ON product TYPE string;
DEFINE FIELD allocated   ON product TYPE bool DEFAULT false;  -- waitlist-only piece?
DEFINE FIELD embedding   ON product TYPE option<array<float>> DEFAULT NONE;
DEFINE FIELD created_at  ON product TYPE datetime VALUE time::now() READONLY;

A few things worth noting:

  • tier uses an ASSERT ... IN [...] to keep the value set closed — no stray client tiers.

  • email is validated with the built-in string::is_email and made unique with an index.

  • embedding is an optional vector field; we will index it for similarity search later.

  • decimal (not float) is the right type for money.

Keep it small — a few makers, a few pieces, a few clients. Note the human-readable record IDs (product:atelier_tote_noir); SurrealDB lets you name records, which makes the seed data and queries far easier to read.

-- Boutiques and the associates who work them
CREATE boutique:rue_mistral  SET name = 'Rue Mistral',  city = 'Paris';
CREATE boutique:madison      SET name = 'Madison Ave',  city = 'New York';

CREATE associate:`hélène` SET name = 'Hélène Roy',   boutique = boutique:rue_mistral;
CREATE associate:james  SET name = 'James Okafor',  boutique = boutique:madison;

-- Artisans
CREATE artisan:camille SET name = 'Camille Fournier', atelier = 'Atelier Sud',  métier = 'leather';
CREATE artisan:`théo`    SET name = 'Théo Marchand',    atelier = 'Atelier Soie', métier = 'silk';

-- Products
CREATE product:atelier_tote_noir CONTENT {
  name: 'Atelier Tote',
  line: 'Atelier',
  material: 'box calf leather',
  colorway: 'noir',
  price: 8900.00,
  description: 'Hand-stitched box calf tote with palladium hardware and a saddle-stitched handle.',
  allocated: true
};

CREATE product:atelier_tote_fauve CONTENT {
  name: 'Atelier Tote',
  line: 'Atelier',
  material: 'box calf leather',
  colorway: 'fauve',
  price: 8900.00,
  description: 'Hand-stitched box calf tote in warm fauve with a hand-painted edge finish.',
  allocated: true
};

CREATE product:jardin_emeraude CONTENT {
  name: 'Jardin Silk Scarf',
  line: 'Foulard',
  material: 'mulberry silk twill',
  colorway: 'emeraude',
  price: 520.00,
  description: 'Ninety-centimetre silk twill scarf, screen-printed in fourteen colors from a hand-drawn botanical motif.',
  allocated: false
};

-- Clients
CREATE client:eleanor CONTENT { name: 'Eleanor Vance', email: 'eleanor@example.com', tier: 'vip' };
CREATE client:sofia   CONTENT { name: 'Sofia Marchetti', email: 'sofia@example.com', tier: 'private' };

You can also bulk-load with INSERT INTO ... [ ... ] when you have a flat list — useful for importing a season's catalog from a CSV export:

INSERT INTO product [
  { name: 'Jardin Silk Scarf', line: 'Foulard', material: 'mulberry silk twill',
    colorway: 'ciel', price: 520.00,
    description: 'The Jardin motif in soft sky blue.', allocated: false },
  { name: 'Jardin Silk Scarf', line: 'Foulard', material: 'mulberry silk twill',
    colorway: 'rubis', price: 520.00,
    description: 'The Jardin motif in deep ruby.', allocated: false }
];

This is where the graph earns its place. Edges are created with RELATE, and they can carry their own fields.

-- Provenance: which artisan crafted which piece, and when
RELATE artisan:camille -> crafted -> product:atelier_tote_noir  CONTENT { year: 2026 };
RELATE artisan:camille -> crafted -> product:atelier_tote_fauve CONTENT { year: 2026 };
RELATE artisan:théo    -> crafted -> product:jardin_emeraude       CONTENT { year: 2026 };

-- Stock: where a piece can be found
RELATE product:jardin_emeraude -> stocked_at -> boutique:rue_mistral CONTENT { quantity: 12 };
RELATE product:jardin_emeraude -> stocked_at -> boutique:madison     CONTENT { quantity: 7 };

-- Clienteling: who looks after whom
RELATE client:eleanor -> served_by -> associate:hélène;
RELATE client:sofia   -> served_by -> associate:james;

-- A purchase, with the commercial details on the edge
RELATE client:eleanor -> purchased -> product:jardin_emeraude CONTENT {
  date: d'2026-02-14',
  price_paid: 520.00,
  boutique: boutique:rue_mistral
};

-- Waitlist entries for an allocated piece, with priority and date
RELATE client:eleanor -> waitlisted -> product:atelier_tote_noir CONTENT {
  since: d'2025-09-01',
  priority: 'high'
};
RELATE client:sofia -> waitlisted -> product:atelier_tote_noir CONTENT {
  since: d'2026-01-20',
  priority: 'standard'
};

Now the hard questions become short traversals.

Provenance — who crafted this piece, and in which atelier?

SELECT
  name,
  <-crafted<-artisan.name    AS maker,
  <-crafted<-artisan.atelier AS atelier
FROM product:atelier_tote_noir;

A client's purchase history, following the outbound purchased edge:

SELECT ->purchased->product.* AS pieces
FROM client:eleanor;

Clients served by a given associate:

SELECT <-served_by<-client.name AS clients
FROM associate:`hélène`;

The allocation question. For the new tote, who is on the waitlist, ordered by priority and how long they have waited — and have they bought from the Atelier line before? The waitlisted edge carries the metadata, so we read it directly:

SELECT
  in.name              AS client,
  in.tier              AS tier,
  priority,
  since,
  -- has this client purchased from the Atelier line before?
  (SELECT VALUE count() FROM in->purchased->product WHERE line = 'Atelier') > 0
    AS atelier_client
FROM product:atelier_tote_noir<-waitlisted
ORDER BY priority DESC, since ASC;

One query answers a question that would otherwise be a report. The waitlist, the client tier, and the purchase history are all one graph.


Why graph traversal beats traditional relational JOINs here. A JOIN recomputes the relationship every time you read it. An edge is the relationship, stored once, walkable from either end. As the maison adds edge types — repairs, gifting, event invitations — the queries stay the same shape instead of growing another join table each time.

Clienteling is about suggesting the piece a client has not seen but would love. That is a similarity problem, and SurrealDB can solve it in-place with a vector index — no separate vector database to keep in sync.

Assume each product's description (or an image) has been turned into an embedding by your model of choice and stored in the embedding field. Define an HNSW index over it:

DEFINE INDEX idx_product_embedding ON product
  FIELDS embedding
  HNSW DIMENSION 384 DIST COSINE TYPE F32;

To find pieces *in the spirit of* one a client loves, embed that piece (or an average of her purchases) into a query vector and run a KNN search with the `` operator — `K` neighbors, `EF` candidates considered:

-- $loved is the embedding of a piece the client adores
LET $loved = (SELECT VALUE embedding FROM ONLY product:jardin_emeraude);

SELECT
  name,
  colorway,
  price,
  vector::distance::knn() AS distance     -- distance from the KNN operator
FROM product
WHERE embedding <|5, 40|> $loved
  AND id != product:jardin_emeraude          -- don't recommend the piece itself
ORDER BY distance;                        -- cosine: smaller distance = more similar

`vector::distance::knn()` returns the distance computed by the `` operator, so there is no need to recompute it. Tuning notes for a catalog this size:

  • DIMENSION must match your embedding model (384 here is a common sentence-embedding size).

  • DIST COSINE suits normalized text/image embeddings; ordering ascending puts the closest pieces first.

  • `EF` (the second number in ``) trades recall for speed — raise it for better results on larger catalogs, lower it for latency.

The point: the same product records that power the graph traversals also answer "show me more like this," with no ETL into a separate store.

An associate searching the catalog does not think in SKUs. They think "the green silk scarf with the garden print." That is full-text search, and again it lives in the same database via a BM25 index.

First an analyzer (lowercase, English stemming so stitched matches stitch), then a search index over the fields associates actually search:

DEFINE ANALYZER maison_text
  TOKENIZERS blank, class
  FILTERS lowercase, snowball(english);

DEFINE INDEX idx_product_search ON product
  FIELDS name, description, colorway
  SEARCH ANALYZER maison_text BM25(1.2, 0.75) HIGHLIGHTS;

Then query with the @@ match operator. The numbered form @1@ ties a predicate to a reference so you can pull its relevance score with search::score(1) and ranked highlights with search::highlight:

SELECT
  name,
  colorway,
  price,
  search::score(1)                          AS relevance,
  search::highlight('<b>', '</b>', 1)       AS snippet
FROM product
WHERE description @1@ 'silk garden print'
ORDER BY relevance DESC;

For a simple boolean "does it match," the bare operator is enough — handy for filtering:

SELECT name, colorway FROM product
WHERE description @@ 'box calf';

Stemming and lowercasing mean "hand stitching" finds the "hand-stitched" tote, and the BM25 score ranks the closest matches first.

Run the three feature sections back to back and notice what didn't happen: you never left the database. The graph traversals, the vector search, and the full-text search all run against the same product records, in the same query language, behind the same connection.

That consolidation pays off when an operation has to touch several of these at once — and stay consistent. Order fulfillment is the canonical case: decrement stock, record the sale, and write the provenance edge atomically.

BEGIN TRANSACTION;

-- decrement boutique stock for the piece
UPDATE product:jardin_emeraude->stocked_at
  SET quantity -= 1
  WHERE out = boutique:rue_mistral;

-- record the purchase as a graph edge with commercial detail
RELATE client:sofia -> purchased -> product:jardin_emeraude CONTENT {
  date: time::now(),
  price_paid: 520.00,
  boutique: boutique:rue_mistral
};

COMMIT TRANSACTION;

If either statement fails, neither is applied — no sold piece without decremented stock, no phantom inventory.

A quick word on scale and performance for a catalog like this:

  • Define indexes for the access patterns you actually have: UNIQUE on client email, HNSW on embeddings, BM25 on searchable text. The graph edges need no extra indexing to traverse.

  • Keep money in decimal, validate closed value sets (tier) with ASSERT, and let VALUE time::now() READONLY maintain audit timestamps so they cannot drift.

  • For a maison's volumes, a single instance is plenty; the same model scales out unchanged if the clientele grows, because the queries describe paths, not table layouts.

If this model fits the way your house actually works, here is how to take it further:

  • Spin up a managed instance and paste the schema in. SurrealDB Cloud gets you a running database in minutes — no local setup, just open the query editor and run the SurrealQL from this post top to bottom.

  • Go deeper on the language. The SurrealDB documentation and the SurrealQL reference cover graph traversal, vector search, and full-text indexing in full, with every operator used here.

  • Evaluating for a retail team? If you are weighing SurrealDB for clienteling, allocation, or a unified product graph, talk to us — we are happy to walk through the architecture for your scale and requirements.

Related posts

Our newsletter

Get tutorials, AI agent recipes, webinars, and early product updates in your inbox every two weeks

SurrealDB

The context layer for AI agents.

Documents, graphs, vectors, time-series, and memory.
One transaction, one query, one deployment.

Explore with AI

Stay in the loop

Tutorials, AI agent recipes, and product updates, every two weeks.

Independently verified

SOC 2 Type 2

GDPR

Cyber Essentials Plus

ISO 27001

Trust Centre

Copyright © 2026 SurrealDB Ltd. Registered in England and Wales. Company no. 13615201

Registered address: 3rd Floor 1 Ashley Road, Altrincham, Cheshire, WA14 2DT, United Kingdom

Trading address: Huckletree Oxford Circus, 213 Oxford Street, London, W1D 2LG, United Kingdom