Mesin Pencari yang Dibuat dengan 80 Baris Python
(alexmolas.com)microsearchadalah implementasi mainan untuk memahami langsung bagian dalam mesin pencari; kelas inti mesin pencarinya kurang dari 80 baris, tetapi proyeknya lebih besar jika menyertakan crawler, API, dan template HTML- Dengan latar belakang masalah situs web kecil dan blog yang sulit ditemukan di mesin pencari besar, data pencarian dibuat dengan mengumpulkan artikel dari 642 RSS feed
- Crawling asinkron berbasis
asynciomemangkas waktu pengumpulan dari 20 menit menjadi 20 detik, dan isi teks yang sudah dibersihkan disimpan sebagai data Parquet - Pencarian berjalan di atas inverted index yang menghubungkan kata dengan jumlah kemunculannya per URL, dan pengurutan hasil menggunakan BM25 berbasis konten alih-alih PageRank berbasis tautan
- UI FastAPI menyediakan kotak pencarian dan halaman hasil, tetapi fitur seperti operator kueri, indeks n-gram, ekspansi kueri/dokumen, dan pengindeksan saat crawling belum tersedia
Tujuan dan cakupan microsearch
microsearchadalah implementasi mesin pencari Python yang dipublikasikan di repositori GitHub- Tujuannya bukan membuat mesin pencari untuk produksi, melainkan membuat contoh mainan yang bisa digunakan untuk menunjukkan bagaimana mesin pencari bekerja di dalamnya
- Target pencariannya lebih dekat ke situs web kecil dan blog yang sulit ditemukan dalam persaingan SEO Google
- Implementasi inti mesin pencari kurang dari 80 baris, tetapi keseluruhan proyek lebih besar jika mencakup kode pendukung seperti crawler data, API, dan template HTML
- Implementasi ini dibuat dalam proses mempelajari Solr dan Lucene serta memahami cara kerja mesin pencari secara lebih mendalam
Crawler berbasis RSS
- Untuk membuat data yang akan dicari, crawler menjelajahi RSS feed blog
- Feed yang digunakan berjumlah total 642 RSS feed
- Sekitar 100 di antaranya adalah blog yang dibaca langsung, seperti ML, sains data, dan matematika
- Sekitar 500 sisanya diambil dari proyek surprisetalk blogs.hn
- Alur crawling adalah mengekstrak URL artikel dari tiap RSS feed, mengunduh HTML artikel, lalu membersihkan teks isi artikel
- Pembersihan HTML menggunakan
BeautifulSoupuntuk menghapusscriptdanstyle, lalu merapikan baris baru dan spasi sebelum mengubahnya menjadi teks - Dengan crawling asinkron menggunakan
aiohttpdanasyncio, waktu eksekusi turun dari 20 menit menjadi 20 detik - Hasilnya dibuat sebagai
DataFrameberisi URL dan isi yang sudah dibersihkan, lalu disimpan keoutput.parquet
Struktur inverted index
- Struktur data inti pertama dari mesin pencari adalah inverted index
- Inverted index memetakan kata kunci ke dokumen, sehingga dokumen mana yang berisi kata tertentu bisa ditemukan dengan cepat
- Implementasinya menggunakan
defaultdictberbentukdict[str, dict[str, int]]- Kunci luar adalah kata
- Kunci dalam adalah URL
- Nilai dalam adalah jumlah kemunculan kata tersebut di dokumen pada URL itu
- Kelas
SearchEnginememiliki dua dictionary internal_index: menyimpan jumlah kemunculan URL per kata_documents: menyimpan konten asli per URL
index(url, content)menormalisasi konten lalu memisahkannya berdasarkan spasi, dan menambah jumlah kemunculan tiap kata per URLbulk_index()menerima daftar URL dan konten untuk mengindeks banyak dokumen sekaligusget_urls(keyword)menormalisasi kata kunci lalu mengembalikan URL yang berisi kata tersebut beserta jumlah kemunculannya
Normalisasi string dan pencarian dasar
- Normalisasi string mengganti tanda baca dengan spasi, merapikan spasi berlebih, lalu mengubahnya menjadi huruf kecil
- Untuk mengurangi perbedaan huruf besar-kecil,
Foodanfoodiperlakukan sebagai kata kunci yang sama - Jika dua dokumen contoh diindeks, hasil pencarian
foomengembalikan kedua dokumenFoo:Hello, World! My name is Foo!Bar:Hello, World! My name is Bar, I'm not Foo!
- Pada tahap ini, mesin hanya mengetahui apakah dokumen berisi kata pencarian dan berapa kali kata itu muncul, sehingga diperlukan pemeringkatan terpisah untuk menentukan urutan hasil
Ranker BM25
- Untuk mengurutkan hasil pencarian digunakan BM25
- PageRank memeringkat dokumen berdasarkan tautan, sedangkan BM25 menghitung skor berdasarkan konten dokumen
SearchEnginememiliki parameter defaultk1=1.5danb=0.75untuk perhitungan BM25- Kelas ini menyediakan atribut yang dibutuhkan untuk perhitungan ranking
posts: daftar URL yang sudah diindeksnumber_of_documents: jumlah total dokumenavdl: panjang rata-rata dokumen
idf(kw)menghitung inverse document frequency untuk kata kunci tertentu- Jumlah total dokumen
N - Jumlah dokumen yang berisi kata kunci tersebut
n_kw - Menggunakan rumus
log((N - n_kw + 0.5) / (n_kw + 0.5) + 1)
- Jumlah total dokumen
bm25(kw)menghitung skor BM25 untuk tiap URL yang berisi kata kunci tersebutsearch(query)menormalisasi kueri dan memecahnya menjadi kata, lalu menjumlahkan skor BM25 tiap kata per URL dan mengembalikannya- Dalam contoh, jika hanya mencari
foo, skor dokumenFoolebih tinggi daripadaBar; jika mencarifoo bar, skor dokumenBarmenjadi lebih tinggi
Antarmuka FastAPI
- Mesin pencari diekspos sebagai aplikasi FastAPI kecil
- Aplikasi membuat instance
SearchEngine, lalu saat startup membaca URL dan konten dari data Parquet dan mengindeksnya denganbulk_index() - Ada tiga route utama
/: merender halaman pencarian dan meneruskan daftar artikel yang sudah diindeks/results/{query}: menjalankan pencarian untuk kueri dan menampilkan 5 URL teratas di halaman hasil/about: merender halaman perkenalan
- Hasil diurutkan menurun berdasarkan skor, lalu hanya URL top-N yang dipilih
- UI dan UX masih punya banyak ruang untuk diperbaiki, tetapi pencarian berjalan cepat dan hasilnya juga cukup baik
Fitur yang belum ada dan keterbatasan
- Implementasi ini belum memiliki banyak fitur yang bisa diharapkan dari mesin pencari nyata
- Tidak ada operator kueri
- Misalnya, pencarian seperti
how to build a search engine -solrdi Google, yang mengecualikan kata tertentu, belum didukung
- Misalnya, pencarian seperti
- Tidak ada indeks n-gram
- Cara mencari hanya dokumen yang memuat dua kata dalam urutan tertentu, seperti
"search engine", belum didukung
- Cara mencari hanya dokumen yang memuat dua kata dalam urutan tertentu, seperti
- Tidak ada ekspansi kueri atau dokumen
- Meski mencari
engine, dokumen yang berisienginestidak otomatis ikut ditemukan
- Meski mencari
- Crawling dan pengindeksan masih terpisah
- Keduanya bisa diintegrasikan dengan cara mengindeks dokumen segera setelah diterima, dan proses ini juga bisa dibuat asinkron
Langkah berikutnya
- Melalui proyek ini, muncul intuisi yang lebih baik tentang bagaimana Solr bekerja di dalamnya
- Terlihat juga bahwa kode asinkron sangat efektif untuk pekerjaan yang berpusat pada IO
- Langkah berikutnya adalah menambahkan fitur semantic search ke mesin pencari
- Eksperimen dengan model embedding dan ANN sudah dilakukan, dan pekerjaan berikutnya adalah memasukkan fitur itu ke
microsearch
1 komentar
Pendapat di Hacker News
Ini benar-benar keren. Saya sedang membuat mesin pencari BM25 yang cukup cepat untuk pengujian lokal dengan Pandas: https://github.com/softwaredoug/searcharray
Alasan memakai Pandas adalah karena BM25 saja tidak cukup, dan saya ingin mudah menghitung serta menggabungkan faktor lain seperti kebaruan dan popularitas dengan pandas/numpy
Sebagai catatan, pencarian frasa adalah bagian yang sulit. Pencocokan frasa punya banyak edge case, dan hal seperti slop juga harus dipertimbangkan. Informasi posisi juga harus dikompresi dengan memori seminimal mungkin: https://github.com/softwaredoug/searcharray/blob/main/searcharray/utils/roaringish.py
Saya rasa sudah mengujinya cukup menyeluruh, tetapi akan senang mendapat masukan. Informasi posisi saya delta-encode lalu encode dengan base36
Benar. Sebagian besar hal sulit dalam pencarian ada pada penanganan skala data. Logikanya sendiri bisa sangat mudah, atau dibuat mudah
Tentu saja bisa juga dibuat rumit tanpa akhir, tetapi proyek ini berhasil memangkas bagian yang tidak perlu. Jika pendekatannya bukan memperbesar mesin pencari, melainkan membuat data secara fisik lebih kecil atau meningkatkan rasio sinyal-terhadap-noise, kita bisa melangkah cukup jauh
Kalau melihat
src/microsearch/engine.py, ada kode sepertiSearchEngine.__init__(self, k1: float = 1.5, b: float = 0.75), tetapi saya sama sekali tidak tahu apa ituk1ataub, dan tidak ada satu pun komentar di seluruh fileApakah komentar sekarang sudah tidak populer?
_documentssepertinya key-nya URL dan value-nya isi URL tersebut, tetapi bisa saja saya salah. Ini bisa menjadi materi untuk belajar dan mengembangkan cara membuat mesin pencari, tetapi kualitas kodenya terasa disayangkan karena tidak didokumentasikanDi luar bercanda, saya setuju bahwa biasanya lebih baik jika dokumentasi dan kode ada bersama. Namun dalam kasus ini, karena ini proyek edukatif, kode dan dokumentasi dipisah, lalu kodenya didokumentasikan di tulisan blog
k1danbadalah nilai bobot standar yang dipakai dalam TF-IDF atau BM25, dan di sini yang dimaksud BM25Komentar tentu berguna, tetapi bagi orang yang akrab dengan masalah ini, nama itu juga langsung bisa dikenali
k1danbadalah parameter penyetel untuk fungsi peringkat BM25. Itu bukan nama yang baru dibuat oleh penulis asli, melainkan nama variabel yang dipakai di hampir semua implementasi dan buku teksBagi orang yang memahami bidang temu kembali informasi, justru penamaan
k1danbitulah yang tepat: https://en.wikipedia.org/wiki/Okapi_BM25a: floatselalu mengingatkan saya pada ceramah Rich Hickey, “yang Anda butuhkan bukan tipe, melainkan nama yang tepat”Saya sangat tidak suka kecenderungan memakai nama variabel satu huruf tanpa penjelasan, yang terasa seperti berasal dari Go, lalu menyalahgunakan sistem tipe seolah-olah itu alat bantu penamaan. Nama dapat menyampaikan informasi semantik tentang apa yang dilakukan program, jadi harus dipakai dengan benar
Saya tidak mengerti apa artinya membanggakan jumlah baris kode, bukan jumlah total
\r\n, sambil memakai dependensi eksternalMemang tidak ada satuan SI untuk mengukur codebase, tetapi menurut saya beban kognitif tetap harus diukur entah bagaimana
collections,math, danstring, semuanya pustaka standarNamun mungkin lebih tepat menyebutnya “engine mesin pencari”. Crawler dan antarmuka tidak termasuk dalam 80 baris itu, tetapi tetap diperlukan dalam satu bentuk atau lainnya, dan implementasi yang disajikan memang cukup menambah jumlah baris serta pustaka. Meski begitu, pustaka-pustaka itu tidak terkait dengan mesin pencari itu sendiri. Kalau kita mulai menghitung dependensi umum seperti
pandasataufastapi, mungkin kita juga harus menghitung jutaan baris dalam sistem operasi, firmware kartu jaringan, sampai kompleksitas hardwareSaya suka. Mesin rekomendasi di bawah 20 baris yang bisa dipakai bersama mesin pencari juga memungkinkan. Jika menyimpan log sesi URL yang diklik, Anda bisa melihat sliding window setelah URL saat ini di tiap sesi, memberi bobot lebih tinggi untuk tautan yang lebih dekat, lalu membuat daftar rekomendasi
Jika hasil rekomendasi diurutkan dan hanya N teratas yang disisakan, Anda mendapatkan daftar URL rekomendasi untuk URL tertentu. Dengan sedikit penyesuaian, Anda juga bisa mencampurkan kueri pencarian yang dimasukkan dan URL yang diklik ke dalam log untuk menghasilkan saran ejaan
Sangat keren dan edukatif. Tapi jangan dipakai untuk deployment :-)
Dulu saya pernah membutuhkan sesuatu yang mirip, tetapi skalanya sedikit lebih besar, sekitar puluhan ribu dokumen, dan jawabannya, seperti biasa, adalah sqlite. Secara struktural sama seperti yang ada di sini, hanya saja lapisan persistensi indeks terbalik-nya sudah ditulis oleh orang lain
Jika mencari dengan tanda kutip ganda seperti
"search engine", Google hanya menampilkan hasil yang memuat dua kata itu dalam urutan tersebutSetidaknya begitu dalam sebagian kasus, tetapi sayangnya tidak selalu. Yang diinginkan pengguna mahir adalah “grep untuk web”, bukan “sesuatu yang memberi tahu apa yang ingin Google tampilkan”
Memang benar Google mengambil terlalu banyak kebebasan saat menafsirkan kueri, tetapi ada banyak pemrosesan yang jelas lebih baik dilakukan oleh mesin pencari mana pun daripada tidak dilakukan sama sekali. Masalah Google Search saat ini adalah sulit menebak mengapa hasil seperti itu muncul, dan tampaknya karena terlalu banyak bergantung pada embedding untuk perbandingan string. Menjengkelkan ketika
"cat food"dicocokkan dengan"dog restaurant", misalnya: secara semantik dekat di ruang embedding, tetapi tidak cocok dengan penalaran manusiaMenurut saya tidak adil menyebutnya kode 80 baris kalau memakai library eksternal seperti
feedparser,bs4, dan sebagainyaelasticsearch, saya setuju, tetapi jika bagian mesin pencari yang sebenarnya diimplementasikan dalam 80 baris itu, menurut saya adil. Library yang diimpor memang jenis yang sebaiknya tidak diimplementasikan sendiriKadang ada tulisan “membuat mesin pencari sendiri” yang sebenarnya hanya panduan instalasi
searxngatauyacy, tetapi ini berbeda dari kasus seperti ituBagus. Menambahkan fitur pencarian fuzzy ke sini sepertinya juga tidak terlalu sulit. Misalnya, agar pencarian
"hackrnew"cocok dengan"hackernews", caranya adalah mencari hasil yang jarak edit prefiksnya berada di bawah ambang tertentuIde dasarnya adalah menambahkan satu indeks terbalik lagi, dengan kunci berupa n-gram (biasanya 3-gram) dari kata-kata dalam koleksi dokumen, dan posting berupa kata atau ID kata tempat n-gram itu muncul. Kita bisa memakai lema berikut: jika
PED(x, y) <= delta, maka|N(x) ∩ N(y)| >= |N(x)| - n ∙ delta. Hitung n-gram dari inputx, ambil posting untuk tiap n-gram, lalu gabungkan duplikatnya untuk mendapatkan jumlah n-gram yang sama dengan tiap kandidat katay. Jika jumlah ini lebih besar dari batas, barulah hitung PED sebenarnya; jika lebih kecil, lewati, sehingga banyak mengurangi komputasi mahalDaftar kata yang diperoleh dengan cara ini kemudian cukup dipakai untuk menanyakan indeks yang sudah ada. Dulu saya memakai pendekatan ini saat membuat mesin pencari fuzzy JS sisi klien di https://dont.watch/. Jika melihat isi kode JS-nya, terlihat indeks terbalik dan indeks n-gram terkompresi dikirim apa adanya sebagai file JS. Mesin pencari sebenarnya sekitar 300 baris JS tanpa dependensi eksternal, dan hanya berisi heuristik yang sangat dasar untuk memperbaiki hasil pencarian