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.
The luxury data problem
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.
The shape of the model
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 thecraftededge.boutique— physical locations; products arestocked_atthem.associate— boutique staff; a client isserved_byone.
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.
Defining the schema
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:
tieruses anASSERT ... IN [...]to keep the value set closed — no stray client tiers.emailis validated with the built-instring::is_emailand made unique with an index.embeddingis an optional vector field; we will index it for similarity search later.decimal(notfloat) is the right type for money.
Seeding the atelier
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 }
];
Relationships that matter
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.
Recommendations without a second system
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:
DIMENSIONmust match your embedding model (384 here is a common sentence-embedding size).DIST COSINEsuits 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.
Search the way an associate thinks
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.
Why one engine
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:
UNIQUEon 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) withASSERT, and letVALUE time::now() READONLYmaintain 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.
Next steps
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.