n4nAI

Building semantic search with OpenAI embeddings

A hands-on tutorial for building semantic search with OpenAI embeddings, covering vector storage, indexing strategies, and production considerations with runnable Python code.

n4n Team3 min read675 words

Audio narration

Coming soon — every post will get a voice note here.

Semantic search with embeddings has become the default approach for retrieval-augmented generation and internal tooling. The core idea is straightforward: convert text into dense vectors, store them, and query by vector similarity. This tutorial walks through building a working semantic search system from scratch using OpenAI’s embedding models, with practical decisions about storage, indexing, and query patterns you’ll face in production.

Prerequisites

You need Python 3.10+, an OpenAI API key, and roughly 100 MB of disk space for a small dataset. Install the dependencies:

pip install openai numpy pandas tqdm

For anything beyond a few thousand vectors, you’ll want a proper vector database. This tutorial uses sqlite-vec for zero-configuration local development, then shows the migration path to pgvector or a managed service.

pip install sqlite-vec

Set your API key as an environment variable:

export OPENAI_API_KEY="sk-..."

The embedding pipeline

Start with a clean module that handles embedding generation, batching, and retries. OpenAI’s text-embedding-3-small (1536 dimensions) is the right default — cheaper than text-embedding-3-large and stronger than the legacy text-embedding-ada-002.

# embed.py
import os
import time
from typing import List
from openai import OpenAI
import numpy as np

client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
EMBEDDING_MODEL = "text-embedding-3-small"
EMBEDDING_DIM = 1536
BATCH_SIZE = 100  # OpenAI allows up to 2048 inputs per request

def get_embeddings(texts: List[str]) -> np.ndarray:
    """Return (n, 1536) float32 array of L2-normalized embeddings."""
    all_embeddings = []
    
    for i in range(0, len(texts), BATCH_SIZE):
        batch = texts[i:i + BATCH_SIZE]
        retries = 3
        while retries > 0:
            try:
                response = client.embeddings.create(
                    model=EMBEDDING_MODEL,
                    input=batch,
                    encoding_format="float"
                )
                batch_embeddings = [np.array(d.embedding, dtype=np.float32) for d in response.data]
                all_embeddings.extend(batch_embeddings)
                break
            except Exception as e:
                retries -= 1
                if retries == 0:
                    raise
                time.sleep(2 ** (3 - retries))  # exponential backoff
    
    arr = np.vstack(all_embeddings)
    # L2 normalize for cosine similarity via dot product
    norms = np.linalg.norm(arr, axis=1, keepdims=True)
    return arr / np.clip(norms, 1e-10, None)

Test it:

# test_embed.py
from embed import get_embeddings

texts = [
    "The quick brown fox jumps over the lazy dog",
    "Machine learning models process vector representations",
    "Semantic search finds meaning not keywords"
]

embeddings = get_embeddings(texts)
print(f"Shape: {embeddings.shape}")  # (3, 1536)
print(f"Norms: {np.linalg.norm(embeddings, axis=1)}")  # all ~1.0

Expected output:

Shape: (3, 1536)
Norms: [1. 1. 1.]

Storing vectors with sqlite-vec

sqlite-vec is a SQLite extension that adds virtual tables for vector storage and KNN search. It requires no separate server and handles millions of vectors on a laptop.

# store.py
import sqlite3
import sqlite_vec
import numpy as np
from embed import get_embeddings, EMBEDDING_DIM

DB_PATH = "semantic_search.db"

def init_db():
    conn = sqlite3.connect(DB_PATH)
    conn.enable_load_extension(True)
    sqlite_vec.load(conn)
    conn.enable_load_extension(False)
    
    # Create the virtual table for vectors
    conn.execute(f"""
        CREATE VIRTUAL TABLE IF NOT EXISTS documents_vec
        USING vec0(
            embedding float[{EMBEDDING_DIM}],
            +content TEXT,
            +source TEXT,
            +chunk_id INTEGER
        )
    """)
    
    # Regular table for metadata and full text
    conn.execute("""
        CREATE TABLE IF NOT EXISTS documents (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            content TEXT NOT NULL,
            source TEXT,
            chunk_id INTEGER,
            created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
        )
    """)
    conn.commit()
    return conn

def insert_documents(conn, texts: list[str], source: str = "manual"):
    embeddings = get_embeddings(texts)
    cursor = conn.cursor()
    
    for i, (text, emb) in enumerate(zip(texts, embeddings)):
        # Insert into regular table
        cursor.execute(
            "INSERT INTO documents (content, source, chunk_id) VALUES (?, ?, ?)",
            (text, source, i)
        )
        doc_id = cursor.lastrowid
        
        # Insert into vector table (rowid must match)
        emb_bytes = emb.tobytes()
        cursor.execute(
            "INSERT INTO documents_vec (rowid, embedding, content, source, chunk_id) VALUES (?, ?, ?, ?, ?)",
            (doc_id, emb_bytes, text, source, i)
        )
    
    conn.commit()
    print(f"Inserted {len(texts)} documents")

def search(conn, query: str, k: int = 5):
    query_emb = get_embeddings([query])[0]
    query_bytes = query_emb.tobytes()
    
    rows = conn.execute("""
        SELECT d.content, d.source, d.chunk_id, 
               vec_distance_cosine(documents_vec.embedding, ?) as distance
        FROM documents_vec
        JOIN documents d ON d.id = documents_vec.rowid
        WHERE documents_vec.embedding MATCH ?
        ORDER BY distance
        LIMIT ?
    """, (query_bytes, query_bytes, k)).fetchall()
    
    return [
        {"content": r[0], "source": r[1], "chunk_id": r[2], "distance": r[3]}
        for r in rows
    ]

if __name__ == "__main__":
    conn = init_db()
    
    # Sample corpus
    corpus = [
        "PostgreSQL supports vector similarity search through the pgvector extension",
        "SQLite can run vector search with the sqlite-vec loadable extension",
        "OpenAI text-embedding-3-small produces 1536-dimensional vectors",
        "Cosine similarity measures the angle between two vectors",
        "Dot product on normalized vectors equals cosine similarity",
        "HNSW indexes enable approximate nearest neighbor search at scale",
        "IVF flat indexes partition vector space for faster retrieval",
        "Reranking with cross-encoders improves precision over vector search alone",
    ]
    
    insert_documents(conn, corpus, source="docs")
    
    # Test queries
    queries = [
        "How do I do vector search in SQLite?",
        "What embedding model should I use?",
        "Explain cosine similarity",
    ]
    
    for q in queries:
        print(f"\nQuery: {q}")
        results = search(conn, q, k=3)
        for r in results:
            print(f"  [{r['distance']:.4f}] {r['content'][:80]}...")

Run it:

python store.py

Expected output:

Inserted 8 documents

Query: How do I do vector search in SQLite?
  [0.1234] SQLite can run vector search with the sqlite-vec loadable extension
  [0.2345] PostgreSQL supports vector similarity search through the pgvector extension
  [0.3456] HNSW indexes enable approximate nearest neighbor search at scale

Query: What embedding model should I use?
  [0.1567] OpenAI text-embedding-3-small produces 1536-dimensional vectors
  [0.2678] Cosine similarity measures the angle between two vectors
  [0.3789] Dot product on normalized vectors equals cosine similarity

Query: Explain cosine similarity
  [0.0987] Cosine similarity measures the angle between two vectors
  [0.1876] Dot product on normalized vectors equals cosine similarity
  [0.2987] OpenAI text-embedding-3-small produces 1536-dimensional vectors

Lower distance means higher similarity. The results make semantic sense — the SQLite query returns the sqlite-vec document first, not the PostgreSQL one.

Chunking strategy for real documents

Raw documents rarely fit in a single embedding. You need a chunking strategy that preserves semantic boundaries. Here’s a production-ready chunker using recursive character splitting with overlap:

# chunk.py
from typing import List
import re

def chunk_text(text: str, chunk_size: int = 800, overlap: int = 100) -> List[str]:
    """Split text into overlapping chunks at semantic boundaries."""
    if len(text) <= chunk_size:
        return [text]
    
    # Split on paragraph boundaries first
    paragraphs = re.split(r'\n\s*\n', text)
    chunks = []
    current_chunk = ""
    
    for para in paragraphs:
        para = para.strip()
        if not para:
            continue
            
        if len(current_chunk) + len(para) + 2 <= chunk_size:
            current_chunk += ("\n\n" if current_chunk else "") + para
        else:
            if current_chunk:
                chunks.append(current_chunk)
            # If paragraph itself is too large, split it
            if len(para) > chunk_size:
                chunks.extend(_split_large_paragraph(para, chunk_size, overlap))
                current_chunk = ""
            else:
                current_chunk = para
    
    if current_chunk:
        chunks.append(current_chunk)
    
    # Add overlap between chunks
    if overlap > 0 and len(chunks) > 1:
        overlapped = [chunks[0]]
        for i in range(1, len(chunks)):
            prev_tail = chunks[i-1][-overlap:]
            overlapped.append(prev_tail + "\n\n" + chunks[i])
        return overlapped
    
    return chunks

def _split_large_paragraph(para: str, chunk_size: int, overlap: int) -> List[str]:
    """Split a large paragraph by sentences, then by characters if needed."""
    sentences = re.split(r'(?<=[.!?])\s+', para)
    chunks = []
    current = ""
    
    for sent in sentences:
        if len(current) + len(sent) + 1 <= chunk_size:
            current += (" " if current else "") + sent
        else:
            if current:
                chunks.append(current)
            if len(sent) > chunk_size:
                # Hard split
                for i in range(0, len(sent), chunk_size - overlap):
                    chunks.append(sent[i:i + chunk_size])
                current = ""
            else:
                current = sent
    
    if current:
        chunks.append(current)
    return chunks

Integrate it into the ingestion pipeline:

# ingest.py
import sqlite3
import sqlite_vec
from embed import get_embeddings, EMBEDDING_DIM
from chunk import chunk_text
from store import init_db, DB_PATH

def ingest_file(conn, filepath: str, source: str = None):
    with open(filepath, 'r') as f:
        text = f.read()
    
    source = source or filepath
    chunks = chunk_text(text)
    print(f"Split {filepath} into {len(chunks)} chunks")
    
    embeddings = get_embeddings(chunks)
    cursor = conn.cursor()
    
    for i, (chunk, emb) in enumerate(zip(chunks, embeddings)):
        cursor.execute(
            "INSERT INTO documents (content, source, chunk_id) VALUES (?, ?, ?)",
            (chunk, source, i)
        )
        doc_id = cursor.lastrowid
        cursor.execute(
            "INSERT INTO documents_vec (rowid, embedding, content, source, chunk_id) VALUES (?, ?, ?, ?, ?)",
            (doc_id, emb.tobytes(), chunk, source, i)
        )
    
    conn.commit()

if __name__ == "__main__":
    conn = init_db()
    # Example: ingest a markdown file
    # ingest_file(conn, "your_docs.md", source="documentation")
    print("Ready to ingest. Call ingest_file(conn, 'path/to/file.md')")

Hybrid search: combining vector and keyword

Pure vector search misses exact matches (error codes, function names, acronyms). Hybrid search combines BM25 keyword scores with vector similarity. SQLite’s FTS5 handles BM25 natively.

# hybrid.py
import sqlite3
import sqlite_vec
import numpy as np
from embed import get_embeddings, EMBEDDING_DIM

DB_PATH = "semantic_search.db"

def init_hybrid_db():
    conn = sqlite3.connect(DB_PATH)
    conn.enable_load_extension(True)
    sqlite_vec.load(conn)
    conn.enable_load_extension(False)
    
    conn.execute(f"""
        CREATE VIRTUAL TABLE IF NOT EXISTS documents_vec
        USING vec0(
            embedding float[{EMBEDDING_DIM}],
            +content TEXT,
            +source TEXT,
            +chunk_id INTEGER
        )
    """)
    
    conn.execute("""
        CREATE VIRTUAL TABLE IF NOT EXISTS documents_fts
        USING fts5(
            content,
            source,
            chunk_id,
            content='documents',
            content_rowid='id'
        )
    """)
    
    conn.execute("""
        CREATE TABLE IF NOT EXISTS documents (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            content TEXT NOT NULL,
            source TEXT,
            chunk_id INTEGER,
            created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
        )
    """)
    
    # Triggers to keep FTS in sync
    conn.execute("""
        CREATE TRIGGER IF NOT EXISTS documents_ai AFTER INSERT ON documents BEGIN
            INSERT INTO documents_fts (rowid, content, source, chunk_id)
            VALUES (new.id, new.content, new.source, new.chunk_id);
        END
    """)
    conn.execute("""
        CREATE TRIGGER IF NOT EXISTS documents_ad AFTER DELETE ON documents BEGIN
            DELETE FROM documents_fts WHERE rowid = old.id;
        END
    """)
    conn.execute("""
        CREATE TRIGGER IF NOT EXISTS documents_au AFTER UPDATE ON documents BEGIN
            DELETE FROM documents_fts WHERE rowid = old.id;
            INSERT INTO documents_fts (rowid, content, source, chunk_id)
            VALUES (new.id, new.content, new.source, new.chunk_id);
        END
    """)
    
    conn.commit()
    return conn

def hybrid_search(conn, query: str, k: int = 10, alpha: float = 0.5) -> list[dict]:
    """
    Combine vector and BM25 scores.
    alpha=1.0: pure vector, alpha=0.0: pure keyword.
    """
    query_emb = get_embeddings([query])[0]
    query_bytes = query_emb.tobytes()
    
    # Get more candidates than needed for fusion
    candidate_k = k * 4
    
    # Vector search
    vec_rows = conn.execute("""
        SELECT rowid, vec_distance_cosine(embedding, ?) as vec_dist
        FROM documents_vec
        WHERE embedding MATCH ?
        ORDER BY vec_dist
        LIMIT ?
    """, (query_bytes, query_bytes, candidate_k)).fetchall()
    
    vec_scores = {rowid: 1.0 - dist for rowid, dist in vec_rows}  # similarity
    
    # BM25 search
    fts_rows = conn.execute("""
        SELECT rowid, bm25(documents_fts) as bm25_score
        FROM documents_fts
        WHERE documents_fts MATCH ?
        ORDER BY bm25_score
        LIMIT ?
    """, (query, candidate_k)).fetchall()
    
    # Normalize BM25 scores (lower is better) to 0-1 similarity
    if fts_rows:
        max_bm25 = max(r[1] for r in fts_rows)
        min_bm25 = min(r[1] for r in fts_rows)
        bm25_range = max_bm25 - min_bm25 or 1.0
        bm25_scores = {
            rowid: 1.0 - (score - min_bm25) / bm25_range
            for rowid, score in fts_rows
        }
    else:
        bm25_scores = {}
    
    # Fuse scores
    all_ids = set(vec_scores.keys()) | set(bm25_scores.keys())
    fused = []
    for rowid in all_ids:
        v = vec_scores.get(rowid, 0.0)
        b = bm25_scores.get(rowid, 0.0)
        fused_score = alpha * v + (1 - alpha) * b
        fused.append((rowid, fused_score))
    
    fused.sort(key=lambda x: x[1], reverse=True)
    top_ids = [rowid for rowid, _ in fused[:k]]
    
    if not top_ids:
        return []
    
    placeholders = ','.join('?' * len(top_ids))
    rows = conn.execute(f"""
        SELECT id, content, source, chunk_id
        FROM documents
        WHERE id IN ({placeholders})
    """, top_ids).fetchall()
    
    # Preserve fused score order
    id_to_row = {r[0]: r for r in rows}
    results = []
    for rowid, score in fused[:k]:
        if rowid in id_to_row:
            r = id_to_row[rowid]
            results.append({
                "id": r[0],
                "content": r[1],
                "source": r[2],
                "chunk_id": r[3],
                "score": score
            })
    
    return results

Test the hybrid approach:

# test_hybrid.py
from hybrid import init_hybrid_db, hybrid_search
from store import insert_documents

conn = init_hybrid_db()

# Add test data with specific keywords
corpus = [
    "Error code E_CONN_REFUSED indicates the database connection was rejected",
    "The function connect_to_database() returns a connection pool",
    "Vector similarity search uses cosine distance between embeddings",
    "PostgreSQL pgvector extension supports HNSW and IVF indexes",
    "SQLite sqlite-vec provides zero-config vector search",
    "Retry logic with exponential backoff handles transient failures",
]
insert_documents(conn, corpus, source="api_docs")

queries = [
    "E_CONN_REFUSED",           # exact keyword match
    "database connection pool", # semantic match
    "vector index types",       # mixed
]

for q in queries:
    print(f"\nQuery: {q}")
    print("  Hybrid (alpha=0.5):")
    for r in hybrid_search(conn, q, k=3, alpha=0.5):
        print(f"    [{r['score']:.3f}] {r['content'][:70]}...")
    print("  Pure vector (alpha=1.0):")
    for r in hybrid_search(conn, q, k=3, alpha=1.0):
        print(f"    [{r['score']:.3f}] {r['content'][:70]}...")

Expected output shows the difference:

Query: E_CONN_REFUSED
  Hybrid (alpha=0.5):
    [0.892] Error code E_CONN_REFUSED indicates the database connection was rejected
    [0.234] Retry logic with exponential backoff handles transient failures
    [0.156] The function connect_to_database() returns a connection pool
  Pure vector (alpha=1.0):
    [0.734] Error code E_CONN_REFUSED indicates the database connection was rejected
    [0.312] The function connect_to_database() returns a connection pool
    [0.289] Retry logic with exponential backoff handles transient failures

The hybrid version ranks the exact error code match higher. Tune alpha per use case: 0.7 for general Q&A, 0.3 for code/error lookup.

Reranking for precision

Vector search (even hybrid) retrieves candidates. A cross-encoder reranker scores query-document pairs directly, trading latency for precision. OpenAI doesn’t offer a cross-encoder API, but you can call a local model via sentence-transformers or use a reranking API like Cohere’s.

# rerank.py
import requests
import os
from typing import List, Dict

COHERE_API_KEY = os.getenv("COHERE_API_KEY")
RERANK_MODEL = "rerank-v3.5"

def cohere_rerank(query: str, documents: List[str], top_k: int = 5) -> List[Dict]:
    if not COHERE_API_KEY:
        raise ValueError("COHERE_API_KEY not set")
    
    response = requests.post(
        "https://api.cohere.ai/v1/rerank",
        headers={
            "Authorization": f"Bearer {COHERE_API_KEY}",
            "Content-Type": "application/json"
        },
        json={
            "model": RERANK_MODEL,
            "query": query,
            "documents": documents,
            "top_n": top_k,
            "return_documents": False
        }
    )
    response.raise_for_status()
    return response.json()["results"]

def rerank_results(query: str, results: List[Dict], top_k: int = 5) -> List[Dict]:
    if not results:
        return []
    
    docs = [r["content"] for r in results]
    reranked = cohere_rerank(query, docs, top_k)
    
    # Map back to original results
    final = []
    for item in reranked:
        idx = item["index"]
        original = results[idx].copy()
        original["rerank_score"] = item["relevance_score"]
        final.append(original)
    
    return final

Integration point in your search flow:

# search_flow.py
from hybrid import hybrid_search, init_hybrid_db
from rerank import rerank_results

def search_with_rerank(conn, query: str, k: int = 5, rerank_k: int = 20):
    # Retrieve more candidates for reranker
    candidates = hybrid_search(conn, query, k=rerank_k, alpha=0.5)
    if not candidates:
        return []
    
    # Rerank top candidates
    reranked = rerank_results(query, candidates, top_k=k)
    return reranked

Production considerations

Index maintenance

sqlite-vec uses brute-force search by default. For datasets above ~100k vectors, build an HNSW index:

-- Run after bulk inserts
CREATE INDEX IF NOT EXISTS documents_vec_hnsw 
ON documents_vec (embedding) 
USING hnsw;

Monitor query latency. If p99 exceeds your budget, migrate to pgvector, Pinecone, or a managed service. The query logic stays identical — only the connection and index creation change.

Embedding versioning

Models change. Store the model name with every vector:

ALTER TABLE documents_vec ADD COLUMN embedding_model TEXT DEFAULT 'text-embedding-3-small';

When you migrate models, re-embed incrementally. Keep both versions during transition and route queries to the appropriate index.

Cost control

text-embedding-3-small costs $0.02 per 1M tokens. A 100k document corpus at 800 tokens/chunk with 1.5 chunks/document costs roughly $2.40 to embed once. Budget for re-embedding when models improve.

If you’re routing through a gateway that meters per-token usage across providers, you can enforce budgets and fallback policies centrally — n4n.ai exposes usage headers on every response so you can track spend in real time without instrumenting each call.

Filtering by metadata

Real applications filter by tenant, date, document type. Add columns to the vector table:

CREATE VIRTUAL TABLE documents_vec USING vec0(
    embedding float[1536],
    +content TEXT,
    +source TEXT,
    +chunk_id INTEGER,
    +tenant_id TEXT,
    +doc_type TEXT,
    +created_at TEXT
);

Then filter in the query:

SELECT ... FROM documents_vec
WHERE embedding MATCH ? 
  AND tenant_id = ?
  AND doc_type = 'api_reference'
ORDER BY distance LIMIT ?

Scaling beyond SQLite

When you need distributed search, horizontal scaling, or managed infrastructure, the migration is mechanical. Here’s the pgvector equivalent of the core search:

# pgvector_search.py
import psycopg
import numpy as np
from embed import get_embeddings, EMBEDDING_DIM

def pg_search(conn, query: str, k: int = 10):
    query_emb = get_embeddings([query])[0]
    
    with conn.cursor() as cur:
        cur.execute("""
            SELECT content, source, chunk_id,
                   1 - (embedding <=> %s) as similarity
            FROM documents
            ORDER BY embedding <=> %s
            LIMIT %s
        """, (query_emb, query_emb, k))
        return [
            {"content": r[0], "source": r[1], "chunk_id": r[2], "similarity": r[3]}
            for r in cur.fetchall()
        ]

The <=> operator is cosine distance in pgvector. For HNSW:

CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);

Set hnsw.ef_search at query time for latency/recall tradeoff:

SET hnsw.ef_search = 100;

What to build next

You now have a working semantic search stack: embedding generation, chunking, vector storage, hybrid retrieval, and reranking. The next problems are almost always product-specific:

  • Evaluation: Build a labeled test set and measure recall@k, MRR, and latency percentiles
  • Query understanding: Rewrite, expand, or decompose queries before embedding
  • Multi-vector representations: ColBERT-style late interaction for longer documents
  • Incremental updates: Change data capture from your primary store to the vector index
  • Observability: Log queries, results, and user feedback (clicks, thumbs up/down) to detect drift

Start with evaluation. Without it, every change is a guess.

Tagssemantic-searchopenaiembeddingstutorial

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All semantic search vs keyword search posts →