---
title: "One graph for the whole maison: luxury retail on SurrealDB"
description: "How a high-end fashion house can model clients, artisans, products, and waitlists as one graph — with vector recommendations and full-text search built in."
url: https://surrealdb.com/blog/one-graph-for-the-whole-maison-modeling-luxury-retail-on-surrealdb
date: 2026-07-23
authors: "Martin Schaer"
---

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

![One graph for the whole maison: luxury retail on SurrealDB](https://cdn.surrealdb.com/04t7vsyxvc28logqvciyyxlo.auto)

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](https://surrealdb.com/). 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, and no second vector store.
- It also does full-text search over craftsmanship notes with a BM25 index, and no Elasticsearch alongside it.
- Everything here is copy-paste SurrealQL. [Spin up an instance and follow along.](https://app.surrealdb.com)

## 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:

```text
        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.

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

```surql
-- 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, so 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.

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

```surql
-- 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, which is useful for importing a season's catalog from a CSV export:

```surql
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.

```surql
-- 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?**

```surql
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:

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

**Clients served by a given associate**:

```surql
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:

```surql
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, with 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:

```surql
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 `<|K, EF|>` operator, where `K` is neighbors and `EF` is candidates considered:

```surql
-- $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 `<|K, EF|>`) 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:

```surql
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`:

```surql
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, and handy for filtering:

```surql
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.

```surql
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.

## 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](https://surrealdb.com/cloud) gets you a running database in minutes, with 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](https://surrealdb.com/docs) and the [SurrealQL reference](https://surrealdb.com/docs/surrealql) 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](https://surrealdb.com/contact). We are happy to walk through the architecture for your scale and requirements.
