TechEarl

How to Add Semantic Search to a MySQL App

Add semantic search to an existing MySQL app with MySQL 9's VECTOR type, an embedding model (Voyage, OpenAI), and a cosine-similarity index. No separate vector database needed.

Ishan KarunaratneIshan Karunaratne⏱️ 9 min readUpdated
Add semantic search to an existing MySQL app with MySQL 9's VECTOR type and embeddings from Voyage or OpenAI. Index, query, and rank without a separate vector DB.

MySQL 9.0 added a native VECTOR type and VEC_DISTANCE_COSINE() function that turn any existing MySQL app into a semantic-search-capable system without adding Pinecone or pgvector to the stack. The integration is four steps: add a VECTOR column to the table you want to search, generate embeddings with a model like Voyage-3 or OpenAI's text-embedding-3-small, backfill the column for existing rows, then query with cosine similarity at search time. I'll walk the full integration on a products table — the same table from the MySQL import CSV walkthrough — with code in Python and Node.js.

The reason this is appealing in 2026: the "use a dedicated vector database" architecture made sense in 2023 when Postgres and MySQL had no vector primitives. Now that they do, keeping embeddings and source data in the same transactional database removes a service from your stack, lets you join semantic results with regular SQL filters, and keeps everything in one backup/replication path. For workloads under ~10M vectors, in-database is the cleaner architecture.

Jump to:

Step 1: Add the VECTOR column

MySQL 9.0 introduced VECTOR(N) where N is the dimension count. The number must match your embedding model's output dimension — 1024 for Voyage-3, 1536 for OpenAI text-embedding-3-small, 3072 for text-embedding-3-large.

sql
ALTER TABLE products
ADD COLUMN description_embedding VECTOR(1024) NULL;

NULL for now — you'll backfill in step 3. After backfill, you can optionally tighten with ALTER TABLE products MODIFY description_embedding VECTOR(1024) NOT NULL; once the column is fully populated.

For the broader ADD COLUMN syntax including the modern ALGORITHM=INSTANT for fast schema changes, see How to Add a Column to a MySQL Table.

Step 2: Pick an embedding model

The major choices in 2026 (covered in detail in How to Build RAG with Embeddings and Vector Search):

  • Voyage-3 (1024 dim) — best benchmark scores on technical content, $0.06 per million tokens
  • OpenAI text-embedding-3-small (1536 dim) — solid default, $0.02 per million tokens
  • OpenAI text-embedding-3-large (3072 dim) — higher quality, $0.13 per million tokens
  • Local (Voyage-Code-3, BGE-large) — zero per-token cost, run on your own GPU

For e-commerce product search like the products example: Voyage-3 or text-embedding-3-small are both strong. Pick the cheaper one for the prototype, evaluate quality on real queries, upgrade if needed.

Match the dimension to your VECTOR(N) column. If you pick the model first, set N accordingly; if you've already created the column, pick a model with that dimension.

Step 3: Backfill existing rows

For an existing products table with thousands of rows, generate embeddings for each row's description and update the description_embedding column.

Python with OpenAI:

python
import os
from openai import OpenAI
import mysql.connector

openai = OpenAI()
db = mysql.connector.connect(
    host="localhost", user="root", password=os.environ["DB_PASS"],
    database="myshop", autocommit=False,
)
cursor = db.cursor()

# Stream products in batches
cursor.execute("SELECT sku, description FROM products WHERE description_embedding IS NULL")
batch_size = 100
batch = []
for sku, description in cursor.fetchall():
    batch.append((sku, description))
    if len(batch) >= batch_size:
        skus = [s for s, _ in batch]
        descriptions = [d for _, d in batch]
        # Batch embed (OpenAI supports up to 2048 inputs per call)
        response = openai.embeddings.create(model="text-embedding-3-small", input=descriptions)
        for (sku, _), emb in zip(batch, response.data):
            # MySQL expects vectors as a JSON-like array string
            vec_literal = "[" + ",".join(str(v) for v in emb.embedding) + "]"
            cursor.execute(
                "UPDATE products SET description_embedding = VEC_FROM_TEXT(%s) WHERE sku = %s",
                (vec_literal, sku),
            )
        db.commit()
        batch = []
# Process leftover
if batch:
    descriptions = [d for _, d in batch]
    response = openai.embeddings.create(model="text-embedding-3-small", input=descriptions)
    for (sku, _), emb in zip(batch, response.data):
        vec_literal = "[" + ",".join(str(v) for v in emb.embedding) + "]"
        cursor.execute(
            "UPDATE products SET description_embedding = VEC_FROM_TEXT(%s) WHERE sku = %s",
            (vec_literal, sku),
        )
    db.commit()

VEC_FROM_TEXT() is MySQL 9's function for converting a JSON-array literal to a VECTOR value. Batch sizes of 100 keep the OpenAI call efficient (the API supports up to 2048 inputs per call but smaller batches recover faster from rate-limit errors).

For a 10K-row table this runs in 1-3 minutes including network. For 1M rows, plan an hour and run it from a server close to the embedding provider's region.

Step 4: Query with cosine similarity

Search-time, embed the user query and run a similarity query:

python
def search(query: str, limit: int = 10):
    # Embed the query
    response = openai.embeddings.create(model="text-embedding-3-small", input=[query])
    query_vec = response.data[0].embedding
    vec_literal = "[" + ",".join(str(v) for v in query_vec) + "]"

    cursor.execute(
        """
        SELECT sku, name, description,
               VEC_DISTANCE_COSINE(description_embedding, VEC_FROM_TEXT(%s)) AS distance
        FROM products
        WHERE description_embedding IS NOT NULL
        ORDER BY distance ASC
        LIMIT %s
        """,
        (vec_literal, limit),
    )
    return cursor.fetchall()

VEC_DISTANCE_COSINE() returns the cosine distance (0.0 = identical, 2.0 = opposite). Lower is more similar. ORDER BY distance ASC ranks the most similar first.

For a Node.js equivalent, the mysql2/promise driver works identically — embed via the OpenAI Node SDK, format the vector literal, run the same SQL.

Combining with regular SQL filters

The killer feature of in-database vectors: SQL filters compose with similarity ranking. Need products in stock under $100 similar to "rugged hiking boots"? It's one query.

sql
SELECT sku, name, price_cents,
       VEC_DISTANCE_COSINE(description_embedding, VEC_FROM_TEXT(:query_vec)) AS distance
FROM products
WHERE description_embedding IS NOT NULL
  AND in_stock = 1
  AND price_cents < 10000
ORDER BY distance ASC
LIMIT 10;

With a dedicated vector database, this requires fetching candidates from the vector DB, then querying MySQL for stock/price, then re-merging — slower, more code, more failure modes. With everything in MySQL, it's one SQL statement that the query planner optimises naturally.

Keeping embeddings in sync on writes

When a product's description changes, the embedding becomes stale. Three patterns to keep them in sync:

1. Write-time sync (simplest, slowest writes): in the same transaction that updates description, call the embedding API and update description_embedding. Adds 100-300ms to each write — fine for low-volume admin edits, painful for high-frequency updates.

2. Async sync via queue (preferred for production): writes update description and emit a "re-embed this row" message to a job queue (SQS, RabbitMQ, BullMQ). A worker consumes the queue, embeds the new description, updates the row. Writes stay fast; embeddings catch up within seconds.

3. Periodic batch resync: a nightly job re-embeds every row whose description has been updated since the last run. Tracked via a description_updated_at timestamp column. Simplest if your data doesn't need real-time semantic-search accuracy.

For an e-commerce catalog with thousands of writes per day, async-queue sync is the right pattern. For a small CMS with a handful of edits per week, write-time sync is fine.

Performance: indexing and limits

MySQL 9 supports approximate-nearest-neighbour (ANN) indexes on VECTOR columns. The index dramatically speeds up similarity queries — without it, every query is a full table scan computing cosine distance against every row.

sql
ALTER TABLE products ADD VECTOR INDEX idx_description_embedding(description_embedding) WITH (TYPE='HNSW');

HNSW (Hierarchical Navigable Small World) is the standard ANN index type. Query latency drops from O(N) full-scan to O(log N) approximate.

For an indexed products table with 1M rows and 1024-dim vectors, query latency is typically 5-20ms. Without the index, 200-2000ms.

Scaling limits in MySQL 9.0+: vectors up to 16KB (about 4000 floats at 4 bytes each), tables up to billions of vectors with appropriate hardware. The practical sweet spot is under ~10M vectors; above that, dedicated vector databases pull ahead on raw query latency.

What to do next

For the broader MySQL operational toolkit this semantic-search feature plugs into:

For the AI side that produces the embeddings:

External references: MySQL 9 VECTOR documentation, Voyage AI embeddings, OpenAI embeddings.

FAQ

See also

TagsMySQLVector SearchSemantic SearchEmbeddingsAIVECTOR type
Share
Ishan Karunaratne

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years across software, Linux systems, DevOps, and infrastructure — and a more recent focus on AI. Currently Chief Technology Officer at a tech startup in the healthcare space.

Keep reading

Related posts

Macro photograph of a printer's typecase drawer with brass-and-wood compartments, one new compartment freshly added at the end, single warm side light

How to Add a Column to a MySQL Table

Add a column to a MySQL table with ALTER TABLE ADD COLUMN. Covers DEFAULT values, NOT NULL on existing rows, AFTER positioning, and ALGORITHM=INSTANT on MySQL 8.0.12+.

Create an EBS volume with aws ec2 create-volume, attach it to a running EC2 instance, format with mkfs.ext4 or mkfs.xfs, mount it, and persist across reboots with a UUID-based /etc/fstab entry. Console, AWS CLI, and Terraform walkthroughs.

How to Add an EBS Volume to an EC2 Instance

Create an EBS volume, attach it to a running EC2 instance, format and mount it, and survive reboots with a UUID-based fstab entry. Console, AWS CLI, and Terraform walkthroughs plus the Nitro device-naming gotcha that trips everyone.