From 5518d44a17e1c6a6863949764118d400f20369ae Mon Sep 17 00:00:00 2001 From: Carlos Mayora Curzio Date: Mon, 13 Jul 2026 12:09:53 +0200 Subject: [PATCH] add: PostgreSQL hybrid search guide Co-authored-by: Cursor --- src/content/docs/guides/_map.json | 3 +- .../docs/guides/postgresql-hybrid-search.mdx | 369 ++++++++++++++++++ 2 files changed, 371 insertions(+), 1 deletion(-) create mode 100644 src/content/docs/guides/postgresql-hybrid-search.mdx diff --git a/src/content/docs/guides/_map.json b/src/content/docs/guides/_map.json index 2104cf9a..485a6edd 100644 --- a/src/content/docs/guides/_map.json +++ b/src/content/docs/guides/_map.json @@ -41,5 +41,6 @@ [ "full-text-search-with-generated-columns", "Full-text search with Generated Columns" - ] + ], + ["postgresql-hybrid-search", "PostgreSQL hybrid search"] ] diff --git a/src/content/docs/guides/postgresql-hybrid-search.mdx b/src/content/docs/guides/postgresql-hybrid-search.mdx new file mode 100644 index 00000000..7dee4c56 --- /dev/null +++ b/src/content/docs/guides/postgresql-hybrid-search.mdx @@ -0,0 +1,369 @@ +--- +title: PostgreSQL hybrid search +--- + +import Section from "@mdx/Section.astro"; +import Prerequisites from "@mdx/Prerequisites.astro"; +import CodeTabs from '@mdx/CodeTabs.astro'; +import CodeTab from '@mdx/CodeTab.astro'; +import Npm from "@mdx/Npm.astro"; + + +- Get started with [PostgreSQL](/docs/get-started-postgresql) +- [Select statement](/docs/select) and [WITH clause](/docs/select#with-clause) +- [Indexes](/docs/indexes-constraints#indexes) +- [sql operator](/docs/sql) +- [Set operations](/docs/set-operations) +- [Generated columns](/docs/generated-columns) +- [PostgreSQL full-text search](/docs/guides/postgresql-full-text-search) +- [Full-text search with Generated Columns](/docs/guides/full-text-search-with-generated-columns) +- [Vector similarity search with pgvector extension](/docs/guides/vector-similarity-search) +- [pgvector extension](/docs/extensions#pg_vector) +- [Drizzle kit](/docs/kit-overview) +- You should have installed the `openai` [package](https://www.npmjs.com/package/openai) for generating embeddings. + + openai + +- You should have `drizzle-orm@0.31.0` and `drizzle-kit@0.22.0` or higher. + + +This guide demonstrates how to implement hybrid search in PostgreSQL with Drizzle ORM. Hybrid search combines multiple retrieval signals — full-text search, fuzzy trigram matching, and semantic vector similarity — and merges their rankings with [reciprocal rank fusion](https://plg.uwaterloo.ca/~gvcormac/cormacksigir09-rrf.pdf) (RRF). + +Each method covers a different failure mode: + +- **Full-text** matches exact keywords and phrases +- **Fuzzy** (`pg_trgm`) handles typos and partial tokens +- **Semantic** (`pgvector`) finds conceptually related documents even when wording differs + +RRF then scores each document by its rank in each result list, so a document that ranks well across multiple signals rises to the top. + +As for now, Drizzle doesn't create extensions automatically, so you need to create them manually. Create an empty migration file and add SQL queries: + +
+```bash +npx drizzle-kit generate --custom +``` + +```sql +CREATE EXTENSION IF NOT EXISTS vector; +CREATE EXTENSION IF NOT EXISTS pg_trgm; +``` +
+ +Create a table with three search columns and matching indexes: a weighted `tsvector` for full-text search, a concatenated text column for trigram similarity, and a vector embedding for semantic search: + + + + ```ts copy {25-39,42-47,50,53-61} + import { type SQL, type SQLChunk, sql } from 'drizzle-orm'; + import { + customType, + index, + pgTable, + serial, + text, + vector, + } from 'drizzle-orm/pg-core'; + + export const tsvector = customType<{ data: string }>({ + dataType() { + return 'tsvector'; + }, + }); + + export const documents = pgTable( + 'documents', + { + id: serial('id').primaryKey(), + title: text('title'), + body: text('body'), + + // Full-text: weighted tsvector over title (A) + body (B) + searchVector: tsvector('search_vector') + .notNull() + .generatedAlwaysAs((): SQL => { + const columnsWithWeights = [ + { column: documents.title, weight: 'A' }, + { column: documents.body, weight: 'B' }, + ]; + + const chunks: SQLChunk[] = columnsWithWeights.map( + ({ column, weight }) => + sql`setweight(to_tsvector('english', coalesce(${column}, '')), '${sql.raw(weight)}')`, + ); + + return sql.join(chunks, sql.raw(' || ')); + }), + + // Fuzzy: concatenated text for pg_trgm word_similarity + searchTrigram: text('search_trigram') + .notNull() + .generatedAlwaysAs((): SQL => { + const columnsToConcat = [documents.title]; + + const chunks: SQLChunk[] = columnsToConcat.map( + (column) => sql`coalesce(${column}, '')`, + ); + + return sql.join(chunks, sql.raw(" || ' ' || ")); + }), + + // Semantic: store the embedding from your model (not generated) + searchEmbedding: vector('search_embedding', { dimensions: 3072 }).notNull(), + }, + (table) => [ + index('documents_search_vector_index').using('gin', table.searchVector), + index('documents_search_trigram_index').using( + 'gin', + table.searchTrigram.op('gin_trgm_ops'), + ), + index('documents_search_embedding_index').using( + 'hnsw', + table.searchEmbedding.op('vector_cosine_ops'), + ), + ], + ); + ``` + + ```sql + CREATE TABLE IF NOT EXISTS "documents" ( + "id" serial PRIMARY KEY NOT NULL, + "title" text, + "body" text, + "search_vector" "tsvector" GENERATED ALWAYS AS (setweight(to_tsvector('english', coalesce("title", '')), 'A') || setweight(to_tsvector('english', coalesce("body", '')), 'B')) STORED NOT NULL, + "search_trigram" text GENERATED ALWAYS AS (coalesce("title", '')) STORED NOT NULL, + "search_embedding" vector(3072) NOT NULL + ); + --> statement-breakpoint + CREATE INDEX IF NOT EXISTS "documents_search_vector_index" ON "documents" USING gin ("search_vector"); + --> statement-breakpoint + CREATE INDEX IF NOT EXISTS "documents_search_trigram_index" ON "documents" USING gin ("search_trigram" gin_trgm_ops); + --> statement-breakpoint + CREATE INDEX IF NOT EXISTS "documents_search_embedding_index" ON "documents" USING hnsw ("search_embedding" vector_cosine_ops); + ``` + + +The `searchVector` and `searchTrigram` columns are generated from `title` and `body`, so they stay in sync automatically. The `searchEmbedding` column is not generated — you write the embedding yourself when inserting or updating rows. + +In this example we will use an `OpenAI` model to generate [embeddings](https://platform.openai.com/docs/guides/embeddings): + +```ts copy +import OpenAI from 'openai'; + +const openai = new OpenAI({ + apiKey: process.env['OPENAI_API_KEY'], +}); + +export const generateEmbedding = async (value: string): Promise => { + const input = value.replaceAll('\n', ' '); + + const { data } = await openai.embeddings.create({ + model: 'text-embedding-3-large', + input, + }); + + return data[0].embedding; +}; +``` + +The hybrid search query runs three retrievals as CTEs, converts each score into a rank with `row_number()`, then fuses those ranks with weighted RRF: + +`score = weight / (smoothing + rank)` + +Documents that appear in multiple lists accumulate score via `sum()`. Tune `topK`, `weight`, and `rrf.smoothing` (commonly `60`) per signal to balance precision and recall. + +
+```ts copy +import { + asc, + cosineDistance, + desc, + eq, + sql, + sum, +} from 'drizzle-orm'; +import { unionAll } from 'drizzle-orm/pg-core'; +import { generateEmbedding } from './embedding'; +import { documents } from './schema'; + +const db = drizzle(...); + +export type HybridSearchParams = { + query: string; + limit: number; + offset: number; + fullText: { + topK: number; + weight: number; + }; + fuzzy: { + topK: number; + weight: number; + }; + semantic: { + topK: number; + weight: number; + }; + rrf: { + smoothing: number; + }; +}; + +export async function hybridSearch({ + query, + limit, + offset, + fullText, + semantic, + fuzzy, + rrf, +}: HybridSearchParams) { + const embedding = await generateEmbedding(query); + + const fullTextQuery = sql`websearch_to_tsquery('english', ${query})`; + + const fullTextMatches = db.$with('full_text_matches').as( + db + .select({ + id: documents.id, + score: sql` + ts_rank_cd(${documents.searchVector}, ${fullTextQuery}, 5) + `.as('score'), + }) + .from(documents) + .where(sql`${documents.searchVector} @@ ${fullTextQuery}`) + .orderBy(desc(sql`score`), asc(documents.id)) + .limit(fullText.topK), + ); + + const fullTextRanking = db.$with('full_text_ranking').as( + db + .select({ + id: fullTextMatches.id, + rank: sql` + row_number() over (order by ${fullTextMatches.score} desc, ${fullTextMatches.id} asc) + `.as('rank'), + }) + .from(fullTextMatches), + ); + + const fuzzyMatches = db.$with('fuzzy_matches').as( + db + .select({ + id: documents.id, + similarity: sql` + word_similarity(${query}, ${documents.searchTrigram}) + `.as('similarity'), + }) + .from(documents) + .where(sql`${documents.searchTrigram} %> ${query}`) + .orderBy(desc(sql`similarity`), asc(documents.id)) + .limit(fuzzy.topK), + ); + + const fuzzyRanking = db.$with('fuzzy_ranking').as( + db + .select({ + id: fuzzyMatches.id, + rank: sql` + row_number() over (order by ${fuzzyMatches.similarity} desc, ${fuzzyMatches.id} asc) + `.as('rank'), + }) + .from(fuzzyMatches), + ); + + const semanticMatches = db.$with('semantic_matches').as( + db + .select({ + id: documents.id, + distance: cosineDistance(documents.searchEmbedding, embedding).as( + 'distance', + ), + }) + .from(documents) + .orderBy(asc(sql`distance`), asc(documents.id)) + .limit(semantic.topK), + ); + + const semanticRanking = db.$with('semantic_ranking').as( + db + .select({ + id: semanticMatches.id, + rank: sql` + row_number() over (order by ${semanticMatches.distance} asc, ${semanticMatches.id} asc) + `.as('rank'), + }) + .from(semanticMatches), + ); + + const combinedResults = db.$with('combined_results').as( + unionAll( + db + .select({ + id: fullTextRanking.id, + score: sql` + ${fullText.weight} / (${rrf.smoothing} + ${fullTextRanking.rank}) + `.as('score'), + }) + .from(fullTextRanking), + db + .select({ + id: fuzzyRanking.id, + score: sql` + ${fuzzy.weight} / (${rrf.smoothing} + ${fuzzyRanking.rank}) + `.as('score'), + }) + .from(fuzzyRanking), + db + .select({ + id: semanticRanking.id, + score: sql` + ${semantic.weight} / (${rrf.smoothing} + ${semanticRanking.rank}) + `.as('score'), + }) + .from(semanticRanking), + ), + ); + + const reciprocalRankFusion = db.$with('reciprocal_rank_fusion').as( + db + .select({ + id: combinedResults.id, + score: sum(combinedResults.score).as('score'), + }) + .from(combinedResults) + .groupBy(combinedResults.id), + ); + + return await db + .with( + fullTextMatches, + fullTextRanking, + fuzzyMatches, + fuzzyRanking, + semanticMatches, + semanticRanking, + combinedResults, + reciprocalRankFusion, + ) + .select() + .from(reciprocalRankFusion) + .innerJoin(documents, eq(documents.id, reciprocalRankFusion.id)) + .orderBy(desc(reciprocalRankFusion.score), asc(documents.id)) + .limit(limit) + .offset(offset); +} +``` + +```ts +const results = await hybridSearch({ + query: 'tips for a family trip', + limit: 10, + offset: 0, + fullText: { topK: 50, weight: 0.4 }, + fuzzy: { topK: 50, weight: 0.2 }, + semantic: { topK: 50, weight: 0.4 }, + rrf: { smoothing: 60 }, +}); +``` +