|
| 1 | +--- |
| 2 | +title: E-commerce & Search |
| 3 | +description: Hybrid BM25 + vector product search, RRF fusion ranking, recommendation graphs, and real-time inventory analytics — without Elasticsearch, a vector database, or a separate OLAP cluster. |
| 4 | +--- |
| 5 | + |
| 6 | +E-commerce search stacks are notoriously complex: Elasticsearch for keyword search, a vector database for semantic similarity, a recommendation engine backed by a graph database, and a data warehouse for sales analytics. NodeDB provides all four as peer engines (plus Graph and FTS overlays) sharing one storage core, one query planner, and one operational surface. |
| 7 | + |
| 8 | +## Engines used |
| 9 | + |
| 10 | +| Engine | Role | |
| 11 | +|---|---| |
| 12 | +| **Full-Text Search** | BM25 keyword search, faceted filtering, autocomplete | |
| 13 | +| **Vector** | Semantic product search, visual similarity, embedding-based recommendations | |
| 14 | +| **Graph** | "Customers also bought", category navigation, brand-product relationships | |
| 15 | +| **Document (schemaless)** | Product catalog, flexible attributes, variant metadata | |
| 16 | +| **Columnar** | Sales analytics, inventory reporting, cohort analysis | |
| 17 | + |
| 18 | +## Hybrid product search |
| 19 | + |
| 20 | +The strongest e-commerce search combines keyword precision with semantic recall, then fuses the ranked lists. `rrf_score(...)` does the fusion in the planner — no application-side merge, no Elasticsearch + Pinecone glue code. |
| 21 | + |
| 22 | +```sql |
| 23 | +CREATE COLLECTION products; |
| 24 | +CREATE SEARCH INDEX ON products FIELDS title, description ANALYZER 'english' FUZZY true; |
| 25 | +-- One vector index per embedding column on the same collection. |
| 26 | +CREATE VECTOR INDEX idx_text ON products (text_embedding) METRIC cosine DIM 384; |
| 27 | +CREATE VECTOR INDEX idx_image ON products (image_embedding) METRIC cosine DIM 512; |
| 28 | + |
| 29 | +-- Hybrid search: BM25 keyword + vector semantic, fused with RRF |
| 30 | +SELECT |
| 31 | + id, |
| 32 | + title, |
| 33 | + price, |
| 34 | + brand, |
| 35 | + rrf_score( |
| 36 | + vector_distance(text_embedding, $query_vec), |
| 37 | + bm25_score(description, $query_text) |
| 38 | + ) AS relevance |
| 39 | +FROM products |
| 40 | +WHERE in_stock = true |
| 41 | + AND price BETWEEN $min_price AND $max_price |
| 42 | + AND category_id = ANY($category_ids) |
| 43 | +ORDER BY relevance DESC |
| 44 | +LIMIT 20; |
| 45 | +``` |
| 46 | + |
| 47 | +## Autocomplete and faceted filtering |
| 48 | + |
| 49 | +```sql |
| 50 | +-- Autocomplete: fuzzy title match, ranked by popularity |
| 51 | +SELECT DISTINCT id, title |
| 52 | +FROM products |
| 53 | +WHERE text_match(title, $prefix, { fuzzy: true, distance: 1 }) |
| 54 | +ORDER BY popularity_score DESC |
| 55 | +LIMIT 8; |
| 56 | + |
| 57 | +-- Facet counts: products per brand in the current keyword result set |
| 58 | +SELECT brand, count(*) AS count |
| 59 | +FROM products |
| 60 | +WHERE text_match(description, $query_text) |
| 61 | + AND in_stock = true |
| 62 | +GROUP BY brand |
| 63 | +ORDER BY count DESC |
| 64 | +LIMIT 15; |
| 65 | +``` |
| 66 | + |
| 67 | +## Visual similarity search |
| 68 | + |
| 69 | +For fashion, furniture, and home goods, image-embedding similarity lets shoppers find products that look alike — searching the `image_embedding` index created above, right alongside the catalog row: |
| 70 | + |
| 71 | +```sql |
| 72 | +-- "Shop similar items" — products whose image embedding is closest to the |
| 73 | +-- viewed one, restricted to the same in-stock category. |
| 74 | +SELECT id, title, price, image_url, |
| 75 | + image_embedding <=> $viewed_vec AS visual_distance |
| 76 | +FROM products |
| 77 | +WHERE category_id = $category_id |
| 78 | + AND in_stock = true |
| 79 | + AND id <> $viewed_product_id |
| 80 | +ORDER BY image_embedding <=> $viewed_vec |
| 81 | +LIMIT 12; |
| 82 | +``` |
| 83 | + |
| 84 | +## Recommendation graph — "Customers also bought" |
| 85 | + |
| 86 | +Co-purchase relationships are graph edges layered on the `products` collection — graph is an overlay, you don't create a separate "graph collection." Traversal-based recommendations run alongside the catalog. |
| 87 | + |
| 88 | +```sql |
| 89 | +-- A batch job recomputes co-purchase counts and (re)writes the edge with the |
| 90 | +-- current weight — DELETE then INSERT, since GRAPH INSERT EDGE is insert-only. |
| 91 | +GRAPH DELETE EDGE IN 'products' FROM $product_a TO $product_b TYPE 'co_purchased'; |
| 92 | +GRAPH INSERT EDGE IN 'products' FROM $product_a TO $product_b TYPE 'co_purchased' |
| 93 | + PROPERTIES { weight: $copurchase_count }; |
| 94 | + |
| 95 | +-- "Customers also bought": direct co-purchase neighbours of a product, |
| 96 | +-- ranked by co-purchase weight. Graph nodes are document rows, so their |
| 97 | +-- catalog fields are available in RETURN. |
| 98 | +MATCH (seed:Product)-[e:co_purchased]->(rec:Product) |
| 99 | +WHERE seed.id = $product_id |
| 100 | + AND rec.in_stock = true |
| 101 | +RETURN rec.id, rec.title, rec.price, e.weight AS affinity |
| 102 | +ORDER BY e.weight DESC |
| 103 | +LIMIT 8; |
| 104 | +``` |
| 105 | + |
| 106 | +## Real-time inventory — LIVE SELECT |
| 107 | + |
| 108 | +Push in-stock / out-of-stock transitions to product pages without polling. `LIVE SELECT` registers a query and streams matching changes over pgwire, WebSocket, or the native protocol. |
| 109 | + |
| 110 | +```sql |
| 111 | +-- A shopper viewing their wishlist subscribes to back-in-stock events |
| 112 | +LIVE SELECT id, title FROM products |
| 113 | +WHERE id = ANY($wishlist_ids) |
| 114 | + AND in_stock = true; |
| 115 | + |
| 116 | +-- When a back-ordered item is restocked, every subscriber gets the row |
| 117 | +-- delivered immediately. Cancel with: CANCEL LIVE SELECT <subscription_id>; |
| 118 | +``` |
| 119 | + |
| 120 | +## Sales analytics — Columnar engine |
| 121 | + |
| 122 | +The Columnar engine handles aggregation-heavy analytics across millions of orders without a separate data warehouse — per-column compression, block statistics, and predicate pushdown. |
| 123 | + |
| 124 | +```sql |
| 125 | +CREATE COLLECTION orders ( |
| 126 | + id TEXT PRIMARY KEY, |
| 127 | + placed_at TIMESTAMP TIME_KEY, |
| 128 | + status TEXT |
| 129 | +) WITH (engine='columnar'); |
| 130 | + |
| 131 | +-- Revenue by category, last 30 days |
| 132 | +SELECT |
| 133 | + c.name AS category, |
| 134 | + count(DISTINCT o.id) AS orders, |
| 135 | + sum(oi.quantity * oi.price) AS revenue, |
| 136 | + avg(oi.quantity * oi.price) AS avg_order_value |
| 137 | +FROM orders o |
| 138 | +JOIN order_items oi ON oi.order_id = o.id |
| 139 | +JOIN products p ON p.id = oi.product_id |
| 140 | +JOIN categories c ON c.id = p.category_id |
| 141 | +WHERE o.placed_at >= now() - INTERVAL '30 days' |
| 142 | + AND o.status = 'completed' |
| 143 | +GROUP BY c.name |
| 144 | +ORDER BY revenue DESC; |
| 145 | + |
| 146 | +-- Conversion funnel: views → add-to-cart → purchase |
| 147 | +SELECT |
| 148 | + time_bucket('1d', e.ts) AS day, |
| 149 | + count(CASE WHEN e.event = 'product_view' THEN 1 END) AS views, |
| 150 | + count(CASE WHEN e.event = 'add_to_cart' THEN 1 END) AS add_to_cart, |
| 151 | + count(CASE WHEN e.event = 'purchase' THEN 1 END) AS purchases, |
| 152 | + round( |
| 153 | + count(CASE WHEN e.event = 'purchase' THEN 1 END)::numeric |
| 154 | + / nullif(count(CASE WHEN e.event = 'product_view' THEN 1 END), 0) * 100, |
| 155 | + 2 |
| 156 | + ) AS conversion_pct |
| 157 | +FROM events e |
| 158 | +WHERE e.ts >= now() - INTERVAL '14 days' |
| 159 | +GROUP BY day |
| 160 | +ORDER BY day; |
| 161 | +``` |
| 162 | + |
| 163 | +<Callout kind="tip" title="Matryoshka embeddings for tiered search"> |
| 164 | +Store full-precision embeddings and rank the first N dimensions for a fast first pass via the `query_dim` tuning argument, then re-rank the top candidates at full precision — no reindexing: |
| 165 | + |
| 166 | +```sql |
| 167 | +SELECT id, title, vector_distance(text_embedding, $query_vec, query_dim => 256) AS distance |
| 168 | +FROM products |
| 169 | +WHERE in_stock = true |
| 170 | +ORDER BY distance |
| 171 | +LIMIT 100; |
| 172 | +``` |
| 173 | +</Callout> |
| 174 | + |
| 175 | +## Why not Elasticsearch + a vector database? |
| 176 | + |
| 177 | +Elasticsearch handles keyword search but requires a separate vector database for semantic recall, a graph database for recommendations, and a data warehouse for analytics. Synchronising product-catalog changes across four systems is the dominant operational burden. NodeDB keeps every engine in the same process: a hybrid search + recommendation + inventory check is one SQL statement, not four API calls. |
0 commit comments