<aside> 📘
Agument Code Series:
How to build your Agent: 11 prompting techniques for better AI agents - Augment Code
📒 Integrate BM25:
🤖 Combining Embeddings with BM25 for Chunk Indexing
PostgreSQL BM25 Full-Text Search: Speed Up Performance with These Tips
📒 ANN
🤖 Implementing ANN-Based Semantic Search with Go and PostgreSQL
https://github.com/pgvector/pgvector
📒 RAG Related:
https://blog.laisky.com/p/llm-rag/
Contextual Retrieval in AI Systems \ Anthropic
</aside>

You might have come across discussions or blog posts suggesting that PostgreSQL's built-in full-text search (FTS) struggles with performance compared to dedicated search engines or specialized extensions. A notable recent example comes from Neon's blog post, "Performance Benchmark: pg_search on Neon" (link).
In their benchmark, Neon compared query performance on their database platform with their pg_search extension (based on Rust's Tantivy library via pgrx) against the Postgres built-in fulltext search setting with tsvector and GIN index. They commendably stated they optimized this standard setup by adding GIN indexes where appropriate (benchmark code available here).
However, while adding GIN indexes is a necessary first step, their results showing significantly slower performance for the "standard" setup suggest crucial additional optimization steps for PostgreSQL FTS were likely missed. The conclusion that standard FTS is inherently much slower than pg_search might be based on an unintentionally handicapped baseline.
Let's dive into how to correctly set up and use standard PostgreSQL FTS for optimal performance, addressing the specific configuration flaws likely present in the baseline used in the Neon benchmark, and demonstrating the true speed of built-in FTS. We'll show concrete numbers demonstrating a ~50x performance increase just by applying these standard optimizations to the baseline configuration.
The analysis used a table structure similar to this with 10 million log entries:
CREATE TABLE benchmark_logs (
id SERIAL PRIMARY KEY,
message TEXT,
country VARCHAR(255),
severity INTEGER,
timestamp TIMESTAMP,
metadata JSONB
);
They tested various query types. Many involved searching the message text field, using queries structured like this in their "standard Postgres" examples:
-- Create index on to_tsvector('english', message)
CREATE INDEX message_gin ON benchmark_logs USING gin (to_tsvector('english', message));
-- Example problematic query structure (likely used in Neon's baseline)
SELECT country, COUNT(*)
FROM benchmark_logs
WHERE to_tsvector('english', message) @@ to_tsquery('english', 'research')
GROUP BY country
ORDER BY country;
Even with a GIN index present on message, this query structure and the likely default GIN index settings are where the performance issues for the standard FTS baseline begin.
To demonstrate the impact of proper optimization on standard FTS, we used the following setup:
Instance: AWS EC2 i7ie.xlarge with local NVMe SSD (minimizing I/O impact).
CPU: 4 vCPUs.
PostgreSQL Configuration: PostgreSQL 16 via Docker, configured for the hardware:
docker run -d \
--name my-postgres \
--network=host \
-v /mnt/localssd/pgdata:/var/lib/postgresql/data \
-e POSTGRES_PASSWORD=mysecretpassword \
postgres:latest \
-c shared_buffers=8GB \
-c maintenance_work_mem=8GB \
-c max_parallel_workers=4 \
-c max_worker_processes=4
Parallelism Note: This setup provides max_parallel_workers_per_gather = 2. The Neon post mentioned their test environment used 8 parallel workers due to larger instances, implying potentially higher parallelism (max_parallel_workers_per_gather up to 8) than our setup. Our optimized results for standard FTS were achieved with less query parallelism.