| Engineering at Cerve | Published December 11, 2025 |
Abstract
When building Cerve’s ONDC marketplace platform, we faced a critical architectural decision: how to implement fast, intelligent product search across lacs of grocery items. While the industry standard points toward Elasticsearch, we took a different path and the sole reason for this was to save cost associated with ElasticSearch. So we dug deeper in leveraging PostgreSQL’s advanced search capabilities with a reactive, multi-strategy search architecture. This post details our journey, the trade-offs we made, and the surprising performance outcomes that validated our approach.
TL;DR: We built a six-strategy intelligent search system using PostgreSQL, Spring WebFlux, R2DBC, and Kotlin Coroutines that delivers sub-second response times with 99th percentile latencies under 200ms, while reducing operational complexity and infrastructure costs by eliminating the need for a separate search service.
Table of Contents
- The Challenge
- The Elasticsearch Temptation
- Our Solution: PostgreSQL-Powered Multi-Strategy Search
- Deep Dive: Architecture & Implementation
- Performance Benchmarks
- What We Gained
- What We Sacrificed
- Lessons Learned
- When Should You Choose This Approach?
The Challenge
Cerve’s ONDC marketplace platform serves small grocery store owners—shopkeepers who run neighborhood kirana stores, local supermarkets, and mom-and-pop shops. These are the backbone of India’s retail economy, but they face a significant technological barrier when going digital.
The Seller Onboarding Problem
Our target customers are small store owners who:
- Lack technical expertise: Most are not tech-savvy and have limited formal education
- Manage large inventories: A typical store has 1,000-5,000 unique grocery items
- Have limited time: Running a store is demanding; they can’t spend days on digital onboarding
- Work in vernacular environments: Often think in regional languages but type in English
The nightmare scenario: Asking these sellers to manually create 1,000+ product listings—entering product names, brands, descriptions, prices, images, nutritional info, FSSAI details, and HSN codes for each item. This is simply not viable.
Our Solution Approach
We built a comprehensive master catalog of standardized grocery products. Instead of manual data entry, sellers can:
- Search for products in our master catalog (e.g., “tata salt”)
- Select the matching product from search results
- Add their selling price and stock quantity
- Go live for buyers immediately
This reduces onboarding from days to hours. But this approach created a critical technical requirement: the search must be extremely forgiving and fast.
Why Search is Mission-Critical
Because our sellers are not tech-savvy:
Problem 1: Spelling Mistakes are Common
- Seller types “colget” instead of “Colgate”
- Seller types “basmti rice” instead of “basmati rice”
- Seller types “amool butter” instead of “Amul butter”
If search fails on typos, sellers get stuck and abandon onboarding.
Problem 2: Zero Tolerance for Latency
- Sellers expect instant results (like Google)
- A slow search (3-5 seconds) feels broken to them
- They’re often on slow 2G/3G mobile connections
Problem 3: Partial Information
- Sellers might only remember the brand (“Tata”) or category (“rice”)
- They need autocomplete-like behavior for partial matches
Technical Requirements
This user experience translated into demanding technical requirements:
Functional Requirements:
- Extreme typo tolerance: Handle 1-2 character substitutions, insertions, deletions
- Flexible matching: Exact matches, partial matches, fuzzy matches, word-level matches
- Multi-field search: Search across product names, brands, manufacturer names, ingredients
- Advanced filtering: Filter by brands, categories, country of origin
- Dual search modes: Search products and variants separately
- Smart pagination: Efficient navigation across millions of records
Non-Functional Requirements:
- Sub-second latency: P95 response times under 200ms (feels instant on mobile)
- High throughput: Support 1000+ concurrent searches during onboarding campaigns
- Strong consistency: Newly added products must be searchable immediately
- Cost efficiency: Minimize infrastructure costs while scaling to thousands of sellers
- Operational simplicity: Small team, can’t manage complex distributed systems
The Scale
- Products: 300K+ unique grocery items across all categories
- Variants: 500K+ product variations (different sizes, flavors, packaging)
- Search Volume: 1K+ searches per minute during peak onboarding hours
- User Expectation: Google-like search experience despite technical limitations
The Elasticsearch Temptation
The conventional wisdom was clear: “Use Elasticsearch for search.” The arguments were compelling:
Why Elasticsearch Made Sense
1. Purpose-Built for Search
- Optimized inverted indices for full-text search
- Sophisticated relevance scoring algorithms (BM25, TF-IDF)
- Built-in support for fuzzy matching and typo tolerance
2. Proven at Scale
- Battle-tested by companies like Uber, Netflix, GitHub
- Horizontal scaling through sharding
- Distributed architecture for high availability
3. Rich Feature Set
- Aggregations and analytics out of the box
- Highlighting and suggestions
- Extensive language analysis support
Why We Didn’t Choose It
Despite these advantages, we identified several concerns:
1. Operational Complexity
Existing Stack: With Elasticsearch:
┌──────────────┐ ┌──────────────┐
│ Spring Boot │ │ Spring Boot │
│ Application │ │ Application │
└──────┬───────┘ └──────┬───────┘
│ │
│ ├─────────┐
▼ ▼ ▼
┌──────────────┐ ┌──────────┐ ┌──────────────┐
│ PostgreSQL │ │PostgreSQL│ │Elasticsearch │
└──────────────┘ └──────────┘ └──────────────┘
Simple to maintain Additional:
- Sync mechanism
- Index management
- Cluster monitoring
- Data consistency issues
2. Data Synchronization Overhead
- Need to maintain consistency between PostgreSQL (source of truth) and Elasticsearch
- Complex change data capture (CDC) pipeline
- Handling sync failures and recovery
- Eventual consistency issues in critical business flows
3. Infrastructure and Cost Constraints
This was the most decisive factor for us. As a bootstrapped startup, we were running our entire stack on three minimal cloud instances:
- Frontend: 1GB RAM instance
- Backend: 1GB RAM instance
- PostgreSQL: 1GB RAM instance
Adding Elasticsearch would mean:
- Another server: Minimum 2GB RAM for a single ES node (4GB+ recommended for production)
- Increased complexity: Managing another service with our small team
- Higher costs: 33%+ increase in infrastructure spend for a feature we weren’t sure we needed
The question became: “Can we achieve acceptable search performance with what we already have?” If yes, we’d save significant costs and operational overhead. If no, we could always migrate to Elasticsearch later.
4. The PostgreSQL Opportunity PostgreSQL 12+ introduced powerful search extensions:
pg_trgm: Trigram-based fuzzy matchingfuzzystrmatch: Levenshtein distance calculations- Full-text search with tsvector and configurable weighting
- Generated columns for automatic index maintenance
- GIN indices for high-performance text search
This led us to ask: “Can we achieve our requirements with PostgreSQL alone?”
Our Solution: PostgreSQL-Powered Multi-Strategy Search
We designed a multi-layered search system that combines PostgreSQL’s advanced features with reactive programming for optimal performance.
Core Architecture Principles
1. Multi-Strategy Search with Intelligent Fallback
Instead of a one-size-fits-all search approach, we implemented six distinct strategies executed in order of specificity. Each strategy targets different search scenarios:
| Strategy | Use Case | Example Query | SQL Technique |
|---|---|---|---|
| EXACT_FULLTEXT | User knows exact terms | “organic rice basmati” | plainto_tsquery with ts_rank |
| PREFIX_MATCH | Autocomplete, partial words | “organ ric” | to_tsquery with prefix operators |
| SIMPLE_TEXT | Substring matching | “ric” anywhere in text | ILIKE '%ric%' |
| TRIGRAM_SIMILARITY | Typos, misspellings | “orgnic ryce” | similarity() with threshold |
| LEVENSHTEIN_NAME | Name-specific fuzzy match | “oranic rice” | levenshtein() with distance limit |
| WORD_FUZZY | Word-level fuzzy matching | “organik” | Word-by-word Levenshtein |
2. Reactive Architecture for Performance
We built the search service using Spring WebFlux with R2DBC for non-blocking database access:
@Service
class SearchService(
private val searchRepository: SearchRepository
) {
suspend fun searchProducts(request: SearchRequest): SearchResult =
withTimeout(60.seconds) {
search(request, SearchMode.PRODUCTS)
}
}
Key architectural components:
- Spring WebFlux: Non-blocking HTTP layer
- R2DBC: Reactive database connectivity
- Kotlin Coroutines: Structured concurrency for readable async code
- Flow API: Backpressure-aware streaming
3. Unified Search Interface
A single repository interface handles both products and variants through dynamic SQL generation:
interface SearchRepository {
fun search(
strategy: SearchStrategy,
searchTerm: String,
searchMode: SearchMode, // PRODUCTS or VARIANTS
offset: Int,
limit: Int,
excludePids: List<Long>,
brands: List<String>? = null,
categories: List<String>? = null,
countries: List<String>? = null,
minSimilarity: Float = 0.3f,
maxDistance: Int = 2
): Flow<UnifiedSearchProjection>
}
This abstraction allows us to search across different tables (products vs. variants with joins) using the same search strategies.
4. Database Schema Optimization
Our PostgreSQL schema includes auto-generated search optimization columns:
CREATE TABLE product(
pid SERIAL NOT NULL,
name VARCHAR(255) NOT NULL,
brand VARCHAR(50) NOT NULL,
description TEXT NOT NULL,
manufacturer VARCHAR(255),
-- Auto-generated weighted full-text search vector
search_vector tsvector GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(name, '')), 'A') ||
setweight(to_tsvector('english', coalesce(brand, '')), 'A') ||
setweight(to_tsvector('english', coalesce(description, '')), 'B') ||
setweight(to_tsvector('english', coalesce(manufacturer, '')), 'B')
) STORED,
-- Auto-generated concatenated text for trigram/fuzzy matching
search_text TEXT GENERATED ALWAYS AS (
lower(trim(
coalesce(name, '') || ' ' ||
coalesce(brand, '') || ' ' ||
coalesce(manufacturer, '')
))
) STORED,
PRIMARY KEY (pid)
);
-- Specialized indices for different search strategies
CREATE INDEX idx_product_search
ON product USING GIN (search_vector);
CREATE INDEX idx_product_search_text_trgm
ON product USING GIN (search_text gin_trgm_ops);
CREATE INDEX idx_product_search_text_btree
ON product USING BTREE (search_text);
The GENERATED ALWAYS AS columns are automatically maintained by PostgreSQL, eliminating manual trigger logic and ensuring indices are always up-to-date.
Deep Dive: Architecture & Implementation
Execution Flow
Here’s how a search request flows through the system:
┌─────────────────────────────────────────────────────────────────┐
│ 1. Request Entry Point (ProductController) │
│ GET /product/search?keyword=organic+rice │
└───────────────────────────┬─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 2. Cache Check (Caffeine In-Memory Cache) │
│ Key: "search:organic rice:limit:20" │
│ Hit Rate: ~65% (10-minute TTL) │
└───────────────────────────┬─────────────────────────────────────┘
│ Cache Miss
▼
┌─────────────────────────────────────────────────────────────────┐
│ 3. Service Layer (SearchService) │
│ Decodes pagination context from pageToken │
│ Manages strategy execution and result aggregation │
└───────────────────────────┬─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 4. Sequential Strategy Execution │
│ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Strategy 1: EXACT_FULLTEXT │ │
│ │ SELECT *, ts_rank(search_vector, query) as score│ │
│ │ WHERE search_vector @@ plainto_tsquery(...) │ │
│ │ Results: 15 products │ │
│ └─────────────────────────────────────────────────┘ │
│ │ │
│ ▼ (need 5 more) │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Strategy 2: PREFIX_MATCH │ │
│ │ WHERE search_vector @@ to_tsquery('organ:*') │ │
│ │ Results: 5 products (deduplicated) │ │
│ └─────────────────────────────────────────────────┘ │
│ │ │
│ ▼ (limit reached, stop) │
└───────────────────────────┬─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 5. Result Aggregation & Response │
│ - Deduplicate by PID │
│ - Encode pagination token for next page │
│ - Cache results (first page only) │
│ - Track execution time │
└─────────────────────────────────────────────────────────────────┘
Strategy Execution Details
Let’s examine each strategy’s SQL implementation:
Strategy 1: EXACT_FULLTEXT
Best for: Exact term matching with relevance ranking
SELECT
p.pid, p.name,
ts_rank(p.search_vector, plainto_tsquery('english', :searchTerm)) as score
FROM product p
WHERE p.search_vector @@ plainto_tsquery('english', :searchTerm)
ORDER BY score DESC
LIMIT 20;
Performance: ~30-50ms for typical queries (uses GIN index)
Strategy 2: PREFIX_MATCH
Best for: Autocomplete and partial word matching
// Input: "organ ric" → Transform to: "organ:* & ric:*"
val processedSearchTerm = searchTerm.trim()
.split(' ')
.filter { it.isNotBlank() }
.joinToString(" & ") { "$it:*" }
SELECT
p.pid, p.name,
COALESCE(ts_rank(p.search_vector, to_tsquery('english', :searchTerm)), 0.0) as score
FROM product p
WHERE p.search_vector @@ to_tsquery('english', :searchTerm)
ORDER BY score DESC
LIMIT 20;
Performance: ~40-70ms (slightly slower due to prefix operator)
Strategy 3: SIMPLE_TEXT
Best for: Substring matching when FTS fails
SELECT
p.pid, p.name,
1.0 as score
FROM product p
WHERE p.search_text ILIKE :searchTerm -- '%organic rice%'
ORDER BY p.pid
LIMIT 20;
Performance: ~80-120ms (uses B-tree index for prefix, full scan for infix)
Strategy 4: TRIGRAM_SIMILARITY
Best for: Fuzzy matching with typos
SELECT
p.pid, p.name,
similarity(p.search_text, :searchTerm) as score
FROM product p
WHERE p.search_text % :searchTerm -- Trigram similarity operator
AND similarity(p.search_text, :searchTerm) >= 0.3
ORDER BY score DESC
LIMIT 20;
Performance: ~100-150ms (GIN trigram index helps significantly)
Example: “orgnic ryce” matches “organic rice” with ~0.7 similarity
Strategy 5: LEVENSHTEIN_NAME
Best for: Name-specific fuzzy matching
SELECT
p.pid, p.name,
(1.0 - (levenshtein(LOWER(:searchTerm), LOWER(p.name))::float /
GREATEST(LENGTH(:searchTerm), LENGTH(p.name), 1))) as score
FROM product p
WHERE levenshtein(LOWER(:searchTerm), LOWER(p.name)) <= 2
AND LENGTH(p.name) > 0
ORDER BY score DESC
LIMIT 20;
Performance: ~120-200ms (no index support, requires table scan with filter)
Example: “oranic rice” (distance=1) matches “organic rice”
Strategy 6: WORD_FUZZY
Best for: Maximum recall when all else fails
SELECT DISTINCT
p.pid, p.name,
(1.0 - (MIN(levenshtein(LOWER(:searchTerm), word))::float /
LENGTH(:searchTerm))) as score
FROM product p,
unnest(string_to_array(p.search_text, ' ')) as word
WHERE levenshtein(LOWER(:searchTerm), word) <= 2
AND LENGTH(word) > 2
GROUP BY p.pid, p.name
ORDER BY score DESC
LIMIT 20;
Performance: ~150-250ms (most expensive strategy, used as last resort)
Example: “basmti” matches products containing the word “basmati”
Intelligent Pagination with Stateless Tokens
We implemented cursor-based pagination that encodes the search context:
data class SearchContext(
val currentStrategyIndex: Int, // Which strategy we're on
val currentStrategyOffset: Int, // Offset within that strategy
val excludedPids: MutableSet<Int> // PIDs already returned
)
// Encode context into a compact string token
fun encodeSearchContext(context: SearchContext): String {
return "${context.currentStrategyIndex}:${context.currentStrategyOffset}:" +
"${context.excludedPids.joinToString(",")}"
}
// Example token: "2:40:12345,12346,12347,..."
// Means: Currently on strategy #2, offset 40, exclude these PIDs
This approach allows:
- Stateless pagination: No server-side session storage
- Consistent results: Same query always returns same ordering
- Deduplication: Prevents duplicate results across strategy boundaries
- Strategy continuation: Resume from where previous page left off
Caching Strategy
We use Caffeine for in-memory caching with specific policies:
private val searchCache: Cache<String, SearchResult> = Caffeine.newBuilder()
.maximumSize(500) // Limit memory footprint
.expireAfterWrite(Duration.ofMinutes(10)) // Data freshness
.removalListener { key, _, cause ->
logger.debug("Cache entry evicted: key={}, cause={}", key, cause)
}
.recordStats() // Monitor hit rate
.build()
Caching Decisions:
- Only cache first page of results (most common query)
- Include filters in cache key for correctness
- 10-minute TTL balances freshness with hit rate
- 500 entry limit (~50MB memory for typical result sizes)
Cache Key Structure:
fun cacheKey(): String {
val brandStr = brands?.joinToString("|") ?: ""
val categoryStr = categories?.joinToString("|") ?: ""
val countryStr = countries?.joinToString("|") ?: ""
return "search:$searchTerm:$brandStr:$categoryStr:$countryStr:$limit"
}
// Example: "search:organic rice:Organic India|Tata::India:20"
Dual Search Modes: Products vs. Variants
Our unified interface supports two distinct search modes:
enum class SearchMode {
PRODUCTS, // Search product table only
VARIANTS // Search variants joined with product
}
PRODUCTS mode: Search the product table
SELECT
p.pid, NULL as parent_id, p.name
FROM product p
WHERE p.search_vector @@ plainto_tsquery('english', :searchTerm)
VARIANTS mode: Search variants with product data
SELECT
v.pid, v.parent_id, m.name
FROM product_variant v
INNER JOIN product m ON v.parent_id = m.pid
WHERE m.search_vector @@ plainto_tsquery('english', :searchTerm)
This flexibility allows:
- Product discovery: Users find base products first
- Variant selection: Users drill down to specific sizes/variants
- Single codebase: Same search logic, different projections
What We Gained
1. Architectural Simplicity
Single Source of Truth: PostgreSQL is both our transactional database and search engine. No synchronization concerns.
Before (with ES): After (PostgreSQL only):
┌──────────────┐ ┌──────────────┐
│ Application │ │ Application │
└──────┬───────┘ └──────┬───────┘
│ │
├─────────┬────────── │
▼ ▼ │ ▼
┌──────────┐ ┌──────────┐ │ ┌──────────────┐
│PostgreSQL│ │Elastics- │ │ │ PostgreSQL │
│ (OLTP) │ │ earch │ │ │ (OLTP+Search)│
└────┬─────┘ └─────▲────┘ │ └──────────────┘
│ │ │
│ ┌──────────┴────┐ │ Complexity: O(1)
└─►│ CDC/Sync │◄┘
│ Pipeline │
└───────────────┘
Complexity: O(n²)
2. Immediate Consistency
Updates to products are immediately searchable:
@Transactional
fun updateProduct(productId: Long, updates: ProductUpdate) {
// Update the product
productRepository.save(product.apply {
productName = updates.name
brand = updates.brand
// search_vector automatically updated via GENERATED column
})
// No need to:
// - Send message to queue
// - Update Elasticsearch index
// - Handle sync failures
// - Deal with eventual consistency
// Searches immediately reflect changes!
}
3. Cost Efficiency
Monthly Infrastructure Costs (Production):
| Component | PostgreSQL Approach | Elasticsearch Approach |
|---|---|---|
| Database Server | $10 (RDS PostgreSQL) | $10 (RDS PostgreSQL) |
| Search Service | - | $30 (Elasticsearch 3-node) |
| Data Sync | - | $10 (Kafka/Debezium) |
| Total | $10 | $50 |
Savings: $40/month or 80% reduction in infrastructure costs.
4. Simplified Operations
Developer Experience:
- Single connection pool to manage
- One query language (SQL) for all data access
- Familiar PostgreSQL tooling and debugging
- No index management or shard rebalancing
- Standard database backup/restore procedures
On-call Burden:
- Fewer services to monitor
- Simpler incident response (single system to debug)
- No distributed system complexities (split-brain, quorum loss)
6. Reactive Performance
Kotlin Coroutines + R2DBC provide non-blocking I/O with readable code:
suspend fun searchProducts(request: SearchRequest): SearchResult {
// Non-blocking, but reads like synchronous code
val results = searchRepository.search(...)
.toList() // Collect Flow
val totalCount = if (request.includeCount) {
searchRepository.searchCount(...) // Parallel query possible
} else null
return SearchResult(results, totalCount, ...)
}
Traditional blocking JDBC would require thread-per-request model, limiting concurrency.
What We Sacrificed
Honesty compels us to acknowledge the limitations of our approach.
1. Horizontal Scalability Ceiling
Limitation: PostgreSQL vertical scaling has limits. Beyond a certain point, you need read replicas.
2. Advanced Relevance Tuning
Limitation: Elasticsearch’s relevance algorithms (BM25, learning-to-rank) are more sophisticated.
3. Real-Time Analytics
Limitation: Elasticsearch aggregations are faster for analytics queries.
Example: “Top 10 brands searched in last 24 hours with trend analysis”
4. Geospatial Search
Limitation: Elasticsearch geo queries are highly optimized.
Our Current Situation: We don’t need location-based product search (yet).
If we need it: PostgreSQL PostGIS extension provides geospatial capabilities, though not as performant as Elasticsearch at extreme scale.
5. Distributed Full-Text Features
What Elasticsearch provides:
- Multi-language analysis with extensive tokenizers
- Phonetic matching (sounds-like searches)
- Advanced highlighting with context
- Did-you-mean suggestions
What we lose:
- Phonetic search (“Colgate” → “Kolgate”)
- Rich highlighting (we have basic highlighting via SQL)
- Multi-language stemming (we support English only)
Lessons Learned
1. PostgreSQL is Seriously Underrated for Search
Industry Perception: “PostgreSQL is a relational database, not a search engine.”
Reality: With pg_trgm, fuzzystrmatch, and full-text search, PostgreSQL rivals dedicated search engines for many use cases.
Key Enabler: Generated columns and GIN indices make it production-ready without manual triggers.
2. Multi-Strategy Search > Single Algorithm
Our six-strategy approach outperforms any single search algorithm because:
- Different queries have different characteristics
- Users don’t know which strategy will work for their input
- Graceful degradation ensures we always return something useful
Data Point: 12% of successful searches required fallback strategies (strategies 3-6). Without them, those users would have seen zero results.
3. Reactive Programming is Worth the Learning Curve
Before (Blocking JDBC):
- Thread-per-request model
- 200 concurrent requests = 200 threads
- High memory consumption (1MB+ per thread stack)
- Context switching overhead
After (R2DBC + Coroutines):
- Event-loop model
- 1,000 concurrent requests = ~10-20 threads
- Low memory footprint
- Better CPU utilization
Result: 5x improvement in throughput without hardware changes.
4. Caching Layer is Critical
Cache hit rate of 60% reduces database load by nearly 2/3. Without caching:
- Database CPU would spike to 95%+
- Latencies would degrade under load
- Need more expensive database instance
Investment: 2 days to implement Caffeine caching Return: $20/month savings on database costs + better performance
When Should You Choose This Approach?
Use PostgreSQL-Based Search When:
✅ Data Volume: < 50M documents (single instance) or < 500M (with read replicas)
✅ Latency SLA: P95 latency 100-500ms is acceptable
✅ Query Patterns: Primarily keyword search with filters, not complex relevance tuning
✅ Consistency Requirements: Strong consistency is valuable (transactional workflows)
✅ Team Expertise: Team knows PostgreSQL deeply but lacks Elasticsearch experience
✅ Operational Constraints: Limited ops resources, prefer simplicity over absolute performance
✅ Cost Sensitivity: Infrastructure budget is constrained
✅ Use Case: Product catalogs, document search, user directories, content management
Use Elasticsearch When:
❌ Massive Scale: > 500M documents or need to shard across data centers
❌ Ultra-Low Latency: Need P95 latency < 50ms
❌ Advanced Features: Need learning-to-rank, advanced analytics, ML-powered search
❌ Multi-Tenant: Each tenant needs isolated search indices
❌ Log Analytics: Time-series data with rollover and retention policies
❌ Complex Relevance: Need sophisticated relevance tuning with A/B testing
❌ Polyglot Persistence: Already have multiple specialized data stores
The Hybrid Approach
Some teams use both:
- PostgreSQL for transactional queries and simple searches
- Elasticsearch for complex full-text search and analytics
When this makes sense:
- You have distinct use cases (OLTP vs. analytics)
- You have ops expertise to manage both
- The cost is justified by business value
Conclusion
Building Cerve’s product search with PostgreSQL was a calculated risk that paid off. We achieved:
- Sub-second search latency (P95: 187ms)
- 80% cost reduction vs. Elasticsearch approach
- Simplified architecture with single data store
- Immediate consistency for better UX
The key insight: PostgreSQL’s advanced search features + reactive architecture + intelligent multi-strategy search = production-ready search without dedicated search infrastructure.
This doesn’t mean Elasticsearch is wrong—it means the right choice depends on your specific constraints. For Cerve, PostgreSQL was the optimal solution.
What’s Next?
We’re exploring:
- Query performance optimizations: TimeScaleDB’s
pg_textsearchfor ElasitcSearch -like performance
About the Author: This post was written by the Cerve Engineering Team, with contributions from backend engineers working on the ONDC marketplace platform.
Want to discuss this approach? We’d love to hear your experiences with PostgreSQL vs. Elasticsearch trade-offs. Reach out to us at engineering@cerve.in.
| *Published on Cerve Engineering Blog | December 22, 2025* |