Short answer: for supplier-invoice search, retrieve a broad candidate set with cheap embeddings, rerank only ambiguous queries, and make both stages replaceable behind a measured TypeScript contract. The winning provider is the one that meets your extraction-quality, residency, latency, and operating constraints on your own invoices. A token price alone can't answer that.
Start with the shape of the workload, not a logo. This field guide assumes a B2B SaaS product that must find invoice evidence before extracting fields such as supplier name, invoice number, due date, currency, and total. The retrieval layer should return citations; a separate extraction step should turn those citations into typed fields.
| Pick this shape | Use it when | Measure before release | Main trade-off |
|---|---|---|---|
| Embedding retrieval only | Invoice wording is regular and the top results are already clean | Recall at candidate count, wrong-supplier rate, p95 latency | Lowest moving-part count, but near-duplicate invoices can crowd the result set |
| Embeddings plus selective reranking | Similar templates, legal entities, or date ranges make the top candidates ambiguous | Field-evidence recall, rerank rate, no-evidence rate | Better ordering where it matters, with another model call on some queries |
| Lexical plus vector retrieval | Exact invoice IDs, tax IDs, and purchase-order numbers matter alongside meaning | Exact-ID recall and semantic recall separately | More indexing and score calibration work |
| No semantic layer | Deterministic identifiers always locate the record | Lookup miss rate and data-normalization errors | Poor fit for natural-language ask-your-docs queries, but easier to audit |
That's the whole decision in miniature.
Trace it.
Observe one failure across the retrieval pipeline
An invoice question usually contains two different jobs. “What did Northwind charge for expedited freight in March?” needs semantic retrieval to locate freight language, while “show invoice NW-1048” needs an exact identifier match. Shoving both through one vector score hides a useful signal: the query type.
Classify the query locally. If it contains a normalized invoice number, purchase-order number, or tax identifier, run lexical retrieval and apply tenant and date filters before any model call. For descriptive questions, retrieve enough chunks to protect recall, then decide whether the candidate set is confused. Confusion is measurable. A small gap between the top scores, repeated supplier names, or several documents from the same template can trigger reranking; a clear result can skip it. The diagram in words is short: query enters; tenant and region policy narrow the corpus; lexical and vector retrieval produce candidates; a gate either returns them or sends a small set to a reranker; the extraction step receives cited text; logs and metrics receive timings, model labels, token counts, and outcome labels. Raw invoice text does not belong in those logs. OpenAI, Cohere, and Voyage are reasonable names to include in a candidate matrix because they are in the reader's comparison set. Don't turn that matrix into a permanent architectural decision. Give each candidate the same inputs and contract, record the same outputs, and verify current pricing and regional processing terms in primary documentation at evaluation time. I'm not sure which one wins for your corpus, and a generic benchmark cannot resolve that uncertainty. A blinded run on representative supplier templates can.
Names come later.
How can cheap embeddings and rerank alternatives govern invoice semantic search?
Treat “cheap” as a system property. The useful unit is not cost per 1M tokens in isolation; it is cost per accepted invoice answer after indexing, query embedding, conditional reranking, retries, and re-evaluation are counted. Check the current official price sheets once, insert those values into the same worksheet, and keep price out of the relevance labels. It gets one column, not the steering wheel.
Quality needs an invoice-shaped definition too. A search result is good when the cited chunk contains enough evidence for the requested field and belongs to the correct tenant, supplier, document, and page. Generic similarity can look impressive while returning the wrong monthly invoice from the same supplier. That is a serious miss even if most words match. Build an evaluation set around those collisions. Include repeated templates, credit notes, invoices with two currencies, OCR spacing errors, supplier aliases, and questions whose answer is absent. Label the evidence spans rather than only the final answer. Then report candidate recall before reranking and ordering quality after reranking. If recall is already lost in stage one, a reranker cannot recover the missing page. Use three release gates. First, every returned document must satisfy tenant and residency policy before its text reaches a model. Second, the known evidence must appear in the retrieved candidate set. Third, extraction must either cite that evidence or return a typed no-evidence result. Selective reranking earns its place when it changes a decision. Track how often the gate invokes it and how often the reranker moves a labeled evidence chunk into the accepted window. A high invocation rate with few useful moves means the gate is noisy. A low rate with frequent search misses means it is too strict. This crisp before/after is far more actionable than one blended “AI quality” score.
No guessing.
Implement spans around the portable provider boundary
Provider portability starts with the data contract you own. Keep vendor request objects outside business logic, preserve stable document IDs, and version the transformation that creates chunks. Otherwise, changing an embedding model can silently mix incompatible vectors in one index.
Here is a compact boundary. Both network-backed and self-hosted adapters can implement it, while the search pipeline sees only normalized vectors and ranked candidates.
type Region = "US" | "EU";
type InvoiceChunk = {
tenantId: string;
documentId: string;
page: number;
text: string;
supplierId?: string;
};
type Candidate = InvoiceChunk & {
score: number;
};
type Usage = {
inputTokens: number;
modelLabel: string;
};
interface Embedder {
embed(texts: string[], region: Region): Promise<{
vectors: number[][];
usage: Usage;
}>;
}
interface Reranker {
rank(query: string, candidates: Candidate[], region: Region): Promise<{
candidates: Candidate[];
usage: Usage;
}>;
}
The application contract deliberately avoids model-specific dimensions, task strings, or response shapes. Those belong in adapters. Persist the adapter label, model label, vector dimension, and schemaVersion next to each index generation, though; portability does not mean pretending embeddings from different models share a coordinate space. A migration builds a new generation and switches an alias only after evaluation passes.
The orchestration layer can expose the exact signals needed for a provider comparison without recording invoice content:
type SearchEvent = {
traceId: string;
tenantHash: string;
region: Region;
queryKind: "exact" | "semantic";
candidateCount: number;
rerankInvoked: boolean;
retrievalMs: number;
rerankMs?: number;
embeddingTokens: number;
rerankTokens: number;
outcome: "evidence_found" | "no_evidence" | "policy_rejected";
schemaVersion: 3;
};
type SearchDeps = {
retrieve(query: string, tenantId: string, limit: number): Promise<Candidate[]>;
reranker: Reranker;
emit(event: SearchEvent): void;
now(): number;
};
const needsRerank = (items: Candidate[]): boolean => {
if (items.length < 2) return false;
const scoreGap = items[0].score - items[1].score;
const supplierCollision =
items[0].supplierId !== undefined &&
items[0].supplierId === items[1].supplierId;
return scoreGap < 0.04 || supplierCollision;
};
async function searchInvoices(
deps: SearchDeps,
query: string,
tenantId: string,
tenantHash: string,
region: Region,
traceId: string,
): Promise<Candidate[]> {
const started = deps.now();
const candidates = await deps.retrieve(query, tenantId, 20);
const retrievalMs = deps.now() - started;
const rerankInvoked = needsRerank(candidates);
let ranked = candidates;
let rerankMs: number | undefined;
let rerankTokens = 0;
if (rerankInvoked) {
const rerankStarted = deps.now();
const result = await deps.reranker.rank(query, candidates.slice(0, 12), region);
ranked = result.candidates;
rerankTokens = result.usage.inputTokens;
rerankMs = deps.now() - rerankStarted;
}
deps.emit({
traceId,
tenantHash,
region,
queryKind: "semantic",
candidateCount: candidates.length,
rerankInvoked,
retrievalMs,
rerankMs,
embeddingTokens: 0,
rerankTokens,
outcome: ranked.length > 0 ? "evidence_found" : "no_evidence",
schemaVersion: 3,
});
return ranked.slice(0, 5);
}
The 0.04, 20, and 12 values are experiment parameters, not universal truths. Put them in versioned configuration and tune them against the labeled invoice set. Your mileage may vary sharply with chunk size and template repetition. The important behavior is stable: filter first, retrieve broadly enough to preserve evidence, rerank only a bounded set, and emit one event that connects the stages.
There is one deliberate omission in the sample: query embedding happens inside retrieve, so its usage should be returned by the production repository and assigned to embeddingTokens. A real implementation should reject an incomplete usage record instead of silently treating it as free. Keep the example readable; keep production accounting strict.
Alert on outcomes, not provider names. A rise in no_evidence for one index generation, a drop in evidence recall during a shadow run, or a regional policy rejection deserves attention. Raw latency alone is weaker: a fast response that cites the wrong invoice is still wrong. Also split dashboards by query kind, region, corpus generation, and supplier-template cohort. Aggregates can hide one troublesome template behind thousands of easy exact-ID searches.
Evaluate quality and reliability in shadow traffic
Replay a fixed, redacted query set through the current and candidate adapters. Use identical chunks, filters, candidate counts, and labels. Do not let one candidate receive cleaner OCR or a larger rerank window. Record the candidate-set overlap, evidence recall, useful-rerank rate, p50 and p95 latency, token usage, no-evidence accuracy, and policy result. A comparison is fair only when the surrounding pipeline stays still.
Then inspect disagreements. Ten carefully labeled disagreements can reveal more than a large blended score: perhaps one setup favors table headers, another separates near-identical legal entities, or the lexical branch is doing all the work for invoice numbers. These observations are hypotheses until the labeled set confirms them. Keep the losing cases in the regression suite, because the next chunking or OCR change can reopen them.
The deployment sequence is equally practical: build a separate index generation, shadow a small query sample without affecting user-visible results, compare gates, canary by tenant, and retain the previous generation for rollback. Emit the same event schema from both paths. If a candidate cannot provide the metadata needed for region enforcement or usage accounting, mark that row as unknown and pause the migration; don't turn missing evidence into a favorable score.
Portability has a team cost. Adapters, dual indexes, replay fixtures, and normalized telemetry all need owners. For a small corpus with one stable provider and no residency constraint, that machinery may cost more engineering time than it returns. Keep one adapter and a clean interface in that case. Add the shadow path when a concrete migration, compliance, or reliability requirement appears.
Deploy by index generation and preserve rollback
Selective reranking is not suitable when exact identifiers fully determine the invoice; stick with normalized lookup and tenant filters. It also cannot repair missing OCR text, bad chunk boundaries, or a candidate set that excluded the correct page. Fix ingestion first.
Provider portability is constrained by model-specific vector spaces, input limits, regional terms, and the operational surface of each adapter. Never swap a model label under an existing index. Re-embed into a new generation, rerun the evidence gates, and make the cutover reversible.
Finally, semantic search should locate evidence, not authorize payment or invent missing fields. Keep extraction typed, return no_evidence when the cited pages do not support a value, and route high-impact exceptions to review. That boundary is less flashy than a leaderboard. It is also what makes invoice search defensible.
Top comments (0)