Hari ini kita akan membuat mesin FTS kita sendiri. Di akhir artikel ini, dia akan dapat mencari jutaan dokumen dalam waktu kurang dari satu milidetik. Kami akan mulai dengan kueri penelusuran sederhana seperti "Kembalikan semua dokumen dengan cat" dan kemudian memperluas mesin untuk mendukung kueri logis yang lebih kompleks.
Catatan: Mesin pencari teks lengkap paling terkenal adalah Lucene (dan juga Elasticsearch dan Solr dibangun di atasnya).
Mengapa Anda membutuhkan FTS
Sebelum Anda menulis kode apa pun, Anda mungkin bertanya: "Tidak bisakah Anda menggunakan grep atau loop yang memeriksa setiap dokumen untuk kata pencarian?" Ya kamu bisa. Tapi itu tidak selalu merupakan ide terbaik.
Perumahan
Kami akan mencari fragmen penjelasan dari Wikipedia bahasa Inggris. Dump terbaru tersedia di dumps.wikimedia.org . Saat ini, ukuran file setelah pembongkaran adalah 913 MB. File XML berisi lebih dari 600 ribu dokumen.
Contoh dokumen:
<title>Wikipedia: Kit-Cat Klock</title>
<url>https://en.wikipedia.org/wiki/Kit-Cat_Klock</url>
<abstract>The Kit-Cat Klock is an art deco novelty wall clock shaped like a grinning cat with cartoon eyes that swivel in time with its pendulum tail.</abstract>
Mengupload dokumen
Pertama, Anda perlu memuat semua dokumen dari dump menggunakan paket bawaan yang sangat praktis
encoding/xml:
import (
"encoding/xml"
"os"
)
type document struct {
Title string `xml:"title"`
URL string `xml:"url"`
Text string `xml:"abstract"`
ID int
}
func loadDocuments(path string) ([]document, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
dec := xml.NewDecoder(f)
dump := struct {
Documents []document `xml:"doc"`
}{}
if err := dec.Decode(&dump); err != nil {
return nil, err
}
docs := dump.Documents
for i := range docs {
docs[i].ID = i
}
return docs, nil
}
Setiap dokumen diberi ID unik. Untuk mempermudah, dokumen yang dimuat pertama diberi ID = 0, ID kedua = 1, dan seterusnya.
Percobaan pertama
Pencarian konten
Sekarang kita memiliki semua dokumen yang dimuat ke dalam memori, mari kita coba menemukan yang menyebutkan kucing. Pertama, mari telusuri semua dokumen dan periksa apakah ada substring
cat:
func search(docs []document, term string) []document {
var r []document
for _, doc := range docs {
if strings.Contains(doc.Text, term) {
r = append(r, doc)
}
}
return r
}
Di laptop saya, pencarian membutuhkan waktu 103ms - tidak terlalu buruk. Jika tempat memeriksa beberapa dokumen dari masalah ini, kita dapat melihat bahwa fungsi memberikan kepuasan pada kata ulat dan kategori , tetapi tidak pada Cat dengan huruf kapital C . Ini bukanlah yang kita cari.
Ada dua hal yang harus diperbaiki sebelum melanjutkan:
- Buat kotak pencarian tidak peka (sehingga keluarannya menyertakan Cat juga ).
- Pertimbangkan batasan kata, bukan substring (sehingga tidak ada kata seperti ulat dan komunikasi di keluaran ).
Telusuri dengan ekspresi reguler
Salah satu solusi jelas yang menyelesaikan kedua masalah tersebut adalah ekspresi reguler .
Dalam hal ini, kami membutuhkan
(?i)\bcat\b:
(?i)berarti ekspresi reguler tidak peka huruf besar / kecil
\bmenunjukkan korespondensi dengan batas kata (tempat di mana ada karakter di satu sisi dan bukan di sisi lain)
Tapi sekarang pencariannya memakan waktu lebih dari dua detik. Seperti yang Anda lihat, sistem mulai melambat bahkan pada 600 ribu dokumen sederhana. Meskipun pendekatan ini mudah diterapkan, pendekatan ini tidak berskala dengan baik. Saat kumpulan data bertambah, semakin banyak dokumen perlu dipindai. Kompleksitas waktu dari algoritma ini adalah linier, yaitu jumlah dokumen yang akan dipindai sama dengan jumlah dokumen. Jika kami memiliki 6 juta dokumen, bukan 600 ribu, pencarian akan memakan waktu 20 detik. Kami harus menemukan sesuatu yang lebih baik.
Indeks terbalik
Untuk mempercepat kueri penelusuran, kami akan memproses teks tersebut dan membuat indeks.
Inti dari FTS adalah struktur data yang disebut indeks terbalik . Ini menghubungkan setiap kata ke dokumen yang berisi kata itu.
Contoh:
documents = {
1: "a donut on a glass plate",
2: "only the donut",
3: "listen to the drum machine",
}
index = {
"a": [1],
"donut": [1, 2],
"on": [1],
"glass": [1],
"plate": [1],
"only": [2],
"the": [2, 3],
"listen": [3],
"to": [3],
"drum": [3],
"machine": [3],
}
Di bawah ini adalah contoh nyata dari indeks terbalik. Ini adalah indeks dalam sebuah buku yang istilahnya diikuti dengan nomor halaman:
Analisis teks
Sebelum mulai membuat indeks, Anda perlu membagi teks sumber menjadi daftar kata (token) yang sesuai untuk pengindeksan dan pencarian.
Penganalisis teks terdiri dari tokenizer dan beberapa filter.
Tokenizer
Tokenizer adalah langkah pertama dalam penguraian teks. Tugasnya adalah mengubah teks menjadi daftar token. Penerapan kami memisahkan teks pada batas kata dan menghapus tanda baca:
func tokenize(text string) []string {
return strings.FieldsFunc(text, func(r rune) bool {
// Split on any character that is not a letter or a number.
return !unicode.IsLetter(r) && !unicode.IsNumber(r)
})
}
> tokenize("A donut on a glass plate. Only the donuts.")
["A", "donut", "on", "a", "glass", "plate", "Only", "the", "donuts"]
Filter
Dalam kebanyakan kasus, mengonversi teks menjadi daftar token tidaklah cukup. Normalisasi tambahan akan diperlukan untuk memfasilitasi pengindeksan dan pencarian.
Huruf kecil
Untuk membuat pencarian tidak peka huruf besar / kecil, filter huruf kecil mengonversi token menjadi huruf kecil. Kata cAt , Cat, dan caT dinormalisasi ke dalam bentuk cat . Nanti, jika mengacu pada indeks, kami juga menormalkan kueri penelusuran menjadi huruf kecil, sehingga kueri penelusuran cAt menemukan kata Cat .
Menghapus kata-kata umum
Hampir setiap teks bahasa Inggris mengandung kata-kata umum seperti a , I , The, atau be . Mereka disebut kata-kata berhenti dan ada di hampir semua dokumen, jadi mereka harus dihapus.
Tidak ada daftar stopword "resmi". Mari kita hilangkan 10 teratas di daftar OEC . Jangan ragu untuk melengkapinya:
var stopwords = map[string]struct{}{ // I wish Go had built-in sets.
"a": {}, "and": {}, "be": {}, "have": {}, "i": {},
"in": {}, "of": {}, "that": {}, "the": {}, "to": {},
}
func stopwordFilter(tokens []string) []string {
r := make([]string, 0, len(tokens))
for _, token := range tokens {
if _, ok := stopwords[token]; !ok {
r = append(r, token)
}
}
return r
}
> stopwordFilter([]string{"a", "donut", "on", "a", "glass", "plate", "only", "the", "donuts"})
["donut", "on", "glass", "plate", "only", "donuts"]
Stemming
Karena aturan tata bahasa, ada berbagai bentuk kata dalam dokumen. Stemming menguranginya menjadi bentuk dasar. Misalnya, memancing , memancing dan memancing semuanya diringkas menjadi bentuk ikan utama .
Menerapkan stemming bukanlah tugas yang sepele dan tidak tercakup dalam artikel ini. Mari kita ambil salah satu modul yang ada :
import snowballeng "github.com/kljensen/snowball/english"
func stemmerFilter(tokens []string) []string {
r := make([]string, len(tokens))
for i, token := range tokens {
r[i] = snowballeng.Stem(token, false)
}
return r
}
> stemmerFilter([]string{"donut", "on", "glass", "plate", "only", "donuts"})
["donut", "on", "glass", "plate", "only", "donut"]
Catatan: Stemmer tidak selalu bekerja dengan benar. Sebagai contoh, beberapa dapat mempersingkat maskapai ke airlin .
Merakit penganalisis
func analyze(text string) []string {
tokens := tokenize(text)
tokens = lowercaseFilter(tokens)
tokens = stopwordFilter(tokens)
tokens = stemmerFilter(tokens)
return tokens
}
Tokenizer dan filter mengubah kalimat menjadi daftar token:
> analyze("A donut on a glass plate. Only the donuts.")
["donut", "on", "glass", "plate", "only", "donut"]
Token siap untuk diindeks.
Membangun indeks
Mari kembali ke indeks terbalik. Ini cocok dengan setiap kata dengan ID dokumen. Tipe data bawaan berfungsi dengan baik untuk menyimpan peta (menampilkan)
map. Kuncinya akan menjadi token (string), dan nilainya akan menjadi daftar ID dokumen:
type index map[string][]int
Dalam proses membangun indeks, dokumen dianalisis dan pengenalnya ditambahkan ke peta:
func (idx index) add(docs []document) {
for _, doc := range docs {
for _, token := range analyze(doc.Text) {
ids := idx[token]
if ids != nil && ids[len(ids)-1] == doc.ID {
// Don't add same ID twice.
continue
}
idx[token] = append(ids, doc.ID)
}
}
}
func main() {
idx := make(index)
idx.add([]document{{ID: 1, Text: "A donut on a glass plate. Only the donuts."}})
idx.add([]document{{ID: 2, Text: "donut is a donut"}})
fmt.Println(idx)
}
Semuanya bekerja! Setiap token dalam tampilan mengacu pada ID dari dokumen yang berisi token itu:
map[donut:[1 2] glass:[1] is:[2] on:[1] only:[1] plate:[1]]
Pertanyaan
Untuk kueri ke indeks, kami akan menerapkan tokenizer dan filter yang sama yang kami gunakan untuk mengindeks:
func (idx index) search(text string) [][]int {
var r [][]int
for _, token := range analyze(text) {
if ids, ok := idx[token]; ok {
r = append(r, ids)
}
}
return r
}
> idx.search("Small wild cat")
[[24, 173, 303, ...], [98, 173, 765, ...], [[24, 51, 173, ...]]
Dan sekarang, akhirnya, kami dapat menemukan semua dokumen yang menyebutkan kucing. Pencarian 600 ribu dokumen membutuhkan waktu kurang dari satu milidetik (18 Ξs)!
Dengan indeks terbalik, kompleksitas waktu kueri penelusuran adalah linier dengan jumlah token penelusuran. Dalam contoh kueri di atas, selain menganalisis teks masukan, hanya tiga pencarian peta yang dilakukan.
Pertanyaan logis
Permintaan sebelumnya mengembalikan daftar dokumen yang tidak ditautkan untuk setiap token. Namun kami biasanya mengharapkan penelusuran untuk kucing liar kecil mengembalikan daftar hasil yang berisi kucing kecil , liar, dan kucing . Langkah selanjutnya adalah menghitung persimpangan antara daftar. Dengan demikian, kami akan mendapatkan daftar dokumen yang sesuai dengan semua token.
Untungnya, ID dalam indeks terbalik kami dimasukkan dalam urutan menaik. Karena ID diurutkan, Anda dapat menghitung perpotongan antara daftar dalam waktu linier. Fungsi ini melakukan
intersectioniterasi melalui dua daftar secara bersamaan dan mengumpulkan pengenal yang ada di keduanya:
func intersection(a []int, b []int) []int {
maxLen := len(a)
if len(b) > maxLen {
maxLen = len(b)
}
r := make([]int, 0, maxLen)
var i, j int
for i < len(a) && j < len(b) {
if a[i] < b[j] {
i++
} else if a[i] > b[j] {
j++
} else {
r = append(r, a[i])
i++
j++
}
}
return r
}
Yang diperbarui
searchmengurai teks permintaan yang diberikan, mencari token dan menghitung persimpangan yang diberikan antara daftar ID:
func (idx index) search(text string) []int {
var r []int
for _, token := range analyze(text) {
if ids, ok := idx[token]; ok {
if r == nil {
r = ids
} else {
r = intersection(r, ids)
}
} else {
// Token doesn't exist.
return nil
}
}
return r
}
Sampah Wikipedia hanya berisi dua dokumen yang secara bersamaan berisi kata kecil , liar dan kucing :
> idx.search("Small wild cat")
130764 The wildcat is a species complex comprising two small wild cat species, the European wildcat (Felis silvestris) and the African wildcat (F. lybica).
131692 Catopuma is a genus containing two Asian small wild cat species, the Asian golden cat (C. temminckii) and the bay cat.
Pencarian berfungsi seperti yang diharapkan!
Ngomong-ngomong, saya pertama kali belajar tentang catopum , ini salah satunya:
kesimpulan
Jadi, kami membuat mesin pencari teks lengkap. Terlepas dari kesederhanaannya, ini dapat memberikan dasar yang kokoh untuk proyek yang lebih maju.
Saya belum menyebutkan banyak aspek yang dapat meningkatkan kinerja secara signifikan dan membuat pencarian lebih nyaman. Berikut beberapa ide untuk perbaikan lebih lanjut:
- Tambahkan operator logika ATAU dan BUKAN .
- Simpan indeks di disk:
- Perlu beberapa saat untuk memulihkan indeks setiap kali aplikasi dimulai ulang.
- Indeks besar mungkin tidak muat di memori.
- Perlu beberapa saat untuk memulihkan indeks setiap kali aplikasi dimulai ulang.
- Bereksperimen dengan memori dan format data yang dioptimalkan CPU untuk menyimpan kumpulan ID. Lihatlah Roaring Bitmaps .
- Mengindeks beberapa bidang dokumen.
- Urutkan hasil berdasarkan relevansi.
Semua kode sumber dipublikasikan di GitHub .