June 10, 2026ยท9 min readยทBy Innovibe

Add Semantic Search to Any App Using Embeddings

Keyword search breaks the moment users don't know exactly what to type. Semantic search fixes that โ€” and it's easier to add than you think. Here's the full implementation.

AISearchEmbeddingsPostgreSQL

Keyword search has a fundamental problem: it matches words, not meaning. A user searching "how to cancel my subscription" won't find your article titled "ending your plan" unless those exact words overlap. Semantic search understands that those two phrases mean the same thing.

The underlying technology is embeddings โ€” numerical representations of text that capture meaning. Similar text produces similar vectors. Searching becomes a geometry problem: find the vectors closest to the query vector.

This post walks through a complete implementation you can drop into any existing app.

The architecture

User query โ†’ Embed query โ†’ Vector similarity search โ†’ Ranked results
                                    โ†‘
                          Pre-embedded documents
                          stored in your DB

You pre-compute embeddings for all your content once (and re-run whenever content changes). At search time, you embed the query and find the closest matches. That's the whole thing.

Step 1: Choose your embedding model

For most applications, OpenAI's text-embedding-3-small is the right call:

  • 1536 dimensions (good accuracy)
  • $0.02 per million tokens (cheap)
  • Fast API, no infrastructure to manage

For production at scale or if you need to keep data on-prem, look at sentence-transformers/all-MiniLM-L6-v2 โ€” runs locally, still very good.

Step 2: Set up pgvector

If you're already using PostgreSQL (you probably should be), pgvector adds vector storage and similarity search natively. No separate vector database needed.

-- Enable the extension
CREATE EXTENSION IF NOT EXISTS vector;

-- Add a vector column to your existing table
ALTER TABLE articles ADD COLUMN embedding vector(1536);

-- Create an index for fast similarity search
CREATE INDEX articles_embedding_idx 
ON articles USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);

The ivfflat index makes similarity search fast at scale. For under ~100k rows you can skip it and use exact search instead.

Step 3: Embed your content

import OpenAI from 'openai'
import { Pool } from 'pg'

const openai = new OpenAI()
const db = new Pool({ connectionString: process.env.DATABASE_URL })

async function embedText(text: string): Promise<number[]> {
  const response = await openai.embeddings.create({
    model: 'text-embedding-3-small',
    input: text.slice(0, 8000), // token limit safety
  })
  return response.data[0].embedding
}

async function embedAllArticles() {
  const { rows } = await db.query(
    'SELECT id, title, content FROM articles WHERE embedding IS NULL'
  )

  console.log(`Embedding ${rows.length} articles...`)

  for (const article of rows) {
    // Combine title + content for richer embedding
    const text = `${article.title}\n\n${article.content}`
    const embedding = await embedText(text)

    await db.query(
      'UPDATE articles SET embedding = $1 WHERE id = $2',
      [`[${embedding.join(',')}]`, article.id]
    )

    // Rate limit: OpenAI allows ~3000 RPM on embeddings
    await new Promise(r => setTimeout(r, 20))
  }

  console.log('Done.')
}

Run this once to backfill, then call embedText and update the row whenever content is created or updated.

Step 4: The search function

async function semanticSearch(query: string, limit = 10) {
  // Embed the query using the same model
  const queryEmbedding = await embedText(query)

  // Find the closest articles by cosine similarity
  const { rows } = await db.query(`
    SELECT 
      id,
      title,
      excerpt,
      1 - (embedding <=> $1::vector) AS similarity
    FROM articles
    WHERE embedding IS NOT NULL
    ORDER BY embedding <=> $1::vector
    LIMIT $2
  `, [`[${queryEmbedding.join(',')}]`, limit])

  return rows
}

// Usage
const results = await semanticSearch("how do I cancel my account")
// Returns articles about cancellation, ending subscriptions, etc.
// even if they don't contain those exact words

The <=> operator is cosine distance in pgvector. Lower distance = more similar. We subtract from 1 to get a similarity score (1.0 = identical, 0.0 = unrelated).

Step 5: Add a search API endpoint

import express from 'express'
const app = express()

app.get('/api/search', async (req, res) => {
  const query = req.query.q as string
  
  if (!query || query.length < 2) {
    return res.json({ results: [] })
  }

  try {
    const results = await semanticSearch(query, 8)
    res.json({ results })
  } catch (err) {
    console.error('Search error:', err)
    res.status(500).json({ error: 'Search failed' })
  }
})

Step 6: Hybrid search (the production upgrade)

Pure semantic search can miss exact matches. A user searching for a product SKU like "INV-2024-003" wants exact match, not semantic similarity. The solution is hybrid search: run both and merge the results.

async function hybridSearch(query: string, limit = 10) {
  const queryEmbedding = await embedText(query)

  const { rows } = await db.query(`
    WITH semantic AS (
      SELECT id, 1 - (embedding <=> $1::vector) AS score
      FROM articles
      WHERE embedding IS NOT NULL
      ORDER BY embedding <=> $1::vector
      LIMIT 20
    ),
    keyword AS (
      SELECT id, ts_rank(search_vector, plainto_tsquery('english', $2)) AS score
      FROM articles
      WHERE search_vector @@ plainto_tsquery('english', $2)
      LIMIT 20
    )
    SELECT 
      a.id, a.title, a.excerpt,
      COALESCE(s.score * 0.7, 0) + COALESCE(k.score * 0.3, 0) AS combined_score
    FROM articles a
    LEFT JOIN semantic s ON a.id = s.id
    LEFT JOIN keyword k ON a.id = k.id
    WHERE s.id IS NOT NULL OR k.id IS NOT NULL
    ORDER BY combined_score DESC
    LIMIT $3
  `, [`[${queryEmbedding.join(',')}]`, query, limit])

  return rows
}

This weights semantic results at 70% and keyword results at 30%. Tune the weights for your use case.

Performance notes

  • Embedding API calls take ~100โ€“300ms. Cache results aggressively (query โ†’ embedding pairs don't change).
  • The ivfflat index makes similarity search sub-10ms on millions of rows.
  • For real-time search (as-you-type), debounce the query by 300ms and show results when the user pauses.

When to use it

Semantic search is worth adding when:

  • Users search in natural language ("best way to export data")
  • Your content uses different terminology than users do
  • You have more than ~500 documents
  • Keyword search produces too many zero-result searches

It's probably overkill when:

  • Users are searching for exact IDs, codes, or names
  • Your search corpus is tiny
  • Search isn't a core part of the product experience

Need semantic search in your product? It's usually a 1โ€“2 day addition. Let's talk.

K
Innovibe
Founder & Technical Lead, Innovibe

Building software for 15+ years. Passionate about AI, system design, and shipping things that work.

Frequently asked questions

Do I need a GPU to run this in production?+

For hosted APIs (OpenAI, Anthropic, Google) โ€” no. You call an HTTPS endpoint. GPUs only matter if you're self-hosting models, which is overkill for most production use cases.

How do I keep LLM costs under control?+

Cache identical prompts aggressively, use the smallest model that meets your quality bar, and set hard token limits per request. A response cache alone can cut costs 40โ€“60% on typical workloads.

What's the difference between fine-tuning and RAG?+

Fine-tuning bakes knowledge into model weights โ€” expensive, slow to update. RAG retrieves context at query time โ€” cheap to update, easier to debug. Use RAG for most production use cases and fine-tune only when you need a very specific tone or format.

What embedding model should I use?+

OpenAI's text-embedding-3-small is the best cost/quality trade-off for most apps. If you need self-hosted, nomic-embed-text or all-MiniLM-L6-v2 are solid. Don't over-engineer your first version โ€” you can swap models later.

How do I handle chunking for RAG?+

Start with 512-token chunks with 20% overlap. Paragraphs make better semantic units than arbitrary token counts. Once you have a baseline, experiment โ€” chunking strategy is one of the highest-leverage RAG improvements.

Building something with AI?

We scope and ship AI features quickly. Let's talk.

Start a Conversation