Skip to main content 首页 创作者 inugamidev ultrathink-oss 搜索
search Full-text search implementation with Algolia, MeiliSearch, PostgreSQL pg_trgm, search UX patterns, autocomplete, and relevance tuning
跳到安装 Skills Marketplace 发现并探索由社区构建的 Agent Skills
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
直接命令不会经过审查 Prompt;运行前请先检查来源。
npx skills add https://github.com/InugamiDev/ultrathink-oss --skill search命令会保持在同一行。复制前请横向滚动并检查完整内容。
想先保存到本地?可下载 SkillsMP 当前能够提供的文件。
下载 Zip 下载中... 同仓库更多 Skills Unified design foundations — design system architecture, tokens, component specs, visual principles, creative vision, figma integration, plus brand design system loader (66 real brands via DESIGN.md). Absorbs design, design-system, design-systems, design-principles, design-router, creative-vision, figma, design-md.
Render, summarize, and present markdown documents and structured content in multiple output modes
Ultra UI skill - combines Google's DESIGN.md spec (machine-readable design tokens) with the ui-ux-pro-max knowledge base (91 styles, 161 palettes, 73 font pairings, 161 products, 104 UX guidelines, 25 chart types). Generates lint-clean DESIGN.md files, validates token references and WCAG contrast, exports Tailwind/DTCG tokens, and diffs design systems version-over-version.
name search description Full-text search implementation with Algolia, MeiliSearch, PostgreSQL pg_trgm, search UX patterns, autocomplete, and relevance tuning layer domain category backend triggers ["full-text search","search implementation","algolia","meilisearch","pg_trgm","search autocomplete","fuzzy search","search relevance","typeahead","search index"] inputs ["Data corpus size and type","Search requirements (fuzzy, faceted, geo, typo-tolerant)","Latency budget","Infrastructure constraints","UX requirements (autocomplete, filters, highlighting)"] outputs ["Search architecture recommendation","Index configuration and schema","Query implementation with ranking","Autocomplete/typeahead component","Sync strategy between DB and search index"] linksTo ["postgresql","api-designer","react","performance-profiler","caching"] linkedFrom ["ecommerce","ui-ux-pro"] preferredNextSkills ["caching","api-designer"] fallbackSkills ["postgresql","redis"] riskLevel low memoryReadPolicy selective memoryWritePolicy selective sideEffects ["May add search service dependencies (algoliasearch, meilisearch)","May require background sync jobs","May add PostgreSQL extensions (pg_trgm, unaccent)"]
Search Domain Skill
Purpose
Search is a core UX feature that users judge harshly -- slow or irrelevant results destroy trust. This skill covers choosing the right search backend, building indexes, tuning relevance, and delivering the fast autocomplete-driven UX that users expect.
When to Use What
Solution Best For Latency Setup Cost PostgreSQL pg_trgm < 1M rows, simple fuzzy search 10-100ms None (extension) Free PostgreSQL FTS < 5M rows, structured search 5-50ms Extension + config Free MeiliSearch < 10M docs, self-hosted, great defaults 1-20ms Docker container Free (self-hosted) Algolia Any scale, zero-ops, instant UX 1-10ms SaaS $$$ (per search) Typesense Self-hosted alternative to Algolia 1-15ms Docker container Free (self-hosted) Elasticsearch > 100M docs, complex aggregations, logs 5-50ms Cluster management $$$ (infra)
Default recommendation : PostgreSQL pg_trgm for MVPs. MeiliSearch for growing apps. Algolia for search-critical products with budget.
Key Concepts
Search Relevance Fundamentals
TF-IDF (Term Frequency - Inverse Document Frequency):
- Words that appear often in a document but rarely across all documents score higher
- "the" appears everywhere -> low signal. "kubernetes" appears rarely -> high signal.
BM25 (Best Match 25):
- Improved TF-IDF used by PostgreSQL FTS, MeiliSearch, Elasticsearch
- Accounts for document length normalization
- Tunable parameters: k1 (term frequency saturation), b (length normalization)
Ranking factors to combine:
1. Text relevance (BM25/TF-IDF)
2. Recency (newer content ranked higher)
3. Popularity (views, sales, ratings)
4. Exact match boost (title match > body match)
5. Personalization (user history, preferences)
Patterns
1. PostgreSQL pg_trgm (Zero Infrastructure)
EXTENSION IF pg_trgm;
EXTENSION IF unaccent;
INDEX idx_products_name_trgm products
gin (name gin_trgm_ops);
INDEX idx_products_description_trgm products
gin (description gin_trgm_ops);
id,
name,
description,
similarity(name, ) name_score,
similarity(description, ) desc_score,
(similarity(name, ) similarity(description, )) combined_score
products
name
description
combined_score
LIMIT ;
pg_trgm.similarity_threshold ;
id, name
products
name ILIKE
name
LIMIT ;
CREATE
NOT
EXISTS
CREATE
NOT
EXISTS
CREATE
ON
USING
CREATE
ON
USING
SELECT
'wireles mouse'
AS
'wireles mouse'
AS
'wireles mouse'
*
2
+
'wireles mouse'
AS
FROM
WHERE
%
'wireles mouse'
OR
%
'wireles mouse'
ORDER
BY
DESC
20
SET
=
0.2
SELECT
FROM
WHERE
'wire%'
ORDER
BY
10
import { sql, desc } from 'drizzle-orm' ;
import { products } from './schema' ;
async function searchProducts (query : string , limit = 20 ) {
const results = await db
.select ({
id : products.id ,
name : products.name ,
price : products.price ,
score : sql<number >`
similarity(${products.name} , ${query} ) * 2 +
similarity(${products.description} , ${query} )
` .as ('score' ),
})
.from (products)
.where (sql`
${products.name} % ${query}
OR ${products.description} % ${query}
` )
.orderBy (desc (sql`score` ))
.limit (limit);
return results;
}
2. PostgreSQL Full-Text Search (Built-in FTS)
ALTER TABLE articles ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('english' , coalesce (title, '' )), 'A' ) ||
setweight(to_tsvector('english' , coalesce (subtitle, '' )), 'B' ) ||
setweight(to_tsvector('english' , coalesce (body, '' )), 'C' ) ||
setweight(to_tsvector('english' , coalesce (tags_text, '' )), 'B' )
) STORED;
CREATE INDEX idx_articles_search ON articles USING gin(search_vector);
SELECT
id,
title,
ts_rank_cd(search_vector, query) AS rank,
ts_headline('english' , body, query,
'StartSel=<mark>, StopSel=</mark>, MaxFragments=2, MaxWords=30'
) AS snippet
FROM articles,
websearch_to_tsquery('english' , 'typescript database migration' ) AS query
WHERE search_vector @@ query
ORDER BY rank DESC
LIMIT 20 ;
SELECT * FROM articles
WHERE search_vector @@ phraseto_tsquery('english' , 'react server components' );
SELECT * FROM articles
WHERE search_vector @@ to_tsquery('english' , 'react:* & server:*' );
async function fullTextSearch (query : string , options : {
limit?: number ;
offset?: number ;
category?: string ;
} = {} ) {
const { limit = 20 , offset = 0 , category } = options;
const results = await db.execute (sql`
SELECT
a.id,
a.title,
a.slug,
a.published_at,
ts_rank_cd(a.search_vector, query) AS rank,
ts_headline('english', a.body, query,
'StartSel=<mark>, StopSel=</mark>, MaxFragments=2, MaxWords=30'
) AS snippet
FROM articles a,
websearch_to_tsquery('english', ${query} ) AS query
WHERE a.search_vector @@ query
${category ? sql`AND a.category = ${category} ` : sql`` }
AND a.published_at IS NOT NULL
ORDER BY rank DESC
LIMIT ${limit}
OFFSET ${offset}
` );
return results.rows ;
}
3. MeiliSearch (Self-Hosted, Instant Search)
docker run -d --name meilisearch \
-p 7700:7700 \
-e MEILI_MASTER_KEY='your-master-key' \
-v meili_data:/meili_data \
getmeili/meilisearch:latest
import { MeiliSearch } from 'meilisearch' ;
const client = new MeiliSearch ({
host : process.env .MEILISEARCH_URL ?? 'http://localhost:7700' ,
apiKey : process.env .MEILISEARCH_MASTER_KEY ,
});
async function setupSearchIndex ( ) {
const index = client.index ('products' );
await index.updateSettings ({
searchableAttributes : ['name' , 'description' , 'brand' , 'tags' ],
displayedAttributes : ['id' , 'name' , 'description' , 'price' , 'image_url' , 'brand' ],
filterableAttributes : ['category' , 'brand' , 'price' , 'in_stock' , 'rating' ],
sortableAttributes : ['price' , 'rating' , 'created_at' ],
typoTolerance : {
enabled : true ,
minWordSizeForTypos : { oneTypo : 4 , twoTypos : 8 },
},
rankingRules : [
'words' ,
'typo' ,
'proximity' ,
'attribute' ,
'sort' ,
'exactness' ,
'rating:desc' ,
],
synonyms : {
phone : ['smartphone' , 'mobile' , 'cell phone' ],
laptop : ['notebook' , 'computer' ],
tv : ['television' , 'monitor' , 'screen' ],
},
});
}
async function indexProducts (products : Product [] ) {
const index = client.index ('products' );
const task = await index.addDocuments (products, { primaryKey : 'id' });
await client.waitForTask (task.taskUid );
}
async function searchProducts (query : string , options : {
category?: string ;
minPrice?: number ;
maxPrice?: number ;
sort?: 'price:asc' | 'price:desc' | 'rating:desc' ;
page?: number ;
hitsPerPage?: number ;
} = {} ) {
const index = client.index ('products' );
const filters : string [] = [];
if (options.category ) filters.push (`category = "${options.category} "` );
if (options.minPrice ) filters.push (`price >= ${options.minPrice} ` );
if (options.maxPrice ) filters.push (`price <= ${options.maxPrice} ` );
filters.push ('in_stock = true' );
const results = await index.search (query, {
filter : filters.join (' AND ' ),
sort : options.sort ? [options.sort ] : undefined ,
page : options.page ?? 1 ,
hitsPerPage : options.hitsPerPage ?? 20 ,
attributesToHighlight : ['name' , 'description' ],
highlightPreTag : '<mark>' ,
highlightPostTag : '</mark>' ,
attributesToCrop : ['description' ],
cropLength : 30 ,
showMatchesPosition : true ,
});
return {
hits : results.hits ,
totalHits : results.totalHits ,
page : results.page ,
totalPages : results.totalPages ,
processingTimeMs : results.processingTimeMs ,
facets : results.facetDistribution ,
};
}
4. Algolia (Managed, Highest Quality)
import algoliasearch from 'algoliasearch' ;
const client = algoliasearch (
process.env .ALGOLIA_APP_ID !,
process.env .ALGOLIA_ADMIN_KEY !
);
const index = client.initIndex ('products' );
async function configureIndex ( ) {
await index.setSettings ({
searchableAttributes : [
'name' ,
'brand' ,
'description' ,
'tags' ,
],
attributesForFaceting : [
'searchable(brand)' ,
'filterOnly(in_stock)' ,
'category' ,
'price_range' ,
],
customRanking : ['desc(rating)' , 'desc(sales_count)' ],
distinct : 1 ,
attributeForDistinct : 'product_group_id' ,
typoTolerance : true ,
queryLanguages : ['en' ],
removeStopWords : true ,
ignorePlurals : true ,
hitsPerPage : 20 ,
});
}
async function syncProducts (products : Product [] ) {
const objects = products.map ((p ) => ({
objectID : p.id ,
name : p.name ,
description : p.description ,
brand : p.brand ,
category : p.category ,
price : p.price ,
price_range : getPriceRange (p.price ),
rating : p.rating ,
sales_count : p.salesCount ,
in_stock : p.stock > 0 ,
image_url : p.imageUrl ,
tags : p.tags ,
}));
await index.partialUpdateObjects (objects, { createIfNotExists : true });
}
const searchClient = algoliasearch (
process.env .NEXT_PUBLIC_ALGOLIA_APP_ID !,
process.env .NEXT_PUBLIC_ALGOLIA_SEARCH_KEY !
);
5. Search UX: Autocomplete Component
'use client' ;
import { useState, useRef, useEffect, useCallback } from 'react' ;
interface SearchResult {
id : string ;
title : string ;
subtitle ?: string ;
url : string ;
highlight ?: string ;
}
interface SearchBarProps {
onSearch : (query : string ) => Promise <SearchResult []>;
placeholder ?: string ;
debounceMs ?: number ;
}
export function SearchBar ({
onSearch,
placeholder = 'Search...' ,
debounceMs = 200 ,
}: SearchBarProps ) {
const [query, setQuery] = useState ('' );
const [results, setResults] = useState<SearchResult []>([]);
const [isOpen, setIsOpen] = useState (false );
const [activeIndex, setActiveIndex] = useState (-1 );
const [isLoading, setIsLoading] = useState (false );
const inputRef = useRef<HTMLInputElement >(null );
const listRef = useRef<HTMLUListElement >(null );
const abortRef = useRef<AbortController | null >(null );
useEffect (() => {
if (query.length < 2 ) {
setResults ([]);
setIsOpen (false );
return ;
}
const timer = setTimeout (async () => {
abortRef.current ?.abort ();
abortRef.current = new AbortController ();
setIsLoading (true );
try {
const hits = await onSearch (query);
setResults (hits);
setIsOpen (hits.length > 0 );
setActiveIndex (-1 );
} catch {
} finally {
setIsLoading (false );
}
}, debounceMs);
return () => clearTimeout (timer);
}, [query, onSearch, debounceMs]);
const handleKeyDown = useCallback (
(e : React .KeyboardEvent ) => {
switch (e.key ) {
case 'ArrowDown' :
e.preventDefault ();
setActiveIndex ((i ) => Math .min (i + 1 , results.length - 1 ));
break ;
case 'ArrowUp' :
e.preventDefault ();
setActiveIndex ((i ) => Math .max (i - 1 , -1 ));
break ;
case 'Enter' :
e.preventDefault ();
if (activeIndex >= 0 && results[activeIndex]) {
window .location .href = results[activeIndex].url ;
}
break ;
case 'Escape' :
setIsOpen (false );
inputRef.current ?.blur ();
break ;
}
},
[results, activeIndex]
);
useEffect (() => {
const handler = (e : MouseEvent ) => {
if (!listRef.current ?.contains (e.target as Node ) &&
!inputRef.current ?.contains (e.target as Node )) {
setIsOpen (false );
}
};
document .addEventListener ('mousedown' , handler);
return () => document .removeEventListener ('mousedown' , handler);
}, []);
return (
<div className ="relative w-full max-w-xl" role ="combobox" aria-expanded ={isOpen} >
<div className ="relative" >
<svg
className ="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400"
fill ="none" viewBox ="0 0 24 24" stroke ="currentColor"
>
<path strokeLinecap ="round" strokeLinejoin ="round" strokeWidth ={2}
d ="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg >
<input
ref ={inputRef}
type ="search"
value ={query}
onChange ={(e) => setQuery(e.target.value)}
onFocus={() => results.length > 0 && setIsOpen(true)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className="w-full px-4 py-3 pl-12 rounded-lg border border-gray-200
text-base bg-white shadow-sm
transition-all duration-200
focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2
focus-visible:outline-none
motion-reduce:transition-none"
role="searchbox"
aria-autocomplete="list"
aria-controls="search-results"
aria-activedescendant={activeIndex >= 0 ? `result-${activeIndex}` : undefined}
/>
{isLoading && (
)}
{isOpen && (
{results.map((result, i) => (
0 ? 'border-t border-gray-100' : ''}`}
onMouseEnter={() => setActiveIndex(i)}
onClick={() => { window.location.href = result.url; }}
>
{result.subtitle && (
{result.subtitle}
)}
))}
)}
);
}
6. Database-to-Search Sync
import { db } from '@/db' ;
import { products } from '@/db/schema' ;
import { gt, sql } from 'drizzle-orm' ;
const BATCH_SIZE = 500 ;
async function incrementalSync (searchIndex : SearchIndex , since : Date ) {
let offset = 0 ;
let synced = 0 ;
while (true ) {
const batch = await db
.select ()
.from (products)
.where (gt (products.updatedAt , since))
.orderBy (products.updatedAt )
.limit (BATCH_SIZE )
.offset (offset);
if (batch.length === 0 ) break ;
await searchIndex.addDocuments (
batch.map (transformForSearch),
{ primaryKey : 'id' }
);
synced += batch.length ;
offset += BATCH_SIZE ;
if (batch.length === BATCH_SIZE ) {
await new Promise ((r ) => setTimeout (r, 100 ));
}
}
return synced;
}
async function fullReindex (searchIndex : SearchIndex ) {
const tempIndex = client.index ('products_temp' );
await tempIndex.updateSettings ({ });
let offset = 0 ;
while (true ) {
const batch = await db
.select ()
.from (products)
.limit (BATCH_SIZE )
.offset (offset);
if (batch.length === 0 ) break ;
await tempIndex.addDocuments (batch.map (transformForSearch));
offset += BATCH_SIZE ;
}
await client.swapIndexes ([{ indexes : ['products' , 'products_temp' ] }]);
await client.index ('products_temp' ).delete ();
}
function transformForSearch (product : typeof products.$inferSelect ) {
return {
id : product.id ,
name : product.name ,
description : product.description ?.slice (0 , 500 ),
brand : product.brand ,
category : product.category ,
price : product.price ,
rating : product.rating ,
in_stock : product.stock > 0 ,
tags : product.tags ,
};
}
Best Practices
Start with PostgreSQL -- pg_trgm and FTS handle most use cases under 1M rows with zero extra infra
Debounce autocomplete -- 150-250ms delay prevents hammering your search backend
Return results in < 100ms -- users perceive > 200ms as slow for search
Show results as-you-type -- do not require pressing Enter; update on every keystroke (debounced)
Highlight matched terms -- users need to see why a result matched
Include faceted filtering -- category, price range, brand filters alongside search
Handle zero results gracefully -- suggest corrections, related terms, or popular items
Log search queries -- the single most valuable analytics for product improvement
Use synonyms and stop words -- "tv" should match "television"; "the" should be ignored
Separate search API key from admin key -- Algolia/MeiliSearch search keys are safe for client-side
Common Pitfalls Pitfall Impact Fix No debounce on keystroke search API hammered, rate limits hit Debounce 150-250ms, abort previous requests Syncing search index synchronously Slow writes, failed search updates block UX Use background jobs, event-driven sync Not tokenizing properly "don't" fails to match "dont" Configure tokenizer, use unaccent extension Returning entire documents Slow network, wasted bandwidth Return only needed fields (id, title, snippet) Case-sensitive search "React" does not match "react" Normalize to lowercase in index and query No relevance tuning Title matches ranked same as body matches Weight searchable attributes (title > body) Single search field for everything Users cannot filter results Add faceted filters (category, date, type) Stale search index Search returns deleted/outdated content Webhook-based sync or frequent incremental sync
<div className ="absolute right-4 top-1/2 -translate-y-1/2" >
<div className ="w-4 h-4 border-2 border-gray-300 border-t-blue-500 rounded-full animate-spin" />
</div >
</div >
<ul
ref ={listRef}
id ="search-results"
role ="listbox"
className ="absolute z-50 w-full mt-2 bg-white rounded-xl border border-gray-200
shadow-lg overflow-hidden max-h-96 overflow-y-auto"
>
<li
key ={result.id}
id ={ `result- ${i }`}
role ="option"
aria-selected ={i === activeIndex}
className ={ `px-4 py-3 cursor-pointer transition-colors duration-150
${i === activeIndex ? 'bg-blue-50 ' : 'hover:bg-gray-50 '}
${i >
<div className ="text-base font-medium text-gray-900"
dangerouslySetInnerHTML ={{ __html: result.highlight ?? result.title }}
/>
<div className ="text-sm text-gray-500 mt-0.5" >
</div >
</li >
</ul >
</div >