Use case · record matching

Matching a supplier feed against your catalogue.

A supplier sends 40,000 rows. They call the colour colour, measure in millimetres where you use centimetres, leave half the fields empty and have never heard of your SKUs. You need to know which of your products each row is — and how sure that is, row by row, so a person only looks at the ones worth looking at.

Why it's hard

The rows don't line up, and the rules never end.

Every integration starts with "we'll just match on name and size" and ends in a thousand lines of special cases. The usual tools break in predictable ways.

String matching

"Hav mug 350 ml" against "Mug, Hav, 0.35 l" is a near-miss for edit distance and a direct hit for a person. Spelling is not meaning, and a threshold that catches one pair wrecks another.

One embedding per row

Flatten the row to a sentence and capacity_ml: 350 becomes a word. Quantities stop being quantities, a missing field reads as a difference, and nothing can tell you which field made the match.

Hand-written rules

Accurate for the supplier you wrote them for. The next feed names things differently and you start again — and nobody can say what the rules will do to a row they've not seen.

The shape of it

Learn the catalogue once. Ask it about every row.

Your catalogue is the population; the feed is a stream of questions about it. Nothing is trained, nothing is labelled, and no field mapping is written by hand.

01 · LEARN

Your catalogue becomes a Space

Post the catalogue once. Hilliance works out what the properties are, what kind each is, how informative each is, and which ones are saying the same thing twice. Nothing is configured.

Along the way it reconciles two things that cost integrations the most time: differently named fields that hold the same thing, and units — a population in centimetres keeps reporting centimetres, and radius_mm arriving later lands where it belongs.

curl -X POST localhost:8080/spaces \
  -H 'content-type: application/json' -d '{
  "name": "catalogue",
  "id_field": "sku",
  "objects": [ … your products … ]
}'

# bigger than one request body? stream it:
curl -X POST 'localhost:8080/spaces?name=catalogue&id_field=sku' \
  -H 'Content-Type: application/x-ndjson' \
  --data-binary @catalogue.jsonl
02 · MATCH

Send the whole feed in batches

Up to 10,000 queries per request, and the results are identical to running each one on its own. Rows are encoded in parallel and each query keeps its own pruning.

The feed's own vocabulary is fine: unfamiliar keys resolve onto known properties when the name is close enough and the value fits, and every resolution is reported back in resolved_properties.

curl -X POST localhost:8080/spaces/catalogue/search/batch \
  -H 'content-type: application/json' -d '{
  "queries": [
    {"object": {"title": "Hav mug", "colour": "mustard",
                "capacity_ml": 350}, "limit": 3},
    {"object": {"title": "Lugn teacup", "color": "sage",
                "diameter_mm": 95}, "limit": 3}
  ]
}'
03 · DECIDE

Relatedness sorts the queue for you

Every match carries four numbers. Relatedness is the one to automate on: it puts chance at 0 and weighs how much evidence there was, so it is comparable across rows of the same feed.

min_relatedness is also a filter, and an exact one — blocks that cannot reach it are never read, so a stricter threshold is cheaper, not dearer.

// the shape of an answer; the numbers depend on your data
"matches": [
  { "id": "SKU-4471",
    "relatedness": 0.74,   // evidence beyond chance
    "similarity":  0.98,   // alike on what they share
    "coverage":    0.61,   // how much that was
    "score":       0.69 }
]
04 · CONSTRAIN

Some things are not a question of similarity

"Like this mug" is for the model. "From this supplier", "in this brand", "under 25" are not — they are literal, and they belong in filters, which read the same block summaries pruning uses and skip whole blocks before anything is scored.

A column that decides who owns a row and says nothing about how alike two rows are can be marked filter_only: filterable, stored, but never evidence.

"filters": [
  { "property": "brand",  "any": ["hägerlund"] },
  { "property": "in_stock", "any": ["true"] }
]

// learning, to keep a column out of the model entirely
"options": { "filter_only": ["supplier_id"] }
05 · EXPLAIN

The review queue gets reasons, not scores

Ask for explain and each property reports its own similarity, its learned weight and its status. A reviewer sees the colour agreed, the capacity agreed, the brand was missing on their side — not a number between 0 and 1.

Because a Space is versioned and immutable, a decision made last Tuesday can be reproduced exactly: pin the version and ask again.

"contributions": {
  "colour":      {"similarity": 0.98, "weight": 0.85,
                 "status": "compared"},
  "capacity_ml": {"similarity": 1.00, "weight": 0.64,
                 "status": "compared"},
  "brand":       {"status": "missing_in_a"}
}
06 · KEEP UP

The catalogue moves; the Space follows

Upsert and delete by id. A small change set reuses the learned model and ships in milliseconds — at four million objects in sixteen shards, changing three objects took 18 ms, because only the shards it touches are rewritten.

Every change makes a new version, and the old one keeps answering exactly as before, so a matching run can pin a version and finish against the catalogue it started with.

curl -X POST localhost:8080/spaces/catalogue/objects \
  -H 'content-type: application/json' -d '{
  "objects": [{"sku": "SKU-4471", "price": 24.90}],
  "delete":  ["SKU-0098"]
}'

# → catalogue-v7, and catalogue-v6 still answers
Picking thresholds

What the numbers look like in practice.

Relatedness is centred on chance, which is what makes a threshold mean something. Measured on 125,000 synthetic products with ten properties:

RelatednessWhat it wasWhat to do with it
0.53 – 0.89a product and its true best match in the population, 10th to 90th percentileAccept. This is what a correct match looks like.
0.30an exact match on three properties out of tenAccept or review, depending on how much of your feed is sparse.
−0.20 – 0.30random pairs of products, 10th to 90th percentileReject. Chance produces this.
0.045{"type": "mug"} against {"type": "cup"} — one matching property out of tenOne field agreeing is not evidence about a ten-field product.

Thresholds are yours, and they are per Space. The same two rows score differently in a catalogue of mugs and a catalogue of everything, because the baseline moves with the population — so calibrate once against your own data, with a few hundred rows a person has already judged, and keep the number with the Space it was measured in.

What it costs to run

A feed is a batch job, and batches are what it's built for.

10,000queries in one request, results identical to running each alone
6,600/sbatched member queries against a 2M-object Space
5.2×500 queries as one batch over HTTP, against one at a time
128 µsto rank 200 candidates that came from somewhere else

Benchmarks from the engine's own suite on an Apple M3 Pro; the figures and their conditions are in the performance section of the documentation. If your candidates come from an inventory query or another system rather than from search, /rank scores exactly the ids you send, with the same numbers and order search would have produced.

Before you build on it

  • It needs a population. Distributions mean something with hundreds of products and a lot more with thousands. A catalogue of fifty is not enough for the statistics to say anything.
  • The decision is still yours. Hilliance returns evidence, not verdicts. Where the cutoff sits between accept, review and reject is a business decision, and it should be measured against rows a person has judged.
  • Numbers from different Spaces are not comparable. A threshold calibrated on your catalogue means nothing in someone else's.
  • Units are read from names and values. capacity_ml and "0.35 l" reconcile; a unit written nowhere cannot be guessed, and currencies are deliberately left alone.

Try it on one real feed.

One container, your own machines, and a catalogue you already have. Nothing leaves the deployment, and there is nothing to train.