Build a Product Recommendation Engine in Pure SQL
Key Takeaways
- Actian Zen V17 AI brings vector embeddings, similarity search, and vector operations directly into SQL.
- The tutorial builds a semantic product recommendation engine without requiring a separate vector database or data synchronization layer.
- Product embeddings are stored in a vector collection and searched by similarity to identify related items.
- Standard SQL JOINs combine vector rankings with live business rules such as inventory, pricing, and product availability.
- The same embed, search, and JOIN pattern can support semantic search, fraud detection, deduplication, and other AI-powered applications.
If you are building AI-powered applications, you are probably juggling multiple systems: your primary database, a separate vector store, maybe a caching layer in between. What if you could ditch that complexity?
With native VectorAI support, Actian Zen V17 AI lets you manage embeddings, perform similarity searches, and handle vector operations directly in SQL. No context switching, no external API calls, no data syncing between systems, Just SQL you already know, plus one new function.
The Problem
A customer on your e-commerce site opens the product page for a “Gaming Laptop Pro.” You want to show a “Customers also viewed” rail underneath it — powered by semantic similarity, not just “same category.”
The usual way to build that means standing up a separate vector database, writing a sync job to keep it current with your product table, and adding a network hop between your app and two data stores instead of one.
Actian Zen V17 AI folds the vector store into the SQL engine itself. Embeddings, similarity search, and CRUD operations on vector “collections” all run as ordinary SQL statements against the same connection you already use for the products table. This walkthrough builds the recommendation feature above, end to end, in about 20 minutes.
WHAT YOU’LL NEED: Actian Zen V17 AI, an ODBC connection (pyodbc works fine), and Python with sentence-transformers installed for generating embeddings locally, no OpenAI key required for this walkthrough.
Architecture Overview
Before the first query, here’s what’s actually happening underneath dbo.vector_collection_search(): your application talks to Zen V17’s SQL engine exactly as it always has, and the SQL engine talks to a new VDE (Vector Data Engine) layer on your behalf.
| Your Application
Python · Node.js · Java · C# |
→ | Zen V17 SQL Engine
SRDE · DBO table functions |
→ | VDE Engine
VectorAI DB · localhost:6573 |
→ | Vector Store
Collections on disk |
Actian Zen V17 AI VectorAI architecture, the SQL engine bridges relational data with VDE vector search.
Every request, a search or a write makes the same round trip:
| 1
Generate Embedding App converts text/images to float arrays |
→ | 2
SET VDE_CREDENTIAL Session-scoped auth, once per connection |
→ | 3
dbo.vector_* Call Bare SQL statement via ODBC, no CALL needed |
→ | 4
VDE Processes ANN search or write on the vector index |
→ | 5
SQL Post-Processing WHERE / JOIN / ORDER BY on the result set |
→ | 6
App Receives Results u64_id, score, payload |
Data flow from application embedding generation through the SQL engine to VDE and back.
The credential handshake is worth calling out on its own, since it’s the one step that’s easy to get wrong the first time:
| Auth Token
Issued by VDE, starts with vdai_… — never stored in query text or logs |
| Session Variable
SET VDE_CREDENTIAL = ‘token’ — set once per connection, expires on disconnect |
| VDE Access Granted
Unlocks vector_collection_create / vector_point_upsert / vector_collection_search, all CRUD |
VDE credential flow, the token is stored as a session variable and filtered from all logs and audit trails.
STEP 1 – Connect and Authenticate
Connect over ODBC using the Zen driver. Some instances also require a VDE (Vector Data Engine) session credential before any vector_* call set it once per connection if yours does.
import pyodbc
conn = pyodbc.connect(
"Driver={Pervasive ODBC Interface};serverName=localhost;DBQ=Demodata;"
)
cur = conn.cursor()
# Only needed if your instance requires it — if this raises
# "Invalid SET statement", your instance authenticates via the
# ODBC connection itself and you can skip this line.
# cur.execute("SET VDE_CREDENTIAL = 'vdai_your_token_here'")
WHY IT MATTERS: On instances that do use it, the token lives in a session variable instead of the query text, so it never shows up in the plan cache, slow-query log, or audit trail.STEP 2 Create a Vector Collection
A collection is where your product embeddings live. Pick a dimension that matches your embedding model and a distance metric that fits the data, cosine is the standard choice for text embeddings.
-- 384 dimensions matches the sentence-transformers model we'll use below
dbo.vector_collection_create ( 'product_embeddings',
'{
"dimension" : 384,
"distance_metric" : "cosine"
}' );
NOTE: Dimension is fixed at creation time. If you switch embedding models later, drop and recreate the collection — there’s no in-place resize.
STEP 3 – Generate and Load Product Embeddings
Here’s the catalog we’re indexing, five products, each with a name and description that gets embedded:
| ID | Name | Description |
| 1 | Gaming Laptop Pro | 32GB RAM, RTX graphics, 165Hz display |
| 2 | Business Ultrabook | Lightweight, all-day battery, fingerprint reader |
| 3 | Mechanical Keyboard | RGB backlit, hot-swappable switches |
| 4 | 4K Monitor 27″ | HDR, USB-C, height-adjustable stand |
| 5 | Wireless Gaming Mouse | 16000 DPI, programmable buttons |
Encode each description with a local embedding model, then load it into the collection:
from sentence_transformers import SentenceTransformer
import json
model = SentenceTransformer('all-MiniLM-L6-v2') # 384-dim, runs locally
products = [
(1, 'Gaming Laptop Pro', '32GB RAM, RTX graphics, 165Hz display'),
(2, 'Business Ultrabook', 'Lightweight, all-day battery, fingerprint reader'),
(3, 'Mechanical Keyboard', 'RGB backlit, hot-swappable switches'),
(4, '4K Monitor 27"', 'HDR, USB-C, height-adjustable stand'),
(5, 'Wireless Gaming Mouse', '16000 DPI, programmable buttons'),
]
# Batch-encode all descriptions in one model call…
texts = [f"{name}. {desc}" for _, name, desc in products]
vectors = model.encode(texts).tolist()
# …then load them all in a single dbo.vector_point_upsert_batch call.
ids = [pid for pid, _, _ in products]
payloads = [json.dumps({'name': name}) for _, name, _ in products]
# Escape single quotes in the assembled JSON — real product names/
# descriptions can contain apostrophes, which would otherwise break
# out of the SQL string literal below.
ids_json = json.dumps(ids).replace("'", "''")
vectors_json = json.dumps(vectors).replace("'", "''")
payloads_json = json.dumps(payloads).replace("'", "''")
cur.execute(f"""
dbo.vector_point_upsert_batch (
'product_embeddings',
'{ids_json}',
null,
'{vectors_json}',
'{payloads_json}',
{len(ids)},
384
)
""")
conn.commit()
NOTE: There’s also a dbo.vector_point_upsert_batch function that packs several vectors into one call.
STEP 4 – Run the Similarity Search
Now the payoff: a customer views the Gaming Laptop Pro. Embed the query the same way you embedded the catalog, then search:
query_vector = model.encode("Gaming Laptop Pro. 32GB RAM, RTX graphics, 165Hz display").tolist()
cur.execute(f"""
SELECT TOP (3)
a.u64_id, a."SIMILARITY_SCORE", a.payload
FROM dbo.vector_collection_search (
'product_embeddings', 'vector',
'{json.dumps(query_vector)}', 3, 384
) a
WHERE a.u64_id != 1 -- exclude the product being viewed
ORDER BY a."SIMILARITY_SCORE" DESC
""")
for row in cur.fetchall():
# Positional indexing — unquoted identifiers get case-folded
# by the engine, so row.<name> attribute access isn't reliable.
print(row[0], row[1], row[2]) # u64_id, SIMILARITY_SCORE, payload
Output on this catalog (measured against a real all-MiniLM-L6-v2 run — your exact numbers may vary slightly by model version, but the ranking holds):
| Rank | Product | Similarity |
| 1 | Wireless Gaming Mouse | 0.44 |
| 2 | 4K Monitor 27″ | 0.37 |
| 3 | Mechanical Keyboard | 0.25 |
The gaming mouse tops the list, with the monitor and keyboard close behind — the model is picking up shared vocabulary around specs and electronics, not a hardcoded “gaming” category. Notice the scores themselves are modest: real cosine similarity on short technical phrases from a small model like this one typically lands well under 1.0 even for genuinely related items — don’t expect near-perfect scores outside of near-duplicate text.
NOTE: If you’re used to embedding demos that show 80–90% similarity for related items, that’s usually with longer, more semantically rich text. Short spec-sheet phrases like these compress less distinctively, so scores in the 0.2–0.5 range for genuinely related items are normal here.
STEP 5 – Add Business Rules with a JOIN
A raw similarity ranking isn’t a shippable recommendation yet, you still need to respect stock levels and price. Because results come back as a normal result set, you JOIN it against your products table like any other query:
cur.execute(f"""
SELECT TOP (3)
p.product_id, p.name, p.price, p.in_stock,
sr."SIMILARITY_SCORE"
FROM dbo.vector_collection_search (
'product_embeddings', 'vector',
'{json.dumps(query_vector)}', 10, 384
) sr
INNER JOIN products p ON CAST(sr.u64_id AS INTEGER) = p.product_id
WHERE sr."SIMILARITY_SCORE" > 0.15
AND p.in_stock = 1
AND p.product_id != 1
ORDER BY sr."SIMILARITY_SCORE" DESC
""")
Notice the search pulls 10 candidates instead of 3 — leave headroom before the relational filters run, so an out-of-stock item doesn’t shrink your final rail to two products. On this catalog the 4K Monitor actually has the second-highest raw similarity score (0.37, ahead of the keyboard’s 0.25), but it’s out of stock, so the JOIN correctly drops it and the keyboard and ultrabook take its place. That’s the entire point of layering SQL filters on top of the vector search: the ranking a model produces and the ranking you can actually ship are two different things.
STEP 6 – Pick a Similarity Threshold
The threshold above (0.15) is deliberately low — it’s there to demonstrate the JOIN mechanics, not as a production recommendation. Vector similarity scores only mean something relative to your use case, your embedding model, and your data’s typical phrasing. Treat the table below as a starting point to calibrate from, not a universal constant:
| Use case | Typical relative range | Why |
| Deduplication | Highest tier | Near-identical records only |
| Strict recommendation | High tier | Same product line |
| Recommendation (this tutorial) | Mid tier, 0.15–0.40 here | Related, not identical |
| Semantic search | Low-mid tier | Broadly on topic |
| Anomaly / fraud detection | Lowest tier | Flag what doesn’t match known patterns |
Don’t treat the specific numbers as portable — our own Step 4 results (0.44 down to 0.25 for genuinely related products) sit far below the 0.65–0.85 range you’ll see quoted in tutorials built on longer, more semantically rich text. Model, embedding dimension, and phrase length all shift the absolute scale. What transfers is the relative ordering: dedup > strict match > loose recommendation > broad topic match > outlier. Start permissive, log the scores your queries actually return in production, and tighten the threshold once you can see your own real distribution.
Where Else This Pattern Applies
The recommendation engine above is one shape of a more general pattern: embed → search → JOIN with relational rules. The same three steps carry over directly:
- Semantic document search — swap product descriptions for support articles or knowledge-base entries; same search call, higher top-N.
- Fraud detection — embed transaction patterns instead of product text, then bucket the returned SIMILARITY_SCORE into risk tiers with a CASE statement.
- Deduplication — same search call, threshold pushed up above 0.95, used to flag near-identical customer or catalog records before insert.
Quick Reference: The Functions You Used
Everything above the API is bare SQL, no stored-procedure wrapper. Keep this table nearby while you build:
| Operation | Function |
| Create a collection | dbo.vector_collection_create(name, config) |
| Insert or update one vector | dbo.vector_point_upsert(collection, id, vector, payload) |
| Insert many vectors | dbo.vector_point_upsert_batch(collection, ids, uuids, vectors, payloads, count, dim) |
| Search | dbo.vector_collection_search(collection, field, query_vector, topN, dim) |
| Delete one vector | dbo.vector_point_delete(collection, id) |
| Delete many vectors | dbo.vector_point_delete_batch(collection, ids, uuids, count) |
| Drop a collection | dbo.vector_collection_drop(name) |
What You Built
In six steps, and without leaving SQL, you: created a vector collection, loaded real product embeddings with a single batch call, ran a semantic similarity search, filtered it against live inventory and pricing with a plain JOIN, and picked a defensible threshold instead of guessing one.
From here, the same pattern scales to your actual catalog: swap the five-row example for a batch job over your full products table, point the search at the product a logged-in customer is currently viewing, and cache the top-N results for your “customers also viewed” rail.
TRY IT NEXT: Take the fraud-detection variant from “Where Else This Pattern Applies” and adapt Step 4–6 above to bucket SIMILARITY_SCORE into risk tiers with a CASE statement instead of a WHERE clause.