Build a Support Ticket Search Tool With Python
Key Takeaways
- Semantic search helps support teams find previously resolved tickets even when new requests use completely different wording.
- The tutorial uses Python, SQL, and Actian Zen V17 AI to embed ticket text, store vectors, and retrieve similar past issues.
- Vector similarity search matches tickets by meaning instead of relying on exact keywords or field values.
- SQL joins and status filters ensure agents only surface relevant tickets that already have verified resolutions.
- The same pattern can support knowledge base search, duplicate bug detection, and onboarding FAQ matching without a separate vector database.
A new ticket comes in: “App won’t let me sign in after the last update.” Somewhere in your history is a near-identical ticket, already solved, with the fix written down. Keyword search won’t find it if the wording doesn’t match. Semantic search will, and you can build it in pure Python plus SQL, with no separate vector database.
The Problem
Support teams re-solve the same handful of issues over and over because past resolutions are buried in a ticket system that only does keyword or exact-field search. “Can’t log in after update” and “App won’t let me sign in after the last update” are the same problem in a support agent’s eyes, but a LIKE ‘%login%’ query won’t connect them.
This tutorial builds a small tool that fixes that: encode every ticket’s text as a vector, store it in Actian Zen V17 AI, and when a new ticket comes in, search for the closest matches semantically, then filter those matches down to ones that were actually resolved.
WHAT YOU’LL NEED: Python 3.8+, the pyodbc and sentence-transformers packages, and an Actian Zen V17 AI database reachable over ODBC. No API keys are needed; embeddings run locally using these packages.
Architecture Overview
Before writing any code, here’s the full picture of what you’re connecting: your Python process, Zen V17’s SQL engine, and the VDE (Vector Data Engine) layer underneath it. VDE runs as a companion process bundled with Zen (localhost:6573 REST / 6574 gRPC, dashboard on 6575).
| Python App
sentence_transformers + pyodbc |
→ | Zen V17 SQL Engine
SRDE · dbo.vector_* |
→ | VDE Engine
VectorAI DB · localhost:6573 |
→ | Vector Store
HNSW index collections on disk |
Python connects to Zen V17 AI via pyodbc — the SQL engine bridges ML embeddings and VDE vector search.
Each request makes the same six-hop round trip, whether you’re indexing a ticket or searching one:
| 1
Text Input Ticket subject string |
→ | 2
model.encode() sentence_transformers → 384-dim float[] |
→ | 3
pyodbc Execute cursor.execute(sql) via ODBC |
→ | 4
Zen SQL Engine dbo.vector_point_upsert / search() |
→ | 5
VDE ANN Search Cosine similarity, top-K |
→ | 6
Python Results u64_id, score, payload |
Complete data flow from raw text through the ML model, ODBC connection, Zen SQL engine, VDE search engine, and back to Python.
Stacked by responsibility, the same system looks like this:
| Application Layer
Python 3.8+ · sentence_transformers · pyodbc |
| Data Layer
pyodbc.connect(DSN) · cursor.execute(sql) |
| SQL Engine Layer
Actian Zen V17 · SRDE · dbo.vector_* functions |
| Vector Engine Layer
VectorAI DB (VDE) · REST :6573 · gRPC :6574 |
| Storage Layer
HNSW index · Float32 binary · JSON payloads |
Five-layer stack — each layer handles one responsibility. You’ll only write code against the top two.
STEP 1 – Install, Connect, and Authenticate
pip install pyodbc sentence-transformers
Then connect. Some instances also require a VDE session credential before any vector_* call — set it once per connection if yours does:
import pyodbc, json
from sentence_transformers import SentenceTransformer
# all-MiniLM-L6-v2: 384-dim, ~90MB, runs locally, ~50ms per ticket
model = SentenceTransformer('all-MiniLM-L6-v2')
conn = pyodbc.connect(
'Driver={Pervasive ODBC Interface};serverName=localhost;DBQ=Demodata;'
)
cursor = 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.
# cursor.execute("SET VDE_CREDENTIAL = 'your_vde_token'")
STEP 2 – Create a Collection for Ticket Embeddings
One collection, dimension matched to the embedding model, cosine distance for text:
cursor.execute("""
dbo.vector_collection_create (
'ticket_embeddings',
'{"dimension": 384, "distance_metric": "cosine"}'
)
""")
conn.commit()
print('Collection ready')
NOTE: Dimension is fixed at creation. If you later switch embedding models, drop and recreate the collection; there’s no in-place resize
STEP 3 – Index Your Existing Tickets
Here’s the backlog we’re indexing: five resolved tickets, each with a subject line that gets embedded:
| ID | Subject | Status |
|---|---|---|
| 101 | Cannot log in after update | Resolved |
| 102 | Password reset email never arrives | Resolved |
| 103 | Invoice PDF won’t download | Resolved |
| 104 | Payment declined at checkout | Resolved |
| 105 | Two-factor code not received | Open |
One function handles both the embedding and the write — it’s the same shape whether this is your first ticket or your hundredth:
def index_ticket(ticket_id, subject):
# Step A: text -> 384-dim vector, entirely local
embedding = model.encode(subject).tolist()
# Step B: upsert — inserts if new, updates if ticket_id exists.
# Inline the JSON directly in the SQL text — dbo.vector_*
# calls don't reliably support ODBC "?" parameter binding.
# Escape single quotes first: real text has apostrophes
# ("won't", "can't") that would otherwise break out of the
# SQL string literal and raise a syntax error.
payload = json.dumps({'subject': subject}).replace("'", "''")
cursor.execute(f"""
dbo.vector_point_upsert (
'ticket_embeddings',
'{{"u64_id": {ticket_id}, "dim": 384}}',
'{json.dumps(embedding)}',
'{payload}'
)
""")
conn.commit()
index_ticket(101, "Cannot login after update")
index_ticket(102, "Password reset email never arrives")
index_ticket(103, "Invoice PDF won't download")
index_ticket(104, "Payment declined at checkout")
index_ticket(105, "Two-factor code not received")
PERFORMANCE TIP: For a real backlog, encode all subjects in one batched model.encode().
STEP 4 – Search: Find Similar Past Tickets
A new ticket lands, worded nothing like the one already in your system:
def find_similar(new_ticket_text, top_k=3):
query_embedding = model.encode(new_ticket_text).tolist()
# Inline the vector directly — dbo.vector_* calls don't
# reliably support ODBC "?" parameter binding.
cursor.execute(f"""
SELECT TOP ({top_k})
a.u64_id, a."SIMILARITY_SCORE", a.payload
FROM dbo.vector_collection_search (
'ticket_embeddings', 'vector',
'{json.dumps(query_embedding)}', {top_k}, 384
) a
WHERE a."SIMILARITY_SCORE" > 0.5
ORDER BY a."SIMILARITY_SCORE" DESC
""")
for u64_id, score, payload in cursor.fetchall():
subj = json.loads(payload)["subject"]
print(f" #{u64_id} {score:.0%} {subj}")
find_similar("App won't let me sign in after the last update")
Output (measured against a real all-MiniLM-L6-v2 run):
| Ticket | Similarity | Subject |
|---|---|---|
| #101 | 74% | Cannot log in after update |
No word in the new ticket matches “login,” “cannot,” or “update” exactly — the model matched on meaning, not vocabulary. That’s the entire value proposition of semantic search over keyword search for this workload.
NOTE: The real score gap here is worth noticing: ticket #101 scores 0.74, but the next-closest ticket (#102, “Password reset email never arrives”) only scores 0.32 — well below the 0.5 threshold, so it correctly doesn’t show up. A clean gap like that between the true match and everything else is what makes a fixed threshold work well; on the product catalog in the companion SQL tutorial, scores were far more bunched together and needed a lower threshold. Always check your own data’s gap before picking a number.
STEP 5 – Only Surface Tickets That Were Actually Resolved
A semantically similar ticket that’s still open doesn’t help an agent. JOIN the vector results against your ticket table and filter on status, just like any other SQL query:
def find_resolved_matches(new_ticket_text, top_k=3):
query_embedding = model.encode(new_ticket_text).tolist()
cursor.execute(f"""
SELECT TOP ({top_k})
t.ticket_id, t.subject, t.resolution_notes,
sr."SIMILARITY_SCORE"
FROM dbo.vector_collection_search (
'ticket_embeddings', 'vector', '{json.dumps(query_embedding)}',
{top_k * 2}, 384 -- over-fetch for filter headroom
) sr
INNER JOIN tickets t
ON CAST(sr.u64_id AS INTEGER) = t.ticket_id
WHERE sr."SIMILARITY_SCORE" > 0.5
AND t.status = 'Resolved'
ORDER BY sr."SIMILARITY_SCORE" DESC
""")
return cursor.fetchall()
On the login query above, only ticket #101 clears the similarity bar at all — so this particular search doesn’t put the status filter to work. But it doesn’t take a hypothetical to see why it matters. Try a different incoming ticket instead:
find_resolved_matches("verification code never received")
Ticket #105 (“Two-factor code not received,” still Open) is the closest semantic match by a wide margin, 0.52 similarity, well clear of the 0.5 threshold and comfortably ahead of every other ticket. Run the plain, unfiltered search, and it comes back ranked #1:
| Ticket | Similarity | Status |
|---|---|---|
| #105 | 52% | Open |
| #102 | 40% | Resolved |
| #104 | 32% | Resolved |
Run find_resolved_matches() on the same text, though, and #105 disappears entirely, AND t.status = ‘Resolved’ removes it even though it’s the single best similarity match in the whole backlog. That’s not a hypothetical: it’s what the JOIN actually does the moment a real open ticket happens to score well. The relational filter does exactly what a WHERE clause always does; it just filters AI output instead of a plain column.
Where Else This Pattern Applies
Swap the ticket subject for a different short text field and the same five functions carry over unchanged:
- Internal knowledge base search, embed article titles/summaries, search on an employee’s question.
- Duplicate bug detection, embed new bug reports, flag anything above 0.90 similarity to an existing open issue before it gets filed twice.
- Onboarding FAQ matching: embed a new-hire’s Slack question, surface the closest existing answer instead of paging a human first.
Choosing Your Embedding Model
all-MiniLM-L6-v2 is the right default for short text like ticket subjects — fast and small enough to run on a laptop. Swap it out as your volume or accuracy needs change:
| Model | Dimensions | Speed | Best for | |
|---|---|---|---|---|
| all-MiniLM-L6-v2 | 384 | ~50ms/text | General purpose — this tutorial | |
| paraphrase-MiniLM-L3-v2 | 384 | ~20ms/text | High ticket volume, lower accuracy | |
| all-mpnet-base-v2 | 768 | ~120ms/text | Higher-accuracy triage | |
| text-embedding-ada-002 | 1536 | ~200ms/call | Best accuracy (needs an API key) | |
NOTE: Whichever model you pick, create the Zen collection with that model’s exact dimension. Dimensions are immutable after creation — mixing models between insert and search silently produces meaningless scores.
What You Built
In five steps, you indexed a ticket backlog as vectors, searched it with plain-English text instead of keywords, and filtered the results down to fixes an agent can actually hand off, all with pyodbc and standard SQL, no separate vector database or REST client.
From here, wire find_resolved_matches() into your ticket-creation webhook so the suggested fixes show up the moment a new ticket is filed, and switch the single index_ticket() calls to a batched upsert once you’re backfilling a real history instead of five sample rows.