Full-text search, Scout, and a well-indexed Postgres column outperform pgvector for 90% of “AI search” features developers are bolting on.
A team adds “AI-powered search” to their product page. Three weeks of work: an embeddings pipeline, a pgvector column, a chunking strategy, a reconciliation job to re-embed content on every edit, a monthly OpenAI bill that shows up in the next invoice cycle. Ships. Usage data two months later: the overwhelming majority of searches are two or three words — a product name, a customer’s last name, an order number, a support-ticket ID. SELECT * FROM products WHERE to_tsvector('english', name) @@ plainto_tsquery('english', ?), with a GIN index, would have returned the same results in under 5ms, for free, with zero moving parts and zero ongoing cost. The vector database wasn’t wrong. It was answering a question almost nobody was actually asking.
This isn’t an argument against semantic search — a previous post on this blog walked through building it properly, and it’s the right tool for genuinely semantic queries: “why won’t my payment go through” matching an article titled “Resolving Declined Card Errors,” zero shared keywords, real conceptual understanding required. That’s maybe 10% of what gets built under the banner of “AI search” in a typical Laravel app. The other 90% is a search box that would be fully solved by full-text search on an indexed column, dressed up as an AI feature because “vector search” sounds more impressive in a sprint demo than “we added a GIN index.” This post is the case for checking which situation you’re actually in before reaching for pgvector — and the honest cost comparison most “add AI search” tutorials skip entirely.
What Full-Text Search Actually Handles — More Than Most Developers Assume
Postgres and MySQL both ship real, mature full-text search built into the database — not a fallback, a legitimate search engine with relevance ranking, stemming, and stopword handling, sitting in infrastructure that’s already running.
// Laravel's built-in whereFullText — no packages required
$products = Product::whereFullText('name', 'wireless headphones')->get();
-- What that generates on Postgres, once the column has a full-text index
CREATE INDEX products_name_fulltext ON products USING GIN (to_tsvector('english', name));
This alone handles the majority of “search this table” features correctly: it stems (“running” matches “run”), it ranks by relevance rather than just filtering, and it ignores stopwords sensibly, all without a single API call, a single embedding generated, or a single dollar spent on inference. The gap most developers don’t realize exists: Postgres’s whereFullText doesn’t order by relevance automatically the way MySQL’s does — for that, Laravel Scout’s database driver is the actual upgrade worth reaching for, not a vector database.
composer require laravel/scout
// config/scout.php
'driver' => env('SCOUT_DRIVER', 'database'),
class Product extends Model
{
use Searchable;
public function toSearchableArray(): array
{
return ['name' => $this->name, 'description' => $this->description];
}
}
$products = Product::search('wireless headphones')->get();
// Scout's database engine handles relevance ordering correctly on both
// MySQL and Postgres — the exact gap whereFullText alone leaves on Postgres
Scout’s database engine — genuinely no external service, no Docker container, no API key — is worth trying before anything else, specifically because it’s the step most “add AI search” write-ups skip entirely on the way to recommending an embeddings pipeline. It’s not a stopgap for a “real” search engine. For a catalog of a few hundred thousand rows, it’s frequently the entire correct answer, permanently.
The Cost Comparison Nobody Runs Before Building
This is the part that gets skipped because it’s less interesting to write about than the embeddings pipeline itself. A real comparison, for a mid-sized product catalog:
Full-text search via Scout’s database driver:
- Infrastructure: none beyond the existing database
- Per-query cost: $0 — it’s a SQL query against an existing index
- Latency: single-digit milliseconds for a well-indexed column, even at hundreds of thousands of rows
- Operational surface: one GIN/full-text index to maintain, which the database already knows how to do
Semantic search via OpenAI embeddings + pgvector:
- Infrastructure:
pgvectorextension, HNSW index, a chunking pipeline, a queued indexing job, a reconciliation strategy for content edits - Per-query cost: one embedding API call per search, unless cached — real, recurring, scales with query volume, not free even at “cents per million tokens” pricing once query volume is meaningful
- Latency: an external API round trip added to every single search request, before the database query even runs
- Operational surface: an embeddings model dependency, a vector index to tune (HNSW parameters, recall/speed tradeoffs), a re-indexing pipeline that has to fire correctly on every content edit, and a new failure mode — the embeddings API being slow or down — that a full-text query never has
Neither list is a reason semantic search is “bad.” It’s a real, legitimate cost, appropriate to pay when the query pattern genuinely needs it — a support-docs search where users type problems in their own words rather than the exact terminology in the article titles. It’s an unnecessary cost, paid indefinitely, when the actual query pattern is “find the product named X” or “find the order placed by customer Y,” which is a full-text or even a plain indexed LIKE problem that never needed an embedding in the first place.
The Query Pattern Test — Actually Look at What Users Type
The single highest-leverage thing skipped before building either kind of search: looking at real query logs, or a reasonable proxy for them, before deciding which kind of search the feature actually needs.
// A cheap first pass, before building anything —
// log actual search queries for a week or two before choosing an approach
class LogSearchQuery
{
public function handle(SearchPerformed $event): void
{
SearchLog::create(['query' => $event->query, 'result_count' => $event->resultCount]);
}
}
-- After a couple weeks: what's the actual shape of what people type?
SELECT query, COUNT(*) as frequency,
LENGTH(query) - LENGTH(REPLACE(query, ' ', '')) + 1 as word_count
FROM search_logs
GROUP BY query
ORDER BY frequency DESC
LIMIT 50;
If the results skew short (one to three words) and skew toward exact terms — product names, SKUs, customer names, order IDs — that’s a full-text search problem, not a semantic one. Embeddings add cost and complexity to a query pattern that keyword matching already handles correctly, and — worth remembering from any real hybrid-search implementation — semantic search is specifically worse than keyword search at exactly this pattern: an embedding model has no particular reason to rank an exact SKU match above something merely similar in meaning.
If a meaningful fraction of queries are full natural-language questions or descriptions with little keyword overlap to the content that should match — “why is my invoice higher this month,” “something to keep drinks cold on a hike” — that’s the real signal for semantic search. This is a genuinely different query shape than “wireless headphones,” and it’s the 10% case a vector database earns its cost on.
The failure mode in both directions is real. Building semantic search for a catalog where 95% of queries are exact product names is paying an ongoing embeddings bill and added latency for no measurable improvement over what a GIN index already provided for free. Sticking with pure full-text search on a genuinely conceptual query pattern — a support-docs search where the actual value is bridging vocabulary gaps between how users describe a problem and how the docs describe the solution — means shipping a search box that quietly fails on exactly the queries that matter most, while looking like it works fine on the easy ones during a demo.
The Middle Ground Nobody Mentions: Scout’s Database Engine Now Supports Hybrid Search Directly
As of recent Laravel versions, Scout’s own database engine supports semantic and hybrid search on Postgres directly, when pgvector is available — combining full-text and vector matching without standing up a separate external search service.
// A nullable vector column alongside the existing full-text index —
// Scout's database engine can use both together
Schema::table('articles', function (Blueprint $table) {
$table->text('searchable_text')->nullable();
$table->vector('embedding', dimensions: 1536)->nullable();
});
class Article extends Model
{
use Searchable;
public function toSearchableArray(): array
{
return ['title' => $this->title, 'body' => $this->body];
}
}
This is worth knowing about specifically because it changes the decision from a binary “full-text or a whole separate vector infrastructure project” into a much smaller step: the database engine you’re already using for full-text search can be extended with a vector column when a genuine semantic need shows up, without adopting a new search service, a new SDK, or a parallel infrastructure project. The honest recommendation, in order: start with whereFullText or Scout’s database driver for keyword search. Add a vector column to the same database engine only once query-log evidence shows a real semantic gap. Reach for a dedicated external search service (Meilisearch, Typesense, Algolia) only once scale or feature requirements — typo tolerance, faceted filtering, geo-search — genuinely exceed what the database engine handles, which for most Laravel apps is a threshold that arrives much later than the first search feature ships, if it arrives at all.
When pgvector Genuinely Is the Right First Choice
None of this is an argument that semantic search is always the wrong call — it’s an argument for checking first. It’s the right first choice, immediately, without needing a query-log experiment, when:
The content and the query vocabulary are known in advance to diverge. A support knowledge base is the clearest case — users describe symptoms, articles describe root causes and fixes, and the vocabulary gap is the entire premise of the feature, not something that needs measuring first.
“Find similar” is the actual feature, not keyword search at all. Product recommendations, “more like this,” duplicate-content detection — these are inherently similarity problems with no keyword-matching equivalent to compare against; there’s no full-text fallback that does the same job.
The catalog is genuinely conceptual rather than nominal. A recipe search where users describe an occasion or a craving rather than an ingredient list, a legal-document search where the relevant precedent uses different terminology than the query — these lean toward real semantic value from the outset.
In all three cases, the earlier post’s implementation — chunking, pgvector, hybrid search via Reciprocal Rank Fusion, query-embedding caching — is the right build, and worth doing properly rather than skipped for a keyword search that won’t actually serve the query pattern. The point of this post isn’t “never use pgvector.” It’s “check which of these two situations you’re actually in before three weeks and a recurring API bill go into solving a problem a GIN index already solved.”
The One Rule
The honest reason vector databases get reached for reflexively isn’t that they’re the wrong technology — it’s that “we added AI-powered semantic search” is a more exciting sentence to put in a release note than “we added a full-text index,” even when the second sentence describes the thing that actually serves 90% of what users type into the search box. Check the query logs before choosing the architecture. If the queries are short and exact, a well-indexed column and Scout’s database driver will outperform an embeddings pipeline on cost, latency, and operational simplicity, and nobody using the feature will ever notice the difference between “AI search” and “a fast, well-built keyword search” — because from the user’s side of the search box, correct results returned quickly is the entire feature, regardless of which architecture produced them.
