Home / Blog / Search For Everyone in Production: Performance, Scalability & Best P
Engineering Blueprint • Nov 6, 2026

Search For Everyone in Production: Performance, Scalability & Best P

Practical engineering guide and architectural blueprint for Search For Everyone in Production: Performance, Scalability & Best P.

UPTO 50% OFF
Trending:
BrickTry

AI is analyzing your requirement...

Generating custom modules, implementation options, and dynamic clarification questions.

Add Custom Requirement or Module

Add your own specific features, integrations, or components. AI will incorporate them to dynamically generate the next relevant options.

1. Progressive Clarifications

Click to expand & answer

2. Scope Modules & Features (/ Selected)

Click row to expand details · Customize options
✓
✕

Modern application architectures have reached a inflection point where search capability is no longer an auxiliary feature—it is the core navigation paradigm. However, moving a search feature from a local development environment running naive ILIKE queries or unindexed vector embeddings to a multi-tenant, high-concurrency production system introduces severe engineering bottlenecks.

As datasets scale beyond millions of documents and query throughput climbs into thousands of requests per second (RPS), teams face strict trade-offs between read latency (sub-50ms p95 targets), write freshness (Change Data Capture lag), memory footprints (HNSW vector graphs vs. inverted indices), and tenant isolation.

This technical blueprint breaks down the production patterns required to deploy, scale, and maintain performant hybrid search infrastructure—combining dense vector retrieval and sparse lexical search—without operational degradation.


1. Architectural Paradigms: Monolithic Storage vs. Polyglot Search Engines

When designing search infrastructure, engineers must evaluate whether to leverage existing relational databases with extended search capabilities or offload search workloads to specialized distributed engines.

                    ┌──────────────────────────────────────────────┐
                    │            API Gateway / Router              │
                    └──────────────────────┬───────────────────────┘
                                           │
                    ┌──────────────────────┴───────────────────────┐
                    │         Hybrid Search Coordinator            │
                    │      (Reciprocal Rank Fusion Engine)         │
                    └──────────────┬──────────────────────┬────────┘
                                   │                      │
           ┌───────────────────────┴──────┐        ┌──────┴───────────────────────┐
           │   Lexical Pipeline (BM25)    │        │    Vector Pipeline (HNSW)     │
           │  (OpenSearch / PG tsvector)  │        │    (pgvector / Qdrant / HNSW) │
           └──────────────┬───────────────┘        └──────┬───────────────────────┘
                          │                               │
                          └───────────────┬───────────────┘
                                          │
                               ┌──────────┴──────────┐
                               │ Reranking & Filter  │
                               │  (Cross-Encoder)    │
                               └──────────┬──────────┘
                                          │
                               ┌──────────┴──────────┐
                               │ Final Result Set    │
                               └─────────────────────┘

Single-Store Approaches (PostgreSQL with pgvector & tsvector)

For workloads under 5 million records with modest concurrency (<500 RPS), consolidating storage and search within PostgreSQL minimizes architecture complexity. PostgreSQL provides:

  • Lexical search via tsvector and GIN (Generalized Inverted Index) indexes.
  • Vector search via pgvector supporting HNSW (Hierarchical Navigable Small World) and IVFFlat (Inverted File Flat) index structures.

Trade-off: Shared compute and memory resources. Heavy HNSW index generation or complex GIN scans can starve the primary database of connection pools and CPU cycles needed for core transactional writes.

Dedicated Distributed Search Systems (OpenSearch, Meilisearch, Typesense, Qdrant)

For enterprise-scale, multi-tenant, high-throughput systems, isolating search to dedicated cluster infrastructure is required.

  • Lexical Engines (OpenSearch/Elasticsearch): Distributed inverted indices optimized for BM25 ranking, complex aggregation pipelines, and high horizontally scaled read replicas.
  • Vector Engines (Qdrant/Milvus): Purpose-built vector databases optimized for high-dimensional vector similarity calculations with native scalar filtering and in-memory HNSW graphs.

Comprehensive Storage Engine Trade-Off Matrix

Metric / Feature Postgres (tsvector + pgvector) OpenSearch / Elasticsearch Meilisearch / Typesense Dedicated Vector DBs (Qdrant/Milvus)
P95 Latency (1M Records) 45ms–120ms 10ms–35ms 5ms–15ms 8ms–25ms
Index Memory Overhead Very High (HNSW in RAM) High (JVM Heap + Lucene RAM) Moderate (Memory-mapped files) Optimized (Quantization support)
Hybrid Search Support Native (SQL joins & unions) Native (RRF / Hybrid scoring) Native (Typo + Vector integration) Sparse-Dense vector payloads
Tenant Isolation Model Row-Level Security (RLS) / Schemas Index-per-tenant / Term filters API Key restrictions / Filters Collection partitions / Payloads
Operational Complexity Low (Single DB paradigm) High (Cluster maintenance, JVM tuning) Low to Moderate (Single binary/Cluster) Moderate (Vector-specific ops)

2. Implementing Production Hybrid Search (BM25 + HNSW Vector)

Pure lexical search (BM25) fails when users input conceptual queries lacking exact keyword matches. Pure vector search fails on exact identifiers, model numbers, or rare proper nouns. Production architectures resolve this by implementing Hybrid Retrieval with Reciprocal Rank Fusion (RRF).

RRF scores documents based on their position in separate retrieval lists, calculating the combined rank score without needing to normalize scores from incompatible distributions:

$$RRF_Score(d \in D) = \sum_{m \in M} \frac{1}{k + r_m(d)}$$

Where $M$ is the set of retrieval systems (lexical and vector), $r_m(d)$ is the document's rank within system $m$, and $k$ is a smoothing constant (typically $k=60$).

Production-Grade Hybrid Search Implementation

The following TypeScript implementation demonstrates a resilient RRF search service with strict input typing, tenant payload filtering, combined rank calculation, and fallback execution.

import { Client as OpenSearchClient } from '@opensearch-project/opensearch';
import { OpenAIEmbeddings } from '@langchain/openai';

export interface SearchQueryOptions {
  tenantId: string;
  query: string;
  limit?: number;
  rrfConstant?: number;
  lexicalWeight?: number;
  vectorWeight?: number;
}

export interface SearchHit {
  id: string;
  score: number;
  source: Record<string, unknown>;
}

export class HybridSearchService {
  private client: OpenSearchClient;
  private embedder: OpenAIEmbeddings;

  constructor(opensearchNode: string, openAIApiKey: string) {
    this.client = new OpenSearchClient({ node: opensearchNode });
    this.embedder = new OpenAIEmbeddings({ openAIApiKey, modelName: 'text-embedding-3-small' });
  }

  /**
   * Executes hybrid search combining BM25 lexical search and dense HNSW vector search
   * using Reciprocal Rank Fusion (RRF) for scoring.
   */
  public async executeHybridSearch(options: SearchQueryOptions): Promise<SearchHit[]> {
    const {
      tenantId,
      query,
      limit = 20,
      rrfConstant = 60,
      lexicalWeight = 1.0,
      vectorWeight = 1.0,
    } = options;

    if (!tenantId || !query.trim()) {
      throw new Error('Invalid input: tenantId and non-empty query are required.');
    }

    // Generate dense embedding vector concurrently with lexical execution prep
    const vectorPromise = this.embedder.embedQuery(query);

    // Build Lexical Query (BM25)
    const lexicalQuery = {
      bool: {
        filter: [{ term: { tenant_id: tenantId } }],
        must: [
          {
            multi_match: {
              query,
              fields: ['title^3', 'content^1', 'tags^2'],
              fuzziness: 'AUTO',
            },
          },
        ],
      },
    };

    const vector = await vectorPromise;

    // Build KNN Vector Query (HNSW)
    const vectorQuery = {
      bool: {
        filter: [{ term: { tenant_id: tenantId } }],
        must: [
          {
            knn: {
              embedding_vector: {
                vector: vector,
                k: limit * 2,
              },
            },
          },
        ],
      },
    };

    // Execute searches in parallel
    const [lexicalRes, vectorRes] = await Promise.all([
      this.client.search({ index: 'documents', body: { query: lexicalQuery, size: limit * 2 } }),
      this.client.search({ index: 'documents', body: { query: vectorQuery, size: limit * 2 } }),
    ]);

    const lexicalHits = lexicalRes.body.hits.hits;
    const vectorHits = vectorRes.body.hits.hits;

    // Apply Reciprocal Rank Fusion
    const rrfScores: Map<string, { score: number; source: Record<string, unknown> }> = new Map();

    // Process Lexical Ranks
    lexicalHits.forEach((hit: any, rank: number) => {
      const docId = hit._id;
      const rankScore = lexicalWeight * (1 / (rrfConstant + (rank + 1)));
      rrfScores.set(docId, {
        score: rankScore,
        source: hit._source,
      });
    });

    // Process Vector Ranks
    vectorHits.forEach((hit: any, rank: number) => {
      const docId = hit._id;
      const rankScore = vectorWeight * (1 / (rrfConstant + (rank + 1)));

      if (rrfScores.has(docId)) {
        const existing = rrfScores.get(docId)!;
        existing.score += rankScore;
      } else {
        rrfScores.set(docId, {
          score: rankScore,
          source: hit._source,
        });
      }
    });

    // Sort combined results by aggregated RRF score
    return Array.from(rrfScores.entries())
      .map(([id, payload]) => ({
        id,
        score: payload.score,
        source: payload.source,
      }))
      .sort((a, b) => b.score - a.score)
      .slice(0, limit);
  }
}

3. Real-Time Indexing Infrastructure: The Outbox Pattern & CDC

Dual-writing to both a database and a search cluster inside API HTTP request routes introduces critical vulnerabilities: network calls can fail, database transactions can roll back after search index calls succeed, and concurrent updates cause state drift.

To maintain strict data integrity, production search architectures decoupling index updates use Change Data Capture (CDC) or the Transactional Outbox Pattern.

[ HTTP API Request ] ──► [ Begin Postgres DB Tx ]
                               │
                               ├─► [ Insert/Update Core Entity Table ]
                               ├─► [ Insert Outbox Event Record ]
                               │
                         [ Commit DB Tx ]
                               │
                               ▼
                    [ Outbox CDC Worker ] (Asynchronous Poll/Debezium)
                               │
                               ▼
                     [ Engine Bulk Indexer ] ──► [ OpenSearch / Qdrant ]

Reliable Search Ingestion Pipeline in Go

The following Go worker demonstrates how to asynchronously process queued transactional outbox mutation events, execute bulk updates against a search engine with backoff retries, and prevent split-brain index states.

package main

import (
	"bytes"
	"context"
	"database/sql"
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"time"

	_ "github.com/lib/pq"
)

type OutboxEvent struct {
	ID        string          `json:"id"`
	TenantID  string          `json:"tenant_id"`
	EntityID  string          `json:"entity_id"`
	Operation string          `json:"operation"` // INSERT, UPDATE, DELETE
	Payload   json.RawMessage `json:"payload"`
}

type IndexWorker struct {
	db             *sql.DB
	searchEndpoint string
	httpClient     *http.Client
}

func NewIndexWorker(dbDSN string, searchEndpoint string) (*IndexWorker, error) {
	db, err := sql.Open("postgres", dbDSN)
	if err != nil {
		return nil, fmt.Errorf("db connection failed: %w", err)
	}
	db.SetMaxOpenConns(10)
	db.SetConnMaxLifetime(5 * time.Minute)

	return &IndexWorker{
		db:             db,
		searchEndpoint: searchEndpoint,
		httpClient:     &http.Client{Timeout: 5 * time.Second},
	}, nil
}

func (w *IndexWorker) StartBatchIngestion(ctx context.Context, batchSize int, pollInterval time.Duration) {
	ticker := time.NewTicker(pollInterval)
	defer ticker.Stop()

	for {
		select {
		case <-ctx.Done():
			log.Println("Shutting down search index worker...")
			return
		case <-ticker.C:
			if err := w.processBatch(ctx, batchSize); err != nil {
				log.Printf("ERROR: Processing index batch failed: %v", err)
			}
		}
	}
}

func (w *IndexWorker) processBatch(ctx context.Context, batchSize int) error {
	tx, err := w.db.BeginTx(ctx, nil)
	if err != nil {
		return err
	}
	defer tx.Rollback()

	// Select and lock outbox rows to prevent concurrent worker collisions
	rows, err := tx.QueryContext(ctx, `
		SELECT id, tenant_id, entity_id, operation, payload
		FROM search_outbox
		WHERE processed = FALSE
		ORDER BY created_at ASC
		LIMIT $1
		FOR UPDATE SKIP LOCKED`, batchSize)
	if err != nil {
		return err
	}
	defer rows.Close()

	var events []OutboxEvent
	var eventIDs []string

	for rows.Next() {
		var e OutboxEvent
		if err := rows.Scan(&e.ID, &e.TenantID, &e.EntityID, &e.Operation, &e.Payload); err != nil {
			return err
		}
		events = append(events, e)
		eventIDs = append(eventIDs, e.ID)
	}

	if len(events) == 0 {
		return nil
	}

	// Prepare Bulk API Payload for Search Cluster
	var bulkBuffer bytes.Buffer
	for _, event := range events {
		meta := fmt.Sprintf(`{ "%s" : { "_index" : "documents", "_id" : "%s" } }%n`,
			mapOpToSearchAction(event.Operation), event.EntityID)
		bulkBuffer.WriteString(meta)

		if event.Operation != "DELETE" {
			bulkBuffer.Write(event.Payload)
			bulkBuffer.WriteString("\n")
		}
	}

	// Submit Payload to Search API with Retries
	if err := w.executeSearchBulkHTTP(ctx, bulkBuffer.Bytes()); err != nil {
		return fmt.Errorf("bulk update to search cluster failed: %w", err)
	}

	// Mark events as processed within the same transactional context
	_, err = tx.ExecContext(ctx, `
		UPDATE search_outbox
		SET processed = TRUE, processed_at = NOW()
		WHERE id = ANY($1)`, pqArray(eventIDs))
	if err != nil {
		return err
	}

	return tx.Commit()
}

func (w *IndexWorker) executeSearchBulkHTTP(ctx context.Context, payload []byte) error {
	req, err := http.NewRequestWithContext(ctx, "POST", fmt.Sprintf("%s/_bulk", w.searchEndpoint), bytes.NewReader(payload))
	if err != nil {
		return err
	}
	req.Header.Set("Content-Type", "application/x-ndjson")

	resp, err := w.httpClient.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	if resp.StatusCode >= 400 {
		return fmt.Errorf("search cluster returned non-2xx status code: %d", resp.StatusCode)
	}

	return nil
}

func mapOpToSearchAction(op string) string {
	switch op {
	case "INSERT":
		return "create"
	case "UPDATE":
		return "index"
	case "DELETE":
		return "delete"
	default:
		return "index"
	}
}

func pqArray(ids []string) string {
	// Simplified format conversion for PostgreSQL array syntax binding
	return fmt.Sprintf("{%s}", bytes.Join(bytes.Fields([]byte(fmt.Sprintf("%q", ids))), []byte(",")))
}

4. Sub-50ms Latency Optimization and Cache Topology

Achieving consistent p95 latencies under 50ms across high-concurrency environments requires structural optimizations across query processing, payload sizing, and caching tiers.

                      ┌──────────────────────────┐
                      │    Incoming Query API    │
                      └────────────┬─────────────┘
                                   │
                     ┌─────────────▼─────────────┐
                     │ Query SHA-256 Hash Lookup │
                     └─────────────┬─────────────┘
                                   │
                   ┌───────────────┴───────────────┐
            Cache Hit                              Cache Miss
                   │                               │
        ┌──────────▼──────────┐         ┌──────────▼──────────┐
        │ Return Redis Cached │         │ Normalize & Truncate│
        │ Search Results Payload        │ Query Input Strings │
        └─────────────────────┘         └──────────┬──────────┘
                                                   │
                                        ┌──────────▼──────────┐
                                        │ Execute Lexical +   │
                                        │ Dense Vector Search │
                                        └──────────┬──────────┘
                                                   │
                                        ┌──────────▼──────────┐
                                        │ Cache Formatted Hits│
                                        │  In Redis (Short TTL)│
                                        └─────────────────────┘

1. Query Normalization & Deduplication

Raw query text variations (e.g., "laptop charger ", "Laptop Charger", "laptop charger") break cache keys. Prior to executing expensive search pipeline calls:

  • Strip trailing/leading whitespace and convert string targets to lowercase.
  • Sort token parameters alphabetically for filters.
  • Generate a deterministic hash key (e.g., SHA-256 of tenant_id + normalized_query + filter_payload).

2. Multi-Tiered Cache Invalidation

Caching full search result payloads directly in Redis introduces freshness bugs when underlying data changes.

  • ID-Only Caching: Cache only ordered arrays of document UUIDs for a given query key (TTL: 60 to 300 seconds).
  • Hydration via MGET: Resolve complete document payloads via primary key lookups using Redis MGET or direct database primary-key indexes.
  • Negative Query Caching: Store zero-hit query states with a short TTL (e.g., 10 seconds) to defend downstream clusters against search-enumeration Denial of Service (DoS) attacks.

3. Dimensionality Reduction & Vector Quantization

Uncompressed 1536-dimensional float32 vectors require ~6KB per vector in memory. For 10 million documents, storing vector graphs consumes upwards of 60GB of dedicated RAM.

  • Scalar Quantization (SQ8): Converts 32-bit floating-point numbers into 8-bit integers. Reduces memory footprint by up to 75% with minor (<1%) loss in recall precision.
  • Product Quantization (PQ): Compresses high-dimensional vectors into small byte-codes, enabling multi-million document search directly in host memory caches.

5. Multi-Tenant Security & Isolation Strategies

Data leakage across tenant boundaries is a critical security vulnerability. Multi-tenant search configurations must enforce strict isolation boundaries.

Strategy A: Hard Tenant Isolation (Index-per-Tenant)

Each tenant receives a separate index (e.g., idx_tenant_uuid_documents).

  • Pros: Absolute data segregation; deleting a tenant is a metadata operation (DELETE /idx_tenant_uuid).
  • Cons: Lucene index overhead scales poorly. Managing tens of thousands of open indices causes memory degradation and thread-pool exhaustion.

Strategy B: Soft Tenant Isolation (Filter Context Routing)

All tenants share a central index, with a indexed, mandatory keyword payload field for tenant_id.

  • Pros: Extremely resource-efficient; scales easily to hundreds of thousands of tenants.
  • Cons: A single developer oversight in omitting the tenant_id filter will leak multi-tenant enterprise data.
// Enforcing tenant isolation at the AST query parsing level inside application code:
{
  "query": {
    "bool": {
      "filter": [
        { "term": { "tenant_id.keyword": "tenant_94f8a2c1" } }
      ],
      "must": [
        { "match": { "content": "financial report" } }
      ]
    }
  }
}

How BrickTry Helps You Build and Scale This

Architecting, benchmarking, and maintaining production-grade hybrid search infrastructure requires deep domain expertise spanning distributed databases, vector topology, and async data ingestion pipelines. BrickTry accelerates every phase of this operational lifecycle.

  • Interactive In-Browser Lab Sandbox (/lab): Instantly test, scaffold, and profile multi-tenant hybrid search architectures in a zero-friction, containerized runtime. Benchmark OpenSearch payloads, run vector quantization tests, and validate RRF scoring formulas directly inside browser-isolated Node, Go, and Python environments.
  • Autonomous AI Dev Pairing: Scaffolds error-handled search indices, writes complex raw SQL/GIN schema migrations, and sets up Change Data Capture outbox models automatically.
  • Senior Engineering Pods: Work alongside senior platform engineers who review vector memory bounds, audit multi-tenant logical boundaries, optimize index shard distribution, and guarantee high-availability deployments.
  • 100% IP & Code Ownership: All generated search configurations, sync workers, Docker topologies, and code modules are pushed directly to private GitHub repositories with zero vendor lock-in.

Frequently Asked Questions

1. When should I use pgvector versus a dedicated search engine like OpenSearch or Qdrant?

Use pgvector when your dataset is under 2–5 million records, write throughput is moderate, and your team prioritizes low architectural complexity within a single PostgreSQL operational footprint. Migrate to dedicated search engines (OpenSearch for lexical/hybrid, Qdrant/Milvus for dense vector workloads) when query throughput exceeds 500 RPS, p95 latency needs to remain under 30ms, or vector graph allocations exceed available relational database RAM.

2. How do I prevent search index latency spikes during massive batch database updates?

Avoid executing real-time synchronous indexing during bulk writes. Implement the Transactional Outbox Pattern combined with an asynchronous ingestion worker. The worker reads queued database mutations, batches operations into multi-document API requests (e.g., 500-1000 items per batch), and submits them to the search engine cluster using controlled concurrency and backoff retries.

3. How does Reciprocal Rank Fusion (RRF) compare to Weighted Linear Combination for hybrid search?

Weighted Linear Combination requires normalizing BM25 scores (which range from 0 to $+\infty$) and cosine similarity scores (which range from -1 to 1 or 0 to 1) into identical distributions before combining them—a process highly sensitive to outliers and varying query lengths. RRF uses relative document positions (ranks) rather than raw scores, making it robust, parameter-light, and consistently accurate across vastly different search distributions without requiring continuous score calibration.

Build, Test, and Scale This on BrickTry

BrickTry pairs you with autonomous AI scaffolding supervised by dedicated senior full-stack software engineers in an interactive in-browser development sandbox (/lab). Test, build, and deploy production-grade software with 100% source code ownership and zero vendor lock-in.

Launch Interactive Requirement Builder →

❤️

Support BrickTry Platform & Engineering Development

Help us build, maintain, and advance our AI engineering platform. Every donation fuels open-source tooling, infrastructure, and continuous improvements.

$
Donor Details
Promote Your Brand / Link Wall

UPI / Credit & Debit Cards / Netbanking
Razorpay
Secure 256-bit encrypted checkout
View Leaderboard & Wall

Hey!

Welcome, Let's chat —
start a new conversation
below.

Recent conversations
See all

We’re online to assist you with your project...

Abhishek A Agrawal • Just now

Start a conversation

Quick contact setup

Please share your details below so our team can reach you.

Worldwide supported

🔒 Your info is only used to connect with our support team.

Abhishek A Agrawal

Online & Ready to Assist

BrickTry

Select a Date & Time Enter Details You are Scheduled

Technical Consultation

Video link provided upon confirmation
SUN
MON
TUE
WED
THU
FRI
SAT
Time zone:

,

•

Google Meet
Default HD Video
Calendar invitations with direct video links will be sent here.

You are scheduled

A calendar invitation has been sent to your email address.