Erstellen Sie Ihr eigenes Forum mit FastAPI: Schritt 8 – Volltextsuche
Lukas Schneider
DevOps Engineer · Leapcell

Im vorherigen Artikel haben wir ein grundlegendes Berechtigungssystem für unser Forum implementiert, das „Administrator“- und „Benutzerbann“-Funktionen unterstützt und damit die Grundlage für eine gesunde Community legte.
Da das Forum immer mehr Inhalte sammelt, fällt es den Benutzern möglicherweise schwer, alte Beiträge zu finden, an denen sie interessiert sind. Eine neue Anforderung zeichnet sich ab: Sollte es nicht eine Suchfunktion geben, die den Benutzern hilft, die Beiträge, die sie lesen möchten, schnell zu finden?
In diesem Artikel fügen wir unserem Forum eine Volltextsuchfunktion hinzu.
Wenn Sie sich mit SQL auskennen, denken Sie vielleicht: Kann man nicht einfach eine LIKE '%keyword%'-Abfrage verwenden, um die Suche zu implementieren? Für einfache Szenarien ist dies tatsächlich möglich. Aber LIKE-Abfragen schneiden bei der Verarbeitung großer Textmengen extrem schlecht ab und verstehen keine sprachlichen Feinheiten (z. B. findet die Suche nach „create“ nicht „creating“).
Daher werden wir eine professionellere und effizientere Lösung wählen: die integrierte Volltextsuchfunktion (FTS) von PostgreSQL. Sie ist nicht nur schnell, sondern unterstützt auch Stemming, das Ignorieren von Stoppwörtern und die Sortierung nach Relevanz, was Suchfunktionen bietet, die weitaus überlegen sind als LIKE.
Schritt 1: Datenbank-Suchinfrastruktur (SQL)
Um die FTS-Funktion von PostgreSQL zu nutzen, müssen wir zunächst einige Änderungen an unserer posts-Tabelle vornehmen: Wir erstellen eine spezielle Spalte, die speziell für die Speicherung optimierter, hochgeschwindigter durchsuchbarer Textdaten bestimmt ist.
Hinzufügen der tsvector-Spalte
Wir fügen der posts-Tabelle eine neue Spalte namens search_vector vom Typ tsvector hinzu. Ihr Zweck ähnelt einem Wörterbuch, das den Titel und Inhalt des Beitrags in einzelne Wörter (Lexeme) zerlegt und diese verarbeitet.
ALTER TABLE posts ADD COLUMN "search_vector" tsvector;
Verwenden eines Triggers zum automatischen Aktualisieren der tsvector-Spalte
Die Spalte search_vector enthält keine Inhalte für sich genommen. Wir müssen den Titel und den Inhalt in das tsvector-Format konvertieren und in diese Spalte schreiben.
Niemand möchte die Spalte search_vector manuell aktualisieren, jedes Mal wenn ein Beitrag erstellt oder aktualisiert wird. Der beste Weg ist, die Datenbank diese Arbeit automatisch mithilfe eines Triggers erledigen zu lassen.
Lassen Sie uns zuerst eine Funktion erstellen. Die Aufgabe dieser Funktion ist es, den title und content zu verketten und sie in das tsvector-Format zu konvertieren.
CREATE OR REPLACE FUNCTION update_post_search_vector() RETURNS TRIGGER AS $$ BEGIN NEW.search_vector := setweight(to_tsvector('english', coalesce(NEW.title, '')), 'A') || setweight(to_tsvector('english', coalesce(NEW.content, '')), 'B'); RETURN NEW; END; $$ LANGUAGE plpgsql;
Die Funktion
setweightermöglicht es uns, verschiedenen Texten aus verschiedenen Feldern unterschiedliche Gewichte zuzuweisen. Hier setzen wir das Gewicht für den Titel ('A') höher als für den Inhalt ('B'). Das bedeutet, dass Beiträge mit dem Schlüsselwort im Titel in den Suchergebnissen höher eingestuft werden.
Als Nächstes erstellen wir einen Trigger, der die von uns gerade erstellte Funktion automatisch aufruft, jedes Mal wenn ein neuer Beitrag eingefügt (INSERT) oder aktualisiert (UPDATE) wird.
CREATE TRIGGER post_search_vector_update BEFORE INSERT OR UPDATE ON posts FOR EACH ROW EXECUTE FUNCTION update_post_search_vector();
Erstellen des Suchindex
Um die Suchgeschwindigkeit sicherzustellen, ist der letzte Schritt die Erstellung eines GIN-Indexes (Generalized Inverted Index) für die Spalte search_vector.
CREATE INDEX post_search_vector_idx ON posts USING gin(search_vector);
Schritt 2: Auffüllen bestehender Daten
Es ist wichtig zu beachten, dass der von uns erstellte Trigger nur für Beiträge funktioniert, die in der Zukunft erstellt oder geändert werden. Für Beiträge, die bereits in der Datenbank vorhanden sind, ist das Feld search_vector immer noch NULL.
Wir müssen eine einmalige UPDATE-Anweisung ausführen, um Suchvektoren für alle vorhandenen Beiträge zu generieren:
-- Backfill search_vector for all existing posts UPDATE posts SET search_vector = setweight(to_tsvector('english', coalesce(title, '')), 'A') || setweight(to_tsvector('english', coalesce(content, '')), 'B');
Wenn Ihre Datenbank mit Leapcell erstellt wurde,
können Sie diese SQL-Anweisungen direkt in seinem webbasierten Bedienfeld ausführen.

Schritt 3: Erstellen der Suchergebnisseite
Wir benötigen eine neue HTML-Seite, um die Suchergebnisse anzuzeigen.
Erstellen Sie im Ordner templates eine neue Datei namens search_results.html. Diese Seite ist posts.html sehr ähnlich, zeigt aber zusätzlich die Suchanfrage des Benutzers an.
templates/search_results.html
<!DOCTYPE html> <html> <head> <title>Search Results - My FastAPI Forum</title> <style> /* (Copy all styles from templates/posts.html) */ body { font-family: sans-serif; margin: 2em; } input, textarea { width: 100%; padding: 8px; margin-bottom: 10px; box-sizing: border-box; } button { padding: 10px 15px; background-color: #007bff; color: white; border: none; cursor: pointer; } header { display: flex; justify-content: space-between; align-items: center; } .post-item { border: 1px solid #ccc; padding: 10px; margin-bottom: 10px; } /* (End of copied styles) */ </style> </head> <body> <header> <h1><a href="/posts" style="text-decoration: none; color: black;">Welcome to my Forum</a></h1> <div class="auth-links"> {% if current_user %} <span>Welcome, {{ current_user.username }}!</span> {% if current_user.is_admin %} <a href="/admin" style="color: red; font-weight: bold;">[Admin Panel]</a> {% endif %} <a href="/logout">Logout</a> {% else %} <a href="/login">Login</a> | <a href="/register">Register</a> {% endif %} </div> </header> <form action="/search" method="GET" style="display: inline-block;"> <input type="search" name="q" placeholder="Search posts..." value="{{ query | escape }}" /> <button type="submit">Search</button> </form> <hr /> <h2>Search Results: "{{ query | escape }}"</h2> {% if posts %} {% for post in posts %} <div class="post-item"> <a href="/posts/{{ post.id }}"><h3>{{ post.title }}</h3></a> <p>{{ post.content }}</p> <small>Author: {{ post.owner.username if post.owner else 'Unknown' }}</small> </div> {% endfor %} {% else %} <p>No posts found matching "{{ query | escape }}". Please try different keywords.</p> {% endif %} </body> </html>
Beachten Sie, dass wir auch {{ query }} im value-Attribut des Suchfelds platziert haben. Auf diese Weise behält das Suchfeld nach der Suche den Suchbegriff des Benutzers bei.
Schritt 4: Implementieren der Such-Backend-Route
Mit der Datenbank und der Frontend-Seite bereit, fügen wir nun die Backend-Logik in main.py hinzu, um Suchanfragen zu verarbeiten.
Aktualisieren Sie zunächst das Post-Modell:
models.py
# ... (previous imports) ... from sqlalchemy.dialects.postgresql import TSVECTOR class Post(Base): __tablename__ = "posts" # ... (other existing fields) # --- New field --- search_vector = Column(TSVECTOR, nullable=True)
Ändern Sie als Nächstes main.py:
main.py (Neue Route und Imports hinzufügen)
# ... (previous imports) ... from fastapi import Query from sqlalchemy import func, desc from sqlalchemy.orm import selectinload # ... (app, templates, dependencies get_db, get_current_user, get_admin_user remain unchanged) ... # --- Routers --- # ... (previous routes /, /posts, /api/posts, /admin, etc. remain unchanged) ... # 1. Add new search route @app.get("/search", response_class=HTMLResponse) async def search_posts( request: Request, q: Optional[str] = Query(None), # Get 'q' parameter from URL query string db: AsyncSession = Depends(get_db), current_user: Optional[models.User] = Depends(get_current_user) ): posts = [] if q and q.strip(): # 1. Process search term: replace spaces with '&' (AND operator) processed_query = " & ".join(q.strip().split()) # 2. Build FTS query # func.to_tsquery('english', ...) converts the query string to tsquery type # models.Post.search_vector.op('@@')(...) is the FTS match operator # func.ts_rank(...) calculates the relevance rank stmt = ( select(models.Post) .where(models.Post.search_vector.op('@@')(func.to_tsquery('english', processed_query))) .order_by(desc(func.ts_rank( models.Post.search_vector, func.to_tsquery('english', processed_query) ))) .options(selectinload(models.Post.owner)) # Preload owner information ) result = await db.execute(stmt) posts = result.scalars().all() return templates.TemplateResponse("search_results.html", { "request": request, "posts": posts, "query": q if q else "", "current_user": current_user }) # ... (subsequent routes /posts/{post_id}, /posts/{post_id}/comments, etc. remain unchanged) ...
Die neue GET /search-Route tut im Wesentlichen Folgendes:
- Liest den Parameter q(Suchbegriff), ersetzt Leerzeichen darin durch&, sodass die Abfrage mit allen Schlüsselwörtern übereinstimmt.
- Verwendet func.to_tsquery,func.ts_rankundop('@@'), um die spezielle FTS-Abfrage zu erstellen und sortiert die Ergebnisse absteigend nach Relevanz (ts_rank).
- Rendert die Vorlage search_results.htmlmit den Abfrageergebnissen.
Schritt 5: Hinzufügen der Suchbox zur Homepageünftes
Schließlich müssen wir dem Benutzer auf der Homepage des Forums einen Einstiegspunkt für die Suche bieten.
Modifizieren Sie templates/posts.html, um das Suchformular im <header> hinzuzufügen.
templates/posts.html (Header aktualisieren)
... (head and style remain unchanged) ... <body> <header> <h1><a href="/posts" style="text-decoration: none; color: black;">Welcome to My Forum</a></h1> <div class="auth-links"> {% if current_user %} <span>Welcome, {{ current_user.username }}!</span> {% if current_user.is_admin %} <a href="/admin" style="color: red; font-weight: bold;">[Admin Panel]</a> {% endif %} <a href="/logout">Logout</a> {% else %} <a href="/login">Login</a> | <a href="/register">Register</a> {% endif %} </div> </header> <form action="/search" method="GET" style="display: inline-block;"> <input type="search" name="q" placeholder="Search posts..." /> <button type="submit">Search</button> </form> ... (rest of the page remains unchanged) ... </body> </html>
Wir haben auch einen <a>-Link zum <h1>-Tag hinzugefügt, damit Benutzer auf den Titel klicken können, um zur Homepage zurückzukehren.
Ausführen und Verifizieren
Das Feature ist nun implementiert. Starten Sie Ihren uvicorn-Server neu:
uvicorn main:app --reload
Öffnen Sie Ihren Browser und rufen Sie http://127.0.0.1:8000 auf.
Sie sehen eine neue Suchbox neben dem Titel oben auf der Seite.

Geben Sie ein beliebiges Wort in das Suchfeld ein und drücken Sie Enter. Die Seite wird zur Route /search weitergeleitet und die entsprechenden Beiträge angezeigt.

Zusammenfassung
Durch die Nutzung der PostgreSQL FTS haben wir unserem Forum eine leistungsstarke und professionelle Volltextsuchfunktion hinzugefügt. Benutzer können nun problemlos frühere Beiträge finden.
Als Nächstes werden wir unser Forum weiter mit Funktionen bereichern. Ihnen ist vielleicht aufgefallen, dass Beiträge nur einfacher Text sein können und keine Bilder enthalten dürfen.
Im nächsten Artikel werden wir implementieren: Benutzern erlauben, Bilder beim Erstellen eines Beitrags hochzuladen.

