Retrieval tanpa embedding
August 3, 2026
Kenapa saya membangun vektor 256 dimensi dengan tangan, deterministik dan gratis dan sengaja bodoh, dan filter gate empat tahap yang ia suapi.
Cara default membangun retrieval sekarang adalah embedding: jalankan teks lewat embedding model, simpan vektornya di vector database, ranking dengan cosine similarity. Ia bekerja dan cepat dipasang. Saya tidak memakainya.
Vektor retrieval di sini dibangun dengan tangan: 256 dimensi, tanpa inferensi model, Go murni, di-hash dengan FNV-1a. Tanpa panggilan API, tanpa GPU, tanpa biaya per item. Tulisan ini soal kenapa itu keputusan yang tepat untuk sistem ini, dan bagaimana vektornya menyuapi filter empat tahap yang memutuskan apa yang disimpan.
Berawal dari obrolan dengan Pak Aria Ghora
Tulisan ini sebagian berawal dari obrolan saya dengan Aria Ghora pada Juni lalu, soal quote antirez bahwa raw vector search adalah struktur data yang fundamental. Satu ide dari beliau yang melekat: vektor itu representasi yang umum, tak harus lahir dari LLM — hasil feature extraction ML klasik, bahkan struktur layout dokumen, semuanya bisa.
Selama bisa di-encode dalam vektor, entah via ekstraksi manual atau via suatu pretrained model, secara teknis bisa dibingkai sebagai permasalahan vector search.
Jawaban saya waktu itu: "saya coba kulik, Pak." Vektor 256 dimensi di bawah adalah hasil kuliknya.
Kenapa tidak embedding
Tiga alasan, kira-kira urut dari yang paling berpengaruh.
Cost. Di volume ini, satu embedding API call per halaman adalah pos anggaran yang nyata.
Reproducibility. Embedding model berubah di bawah kaki Anda. Vendor memperbarui model, vektor bergeser, dan similarity threshold yang Anda tune bulan lalu diam-diam berarti hal yang berbeda. Encoder buatan tangan adalah fungsi murni. Input sama, 256 angka yang sama, tiap kali, jadi ketika sebuah match kelihatan salah, saya bisa memproduksi ulangnya persis.
Alasan utamanya struktural. Embedding melebur segalanya (topik, kategori, wilayah, gaya bahasa) jadi satu vektor, dan cosine similarity mencampur semuanya. Saya butuh sebagian informasi itu jadi hard filter dan sisanya jadi soft ranking: dua item wajib satu kategori dan satu wilayah, baru di-ranking berdasarkan konten. Tak ada cara bersih untuk berkata "dimensi ini constraint, dimensi itu score" di dalam satu embedding terlatih. Jadi saya membangun vektornya untuk memisahkan keduanya.
Vektornya dua blok
dim = 256
// Blok SHAPE, dimensi 0..63: hard equality filter, bukan similarity math
catBase = 0, catSpan = 16 // one-hot kategori
provBase = 16, provSpan = 38 // one-hot wilayah
flagBase = 58, flagSpan = 6 // structural flag
shapeEnd = 64
// Blok CONTENT, dimensi 64..255: inilah yang benar-benar diukur cosine
contentBase = 64, contentSpan = 19264 dimensi pertama adalah shape block: one-hot kategori, one-hot wilayah, dan beberapa structural flag. Ia tak pernah dipakai dalam perhitungan jarak. Ia filter key: "bandingkan aku hanya dengan item berkategori dan berwilayah sama".
192 dimensi sisanya adalah content block: fingerprint teks item yang di-feature-hash, dan satu-satunya bagian yang disentuh cosine similarity.
Aturan yang saya pegang: shape memfilter dan content me-ranking, dan keduanya tak pernah runtuh jadi satu score. Meleburnya akan mengembalikan saya ke satu angka campuran khas embedding, yang justru hal yang saya hindari.
Feature-hashing kontennya
Content block memakai hashing trick, yang mendahului transformer bertahun-tahun. Tak ada vocabulary terlatih dan tak ada embedding table. Anda ambil fitur (di sini, character 3-gram atas nama ternormalisasi, region token, dan bag of attributes khas kategori), hash tiap fitur ke sebuah bucket, dan naikkan.
Satu detail yang perlu diketahui adalah versi signed-nya. Kalau tiap fitur cuma menaikkan, fitur yang collide selalu saling menguatkan dan collision berubah jadi sinyal palsu. Memakai bit hash kedua untuk memilih tanda membuat collision cenderung saling meniadakan:
// Feature hashing dengan signed trick (Weinberger dkk.).
// Bit hash kedua menentukan +1 atau -1, jadi fitur yang collide
// cenderung saling meniadakan alih-alih menguatkan. Collision jadi
// noise, bukan bias.
func addFeature(vec []float32, token string, base, span int) {
h := fnv32(token)
bucket := base + int(h%uint32(span))
if h&(1<<31) != 0 {
vec[bucket] -= 1
} else {
vec[bucket] += 1
}
}Beberapa batasan menjaganya waras (maksimal 48 token per item, di-dedup, minimal 3 huruf, stopword dibuang), lalu seluruh vektor di-L2-normalize supaya operator cosine pgvector berperilaku baik. Itu seluruh encoder-nya: satu function call sinkron per item, tanpa batching, tanpa jaringan. Vektornya tak tahu arti kata mana pun; ia cuma tahu bucket mana yang menyala. Untuk pekerjaan ini itu cukup, dan menjadi deterministik dan gratis lebih berharga daripada memahami teksnya.
Penyimpanan: pgvector, dipartisi berdasarkan kategori
Vektornya tinggal di Postgres dengan pgvector dan HNSW. Satu keputusan yang tak-kentara adalah mempartisi tabel berdasarkan category_id (16 partisi plus satu default) dengan HNSW index lokal terpisah di tiap partisi.
Ini penting karena cara HNSW berinteraksi dengan filter. HNSW itu aproksimat: ia menyusuri graf dan mengembalikan kira-kira ef_search tetangga terdekat. Kalau Anda lalu memfilter itu dengan category_id = 3, dan kategori 3 cuma 1% dari sejuta baris, tetangga aproksimatnya bisa balik dengan nol baris yang cocok. Hasil kosong dari tabel yang jelas berisi match, recall ambruk diam-diam. Mempartisi berdasarkan kunci yang sama dengan yang Anda filter menghindarinya: query untuk kategori 3 hanya menyentuh index kategori 3, graf kecil di mana tiap simpul sudah lolos filter.
-- Shape block adalah hard equality pre-filter (langkah "blocking").
-- Cosine distance content block (<=>) melakukan ranking.
-- Karena tabel dipartisi berdasarkan category_id, ini mengenai satu
-- HNSW graph kecil per partisi, jadi recall tetap tinggi.
SELECT candidate_id, title, 1 - (vec <=> $1::vector) AS similarity
FROM vector_index
WHERE category_id = $2 AND province_id = $3
ORDER BY vec <=> $1::vector
LIMIT 8;Satu potong kebersihan: ketika pipeline menolak sebuah item sebagai duplikat atau tak relevan, vektornya dihapus dalam transaksi yang sama dengan keputusan itu, jadi index-nya hanya memegang vektor yang layak dicocokkan.
Filter gate itu sebuah funnel
Sistem memutuskan, per halaman, apakah menyimpannya dan apakah ia sudah tercakup. Itu bukan satu keputusan tapi empat, disusun supaya tiap tahap lebih presisi dan lebih mahal dari yang sebelumnya, dan berjalan hanya pada yang diloloskan tahap sebelumnya.
Lexical term list, saat crawl, tanpa biaya AI. Domain vocabulary yang ditanam tangan, diberi skor (hit di judul 2 poin, di body 1) dengan threshold yang di bawahnya halaman tak pernah masuk bagian mahal pipeline. Ini berjalan pada segalanya, jadi ia harus gratis.
Pre-gate LLM yang murah dan permisif. Ekstraksi (mengubah halaman jadi data terstruktur) kira-kira 95% dari total AI spend, dan gate presisi di hilir menolak sekitar 85% dari yang diproduksi ekstraksi. Jadi model kecil dan cepat berjalan dulu, dibatasi 150 token output, cuma untuk membuang sampah jelas sebelum membayar ekstraksi penuh. Ia fail open: pada error apa pun atau balasan tak terbaca, ia meloloskan halaman, karena pre-gate yang salah-menolak kehilangan data permanen, sementara yang salah-menerima cuma memboroskan uang di hilir.
Gate LLM presisi. Category-range check deterministik dulu (masih gratis), lalu satu call ke model yang lebih kuat dengan classification prompt yang dikeraskan. Kalau balasannya ambigu, ia tak dipaksa jadi pass/fail; job-nya di-retry. Verdict salah yang diam lebih mahal dari call kedua.
Vector gate, tempat retrieval terjadi.
Reject di tahap mana pun melewati segala yang di bawahnya, jadi penilaian mahal hanya berjalan pada sisa kecil yang tak bisa diselesaikan filter murah.
Retrieval itu sebuah cascade
Vector gate bukan sekadar "cosine search, top-k". Ia cascade empat langkah berurutan prioritas, dan yang penting tak satu pun BM25 atau full-text search:
- Exact normalized-title lookup terhadap reference corpus. Match termurah; kalau judulnya identik, itu jawabannya.
- Vector ANN search, query di atas. Shape block memfilter, content block me-ranking, 8 teratas.
- Fuzzy title fallback, ketika 1 dan 2 sama-sama kosong: trigram-Jaccard similarity yang ditulis tangan di Go (saya memilih tak memasang
pg_trgm), dibatasi SQL prefix-scan sampai maksimal 500 kandidat supaya tak pernah memindai seluruh tabel. - Net-new, tak ada yang cocok.
Ketika ada match, keputusannya menggabungkan tiga sinyal independen, bukan satu:
Cosine band vektor (deterministik, tanpa AI):
similarity >= 0.90 -> near-duplicate, skip
0.60 .. 0.90 -> probable match, butuh penilaian
similarity < 0.60 -> net-new
+ title trigram similarity (deterministik, Go)
+ content coverage dinilai-LLM (seberapa banyak ini sudah dicakup match?)Cosine band adalah potongan pertama yang cepat dan deterministik. Hanya tengah 0.60–0.90, "mungkin benda yang sama, tak yakin", yang membelanjakan LLM call, untuk menilai seberapa banyak item baru sudah dicakup yang lama. Cross-validation call terakhir bertindak sebagai trust gate, dan mempublikasikan menuntut semuanya sejajar:
func shouldPublish(d Decision) bool {
if d.CulturalGate != "pass" || d.CrossValidation != "accept" {
return false
}
return d.NoveltyVerdict == "net-new" ||
d.NoveltyVerdict == "enrich" ||
d.NoveltyVerdict == "supplement"
}Near-duplicate di 0.90 ke atas tak pernah menghabiskan satu token pun. Cosine band menyelesaikannya, dan LLM cuma dipakai untuk tengah yang ambigu.
Memutuskan ulang ketika classifier berubah
Ada failure mode khas LLM gate. Ketika Anda memperbaiki classification prompt (Anda menemukan sekelas false positive dan mengeraskannya) segala yang sudah dipublikasikan di bawah prompt lama kini mencurigakan, dan perbaikannya tak menjangkau ke belakang.
Jadi ada batch re-gate yang menjalankan ulang gate saat ini terhadap tiap item yang sudah dipublikasikan. Satu batasan membentuk desainnya: raw page body cuma disimpan 7 hari, jadi untuk item yang lebih tua HTML sumbernya sudah hilang dan re-gate berjalan terhadap stored candidate yang diekstrak. Ia dry-run secara default dan butuh -execute -confirm=<token> eksplisit untuk mengubah apa pun, karena ia bisa membatalkan publikasi. Dan karena store di hilir tak punya delete API, ia tak berpura-pura punya; ia mencetak purge list untuk manusia. Membangun sebuah filter bukan cuma memutuskan dengan baik sekarang, tapi mampu memutuskan-ulang apa yang salah Anda putuskan sebelumnya.
Kenapa vektor bodoh itu alat yang tepat
Vektor di pusat semua ini tak punya model di baliknya dan tak punya gagasan makna. Itu intinya. Ia gratis, jadi berjalan pada tiap item; deterministik, jadi sebuah threshold berarti hal yang sama sepanjang waktu; dan cukup cepat sampai tugasnya yang satu, mempersempit sejuta baris jadi delapan, menyisakan LLM mahal cuma melihat delapan itu. Embedding memang akan lebih pintar, dan juga lebih lambat, lebih mahal, tak reproducible, dan tak bisa menjaga pemisahan shape/content yang jadi tumpuan seluruh sistem. Untuk masalah ini vektor bodoh adalah alat yang lebih baik.