Progress belajar
Modul 70 dari 73
0% 0/73 modul selesai
Setelah selesai, tandai modul ini agar progres kursus tetap rapi.
Progress disimpan lokal di browser ini.
Optimasi Pencarian Produk
dengan PostgreSQL
Pencarian produk yang baik bukan hanya cepat, tetapi juga relevan saat customer mengetik nama bahan, brand, atau masalah kulit.
Kenapa Search Perlu Dioptimasi?
Dari LIKE sederhana menuju search yang bisa dipakai customer sungguhan
Di katalog skincare, customer jarang mencari dengan satu pola rapi seperti name = 'Sunscreen SPF 50'.
Mereka bisa mengetik sunscreen oily skin, niacinamide serum, wardah acne, atau moisturizer ceramide. Kalau API hanya memakai ILIKE '%keyword%', database harus banyak membaca teks mentah dan relevansi hasil sulit diurutkan.
Di Roadmap 5 kita sudah membangun fondasi discovery (filter, sort, ILIKE, pagination) untuk katalog kecil. Modul ini adalah lapis berikutnya: bagaimana membuat pencarian tetap cepat dan relevan saat katalog tumbuh, keyword makin bebas, dan tim mulai berdebat apakah perlu search engine terpisah.
Di Laravel atau SQL biasa, LIKE mirip mencari substring. PostgreSQL full-text search memecah teks menjadi token yang dapat dicari, diindeks, dan diberi ranking, mirip dengan cara Laravel Scout mengirim dokumen ke engine eksternal, hanya saja semua tetap di dalam PostgreSQL.
- Cocok untuk pencarian substring sederhana.
- Sulit memberi ranking berbasis relevansi.
%keyword%tidak bisa dipercepat indeks B-tree biasa.
- Cocok untuk dokumen teks seperti nama, brand, dan deskripsi produk.
- Mendukung operator query, stemming sesuai konfigurasi bahasa, dan ranking relevansi.
- Dapat dipercepat dengan GIN index pada
tsvector.
Di modul ini kita tetap memakai PostgreSQL, bukan langsung menambah layanan baru. Prinsip scaling yang sehat adalah mengoptimalkan tempat yang tepat lebih dulu, lalu baru menambah komponen seperti pg_trgm atau OpenSearch saat kebutuhan sudah jelas.
PostgreSQL Search Basics: LIKE, B-tree, pg_trgm
Kenapa ILIKE '%kw%' lambat, dan jembatan native sebelum lompat ke FTS
Sebelum full-text search, pahami dulu kenapa pendekatan paling polos justru jadi mahal saat katalog membesar.
Bentuk paling sederhana adalah LIKE (case-sensitive) dan ILIKE (case-insensitive). Untuk pencocokan prefix seperti name ILIKE 'sunscreen%', indeks B-tree masih bisa menolong karena pola ter-anchor di kiri. Tetapi customer hampir selalu mencari substring di tengah: '%sunscreen%'.
Indeks B-tree mengurutkan nilai dari karakter pertama. Pola '%kw%' tidak ter-anchor di kiri, jadi PostgreSQL tidak tahu dari mana mulai menelusuri pohon dan jatuh ke Seq Scan: baca semua baris, cocokkan satu per satu. Makin besar katalog, makin lambat.
sql/like-vs-prefix.sql-- Bisa pakai B-tree (left-anchored prefix) SELECT id, name FROM products WHERE name ILIKE 'sunscreen%'; -- TIDAK bisa pakai B-tree biasa -> Seq Scan SELECT id, name FROM products WHERE name ILIKE '%sunscreen%';
Di sinilah ekstensi pg_trgm masuk sebagai jembatan native PostgreSQL. pg_trgm memecah teks menjadi trigram (potongan tiga karakter) dan menyediakan indeks GIN gin_trgm_ops yang bisa mempercepat ILIKE '%kw%' tanpa perlu anchor kiri, plus fungsi similarity() dan word_similarity() untuk toleransi typo ringan.
migrations/202606060901_enable_pg_trgm.sqlCREATE EXTENSION IF NOT EXISTS pg_trgm; -- Mempercepat ILIKE '%kw%' dan regex, tanpa harus anchor kiri CREATE INDEX idx_products_name_trgm ON products USING GIN (name gin_trgm_ops);
sql/trgm-fuzzy.sql-- Operator '%' : threshold similarity default 0.3 (pg_trgm.similarity_threshold) SELECT name, similarity(name, 'niasinamide') AS score FROM products WHERE name % 'niasinamide' -- typo 'niasinamide' tetap menemukan 'niacinamide' ORDER BY score DESC LIMIT 5;
pg_trgm memberi fuzzy match dan autocomplete ringan tanpa keluar dari PostgreSQL. Ini sering jadi langkah-antara yang cukup, sebelum benar-benar perlu OpenSearch. Kita bahas posisinya di Section 12.
Di Laravel, where('name', 'LIKE', "%$kw%") setara ILIKE di sini, dan whereFullText() memetakan ke MySQL FULLTEXT. PostgreSQL FTS berbeda: MySQL FULLTEXT memakai indeks bawaan engine, sedangkan di PostgreSQL kita eksplisit membangun tsvector + GIN sendiri, sehingga lebih bisa dikontrol bobot dan konfigurasinya.
Jadi tiga lapis native PostgreSQL: LIKE/ILIKE untuk substring sederhana, pg_trgm untuk fuzzy dan '%kw%' cepat, dan full-text search untuk pencarian dokumen multi-kolom dengan ranking. Sisa modul fokus pada lapis ketiga, karena itu yang paling kuat untuk pencarian produk.
Mental Model Full-Text Search
Dokumen, query, match, ranking
PostgreSQL full-text search bekerja dengan dua tipe utama, tsvector untuk dokumen dan tsquery untuk pertanyaan pencarian.
Representasi dokumen yang sudah dinormalisasi menjadi lexeme. Untuk produk skincare, dokumennya gabungan nama produk, brand, dan deskripsi.
Representasi query pencarian. Contoh terkontrol: sunscreen & spf, artinya hasil harus mengandung lexeme sunscreen dan spf.
Operator match PostgreSQL yang mengevaluasi apakah tsvector memenuhi tsquery.
Fungsi ranking yang memberi skor relevansi float4 berdasarkan frekuensi dan bobot lexeme yang cocok. Ada juga ts_rank_cd yang memperhitungkan kedekatan antar-lexeme.
Generalized Inverted Index, tipe indeks PostgreSQL yang cocok untuk mencari elemen di dalam nilai komposit seperti daftar lexeme pada tsvector.
flowchart LR Input["Customer mengetik keyword"] --> Query["tsquery"] Product["name + brand + description"] --> Vector["tsvector"] Vector --> Index["GIN index"] Query --> Match["operator @@"] Index --> Match Match --> Rank["ts_rank"] Rank --> Result["produk relevan"]
Gambar 1. Full-text search mengubah teks produk dan input pencarian menjadi bentuk yang bisa dicocokkan dan diurutkan.
Pernah pakai Algolia atau Elasticsearch di proyek JS/PHP? Petakan saja istilahnya: inverted index → GIN, analyzer/tokenizer → text search configuration (simple/english), dan BM25 → ts_rank (tetapi ts_rank tidak memakai statistik frekuensi lintas-korpus seperti BM25, jadi kualitas relevansinya lebih sederhana).
Konsep tsvector, tsquery, operator @@, ranking, dan indeks untuk full-text search dijelaskan di dokumentasi PostgreSQL Chapter 12: Full Text Search.
Membangun tsvector dari Banyak Kolom
Nama produk lebih penting dari deskripsi panjang
Untuk katalog skincare, pencarian biasanya harus membaca lebih dari satu kolom.
Kita akan membuat kolom search_vector dari name, brand, dan description. Nama produk diberi bobot lebih tinggi karena customer yang mencari sunscreen biasanya ingin produk yang namanya mengandung sunscreen muncul di atas deskripsi yang hanya menyebut kata itu sekilas.
Contoh ini memakai konfigurasi simple: kamus simple melipat token ke lowercase tetapi TIDAK membuang stop word dan TIDAK melakukan stemming, jadi niacinamide atau COSRX tetap utuh. Trade-off-nya, simple tidak menyatukan jamak/derivasi (serums bukan serum). Konfigurasi english menerapkan stemmer Snowball plus stop words, lebih bagus untuk kalimat Inggris penuh tetapi berisiko over-stemming nama brand atau bahan aktif.
migrations/202606060903_add_product_search.sqlALTER TABLE products ADD COLUMN search_vector tsvector GENERATED ALWAYS AS ( setweight(to_tsvector('simple', coalesce(name, '')), 'A') || setweight(to_tsvector('simple', coalesce(brand, '')), 'B') || setweight(to_tsvector('simple', coalesce(description, '')), 'C') ) STORED; CREATE INDEX idx_products_search_vector ON products USING GIN (search_vector);
Kolom generated membuat aplikasi Go tidak perlu mengingat kapan harus mengisi ulang search_vector. Setiap kali name, brand, atau description berubah, PostgreSQL menghitung ulang nilai tersebut secara otomatis.
Dulu cara klasik adalah tsvector_update_trigger. Dokumentasi PostgreSQL kini menyatakan metode trigger itu sudah usang (obsoleted) dan menyarankan stored generated column seperti di atas. Generated column lebih ringkas, deklaratif, dan tidak bisa lupa di-fire saat ada jalur update baru.
CREATE TRIGGER ... tsvector_update_trigger(...).- Harus didaftarkan dan dijaga manual.
- Rentan lupa saat tabel berubah skema.
- Definisi menyatu dengan kolom, satu sumber kebenaran.
- PostgreSQL menjaga
search_vectorselalu sinkron. - Direkomendasikan dokumentasi resmi.
Untuk tabel besar, CREATE INDEX CONCURRENTLY lebih aman karena mengurangi blocking write, tetapi tidak boleh dijalankan di dalam transaction migration yang sama.
migrations/202606060904_add_product_search_index_concurrent.sqlCREATE INDEX CONCURRENTLY IF NOT EXISTS idx_products_search_vector ON products USING GIN (search_vector);
Bila katalog memuat istilah beraksen (mis. nama Prancis), ekstensi unaccent bisa dirangkai ke to_tsvector agar crème cocok dengan creme. Tambahkan hanya jika benar dibutuhkan, karena menambah satu lapis konfigurasi.
GIN Index untuk Search Vector
Agar search tidak scan seluruh katalog
GIN index membuat PostgreSQL bisa menemukan produk yang mengandung lexeme tertentu tanpa membaca semua baris satu per satu.
GIN bekerja seperti inverted index: lexeme menunjuk ke daftar row yang memilikinya. Ini mirip struktur internal search engine, tetapi tetap berada di PostgreSQL.
sql/explain-search.sqlEXPLAIN (ANALYZE, BUFFERS) SELECT id, name, brand FROM products WHERE search_vector @@ websearch_to_tsquery('simple', 'sunscreen spf') ORDER BY ts_rank(search_vector, websearch_to_tsquery('simple', 'sunscreen spf')) DESC LIMIT 20;
Saat indeks efektif, plan biasanya menunjukkan Bitmap Index Scan pada indeks GIN, lalu Bitmap Heap Scan untuk mengambil row produk. Tetapi ada satu hal penting: GIN hanya mempercepat tahap match @@, bukan tahap ORDER BY rank.
flowchart LR Q["websearch_to_tsquery"] --> GIN["GIN Bitmap Index Scan<br/>(@@ + filter)"] GIN --> Cand["kandidat baris<br/>(sudah disempitkan)"] Cand --> RankCalc["ts_rank dihitung<br/>per kandidat (I/O bound)"] RankCalc --> Sort["ORDER BY rank DESC<br/>(sort di heap, bukan index)"] Sort --> Limit["LIMIT N"]
Gambar 2. Pipeline dua fase: GIN menyaring kandidat, lalu ranking dan sorting dihitung hanya pada kandidat itu. GIN tidak mempercepat sorting.
Dokumentasi PostgreSQL memperingatkan bahwa ranking bisa mahal karena harus membaca tsvector tiap dokumen yang cocok, sehingga cenderung I/O-bound dan lambat. Itulah alasan kuat untuk membatasi kandidat lebih dulu dengan filter dan @@, baru menghitung ts_rank pada sisa yang kecil, lalu LIMIT.
Search match
search_vector @@ query cocok untuk GIN index.
Ranking
ts_rank membantu urutan relevansi, tetapi bukan indeks urutan dan dihitung di heap.
Filter domain
Kategori, status aktif, dan rentang harga tetap butuh indeks B-tree yang sesuai.
Query Search, Ranking, dan Highlight
to_tsquery untuk query terkontrol, websearch_to_tsquery untuk input customer
to_tsquery powerful, tetapi input customer bebas lebih aman diproses dengan websearch_to_tsquery.
to_tsquery menerima sintaks khusus seperti &, |, !, dan operator phrase. Ini bagus untuk query yang dibangun aplikasi atau admin, tetapi melempar error jika string dari customer tidak valid. Untuk search box publik, websearch_to_tsquery lebih ramah: ia menerima gaya pencarian web, tidak pernah melempar error sintaks, dan mengenali frasa berkutip, kata kunci or, serta tanda minus untuk negasi.
sql/tsquery-examples.sqlSELECT to_tsvector('simple', 'Azarine Hydrasoothe Sunscreen Gel SPF 45'); -- Frasa berkutip -> 'fat' <-> 'rat' (harus berurutan) SELECT websearch_to_tsquery('simple', '"low ph cleanser"'); -- OR eksplisit SELECT websearch_to_tsquery('simple', 'serum or essence'); -- Negasi dengan tanda minus SELECT websearch_to_tsquery('simple', 'sunscreen -spray');
Query utama untuk katalog menggabungkan match, ranking, dan filter status aktif.
sql/search-products-ranked.sqlWITH q AS ( SELECT websearch_to_tsquery('simple', $1) AS query ) SELECT p.id, p.name, p.brand, p.slug, p.price, p.image_url, ts_rank(p.search_vector, q.query) AS rank FROM products p CROSS JOIN q WHERE p.is_active = true AND p.deleted_at IS NULL AND p.search_vector @@ q.query ORDER BY rank DESC, p.id DESC LIMIT $2;
Jika admin panel punya mode query lanjutan, barulah to_tsquery masuk akal karena user internal memahami sintaks sunscreen & spf.
sql/search-products-admin-advanced.sqlWITH q AS ( SELECT to_tsquery('simple', $1) AS query ) SELECT p.id, p.name, p.brand, ts_rank(p.search_vector, q.query) AS rank FROM products p CROSS JOIN q WHERE p.search_vector @@ q.query ORDER BY rank DESC, p.id DESC LIMIT 50;
ts_rank vs ts_rank_cd dan parameter normalization
ts_rank punya saudara ts_rank_cd (cover density) yang memperhitungkan kedekatan antar-lexeme yang cocok, berguna saat posisi kata penting (frasa). Keduanya menerima parameter normalization opsional, sebuah bitmask yang mengatur apakah skor dinormalisasi terhadap panjang dokumen. Default 0 mengabaikan panjang dokumen.
sql/ts-rank-variants.sql-- Bobot default array: {D, C, B, A} = {0.1, 0.2, 0.4, 1.0} -- name(A)=1.0, brand(B)=0.4, description(C)=0.2 -> nama produk menang. -- normalization = 2 -> bagi skor dengan log panjang dokumen SELECT p.name, ts_rank(p.search_vector, q.query, 2) AS rank_norm, ts_rank_cd(p.search_vector, q.query) AS rank_cd FROM products p CROSS JOIN websearch_to_tsquery('simple', 'low ph cleanser') AS q(query) WHERE p.search_vector @@ q.query ORDER BY rank_norm DESC LIMIT 10;
flowchart TD A["setweight 'A'<br/>name · bobot 1.0"] --> Score["skor relevansi ts_rank"] B["setweight 'B'<br/>brand · bobot 0.4"] --> Score C["setweight 'C'<br/>description · bobot 0.2"] --> Score Score --> Order["produk dengan match di name<br/>naik ke atas"]
Gambar 3. setweight memetakan kolom ke label A/B/C/D yang punya bobot default berbeda, sehingga match pada nama produk berkontribusi lebih besar ke skor.
Highlight hasil dengan ts_headline
UI e-commerce sering menampilkan cuplikan yang menyorot kata yang cocok. ts_headline membuat snippet itu langsung dari teks asli.
sql/ts-headline.sqlSELECT p.name, ts_headline('simple', p.description, q.query, 'StartSel=<mark>, StopSel=</mark>, MaxWords=20, MinWords=8') AS snippet FROM products p CROSS JOIN websearch_to_tsquery('simple', 'ceramide barrier') AS q(query) WHERE p.search_vector @@ q.query LIMIT 5;
Tetap kirim keyword sebagai parameter SQL. Parameter mencegah SQL injection, sedangkan fungsi seperti websearch_to_tsquery mengubah input teks jadi query full-text yang aman.
Filtering Strategy: Facet dan Index
Search jarang berdiri sendiri di katalog skincare
Di produk skincare, search hampir selalu digabung dengan kategori, brand, range harga, tipe kulit, dan status stok.
Filter bukan sekadar menempel AND di SQL. Sebagai strategi, ada tiga keputusan: facet apa yang ditawarkan, indeks apa yang menopangnya, dan urutan evaluasi filter vs match GIN.
Facet umum katalog
Kategori (category_slug), brand, rentang harga, tipe kulit, dan status aktif. Inilah dimensi yang biasa muncul di sidebar filter.
Index pendukung
B-tree pada category_slug, brand, dan price; partial index pada baris aktif agar lebih ramping.
migrations/202606060905_add_filter_indexes.sql-- Equality filter umum CREATE INDEX idx_products_category ON products (category_slug); CREATE INDEX idx_products_brand ON products (brand); -- Range harga (PriceRupiah disimpan sebagai bigint) CREATE INDEX idx_products_price ON products (price); -- Partial index: hanya baris yang benar-benar tampil di katalog CREATE INDEX idx_products_active ON products (category_slug) WHERE is_active = true AND deleted_at IS NULL;
Contoh API: customer mencari serum niacinamide, lalu memfilter kategori serum, brand tertentu, dan harga di bawah batas. SQL-nya tetap satu query, dengan filter dan match digabung di WHERE yang sama.
sql/search-products-filtered.sqlWITH q AS ( SELECT websearch_to_tsquery('simple', $1) AS query ) SELECT p.id, p.name, p.brand, p.slug, p.price, ts_rank(p.search_vector, q.query) AS rank FROM products p CROSS JOIN q WHERE p.is_active = true AND p.deleted_at IS NULL AND ($2::text IS NULL OR p.category_slug = $2) -- facet kategori AND ($3::text IS NULL OR p.brand = $3) -- facet brand AND ($4::bigint IS NULL OR p.price <= $4) -- facet harga (PriceRupiah) AND ($5::text IS NULL OR p.skin_type = $5) -- facet tipe kulit AND p.search_vector @@ q.query ORDER BY rank DESC, p.id DESC LIMIT $6;
Kamu tidak perlu menebak filter mana dijalankan dulu. Planner PostgreSQL memilih jalur termurah: bisa GIN dulu lalu saring filter, atau filter selektif dulu lalu cek @@. Tugasmu adalah menyediakan indeks yang tepat (GIN untuk @@, B-tree untuk facet) dan menjaga statistik tetap segar (ANALYZE).
Jangan jadikan full-text search sebagai sumber kebenaran stok. Filter in_stock boleh ada di query katalog, tetapi angka stok yang dipakai untuk checkout harus tetap dihitung dari domain inventory karena nilainya sangat dinamis.
Sorting Strategy: Multi-Mode Katalog
Relevansi hanya satu dari banyak cara customer mengurutkan
Search bukan satu-satunya urutan. Katalog skincare butuh mode sort yang beragam: paling relevan, terbaru, termurah, termahal, terlaris, dan A-Z.
Setiap mode sort memetakan ke kolom dan tie-breaker yang berbeda. Kunci sehatnya: selalu akhiri ORDER BY dengan kolom unik dan deterministik (id) supaya urutan stabil antar-halaman, dan supaya keyset pagination bisa bekerja.
| Mode | Kolom sort | Tie-breaker |
|---|---|---|
| Relevansi | ts_rank(...) DESC | id DESC |
| Terbaru | created_at DESC | id DESC |
| Termurah | price ASC | id ASC |
| Termahal | price DESC | id DESC |
| Terlaris | sold_count DESC | id DESC |
| A-Z | name ASC | id ASC |
Jangan pernah memasukkan nama kolom dari query string langsung ke SQL. Petakan mode sort yang valid ke ekspresi ORDER BY lewat switch di Go. Mode tak dikenal jatuh ke default. Ini mencegah SQL injection lewat parameter sort.
internal/product/sort.gopackage product // sortClause memetakan mode sort yang di-whitelist ke ekspresi ORDER BY. // Tie-breaker id menjaga urutan stabil dan cocok untuk keyset pagination. func sortClause(mode string) string { switch mode { case "newest": return "p.created_at DESC, p.id DESC" case "price_asc": return "p.price ASC, p.id ASC" case "price_desc": return "p.price DESC, p.id DESC" case "best_selling": return "p.sold_count DESC, p.id DESC" case "name_asc": return "p.name ASC, p.id ASC" default: // "relevance" return "rank DESC, p.id DESC" } }
Sort created_at DESC atau price ASC pada katalog besar terbantu indeks B-tree composite seperti (category_slug, price) agar PostgreSQL bisa membaca terurut tanpa sort terpisah. Mode relevance adalah pengecualian: ts_rank selalu di-sort di heap karena bukan kolom terindeks.
Di Laravel kamu mungkin terbiasa Product::orderBy($request->sort). Itu rapuh karena $request->sort bisa diisi apa saja. Versi Go di atas memilih eksplisit lewat switch, sehingga hanya mode yang kamu izinkan yang pernah menyentuh SQL.
Keyset Pagination yang Benar
OFFSET besar lambat, dan keyset pada float rank itu jebakan
OFFSET besar lambat karena PostgreSQL tetap membaca lalu membuang ribuan baris sebelum mengembalikan halaman berikutnya.
flowchart LR
subgraph OFFSET["OFFSET 10000"]
O1["scan baris 1..10000"] --> O2["buang semua"] --> O3["ambil 20 berikutnya"]
end
subgraph KEYSET["Keyset (cursor)"]
K1["seek langsung<br/>ke cursor terakhir"] --> K2["ambil 20 berikutnya"]
endGambar 4. OFFSET membaca lalu membuang baris yang dilewati (scan-and-discard), sedangkan keyset langsung melompat ke posisi cursor lalu membaca maju.
Untuk search yang bisa di-scroll, gunakan keyset pagination. Idenya: simpan nilai kolom sort dari item terakhir sebagai cursor, lalu minta baris setelahnya.
Godaan pertama adalah WHERE rank < $cursor_rank OR (rank = $cursor_rank AND id < $cursor_id). Masalahnya, ts_rank mengembalikan float4 yang dihitung ulang tiap query, dan perbandingan kesetaraan float (rank = $cursor_rank) rapuh: pembulatan bisa membuat baris batas tidak pernah cocok, sehingga item terlewat atau duplikat antar-halaman. Selain itu rank bukan kolom unik dan tidak terindeks.
Solusinya: pakai row-value (tuple) comparison. Karena rank dan id sama-sama menurun (DESC), tuple comparison (rank, id) < (cursor_rank, cursor_id) sah dan setara dengan bentuk OR panjang, tetapi lebih ringkas dan menghindari perangkap float. Cursor harus menyimpan nilai rank EKSAK dari baris terakhir, bukan dihitung ulang.
sql/search-products-keyset.sqlWITH q AS ( SELECT websearch_to_tsquery('simple', $1) AS query ), ranked AS ( SELECT p.id, p.name, p.brand, p.slug, p.price, ts_rank(p.search_vector, q.query) AS rank FROM products p CROSS JOIN q WHERE p.is_active = true AND p.deleted_at IS NULL AND ($2::text IS NULL OR p.category_slug = $2) AND p.search_vector @@ q.query ) SELECT id, name, brand, slug, price, rank FROM ranked -- tuple comparison: keduanya DESC, jadi (rank, id) < (cursor_rank, cursor_id) WHERE $3::real IS NULL OR (rank, id) < ($3::real, $4::bigint) ORDER BY rank DESC, id DESC LIMIT $5;
Karena rank adalah float non-unik, halaman yang sangat dalam pada mode relevansi tetap berisiko. Untuk scroll panjang, mode deterministik seperti terbaru atau harga lebih kokoh: cursor-nya (created_at, id) atau (price, id) yang terindeks dan stabil. Beri mode relevansi disclaimer bahwa halaman dalam mungkin tidak sempurna stabil.
sql/keyset-newest.sql-- Mode 'terbaru': cursor (created_at, id), keduanya DESC -> tuple comparison SELECT id, name, brand, slug, price, created_at FROM products WHERE is_active = true AND deleted_at IS NULL AND ($1::timestamptz IS NULL OR (created_at, id) < ($1::timestamptz, $2::bigint)) ORDER BY created_at DESC, id DESC LIMIT $3;
(a, b) < (x, y) setara a < x OR (a = x AND b < y) dan hanya benar bila kedua kolom searah (sama-sama DESC atau sama-sama ASC). Untuk mode campur seperti price ASC, id DESC, tuple comparison polos tidak berlaku, kamu harus menulis bentuk OR eksplisit dengan arah yang sesuai per kolom.
Response API cukup mengirim next_cursor yang meng-encode nilai kolom sort dan id terakhir (mis. base64 dari JSON {"rank":0.061,"id":842}). Request berikutnya mengirim cursor itu kembali, dan server men-decode-nya menjadi parameter SQL.
Implementasi Repository dengan pgx
Go menjaga SQL tetap eksplisit dan mudah diukur
Di Go, repository menyimpan SQL yang jelas, menerima context.Context sebagai parameter pertama, lalu mengembalikan struct hasil pencarian.
- internal/
- product/
- search_repository.go query full-text search dengan pgxpool
- search_service.go validasi input dan cursor (sumber kebenaran limit)
- sort.go whitelist mode sort
- handler.go HTTP handler untuk /v1/products/search
- migrations/
- 202606060903_add_product_search.sql
- go.mod
Konvensi proyek menaruh validasi (clamp limit, normalisasi keyword) di service layer, agar repository hanya mengeksekusi query yang sudah bersih. Repository fokus pada SQL dan pemetaan baris.
internal/product/search_repository.gopackage product import ( "context" "fmt" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) // SearchRepository menerima *pgxpool.Pool yang sudah dikonfigurasi // (MaxConns, MaxConnLifetime) di lapisan bootstrap, lihat modul // connection-pool tuning di Roadmap 9 Chapter 1. type SearchRepository struct { pool *pgxpool.Pool } func NewSearchRepository(pool *pgxpool.Pool) *SearchRepository { return &SearchRepository{pool: pool} } // SearchProductsParams sudah tervalidasi di service: keyword bersih, // Limit sudah di-clamp. Repository tidak memvalidasi ulang. type SearchProductsParams struct { Keyword string CategorySlug *string LastRank *float32 // nilai rank EKSAK dari baris terakhir (cursor) LastID *int64 Limit int32 } type SearchProductRow struct { ID int64 Name string Brand string Slug string PriceRupiah int64 `db:"price"` // harga sebagai int64 Rupiah Rank float32 } // Pemetaan parameter: // $1 keyword | $2 category_slug | $3 last_rank | $4 last_id | $5 limit const searchProductsQuery = ` WITH q AS ( SELECT websearch_to_tsquery('simple', $1) AS query ), ranked AS ( SELECT p.id, p.name, p.brand, p.slug, p.price, ts_rank(p.search_vector, q.query) AS rank FROM products p CROSS JOIN q WHERE p.is_active = true AND p.deleted_at IS NULL AND ($2::text IS NULL OR p.category_slug = $2) AND p.search_vector @@ q.query ) SELECT id, name, brand, slug, price, rank FROM ranked WHERE $3::real IS NULL OR (rank, id) < ($3::real, $4::bigint) ORDER BY rank DESC, id DESC LIMIT $5` func (r *SearchRepository) SearchProducts(ctx context.Context, params SearchProductsParams) ([]SearchProductRow, error) { rows, err := r.pool.Query(ctx, searchProductsQuery, params.Keyword, params.CategorySlug, params.LastRank, params.LastID, params.Limit) if err != nil { return nil, fmt.Errorf("search products: %w", err) } defer rows.Close() products, err := pgx.CollectRows(rows, pgx.RowToStructByName[SearchProductRow]) if err != nil { return nil, fmt.Errorf("search products collect: %w", err) } // pgx.CollectRows mengembalikan slice kosong (bukan error) saat tak ada baris. return products, nil }
Validasi yang dipakai repository di atas tinggal di service, satu tempat (DRY).
internal/product/search_service.gopackage product import ( "context" "errors" "strings" ) var ErrEmptyKeyword = errors.New("keyword is required") const defaultLimit = 20 const maxLimit = 50 type SearchService struct { repo *SearchRepository } func NewSearchService(repo *SearchRepository) *SearchService { return &SearchService{repo: repo} } func (s *SearchService) Search(ctx context.Context, params SearchProductsParams) ([]SearchProductRow, error) { params.Keyword = strings.TrimSpace(params.Keyword) if params.Keyword == "" { return nil, ErrEmptyKeyword } // Clamp limit di service: satu sumber kebenaran. if params.Limit <= 0 || params.Limit > maxLimit { params.Limit = defaultLimit } return s.repo.SearchProducts(ctx, params) }
Di Laravel, builder atau Laravel Scout menyembunyikan SQL di balik driver. Di Go dengan pgx, SQL terlihat jelas sehingga lebih mudah di-EXPLAIN, dioptimasi, dan dibahas dengan DBA. Scout-style external engine (analog OpenSearch) baru dipertimbangkan saat PostgreSQL tidak lagi cukup, bukan sebagai default.
Kode di atas sengaja tidak membuat query builder dinamis. Untuk search produk yang kritikal, query eksplisit lebih mudah di-review daripada string SQL yang disusun dari banyak cabang.
Skenario API Online Shop Skincare
Search menjadi bagian dari customer journey, bukan fitur terpisah
Customer journey katalog dimulai dari browse, filter, search, lalu masuk ke product detail.
/v1/products/search?q=sunscreen&category=suncare&sort=relevance Cari produk aktif dengan full-text search, filter kategori, dan mode sort /v1/products?category=suncare&sort=price_asc Browse katalog tanpa keyword, tetap memakai filter, sort, dan keyset pagination /v1/products/{slug} Product detail, cocok digabung dengan cache dari modul sebelumnya sequenceDiagram participant FE as Frontend React participant API as Go API participant SVC as Product Service participant PG as PostgreSQL FE->>API: GET /v1/products/search?q=serum niacinamide&sort=relevance API->>SVC: Validate keyword, filters, sort mode, cursor SVC->>PG: search_vector @@ query + filters + ORDER BY PG-->>SVC: ranked products SVC-->>API: items + next_cursor API-->>FE: 200 JSON
Gambar 5. Search tetap lewat service layer agar validasi domain, whitelist sort, dan aturan pagination tidak bocor ke handler.
next_cursor cocok dengan pola infinite scroll TanStack Query: kembalikan cursor dari getNextPageParam, dan useInfiniteQuery akan mengirimnya kembali di request berikut. Cursor (bukan nomor halaman) memang pas untuk scroll karena halaman bertambah, bukan melompat. Tambahkan debounce plus AbortController di search box agar tiap keystroke tidak menembak FTS yang I/O-bound.
Keyword
Dipakai untuk full-text search pada nama, brand, dan deskripsi.
Filter
Kategori, brand, harga, tipe kulit, dan status aktif dipakai di WHERE yang sama.
Cursor + sort
Frontend menyimpan cursor halaman terakhir dan mode sort, bukan angka halaman besar.
Search result lebih dinamis daripada product detail. Cache product detail boleh agresif, tetapi search result perlu hati-hati karena kombinasi keyword, filter, sort, dan ranking membuat ruang cache key meledak.
pg_trgm dan Kapan Perlu OpenSearch?
Jangan menambah sistem baru hanya karena terdengar enterprise
PostgreSQL, dengan FTS dan pg_trgm, sudah cukup untuk banyak online shop kecil sampai menengah.
Ada langkah-antara yang sering terlewat sebelum lompat ke OpenSearch: pg_trgm. Ekstensi ini memberi typo tolerance dan autocomplete ringan secara native, jadi pohon keputusannya bukan biner “FTS sekarang vs OpenSearch nanti”.
flowchart TD
Start["Butuh search lebih baik"] --> Q1{"Substring / fuzzy /<br/>typo tolerance ringan?"}
Q1 -->|"Ya, dan dokumen pendek"| TRGM["pg_trgm<br/>gin_trgm_ops + similarity"]
Q1 -->|"Butuh ranking multi-kolom<br/>+ stemming"| FTS["PostgreSQL FTS<br/>tsvector + GIN"]
FTS --> Q2{"Volume >1jt,<br/>multi-language serius,<br/>BM25/relevansi tuning,<br/>autocomplete kompleks?"}
TRGM --> Q2
Q2 -->|"Tidak"| Stay["Tetap di PostgreSQL"]
Q2 -->|"Ya, terbukti dari data"| OS["OpenSearch<br/>(managed: AWS OpenSearch Service)"]Gambar 6. pg_trgm adalah node tengah Postgres-native antara FTS dan OpenSearch, bukan langkah yang dilompati.
OpenSearch mulai masuk akal ketika kebutuhan search melampaui kemampuan PostgreSQL sebagai database utama. AWS OpenSearch Service adalah managed service untuk klaster OpenSearch (fork dari Elasticsearch 7.10.2 berlisensi ALv2), kuat untuk relevansi BM25, fuzzy/typo, autocomplete, faceting, dan analitik log.
Sebelum memutuskan, kenali batasnya: PostgreSQL FTS tidak punya fuzzy/typo tolerance bawaan (itu tugas pg_trgm), tidak memakai BM25, dan skor ts_rank tidak memakai statistik frekuensi lintas-korpus. Untuk relevansi yang benar-benar kompleks, inilah celah yang ditutup OpenSearch.
Tetap PostgreSQL dulu
Katalog masih ratusan ribu produk, bahasa terbatas, typo tolerance ringan cukup dengan pg_trgm, dan query bisa dijaga dengan indeks.
Pertimbangkan OpenSearch
Volume sangat besar, multi-language serius, fuzzy/relevansi kompleks wajib, autocomplete canggih, dan tim siap mengelola sinkronisasi data.
OpenSearch berarti ada pipeline sinkronisasi, reindex, monitoring cluster, mapping, dan failure mode baru. Ambang “1 juta produk” hanyalah indikatif; sumber lain bahkan menyebut PG FTS nyaman sampai ratusan ribu baris sebelum tuning serius. Putuskan dari sinyal kebutuhan, bukan satu angka kaku, dan hanya setelah bottleneck terbukti lewat data.
flowchart LR
PG[("PostgreSQL<br/>(source of truth)")] -->|"outbox / CDC"| Indexer["Indexer service"]
Indexer -->|"bulk index"| OS[("OpenSearch cluster")]
FE["Frontend"] -->|"search query"| OSGambar 7. Saat pindah ke OpenSearch, data tetap dimiliki PostgreSQL; sebuah indexer (lewat outbox atau CDC) menjaga indeks tetap sinkron. Pola outbox ini menyiapkan jembatan ke modul Event-Driven (Chapter 4).
- Satu sumber data, transaksi tetap sederhana.
- Bagus untuk search katalog yang masih dekat dengan SQL filter.
- Lebih mudah dioperasikan bersama RDS PostgreSQL.
- Lebih kuat untuk fuzzy search, autocomplete, multi-language, dan relevansi BM25.
- Butuh sinkronisasi dari database utama.
- Menambah biaya infrastruktur dan observability.
Jebakan Umum Search Optimization
Bug search jarang error keras, lebih sering hasil salah diam-diam
Sebagian besar masalah search bukan crash, melainkan hasil yang salah urut, terlewat, atau lambat tanpa alasan jelas.
Keyset pada float rank
Memakai rank = $cursor pada float4 membuat baris batas tidak cocok. Pakai tuple comparison dan simpan rank eksak, atau keyset pada kolom deterministik.
Lupa tie-breaker id
ORDER BY rank DESC tanpa id membuat urutan tidak stabil, halaman bisa menampilkan baris yang sama atau melompat.
to_tsquery untuk input publik
to_tsquery melempar error pada input bebas customer. Pakai websearch_to_tsquery untuk search box.
Mengharap GIN mempercepat sort
GIN hanya untuk @@. ORDER BY rank tetap di-sort di heap; batasi kandidat dengan filter lebih dulu.
String interpolation ke SQL
Menyusun WHERE name ILIKE '%' || kw atau kolom sort dari query string membuka SQL injection. Selalu parameter dan whitelist.
english vs simple asal pilih
english bisa over-stemming brand/bahan. Untuk katalog penuh nama produk, simple sering lebih aman.
Jika @@ mencocokkan puluhan ribu baris, ts_rank dihitung untuk semuanya sebelum LIMIT. Selalu sempitkan dengan filter selektif (kategori, status) supaya ranking hanya berjalan pada kandidat yang relevan.
Hands-on Ringan
Tambahkan search ke katalog lokal dan ukur plan-nya
Hands-on ini fokus pada validasi search dengan SQL dan repository Go, bukan membuat UI.
Jalankan CREATE EXTENSION pg_trgm, migration ADD COLUMN search_vector, dan GIN index pada tabel products.
Masukkan beberapa produk sunscreen, serum, moisturizer, dan cleanser dengan brand serta deskripsi realistis.
Jalankan websearch_to_tsquery untuk keyword sunscreen spf, pastikan produk paling relevan di atas, lalu coba ts_headline.
Ketik typo seperti niasinamide dan pastikan similarity() tetap menemukan produk niacinamide.
Gunakan EXPLAIN (ANALYZE, BUFFERS) sebelum dan sesudah GIN index untuk melihat perbedaan Seq Scan vs Bitmap Index Scan.
Pakai SearchService.Search dari handler GET /v1/products/search dengan keyset cursor.
sql/seed-search-products.sqlINSERT INTO products (name, brand, slug, description, price, category_slug, is_active) VALUES ('Hydrating Sunscreen Gel SPF 50', 'Azarine', 'azarine-hydrating-sunscreen-gel-spf-50', 'Lightweight sunscreen gel for oily skin and daily outdoor use', 65000, 'suncare', true), ('Niacinamide Brightening Serum', 'Somethinc', 'somethinc-niacinamide-brightening-serum', 'Serum with niacinamide for uneven tone and dull skin', 89000, 'serum', true), ('Ceramide Barrier Moisturizer', 'Skintific', 'skintific-ceramide-barrier-moisturizer', 'Moisturizer with ceramide to support skin barrier', 125000, 'moisturizer', true), ('Gentle Low pH Cleanser', 'COSRX', 'cosrx-gentle-low-ph-cleanser', 'Daily cleanser for sensitive and acne prone skin', 99000, 'cleanser', true);
Terminalpsql "$DATABASE_URL" -f migrations/202606060901_enable_pg_trgm.sql psql "$DATABASE_URL" -f migrations/202606060903_add_product_search.sql psql "$DATABASE_URL" -f sql/seed-search-products.sql psql "$DATABASE_URL" -f sql/explain-search.sql go test ./...
Query search memakai GIN index, hasil punya ranking, filter kategori dan mode sort bekerja, fuzzy pg_trgm menangani typo ringan, dan halaman berikutnya memakai cursor tuple, bukan OFFSET besar.
Ringkasan & Poin Penting
Search optimization di proyek skincare berarti membuat katalog mudah ditemukan tanpa buru-buru menambah search engine terpisah.
Yang Wajib Menempel
- PostgreSQL search punya tiga lapis native:
LIKE/ILIKEuntuk substring,pg_trgm(gin_trgm_ops,similarity) untuk fuzzy dan'%kw%'cepat, dan full-text search untuk dokumen multi-kolom dengan ranking. - B-tree gagal mempercepat
ILIKE '%kw%'karena tidak ter-anchor di kiri; GIN dengangin_trgm_opsatautsvectormenutup celah itu. search_vectordibangun lewat stored generated column (setweightA/B/C), cara modern yang menggantikan trigger lama yang kini usang.- GIN hanya mempercepat match
@@;ts_rankitu I/O-bound dan di-sort di heap, jadi batasi kandidat dengan filter lebih dulu. - Filtering strategy: facet (kategori, brand, harga, tipe kulit) ditopang B-tree dan partial index, digabung di
WHEREyang sama dengan match. - Sorting strategy multi-mode (relevansi, terbaru, harga, terlaris, A-Z) di-whitelist lewat
switchdi Go, selalu dengan tie-breakeriddeterministik. - Keyset pagination memakai tuple comparison
(rank, id) < (cursor_rank, cursor_id)untuk arah sort sama; hindari kesetaraan float, dan untuk halaman dalam pilih kolom deterministik seperti(created_at, id). websearch_to_tsqueryaman untuk search box publik;to_tsqueryhanya untuk admin atau query yang dibangun aplikasi.pg_trgmadalah langkah-antara sebelum OpenSearch; OpenSearch baru dipertimbangkan saat volume, multi-language, fuzzy/relevansi BM25, dan autocomplete kompleks terbukti jadi kebutuhan nyata.
Di proyek online shop skincare, modul ini memperkuat katalog produk. Setelah product detail bisa di-cache dan search bisa memakai indeks, langkah scaling berikutnya adalah memisahkan proses berat seperti notifikasi, email, dan integrasi payment ke arsitektur event-driven, persis pola outbox yang kita pakai untuk menyinkronkan indeks pencarian.
Progress disimpan lokal di browser ini.