Skip to content

AI & Machine Learning·6 min read

Why Hybrid Search Beat Pure Embeddings in Our Invoice AI Pipeline

Pure dense embeddings failed on alphanumeric serial numbers in our Invoice AI pipeline. Combining PostgreSQL tsvector full-text search with pgvector cosine distance lifted top-1 matching accuracy from 61.4% to 94.8%.

Anatoli NavahrodskiFounder & CEO, GlanitPublished 28 August 2026

Why did dense embeddings fail on exact serial numbers in Invoice AI?

Vector embeddings project text into high-dimensional geometric spaces based on semantic similarity, mapping SN-8849-X and SN-8849-Y to nearly identical spatial points with cosine similarity above 0.962. In our Invoice AI extraction engine, which processes over 45,000 vendor invoices per month, pure dense vector retrieval using text-embedding-3-small misclassified 38.6% of alphanumeric part numbers. Dense models excel at identifying that "hydraulic valve" matches "fluid control assembly", but they blur the token-level character variations that separate two distinct line items in a catalog. Combining lexical full-text search via PostgreSQL tsvector with pgvector similarity search brought our top-1 line-item matching recall from 61.4% to 94.8%.

When we launched the initial prototype of Invoice AI on 14 March 2024, our architecture relied entirely on OpenAI text-embedding-3-small vectors stored in a PostgreSQL 16 database with the pgvector extension. For descriptive line items like "Heavy Duty 12V Alternator", dense vector search returned the correct internal SKU in 98.1% of test cases. The failure occurred when vendor invoices contained raw part codes like MOD-9942-B alongside identical description strings. The embedding model ignored the single-character suffix variation, returning MOD-9942-A as the top candidate because their position in the vector space differed by less than 0.004 euclidean distance.

Lexical search engines handle this case trivially because inverted indexes operate on exact string tokens rather than semantic concepts. A standard GIN index matching MOD-9942-B evaluates exact character token equality, scoring exact string collisions higher than partial matches. However, pure keyword matching failed whenever vendors abbreviated product descriptions or used regional synonyms that shared zero tokens with our master inventory table.

How did we structure the PostgreSQL 16 hybrid retrieval query?

We avoided running dedicated vector databases like Qdrant alongside Elasticsearch to prevent cross-database synchronization lag during high-concurrency invoice ingestion. Maintaining dual ingestion pipelines introduced transient state drift whenever a catalog update failed on one cluster. Instead, we co-located pgvector 0.7.0 and native PostgreSQL tsvector columns in a single table, merging sparse keyword ranks and dense vector distance inside a single SQL query using Reciprocal Rank Fusion (RRF).

Our catalog table stores both pre-computed 1536-dimensional embeddings and a combined full-text search vector generated from the product SKU, vendor part number, and description. The SQL query below executes both index scans in parallel and blends their ranks using an RRF constant of k = 60:

WITH vector_matches AS (
    SELECT id, ROW_NUMBER() OVER (ORDER BY embedding <-> $1) AS rank
    FROM master_catalog
    ORDER BY embedding <-> $1
    LIMIT 40
),
text_matches AS (
    SELECT id, ROW_NUMBER() OVER (ORDER BY ts_rank_cd(fts_vector, plainto_tsquery('simple', $2)) DESC) AS rank
    FROM master_catalog
    WHERE fts_vector @@ plainto_tsquery('simple', $2)
    ORDER BY ts_rank_cd(fts_vector, plainto_tsquery('simple', $2)) DESC
    LIMIT 40
)
SELECT 
    COALESCE(v.id, t.id) AS catalog_id,
    COALESCE(1.0 / (60 + v.rank), 0.0) + COALESCE(1.0 / (60 + t.rank), 0.0) AS rrf_score
FROM vector_matches v
FULL OUTER JOIN text_matches t ON v.id = t.id
ORDER BY rrf_score DESC
LIMIT 10;

Using plainto_tsquery('simple', $2) was a deliberate decision. The default english dictionary stems words, which stripped part numbers like CAT-442-9 into isolated digits. The simple dictionary preserves raw token strings, allowing exact alphanumeric prefix matching without language-specific transformations.

Dense vs sparse vs hybrid: what did the benchmark show on our dataset?

We benchmarked four retrieval strategies against a test dataset of 12,000 historical invoice line items extracted from PDF documents. Each line item was evaluated on its ability to pull the exact matching SKU from a master catalog containing 420,000 product rows. The evaluation ran on an AWS Aurora PostgreSQL db.r6g.xlarge instance with 32 GB RAM.

Retrieval Accuracy and Performance Benchmarks on 12,000 Invoice Line Items
StrategyTop-1 RecallTop-5 Recallp95 Latency (ms)Index Size per 100k Rows
Pure Dense (HNSW, text-embedding-3-small)61.4%82.1%14.2 ms610 MB
Pure Sparse (tsvector, GIN index)74.8%86.3%6.1 ms125 MB
Hybrid (RRF, k=60, HNSW + GIN)94.8%98.7%22.6 ms735 MB
Hybrid (Linear Score Sum, Normalized)89.2%95.1%28.4 ms735 MB
Retrieval Accuracy and Performance Benchmarks on 12,000 Invoice Line Items

What broke when we tuned the Reciprocal Rank Fusion parameters?

RRF parameter tuning broke edge cases before we stabilized it. On 4 November 2024, an automated test deployment dropped line-item precision by 11.3% after we lowered the RRF constant k from 60 to 10. The lower constant over-indexed on top-ranked keyword matches, causing short numeric strings like invoice dates (2024-03-01) or zip codes in vendor header fields to outweigh strong semantic descriptions.

We also attempted to replace RRF with a linear weighted sum of normalized cosine distance and ts_rank_cd scores. The linear score function was unmanageable because ts_rank_cd returns unbounded float values depending on document length and token frequency, whereas cosine distance resides strictly within the range [0, 1]. Score normalization required dynamically fetching min-max statistics across the candidate set on every query, which added 5.8 ms to execution latency.

RRF eliminates score normalization entirely because it relies exclusively on relative ordinal positions rather than raw distance values. A item ranked #1 in the keyword search receives an RRF score component of 1.0 / (60 + 1) = 0.01639 regardless of whether its raw ts_rank_cd score was 0.12 or 4.85.

How much latency and memory does hybrid search add in production?

Hybrid search added 8.4 ms of latency per query compared to pure HNSW vector retrieval, moving p95 execution time from 14.2 ms to 22.6 ms. This performance tax is acceptable for our Invoice AI pipeline because OCR pre-processing and document parsing take 1.8 seconds per PDF page. The extra 8.4 ms spent inside PostgreSQL is unnoticeable to end users waiting for asynchronous batch extraction results.

Memory consumption increased by 14% on our database instance. Storing an HNSW vector index on a 1536-dimensional float vector requires approximately 610 MB per 100,000 rows when built with m = 16 and ef_construction = 64. The corresponding GIN index for full-text search added 125 MB per 100,000 rows. PostgreSQL held both indexes in memory simultaneously inside the shared_buffers pool without swapping.

We encountered one operational issue with PostgreSQL autovacuum workers. Frequent updates to catalog vector embeddings triggered GIN index bloat, causing p99 keyword search latencies to spike to 140 ms during bulk inventory syncs. Decreasing autovacuum_vacuum_scale_factor to 0.05 on the master catalog table forced vacuuming to run in smaller, frequent batches, flattening the latency spike back to 25 ms.

What would we change if we rebuilt the hybrid pipeline today?

If we were rebuilding the extraction engine today, we would evaluate SPLADE or BGE-M3 learned sparse embeddings directly alongside dense vectors, rather than using traditional tsvector tokenization. Standard PostgreSQL full-text search lacks true query expansion, meaning it misses cases where an invoice calls an item "fastener" while the catalog titles it "hex bolt". Learned sparse embeddings generate weighted token vectors that capture both exact term matches and context-aware lexical expansions.

We would also move from static candidate cutoff limits (LIMIT 40 per subquery) to dynamic score thresholds. When an invoice contains a perfectly explicit part code like SKU-77381-V2, fetching and scoring 40 dense vector candidates wastes compute cycles. A threshold-based execution path that exits early on high-confidence exact keyword matches would shave an estimated 6 ms off p95 query latency across standard invoice formats.

Frequently asked questions