Eine Suchmaschine in 80 Zeilen Python
(alexmolas.com)microsearchist eine Toy-Implementierung, um das Innenleben einer Suchmaschine direkt zu verstehen. Die zentrale Suchmaschinenklasse umfasst weniger als 80 Zeilen, mit Crawler, API und HTML-Templates ist das Projekt aber größer.- Vor dem Hintergrund, dass kleine Websites und Blogs von großen Suchmaschinen oft schlecht gefunden werden, wurden Beiträge aus 642 RSS-Feeds gesammelt, um Suchdaten zu erstellen.
- Durch asynchrones Crawling auf Basis von
asynciosank die Erfassungszeit von 20 Minuten auf 20 Sekunden; der bereinigte Haupttext wird als Parquet-Daten gespeichert. - Die Suche arbeitet auf einem invertierten Index, der Wörter mit ihrer Häufigkeit pro URL verknüpft; für das Ranking der Ergebnisse wird statt linkbasiertem PageRank das inhaltsbasierte BM25 verwendet.
- Eine FastAPI-Oberfläche bietet Suchfeld und Ergebnisseite, aber Funktionen wie Query-Operatoren, n-Gram-Indexierung, Query-/Dokumenterweiterung oder Indexierung während des Crawlings fehlen noch.
Ziel und Umfang von microsearch
microsearchist eine in einem GitHub-Repository veröffentlichte Python-Implementierung einer Suchmaschine.- Das Ziel ist keine Suchmaschine für den Produktiveinsatz, sondern ein nutzbares Toy-Beispiel, das zeigt, wie Suchmaschinen intern funktionieren.
- Das Suchziel liegt näher bei kleinen Websites und Blogs, die im Google-SEO-Wettbewerb nicht gut gefunden werden.
- Die Kernimplementierung der Suchmaschine umfasst weniger als 80 Zeilen, aber mit Hilfscode wie Daten-Crawler, API und HTML-Templates ist das Gesamtprojekt größer.
- Die Implementierung entstand beim Arbeiten mit Solr und Lucene sowie beim Versuch, die Funktionsweise von Suchmaschinen tiefer zu verstehen.
RSS-basierter Crawler
- Um Daten für die Suche zu erzeugen, werden Blog-RSS-Feeds gecrawlt.
- Insgesamt wurden 642 RSS-Feeds verwendet.
- Etwa 100 davon sind Blogs, die der Autor selbst liest, etwa zu ML, Data Science und Mathematik.
- Die übrigen rund 500 stammen aus dem Projekt surprisetalk blogs.hn.
- Das Crawling folgt dem Ablauf, aus jedem RSS-Feed Beitrags-URLs zu extrahieren, das HTML der Beiträge herunterzuladen und anschließend den Haupttext zu bereinigen.
- Die HTML-Bereinigung entfernt mit
BeautifulSoupscriptundstyleund wandelt den Inhalt nach Bereinigung von Zeilenumbrüchen und Leerraum in Text um. - Mit asynchronem Crawling über
aiohttpundasynciosank die Laufzeit von 20 Minuten auf 20 Sekunden. - Das Ergebnis wird als
DataFramemit URL und bereinigtem Haupttext erstellt und inoutput.parquetgespeichert.
Struktur des invertierten Index
- Die erste zentrale Datenstruktur der Suchmaschine ist der invertierte Index.
- Ein invertierter Index bildet Keywords auf Dokumente ab und ermöglicht so, schnell zu finden, in welchen Dokumenten ein bestimmtes Wort vorkommt.
- Die Implementierung verwendet ein
defaultdictin der Formdict[str, dict[str, int]].- Der äußere Schlüssel ist das Wort.
- Der innere Schlüssel ist die URL.
- Der innere Wert ist die Anzahl, wie oft das Wort im Dokument dieser URL vorkommt.
- Die Klasse
SearchEnginebesitzt zwei interne Dictionaries._index: speichert die Häufigkeit pro URL für jedes Wort_documents: speichert den Originalinhalt pro URL
index(url, content)normalisiert den Inhalt, teilt ihn an Leerzeichen auf und erhöht dann für jedes Wort die Häufigkeit pro URL.bulk_index()nimmt Listen von URLs und Inhalten entgegen und indexiert mehrere Dokumente auf einmal.get_urls(keyword)normalisiert das Keyword und gibt die URLs zurück, die dieses Wort enthalten, zusammen mit ihrer Häufigkeit.
String-Normalisierung und einfache Suche
- Die String-Normalisierung ersetzt Satzzeichen durch Leerzeichen, bereinigt mehrfachen Leerraum und wandelt alles in Kleinbuchstaben um.
- Um Unterschiede bei Groß- und Kleinschreibung zu verringern, werden
Fooundfooals dasselbe Keyword behandelt. - Wenn zwei Beispieldokumente indexiert werden, liefert eine Suche nach
foobeide Dokumente zurück.Foo:Hello, World! My name is Foo!Bar:Hello, World! My name is Bar, I'm not Foo!
- In diesem Schritt ist nur bekannt, ob ein Dokument den Suchbegriff enthält und wie oft; um die Reihenfolge der Ergebnisse festzulegen, ist daher ein separates Ranking nötig.
BM25-Ranker
- Für die Sortierung der Suchergebnisse wird BM25 verwendet.
- PageRank rankt Dokumente auf Basis von Links, BM25 berechnet dagegen Bewertungen auf Grundlage des Dokumentinhalts.
SearchEnginebesitzt für die BM25-Berechnung die Standardparameterk1=1.5undb=0.75.- Die Klasse stellt Eigenschaften bereit, die für die Ranking-Berechnung benötigt werden.
posts: Liste der indexierten URLsnumber_of_documents: Gesamtzahl der Dokumenteavdl: durchschnittliche Dokumentlänge
idf(kw)berechnet die inverse Dokumenthäufigkeit für ein bestimmtes Keyword.- Gesamtzahl der Dokumente
N - Anzahl der Dokumente mit diesem Keyword
n_kw - Verwendet wird die Formel
log((N - n_kw + 0.5) / (n_kw + 0.5) + 1)
- Gesamtzahl der Dokumente
bm25(kw)berechnet für jede URL, die das Keyword enthält, den BM25-Score.search(query)normalisiert die Query, zerlegt sie in Wörter und summiert dann die BM25-Scores der einzelnen Wörter pro URL.- Im Beispiel erhält bei einer Suche nur nach
foodas DokumentFooeinen höheren Score alsBar; bei einer Suche nachfoo barerhältBarden höheren Score.
FastAPI-Oberfläche
- Die Suchmaschine wird als kleine FastAPI-App bereitgestellt.
- Die App erzeugt eine Instanz von
SearchEngineund liest beim Start URLs und Inhalte aus den Parquet-Daten, um sie mitbulk_index()zu indexieren. - Es gibt drei zentrale Routen.
/: rendert die Suchseite und übergibt die Liste der indexierten Beiträge/results/{query}: durchsucht die Query und zeigt die Top 5 URLs auf der Ergebnisseite an/about: rendert eine Infoseite
- Die Ergebnisse werden absteigend nach Score sortiert, anschließend werden nur die Top-N-URLs ausgewählt.
- Bei UI und UX gibt es noch viel Raum für Verbesserungen, aber die Suche arbeitet schnell und die Ergebnisse sind nicht schlecht.
Fehlende Funktionen und Einschränkungen
- In der Implementierung fehlen mehrere Funktionen, die man von einer echten Suchmaschine erwarten würde.
- Es gibt keine Query-Operatoren.
- Eine Suche wie bei Google
how to build a search engine -solr, bei der bestimmte Wörter ausgeschlossen werden, wird zum Beispiel nicht unterstützt.
- Eine Suche wie bei Google
- Es gibt keine n-Gram-Indexierung.
- Damit wird keine Suche unterstützt, die nur Dokumente findet, in denen zwei Wörter in einer bestimmten Reihenfolge vorkommen, etwa
"search engine".
- Damit wird keine Suche unterstützt, die nur Dokumente findet, in denen zwei Wörter in einer bestimmten Reihenfolge vorkommen, etwa
- Es gibt keine Query- oder Dokumenterweiterung.
- Wer nach
enginesucht, findet nicht automatisch auch Dokumente, dieenginesenthalten.
- Wer nach
- Crawling und Indexierung sind getrennt.
- Beides könnte so integriert werden, dass Dokumente direkt beim Empfang indexiert werden, und auch dieser Prozess ließe sich asynchron gestalten.
Nächste Schritte
- Durch das Projekt entsteht mehr Intuition dafür, wie Solr intern funktioniert.
- Bestätigt hat sich auch, dass asynchroner Code bei IO-lastigen Aufgaben einen großen Effekt hat.
- Der nächste Schritt ist, der Suchmaschine semantische Suche hinzuzufügen.
- Es wurde bereits mit Embedding-Modellen und ANN experimentiert, und diese Funktion in
microsearchzu integrieren, ist die nächste Aufgabe.
1 Kommentare
Meinungen auf Hacker News
Das ist wirklich cool. Ich baue mit Pandas eine ziemlich schnelle BM25-Suchmaschine für lokale Tests: https://github.com/softwaredoug/searcharray
Der Grund für Pandas ist, dass BM25 allein nicht ausreicht; ich möchte andere Faktoren wie Aktualität und Popularität mit pandas/numpy leicht berechnen und kombinieren können.
Nebenbei: Phrasensuche ist der schwierige Teil. Beim Phrase Matching gibt es viele Grenzfälle, und Dinge wie Slop müssen ebenfalls berücksichtigt werden. Positionsinformationen müssen außerdem in möglichst wenig Speicher komprimiert werden: https://github.com/softwaredoug/searcharray/blob/main/searcharray/utils/roaringish.py
Ich denke, ich habe es ziemlich gründlich getestet, würde mich aber über Feedback freuen. Die Positionsinformationen habe ich delta-codiert und in base36 codiert.
Stimmt. Die meisten schwierigen Teile bei Suche liegen im Umgang mit Datenumfang. Die Logik selbst kann erstaunlich einfach sein oder einfach gemacht werden.
Natürlich kann man sie auch endlos kompliziert machen, aber dieses Projekt hat die unnötigen Teile gut weggelassen. Wenn man es nicht als Problem angeht, die Suchmaschine größer zu machen, sondern die Daten physisch kleiner zu machen oder das Signal-Rausch-Verhältnis zu erhöhen, kommt man ziemlich weit.
Wenn man sich
src/microsearch/engine.pyansieht, gibt es dort Code wieSearchEngine.__init__(self, k1: float = 1.5, b: float = 0.75), aber ich habe keinerlei Ahnung, wask1oderbsind, und in der gesamten Datei gibt es keinen einzigen Kommentar.Sind Kommentare heutzutage nicht mehr angesagt?
_documentsscheint ein Mapping zu sein, bei dem der Schlüssel die URL und der Wert der Inhalt dieser URL ist, aber ich könnte mich irren. Das hätte Material sein können, mit dem man lernen und erweitern kann, wie man eine Suchmaschine baut, aber wegen der fehlenden Dokumentation ist die Codequalität enttäuschend.Spaß beiseite: Ich stimme zu, dass es normalerweise besser ist, wenn Dokumentation und Code zusammenstehen. In diesem Fall ist es aber ein Lehrprojekt, daher wurde Code und Dokumentation getrennt und der Code im Blogpost dokumentiert.
k1undbsind standardmäßige Gewichtungswerte, die bei TF-IDF oder BM25 verwendet werden; hier geht es um BM25.Kommentare wären hilfreich, aber für Leute, die mit diesem Problem vertraut sind, sind das auch sofort erkennbare Namen.
k1undbsind Einstellungsparameter der BM25-Rankingfunktion. Das sind keine Namen, die sich der Autor des Originalbeitrags neu ausgedacht hat; fast alle Implementierungen und Lehrbücher verwenden diese Variablennamen.Damit jemand aus dem Information-Retrieval-Bereich es versteht, ist es sogar richtig, sie
k1undbzu nennen: https://en.wikipedia.org/wiki/Okapi_BM25a: floatmuss ich immer an Rich Hickeys Vortrag „Man braucht keine Typen, man braucht richtige Namen“ denken.Ich hasse diese Tendenz, die sich anfühlt, als käme sie aus Go: unerklärte einbuchstabige Variablennamen zu verwenden und das Typsystem als Hilfsmittel für Namen zu missbrauchen. Namen können semantische Informationen darüber vermitteln, was ein Programm tut, also sollte man sie richtig verwenden.
Ich verstehe nicht, was es bringen soll, mit der Anzahl der Codezeilen statt mit der gesamten Zahl der
\r\nzu prahlen, wenn man externe Abhängigkeiten nutzt.Es gibt keine SI-Einheit zum Messen einer Codebase, aber die kognitive Last sollte man irgendwie messen.
collections,mathundstring, alles Standardbibliothek.Genauer gesagt wäre es vielleicht passender, sie „Suchmaschinen-Engine“ zu nennen. Crawler und Interface sind in diesen 80 Zeilen nicht enthalten, werden aber in irgendeiner Form benötigt, und die gezeigte Implementierung erhöht Zeilenzahl und Bibliotheken deutlich. Trotzdem haben diese Bibliotheken nichts mit der Suchmaschine selbst zu tun. Wenn man anfängt, allgemeine Abhängigkeiten wie
pandasoderfastapimitzuzählen, müsste man vielleicht auch die Millionen Zeilen des Betriebssystems, die Firmware der Netzwerkkarte und die Hardware-Komplexität mitzählen.Gefällt mir. Man kann auch eine Empfehlungs-Engine mit unter 20 Zeilen bauen, die zusammen mit einer Suchmaschine nutzbar ist. Wenn man Session-Logs der angeklickten URLs speichert, kann man in jeder Session ein Sliding Window hinter der aktuellen URL betrachten und näheren Links ein höheres Gewicht geben, um eine Empfehlungsliste zu erstellen.
Wenn man die Empfehlungen sortiert und nur die Top N behält, erhält man eine Liste empfohlener URLs für eine bestimmte URL. Mit etwas Feinschliff kann man auch eingegebene Suchbegriffe und angeklickte URLs in den Logs mischen und daraus Rechtschreibvorschläge ableiten.
Sehr cool und lehrreich. Nur bitte nicht deployen :-)
Früher brauchte ich mal etwas Ähnliches, allerdings in etwas größerem Maßstab mit einigen zehntausend Dokumenten, und die Antwort war wie immer sqlite. Strukturell ist es dasselbe wie hier, nur dass die Persistenzschicht für den invertierten Index jemand anderes für einen geschrieben hat.
Google zeigt bei einer Suche mit Anführungszeichen wie
"search engine"nur Ergebnisse an, in denen die beiden Wörter in genau dieser Reihenfolge vorkommen.Zumindest in manchen Fällen, aber leider nicht immer. Was Power-User wollen, ist „grep fürs Web“, nicht „Google sagt dir, was es dir zeigen will“.
Es stimmt, dass Google sich beim Interpretieren von Suchanfragen zu viele Freiheiten nimmt, aber es gibt viele Verarbeitungen, die bei jeder Suchmaschine eindeutig besser sind, als sie nicht zu machen. Das Problem der heutigen Google-Suche ist, dass schwer nachzuvollziehen ist, warum bestimmte Ergebnisse erscheinen; es wirkt, als läge das daran, dass sie sich beim String-Vergleich zu stark auf Embeddings stützt. Es ist frustrierend, wenn etwa
"cat food"mit"dog restaurant"matcht: Im Embedding-Raum semantisch nah, aber nicht passend zur menschlichen Schlussfolgerung.Ich finde es nicht fair, von 80 Zeilen Code zu sprechen, wenn externe Bibliotheken wie
feedparser,bs4usw. verwendet werden.elasticsearchgebaut wäre, würde ich zustimmen; wenn aber der eigentliche Suchmaschinen-Teil in diesen 80 Zeilen implementiert ist, finde ich es fair. Die eingebundenen Bibliotheken sind genau die Art von Dingen, die man besser nicht selbst implementiert.Manchmal sind Artikel mit dem Titel „Meine eigene Suchmaschine bauen“ in Wahrheit Installationsanleitungen für
searxngoderyacy; das hier ist etwas anderes.Gut. Es dürfte auch nicht besonders schwer sein, hier Fuzzy Search hinzuzufügen. Zum Beispiel so, dass eine Suche nach
"hackrnew"mit"hackernews"matcht, indem man Ergebnisse findet, deren Präfix-Edit-Distanz unter einem bestimmten Schwellwert liegt.Die Grundidee ist, einen weiteren invertierten Index anzulegen, dessen Schlüssel die n-Gramme (meist 3-Gramme) der Wörter in der Dokumentsammlung sind und dessen Postings die Wörter bzw. Wort-IDs sind, in denen dieses n-Gramm vorkommt. Man kann das Lemma
PED(x, y) <= deltaimpliziert|N(x) ∩ N(y)| >= |N(x)| - n ∙ deltanutzen. Man berechnet die n-Gramme der Eingabex, holt die Postings für jedes n-Gramm und führt Duplikate zusammen; so erhält man die Anzahl der n-Gramme, die jeder Kandidatymit der Eingabe teilt. Liegt diese Zahl über dem Grenzwert, berechnet man erst dann die tatsächliche PED; liegt sie darunter, überspringt man das und spart viel teure Berechnung.Die so erhaltene Wortliste kann man dann gegen den bestehenden Index abfragen. Ich habe diesen Ansatz früher verwendet, als ich für https://dont.watch/ eine clientseitige JS-Fuzzy-Suchmaschine gebaut habe. Wenn man in den JS-Code schaut, sieht man, dass der invertierte Index und der komprimierte n-Gramm-Index direkt als JS-Datei ausgeliefert werden. Die eigentliche Suchmaschine besteht aus etwa 300 Zeilen JS ohne externe Abhängigkeiten und enthält nur sehr grundlegende Heuristiken zur Verbesserung der Suchergebnisse.