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.
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.
"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.
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.
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.
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.
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
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} ] }'
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 } ]
"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"] }
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"} }
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
Relatedness is centred on chance, which is what makes a threshold mean something. Measured on 125,000 synthetic products with ten properties:
| Relatedness | What it was | What to do with it |
|---|---|---|
| 0.53 – 0.89 | a product and its true best match in the population, 10th to 90th percentile | Accept. This is what a correct match looks like. |
| 0.30 | an exact match on three properties out of ten | Accept or review, depending on how much of your feed is sparse. |
| −0.20 – 0.30 | random pairs of products, 10th to 90th percentile | Reject. Chance produces this. |
| 0.045 | {"type": "mug"} against {"type": "cup"} — one matching property out of ten | One 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.
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.
capacity_ml and "0.35 l" reconcile; a unit written nowhere cannot be guessed, and currencies are deliberately left alone.One container, your own machines, and a catalogue you already have. Nothing leaves the deployment, and there is nothing to train.