Reproducible latency benchmark of scout-postgres against Laravel Scout's
default database driver, on a single Postgres 18 service with hot cache.
The methodology, the artisan command, and the raw numbers are all here so that anyone can rerun the benchmark on their own hardware and corpus and verify (or contradict) the package's performance claims.
- Corpus: 500,150 rows in a
bookstable.- 500,000 generated by
BookFactory(fakersentence+paragraph). - 100 deterministic "Modern World History Encyclopedia" rows.
- 50 deterministic "A Comprehensive History of Modern Philosophical Thought" rows.
- Indexed columns + weights:
title:A,subtitle:B,author:B,summary:C(thesummarycolumn carries multi-paragraph text and is the dominant cost driver).
- 500,000 generated by
- Query set: seven representative shapes — common single token, two words with no literal collocation, rare phrase, short prefix, typo, no-match, and a long natural-language query.
- Execution: for each driver and each query the harness runs a 3-call
warm-up (discarded), then 30 measured calls; per-call wall time captured
via
hrtime(true). Reported numbers arep50andp95of the 30 measured samples, in milliseconds. - Hardware: local Fedora workstation, single Postgres 18.3 service, PHP 8.5 CLI, Laravel 13.0. Cache is hot — first 3 calls warm shared_buffers and the GIN indexes.
The harness lives in a sibling Laravel project — see
scout-postgres-benchmark.
The artisan command source is also vendored under src/ here for
reference.
Quick reproduction:
git clone https://github.com/jonaspauleta/scout-postgres-benchmark.git
cd scout-postgres-benchmark
composer install
cp .env.example .env && php artisan key:generate
# Configure DB_* in .env to point at a local Postgres 14+
php artisan migrate
php artisan bench:scout --seed=500000 --runs=30 --warmup=3Run on Postgres 18.3, PHP 8.5, Laravel 13, 500,150 rows, hot cache.
Run with the shipped defaults: query_strategy=adaptive,
prefix_fast_path=true, disable_jit=true, total_count=false,
trigram_function=similarity, trigram_threshold=0.3. Schema migrated with
the search_text cap (1000 chars).
| query | pgsql p50 (ms) |
database p50 (ms) |
hits (pg / db) | notes |
|---|---|---|---|---|
world |
4.0 | 2.4 | 20 / 20 | adaptive returns FTS-only on common token |
modern history |
5.4 | 2100.1 | 20 / 0 | FTS bridges token gaps; database seq-scans 500k |
philosophical exposition |
5.3 | 2.5 | 20 / 20 | total_count=false removes the window-aggregate |
phil |
4.4 | 2.4 | 20 / 20 | short-prefix fast path skips trigram entirely |
philosphy |
45.0 | 1988.7 | 0 / 0 | adaptive fallback to hybrid recovers via trigram |
qwxzqwxzqwxz |
8.0 | 2067.6 | 0 / 0 | GIN miss is instant; LIKE seq-scans 500k |
a comprehensive history of modern philosophical thought |
8.8 | 3.1 | 20 / 20 | FTS handles long queries |
The 10× corpus growth versus the previous 50k run shows where the engine holds latency bounded and where it doesn't:
- FTS-bound queries (
world,modern history,philosophical exposition,phil,long_query) all stay sub-10 ms. GIN scans are O(matches), not O(rows). - The adaptive trigram fallback (
philosphy) is the one path that scales linearly with the corpus — 13 ms at 50k, 45 ms at 500k. Still under any reasonable latency budget, but the dominant cost on typo queries. qwxzqwxzqwxzstays cheap because the trigram bitmap is empty — the GIN index returns no candidates regardless of corpus size.
Same configuration, smaller corpus, same hardware:
| query | pgsql p50 (ms) |
database p50 (ms) |
hits (pg / db) |
|---|---|---|---|
world |
4.0 | 2.3 | 20 / 20 |
modern history |
4.9 | 190.5 | 20 / 0 |
philosophical exposition |
4.6 | 2.8 | 20 / 20 |
phil |
4.0 | 2.7 | 20 / 20 |
philosphy |
13.1 | 186.3 | 0 / 0 |
qwxzqwxzqwxz |
7.9 | 191.5 | 0 / 0 |
| long natural query | 7.1 | 2.9 | 20 / 20 |
scout-postgreshas measurably better recall. Multi-token queries, prefix matches, and accent variants all return matches that thedatabasedriver'sLIKE %term%strategy misses entirely.- The adaptive strategy + short-prefix fast path are the dominant wins.
The fast path skips both
websearch_to_tsqueryand the trigram bitmap entirely on single short tokens, sophilruns in ~4 ms even on 500k rows. Multi-token FTS queries (e.g.modern history) stay sub-10 ms because adaptive returns the FTS hits without running the trigram pass. total_count=falseremoves a major p95 cost. ACOUNT(*) OVER()window aggregate forces materialisation of the full match set beforeLIMIT. With it off, latency scales with page size, not match-set size. Opt back in per-query when an exact total is required.trigram_thresholdis the dominant trigram cost knob. A 2× change in threshold can change p50 by 10–100× on the hybrid fallback path. The default0.3is chosen to be safe for typical mixed-length corpora; tune higher for long-text corpora, lower only for short titles where false negatives are the bigger concern.- The
databasedriver wins on small literal-substring queries becauseLIMIT 20short-circuits its sequential scan. It loses badly onno_match(full table seq scan) and on multi-token queries (zero recall).
- Benchmark uses faker-generated text. Real corpora have different trigram distributions; numbers will differ.
- Hot cache only. Cold-cache numbers (first query after a restart) are meaningfully higher and not measured here.
- Single-node, no replication, no concurrent load. Latency under load is not characterised.
- Postgres 18 with default settings (no shared_buffers tuning).
The harness is set up so anyone can rerun against their own corpus — that is the only number that ultimately matters.