Hybrid Search
Use hybrid search when either semantic similarity or exact vocabulary can identify a relevant document. Run vector and full-text search separately, then combine their results with fusion or reranking.
Lakebase Search requires Postgres 16 or later. Hybrid search uses both extensions:
CREATE EXTENSION IF NOT EXISTS lakebase_vector CASCADE;
CREATE EXTENSION IF NOT EXISTS lakebase_text;
lakebase_vector installs pgvector through CASCADE; lakebase_text has no extension dependency. Both rely on preloaded libraries that Neon enables by default. If the project customized its preloaded-library list, confirm both libraries remain enabled.
Prepare a table with both vector and text-search columns:
CREATE TABLE documents (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
title text NOT NULL,
body text NOT NULL,
embedding vector(1024),
body_tsv tsvector GENERATED ALWAYS AS
(to_tsvector('english', body)) STORED
);
The 1024-dimensional column is an example. Set it to the output dimension of your selected embedding model. Generate document embeddings in batches and embed each query with the same model and dimensions; Vector search shows the AI Gateway calls. Follow any model-specific query instructions, while keeping the original query text for BM25. Choose a PostgreSQL text-search configuration appropriate for the corpus.
Create and validate each retriever independently before combining them. Follow Vector search and Full-text search for their indexes, query operators, and tuning.
Choose how to order the combined results:
- Jev reranking: retrieve vector and full-text search candidates, deduplicate by document ID, score each candidate with Jev, and take the final top results.
- Reciprocal Rank Fusion (RRF): fuse the retrievers' ranks instead of their incomparable vector distances and BM25 scores, with no additional model call.
Jev can score the deduplicated candidate pool directly. RRF is optional when you want to prioritize or trim a larger pool before reranking, or provide a model-free fallback. Measure relevance and added latency on your corpus to choose a path.
Rerank with Jev
Retrieve up to 40 candidates per retriever and deduplicate with UNION. Bind the query embedding as $1 and the original query text as $2:
WITH vector_candidates AS (
SELECT id
FROM documents
WHERE embedding IS NOT NULL
ORDER BY embedding <=> $1::vector
LIMIT 40
),
keyword_candidates AS (
SELECT id
FROM (
SELECT
id,
body_tsv <@> to_bm25query(
to_tsvector('english', $2),
'documents_body_bm25'::regclass
) AS score
FROM documents
) AS keyword_scored
WHERE score < 0
ORDER BY score, id
LIMIT 40
),
candidate_ids AS (
SELECT id FROM vector_candidates
UNION
SELECT id FROM keyword_candidates
)
SELECT d.id, d.title, d.body
FROM candidate_ids AS c
JOIN documents AS d ON d.id = c.id
ORDER BY d.id;
Apply the same access-control and tenant filters inside both retrievers, before their limits. Take the final top results only after Jev scoring.
Batch yes/no relevance questions with the query shared in state and each candidate in its own question's instructions. Sort the returned probabilities in descending order; equal probabilities keep the input order.
Use Neon's provider with the Vercel AI SDK. Requirements:
- Install the tested versions:
npm install @neon/ai-sdk-provider@1.1.0 ai@7.0.133. Decision models require AI SDK 7.0.133+; pin versions and retest upgrades whileexperimental_decideis experimental. - Load the same
NEON_AI_GATEWAY_TOKENandNEON_AI_GATEWAY_BASE_URLused for embeddings; the provider handles the endpoint and credentials. - The AI Gateway, for both Jev and embeddings, requires a paid Neon plan with prepaid AI Gateway credits. Without them, embed documents and queries with one model from another provider and use RRF.
import { neon } from "@neon/ai-sdk-provider";
import { experimental_decide as decide } from "ai";
type Candidate = { id: string; title: string; body: string };
async function rerankWithJev(
queryText: string,
candidates: Candidate[], // deduplicated, authorized rows in a stable order
limit = 10,
batchSize = 80,
): Promise<Candidate[]> {
if (!Number.isInteger(limit) || limit < 1) {
throw new Error("limit must be a positive integer");
}
if (!Number.isInteger(batchSize) || batchSize < 1) {
throw new Error("batchSize must be a positive integer");
}
if (candidates.length === 0) return [];
const model = neon.decisionModel("typesafeai-jev-1-14-0");
const criteria = {
true: "The candidate states the specific facts or steps the query asks for.",
false:
"The candidate only shares keywords or a topic with the query, answers a different question, or lacks the requested information.",
};
const abortSignal = AbortSignal.timeout(10_000); // whole reranking budget
const scored: { doc: Candidate; index: number; probability: number }[] = [];
for (let start = 0; start < candidates.length; start += batchSize) {
const batch = candidates.slice(start, start + batchSize);
const { answers } = await decide({
model,
state: { query: queryText }, // original text, without embedding instructions
questions: Object.fromEntries(
batch.map((doc, offset) => [
`d${start + offset}`, // response key only; the candidate goes in instructions
{
type: "boolean" as const,
instructions: {
question:
"Given the search query in state.query, does this candidate passage supply the specific information the user is looking for?",
candidate: { title: doc.title, body: doc.body },
},
criteria,
},
]),
),
maxRetries: 0, // propagate errors to the caller without retrying
abortSignal,
});
for (const [offset, doc] of batch.entries()) {
const index = start + offset;
const answer = answers[`d${index}`];
if (answer.type !== "boolean") {
throw new Error(`Missing relevance probability for d${index}`);
}
scored.push({ doc, index, probability: answer.probability });
}
}
return scored
.sort((a, b) => b.probability - a.probability || a.index - b.index)
.slice(0, limit)
.map(({ doc }) => doc);
}
The SDK's boolean answer exposes the probability of yes as answer.probability (TypeSafe calls it noul). This is a probability that the passage answers the query, not a degree of completeness. Calibrate a threshold on judged no-match queries if the application must return "no answer."
batchSize = 80 matches the maximum candidate pool above. Each request must fit Jev 1.14's 65,536-token context window, including all candidate text and questions; reduce the batch size for longer passages. Tune the shared timeout to the request's latency budget.
The caller can catch reranking errors and use RRF as a fallback.
RRF Example
Use this query on its own when you want hybrid search without an additional model call.
For rank r and constant k, each retriever contributes 1 / (k + r). Start with a target of 40 candidates per retriever and k = 60; vector cutoff ties can exceed that target. Tune both for the corpus and workload.
Embed the query text with the same embedding model used for the stored documents, then bind that vector as $1, the original query text as $2, and the final result count as $3:
WITH vector_ranked AS (
SELECT id, RANK() OVER (ORDER BY distance) AS rank
FROM (
SELECT id, embedding <=> $1::vector AS distance
FROM documents
WHERE embedding IS NOT NULL
ORDER BY distance
FETCH FIRST 40 ROWS WITH TIES
) AS vector_candidates
),
keyword_ranked AS (
SELECT id, RANK() OVER (ORDER BY score) AS rank
FROM (
SELECT id, score
FROM (
SELECT
id,
body_tsv <@> to_bm25query(
to_tsvector('english', $2),
'documents_body_bm25'::regclass
) AS score
FROM documents
) AS keyword_scored
WHERE score < 0
ORDER BY score, id
LIMIT 40
) AS keyword_candidates
)
SELECT
d.id,
d.title,
COALESCE(1.0 / (60 + v.rank), 0) +
COALESCE(1.0 / (60 + k.rank), 0) AS rrf_score
FROM documents AS d
LEFT JOIN vector_ranked AS v ON v.id = d.id
LEFT JOIN keyword_ranked AS k ON k.id = d.id
WHERE v.id IS NOT NULL OR k.id IS NOT NULL
ORDER BY rrf_score DESC, d.id
LIMIT $3;
score < 0 excludes BM25 non-matches. Many matching documents can share a BM25 score, so WITH TIES could admit a large group at the cutoff. ORDER BY score, id LIMIT 40 keeps keyword candidates bounded and breaks boundary ties by ID. RANK() uses score alone so retained ties receive equal ranks.
Vector retrieval preserves cutoff ties and can exceed 40 candidates. lakebase_bm25.default_limit defaults to 1000; the SQL limit bounds keyword rows entering fusion, not total scoring work.
Adapt the Hybrid Search
- Retrieve more candidates from each source than the final result count; otherwise one retriever can dominate before fusion has enough overlap. Keep
lakebase_bm25.default_limitat least as large as the BM25 candidate target. - Keep each retriever's operator and index configuration correct independently before tuning fusion or reranking.
- Tune candidate counts and the RRF constant with judged or behavioral relevance data, plus latency measurements.
- Compare vector-only, BM25-only, RRF-only, the deduplicated union plus Jev, and optional RRF plus Jev on the same judged queries to isolate each stage's contribution. Measure recall before reranking and nDCG@10 or MRR after it, alongside p95 latency, input-token cost, and fallback rate.
- Add weights only when product evidence shows one retriever should contribute more. Weight the reciprocal-rank contributions, not the raw vector distance and negative BM25 score.
- Apply the same access-control and tenant filters to both candidate CTEs. If BM25 filters are strict and cheap, evaluate whether
lakebase_bm25.prefilterimproves the filtered query.
Sources: Lakebase Search get-started guide and TypeSafe reranking cookbook.