crumpled paper texture
Back to Engineering Articles
EngineeringSeptember 19, 2026(Updated: Sep 19, 2026)28 min read

PostgreSQL + pgvector: Building Semantic Search and RAG Without a Separate Vector Database

A complete, practical tutorial for adding semantic search, hybrid search, and RAG to an existing PostgreSQL application using pgvector — covering embeddings, HNSW, metadata filtering, Reciprocal Rank Fusion, and background embedding pipelines.

Yash Nandvana

Yash Nandvana

Full Stack Developer

PostgreSQL pgvector Architecture — Semantic Search and RAG

The Actual Problem

You already have thousands of technical documents in PostgreSQL.

Product asks: "Can users search these documents using natural language?"

The obvious answer is: add a vector database. Spin up Pinecone, Weaviate, or Qdrant alongside your existing PostgreSQL.

But that means two databases to maintain, two schemas to keep in sync, two sets of backups, two connection pools, two sources of truth. Every document update must propagate to both systems. Multi-tenancy becomes a distributed data problem. The operational complexity compounds quickly.

The question worth asking first is: does your workload actually need a dedicated vector database?

For most SaaS applications — especially those with existing PostgreSQL infrastructure, moderate vector workloads, and strong relational filtering requirements — pgvector inside PostgreSQL is a practical and operationally simpler path.

This article is a complete practical tutorial. By the end, you will have built:

Document chunking and embedding generation
Vector storage inside PostgreSQL with pgvector
Exact and approximate (HNSW) vector search
Metadata filtering with tenant isolation
PostgreSQL full-text search
Hybrid search with Reciprocal Rank Fusion
A complete RAG pipeline with source attribution
Background embedding jobs with BullMQ
A production checklist covering monitoring, security, and failure handling

The Starting Point

We have a SaaS application that stores technical documentation. Users currently search with keyword queries.

The schema:

sql
CREATE TABLE documents (
    id          BIGSERIAL PRIMARY KEY,
    title       TEXT NOT NULL,
    content     TEXT NOT NULL,
    category    TEXT,
    author_id   BIGINT,
    tenant_id   BIGINT NOT NULL,
    created_at  TIMESTAMPTZ DEFAULT NOW(),
    updated_at  TIMESTAMPTZ DEFAULT NOW()
);

Sample data we'll work with throughout:

sql
INSERT INTO documents (title, content, category, author_id, tenant_id) VALUES
('Shopify Webhooks Guide',
 'Shopify sends webhook events to your endpoint whenever specific events occur in a store. To ensure delivery reliability, implement exponential backoff when webhook delivery fails. Verify HMAC signatures on every incoming request using the X-Shopify-Hmac-Sha256 header.',
 'shopify', 1, 1),
('Node.js API Guide',
 'Building REST APIs with Node.js requires handling async errors properly. Use middleware to catch unhandled promise rejections. Implement request validation before business logic.',
 'backend', 1, 1),
('PostgreSQL Transactions Guide',
 'ACID transactions guarantee data integrity. Use BEGIN, COMMIT, and ROLLBACK to wrap operations that must succeed or fail together. Avoid long-running transactions to prevent lock contention.',
 'database', 1, 1),
('Redis Caching Guide',
 'Cache frequently accessed data with appropriate TTL values. Use cache-aside pattern to avoid cache stampede. Invalidate cache entries when underlying data changes.',
 'infrastructure', 1, 1),
('API Security Guide',
 'Protect API endpoints with authentication and authorization. Use JWT tokens with short expiry and refresh token rotation. Rate limiting prevents abuse and protects backend resources.',
 'security', 1, 1);

The current search implementation:

sql
-- Current keyword search
SELECT id, title, category
FROM documents
WHERE tenant_id = $1
  AND (title ILIKE '%' || $2 || '%' OR content ILIKE '%' || $2 || '%')
ORDER BY updated_at DESC;

This works until users start asking natural language questions:

User queryDocument containsMatch?
"retry failed webhooks""exponential backoff for delivery failures"❌ ILIKE misses it
"prevent API abuse""rate limiting protects backend resources"❌ ILIKE misses it
"database consistency""ACID transactions guarantee data integrity"❌ ILIKE misses it

The gap is vocabulary mismatch — the user's words and the document's words are different, even though the meaning is the same.

Why Keyword Search Has Limits (And What FTS Does)

Before adding vectors, it is worth understanding what PostgreSQL's full-text search (FTS) actually does — because it is more capable than plain ILIKE, and it belongs in your hybrid search stack.

PostgreSQL FTS tokenizes text, applies stemming (so "failing" matches "fail" and "failed"), removes stop words, and produces a tsvector representation that can be indexed with a GIN index and queried using tsquery.

sql
-- What FTS sees:
SELECT to_tsvector('english', 'Use exponential backoff when webhook delivery fails');
-- Result: 'backoff':3 'deliveri':6 'exponenti':2 'fail':7 'use':1 'webhook':5

A query for "retry failed webhooks" produces:

sql
SELECT to_tsquery('english', 'retry & failed & webhook');
-- Result: 'retri' & 'fail' & 'webhook'

FTS would match "fails" and "webhook" here, but not the conceptual relationship to "exponential backoff". Full-text search operates on vocabulary. Semantic search operates on meaning.

Both serve different retrieval needs. We will use both.

What Is an Embedding?

An embedding is a dense numeric vector that represents the meaning of a piece of text.

text
"How do I retry failed webhook requests?"
        ↓
  Embedding Model
        ↓
[0.0231, -0.1847, 0.4421, 0.0089, -0.3201, ...]
  (1,536 dimensions for text-embedding-3-small)

The key property: texts with similar meaning produce vectors that are geometrically close to each other in high-dimensional space.

Cosine distance measures the angle between two vectors. When it is near 0, the vectors point in similar directions — semantically similar content. When it is near 1, the content is dissimilar.

Two things worth understanding about dimensions:

1
The number 1,536 is specific to text-embedding-3-small. The model determines the dimensionality — you cannot change it without re-generating all embeddings with a different model.
2
Individual dimensions do not have simple human-readable meanings. The embedding is a holistic representation produced by the model's learned parameters.

OpenAI's text-embedding-3-small is the model we'll use. It produces 1,536-dimensional vectors (or fewer if you use the dimensions parameter to truncate) and offers a good balance of quality and cost. The SDK call:

javascript
import OpenAI from 'openai';

const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

async function generateEmbedding(text) {
  const response = await openai.embeddings.create({
    model: 'text-embedding-3-small',
    input: text,
    encoding_format: 'float',
  });
  return response.data[0].embedding; // float[]
}

This is verified against the current OpenAI Node.js SDK (openai npm package). The method is openai.embeddings.create, the response shape is response.data[0].embedding.

Why pgvector?

pgvector is a PostgreSQL extension that adds:

A vector column type
Distance operators: <-> (L2), <=> (cosine), <#> (inner product), <+> (L1)
HNSW and IVFFlat approximate nearest neighbor indexes
Exact nearest neighbor search by default

Architecture with pgvector:

text
PostgreSQL
├── users
├── documents         (relational data)
├── document_chunks   (chunks + embeddings)
└── All in one database

Why this works well for existing PostgreSQL applications:

Vectors live alongside relational data — one query, one join, one transaction
WHERE clause filtering uses standard PostgreSQL indexes
Existing backup, monitoring, and operational tooling applies
Multi-tenant isolation is enforced with the same mechanisms you already use
No data synchronization between two systems

When a dedicated vector database might make sense:

You need to store billions of vectors with specialized index structures optimized purely for ANN workloads
You need features specific to vector databases: advanced reranking models, multimodal embeddings, complex metadata filtering at very large scale
Vector search is the core product, not a feature alongside relational data
Your team has capacity to operate and monitor a second production system

Do not frame this as "pgvector vs. Pinecone". Frame it as: does my workload fit inside PostgreSQL? For most SaaS applications with moderate vector needs, the answer is yes.

Install pgvector

The fastest way to get PostgreSQL with pgvector for local development is Docker:

yaml
# docker-compose.yml
version: '3.8'
services:
  postgres:
    image: pgvector/pgvector:pg17
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: docsearch
    ports:
      - '5432:5432'
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:

Start it:

bash
docker-compose up -d

Then enable the extension in your database (once per database):

sql
CREATE EXTENSION IF NOT EXISTS vector;

Verify it is installed:

sql
SELECT * FROM pg_extension WHERE extname = 'vector';

The pgvector image tags follow the pattern pgvector/pgvector:pg17 for PostgreSQL 17 with pgvector pre-installed. As of this writing, pgvector 0.8.x supports PostgreSQL 13 through 18.

pgvector Workflow — how documents flow from ingestion through chunking, embedding generation, and into PostgreSQL for retrieval

pgvector Workflow — how documents flow from ingestion through chunking, embedding generation, and into PostgreSQL for retrieval

Database Design: Documents and Chunks

A common mistake is adding a single embedding column directly to the documents table.

Why that is wrong:

A 30-page technical guide produces one embedding for the entire document. That embedding blurs together everything in the guide. When a user asks a specific question, the embedding of the whole document is a poor match for the embedding of that specific question, even if the answer is in the document.

The right approach is chunking:

text
Document: "Shopify Webhooks Guide" (2,000 words)
    ├── Chunk 1: Introduction + What are webhooks (chunk_index: 0)
    ├── Chunk 2: HMAC signature verification (chunk_index: 1)
    ├── Chunk 3: Retry logic and exponential backoff (chunk_index: 2)
    └── Chunk 4: Testing with ngrok and CLI (chunk_index: 3)

Each chunk gets its own embedding. Retrieval finds the specific chunk that answers the specific question.

Schema:

sql
CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE documents (
    id              BIGSERIAL PRIMARY KEY,
    title           TEXT NOT NULL,
    content         TEXT NOT NULL,
    category        TEXT,
    author_id       BIGINT,
    tenant_id       BIGINT NOT NULL,
    content_hash    TEXT,          -- For detecting content changes
    created_at      TIMESTAMPTZ DEFAULT NOW(),
    updated_at      TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE document_chunks (
    id              BIGSERIAL PRIMARY KEY,
    document_id     BIGINT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
    chunk_index     INTEGER NOT NULL,
    content         TEXT NOT NULL,
    metadata        JSONB DEFAULT '{}',
    embedding       vector(1536),   -- text-embedding-3-small dimensions
    embedding_model TEXT,           -- Track which model generated this
    created_at      TIMESTAMPTZ DEFAULT NOW(),
    updated_at      TIMESTAMPTZ DEFAULT NOW(),
    UNIQUE(document_id, chunk_index)
);

-- Standard relational indexes
CREATE INDEX idx_document_chunks_document_id ON document_chunks(document_id);
CREATE INDEX idx_documents_tenant_id ON documents(tenant_id);
CREATE INDEX idx_document_chunks_tenant ON document_chunks(document_id)
    INCLUDE (content, embedding);

-- Composite index for tenant-scoped document filtering
CREATE INDEX idx_documents_tenant_category ON documents(tenant_id, category);

Note the embedding_model column. This matters when you need to migrate to a new embedding model — you need to know which chunks were generated with which model, so you can selectively regenerate them.

Document Chunking

Chunk size is one of the variables that most directly affects retrieval quality. There is no universally correct number. Shorter chunks are more precise but lose surrounding context. Longer chunks provide more context but produce noisier embeddings.

A reasonable starting point for technical documentation: 400–600 tokens per chunk, with 50–100 token overlap between adjacent chunks. Overlap prevents losing information at chunk boundaries.

javascript
// src/documents/chunker.js
const CHUNK_SIZE = 500;       // approximate words (not tokens)
const CHUNK_OVERLAP = 75;     // words of overlap between adjacent chunks

/**
 * Splits document content into overlapping chunks.
 * Attempts to split at paragraph boundaries to preserve coherence.
 */
function chunkDocument(document) {
  const { id, title, content, category, tenant_id } = document;

  // Split on paragraph boundaries first
  const paragraphs = content
    .split(/\n{2,}/)
    .map(p => p.trim())
    .filter(p => p.length > 0);

  const chunks = [];
  let currentChunk = [];
  let currentWordCount = 0;
  let chunkIndex = 0;

  for (const paragraph of paragraphs) {
    const words = paragraph.split(/\s+/);

    // If adding this paragraph would exceed chunk size, flush current chunk
    if (currentWordCount + words.length > CHUNK_SIZE && currentChunk.length > 0) {
      const chunkContent = currentChunk.join('\n\n');

      chunks.push({
        document_id: id,
        chunk_index: chunkIndex++,
        content: chunkContent,
        metadata: {
          document_title: title,
          category,
          tenant_id,
          char_count: chunkContent.length,
          word_count: currentWordCount,
        },
      });

      // Carry over last N words as overlap
      const overlapWords = currentChunk
        .join(' ')
        .split(/\s+/)
        .slice(-CHUNK_OVERLAP);

      currentChunk = [overlapWords.join(' ')];
      currentWordCount = overlapWords.length;
    }

    currentChunk.push(paragraph);
    currentWordCount += words.length;
  }

  // Flush the last chunk
  if (currentChunk.length > 0) {
    const chunkContent = currentChunk.join('\n\n');
    chunks.push({
      document_id: id,
      chunk_index: chunkIndex,
      content: chunkContent,
      metadata: {
        document_title: title,
        category,
        tenant_id,
        char_count: chunkContent.length,
        word_count: currentWordCount,
      },
    });
  }

  return chunks;
}

module.exports = { chunkDocument };

Common mistake: Chunking by character count alone without respecting sentence or paragraph boundaries. This produces mid-sentence splits that degrade embedding quality.

Production consideration: Evaluate retrieval quality against your actual content. Technical documentation with code blocks may need a chunking strategy that keeps code blocks intact. If your documents are heavily structured (headings, subheadings), chunk by heading sections rather than by word count.

Generating Embeddings

Before storing vectors, you need to generate them. Here is the embedding service, including batching and retry logic:

javascript
// src/embeddings/embeddingService.js
import OpenAI from 'openai';

const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

const EMBEDDING_MODEL = 'text-embedding-3-small';
const MAX_BATCH_SIZE = 100; // OpenAI allows batching multiple inputs
const MAX_RETRIES = 3;
const INITIAL_RETRY_DELAY_MS = 1000;

/**
 * Generate an embedding for a single text string.
 * Returns a float[] of 1,536 dimensions.
 */
async function generateEmbedding(text) {
  return generateEmbeddings([text]).then(results => results[0]);
}

/**
 * Generate embeddings for an array of texts in a single API call.
 * Batching is significantly more efficient than one call per chunk.
 */
async function generateEmbeddings(texts) {
  let attempt = 0;

  while (attempt < MAX_RETRIES) {
    try {
      const response = await openai.embeddings.create({
        model: EMBEDDING_MODEL,
        input: texts,
        encoding_format: 'float',
      });

      // Response shape: { data: [{ index: 0, embedding: float[] }, ...] }
      // Sort by index to preserve input order (API guarantees order but explicit is safer)
      return response.data
        .sort((a, b) => a.index - b.index)
        .map(item => item.embedding);

    } catch (error) {
      attempt++;

      if (attempt >= MAX_RETRIES) {
        throw new Error(
          `Embedding generation failed after ${MAX_RETRIES} attempts: ${error.message}`
        );
      }

      // Exponential backoff: 1s, 2s, 4s
      const delay = INITIAL_RETRY_DELAY_MS * Math.pow(2, attempt - 1);
      console.warn(`Embedding attempt ${attempt} failed, retrying in ${delay}ms`);
      await new Promise(resolve => setTimeout(resolve, delay));
    }
  }
}

/**
 * Process chunks in batches to respect API limits.
 */
async function generateChunkEmbeddings(chunks) {
  const results = [];

  for (let i = 0; i < chunks.length; i += MAX_BATCH_SIZE) {
    const batch = chunks.slice(i, i + MAX_BATCH_SIZE);
    const texts = batch.map(chunk => chunk.content);
    const embeddings = await generateEmbeddings(texts);

    results.push(
      ...batch.map((chunk, idx) => ({
        ...chunk,
        embedding: embeddings[idx],
        embedding_model: EMBEDDING_MODEL,
      }))
    );
  }

  return results;
}

export { generateEmbedding, generateEmbeddings, generateChunkEmbeddings, EMBEDDING_MODEL };

Critical: Store the embedding_model with each chunk. When OpenAI releases a new embedding model (or you decide to switch models), you need to know which chunks to regenerate. Embeddings from different models are not comparable — you cannot mix them in the same search.

Storing Vectors in PostgreSQL

This is where Prisma's limitations matter. As of late 2026, Prisma has introduced a pgvector extension package (@prisma/orm-extension-pgvector) that provides type-safe access to vector operations. However, for maximum clarity and direct control, this tutorial uses raw SQL via prisma.$queryRaw and prisma.$executeRaw for vector-specific operations. This approach always works regardless of Prisma version.

The vector type must be cast explicitly when inserting:

javascript
// src/db/chunkRepository.js
import { PrismaClient } from '@prisma/client';
import { Prisma } from '@prisma/client';

const prisma = new PrismaClient();

/**
 * Insert document chunks with their embeddings.
 * Uses raw SQL because Prisma requires explicit casting for the vector type.
 */
async function insertChunksWithEmbeddings(chunks) {
  // Process in a transaction to ensure all-or-nothing insertion
  return prisma.$transaction(async (tx) => {
    for (const chunk of chunks) {
      // Format the vector as a PostgreSQL array literal: '[0.1, -0.2, ...]'
      const vectorLiteral = `[${chunk.embedding.join(',')}]`;

      await tx.$executeRaw`
        INSERT INTO document_chunks
          (document_id, chunk_index, content, metadata, embedding, embedding_model)
        VALUES (
          ${chunk.document_id},
          ${chunk.chunk_index},
          ${chunk.content},
          ${chunk.metadata}::jsonb,
          ${vectorLiteral}::vector,
          ${chunk.embedding_model}
        )
        ON CONFLICT (document_id, chunk_index)
        DO UPDATE SET
          content = EXCLUDED.content,
          metadata = EXCLUDED.metadata,
          embedding = EXCLUDED.embedding,
          embedding_model = EXCLUDED.embedding_model,
          updated_at = NOW()
      `;
    }
  });
}

/**
 * Delete all chunks for a document (before re-processing).
 */
async function deleteChunksForDocument(documentId) {
  return prisma.$executeRaw`
    DELETE FROM document_chunks WHERE document_id = ${documentId}
  `;
}

export { insertChunksWithEmbeddings, deleteChunksForDocument };

The key syntax: ${vectorLiteral}::vector casts the PostgreSQL array literal string to the vector type. Without this cast, the insertion will fail.

First Semantic Search

Now let's build actual vector search.

javascript
// src/search/vectorSearch.js
import { generateEmbedding } from '../embeddings/embeddingService.js';
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

/**
 * Search for the top-K most semantically similar chunks to a query.
 *
 * The <=> operator is cosine distance.
 * Cosine distance ranges from 0 (identical direction) to 2 (opposite).
 * Cosine similarity = 1 - cosine_distance.
 *
 * text-embedding-3-small vectors are already normalized, so
 * cosine distance is the appropriate metric.
 */
async function semanticSearch({ query, tenantId, limit = 10, category = null }) {
  const queryEmbedding = await generateEmbedding(query);
  const vectorLiteral = `[${queryEmbedding.join(',')}]`;

  const results = await prisma.$queryRaw`
    SELECT
      dc.id               AS chunk_id,
      dc.document_id,
      dc.chunk_index,
      dc.content,
      dc.metadata,
      d.title,
      d.category,
      1 - (dc.embedding <=> ${vectorLiteral}::vector) AS cosine_similarity
    FROM document_chunks dc
    JOIN documents d ON d.id = dc.document_id
    WHERE d.tenant_id = ${tenantId}
      AND dc.embedding IS NOT NULL
      ${category ? Prisma.sql`AND d.category = ${category}` : Prisma.empty}
    ORDER BY dc.embedding <=> ${vectorLiteral}::vector
    LIMIT ${limit}
  `;

  return results;
}

export { semanticSearch };

Understanding the distance operators:

OperatorDistance typeUse case
<=>Cosine distanceNormalized vectors (most embedding models)
<->L2 (Euclidean)Unnormalized vectors
<#>Negative inner productWhen maximizing inner product similarity
<+>L1 distanceSparse representations

For text-embedding-3-small, cosine distance (<=>) is appropriate. The distance is 0 for identical direction, approaching 2 for opposite. We convert to similarity with 1 - distance, giving 1.0 for identical, 0.0 for maximally dissimilar.

Common mistake: Confusing lower distance with lower similarity. An ORDER BY embedding <=> query LIMIT 10 orders by ascending distance — the first results are the most similar, not the least.

Exact Search vs. Approximate Search

By default, pgvector performs exact nearest neighbor search. Every vector is compared against the query vector. This guarantees perfect recall — the top-K results are always the true top-K.

The problem: exact search scales linearly with the number of vectors. At 10,000 vectors it is fast. At 10 million it becomes a bottleneck.

Approximate nearest neighbor (ANN) search trades some recall for speed. An index pre-organizes vectors so only a subset needs to be compared at query time.

pgvector supports two ANN index types:

HNSWIVFFlat
------
Build timeSlowerFaster
Memory usageHigherLower
Query performanceBetter recall/speed tradeoffLower recall at same speed
Can build on empty table✅ Yes❌ No (needs data for k-means)
Good starting pointWhen build time matters

For most use cases, HNSW is the better default.

HNSW Index

HNSW (Hierarchical Navigable Small World) builds a multilayer graph where each layer connects vectors to their approximate neighbors. Search traverses from the top layer (coarser connections) down to the bottom layer (precise connections).

sql
-- Add HNSW index for cosine distance (matches our <=> operator)
CREATE INDEX idx_chunk_embedding_hnsw
    ON document_chunks
    USING hnsw (embedding vector_cosine_ops)
    WITH (m = 16, ef_construction = 64);

Parameters:

m (default: 16) — Maximum connections per node per layer. Higher values improve recall but increase memory and build time. 16 is a reasonable default.
ef_construction (default: 64) — Size of the dynamic candidate list during graph construction. Higher values build a better graph at the cost of build time.

Query-time parameter:

sql
-- Before running a search, tune ef_search for this session
SET hnsw.ef_search = 100;  -- default is 40

-- Or per-query inside a transaction:
BEGIN;
SET LOCAL hnsw.ef_search = 100;
SELECT ... FROM document_chunks ORDER BY embedding <=> $1 LIMIT 10;
COMMIT;

Higher ef_search improves recall at the cost of query latency. The default of 40 is conservative. For production, benchmark recall at different values against your specific dataset.

Inspecting query performance:

sql
EXPLAIN (ANALYZE, BUFFERS)
SELECT dc.id, dc.content, dc.embedding <=> '[...]'::vector AS distance
FROM document_chunks dc
JOIN documents d ON d.id = dc.document_id
WHERE d.tenant_id = 1
ORDER BY dc.embedding <=> '[...]'::vector
LIMIT 10;

Look for Index Scan using idx_chunk_embedding_hnsw in the output. If you see Seq Scan, the planner is not using the index — check your WHERE clause and whether the index exists for the correct operator class (vector_cosine_ops).

IVFFlat Index

IVFFlat partitions vectors into lists clusters using k-means. At query time, only the probes closest clusters are searched.

sql
-- Build IVFFlat after loading your initial data (k-means requires data)
-- Rule of thumb: lists = rows / 1000 for up to 1M rows
CREATE INDEX idx_chunk_embedding_ivfflat
    ON document_chunks
    USING ivfflat (embedding vector_cosine_ops)
    WITH (lists = 100);
sql
-- Set probes at query time (default: 1, start with sqrt(lists))
SET ivfflat.probes = 10;

Important: Build the IVFFlat index after loading data. An index built on an empty table will have poor cluster quality and bad recall. If you add significantly more data after building the index, rebuild it.

Metadata Filtering

Filtering is not just a performance optimization — it is a correctness and security requirement.

In a multi-tenant SaaS, Tenant A must never retrieve Tenant B's documents. This is not optional. If you implement vector search without proper tenant filtering, you have a data breach.

sql
-- Correct: filter is part of the query
SELECT
  dc.id, dc.content, d.title, d.category,
  1 - (dc.embedding <=> '[...]'::vector) AS similarity
FROM document_chunks dc
JOIN documents d ON d.id = dc.document_id
WHERE d.tenant_id = $1              -- <-- MANDATORY
  AND dc.embedding IS NOT NULL
ORDER BY dc.embedding <=> '[...]'::vector
LIMIT 10;

How filtering interacts with the HNSW index:

With HNSW (and IVFFlat), the index scan happens first, then the filter is applied. If the filter is very selective (e.g., a small tenant with few documents), the index may return many candidates that fail the WHERE clause, reducing recall.

pgvector 0.8 introduced iterative index scans to address this:

sql
-- Enable iterative scanning (pgvector 0.8+)
SET hnsw.iterative_scan = relaxed_order;
-- Options: off (default), relaxed_order (faster, approx), strict_order (more precise, slower)

-- Safety valve: limit maximum tuples scanned during iteration
SET hnsw.max_scan_tuples = 20000;

With iterative scanning enabled, if the initial ANN pass returns too few results after filtering, the engine performs additional passes over the index until either the LIMIT is satisfied or max_scan_tuples is reached.

For multi-tenant SaaS at scale, consider table partitioning by tenant_id:

sql
-- Partition document_chunks by tenant_id
-- Each partition gets its own HNSW index — smaller index = faster search per tenant
CREATE TABLE document_chunks (
    id          BIGSERIAL,
    document_id BIGINT NOT NULL,
    tenant_id   BIGINT NOT NULL,
    chunk_index INTEGER NOT NULL,
    content     TEXT NOT NULL,
    metadata    JSONB DEFAULT '{}',
    embedding   vector(1536),
    ...
) PARTITION BY HASH (tenant_id);

CREATE TABLE document_chunks_0 PARTITION OF document_chunks
    FOR VALUES WITH (modulus 4, remainder 0);
-- ... (create additional partitions)

Partitioning is an architectural decision with tradeoffs: faster per-tenant search, more complex schema management, harder cross-tenant analytics. Evaluate it when tenant count and data volume justify the complexity.

PostgreSQL Full-Text Search

Now let's implement FTS as the second retrieval leg of our hybrid search.

Add a generated tsvector column that is automatically maintained:

sql
-- Add a generated tsvector column for FTS
-- Using 'english' configuration: tokenization + stemming + stop words
ALTER TABLE documents ADD COLUMN search_vector tsvector
    GENERATED ALWAYS AS (
        setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
        setweight(to_tsvector('english', coalesce(content, '')), 'B')
    ) STORED;

-- GIN index for fast full-text search
CREATE INDEX idx_documents_search_vector ON documents USING gin(search_vector);

The setweight function assigns weight labels (A–D) to different parts of the document. Matches in the title (weight A) rank higher than matches in content (weight B).

FTS keyword search:

javascript
// src/search/ftsSearch.js
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

/**
 * PostgreSQL full-text search using tsvector/tsquery.
 * Uses plainto_tsquery which handles natural language input
 * (vs. to_tsquery which requires explicit operators).
 */
async function fullTextSearch({ query, tenantId, limit = 20, category = null }) {
  // plainto_tsquery converts plain text to tsquery safely
  // 'english' configuration: stemming + stop words
  const results = await prisma.$queryRaw`
    SELECT
      d.id          AS document_id,
      d.title,
      d.category,
      dc.id         AS chunk_id,
      dc.chunk_index,
      dc.content,
      ts_rank(d.search_vector, plainto_tsquery('english', ${query})) AS fts_rank
    FROM documents d
    JOIN document_chunks dc ON dc.document_id = d.id
    WHERE d.tenant_id = ${tenantId}
      AND d.search_vector @@ plainto_tsquery('english', ${query})
      ${category ? `AND d.category = '${category}'` : ''}
    ORDER BY fts_rank DESC
    LIMIT ${limit}
  `;

  return results;
}

export { fullTextSearch };

plainto_tsquery is safer than to_tsquery for user-supplied input because it handles arbitrary text without requiring tsquery syntax. ts_rank computes a relevance score based on term frequency and position weights.

Hybrid Search

Neither FTS nor vector search is universally better. They complement each other:

FTS is better when:

The query contains exact technical terms: "HMAC-SHA256", "useCallback", "ACID"
The user knows the exact terminology in the document
Acronyms and product names matter

Semantic search is better when:

The user describes intent rather than terminology: "how do I stop my API from being abused?"
Vocabulary mismatch between query and document
The user is exploring without knowing exact terminology

Hybrid search combines both:

javascript
// src/search/hybridSearch.js
import { semanticSearch } from './vectorSearch.js';
import { fullTextSearch } from './ftsSearch.js';

/**
 * Reciprocal Rank Fusion combines results from multiple ranked lists.
 *
 * RRF score for a document = sum of 1 / (k + rank_in_each_list)
 * where k is a smoothing constant (60 is the standard value from the original paper).
 *
 * Documents appearing high in both lists score highest.
 * Documents appearing in only one list still contribute.
 */
function reciprocalRankFusion(rankedLists, k = 60) {
  const scores = new Map(); // chunk_id -> RRF score
  const metadata = new Map(); // chunk_id -> chunk data

  for (const rankedList of rankedLists) {
    rankedList.forEach((item, rank) => {
      const id = String(item.chunk_id);
      const current = scores.get(id) || 0;
      scores.set(id, current + 1 / (k + rank + 1));

      if (!metadata.has(id)) {
        metadata.set(id, item);
      }
    });
  }

  // Sort by combined RRF score (descending)
  return Array.from(scores.entries())
    .sort((a, b) => b[1] - a[1])
    .map(([id, score]) => ({
      ...metadata.get(id),
      rrf_score: score,
    }));
}

/**
 * Hybrid search: runs FTS and semantic search in parallel,
 * then fuses results using Reciprocal Rank Fusion.
 */
async function hybridSearch({ query, tenantId, limit = 10, category = null }) {
  // Run both searches in parallel
  const [semanticResults, ftsResults] = await Promise.all([
    semanticSearch({ query, tenantId, limit: 20, category }),
    fullTextSearch({ query, tenantId, limit: 20, category }),
  ]);

  // Fuse the two ranked lists
  const fused = reciprocalRankFusion([semanticResults, ftsResults]);

  return fused.slice(0, limit);
}

export { hybridSearch, reciprocalRankFusion };

Why RRF and not a weighted sum of scores?

Raw scores from different systems are on different scales. A cosine similarity of 0.85 and an ts_rank of 0.40 cannot be meaningfully added without careful normalization, which requires knowing the score distribution. RRF sidesteps this by using only the rank position, not the score magnitude. It is simple, interpretable, and works consistently across different retrieval methods.

RRF is a starting point, not the final answer. As you accumulate user feedback and click-through data, you can tune the weight given to each retrieval method or introduce a cross-encoder reranker as a final step.

Building the RAG Pipeline

Retrieval provides evidence. The LLM generates a coherent answer from that evidence. These are distinct concerns.

javascript
// src/rag/ragPipeline.js
import OpenAI from 'openai';
import { hybridSearch } from '../search/hybridSearch.js';

const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

/**
 * Build the context string from retrieved chunks.
 * Each chunk is labeled with its document title and index for citation.
 */
function buildContext(chunks) {
  return chunks
    .map((chunk, idx) => (
      `[Source ${idx + 1}: ${chunk.title} (chunk ${chunk.chunk_index})]\n${chunk.content}`
    ))
    .join('\n\n---\n\n');
}

/**
 * Extract source attribution from retrieved chunks.
 * Returns unique documents, deduplicated by document_id.
 */
function extractSources(chunks) {
  const seen = new Set();
  return chunks
    .filter(chunk => {
      if (seen.has(chunk.document_id)) return false;
      seen.add(chunk.document_id);
      return true;
    })
    .map(chunk => ({
      document_id: chunk.document_id,
      title: chunk.title,
      category: chunk.category,
      chunk_index: chunk.chunk_index,
    }));
}

/**
 * RAG pipeline:
 * 1. Retrieve relevant chunks via hybrid search
 * 2. Build LLM context from chunks
 * 3. Call LLM with context + user question
 * 4. Return answer + source attribution
 */
async function ragQuery({ question, tenantId, category = null, topK = 5 }) {
  // Step 1: Retrieve relevant chunks
  const chunks = await hybridSearch({
    query: question,
    tenantId,
    limit: topK,
    category,
  });

  if (chunks.length === 0) {
    return {
      answer: "I don't have relevant documentation to answer this question. Please check the available documentation categories.",
      sources: [],
      chunks_used: 0,
    };
  }

  // Step 2: Build context
  const context = buildContext(chunks);

  // Step 3: Call LLM
  // The prompt explicitly instructs the model to:
  // - Use only the provided context
  // - Acknowledge when context is insufficient
  // - Not invent information
  const response = await openai.chat.completions.create({
    model: 'gpt-4o-mini',
    messages: [
      {
        role: 'system',
        content: `You are a technical documentation assistant. 
Answer questions using ONLY the provided documentation excerpts below.
If the documentation does not contain enough information to answer the question, 
say so clearly — do not invent information not present in the context.
When referencing specific information, indicate which source it comes from.`,
      },
      {
        role: 'user',
        content: `Documentation context:\n\n${context}\n\nQuestion: ${question}`,
      },
    ],
    temperature: 0.1, // Low temperature for factual retrieval tasks
    max_tokens: 800,
  });

  const answer = response.choices[0].message.content;

  // Step 4: Return answer + sources
  return {
    answer,
    sources: extractSources(chunks),
    chunks_used: chunks.length,
    usage: {
      prompt_tokens: response.usage.prompt_tokens,
      completion_tokens: response.usage.completion_tokens,
    },
  };
}

export { ragQuery };

The prompt matters. Without explicit instructions, language models tend to fill gaps with plausible-sounding but fabricated information. The system prompt here:

1
Restricts the model to the provided context
2
Instructs it to acknowledge insufficient context rather than guess
3
Encourages source attribution in the answer

Example API endpoint:

javascript
// src/api/searchRoutes.js
import express from 'express';
import { ragQuery } from '../rag/ragPipeline.js';

const router = express.Router();

// All routes require authentication middleware (authMiddleware checks tenant_id)
router.post('/rag', async (req, res) => {
  const { question, category } = req.body;
  const tenantId = req.user.tenant_id; // Never accept tenant_id from client

  if (!question || typeof question !== 'string' || question.trim().length === 0) {
    return res.status(400).json({ error: 'question is required' });
  }

  try {
    const result = await ragQuery({ question, tenantId, category });
    return res.json(result);
  } catch (error) {
    console.error('RAG query error:', error);
    return res.status(500).json({ error: 'Search temporarily unavailable' });
  }
});

export default router;

Security note: tenantId is always taken from the authenticated session, never from the request body. This is non-negotiable.

Document Update Pipeline

When a document's content changes, stale embeddings must be detected and replaced.

javascript
// src/documents/documentService.js
import crypto from 'crypto';
import { PrismaClient } from '@prisma/client';
import { chunkDocument } from './chunker.js';
import { generateChunkEmbeddings } from '../embeddings/embeddingService.js';
import { insertChunksWithEmbeddings, deleteChunksForDocument } from '../db/chunkRepository.js';
import { embeddingQueue } from '../queue/embeddingQueue.js';

const prisma = new PrismaClient();

/**
 * Compute content hash to detect actual changes.
 * If content hasn't changed, there's no need to regenerate embeddings.
 */
function computeContentHash(content) {
  return crypto.createHash('sha256').update(content).digest('hex');
}

/**
 * Update a document and queue embedding regeneration if content changed.
 */
async function updateDocument(documentId, { title, content, category }) {
  const newHash = computeContentHash(content);

  const existing = await prisma.document.findUnique({
    where: { id: documentId },
    select: { content_hash: true },
  });

  await prisma.document.update({
    where: { id: documentId },
    data: { title, content, category, content_hash: newHash, updated_at: new Date() },
  });

  // Only queue re-embedding if content actually changed
  if (!existing || existing.content_hash !== newHash) {
    await embeddingQueue.add('process-document', {
      documentId,
      operation: 'update',
    }, {
      attempts: 3,
      backoff: { type: 'exponential', delay: 2000 },
    });
  }

  return { documentId, embeddingQueued: !existing || existing.content_hash !== newHash };
}

export { updateDocument, computeContentHash };

The content hash check prevents unnecessary API calls to the embedding provider when a document update doesn't actually change the content (e.g., only metadata changed).

Background Embedding Jobs with BullMQ

Embedding generation should never happen synchronously inside an HTTP request. It involves external API calls that can take hundreds of milliseconds per batch, can fail, and may be rate-limited.

Architecture:

text
POST /documents
    ↓
Save document to PostgreSQL
    ↓
Add job to Redis/BullMQ queue
    ↓
Return 202 Accepted immediately

[Background Worker]
    ↓
Dequeue job
    ↓
Chunk document
    ↓
Generate embeddings (OpenAI API)
    ↓
Store vectors in PostgreSQL
    ↓
Mark document as indexed
javascript
// src/queue/embeddingQueue.js
import { Queue } from 'bullmq';
import Redis from 'ioredis';

const connection = new Redis({
  host: process.env.REDIS_HOST || 'localhost',
  port: Number(process.env.REDIS_PORT) || 6379,
  maxRetriesPerRequest: null, // Required by BullMQ
});

export const embeddingQueue = new Queue('document-embeddings', {
  connection,
  defaultJobOptions: {
    attempts: 3,
    backoff: {
      type: 'exponential',
      delay: 2000, // 2s, 4s, 8s
    },
    removeOnComplete: 100, // Keep last 100 completed jobs
    removeOnFail: 500,     // Keep last 500 failed jobs for debugging
  },
});
javascript
// src/workers/embeddingWorker.js
import { Worker } from 'bullmq';
import Redis from 'ioredis';
import { PrismaClient } from '@prisma/client';
import { chunkDocument } from '../documents/chunker.js';
import { generateChunkEmbeddings } from '../embeddings/embeddingService.js';
import { insertChunksWithEmbeddings, deleteChunksForDocument } from '../db/chunkRepository.js';

const prisma = new PrismaClient();

const connection = new Redis({
  host: process.env.REDIS_HOST || 'localhost',
  port: Number(process.env.REDIS_PORT) || 6379,
  maxRetriesPerRequest: null,
});

const worker = new Worker(
  'document-embeddings',
  async (job) => {
    const { documentId, operation } = job.data;

    await job.updateProgress(10);

    // Fetch the document
    const document = await prisma.document.findUnique({
      where: { id: documentId },
    });

    if (!document) {
      // Document was deleted between enqueue and processing — not an error
      console.log(`Document ${documentId} no longer exists, skipping.`);
      return { skipped: true };
    }

    await job.updateProgress(20);

    // Chunk the document
    const chunks = chunkDocument(document);
    await job.updateProgress(40);

    // Generate embeddings in batches
    const chunksWithEmbeddings = await generateChunkEmbeddings(chunks);
    await job.updateProgress(70);

    // Replace old chunks: delete first, then insert
    // This ensures atomicity — no period with partial data
    await deleteChunksForDocument(documentId);
    await insertChunksWithEmbeddings(chunksWithEmbeddings);

    await job.updateProgress(90);

    // Mark document as indexed
    await prisma.document.update({
      where: { id: documentId },
      data: { indexed_at: new Date() },
    });

    await job.updateProgress(100);

    return { documentId, chunksProcessed: chunks.length };
  },
  {
    connection,
    concurrency: 3, // Process up to 3 documents in parallel
  }
);

worker.on('completed', (job, result) => {
  console.log(`Job ${job.id} completed: ${JSON.stringify(result)}`);
});

worker.on('failed', (job, error) => {
  console.error(`Job ${job.id} failed (attempt ${job.attemptsMade}): ${error.message}`);
});

export { worker };

Failure handling:

Failure scenarioBehavior
OpenAI API rate limit (429)Exponential backoff: 2s → 4s → 8s
OpenAI API unavailableRetry 3 times, then mark failed
Document deleted before processingSkip gracefully, log
Worker crashes mid-jobBullMQ detects stalled job and requeues
Duplicate job for same documentUse idempotent delete + insert — safe to re-run

The delete-then-insert pattern for chunks is idempotent. If the job fails after deletion but before insertion, re-running it will regenerate all chunks correctly. There is a brief window where the document has no chunks, but this is acceptable for background indexing.

Monitoring and Evaluation

"The search feels good" is not a measurement.

Build an evaluation dataset early. You need at least 50 real or representative user questions, each annotated with the expected relevant documents.

javascript
// src/eval/searchEval.js

// Example evaluation dataset
const evalDataset = [
  {
    query: "How do I retry failed webhook requests?",
    expectedDocumentIds: [1], // Shopify Webhooks Guide
  },
  {
    query: "prevent API abuse and rate limiting",
    expectedDocumentIds: [5], // API Security Guide
  },
  {
    query: "database consistency and ACID",
    expectedDocumentIds: [3], // PostgreSQL Transactions Guide
  },
];

/**
 * Precision@K: What fraction of top-K results are relevant?
 * Recall@K: What fraction of relevant docs appear in top-K results?
 */
async function evaluateSearch(searchFn, dataset, k = 5) {
  const results = [];

  for (const item of dataset) {
    const searchResults = await searchFn({ query: item.query, tenantId: 1, limit: k });
    const returnedIds = searchResults.map(r => r.document_id);

    const relevantReturned = returnedIds.filter(id => item.expectedDocumentIds.includes(id));

    const precisionAtK = relevantReturned.length / k;
    const recallAtK = relevantReturned.length / item.expectedDocumentIds.length;

    results.push({ query: item.query, precisionAtK, recallAtK });
  }

  const avgPrecision = results.reduce((s, r) => s + r.precisionAtK, 0) / results.length;
  const avgRecall = results.reduce((s, r) => s + r.recallAtK, 0) / results.length;

  return { avgPrecision, avgRecall, details: results };
}

export { evaluateSearch };

Run this evaluation before and after:

Changing chunk size
Switching embedding models
Adjusting HNSW parameters
Changing the RRF fusion weighting

Metrics to track in production:

Embedding generation latency (per chunk, per batch)
Search query latency (end-to-end, vector query, FTS query)
Failed embedding jobs (alert if the rate increases)
RAG answer feedback (thumbs up/down from users)
Embedding API cost per document and per tenant

Performance Considerations

Connection pooling: Use PgBouncer or equivalent. Vector queries hold connections for longer than typical CRUD operations. Default PostgreSQL connection limits will become a bottleneck.

Batch embedding calls: Never call the embedding API once per chunk. Batch 100 chunks per API call. The latency difference is significant.

Cache query embeddings: If users repeat similar queries, consider caching the query embedding in Redis:

javascript
// Cache query embeddings for 1 hour
async function getCachedQueryEmbedding(query) {
  const cacheKey = `qembed:${crypto.createHash('md5').update(query).digest('hex')}`;
  const cached = await redis.get(cacheKey);
  if (cached) return JSON.parse(cached);

  const embedding = await generateEmbedding(query);
  await redis.setex(cacheKey, 3600, JSON.stringify(embedding));
  return embedding;
}

LIMIT matters: ORDER BY embedding <=> $1 LIMIT 10 is much faster than LIMIT 1000. Always set a reasonable top-K. The HNSW index is most efficient with small LIMIT values.

Set maintenance_work_mem before building large indexes:

sql
SET maintenance_work_mem = '2GB';
CREATE INDEX idx_chunk_embedding_hnsw ON document_chunks
    USING hnsw (embedding vector_cosine_ops)
    WITH (m = 16, ef_construction = 64);

Security

Tenant isolation is mandatory and must be enforced at the query layer:

javascript
// Always derive tenantId from the authenticated session
// NEVER accept it from the request body or query parameters
const tenantId = req.session.tenantId; // from auth middleware

Vector search does not bypass authorization. A chunk retrieved by vector similarity must still pass your normal authorization checks. If a document should be accessible only to users with specific permissions, that permission check must happen after retrieval, not only in the embedding indexing pipeline.

Embedding API data handling: When using the OpenAI API, document content is sent to OpenAI's servers for embedding. Review OpenAI's data usage policies. For applications with strict data residency requirements or sensitive content, evaluate:

Self-hosted embedding models (e.g., via Ollama or a private inference endpoint)
OpenAI Enterprise with data processing agreements

Prompt injection: Malicious document content could attempt to manipulate the LLM's behavior through retrieved context. Mitigations:

Validate and sanitize document content at ingestion time
Use a lower temperature for RAG generation
Keep the system prompt authoritative and clearly framed
Monitor for anomalous LLM outputs

SQL injection: Always use parameterized queries. The raw SQL examples in this article use Prisma's tagged template literal syntax (prisma.$queryRaw with ${value}) which parameterizes values automatically.

When pgvector Is Enough

Good fit for pgvector:

You have an existing PostgreSQL application
Vector search is a feature, not the core product
You need strong relational filtering (tenant isolation, category, date ranges)
You need transactional guarantees when inserting documents + chunks + embeddings together
Your vector workload is in the hundreds of thousands to low millions of records
Operational simplicity matters — one database, one backup strategy, one connection pool

When to evaluate dedicated vector infrastructure:

You need to store and search hundreds of millions to billions of vectors with sub-10ms latency
You need specialized features: multimodal indexing, real-time index updates at extreme throughput, or advanced ANN algorithms not available in pgvector
Vector search is the core workload and you need to scale it independently from your relational database
You have a team with capacity to operate a second production system reliably

This is an architectural decision. Benchmark your actual workload before concluding that pgvector is insufficient.

Complete Project Structure

text
src/
├── api/
│   ├── searchRoutes.js       # /search and /rag endpoints
│   └── documentRoutes.js     # Document CRUD + trigger embedding jobs
├── db/
│   ├── chunkRepository.js    # Raw SQL for vector insert/delete
│   └── prisma.js             # PrismaClient singleton
├── embeddings/
│   └── embeddingService.js   # OpenAI SDK calls, batching, retries
├── documents/
│   ├── chunker.js            # Text chunking logic
│   └── documentService.js    # Business logic + content hash
├── search/
│   ├── vectorSearch.js       # pgvector cosine similarity search
│   ├── ftsSearch.js          # PostgreSQL full-text search
│   └── hybridSearch.js       # RRF fusion of both
├── rag/
│   └── ragPipeline.js        # Context construction + LLM call
├── workers/
│   └── embeddingWorker.js    # BullMQ worker
├── queue/
│   └── embeddingQueue.js     # BullMQ queue definition
├── eval/
│   └── searchEval.js         # Evaluation dataset + metrics
└── utils/
    └── contentHash.js        # SHA-256 content hashing

Implementation Checklist

text
[ ] PostgreSQL 13+ installed
[ ] pgvector extension installed (CREATE EXTENSION vector)
[ ] documents table created with content_hash and tenant_id
[ ] document_chunks table created with vector(1536) column
[ ] Embedding model chosen and documented (text-embedding-3-small)
[ ] Chunking implemented with configurable size and overlap
[ ] Embedding generation with batching and retry logic
[ ] Vectors stored with cast (::vector) and model tracking
[ ] First semantic search working with <=> operator
[ ] HNSW index created with vector_cosine_ops
[ ] ef_search tuned and tested
[ ] Tenant filter applied in ALL search queries
[ ] Iterative index scan enabled for filtered search
[ ] Generated tsvector column with GIN index
[ ] Full-text search with plainto_tsquery working
[ ] Hybrid search with RRF fusion implemented
[ ] RAG pipeline with system prompt constraints
[ ] Source attribution in RAG response
[ ] Background embedding queue with BullMQ + Redis
[ ] Exponential backoff on embedding API failures
[ ] Content hash for change detection
[ ] Evaluation dataset created (50+ questions)
[ ] Precision@K and Recall@K measured
[ ] Query embedding caching in Redis
[ ] Prompt injection mitigations reviewed
[ ] Tenant isolation verified by code review and testing
[ ] Data handling policy reviewed with legal/compliance

Conclusion

The architecture we built here does not require a second database. It uses PostgreSQL as the single source of truth for documents, metadata, and embeddings — running FTS and vector search as complementary retrieval methods in the same query layer.

The hybrid approach (FTS + pgvector + RRF) handles the real retrieval scenarios better than either method alone. FTS captures exact terminology. Semantic search captures meaning. Combined, they cover the vocabulary gap that makes pure keyword search frustrating.

The background job architecture keeps the HTTP response path fast. Embedding generation is expensive, rate-limited, and failure-prone — it belongs in a queue, not in a synchronous request.

And the evaluation step is not optional. Measuring Precision@K and Recall@K on a real dataset is the only way to know whether your chunk size, embedding model, index parameters, and fusion weights are actually working.

Further Reading

[Shopify Webhooks: A Complete Guide for Developers](/blog/shopify/shopify-webhooks-guide) — Understanding webhook reliability patterns (referenced in our sample documents)
[Building a Production-Ready REST API with Node.js, PostgreSQL & Prisma](/blog/full-stack/nodejs-postgresql-prisma-rest-api) — The Node.js + PostgreSQL foundation this architecture builds on
[AI Coding Agents in 2026: What Actually Works](/blog/ai-developer-tools/ai-coding-agents-2026) — Practical context for using AI tooling in your development workflow
[pgvector GitHub README](https://github.com/pgvector/pgvector) — Official documentation for all operators, index types, and configuration
[PostgreSQL Full Text Search — Chapter 12](https://www.postgresql.org/docs/current/textsearch.html) — Official PostgreSQL FTS reference
#PostgreSQL#pgvector#Vector Search#Semantic Search#RAG#Embeddings#Node.js#AI#Full-Stack#Backend#HNSW#Hybrid Search
Yash Nandvana

Yash NandvanaFull Stack Developer

Full Stack & Shopify Developer building scalable web apps, developer tools, and AI solutions.

Learn more about Yash
wingsLogo

FROM CONCEPT TO CREATION

LET'S MAKE IT HAPPEN!

I'm available for full-time roles & freelance projects.

I thrive on crafting dynamic web applications, and
delivering seamless user experiences.

>_~/terminal