Full-Text Search: Why LIKE and tsvector Aren't Enough
Every application eventually needs search. First WHERE name ILIKE '%query%' works — on a thousand records. Then PostgreSQL tsvector handles a million. But when you need instant typeahead, typo tolerance, faceted filtering, synonyms, and relevance ranking — PostgreSQL falls short.
Three engines dominate in 2026: Elasticsearch — the veteran with a massive ecosystem, Meilisearch — minimalist and fast out of the box, Typesense — positioned as "self-hosted Algolia." Each solves full-text search, but the approach, complexity, and performance differ radically.
I've worked with Elasticsearch on two e-commerce projects (product catalog, 2M+ documents), connected Meilisearch to a Rails portfolio for article search, and tested Typesense on an HR system for resume search. Here's an honest comparison.
Architecture and Philosophy
Elasticsearch — The Swiss Army Knife
Elasticsearch is built on Apache Lucene. It's not just a search engine — it's a distributed system for storage, search, and analytics. Clusters, shards, replicas, aggregations, ingest pipelines — all built in.
The upside: scales horizontally to petabytes. The downside: a minimum production cluster is 3 nodes, each wanting 4-8 GB RAM. For searching 10K documents, it's like hiring a dump truck to move a backpack.
Meilisearch — Simplicity First
Meilisearch is a single Rust binary. Download, run, it works. No clusters, no shards, no Lucene configuration. One process, one data file.
curl -L https://install.meilisearch.com | sh
./meilisearch --master-key="your-secret-key"
# Ready. Listening on :7700
Philosophy: 95% of applications don't need petabytes and 50-node clusters. They need fast search with typo tolerance that works out of the box.
Typesense — Balance Between Simplicity and Power
Typesense is written in C++, optimized for speed. Supports clustering (Raft consensus) but can run as a single process. Positioned as a self-hosted Algolia alternative.
Architecture Comparison
| Aspect | Elasticsearch | Meilisearch | Typesense |
|---|---|---|---|
| Language | Java (Lucene) | Rust | C++ |
| Clustering | Yes (built-in) | No (single node) | Yes (Raft) |
| Minimum RAM | 4 GB | 256 MB | 256 MB |
| Storage | Disk (Lucene segments) | Disk (LMDB) | Memory + disk |
| License | SSPL / Elastic License | MIT | GPL-3.0 |
| Cloud service | Elastic Cloud | Meilisearch Cloud | Typesense Cloud |
Indexing Data
Elasticsearch: Mappings and Analyzers
curl -X PUT "localhost:9200/products" -H 'Content-Type: application/json' -d'
{
"settings": {
"number_of_shards": 1,
"analysis": {
"analyzer": {
"product_analyzer": {
"type": "custom",
"tokenizer": "standard",
"filter": ["lowercase", "snowball_en", "synonym_filter"]
}
},
"filter": {
"snowball_en": { "type": "snowball", "language": "English" },
"synonym_filter": {
"type": "synonym",
"synonyms": ["phone, smartphone, mobile", "laptop, notebook"]
}
}
}
},
"mappings": {
"properties": {
"name": {
"type": "text",
"analyzer": "product_analyzer",
"fields": {
"keyword": { "type": "keyword" },
"suggest": { "type": "search_as_you_type" }
}
},
"description": { "type": "text", "analyzer": "product_analyzer" },
"category": { "type": "keyword" },
"brand": { "type": "keyword" },
"price": { "type": "float" },
"in_stock": { "type": "boolean" },
"rating": { "type": "float" }
}
}
}'
Elasticsearch requires explicit mappings for production. Without them, dynamic mapping may create suboptimal types. Configuring analyzers is a science of its own: tokenizers, filters, stemming per language.
Meilisearch: Zero-Config Indexing
curl -X POST "localhost:7700/indexes/products/documents" \
-H "Authorization: Bearer your-master-key" \
-H "Content-Type: application/json" \
-d '[
{"id": 1, "name": "iPhone 16 Pro Max", "category": "smartphones", "brand": "Apple", "price": 1199.99, "in_stock": true, "rating": 4.8},
{"id": 2, "name": "Samsung Galaxy S26 Ultra", "category": "smartphones", "brand": "Samsung", "price": 1099.99, "in_stock": true, "rating": 4.7}
]'
No mapping needed. Meilisearch auto-detects field types and configures search. For 90% of cases, this is sufficient.
curl -X PATCH "localhost:7700/indexes/products/settings" \
-H "Authorization: Bearer your-master-key" \
-H "Content-Type: application/json" \
-d '{
"searchableAttributes": ["name", "description", "brand"],
"filterableAttributes": ["category", "brand", "price", "in_stock"],
"sortableAttributes": ["price", "rating"],
"typoTolerance": { "enabled": true }
}'
Typesense: Typed Schema
curl -X POST "localhost:8108/collections" \
-H "X-TYPESENSE-API-KEY: your-api-key" \
-H "Content-Type: application/json" \
-d '{
"name": "products",
"fields": [
{"name": "name", "type": "string"},
{"name": "description", "type": "string"},
{"name": "category", "type": "string", "facet": true},
{"name": "brand", "type": "string", "facet": true},
{"name": "price", "type": "float"},
{"name": "in_stock", "type": "bool", "facet": true},
{"name": "rating", "type": "float"}
],
"default_sorting_field": "rating"
}'
Typesense requires a schema, but it's simpler than Elasticsearch mappings. No analyzers — Typesense automatically handles tokenization and typos for 30+ languages.
Search: Syntax and Capabilities
Elasticsearch: Powerful Query DSL
{
"query": {
"bool": {
"must": [{
"multi_match": {
"query": "smartphone apple",
"fields": ["name^3", "description", "brand^2"],
"fuzziness": "AUTO"
}
}],
"filter": [
{ "term": { "in_stock": true } },
{ "range": { "price": { "gte": 500, "lte": 2000 } } }
]
}
},
"aggs": {
"categories": { "terms": { "field": "category", "size": 10 } },
"brands": { "terms": { "field": "brand", "size": 10 } },
"price_ranges": {
"range": {
"field": "price",
"ranges": [{"to": 500}, {"from": 500, "to": 1000}, {"from": 1000}]
}
}
},
"highlight": { "fields": { "name": {}, "description": {} } },
"sort": [{ "_score": "desc" }, { "rating": "desc" }],
"size": 20
}
The most powerful of the three. Bool queries, nested queries, functionscore, scriptscore, aggregations at analytics-database level. But also the most verbose — a simple search needs 30+ lines of JSON.
Meilisearch: Minimalist API
{
"q": "smartphone apple",
"filter": "in_stock = true AND price >= 500 AND price <= 2000",
"facets": ["category", "brand"],
"sort": ["rating:desc"],
"limit": 20,
"attributesToHighlight": ["name", "description"]
}
Response includes processingTimeMs: 2 — two milliseconds. Typos handled automatically: "smarphone" finds "smartphone." Faceted filtering built in.
Typesense: Typed Search
q=smartphone apple
query_by=name,description,brand
filter_by=in_stock:true && price:=[500..2000]
facet_by=category,brand
sort_by=_text_match:desc,rating:desc
per_page=20
Typesense uses GET parameters (POST also works). Filter syntax price:=[500..2000] is more compact than Elasticsearch but less flexible.
Typo Tolerance
| Query | Elasticsearch | Meilisearch | Typesense |
|---|---|---|---|
| "iphone" (exact) | Yes | Yes | Yes |
| "iphon" (missing letter) | Yes (fuzziness:AUTO) | Yes | Yes |
| "iphonee" (extra letter) | Yes | Yes | Yes |
| "iphnoe" (transposition) | No* | Yes | Yes |
| "smarphone" (typo) | Partial | Yes | Yes |
Meilisearch and Typesense are significantly better at typos out of the box. Elasticsearch requires tuning fuzziness, phonetic analysis, and character filters.
Integration with Rails
Elasticsearch: searchkick gem
gem 'searchkick'
class Product < ApplicationRecord
searchkick language: "english",
word_start: [:name],
suggest: [:name],
callbacks: :async
def search_data
{
name: name,
description: description,
category: category.name,
brand: brand.name,
price: price.to_f,
in_stock: in_stock?,
rating: rating.to_f,
}
end
end
results = Product.search(
"smartphone apple",
where: { in_stock: true, price: { gte: 500, lte: 2000 } },
aggs: [:category, :brand],
order: { rating: :desc },
page: 1, per_page: 20,
highlight: true,
)
Meilisearch: meilisearch-rails gem
gem 'meilisearch-rails'
class Product < ApplicationRecord
include MeiliSearch::Rails
meilisearch do
attribute :name, :description, :category_name, :brand_name, :price, :rating, :in_stock
searchable_attributes [:name, :description, :brand_name]
filterable_attributes [:category_name, :brand_name, :price, :in_stock]
sortable_attributes [:price, :rating]
end
end
results = Product.search(
"smartphone apple",
filter: "in_stock = true AND price >= 500",
sort: ["rating:desc"],
facets: ["category_name", "brand_name"],
)
Typesense: manual service
gem 'typesense'
class TypesenseSearch
def self.search_products(query, filters: {}, page: 1, per_page: 20)
TYPESENSE_CLIENT
.collections['products']
.documents
.search({
q: query,
query_by: 'name,description,brand',
filter_by: build_filter(filters),
facet_by: 'category,brand',
sort_by: '_text_match:desc,rating:desc',
per_page: per_page, page: page,
})
end
end
Typesense has no Rails integration at the searchkick level. You write a service manually — full control over queries.
Performance Benchmarks
Tests: 1M documents, 4 cores, 16 GB RAM, SSD, single-node Docker, 100 concurrent clients.
Indexing Speed
| Operation | Elasticsearch | Meilisearch | Typesense |
|---|---|---|---|
| Index 1M documents | 45s | 28s | 22s |
| Index 1 document | 12ms | 8ms | 3ms |
| Update 1 document | 15ms | 10ms | 4ms |
Search Latency (p50 / p99)
| Query | Elasticsearch | Meilisearch | Typesense |
|---|---|---|---|
| Simple (1 word) | 5ms / 25ms | 2ms / 8ms | 1ms / 5ms |
| Phrase (3 words) | 12ms / 45ms | 3ms / 12ms | 2ms / 8ms |
| Search + filter + facets | 18ms / 65ms | 5ms / 18ms | 3ms / 12ms |
| Search with typo | 15ms / 55ms | 3ms / 10ms | 2ms / 7ms |
| Typeahead (prefix) | 8ms / 30ms | 1ms / 5ms | 1ms / 4ms |
Resource Usage
| Metric | Elasticsearch | Meilisearch | Typesense |
|---|---|---|---|
| RAM (1M docs) | 2.8 GB | 450 MB | 800 MB |
| Disk (1M docs) | 1.2 GB | 380 MB | 520 MB |
| CPU (100 qps) | 25% | 8% | 6% |
Typesense and Meilisearch are 3-10x faster than Elasticsearch on typical search queries. Elasticsearch compensates with powerful aggregations and Query DSL flexibility.
When to Choose What
Elasticsearch
- More than 10M documents and need horizontal scaling
- Complex aggregations: histograms, pipeline aggs, statistics
- Already have ELK stack (logs + metrics + APM)
- Need nested documents, parent-child relationships
- Team has Elasticsearch expertise
- Budget for infrastructure: minimum 3 nodes × 4 GB RAM
Meilisearch
- Product catalog, articles, documentation — up to 10M documents
- Need results in 2 days, not 2 weeks
- Limited budget (VPS with 1-2 GB RAM)
- Typo tolerance is critical (e-commerce, user-facing search)
- Team without Elasticsearch expertise
- MVPs and startups: minimum config, maximum results
Typesense
- Need self-hosted Algolia: instant typeahead, facets, typos
- High availability: built-in Raft clustering (3 nodes)
- Geo search with distance sorting
- Budget between Meilisearch (minimum) and Elasticsearch (maximum)
- Need speed: Typesense consistently wins benchmarks
None of the Three
- Under 100K records — PostgreSQL
tsvector+pg_trgmis enough - Only exact field matching — regular
WHERE+ indexes - Log search — use ClickHouse or Loki instead
- Real-time analytics — ClickHouse, not Elasticsearch
Final Checklist
Choosing an Engine
- [ ] Determine volume: < 100K → PostgreSQL, < 10M → Meilisearch/Typesense, > 10M → Elasticsearch
- [ ] Determine requirements: typos, facets, geo, aggregations, multilingual
- [ ] Assess budget: Elasticsearch = 12+ GB RAM, Meilisearch/Typesense = 1-2 GB
- [ ] Assess team expertise: Elasticsearch requires configuration experience
Integration
- [ ] Choose gem: searchkick (ES), meilisearch-rails (Meili), typesense-ruby (Typesense)
- [ ] Configure async indexing: don't block HTTP requests on index updates
- [ ] Configure searchable attributes: don't index unnecessary fields
- [ ] Configure filterable attributes: only fields used for filtering
Production
- [ ] Backups: snapshots (ES), data dump (Meili), export (Typesense)
- [ ] Monitoring: query latency, indexing lag, memory usage
- [ ] Rate limiting: protect against search abuse
- [ ] Relevance testing: verify search returns expected results
- [ ] Fallback: when search engine is down — fallback to PostgreSQL ILIKE
Comments (0)