Build a Redis AI Support Router

Route support tickets by meaning with local Redis vector search.

Introduction

30 Second Summary

Support teams often receive the same problem in dozens of phrasings. Exact-word rules miss those requests when customers describe familiar issues in unfamiliar language.

In this project, you will build a terminal-based support ticket router that classifies requests by meaning. Redis vector search will compare sentence embeddings to return the closest category with a suggested resolution plus a semantic similarity score.

What You'll Build

You enter a paraphrased ticket in your terminal to see its closest support category beside a suggested resolution plus a confidence score.

By the end of this project, you'll have:

  • A keyword baseline you can run to watch exact-word matching fail on a paraphrased billing issue.
  • A Redis-backed semantic router that recognizes reworded billing, delivery, and technical issues.
  • A three-ticket terminal demo that displays each category beside its suggested resolution plus its semantic similarity score.
  • Secret Mission: Add a configurable confidence threshold that sends uncertain tickets to human review.

Are there any prerequisites?

You'll need Python 3.10 or later, VS Code, Docker Desktop, plus internet access for the initial package and model downloads. No external account or paid API is required.

Before We Start

This checkpoint locks in the goal for your local support-ticket router across billing, account access, delivery, cancellation, technical, and refund issues. Semantic matching fits this job because customers describe the same issue in different words.

Set Up the Local AI and Redis Environment

Your router needs Python to generate sentence embeddings. It needs Redis to store those vectors for semantic search.

A virtual environment keeps the router's packages isolated. Docker provides the local Redis deployment with the Query Engine included.

In this step, get ready to:
  • Create a project workspace in VS Code.
  • Prepare an isolated Python environment with pinned dependencies.
  • Run a local Redis container that responds to PING.
Create the project and verify your tools

A named project folder keeps your code beside its environment files. VS Code gives you one workspace for editing those files.

  • Press Cmd+Space to open Spotlight.
  • Type Terminal into Spotlight. Press Return.

You should see a Terminal window with a command prompt.

  • Move to your Desktop by running this command:
cd ~/Desktop

What does this command do?

The cd command changes your current folder. ~/Desktop points to the Desktop inside your macOS home folder.

  • Create the redis-ai-router folder by running these commands:
mkdir redis-ai-router
cd redis-ai-router

What do these commands do?

  • The first command creates the redis-ai-router folder on your Desktop.
  • The second command moves your terminal into that folder.
  • Confirm your location by running this command:
pwd

What does this command do?

The pwd command prints the folder your terminal currently uses. The path should end with Desktop/redis-ai-router.

  • Press Cmd+Space to open Spotlight.
  • Type Visual Studio Code into Spotlight. Press Return.

You should see VS Code open.

  • Click File in the top menu bar.
  • Click Open Folder....

You should see the macOS folder picker.

  • Select the redis-ai-router folder on your Desktop.
  • Click Open.
  • Select Yes, I trust the authors if the Workspace Trust dialog appears.

You should see redis-ai-router at the top of the Explorer sidebar. Your project now has a visible home.

The package versions in this project require Python 3.10 or later. Check your installed version before creating the environment.

  • Switch back to the Terminal window from earlier.
  • Check your Python version by running:
python3 --version

What does this command do?

This command prints the version attached to python3. The router needs Python 3.10 or later because both pinned packages require that minimum.

✔️ I see the required version or higher

Good news. Your Python installation can create the router's environment.

ⓧ I see an older version

Your current Python is below 3.10. Install a current supported release before continuing.

  • Open the Python releases for macOS page.
  • Download a current stable macOS installer.
  • Run the downloaded installer with its standard options.
  • Return to the Terminal window from earlier.
  • Check the updated version by running:
python3 --version

What should I see?

The command should now report Python 3.10 or later. The newer Python can coexist with the older macOS-managed installation.

ⓧ Command not found

Your shell cannot find Python. Install a current supported release from the official macOS downloads page.

  • Open the Python releases for macOS page.
  • Download a current stable macOS installer.
  • Run the downloaded installer with its standard options.
  • Return to the Terminal window from earlier.
  • Confirm the installation by running:
python3 --version

What should I see?

The command should print Python 3.10 or later. That output confirms the executable is available to your terminal.

Docker Desktop must be running before it can create the Redis container. Its startup can take a moment after you open it.

  • Press Cmd+Space to open Spotlight.
  • Type Docker into Spotlight. Press Return.
  • Wait for Docker Desktop to reach its ready state.
  • Return to the Terminal window from earlier.
  • Check the Docker command by running:
docker --version

What does this command prove?

The command prints the installed Docker version. Seeing version details confirms that your terminal can reach the Docker command.

You should see Docker version information in the terminal.

Docker command unavailable?

  • Confirm Docker Desktop has finished starting before running the version check again.
  • Restart Docker Desktop if its interface remains in a loading state.

Still blocked? Help me diagnose why Docker is unavailable in my macOS terminal.

Create the environment and install dependencies

The router uses two pinned packages. The Redis client connects Python to the database. The Sentence Transformers library creates local embeddings without a paid API.

  • Switch back to the redis-ai-router workspace in VS Code.
  • Select the redis-ai-router folder in the Explorer sidebar.
  • Use the file-creation control at the top of the Explorer sidebar.

You should see an input for the new file name.

  • Enter requirements.txt as the file name.

You should see requirements.txt open in the editor.

  • Add the two pinned dependencies by copying this code into requirements.txt:
redis==8.1.0
sentence-transformers==6.2.0

What are these dependencies?

  • The redis==8.1.0 package lets Python store hashes and run raw Redis search commands.
  • The sentence-transformers==6.2.0 package loads the embedding model used later in the project.
  • The exact pins keep your local setup aligned with the code in the walkthrough.
  • Save requirements.txt with Cmd+S.
  • Confirm the editor shows both dependency lines.

Your dependency list is now saved inside redis-ai-router.

The virtual environment places these packages inside the project folder. This prevents the router from changing packages used by other Python projects.

  • Switch back to the Terminal window from earlier.
  • Create the tutorial-env environment by running:
python -m venv tutorial-env

What does this command do?

Python's venv module creates an isolated interpreter inside tutorial-env. Packages installed after activation stay inside that environment.

  • Confirm the tutorial-env folder appears in the VS Code Explorer sidebar.
  • Activate the environment by running:
source tutorial-env/bin/activate

What does activation change?

Activation updates the current shell so python uses the interpreter inside tutorial-env. It also directs package installation into that environment.

You should see (tutorial-env) at the start of your terminal prompt.

Environment prompt missing?

  • Confirm your terminal path ends with redis-ai-router.
  • Confirm the tutorial-env folder exists in the VS Code Explorer sidebar.
  • Run the activation command above again from the project folder.

Need another pair of eyes? Help me activate tutorial-env on macOS.

The dependency install can take a few minutes because the machine-learning package has supporting libraries. A busy terminal during this download is expected.

  • Install the pinned dependencies by running:
python -m pip install -r requirements.txt

What does this command do?

  • The python -m pip portion uses the package installer attached to the active environment.
  • The -r requirements.txt portion reads each pinned dependency from your saved file.
  • The installation prepares Python for local embeddings and Redis communication.

You should see the installation finish without package errors. The output should name Redis 8.1.0 and Sentence Transformers 6.2.0 among the installed packages.

Dependency installation failed?

  • Confirm the terminal prompt begins with (tutorial-env).
  • Confirm requirements.txt contains the two lines shown above.
  • Confirm your internet connection is available for the package downloads.

Still seeing an installation failure? Help me troubleshoot the pinned Python packages.

✔️ Awesome, I've got everything!

Great. Your virtual environment now has the two packages required by the router.

ⓧ I'd like to double check the full code

Compare your saved requirements.txt with this complete file.

redis==8.1.0
sentence-transformers==6.2.0

What should match?

Both lines must match exactly. Each version pin connects the project to the package releases used by the remaining steps.

Start Redis and verify the connection

The official Redis image includes the Redis Query Engine needed for vector indexing. Docker runs that database locally on port 6379.

Keep this database local

This command publishes Redis on localhost:6379 without authentication. Use it only for local development on your Mac.

The first launch may take a few minutes while Docker downloads the Redis image. Later starts use the local image.

  • Start the Redis container by running:
docker run -d --name redis -p 6379:6379 redis:8.4-alpine

What does this command do?

  • The -d option runs the container in the background.
  • The --name redis option assigns the container name used by later commands.
  • The -p 6379:6379 option maps the Mac's port to the Redis port inside the container.
  • The redis:8.4-alpine value selects the required Redis Open Source image.

You should see a container identifier in the terminal. Docker is now running Redis in the background.

  • Connect to the Redis command line by running:
docker exec -it redis redis-cli

What does this command do?

Docker runs redis-cli inside the container named redis. The interactive connection lets you send commands directly to the local database.

Before you test the connection, what do you think Redis will return when it is healthy?

  • Check Redis liveness by entering:
PING

What does this command prove?

The PING command asks Redis for a liveness response. A healthy server returns PONG.

You should see PONG in the terminal. That is the setup complete: your local Redis server is accepting commands.

Don't see PONG?

  • Confirm Docker Desktop still shows a ready state.
  • Confirm the container command used the name redis.
  • Confirm the connection command targets the same container name.

Need help finding the break? Help me troubleshoot why my local Redis container does not return PONG.

  • Press Control+C to return to your normal terminal prompt.

Your workspace is open. Its Python packages are isolated. Redis is ready for ticket data. Next up, you will build a keyword router and watch a paraphrased billing request expose its limitation.

Build a Keyword Router That Fails

Your local Redis service is ready. The next question is how your Python router decides where each ticket belongs.

Keyword routing compares incoming text with literal phrases. This step tests the same issue twice because customer wording shifts constantly.

In this step, get ready to:
  • Build a command-line keyword router for six support categories.
  • Compare an exact phrase with a paraphrased billing ticket.
Prepare the support cases

A knowledge base keeps each support example beside its category. It also stores the resolution that an agent could suggest for that issue.

  • In the VS Code Explorer sidebar, create a new file named app.py inside redis-ai-router.
  • Store the six support cases by copying this code into app.py:
import argparse

KNOWLEDGE_BASE = [
    {"id": "billing-duplicate", "category": "billing", "example": "My card was charged twice for a single purchase.", "resolution": "Verify both charges, then reverse the duplicate transaction."},
    {"id": "account-password", "category": "account_access", "example": "I forgot my password and cannot sign in.", "resolution": "Send a password reset link and verify the account email."},
    {"id": "delivery-delayed", "category": "delivery", "example": "My order has not arrived and the tracking status is not updating.", "resolution": "Check the carrier status and open a delayed-delivery investigation."},
    {"id": "subscription-cancel", "category": "cancellation", "example": "I want to cancel my subscription before the next renewal.", "resolution": "Confirm the renewal date and cancel future billing."},
    {"id": "technical-crash", "category": "technical", "example": "The mobile app crashes whenever I open it.", "resolution": "Collect the app version and device details, then start crash troubleshooting."},
    {"id": "refund-missing", "category": "refund", "example": "I returned an item but have not received my refund.", "resolution": "Verify the return receipt and check the refund processing status."},
]

What does this code establish?

  • Each id gives one support case a unique name.
  • Each category holds the route that the program should return.
  • Each example represents a customer message that belongs to the category.
  • Each resolution holds the response that could resolve the issue.
  • Save app.py.
  • Confirm app.py is listed beside requirements.txt in the Explorer sidebar.

The first project file is in place. It now holds all six support issues that your router needs to recognise.

Cannot find app.py?

  • Check that app.py sits beside requirements.txt in the redis-ai-router folder.
  • Move app.py into redis-ai-router if it appears outside the folder.

Still missing the file? Help me check where app.py was created in VS Code.

Connect phrases to categories

Each keyword rule pairs one literal phrase with one category. The router lowercases the ticket before checking whether a configured phrase appears inside it.

  • Add the first routing rule below KNOWLEDGE_BASE by copying this code:
KEYWORD_RULES = [
    ("charged twice", "billing"),
]


def keyword_route(ticket):
    lowered = ticket.lower()
    for phrase, category in KEYWORD_RULES:
        if phrase in lowered:
            return category
    return None

How does the routing core work?

  • The KEYWORD_RULES list stores the phrase first. It stores the matching category second.
  • The lower() call makes capitalisation irrelevant during matching.
  • The keyword_route() function returns the category for the first matching phrase. It returns None when every rule misses.

A command-line interface lets you supply a ticket when you run the script. The same interface prints a clear result for both matching tickets and missed tickets.

  • Add the command-line runner below keyword_route(ticket) by copying this code:
def parse_args():
    parser = argparse.ArgumentParser(description="Route support tickets by keywords or meaning.")
    parser.add_argument("--mode", choices=("keyword", "vector"), required=True)
    parser.add_argument("--ticket")
    return parser.parse_args()


def main():
    args = parse_args()
    ticket = args.ticket or input("Describe the support issue: ")
    category = keyword_route(ticket)
    if category is None:
        print("Keyword router: NO MATCH")
        print("Limitation: the wording did not contain a configured phrase.")
    else:
        print(f"Keyword router category: {category}")


if __name__ == "__main__":
    main()

How does the command-line runner work?

  • The parse_args() function requires a valid --mode value. It also accepts an optional --ticket value.
  • The main() function uses the supplied ticket. It prompts for one when the argument is missing.
  • The final condition runs main() when you execute app.py directly.
  • Save app.py.
  • Return to the terminal panel from earlier.
  • Route the configured phrase by running this command:
python app.py --mode keyword --ticket "charged twice"

What should you see?

The terminal prints Keyword router category: billing.

Your first route works. The literal phrase now reaches the correct billing category.

Did the first route fail?

  • Confirm the terminal prompt shows that tutorial-env is still active.
  • Check that the terminal is inside the redis-ai-router folder.
  • Compare the spelling of KEYWORD_RULES with the spelling inside keyword_route().

Need another pair of eyes? Help me debug why my charged twice ticket does not route to billing.

Expose the paraphrase gap

The billing rule proves the basic loop works. Complete the remaining category rules before comparing the exact phrase with a new version of the same request.

  • In app.py, find the current billing-only rule block:
KEYWORD_RULES = [
    ("charged twice", "billing"),
]

What are you replacing?

This block contains the single billing rule that you just tested successfully.

  • Replace the billing-only block with the complete rule set below:
KEYWORD_RULES = [
    ("charged twice", "billing"),
    ("forgot my password", "account_access"),
    ("tracking status", "delivery"),
    ("cancel my subscription", "cancellation"),
    ("app crashes", "technical"),
    ("received my refund", "refund"),
]

How do the six rules line up?

  • Each tuple keeps the literal phrase first. It keeps the returned category second.
  • The six categories align with the six records in KNOWLEDGE_BASE.
  • Save app.py.
  • Recheck the exact billing route by running this command:
python app.py --mode keyword --ticket "charged twice"

What should you see now?

The terminal still prints Keyword router category: billing. The expanded rules preserve the original match.

Before you run the next ticket, do you think the same billing meaning will reach the rule when every word changes?

  • Test the paraphrased billing ticket by running:
python app.py --mode keyword --ticket "I was billed two times for one order"

What does the router reveal?

The terminal prints Keyword router: NO MATCH. The next line prints Limitation: the wording did not contain a configured phrase.

You found the brittle edge. The meaning stayed constant. The wording moved outside the configured phrase.

Seeing a different result?

  • Confirm the paraphrased command contains I was billed two times for one order.
  • Check that the billing phrase inside KEYWORD_RULES remains charged twice.
  • Confirm keyword_route() checks whether each phrase appears inside lowered.

Still seeing an unexpected category? Help me compare my keyword router with the expected paraphrase failure.

Your finished app.py should match this checkpoint.

✔️ Awesome, I've got everything!

Great. Your saved app.py now preserves the successful exact match and the intentional paraphrase miss.

ⓧ I'd like to double check the full code

  • Compare your saved app.py with this complete checkpoint:
import argparse

KNOWLEDGE_BASE = [
    {"id": "billing-duplicate", "category": "billing", "example": "My card was charged twice for a single purchase.", "resolution": "Verify both charges, then reverse the duplicate transaction."},
    {"id": "account-password", "category": "account_access", "example": "I forgot my password and cannot sign in.", "resolution": "Send a password reset link and verify the account email."},
    {"id": "delivery-delayed", "category": "delivery", "example": "My order has not arrived and the tracking status is not updating.", "resolution": "Check the carrier status and open a delayed-delivery investigation."},
    {"id": "subscription-cancel", "category": "cancellation", "example": "I want to cancel my subscription before the next renewal.", "resolution": "Confirm the renewal date and cancel future billing."},
    {"id": "technical-crash", "category": "technical", "example": "The mobile app crashes whenever I open it.", "resolution": "Collect the app version and device details, then start crash troubleshooting."},
    {"id": "refund-missing", "category": "refund", "example": "I returned an item but have not received my refund.", "resolution": "Verify the return receipt and check the refund processing status."},
]

KEYWORD_RULES = [
    ("charged twice", "billing"),
    ("forgot my password", "account_access"),
    ("tracking status", "delivery"),
    ("cancel my subscription", "cancellation"),
    ("app crashes", "technical"),
    ("received my refund", "refund"),
]


def keyword_route(ticket):
    lowered = ticket.lower()
    for phrase, category in KEYWORD_RULES:
        if phrase in lowered:
            return category
    return None


def parse_args():
    parser = argparse.ArgumentParser(description="Route support tickets by keywords or meaning.")
    parser.add_argument("--mode", choices=("keyword", "vector"), required=True)
    parser.add_argument("--ticket")
    return parser.parse_args()


def main():
    args = parse_args()
    ticket = args.ticket or input("Describe the support issue: ")
    category = keyword_route(ticket)
    if category is None:
        print("Keyword router: NO MATCH")
        print("Limitation: the wording did not contain a configured phrase.")
    else:
        print(f"Keyword router category: {category}")


if __name__ == "__main__":
    main()

What should this file contain?

The checkpoint contains six support records. It also contains six literal rules plus the command-line routing flow.

Your keyword baseline now proves that literal phrases miss paraphrases. Next, you'll replace that brittle lookup with Redis vector search so meaning drives the route.

Replace Keywords with Redis Vector Search

Your keyword router proved that the configured phrase can reach the billing category. A customer paraphrase still falls through with no match.

In this step, Redis vector search closes that gap. Sentence embeddings represent each ticket by meaning so semantic similarity can connect different wording to the same support issue.

In this step, get ready to:
  • Encode the six support examples as 384-dimensional vectors.
  • Store the vectors with their support metadata in Redis hashes.
  • Retrieve the closest issue with KNN vector search.
Prepare embeddings for Redis

The Sentence Transformers model maps each support example to a vector with 384 numbers. Redis needs those numbers as FLOAT32 bytes before it can store or compare them.

  • In app.py, locate the existing import argparse line.
  • Replace that line with the imports and vector settings below:
import argparse
import struct

import redis
from redis.exceptions import ResponseError
from sentence_transformers import SentenceTransformer

MODEL_NAME = "sentence-transformers/all-MiniLM-L6-v2"
INDEX_NAME = "support-issues"
KEY_PREFIX = "support:"
VECTOR_DIM = 384

What does this setup add?

  • The new imports provide vector packing, the Redis client, index error handling, and the embedding model.
  • The model setting selects sentence-transformers/all-MiniLM-L6-v2. It produces the 384-dimensional embeddings required by the index.
  • The index settings keep the Redis key prefix, index name, and vector dimension consistent throughout the router.
  • Save app.py.

Before you run this check, do you expect the original paraphrase to match now that only the configuration exists?

  • Confirm the keyword path still runs by running this command:
python app.py --mode keyword --ticket "I was billed two times for one order"

What should you see?

You should still see Keyword router: NO MATCH. The new configuration has loaded successfully while the keyword path keeps exposing the original limitation.

Seeing an import error?

  • Confirm the terminal prompt still shows the activated tutorial-env environment.
  • Check that requirements.txt still contains the two pinned dependencies from setup.

Need another pair of eyes? Help me diagnose the import failure in app.py.

A FLAT vector index performs exact comparisons across this six-record knowledge base. Each indexed Redis hash holds the support metadata beside its embedding.

  • In app.py, locate the return None line at the end of keyword_route().
  • Add the vector conversion helper and index function below keyword_route() by copying this code:
def vector_to_bytes(vector):
    return struct.pack(f"<{len(vector)}f", *vector)


def create_index(client):
    try:
        client.execute_command("FT.CREATE", INDEX_NAME, "ON", "HASH", "PREFIX", "1", KEY_PREFIX, "SCHEMA", "category", "TAG", "example", "TEXT", "resolution", "TEXT", "embedding", "VECTOR", "FLAT", "6", "TYPE", "FLOAT32", "DIM", str(VECTOR_DIM), "DISTANCE_METRIC", "COSINE")
    except ResponseError as error:
        if "Index already exists" not in str(error):
            raise

How does the index work?

  • The conversion helper packs each embedding into FLOAT32 bytes that match the index schema.
  • The FT.CREATE command limits the index to hash keys beginning with support:.
  • The schema makes category a tag field. It makes example and resolution text fields.
  • The embedding field uses a FLAT index with FLOAT32 values, 384 dimensions, and cosine distance.
  • Save app.py.
  • Confirm the script still loads by running the keyword check again:
python app.py --mode keyword --ticket "I was billed two times for one order"

What does this check prove?

You should see Keyword router: NO MATCH again. This confirms the new helper and index definition load without breaking the existing route.

Seeing a syntax error?

  • Check that vector_to_bytes() begins after the final line of keyword_route().
  • Check the indentation inside the try block and the exception block.

Still stuck? Help me compare my vector helper and Redis index function with the target code.

The model can encode all six support examples in one batch. Storing the resulting bytes beside each record gives Redis both the searchable vector and the answer metadata.

  • Add load_knowledge_base() directly below create_index() by copying this code:
def load_knowledge_base(client, model):
    examples = [record["example"] for record in KNOWLEDGE_BASE]
    embeddings = model.encode(examples)
    for record, embedding in zip(KNOWLEDGE_BASE, embeddings, strict=True):
        client.hset(f"{KEY_PREFIX}{record['id']}", mapping={"category": record["category"], "example": record["example"], "resolution": record["resolution"], "embedding": vector_to_bytes(embedding)})

What does this loader do?

  • The list comprehension gathers the six example sentences in knowledge-base order.
  • The model creates one embedding for every example in a single call.
  • Each hash stores its category, example, resolution, and packed embedding under a support: key.
  • Save app.py.
  • Confirm the loader does not disrupt keyword mode by running:
python app.py --mode keyword --ticket "I was billed two times for one order"

What should the script report?

The script should print Keyword router: NO MATCH. Your embedding loader is now part of the file without changing the keyword result.

Does the file stop loading?

  • Confirm load_knowledge_base() sits outside the exception block in create_index().
  • Check that every knowledge-base record still includes id, category, example, and resolution.

Need help locating the mismatch? Help me debug the knowledge-base loader.

Search for the closest support issue

A KNN search asks Redis for the nearest stored vector. Cosine distance measures the gap between the ticket embedding and each known support example.

The client uses RESP2 so the response arrives as a predictable array of document fields. The router converts cosine distance into a similarity score by subtracting it from one.

  • Add the response decoder and semantic route directly below load_knowledge_base() by copying this code:
def decode_value(value):
    if isinstance(value, bytes):
        return value.decode("utf-8")
    return str(value)


def semantic_route(client, model, ticket):
    query_vector = vector_to_bytes(model.encode([ticket])[0])
    response = client.execute_command("FT.SEARCH", INDEX_NAME, "*=>[KNN 1 @embedding $query_vector AS distance]", "PARAMS", "2", "query_vector", query_vector, "RETURN", "4", "category", "example", "resolution", "distance", "SORTBY", "distance", "DIALECT", "2")
    if not response or response[0] == 0:
        return None
    raw_fields = response[2]
    fields = {decode_value(raw_fields[position]): decode_value(raw_fields[position + 1]) for position in range(0, len(raw_fields), 2)}
    distance = float(fields["distance"])
    return {"category": fields["category"], "example": fields["example"], "resolution": fields["resolution"], "similarity": max(0.0, min(1.0, 1.0 - distance))}

How does semantic retrieval work?

  • The router encodes the incoming ticket with the same model used for the stored examples.
  • The FT.SEARCH query requests one nearest neighbor from the embedding field.
  • The response parser rebuilds the returned field-value pairs as a Python dictionary.
  • The final dictionary carries the matched support metadata plus a similarity score bounded between zero and one.
  • Save app.py.
  • Confirm the file still runs through keyword mode by running:
python app.py --mode keyword --ticket "I was billed two times for one order"

What does the result confirm?

You should still see Keyword router: NO MATCH. The semantic query function now loads correctly beside the original router.

Seeing a parser failure?

  • Confirm decode_value() appears before semantic_route().
  • Check that the response fields use the exact names from the index schema.

Need help checking the query? Help me debug my semantic route function.

The closest record is useful only when the learner can see what Redis selected. A dedicated formatter keeps the ticket, category, resolution, and score together in one result block.

  • Add print_semantic_result() directly below semantic_route() by copying this code:
def print_semantic_result(ticket, result):
    print(f"\nTicket: {ticket}")
    if result is None:
        print("Vector router: NO MATCH")
        return
    print(f"Category: {result['category']}")
    print(f"Matched example: {result['example']}")
    print(f"Suggested resolution: {result['resolution']}")
    print(f"Semantic similarity: {result['similarity']:.3f}")

Why use a result formatter?

  • The no-match branch gives an explicit fallback when Redis returns no records.
  • The matched branch displays the retrieved category, source example, suggested resolution, and three-decimal similarity score.
  • Keeping presentation separate from retrieval makes the search function reusable.
  • Save app.py.
  • Confirm the new formatter leaves keyword mode intact by running:
python app.py --mode keyword --ticket "I was billed two times for one order"

What should remain unchanged?

You should see Keyword router: NO MATCH one final time. The original obstacle remains visible until the main function activates vector mode.

Seeing a formatting error?

  • Check that each dictionary field name inside the formatted strings matches the dictionary returned by semantic_route().
  • Confirm the similarity line keeps its closing brace and three-decimal format.

Still seeing a problem? Help me repair the semantic result formatter.

Route the paraphrased ticket by meaning

The final connection gives each mode its own path. Keyword mode preserves the designed failure while vector mode loads the model, builds the Redis index, stores the six examples, and performs the semantic search.

  • In app.py, locate the existing main() function.
  • Replace the complete function with this vector-aware version:
def main():
    args = parse_args()
    if args.mode == "keyword":
        ticket = args.ticket or input("Describe the support issue: ")
        category = keyword_route(ticket)
        if category is None:
            print("Keyword router: NO MATCH")
            print("Limitation: the wording did not contain a configured phrase.")
        else:
            print(f"Keyword router category: {category}")
        return
    client = redis.Redis(host="localhost", port=6379, db=0, protocol=2)
    model = SentenceTransformer(MODEL_NAME)
    create_index(client)
    load_knowledge_base(client, model)
    ticket = args.ticket or input("Describe the support issue: ")
    print_semantic_result(ticket, semantic_route(client, model, ticket))

How does vector mode run?

  • The keyword branch keeps its original routing behavior and returns before any model work begins.
  • The vector branch connects to database zero on localhost:6379 with protocol=2.
  • The branch loads the embedding model before creating the index and writing the six records.
  • The final call retrieves the nearest record before passing it to the result formatter.
  • Save app.py.

The first vector run downloads the public embedding model. A quiet pause during this run is expected.

Before you run the router, do you think the paraphrased ticket will still miss the billing category?

  • Test the semantic route by running this command:
python app.py --mode vector --ticket "I was billed two times for one order"

What should you see?

  • The category should be billing.
  • The matched example should describe a card charged twice for one purchase.
  • The suggested resolution should tell the agent to verify both charges before reversing the duplicate transaction.
  • The semantic similarity line should show a three-decimal score.

That is the breakthrough. Your router now recognizes the duplicate-charge issue even when the customer uses completely different wording.

Vector mode not returning billing?

  • Confirm the local redis container is still running and reachable on localhost:6379.
  • Confirm the terminal still uses the activated tutorial-env environment.
  • Compare the model name, vector dimension, index schema, and query field with the code above.

Need help tracing the pipeline? Help me debug why vector mode does not return the billing match.

✔️ Awesome, I've got everything!

  • Keep app.py saved with the working vector result in your terminal.

ⓧ I'd like to double check the full code

  • Compare your complete app.py file with this reference:
import argparse
import struct

import redis
from redis.exceptions import ResponseError
from sentence_transformers import SentenceTransformer

MODEL_NAME = "sentence-transformers/all-MiniLM-L6-v2"
INDEX_NAME = "support-issues"
KEY_PREFIX = "support:"
VECTOR_DIM = 384

KNOWLEDGE_BASE = [
    {"id": "billing-duplicate", "category": "billing", "example": "My card was charged twice for a single purchase.", "resolution": "Verify both charges, then reverse the duplicate transaction."},
    {"id": "account-password", "category": "account_access", "example": "I forgot my password and cannot sign in.", "resolution": "Send a password reset link and verify the account email."},
    {"id": "delivery-delayed", "category": "delivery", "example": "My order has not arrived and the tracking status is not updating.", "resolution": "Check the carrier status and open a delayed-delivery investigation."},
    {"id": "subscription-cancel", "category": "cancellation", "example": "I want to cancel my subscription before the next renewal.", "resolution": "Confirm the renewal date and cancel future billing."},
    {"id": "technical-crash", "category": "technical", "example": "The mobile app crashes whenever I open it.", "resolution": "Collect the app version and device details, then start crash troubleshooting."},
    {"id": "refund-missing", "category": "refund", "example": "I returned an item but have not received my refund.", "resolution": "Verify the return receipt and check the refund processing status."},
]

KEYWORD_RULES = [("charged twice", "billing"), ("forgot my password", "account_access"), ("tracking status", "delivery"), ("cancel my subscription", "cancellation"), ("app crashes", "technical"), ("received my refund", "refund")]


def keyword_route(ticket):
    lowered = ticket.lower()
    for phrase, category in KEYWORD_RULES:
        if phrase in lowered:
            return category
    return None


def vector_to_bytes(vector):
    return struct.pack(f"<{len(vector)}f", *vector)


def create_index(client):
    try:
        client.execute_command("FT.CREATE", INDEX_NAME, "ON", "HASH", "PREFIX", "1", KEY_PREFIX, "SCHEMA", "category", "TAG", "example", "TEXT", "resolution", "TEXT", "embedding", "VECTOR", "FLAT", "6", "TYPE", "FLOAT32", "DIM", str(VECTOR_DIM), "DISTANCE_METRIC", "COSINE")
    except ResponseError as error:
        if "Index already exists" not in str(error):
            raise


def load_knowledge_base(client, model):
    examples = [record["example"] for record in KNOWLEDGE_BASE]
    embeddings = model.encode(examples)
    for record, embedding in zip(KNOWLEDGE_BASE, embeddings, strict=True):
        client.hset(f"{KEY_PREFIX}{record['id']}", mapping={"category": record["category"], "example": record["example"], "resolution": record["resolution"], "embedding": vector_to_bytes(embedding)})


def decode_value(value):
    if isinstance(value, bytes):
        return value.decode("utf-8")
    return str(value)


def semantic_route(client, model, ticket):
    query_vector = vector_to_bytes(model.encode([ticket])[0])
    response = client.execute_command("FT.SEARCH", INDEX_NAME, "*=>[KNN 1 @embedding $query_vector AS distance]", "PARAMS", "2", "query_vector", query_vector, "RETURN", "4", "category", "example", "resolution", "distance", "SORTBY", "distance", "DIALECT", "2")
    if not response or response[0] == 0:
        return None
    raw_fields = response[2]
    fields = {decode_value(raw_fields[position]): decode_value(raw_fields[position + 1]) for position in range(0, len(raw_fields), 2)}
    distance = float(fields["distance"])
    return {"category": fields["category"], "example": fields["example"], "resolution": fields["resolution"], "similarity": max(0.0, min(1.0, 1.0 - distance))}


def print_semantic_result(ticket, result):
    print(f"\nTicket: {ticket}")
    if result is None:
        print("Vector router: NO MATCH")
        return
    print(f"Category: {result['category']}")
    print(f"Matched example: {result['example']}")
    print(f"Suggested resolution: {result['resolution']}")
    print(f"Semantic similarity: {result['similarity']:.3f}")


def parse_args():
    parser = argparse.ArgumentParser(description="Route support tickets by keywords or meaning.")
    parser.add_argument("--mode", choices=("keyword", "vector"), required=True)
    parser.add_argument("--ticket")
    return parser.parse_args()


def main():
    args = parse_args()
    if args.mode == "keyword":
        ticket = args.ticket or input("Describe the support issue: ")
        category = keyword_route(ticket)
        if category is None:
            print("Keyword router: NO MATCH")
            print("Limitation: the wording did not contain a configured phrase.")
        else:
            print(f"Keyword router category: {category}")
        return
    client = redis.Redis(host="localhost", port=6379, db=0, protocol=2)
    model = SentenceTransformer(MODEL_NAME)
    create_index(client)
    load_knowledge_base(client, model)
    ticket = args.ticket or input("Describe the support issue: ")
    print_semantic_result(ticket, semantic_route(client, model, ticket))


if __name__ == "__main__":
    main()

What should the full file contain?

The complete file keeps the keyword baseline beside the embedding, indexing, storage, retrieval, parsing, and output functions. The main function selects the correct pipeline from the requested mode.

Your Redis-backed router now recovers the billing issue that keyword matching missed. Next, you'll run several paraphrased tickets through the same pipeline as a repeatable demo.

Turn the Router into a Repeatable Demo

Your Redis vector route now handles a paraphrased billing ticket. One correct result could still be a lucky match.

A repeatable demo runs three paraphrases through the same search pipeline. This gives you evidence across billing, delivery, plus technical issues.

In this step, get ready to:
  • Define three paraphrased demo tickets.
  • Connect a demo option to the existing semantic routing pipeline.
  • Verify the automated demo plus the interactive path.
Define the demo ticket set

A fixed ticket set makes each run comparable. The three messages use wording that differs from the stored examples.

  • In app.py, locate the KEYWORD_RULES list.
  • Add DEMO_TICKETS directly below KEYWORD_RULES by copying this line:
DEMO_TICKETS = ["I was billed two times for one order.", "Where is my package? The tracking information has not changed.", "The application closes as soon as I launch it."]

What does this code do?

  • The first ticket paraphrases the stored duplicate-charge example.
  • The second ticket describes stalled package tracking in different words.
  • The third ticket describes an application crash without using the configured keyword phrase.
  • Save app.py.
  • Return to the terminal panel from earlier.

The current interactive path provides a quick regression check before you connect the new list. The script asks for one support issue after the model loads.

  • Start the existing interactive vector route by running:
python app.py --mode vector

What does this command do?

This command selects vector mode without supplying a ticket. The script responds by asking you to enter one interactively.

  • Enter Where is my package? The tracking information has not changed. at the prompt.

You should see the delivery category. You should also see a matched example, a suggested resolution, plus a semantic similarity score.

Missing the delivery result?

  • Confirm that you saved app.py before returning to the terminal.
  • Check that the local redis container from earlier is still running.
  • Return to the activated tutorial-env if your terminal cannot import the project packages.

Still stuck? Help me troubleshoot why my interactive vector route does not return the delivery category.

Connect the demo path

The command-line parser needs a switch for the repeatable run. The main flow also needs to choose between the fixed list and one interactive ticket.

  • In app.py, locate the current parse_args() function.
  • Find this version of the function:
def parse_args():
    parser = argparse.ArgumentParser(description="Route support tickets by keywords or meaning.")
    parser.add_argument("--mode", choices=("keyword", "vector"), required=True)
    parser.add_argument("--ticket")
    return parser.parse_args()

What is changing here?

The current parser accepts a routing mode plus an optional ticket. It does not yet recognize the demo switch.

  • Replace the current function with this updated version:
def parse_args():
    parser = argparse.ArgumentParser(description="Route support tickets by keywords or meaning.")
    parser.add_argument("--mode", choices=("keyword", "vector"), required=True)
    parser.add_argument("--ticket")
    parser.add_argument("--demo", action="store_true")
    return parser.parse_args()

How does the demo switch work?

The --demo option stores whether the learner supplied that switch. The rest of the program can use args.demo to select the fixed ticket list.

  • In main(), locate these two lines near the end of the function:
    ticket = args.ticket or input("Describe the support issue: ")
    print_semantic_result(ticket, semantic_route(client, model, ticket))

What does the current path do?

The current path selects one supplied ticket or asks for one interactively. It sends that single ticket through semantic_route().

  • Replace those two lines with the demo-aware routing loop:
    tickets = DEMO_TICKETS if args.demo else [args.ticket or input("Describe the support issue: ")]
    for ticket in tickets:
        print_semantic_result(ticket, semantic_route(client, model, ticket))

How does the loop reuse the pipeline?

  • The tickets variable holds all three demo messages when args.demo is active.
  • The fallback wraps a supplied or interactive ticket in a one-item list.
  • The loop sends every selected ticket through the existing semantic_route() function.
  • The model remains outside the loop, so one model instance serves every ticket in the run.
  • Save app.py.
Run both routing paths

The automated run now gives every ticket the same model plus the same Redis search process. This makes the category results easy to compare.

Before you run the demo, do you expect all three paraphrases to retrieve their intended categories?

  • Run the three-ticket demo with this command:
python app.py --mode vector --demo

What happens during this run?

The program loads SentenceTransformer(MODEL_NAME) once before the ticket loop. Each demo ticket then receives its own embedding plus its own nearest-neighbor search.

You should see three result blocks with the categories billing, delivery, plus technical. Each block should include a matched example, a suggested resolution, plus a semantic similarity score.

You now have repeatable evidence that the router handles paraphrases across three support categories.

The demo option should preserve the original interactive path. One final custom ticket confirms that both paths use the same router.

  • Start vector mode without the demo option by running:
python app.py --mode vector

Why test the interactive path?

This command leaves args.demo inactive. The fallback path asks for one ticket before sending it through the same loop.

  • Enter My phone app shuts down when I start it. at the prompt.

You should see the technical category with its crash-troubleshooting resolution. This confirms that vector mode still accepts interactive input.

Demo output incomplete?

  • Check that parse_args() includes the --demo argument if the switch is rejected.
  • Check that main() loops over tickets if you see only one result block.
  • Confirm that DEMO_TICKETS contains all three messages if a category is missing.

Need another pair of eyes? Help me debug why my repeatable vector demo does not show three complete result blocks.

✔️ Awesome, I've got everything!

Your saved app.py now supports the three-ticket demo plus the original single-ticket path.

ⓧ I'd like to double check the full code

Compare your saved app.py with this complete version.

import argparse
import struct

import redis
from redis.exceptions import ResponseError
from sentence_transformers import SentenceTransformer

MODEL_NAME = "sentence-transformers/all-MiniLM-L6-v2"
INDEX_NAME = "support-issues"
KEY_PREFIX = "support:"
VECTOR_DIM = 384

KNOWLEDGE_BASE = [
    {"id": "billing-duplicate", "category": "billing", "example": "My card was charged twice for a single purchase.", "resolution": "Verify both charges, then reverse the duplicate transaction."},
    {"id": "account-password", "category": "account_access", "example": "I forgot my password and cannot sign in.", "resolution": "Send a password reset link and verify the account email."},
    {"id": "delivery-delayed", "category": "delivery", "example": "My order has not arrived and the tracking status is not updating.", "resolution": "Check the carrier status and open a delayed-delivery investigation."},
    {"id": "subscription-cancel", "category": "cancellation", "example": "I want to cancel my subscription before the next renewal.", "resolution": "Confirm the renewal date and cancel future billing."},
    {"id": "technical-crash", "category": "technical", "example": "The mobile app crashes whenever I open it.", "resolution": "Collect the app version and device details, then start crash troubleshooting."},
    {"id": "refund-missing", "category": "refund", "example": "I returned an item but have not received my refund.", "resolution": "Verify the return receipt and check the refund processing status."},
]

KEYWORD_RULES = [("charged twice", "billing"), ("forgot my password", "account_access"), ("tracking status", "delivery"), ("cancel my subscription", "cancellation"), ("app crashes", "technical"), ("received my refund", "refund")]
DEMO_TICKETS = ["I was billed two times for one order.", "Where is my package? The tracking information has not changed.", "The application closes as soon as I launch it."]


def keyword_route(ticket):
    lowered = ticket.lower()
    for phrase, category in KEYWORD_RULES:
        if phrase in lowered:
            return category
    return None


def vector_to_bytes(vector):
    return struct.pack(f"<{len(vector)}f", *vector)


def create_index(client):
    try:
        client.execute_command("FT.CREATE", INDEX_NAME, "ON", "HASH", "PREFIX", "1", KEY_PREFIX, "SCHEMA", "category", "TAG", "example", "TEXT", "resolution", "TEXT", "embedding", "VECTOR", "FLAT", "6", "TYPE", "FLOAT32", "DIM", str(VECTOR_DIM), "DISTANCE_METRIC", "COSINE")
    except ResponseError as error:
        if "Index already exists" not in str(error):
            raise


def load_knowledge_base(client, model):
    examples = [record["example"] for record in KNOWLEDGE_BASE]
    embeddings = model.encode(examples)
    for record, embedding in zip(KNOWLEDGE_BASE, embeddings, strict=True):
        client.hset(f"{KEY_PREFIX}{record['id']}", mapping={"category": record["category"], "example": record["example"], "resolution": record["resolution"], "embedding": vector_to_bytes(embedding)})


def decode_value(value):
    if isinstance(value, bytes):
        return value.decode("utf-8")
    return str(value)


def semantic_route(client, model, ticket):
    query_vector = vector_to_bytes(model.encode([ticket])[0])
    response = client.execute_command("FT.SEARCH", INDEX_NAME, "*=>[KNN 1 @embedding $query_vector AS distance]", "PARAMS", "2", "query_vector", query_vector, "RETURN", "4", "category", "example", "resolution", "distance", "SORTBY", "distance", "DIALECT", "2")
    if not response or response[0] == 0:
        return None
    raw_fields = response[2]
    fields = {decode_value(raw_fields[position]): decode_value(raw_fields[position + 1]) for position in range(0, len(raw_fields), 2)}
    distance = float(fields["distance"])
    return {"category": fields["category"], "example": fields["example"], "resolution": fields["resolution"], "similarity": max(0.0, min(1.0, 1.0 - distance))}


def print_semantic_result(ticket, result):
    print(f"\nTicket: {ticket}")
    if result is None:
        print("Vector router: NO MATCH")
        return
    print(f"Category: {result['category']}")
    print(f"Matched example: {result['example']}")
    print(f"Suggested resolution: {result['resolution']}")
    print(f"Semantic similarity: {result['similarity']:.3f}")


def parse_args():
    parser = argparse.ArgumentParser(description="Route support tickets by keywords or meaning.")
    parser.add_argument("--mode", choices=("keyword", "vector"), required=True)
    parser.add_argument("--ticket")
    parser.add_argument("--demo", action="store_true")
    return parser.parse_args()


def main():
    args = parse_args()
    if args.mode == "keyword":
        ticket = args.ticket or input("Describe the support issue: ")
        category = keyword_route(ticket)
        if category is None:
            print("Keyword router: NO MATCH")
            print("Limitation: the wording did not contain a configured phrase.")
        else:
            print(f"Keyword router category: {category}")
        return
    client = redis.Redis(host="localhost", port=6379, db=0, protocol=2)
    model = SentenceTransformer(MODEL_NAME)
    create_index(client)
    load_knowledge_base(client, model)
    tickets = DEMO_TICKETS if args.demo else [args.ticket or input("Describe the support issue: ")]
    for ticket in tickets:
        print_semantic_result(ticket, semantic_route(client, model, ticket))


if __name__ == "__main__":
    main()

What should match?

This reference shows the exact end state for app.py. The demo data, command-line option, plus routing loop should appear in the same order.

Secret mission

Route Uncertain Tickets to a Human

Nearest-neighbor search always produces a closest match, even for an unrelated ticket. Add a configurable confidence threshold. Weak matches will go to human review without an automated resolution.

Clean Up Your Resources

Clean Up Your Resources

Your local project has no ongoing cloud costs. Choose whether to keep your resources running, pause them for later, or delete them entirely.

Resources you used:

  • Your Docker container named redis runs on port 6379. It holds the Redis vector index named support-issues. It also holds six support: hashes.
  • Your redis-ai-router folder contains app.py.
  • Your redis-ai-router folder also contains requirements.txt.
  • The tutorial-env Python virtual environment sits inside the same folder.

Keep Redis Local

Port 6379 was published without authentication for local development.

  • Keep this container away from Internet-facing hosts.

Keep everything running

No action is needed. Choose this option if you are still testing tickets or tuning the confidence threshold.

  • Leave the redis container running for more routing experiments.
  • Keep the redis-ai-router folder for future changes.
  • Keep the setup on your local Mac.

Pause - I'll come back to this later

Stop the container to free its active resources. Your project files remain available for your next session.

  • Switch back to the terminal from earlier.
  • Stop the redis container by running this command:
docker stop redis

What does this command do?

This command stops the local container. The support-issues index remains inside the stopped container.

Your six support hashes remain available when the same container starts again.

  • Start the stopped redis container from the container controls in Docker Desktop when you return.

Delete - I don't want to use this again

Deletion is permanent. These commands target only the redis container and the redis-ai-router folder.

  • Switch back to the terminal with tutorial-env active.
  • Stop and remove the redis container by running these commands:
docker stop redis
docker rm redis

What do these commands remove?

The first command stops the container. The second command removes the stopped container.

Removing the container also deletes the support-issues index and its six support hashes.

  • Leave the active virtual environment by running this shell command:
deactivate

What does this command do?

This command ends the current shell session's use of tutorial-env.

The environment remains inside the project folder until you delete that folder.

  • Remove the redis-ai-router folder from its parent directory by running these commands:
cd ..
rm -rf redis-ai-router

What do these commands remove?

The first line moves your terminal outside the project folder. The second line deletes redis-ai-router.

This removes app.py, requirements.txt, and tutorial-env together.

Nice Work!

Nice Work!

You did it! You built a local AI support ticket router that uses Redis vector search to classify paraphrased issues by meaning.

You've learned how to:

  • Built a keyword router that exposed the limits of exact phrase matching when a billing issue was paraphrased.
  • Created 384-dimensional sentence embeddings from six support examples for meaning-based matching.
  • Stored vectors with support metadata in Redis hashes. Queried a FLAT vector index to power a repeatable three-ticket demo with categories, resolutions, and similarity scores.
  • Completed the optional Secret Mission by adding a configurable confidence threshold that routes low-similarity matches to human review.

Ready to quiz yourself?