Search Patterns | Skills PoolSearch Patterns
Search implementation patterns: full-text search with Postgres tsvector, Typesense for production search, Elasticsearch for complex analytics, faceted search, autocomplete, typo tolerance, vector/semantic search, and relevance tuning.
marvinrichter9 スター2026/03/10 Search Patterns Skill
When to Activate
- Adding search to any part of your product
- Users are complaining about poor search results
- Autocomplete / typeahead needed
- Faceted filtering (by category, price, date, etc.)
- Semantic/AI search over your content
- Migrating away from LIKE queries in Postgres
- Choosing between Typesense, Elasticsearch, Algolia, or Postgres full-text for a new use case
- Designing a search index schema or planning a re-index strategy after schema changes
Technology Selection
| Need | Solution | When |
|---|
| Simple search, small dataset (<100k) | Postgres full-text (tsvector) | Already on Postgres |
| Great UX, typo tolerance, fast setup | Typesense | Most product search |
|
| Complex analytics, large scale |
| Elasticsearch / OpenSearch |
| When you need aggregations + scale |
| Semantic / meaning-based search | pgvector or Typesense | RAG, "find similar" |
| E-commerce with merchandising | Algolia | When budget allows |
-- Add tsvector column (auto-updated via trigger)
ALTER TABLE products ADD COLUMN search_vector TSVECTOR;
-- Generate from multiple fields with weights
-- 'A' = highest weight (title), 'B' = medium (tags), 'C' = lowest (description)
UPDATE products
SET search_vector =
setweight(to_tsvector('english', COALESCE(name, '')), 'A') ||
setweight(to_tsvector('english', COALESCE(tags::text, '')), 'B') ||
setweight(to_tsvector('english', COALESCE(description, '')), 'C');
-- Keep updated automatically
CREATE OR REPLACE FUNCTION update_product_search_vector()
RETURNS TRIGGER AS $$
BEGIN
NEW.search_vector :=
setweight(to_tsvector('english', COALESCE(NEW.name, '')), 'A') ||
setweight(to_tsvector('english', COALESCE(NEW.tags::text, '')), 'B') ||
setweight(to_tsvector('english', COALESCE(NEW.description, '')), 'C');
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER products_search_vector_update
BEFORE INSERT OR UPDATE ON products
FOR EACH ROW EXECUTE FUNCTION update_product_search_vector();
-- GIN index for performance
CREATE INDEX idx_products_search ON products USING GIN(search_vector);
-- Query: ranked results
SELECT
id, name, description,
ts_rank_cd(search_vector, query) AS rank
FROM products, plainto_tsquery('english', $1) query
WHERE search_vector @@ query
ORDER BY rank DESC
LIMIT 20;
-- Autocomplete with prefix matching
SELECT name FROM products
WHERE search_vector @@ to_tsquery('english', $1 || ':*') -- prefix match
LIMIT 5;
// TypeScript query helper
async function searchProducts(q: string, limit = 20) {
return db.execute(sql`
SELECT id, name, description,
ts_rank_cd(search_vector, query) AS rank
FROM products, plainto_tsquery('english', ${q}) query
WHERE search_vector @@ query
ORDER BY rank DESC
LIMIT ${limit}
`);
}
Pattern 2: Typesense (recommended for product search)
Typesense: open-source, typo-tolerant, fast, easy to self-host or use Typesense Cloud.
// search/typesense.ts
import Typesense from 'typesense';
const client = new Typesense.Client({
nodes: [{ host: process.env.TYPESENSE_HOST!, port: 443, protocol: 'https' }],
apiKey: process.env.TYPESENSE_API_KEY!,
connectionTimeoutSeconds: 2,
});
// Schema definition
const productSchema = {
name: 'products',
fields: [
{ name: 'id', type: 'string' as const },
{ name: 'name', type: 'string' as const },
{ name: 'description', type: 'string' as const, optional: true },
{ name: 'price', type: 'float' as const },
{ name: 'category', type: 'string' as const, facet: true }, // facetable
{ name: 'tags', type: 'string[]' as const, facet: true },
{ name: 'inStock', type: 'bool' as const, facet: true },
{ name: 'rating', type: 'float' as const },
{ name: 'createdAt', type: 'int64' as const }, // Unix timestamp for sorting
{ name: 'embedding', type: 'float[]' as const, num_dim: 1536, optional: true },
],
default_sorting_field: 'rating',
};
// Index a document
async function indexProduct(product: Product) {
await client.collections('products').documents().upsert({
id: product.id,
name: product.name,
description: product.description,
price: product.price,
category: product.category,
tags: product.tags,
inStock: product.stock > 0,
rating: product.rating,
createdAt: Math.floor(product.createdAt.getTime() / 1000),
});
}
// Search with facets and filters
async function search(params: {
q: string;
category?: string;
minPrice?: number;
maxPrice?: number;
inStock?: boolean;
page?: number;
}) {
const filterParts: string[] = [];
if (params.category) filterParts.push(`category:=${params.category}`);
if (params.inStock !== undefined) filterParts.push(`inStock:=${params.inStock}`);
if (params.minPrice !== undefined) filterParts.push(`price:>=${params.minPrice}`);
if (params.maxPrice !== undefined) filterParts.push(`price:<=${params.maxPrice}`);
return client.collections('products').documents().search({
q: params.q || '*',
query_by: 'name,description,tags',
query_by_weights: '3,1,2', // name most important
filter_by: filterParts.join(' && ') || undefined,
facet_by: 'category,tags,inStock',
sort_by: params.q === '' ? 'rating:desc' : '_text_match:desc,rating:desc',
page: params.page ?? 1,
per_page: 24,
highlight_full_fields: 'name,description',
typo_tokens_threshold: 1, // allow 1 typo
num_typos: 2,
});
}
Pattern 3: Faceted Search UI
// components/SearchPage.tsx
function SearchPage() {
const [q, setQ] = useQueryState('q', parseAsString.withDefault(''));
const [category, setCategory] = useQueryState('category');
const [minPrice, setMinPrice] = useQueryState('minPrice', parseAsFloat);
const [maxPrice, setMaxPrice] = useQueryState('maxPrice', parseAsFloat);
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
const { data } = useQuery({
queryKey: ['search', { q, category, minPrice, maxPrice, page }],
queryFn: () => search({ q, category, minPrice, maxPrice, page }),
placeholderData: keepPreviousData,
});
return (
<div className="grid grid-cols-[240px_1fr] gap-6">
{/* Facet sidebar */}
<aside>
<FacetGroup
title="Category"
facets={data?.facet_counts?.find(f => f.field_name === 'category')?.counts}
selected={category}
onSelect={setCategory}
/>
<PriceRangeFacet
min={minPrice}
max={maxPrice}
onChange={(min, max) => { setMinPrice(min); setMaxPrice(max); }}
/>
</aside>
{/* Results */}
<main>
<SearchInput value={q} onChange={(v) => { setQ(v); setPage(1); }} />
<ResultGrid hits={data?.hits} />
<Pagination
total={data?.found}
page={page}
perPage={24}
onChange={setPage}
/>
</main>
</div>
);
}
Keeping Search Index in Sync
// sync/search-sync.ts — keep Typesense in sync with Postgres
// Option 1: Event-driven (recommended) — sync after every write
export async function onProductSaved(product: Product) {
await indexProduct(product);
}
export async function onProductDeleted(productId: string) {
await client.collections('products').documents(productId).delete();
}
// Option 2: Full re-index (for schema changes or initial setup)
async function reindexAll() {
const batchSize = 100;
let offset = 0;
while (true) {
const products = await db.query.products.findMany({
limit: batchSize,
offset,
orderBy: asc(productsTable.id),
});
if (products.length === 0) break;
// Typesense bulk import
await client.collections('products').documents().import(
products.map(toSearchDocument),
{ action: 'upsert', batch_size: 100 }
);
offset += batchSize;
console.log(`Indexed ${offset} products`);
}
}
Semantic Search (hybrid text + vector)
// Combine BM25 (keyword) + vector (semantic) using Typesense built-in hybrid
async function semanticSearch(q: string) {
const embedding = await embed(q);
return client.collections('products').documents().search({
q,
query_by: 'name,description,embedding',
vector_query: `embedding:([${embedding.join(',')}], k:50)`,
// Typesense automatically combines text + vector with RRF
});
}
Checklist
02
When to Activate