尚未偵測到更新。GitHub 發布版本後會顯示在這裡。
個人 GitHub 帳號下公開儲存庫的擁有者可以登入並驗證所有權,自行管理介紹,無需管理員核准。
登入並產生驗證檔案,在 30 分鐘內將其提交至此儲存庫的預設分支。其他分支或分叉儲存庫中的檔案無效。然後在下方檢查檔案,即可無需管理員核准直接管理專案。請保留檔案以維持管理權限。
如需登入,將前往登入頁面。登入後請回到此處,再次點擊所選的驗證按鈕。
在專案資料夾中執行 /nomorevibe verify,即可驗證所有權並自行管理介紹。
一分鐘完成驗證正在載入完整 README。 以下為已儲存的摘錄。
Natural-language product search for small online stores, running inside the Postgres they already have. No Elasticsearch or Algolia, and no extra servers.
Built on Supabase: pgvector (semantic), pg_trgm + full-text (keyword, typos), pg_cron + an Edge Function running gte-small (embeddings, no API bill).
New here? Most of the logic lives in SQL functions inside Postgres, not in application code. That's on purpose: the search runs next to the data, and the app stays thin. The sections below walk through it in plain language. Start with the big picture, then follow one search.檢視完整 README ↗Natural-language product search for small online stores, running inside the Postgres they already have. No Elasticsearch or Algolia, and no extra servers.
"laal cotton kurti 1.5k se kam for ladies"
→ filters { color: red, gender: women, price ≤ 1500 } (plain SQL)
→ text "cotton kurta" (keyword + vector search)
Built on Supabase: pgvector (semantic), pg_trgm + full-text (keyword, typos), pg_cron + an Edge Function running gte-small (embeddings, no API bill).
New here? Most of the logic lives in SQL functions inside Postgres, not in application code. That's on purpose: the search runs next to the data, and the app stays thin. The sections below walk through it in plain language. Start with the big picture, then follow one search.
There are two flows. One keeps the search index up to date when products change. The other answers a shopper's query.
flowchart LR
subgraph Sync["① Keeping the index fresh (background)"]
direction LR
A[Store edits a product] -->|trigger| B[search.product_search<br/>row rebuilt, marked stale]
B --> C{{pg_cron<br/>every 10s}}
C -->|pg_net HTTP call| D[embed-worker<br/>Edge Function]
D -->|gte-small| E[384-number vector saved]
end
subgraph Query["② Answering a search"]
direction LR
U[Shopper types<br/>'red kurti under 1500'] --> W[web app]
W -->|"1-2 words"| AC["autocomplete()<br/>SQL only, ~10ms"]
W -->|"3+ words or Enter"| S[search<br/>Edge Function]
S --> P["parse_search_query()<br/>color=red, price≤1500"]
S -->|gte-small| V[query vector]
P --> H["search_products()<br/>keyword + typo + vector"]
V --> H
H --> R[ranked products]
end
What runs where:
| Inside Postgres (SQL / PL/pgSQL) | In Edge Functions (Deno / TypeScript) |
|---|---|
| Parsing filters out of the query | Running the gte-small AI model |
| Keyword, typo and vector search, plus ranking | Thin glue: receive the request, call SQL, return JSON |
| Triggers that keep the index in sync | |
| The embedding job queue, scheduling (pg_cron) and HTTP calls (pg_net) |
The Edge Functions exist mainly because the AI model runs in the Deno runtime, not inside Postgres. Everything else is plain Postgres plus two open-source extensions (pgvector, pg_trgm), so it isn't tied to Supabase.
Each method covers a weakness of the others. The search runs all three and merges the results.
| Method | Postgres feature | Stores | Good at | Example |
|---|---|---|---|---|
| Keyword | tsvector (built in) + GIN index | Word stems: 'run':1 'shoe':2 | Exact terms, brands, specific words | "nike", "waterproof" |
| Typo-tolerant | pg_trgm + GIN index | 3-letter chunks of the text | Misspellings, partial words | "sneekers", "kur…" |
| Semantic | vector(384) (pgvector) + HNSW index | 384 numbers that represent meaning | Different wording for the same thing | "shoes for jogging" finds "running sneakers" |
tsvector and vector sound alike but are opposites: tsvector matches words exactly, vector measures how close two texts are in meaning.
Merging the rankings: each method ranks the products. Reciprocal Rank Fusion gives each product 1 / (50 + its rank) for each method that found it, and adds the numbers up. A product found near the top by both keyword and vector search wins.
The query is "laal cotton kurti 1.5k se kam for ladies" (Hinglish for "red cotton kurti under 1.5k for women").
1. The web app picks a path. web/components/SearchBox.tsx: under 3 words, it calls autocomplete() straight in Postgres. Otherwise it calls the search Edge Function through web/lib/supabase.ts.
2. The Edge Function takes the request. supabase/functions/search/index.ts is about 70 lines. It calls search_prepare, which parses the query and looks up a cached embedding.
3. Filters are pulled out with rules, not an LLM. parse_search_query():
{ "text": "cotton kurta",
"filters": { "colors": ["red"], "genders": ["women"], "price_max": 1500 } }
laal → red and kurti → kurta come from a synonym table. 1.5k se kam → price_max: 1500. Brands and colors are matched against the real catalog.
4. The remaining text becomes a vector. _shared/embed.ts: "cotton kurta" goes into gte-small and comes out as 384 numbers. The model runs inside the Supabase Edge Runtime, so there's no API key and no bill. The result is cached for the next person who searches the same thing.
5. The hybrid search runs. search_products():
price <= 1500, color = red, …)6. JSON goes back with the results, the parsed filters and timings for each step.
This is the part people usually find hardest to follow, because nothing in the app code calls it. It's all triggers and a scheduler.
The trick is two fingerprints per product in search.product_search:
| Column | Meaning |
|---|---|
content_hash | md5() of the product's current search text |
embedded_hash | md5() of the text the vector was made from |
When they differ, the vector is stale. There's no separate queue table: "needs work" just means the fingerprints don't match.
Example: update products set description = '…' where id = 345;
products and calls refresh_product(345).build_document() rebuilds the product's search text from 5 tables: name, brand, category, colors, attributes and description.content_hash gets the new fingerprint. embedded_hash still has the old one, so the row is now stale.invoke_embed_worker(). It sees the stale row and calls the embed-worker Edge Function over HTTP (pg_net).embed-worker claims the row with a 2-minute lease, makes the vector, and calls save_embeddings().save_embeddings() saves the vector only if the fingerprint still matches the one the worker started with. If the product was edited again in the meantime, the vector is thrown away and the row is retried with the new text.Changes that update the index: product name, description and gender; variant colors; attributes; brand names (every product of that brand); category names (every product in that category).
Changes that don't: price, stock and rating. They change often and are filtered with plain SQL, so re-embedding would be wasted work. SKUs are only used for keyword search, so they don't affect the vector either.
Watch it happen in any SQL client:
select * from embedding_status(); -- total / embedded / pending
select status, start_time from cron.job_run_details order by runid desc limit 5; -- cron firing
select status_code, content, created from net._http_response order by id desc limit 5; -- worker replies
Why a separate search schema rather than an embedding column on products?
public as a REST API automatically. Keeping vectors, hashes and leases in search keeps them off the API. The only way in is through chosen functions.products don't compete for locks with the embedding worker, and don't rewrite rows carrying a large vector.Why SQL functions rather than application code? Search, filtering and ranking happen next to the data with no back-and-forth between app and database. Triggers keep the index consistent in the same transaction as the product change. There's no separate search service to run and keep in sync.
Why language sql for some functions and plpgsql for others? sql is for single queries, which the planner can inline (7 functions). plpgsql is for anything with variables, branches, loops or triggers (11 functions).
| Order | File | What you'll learn |
|---|---|---|
| 1 | functions/search/index.ts | The whole request flow in about 70 lines of TypeScript |
| 2 | migrations/…04_query_parser.sql | Turning free text into filters with rules and a synonym table |
| 3 | migrations/…05_search_functions.sql | Hybrid search and RRF ranking, and autocomplete |
| 4 | migrations/…03_search_index.sql | The search table, its indexes and the sync triggers |
| 5 | migrations/…06_embedding_worker.sql + functions/embed-worker | Job queue, leases, pg_cron and pg_net |
| — | migrations/…01, …02 | Extensions, and a typical store catalog (unchanged by search) |
| Layer | What | Where |
|---|---|---|
| Search document | Each product and its related tables (brand, category, colors, attributes) are flattened into one text. Price and stock are left out. | search.build_document() |
| Index | search.product_search holds the document, tsvectors, trigram index and the vector(384) embedding. It lives in its own table and schema, so the store's tables aren't modified. | migration …03 |
| Sync | Triggers on products, variants, attributes, brands and categories rebuild the document. If md5(content) changed, the row becomes "dirty". Stock and price updates never trigger re-embedding. | migration …03 |
| Embedding worker | Every 10s pg_cron checks for dirty rows and calls the embed-worker Edge Function. The function claims a batch with a lease, embeds it with gte-small, and saves only if the hash still matches. | migration …06, functions/embed-worker |
| Query parser | Rules, no LLM. It extracts price ("under 1500", "1.5k se kam", "between 1k and 2k"), sizes ("size xl", "uk 8"), brands and colors from the catalog, and genders. A Hinglish synonym table handles terms like laal→red, shaadi→wedding and joote→shoes. | parse_search_query() |
| Autocomplete | Runs on each keystroke. Prefix full-text search plus trigram similarity on names, brands and categories (around 10ms), with no embeddings. | autocomplete() |
| Search | Hybrid Reciprocal Rank Fusion of keyword rank and vector rank, with in-stock and popularity boosts. When filters are present it scans only the matching products exactly; otherwise it uses HNSW. | search_products() |
| API | The search Edge Function parses the query, embeds it (or uses the cached embedding), then runs the search. | functions/search |
The UI calls autocomplete while the shopper types fewer than 3 words, and switches to the search function for longer phrases or on Enter.
supabase/
migrations/ schema, search index, parser, search functions, worker + cron
functions/ search (public API), embed-worker (cron target)
seed.sql generated demo catalog (478 Indian fashion products)
images/products/ one generated SVG illustration per product (Storage bucket seed)
scripts/
generate-seed.mjs regenerate seed.sql and the product images
product-art.mjs draws each product from its category, pattern and colors
backfill.mjs drain the embedding queue quickly after a bulk import
web/ Next.js demo store with the autocomplete dropdown
You need Node 20+ and a Supabase project.
Log in and link the CLI. link asks for your database password:
npx supabase login
npx supabase link --project-ref YOUR-PROJECT-REF
Create the schema and load the demo catalog:
npm run db:push
Then upload the product images to the public product-images Storage bucket:
npx supabase seed buckets --project-ref YOUR-PROJECT-REF
Pick a worker secret (any long random string) and give it to the Edge Functions:
npx supabase secrets set EMBED_WORKER_SECRET=your-long-random-string
Deploy the functions:
npm run functions:deploy
Let pg_cron reach the worker. In the Supabase SQL editor, run:
select vault.create_secret('https://YOUR-PROJECT-REF.supabase.co', 'project_url');
select vault.create_secret('your-long-random-string', 'embed_worker_secret');
Indexing starts within 10 seconds. Check progress with select * from embedding_status();.
To finish faster, copy .env.example to .env, fill it in, and run npm run backfill.
Run the demo store. Copy web/.env.local.example to web/.env.local and add your project URL and anon (publishable) key. Then run:
npm install --prefix web
npm run dev
Open http://localhost:3000.
The whole stack runs locally, including gte-small, pg_cron and Vault:
npx supabase start
Upload the product images to local Storage:
npm run images:upload
Create supabase/functions/.env containing EMBED_WORKER_SECRET=local-test-secret, then run:
npx supabase functions serve --env-file supabase/functions/.env
In the local database, add the Vault secrets. Inside Docker, pg_cron reaches the API through the Kong container:
select vault.create_secret('http://supabase_kong_postgre-ecomm-search:8000', 'project_url');
select vault.create_secret('local-test-secret', 'embed_worker_secret');
Point web/.env.local at http://127.0.0.1:54321 with the local anon key that supabase start prints.
After npx supabase start, connect any SQL client (psql, DBeaver, pgAdmin) to postgresql://postgres:[email protected]:54322/postgres and try these:
select parse_search_query('laal cotton kurti 1.5k se kam for ladies');
-- {"text": "cotton kurta", "filters": {"colors": ["red"], "genders": ["women"], "price_max": 1500}, ...}
select kind, label, sublabel from autocomplete('kur');
-- category | Kurtas | Women
-- category | Kurta Sets | Women
-- product | Sutraa Silk Blend Floral Print Kurta Palazzo Set | Sutraa · Kurta Sets
select name, price from search_products('silk saree', null, '{"price_max": 5000}'); -- keyword-only (no embedding)
-- Rangreza Premium Pure Silk Zari Work Chiffon Saree | 1649.00
select * from embedding_status();
-- total 478 | embedded 478 | pending 0
Or call the full search API (semantic included):
curl "http://127.0.0.1:54321/functions/v1/search?q=red+running+shoes+under+2000"
The first call after a start takes longer while gte-small loads. Later calls take under a second.
search_products keeps vector hits within semantic_window (0.05) of the best match.ts_rank_cd rewards documents that match more terms.SKU-1234, ab12) skip vector search entirely.search.synonyms.<script> widget for any storefront