Blog | Product & Technology | | 9 min read

Build a Support Ticket Search Tool With Python

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.