Get started with Lakebase Search
This guide sets up Lakebase Search on a Neon project enabling both extensions, creating a schema that supports vector and full text search, inserting documents with embeddings, and querying from TypeS...
This guide sets up Lakebase Search on a Neon project: enabling both extensions, creating a schema that supports vector and full-text search, inserting documents with embeddings, and querying from TypeScript.
Prerequisites
Section titled “Prerequisites”-
A Neon project. You enable Lakebase Search on it in the first step below.
-
Postgres 16 or later (Lakebase Search requires PG16+)
-
Node.js 18 or later
-
Choose an embedding provider to turn text into vectors:
- Neon AI Gateway (this guide's default): set
NEON_AI_GATEWAY_TOKENandNEON_AI_GATEWAY_BASE_URL(see Get started) and use theqwen3-embedding-0-6bmodel (1024 dimensions). Requires a paid plan in a Neon AI Gateway region. - OpenAI directly: set
OPENAI_API_KEYand drop both client overrides so it's justnew OpenAI(), then change the model totext-embedding-3-small(1536 dimensions). - Any other provider: generate the vector with its SDK and match the
VECTORcolumn to its dimensions.
- Neon AI Gateway (this guide's default): set
Enable the extensions
Install the extensions in the Neon SQL Editor or any connected Postgres client:
SQL CREATE EXTENSION IF NOT EXISTS lakebase_vector CASCADE; CREATE EXTENSION IF NOT EXISTS lakebase_text CASCADE;CASCADEautomatically installspgvectorif it is not already present, sincelakebase_vectordepends on it.Create a table
SQL CREATE TABLE documents ( id SERIAL PRIMARY KEY, title TEXT NOT NULL, body TEXT NOT NULL, embedding VECTOR(1024), body_tsv TSVECTOR GENERATED ALWAYS AS (to_tsvector('english', body)) STORED ); CREATE INDEX documents_embedding_idx ON documents USING lakebase_ann (embedding vector_cosine_ops);The
lakebase_bm25index is created in a later step, after data is inserted. BM25 computes corpus-wide statistics (document count, term frequencies) at index build time, so the index must be built on populated data to return meaningful scores.Set up your project
The remaining steps run from a local TypeScript project. Install dependencies:
Bash npm install @neondatabase/serverless openai dotenvnote
This guide uses the
openaiSDK pointed at the Neon AI Gateway. Version 6 of the SDK requiresencoding_format: 'float'on embedding calls (included below); on v7 and later it's optional. See Embeddings for details.Create a
.envfile with your Neon connection string and AI Gateway credentials. See Get started with AI Gateway for how to obtain the gateway values:.env DATABASE_URL=postgresql://[user]:[password]@[neon_hostname]/[dbname]?sslmode=require NEON_AI_GATEWAY_TOKEN=nt_live_... NEON_AI_GATEWAY_BASE_URL=https://[branch-host]Run the demo
Create
search.tsand paste the following. It inserts documents with embeddings, creates thelakebase_bm25index, runs a vector search, then runs a BM25 text search:search.ts import 'dotenv/config'; import { neon } from '@neondatabase/serverless'; import OpenAI from 'openai'; const sql = neon(process.env.DATABASE_URL!); // Point the OpenAI SDK at the Neon AI Gateway. const openai = new OpenAI({ apiKey: process.env.NEON_AI_GATEWAY_TOKEN, baseURL: `${process.env.NEON_AI_GATEWAY_BASE_URL}/v1`, }); const documents = [ { title: 'Vector search on Postgres', body: 'lakebase_vector adds a lakebase_ann index to Postgres for fast approximate nearest-neighbor search at billion-vector scale.', }, { title: 'BM25 full-text search', body: 'lakebase_text adds a lakebase_bm25 index that provides BM25 ranking and top-K pushdown while preserving standard tsvector types.', }, { title: 'AI agent memory', body: 'Store conversation history, session state, and vector embeddings in a single Postgres database to power AI agent backends.', }, { title: 'Branching for retrieval experiments', body: 'Neon branching lets you test new chunking strategies or embedding models on a branch without rebuilding your search indexes.', }, { title: 'Scale-to-zero search', body: 'Lakebase Search indexes survive cold starts. Your vector and BM25 indexes are available immediately after a Neon compute wakes up.', }, ]; async function embedAndInsert() { for (const doc of documents) { const { data } = await openai.embeddings.create({ model: 'qwen3-embedding-0-6b', input: doc.body, encoding_format: 'float', // required on the openai SDK v6; harmless on v7+ }); await sql` INSERT INTO documents (title, body, embedding) VALUES (${doc.title}, ${doc.body}, ${JSON.stringify(data[0].embedding)}::vector) `; } } async function vectorSearch(query: string, limit = 5) { const { data } = await openai.embeddings.create({ model: 'qwen3-embedding-0-6b', input: query, encoding_format: 'float', }); return sql` SELECT id, title, embedding <=> ${JSON.stringify(data[0].embedding)}::vector AS distance FROM documents ORDER BY distance LIMIT ${limit} `; } // BM25 scores are negative — lower (more negative) means more relevant async function textSearch(query: string, limit = 5) { return sql` SELECT id, title, body_tsv <@> to_bm25query( to_tsvector('english', ${query}), 'documents_bm25' ) AS score FROM documents ORDER BY score LIMIT ${limit} `; } async function main() { console.log('Inserting documents...'); await embedAndInsert(); console.log('Building BM25 index...'); await sql` CREATE INDEX IF NOT EXISTS documents_bm25 ON documents USING lakebase_bm25 (body_tsv) WITH (default_limit = 10) `; console.log('\nVector search — "how do agents store memory?":'); console.log(await vectorSearch('how do agents store memory?')); console.log('\nBM25 search — "vector search":'); console.log(await textSearch('vector search')); } main();Run it:
Bash npx tsx search.tsCombine results with hybrid search
Vector and keyword search each catch matches the other misses, so most real-world search combines them. There's no built-in hybrid function; you write one query that runs both searches and merges their results. The technique here is Reciprocal Rank Fusion (RRF): take the top candidates from each search, rank each list, then score every row by
1 / (60 + rank)summed across both lists, so rows that rank well in either or both come out on top.Add a
hybridSearchfunction tosearch.ts, alongsidevectorSearchandtextSearch. It reuses thedocumentstable and indexes from above:search.ts async function hybridSearch(query: string, limit = 5) { const { data } = await openai.embeddings.create({ model: 'qwen3-embedding-0-6b', input: query, encoding_format: 'float', }); const queryVector = JSON.stringify(data[0].embedding); return sql` WITH vector_ranked AS ( SELECT id, RANK() OVER (ORDER BY dist) AS rank FROM ( SELECT id, embedding <=> ${queryVector}::vector AS dist FROM documents ORDER BY dist LIMIT 40 ) v ), keyword_ranked AS ( SELECT id, RANK() OVER (ORDER BY score) AS rank FROM ( SELECT id, body_tsv <@> to_bm25query(to_tsvector('english', ${query}), 'documents_bm25') AS score FROM documents ORDER BY score LIMIT 40 ) k ) SELECT d.id, d.title, COALESCE(1.0 / (60 + v.rank), 0) + COALESCE(1.0 / (60 + k.rank), 0) AS rrf_score FROM documents d LEFT JOIN vector_ranked v ON d.id = v.id LEFT JOIN keyword_ranked k ON d.id = k.id WHERE v.id IS NOT NULL OR k.id IS NOT NULL ORDER BY rrf_score DESC, d.id LIMIT ${limit} `; }Each search contributes its top 40 candidates.
RANK()gives tied scores the same rank, the constant60keeps low-ranked results from dominating, andd.idbreaks ties for stable ordering. Tune the per-searchLIMITand the RRF constant for your data; weighted scoring is another common approach.
Next steps
Section titled “Next steps”- lakebase_vector reference: index options, operator classes, tuning guide
- lakebase_text reference: BM25 operators, functions, fallback parameters, prefilter
- lakebase_tokenizer reference: optional configurable tokenization, stop words, synonyms, and stemming
Need help?
Section titled “Need help?”Join our Discord Server to ask questions or see what others are doing with Neon. For paid plan support options, see Support.