| name | search-patterns |
| description | 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. |
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 |
Pattern 1: Postgres Full-Text Search (no extra infra)
ALTER TABLE products ADD COLUMN search_vector TSVECTOR;
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');
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();
CREATE INDEX idx_products_search ON products USING GIN(search_vector);
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;
SELECT name FROM products
WHERE search_vector @@ to_tsquery('english', $1 || ':*')
LIMIT 5;
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.
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,
});
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', : , : },
{ : , : , : },
{ : , : , : },
{ : , : },
{ : , : },
{ : , : , : , : },
],
: ,
};
() {
client.().().({
: product.,
: product.,
: product.,
: product.,
: product.,
: product.,
: product. > ,
: product.,
: .(product..() / ),
});
}
() {
: [] = [];
(params.) filterParts.();
(params. !== ) filterParts.();
(params. !== ) filterParts.();
(params. !== ) filterParts.();
client.().().({
: params. || ,
: ,
: ,
: filterParts.() || ,
: ,
: params. === ? : ,
: params. ?? ,
: ,
: ,
: ,
: ,
});
}
Pattern 3: Faceted Search UI
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
=
=
= ) => { setMinPrice(min); setMaxPrice(max); }}
/>
{/* Results */}
{ setQ(v); setPage(1); }} />
);
}
Keeping Search Index in Sync
export async function onProductSaved(product: Product) {
await indexProduct(product);
}
export async function onProductDeleted(productId: string) {
await client.collections('products').documents(productId).delete();
}
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;
await client.collections().().(
products.(toSearchDocument),
{ : , : }
);
offset += batchSize;
.();
}
}
Semantic Search (hybrid text + vector)
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)`,
});
}
Checklist