Back to Resources
Build & Launch
Advanced
45 min
Chris MaskChris Mask
Dec 6, 2024

Search Technology Implementation Guide for Marketplaces

Learn how to select and implement the right search technology with code examples for PostgreSQL, Typesense, Elasticsearch, and Algolia.

Who Is This For?

This guide is specifically designed for:

Startup Stage:

MVP & Launch

Building your minimum viable product and preparing for market launch.

Best For Role:

Developers

Technical implementation guides and code examples for developers.

Expected Impact:

Strategic

Medium-term initiatives that build competitive advantages.

Platform: Platform Agnostic
Reading Level: Advanced

What You'll Learn

  • Evaluate search technologies based on scale and requirements
  • Implement full-text search with PostgreSQL for MVPs
  • Configure Typesense for sub-50ms search performance
  • Build Elasticsearch clusters with sync strategies
  • Optimize Algolia integration for conversion

Prerequisites

  • Database design fundamentals
  • RESTful API development
  • Understanding of indexing concepts

Search Technology Implementation Guide for Marketplaces

Implement the right search technology for your scale with complete code examples and migration paths from PostgreSQL to Typesense, Elasticsearch, or Algolia.

For architecture decisions that affect search, see 7 architecture decisions marketplace founders regret. For the UX side of search, read our search filter UX patterns guide.

Technology Decision Matrix

Selection Framework

Choose your search technology based on four key factors:

interface SearchRequirements {
  scale: {
    documents: number;
    queriesPerSecond: number;
    concurrentUsers: number;
  };
  features: {
    typoTolerance: boolean;
    faceting: boolean;
    geoSearch: boolean;
    personalizedRanking: boolean;
    multiLanguage: boolean;
  };
  constraints: {
    budget: number;
    devTime: number; // hours
    infrastructure: "cloud" | "self-hosted" | "hybrid";
  };
  performance: {
    targetLatency: number; // ms
    indexingSpeed: number; // docs/sec
    freshness: "real-time" | "near-real-time" | "batch";
  };
}

function recommendSearchTech(requirements: SearchRequirements): Technology {
  // PostgreSQL: < 100K documents, < 10 QPS, MVP stage
  if (
    requirements.scale.documents < 100000 &&
    requirements.scale.queriesPerSecond < 10 &&
    !requirements.features.personalizedRanking
  ) {
    return {
      name: "PostgreSQL Full-Text",
      setup: "native",
      cost: 0,
      devTime: 8,
      performance: "100-500ms",
    };
  }

  // Typesense: < 10M documents, < 100 QPS, cost-conscious
  if (
    requirements.scale.documents < 10000000 &&
    requirements.constraints.budget < 500 &&
    requirements.performance.targetLatency < 50
  ) {
    return {
      name: "Typesense",
      setup: "self-hosted or cloud",
      cost: 50,
      devTime: 24,
      performance: "10-50ms",
    };
  }

  // Elasticsearch: > 10M documents, complex queries, self-hosted
  if (
    requirements.scale.documents > 10000000 &&
    requirements.constraints.infrastructure === "self-hosted" &&
    requirements.features.faceting
  ) {
    return {
      name: "Elasticsearch",
      setup: "self-hosted cluster",
      cost: 200,
      devTime: 80,
      performance: "20-100ms",
    };
  }

  // Algolia: Any scale, managed solution, conversion-focused
  return {
    name: "Algolia",
    setup: "fully managed",
    cost: 1000,
    devTime: 16,
    performance: "1-20ms",
  };
}

Technology Comparison Table

TechnologyBest ForDocumentsQPSLatencyMonthly CostSetup Time
PostgreSQLMVP < 100K docs10K-100K< 10100-500ms$08 hours
TypesenseGrowth < 10M docs100K-10M< 10010-50ms$50-50024 hours
ElasticsearchScale > 10M docs1M-100M+< 100020-100ms$200-200080 hours
AlgoliaManaged, Any scaleAnyAny1-20ms$1000+16 hours

Implementation for MVP Stage

Use PostgreSQL's native full-text search for initial launch:

-- Create search index on listings
CREATE INDEX idx_listings_search ON listings
USING GIN(to_tsvector('english',
  title || ' ' ||
  description || ' ' ||
  COALESCE(tags::text, '')
));

-- Add generated column for better performance
ALTER TABLE listings
ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
  setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
  setweight(to_tsvector('english', coalesce(description, '')), 'B') ||
  setweight(to_tsvector('english', coalesce(tags::text, '')), 'C')
) STORED;

CREATE INDEX idx_listings_search_vector ON listings
USING GIN(search_vector);

-- Search query with ranking
CREATE FUNCTION search_listings(
  query_text TEXT,
  limit_count INT DEFAULT 20,
  offset_count INT DEFAULT 0
) RETURNS TABLE (
  id UUID,
  title TEXT,
  description TEXT,
  price DECIMAL,
  rank REAL
) AS $$
BEGIN
  RETURN QUERY
  SELECT
    l.id,
    l.title,
    l.description,
    l.price,
    ts_rank(l.search_vector, websearch_to_tsquery('english', query_text)) AS rank
  FROM listings l
  WHERE l.search_vector @@ websearch_to_tsquery('english', query_text)
    AND l.status = 'active'
  ORDER BY rank DESC, l.created_at DESC
  LIMIT limit_count
  OFFSET offset_count;
END;
$$ LANGUAGE plpgsql;

Advanced Filtering with PostgreSQL

Combine full-text search with structured filters:

interface SearchFilters {
  query?: string;
  category?: string;
  priceRange?: { min: number; max: number };
  location?: { lat: number; lng: number; radius: number };
  rating?: number;
  verified?: boolean;
}

class PostgreSQLSearchService {
  async search(
    filters: SearchFilters,
    pagination: { limit: number; offset: number },
  ): Promise<SearchResults> {
    const conditions: string[] = ["status = 'active'"];
    const params: any[] = [];
    let paramIndex = 1;

    // Full-text search
    if (filters.query) {
      conditions.push(
        `search_vector @@ websearch_to_tsquery('english', $${paramIndex})`,
      );
      params.push(filters.query);
      paramIndex++;
    }

    // Category filter
    if (filters.category) {
      conditions.push(`category = $${paramIndex}`);
      params.push(filters.category);
      paramIndex++;
    }

    // Price range
    if (filters.priceRange) {
      conditions.push(`price BETWEEN $${paramIndex} AND $${paramIndex + 1}`);
      params.push(filters.priceRange.min, filters.priceRange.max);
      paramIndex += 2;
    }

    // Geographic search
    if (filters.location) {
      conditions.push(`
        ST_DWithin(
          location::geography,
          ST_MakePoint($${paramIndex}, $${paramIndex + 1})::geography,
          $${paramIndex + 2}
        )
      `);
      params.push(
        filters.location.lng,
        filters.location.lat,
        filters.location.radius,
      );
      paramIndex += 3;
    }

    // Rating filter
    if (filters.rating) {
      conditions.push(`avg_rating >= $${paramIndex}`);
      params.push(filters.rating);
      paramIndex++;
    }

    const query = `
      SELECT
        l.*,
        ${filters.query ? `ts_rank(l.search_vector, websearch_to_tsquery('english', $1)) AS rank,` : ""}
        COUNT(*) OVER() AS total_count
      FROM listings l
      WHERE ${conditions.join(" AND ")}
      ORDER BY ${filters.query ? "rank DESC," : ""} created_at DESC
      LIMIT $${paramIndex}
      OFFSET $${paramIndex + 1}
    `;

    params.push(pagination.limit, pagination.offset);

    const results = await db.query(query, params);

    return {
      items: results.rows,
      total: results.rows[0]?.total_count || 0,
      page: Math.floor(pagination.offset / pagination.limit) + 1,
      pages: Math.ceil((results.rows[0]?.total_count || 0) / pagination.limit),
    };
  }
}

Performance Optimization

Optimize PostgreSQL search for sub-500ms response times:

-- Partial index for active listings only
CREATE INDEX idx_active_listings_search ON listings
USING GIN(search_vector)
WHERE status = 'active';

-- Materialized view for popular searches
CREATE MATERIALIZED VIEW popular_search_results AS
SELECT
  query,
  array_agg(listing_id ORDER BY rank DESC) AS listing_ids,
  COUNT(*) AS result_count
FROM search_queries sq
JOIN search_results sr ON sq.id = sr.query_id
WHERE sq.query_count > 100
GROUP BY query;

CREATE UNIQUE INDEX idx_popular_searches ON popular_search_results(query);

-- Refresh periodically
REFRESH MATERIALIZED VIEW CONCURRENTLY popular_search_results;

Typesense Implementation

Cluster Setup and Configuration

Deploy Typesense for sub-50ms search performance:

# docker-compose.yml
version: "3.8"
services:
  typesense:
    image: typesense/typesense:0.25.1
    ports:
      - "8108:8108"
    volumes:
      - ./typesense-data:/data
    environment:
      - TYPESENSE_API_KEY=${TYPESENSE_API_KEY}
      - TYPESENSE_DATA_DIR=/data
    command: "--data-dir /data --api-key=${TYPESENSE_API_KEY} --enable-cors"

Schema Definition

Configure collection schema with ranking signals:

import Typesense from "typesense";

const client = new Typesense.Client({
  nodes: [
    {
      host: "localhost",
      port: 8108,
      protocol: "http",
    },
  ],
  apiKey: process.env.TYPESENSE_API_KEY!,
  connectionTimeoutSeconds: 2,
});

const listingSchema = {
  name: "listings",
  fields: [
    // Text search fields
    { name: "title", type: "string", facet: false },
    { name: "description", type: "string", facet: false },
    { name: "tags", type: "string[]", facet: true },

    // Structured filters
    { name: "category", type: "string", facet: true },
    { name: "subcategory", type: "string", facet: true, optional: true },
    { name: "price", type: "float", facet: true },
    { name: "currency", type: "string", facet: true },

    // Geographic search
    { name: "location", type: "geopoint", facet: false },
    { name: "city", type: "string", facet: true },
    { name: "state", type: "string", facet: true },

    // Ranking signals
    { name: "avg_rating", type: "float", facet: true },
    { name: "review_count", type: "int32", facet: false },
    { name: "booking_count", type: "int32", facet: false },
    { name: "verified", type: "bool", facet: true },
    { name: "featured", type: "bool", facet: true },
    { name: "created_at", type: "int64", facet: false },

    // Metadata
    { name: "status", type: "string", facet: true },
    { name: "provider_id", type: "string", facet: false },
    { name: "image_url", type: "string", facet: false, optional: true },
  ],

  // Default sorting field
  default_sorting_field: "booking_count",

  // Token separators for better matching
  token_separators: ["-", "_"],
};

await client.collections().create(listingSchema);

Document Indexing

Sync documents from PostgreSQL to Typesense:

class TypesenseIndexer {
  private batchSize = 1000;

  async indexAllListings(): Promise<void> {
    let offset = 0;
    let hasMore = true;

    while (hasMore) {
      const listings = await db.listings.findMany({
        take: this.batchSize,
        skip: offset,
        where: { status: "active" },
        include: {
          provider: { select: { id: true, verified: true } },
          reviews: {
            select: { rating: true },
          },
        },
      });

      if (listings.length === 0) {
        hasMore = false;
        continue;
      }

      const documents = listings.map((listing) => ({
        id: listing.id,
        title: listing.title,
        description: listing.description,
        tags: listing.tags || [],
        category: listing.category,
        subcategory: listing.subcategory,
        price: listing.price,
        currency: listing.currency,
        location: [listing.latitude, listing.longitude],
        city: listing.city,
        state: listing.state,
        avg_rating: this.calculateAvgRating(listing.reviews),
        review_count: listing.reviews.length,
        booking_count: listing.bookingCount,
        verified: listing.provider.verified,
        featured: listing.featured,
        created_at: listing.createdAt.getTime(),
        status: listing.status,
        provider_id: listing.providerId,
        image_url: listing.images[0]?.url,
      }));

      await client
        .collections("listings")
        .documents()
        .import(documents, { action: "upsert" });

      offset += this.batchSize;
      console.log(`Indexed ${offset} documents`);
    }
  }

  async indexDocument(listingId: string): Promise<void> {
    const listing = await db.listings.findUnique({
      where: { id: listingId },
      include: {
        provider: { select: { id: true, verified: true } },
        reviews: { select: { rating: true } },
      },
    });

    if (!listing) {
      await this.deleteDocument(listingId);
      return;
    }

    const document = this.transformToDocument(listing);

    await client.collections("listings").documents().upsert(document);
  }

  async deleteDocument(listingId: string): Promise<void> {
    await client.collections("listings").documents(listingId).delete();
  }
}

Advanced Search Queries

Implement multi-faceted search with ranking:

class TypesenseSearchService {
  async search(params: SearchParams): Promise<SearchResults> {
    const searchParameters = {
      q: params.query || "*",
      query_by: "title,description,tags",

      // Prefix matching on title for better relevance
      prefix: params.query ? "true,false,false" : "false",

      // Filters
      filter_by: this.buildFilterString(params.filters),

      // Faceting
      facet_by: "category,tags,city,avg_rating,verified",
      max_facet_values: 20,

      // Sorting (custom ranking formula)
      sort_by: this.buildSortString(params.sort),

      // Pagination
      page: params.page || 1,
      per_page: params.perPage || 20,

      // Typo tolerance
      num_typos: 2,
      typo_tokens_threshold: 1,

      // Highlighting
      highlight_full_fields: "title,description",
      highlight_affix_num_tokens: 3,

      // Geographic search
      ...(params.location && {
        sort_by: `location(${params.location.lat}, ${params.location.lng}):asc`,
      }),
    };

    const results = await client
      .collections("listings")
      .documents()
      .search(searchParameters);

    return {
      items: results.hits?.map((hit) => hit.document) || [],
      facets: this.transformFacets(results.facet_counts),
      total: results.found,
      page: results.page,
      pages: Math.ceil(results.found / (params.perPage || 20)),
      searchTime: results.search_time_ms,
    };
  }

  private buildFilterString(filters: SearchFilters): string {
    const conditions: string[] = ["status:=active"];

    if (filters.category) {
      conditions.push(`category:=${filters.category}`);
    }

    if (filters.priceRange) {
      conditions.push(
        `price:[${filters.priceRange.min}..${filters.priceRange.max}]`,
      );
    }

    if (filters.rating) {
      conditions.push(`avg_rating:>=${filters.rating}`);
    }

    if (filters.verified) {
      conditions.push(`verified:=true`);
    }

    if (filters.location) {
      conditions.push(
        `location:(${filters.location.lat}, ${filters.location.lng}, ${filters.location.radius} km)`,
      );
    }

    return conditions.join(" && ");
  }

  private buildSortString(sort?: SortOption): string {
    if (sort?.field === "relevance") {
      return "_text_match:desc,booking_count:desc";
    }
    if (sort?.field === "price") {
      return `price:${sort.order},booking_count:desc`;
    }
    if (sort?.field === "rating") {
      return `avg_rating:${sort.order},review_count:desc`;
    }
    // Default: popularity-based ranking
    return "booking_count:desc,avg_rating:desc,created_at:desc";
  }
}

Real-Time Sync Strategy

Keep Typesense in sync with PostgreSQL using event streaming:

class TypesenseSyncService {
  private queue: Queue;
  private indexer: TypesenseIndexer;

  constructor() {
    this.queue = new Queue("typesense-sync", {
      connection: redis,
    });
    this.indexer = new TypesenseIndexer();
    this.setupWorker();
  }

  private setupWorker(): void {
    this.queue.process(async (job) => {
      const { operation, listingId } = job.data;

      switch (operation) {
        case "index":
        case "update":
          await this.indexer.indexDocument(listingId);
          break;
        case "delete":
          await this.indexer.deleteDocument(listingId);
          break;
      }
    });
  }

  // Call these from your application logic
  async onListingCreated(listingId: string): Promise<void> {
    await this.queue.add("index", { operation: "index", listingId });
  }

  async onListingUpdated(listingId: string): Promise<void> {
    await this.queue.add("update", { operation: "update", listingId });
  }

  async onListingDeleted(listingId: string): Promise<void> {
    await this.queue.add("delete", { operation: "delete", listingId });
  }
}

Elasticsearch Implementation

Cluster Architecture

Deploy production-ready Elasticsearch cluster:

# docker-compose.yml for 3-node cluster
version: "3.8"
services:
  es01:
    image: docker.elastic.co/elasticsearch/elasticsearch:8.11.0
    container_name: es01
    environment:
      - node.name=es01
      - cluster.name=marketplace-cluster
      - discovery.seed_hosts=es02,es03
      - cluster.initial_master_nodes=es01,es02,es03
      - bootstrap.memory_lock=true
      - "ES_JAVA_OPTS=-Xms4g -Xmx4g"
    ulimits:
      memlock:
        soft: -1
        hard: -1
    volumes:
      - es01-data:/usr/share/elasticsearch/data
    ports:
      - 9200:9200

  es02:
    image: docker.elastic.co/elasticsearch/elasticsearch:8.11.0
    container_name: es02
    environment:
      - node.name=es02
      - cluster.name=marketplace-cluster
      - discovery.seed_hosts=es01,es03
      - cluster.initial_master_nodes=es01,es02,es03
      - bootstrap.memory_lock=true
      - "ES_JAVA_OPTS=-Xms4g -Xmx4g"
    ulimits:
      memlock:
        soft: -1
        hard: -1
    volumes:
      - es02-data:/usr/share/elasticsearch/data

  es03:
    image: docker.elastic.co/elasticsearch/elasticsearch:8.11.0
    container_name: es03
    environment:
      - node.name=es03
      - cluster.name=marketplace-cluster
      - discovery.seed_hosts=es01,es02
      - cluster.initial_master_nodes=es01,es02,es03
      - bootstrap.memory_lock=true
      - "ES_JAVA_OPTS=-Xms4g -Xmx4g"
    ulimits:
      memlock:
        soft: -1
        hard: -1
    volumes:
      - es03-data:/usr/share/elasticsearch/data

volumes:
  es01-data:
  es02-data:
  es03-data:

Index Mapping

Define optimal mapping for marketplace listings:

import { Client } from "@elastic/elasticsearch";

const esClient = new Client({
  node: "http://localhost:9200",
});

const listingMapping = {
  settings: {
    number_of_shards: 3,
    number_of_replicas: 2,
    analysis: {
      analyzer: {
        marketplace_analyzer: {
          type: "custom",
          tokenizer: "standard",
          filter: [
            "lowercase",
            "asciifolding",
            "marketplace_synonym",
            "english_stop",
            "english_stemmer",
          ],
        },
      },
      filter: {
        marketplace_synonym: {
          type: "synonym",
          synonyms: [
            "apartment, flat, condo",
            "tutor, teacher, instructor",
            "repair, fix, service",
          ],
        },
        english_stop: {
          type: "stop",
          stopwords: "_english_",
        },
        english_stemmer: {
          type: "stemmer",
          language: "english",
        },
      },
    },
  },
  mappings: {
    properties: {
      // Text fields with custom analyzer
      title: {
        type: "text",
        analyzer: "marketplace_analyzer",
        fields: {
          keyword: { type: "keyword" },
          ngram: {
            type: "text",
            analyzer: "ngram_analyzer",
          },
        },
      },
      description: {
        type: "text",
        analyzer: "marketplace_analyzer",
      },

      // Keyword fields for exact matching
      category: { type: "keyword" },
      tags: { type: "keyword" },
      status: { type: "keyword" },

      // Numeric fields
      price: { type: "float" },
      avg_rating: { type: "float" },
      review_count: { type: "integer" },
      booking_count: { type: "integer" },

      // Boolean fields
      verified: { type: "boolean" },
      featured: { type: "boolean" },

      // Date fields
      created_at: { type: "date" },
      updated_at: { type: "date" },

      // Geo field
      location: { type: "geo_point" },

      // Nested objects
      provider: {
        type: "nested",
        properties: {
          id: { type: "keyword" },
          name: { type: "text" },
          verified: { type: "boolean" },
          rating: { type: "float" },
        },
      },
    },
  },
};

await esClient.indices.create({
  index: "listings",
  body: listingMapping,
});

Search Query DSL

Build complex search queries with aggregations:

class ElasticsearchService {
  async search(params: SearchParams): Promise<SearchResults> {
    const query = {
      bool: {
        must: this.buildMustClauses(params),
        filter: this.buildFilterClauses(params),
        should: this.buildBoostClauses(params),
        minimum_should_match: 0,
      },
    };

    const response = await esClient.search({
      index: "listings",
      body: {
        query,
        aggs: this.buildAggregations(params),
        sort: this.buildSort(params),
        from: (params.page - 1) * params.perPage,
        size: params.perPage,
        highlight: {
          fields: {
            title: {},
            description: {},
          },
        },
      },
    });

    return this.transformResults(response);
  }

  private buildMustClauses(params: SearchParams): any[] {
    const clauses: any[] = [];

    if (params.query) {
      clauses.push({
        multi_match: {
          query: params.query,
          fields: ["title^3", "title.ngram^2", "description", "tags^2"],
          type: "best_fields",
          fuzziness: "AUTO",
          prefix_length: 2,
        },
      });
    }

    return clauses.length > 0 ? clauses : [{ match_all: {} }];
  }

  private buildFilterClauses(params: SearchParams): any[] {
    const filters: any[] = [{ term: { status: "active" } }];

    if (params.filters.category) {
      filters.push({ term: { category: params.filters.category } });
    }

    if (params.filters.priceRange) {
      filters.push({
        range: {
          price: {
            gte: params.filters.priceRange.min,
            lte: params.filters.priceRange.max,
          },
        },
      });
    }

    if (params.filters.rating) {
      filters.push({
        range: {
          avg_rating: { gte: params.filters.rating },
        },
      });
    }

    if (params.filters.location) {
      filters.push({
        geo_distance: {
          distance: `${params.filters.location.radius}km`,
          location: {
            lat: params.filters.location.lat,
            lon: params.filters.location.lng,
          },
        },
      });
    }

    return filters;
  }

  private buildBoostClauses(params: SearchParams): any[] {
    return [
      // Boost verified providers
      {
        term: {
          verified: {
            value: true,
            boost: 2.0,
          },
        },
      },
      // Boost featured listings
      {
        term: {
          featured: {
            value: true,
            boost: 1.5,
          },
        },
      },
      // Boost recent listings
      {
        range: {
          created_at: {
            gte: "now-30d/d",
            boost: 1.2,
          },
        },
      },
      // Boost high-rated listings
      {
        range: {
          avg_rating: {
            gte: 4.5,
            boost: 1.3,
          },
        },
      },
    ];
  }

  private buildAggregations(params: SearchParams): any {
    return {
      categories: {
        terms: {
          field: "category",
          size: 20,
        },
      },
      price_ranges: {
        range: {
          field: "price",
          ranges: [
            { to: 50, key: "budget" },
            { from: 50, to: 150, key: "mid" },
            { from: 150, to: 300, key: "premium" },
            { from: 300, key: "luxury" },
          ],
        },
      },
      rating_buckets: {
        histogram: {
          field: "avg_rating",
          interval: 0.5,
          min_doc_count: 1,
        },
      },
      verified_count: {
        filter: {
          term: { verified: true },
        },
      },
    };
  }
}

Bulk Indexing with Logstash

Configure Logstash pipeline for bulk data sync:

# logstash.conf
input {
  jdbc {
    jdbc_driver_library => "/path/to/postgresql-driver.jar"
    jdbc_driver_class => "org.postgresql.Driver"
    jdbc_connection_string => "jdbc:postgresql://localhost:5432/marketplace"
    jdbc_user => "dbuser"
    jdbc_password => "dbpass"
    statement => "
      SELECT
        l.id,
        l.title,
        l.description,
        l.category,
        l.price,
        l.status,
        l.created_at,
        l.latitude,
        l.longitude,
        p.id as provider_id,
        p.name as provider_name,
        p.verified as provider_verified,
        AVG(r.rating) as avg_rating,
        COUNT(r.id) as review_count,
        COUNT(b.id) as booking_count
      FROM listings l
      LEFT JOIN providers p ON l.provider_id = p.id
      LEFT JOIN reviews r ON l.id = r.listing_id
      LEFT JOIN bookings b ON l.id = b.listing_id
      WHERE l.updated_at > :sql_last_value
      GROUP BY l.id, p.id, p.name, p.verified
      ORDER BY l.updated_at ASC
    "
    schedule => "*/5 * * * *" # Every 5 minutes
    use_column_value => true
    tracking_column => "updated_at"
    tracking_column_type => "timestamp"
  }
}

filter {
  mutate {
    add_field => {
      "[location][lat]" => "%{latitude}"
      "[location][lon]" => "%{longitude}"
    }
    remove_field => ["latitude", "longitude"]
  }

  mutate {
    add_field => {
      "[provider][id]" => "%{provider_id}"
      "[provider][name]" => "%{provider_name}"
      "[provider][verified]" => "%{provider_verified}"
    }
    remove_field => ["provider_id", "provider_name", "provider_verified"]
  }
}

output {
  elasticsearch {
    hosts => ["localhost:9200"]
    index => "listings"
    document_id => "%{id}"
    action => "index"
  }
}

Algolia Implementation

Index Configuration

Set up Algolia index with optimal settings:

import algoliasearch from "algoliasearch";

const algoliaClient = algoliasearch(
  process.env.ALGOLIA_APP_ID!,
  process.env.ALGOLIA_ADMIN_KEY!,
);

const index = algoliaClient.initIndex("listings");

// Configure index settings
await index.setSettings({
  // Searchable attributes with ranking
  searchableAttributes: ["title", "unordered(description)", "tags", "category"],

  // Attributes for faceting
  attributesForFaceting: [
    "filterOnly(status)",
    "searchable(category)",
    "searchable(tags)",
    "city",
    "verified",
    "avg_rating",
  ],

  // Custom ranking (tie-breaking)
  customRanking: [
    "desc(featured)",
    "desc(booking_count)",
    "desc(avg_rating)",
    "desc(verified)",
    "desc(created_at)",
  ],

  // Ranking formula
  ranking: [
    "typo",
    "geo",
    "words",
    "filters",
    "proximity",
    "attribute",
    "exact",
    "custom",
  ],

  // Typo tolerance
  typoTolerance: true,
  minWordSizefor1Typo: 4,
  minWordSizefor2Typos: 8,

  // Geo search
  attributesForFaceting: ["filterOnly(location)"],

  // Highlighting
  attributesToHighlight: ["title", "description"],
  highlightPreTag: "<mark>",
  highlightPostTag: "</mark>",

  // Pagination
  hitsPerPage: 20,
  paginationLimitedTo: 1000,
});

Batch Indexing

Sync PostgreSQL data to Algolia in batches:

class AlgoliaIndexer {
  private batchSize = 1000;
  private index = algoliaClient.initIndex("listings");

  async indexAllListings(): Promise<void> {
    let offset = 0;
    let hasMore = true;

    while (hasMore) {
      const listings = await db.listings.findMany({
        take: this.batchSize,
        skip: offset,
        where: { status: "active" },
        include: {
          provider: true,
          reviews: { select: { rating: true } },
        },
      });

      if (listings.length === 0) {
        hasMore = false;
        continue;
      }

      const objects = listings.map((listing) => ({
        objectID: listing.id,
        title: listing.title,
        description: listing.description,
        tags: listing.tags || [],
        category: listing.category,
        price: listing.price,
        _geoloc: {
          lat: listing.latitude,
          lng: listing.longitude,
        },
        city: listing.city,
        state: listing.state,
        avg_rating: this.calculateAvgRating(listing.reviews),
        review_count: listing.reviews.length,
        booking_count: listing.bookingCount,
        verified: listing.provider.verified,
        featured: listing.featured,
        created_at: listing.createdAt.getTime() / 1000,
        status: listing.status,
        image_url: listing.images[0]?.url,
      }));

      await this.index.saveObjects(objects);

      offset += this.batchSize;
      console.log(`Indexed ${offset} documents to Algolia`);
    }
  }

  async partialUpdateObject(
    listingId: string,
    updates: Partial<Record<string, any>>,
  ): Promise<void> {
    await this.index.partialUpdateObject({
      objectID: listingId,
      ...updates,
    });
  }
}

InstantSearch Integration

Build reactive search UI with Algolia InstantSearch:

import {
  InstantSearch,
  SearchBox,
  Hits,
  RefinementList,
  Configure,
} from "react-instantsearch-dom";
import algoliasearch from "algoliasearch/lite";

const searchClient = algoliasearch(
  process.env.NEXT_PUBLIC_ALGOLIA_APP_ID!,
  process.env.NEXT_PUBLIC_ALGOLIA_SEARCH_KEY!,
);

export function SearchInterface() {
  return (
    <InstantSearch searchClient={searchClient} indexName="listings">
      <Configure
        hitsPerPage={20}
        attributesToSnippet={["description:50"]}
        snippetEllipsisText="..."
      />

      <div className="search-container">
        <SearchBox placeholder="Search listings..." autoFocus />

        <div className="search-body">
          <aside className="filters">
            <RefinementList attribute="category" limit={10} showMore />
            <RefinementList attribute="tags" limit={10} showMore />
            <RefinementList attribute="city" limit={10} searchable />
          </aside>

          <main className="results">
            <Hits hitComponent={ListingHit} />
          </main>
        </div>
      </div>
    </InstantSearch>
  );
}

function ListingHit({ hit }: { hit: any }) {
  return (
    <article className="listing-card">
      <img src={hit.image_url} alt={hit.title} />
      <div className="content">
        <h3>
          <Highlight attribute="title" hit={hit} />
        </h3>
        <p>
          <Snippet attribute="description" hit={hit} />
        </p>
        <div className="meta">
          <span>${hit.price}</span>
          <span>★ {hit.avg_rating.toFixed(1)}</span>
          {hit.verified && <span className="badge">Verified</span>}
        </div>
      </div>
    </article>
  );
}

Performance Benchmarks

Comparative Testing Results

Based on 100K documents, 50 concurrent users:

MetricPostgreSQLTypesenseElasticsearchAlgolia
Average Latency250ms35ms60ms15ms
P95 Latency500ms50ms120ms25ms
P99 Latency800ms75ms200ms40ms
Indexing Speed500/sec5000/sec3000/sec10000/sec
Memory Usage2GB4GB8GBN/A (managed)
Setup Time2 hours8 hours24 hours4 hours
Monthly Cost$0$50$200$500

Migration Paths

PostgreSQL → Typesense Migration

Step-by-step migration with zero downtime:

class SearchMigration {
  async migrateToTypesense(): Promise<void> {
    // Step 1: Set up Typesense collection
    await this.setupTypesenseCollection();

    // Step 2: Backfill all existing data
    await this.backfillData();

    // Step 3: Enable dual-write mode
    await this.enableDualWrite();

    // Step 4: Monitor and compare results
    await this.monitorBothSystems(7); // 7 days

    // Step 5: Gradually shift traffic
    await this.shiftTraffic([10, 25, 50, 75, 100]);

    // Step 6: Deprecate PostgreSQL search
    await this.removePostgreSQLSearch();
  }

  private async enableDualWrite(): Promise<void> {
    // Modify your application to write to both systems
    const originalCreateListing = db.listings.create;

    db.listings.create = async (data: any) => {
      const listing = await originalCreateListing(data);

      // Asynchronously index to Typesense
      this.typesenseIndexer.indexDocument(listing.id).catch((err) => {
        logger.error("Typesense indexing failed", {
          listingId: listing.id,
          error: err,
        });
      });

      return listing;
    };
  }

  private async shiftTraffic(percentages: number[]): Promise<void> {
    for (const pct of percentages) {
      console.log(`Shifting ${pct}% traffic to Typesense`);

      // Update feature flag or load balancer
      await featureFlags.set("typesense_traffic_percentage", pct);

      // Wait 24 hours to monitor
      await this.sleep(86400000);

      // Check error rates
      const errorRate = await this.getErrorRate("typesense");
      if (errorRate > 0.01) {
        throw new Error(`Error rate too high: ${errorRate}`);
      }
    }
  }
}

Implementation Checklist

PostgreSQL Full-Text (MVP Stage)

  • Create GIN index on search fields
  • Add generated tsvector column
  • Implement search function with ranking
  • Add filters for category, price, location
  • Test performance with 100K records
  • Set up query monitoring

Typesense (Growth Stage)

  • Deploy Typesense server (Docker or cloud)
  • Define collection schema
  • Backfill initial data from PostgreSQL
  • Set up real-time sync (queue-based)
  • Implement search API wrapper
  • Configure facets and filters
  • Test typo tolerance and synonyms
  • Monitor search analytics

Elasticsearch (Scale Stage)

  • Set up 3-node cluster
  • Design index mapping with analyzers
  • Configure Logstash for bulk sync
  • Implement advanced query DSL
  • Set up aggregations for facets
  • Configure geo search
  • Optimize shard allocation
  • Set up monitoring (Kibana)

Algolia (Managed Solution)

  • Create Algolia application
  • Configure index settings
  • Batch upload initial data
  • Implement InstantSearch UI
  • Set up webhooks for real-time sync
  • Configure custom ranking
  • Test analytics dashboard
  • Optimize monthly quota usage

Next Steps

  1. Assess Current Scale: Measure document count, QPS, and latency requirements
  2. Choose Technology: Use decision matrix to select the right solution
  3. Plan Migration: If migrating, follow gradual rollout strategy
  4. Implement Monitoring: Track search performance, usage, and conversion
  5. Optimize Iteratively: Use analytics to refine ranking and relevance

How much should your build actually cost?

Get a personalized investment estimate based on your platform type, scope, and timeline.

Open the Investment Calculator
#search
#elasticsearch
#typesense
#algolia
#performance
#postgresql
#full-text
Found this helpful? Share it
Share:

About the Author

Chris Mask

Chris Mask

Founder & CEO

Serial entrepreneur, marketplace architect, and AI-assisted development pioneer with 7+ years building two-sided platforms. Founded Directorism after launching and exiting two successful marketplace businesses. Has personally architected and consulted on 200+ marketplace and directory projects. Recognized authority on cold-start problems, platform economics, marketplace SEO, and leveraging AI tools for rapid development. Early adopter of AI-powered coding workflows, integrating Claude, Cursor, and agentic development patterns into production systems.