Product Catalogues as Graphs
11 min read · updated August 4, 2026
A product catalogue is the most common knowledge graph in commercial use and it is usually not modelled as one. The three decisions that determine whether it will answer real questions are made in the first week: separating product from variant, typing the attributes, and deriving compatibility rather than listing it.
Product, variant, offer: three things, not one
The single most expensive modelling mistake in a catalogue is one node for “a product”. Three distinct things wear that name:
| Node | Description |
|---|---|
| Product | The abstract thing a customer has in mind: 'the RX-40 gearbox'. Carries the description, the imagery, the reviews, the category. |
| Variant (SKU) | The physically distinct orderable item: RX-40, 200 Nm, flange mount, black. Carries dimensions, weight, barcode, certifications. |
| Offer | A price for a variant, from a seller, in a currency, for a market, over a period. Several offers per variant is normal and the price does not belong on the variant. |
(p:Product {id: 'pr_rx40', name: 'RX-40 harmonic drive'})
-[:HAS_VARIANT]->(v:Variant {sku: 'RX40-200-FL-BK', gtin: '04012345678901'})
-[:OFFERED_AS]->(o:Offer {price_minor: 129900, currency: 'EUR',
market: 'DE', valid_from: date('2026-07-01')})Collapsing these produces the failures every catalogue eventually has: reviews attached to one colour, a price that cannot vary by market, and a search result page listing the same product eleven times because eleven variants matched. Prices as integer minor units rather than floats is the other rule, for the reasons in storing money as integers.
Attributes are typed values, not strings
Attribute chaos is what makes catalogues unqueryable. The same specification arrives from four suppliers as "16GB", "16 GB", "16384 MB" and "16gb DDR5". As strings, those are four values and no filter works. The fix is to model the attribute definition as a node, with a unit and a datatype, and store the value in a canonical unit:
(:AttributeDef {id: 'attr_torque', name: 'rated torque',
datatype: 'decimal', unit: 'Nm', unit_dimension: 'torque'})
(v:Variant)-[:HAS_ATTRIBUTE {value_num: 200.0, unit: 'Nm',
raw: '200 Nm', source: 'supplier_feed_7'}]
->(:AttributeDef {id: 'attr_torque'})Three things fall out of that shape. Range filters work, because the value is numeric and in one unit. The original string survives in raw, so a normalisation bug is diagnosable instead of destructive. And unit_dimension lets a validator reject a torque expressed in millimetres, which is a real class of supplier feed error and is invisible when everything is a string.
The attribute definitions themselves are a controlled vocabulary and should be governed like one — see ontologies, taxonomies and schemas. A catalogue that has acquired both rated torque and torque (rated) as separate definitions has already lost, and only a person can merge them.
Compatibility, and why curated pairs fail
“Does this fit that” is the highest-value question a catalogue answers and the one most often implemented as a hand-curated list of pairs. Do the arithmetic before choosing that path.
40 chassis models x 200 candidate parts = 8,000 pairs to curate add one chassis model: +200 pairs add one part: + 40 pairs add a second dimension (e.g. 3 mounting kits): 8,000 x 3 = 24,000 triples at 15 seconds of expert time per decision, 24,000 triples is 100 hours — and it is wrong again after the next release
The alternative is to model the reason things fit. Fitment is almost always a conjunction of interface constraints — a bolt pattern, a shaft diameter, a voltage range, a clearance — and those are properties of each side that already exist:
// derive fitment from interface constraints
MATCH (c:Chassis {model: 'RX-40'})-[:HAS_INTERFACE]->(i:Interface)
MATCH (p:Variant)-[:HAS_INTERFACE]->(j:Interface)
WHERE i.bolt_pattern = j.bolt_pattern
AND i.shaft_mm = j.shaft_mm
AND j.max_torque_nm >= c.required_torque_nm
AND j.voltage_min <= c.supply_v AND c.supply_v <= j.voltage_max
RETURN p.sku, p.name
ORDER BY p.skuNow adding a chassis adds one node and its interface, and every compatible part is found without a single curated pair. The curated list does not disappear entirely — it becomes the exception table: explicit :KNOWN_INCOMPATIBLE edges for the cases where the constraints say yes and reality says no, each with a reason and a source. That table is small, high-value, and exactly the kind of thing domain experts should be spending their time on.
Categories and facets are different
A category is where a product lives in a hierarchy; a facet is a filter in a result set. Conflating them produces a category tree with branches like “Gearboxes / Black / Over 200 Nm”, which is a combinatorial explosion of categories that must be maintained by hand.
- Categories are a taxonomy with
broader/narrower, ideally one primary path per product plus secondary placements. They exist for navigation and for reporting. - Facets are attribute definitions marked as filterable, with a display order and a value type. The facet list for a category is a property of the category, so a gearbox listing filters on torque and a cable listing filters on length without any special-case code.
- Facet counts require the complete set, which is the aggregation case from questions vectors cannot answer. A facet count computed over the top 50 similarity results is wrong and looks right. See also faceted search.
What the model buys you
The payoff is that several expensive questions become the same query shape over one graph, rather than four systems.
Recall impact. A component is recalled; which finished goods, and which customers, are affected? This is the multi-hop traversal, and having it take a minute rather than a week is the strongest business case a product graph has:
MATCH (c:Component {id: $recalled})
MATCH (c)<-[:CONTAINS*1..6]-(v:Variant)
MATCH (v)<-[:ORDERED]-(o:Order)-[:PLACED_BY]->(cust:Customer)
WHERE o.shipped_at > date('2025-01-01')
RETURN cust.id, cust.email, collect(DISTINCT v.sku) AS affected_skusCompleteness for a build. “What else do I need to make this work” is a query for required interfaces the customer has not covered, and it is the single most effective attachment mechanism in technical retail — much better than the co-purchase statistics used in recommendation systems, because it is a fact rather than a correlation.
Substitution. When a variant is out of stock, a substitute is any variant satisfying the same interface constraints within tolerance. That is the fitment query with the chassis replaced by the unavailable part, which is the kind of reuse a shared model exists to produce.
Where this model breaks
Three cases defeat the product/variant/offer shape as written, and it is better to know which one you are in before the catalogue is loaded:
- Configurable products. A machine ordered with fourteen independent options has no enumerable variant set — 214 is 16,384 combinations before any of them is a real SKU. Model the options and their compatibility rules instead, and generate the configuration at order time. The variant node then represents a configuration that was actually ordered, which is the only version of it that is finite.
- Bundles and kits. A kit is a variant that contains other variants, so
:CONTAINSbecomes recursive and stock, price and lead time all have to be derived from the components rather than stored. Derive them; a kit with its own stored stock level diverges from its parts within a week. - Marketplaces. When third parties supply the offers, two sellers describe one variant differently and neither is authoritative. That is entity resolution on product data, with the additional problem that a false merge attaches one seller’s reviews and returns to another’s item. Barcodes help and are not sufficient, because they are reused and mistyped.
The general rule underneath all three: the variant is the thing you can physically pick from a shelf, and anything that cannot be picked is a derivation rather than a node. Where the catalogue disagrees with that rule it is usually because a supplier feed defined a SKU that does not correspond to an object, which is worth catching at ingestion rather than modelling around forever.