FastAPIで独自のフォーラムを構築する:ステップ8 - 全文検索
Lukas Schneider
DevOps Engineer · Leapcell

前の記事では、フォーラムの基本的な権限システムを実装し、「管理者」と「ユーザーの禁止」機能をサポートして、健全なコミュニティの基盤を築きました。
フォーラムにコンテンツが増えるにつれて、ユーザーは興味のある古い投稿を見つけるのが難しくなるかもしれません。新しい要件が発生しています。ユーザーが読みたい記事をすばやく見つけるための検索機能が必要なのではないでしょうか?
この記事では、フォーラムに全文検索機能を追加します。
SQLの知識があれば、「LIKE '%keyword%'クエリを使用して検索を実装できないか?」と考えるかもしれません。簡単なシナリオでは、これは実際可能です。しかし、LIKEクエリは大量のテキストを扱う際には極端にパフォーマンスが悪く、言語的な複雑さを理解できません(たとえば、「create」を検索しても「creating」には一致しません)。
したがって、より専門的で効率的なソリューションを採用します。PostgreSQLの組み込み全文検索(FTS)機能を使用します。これは高速であるだけでなく、ステミング、ストップワードの無視、関連性によるソートもサポートしており、LIKEよりもはるかに優れた検索機能を提供します。
ステップ1:データベース検索インフラストラクチャ(SQL)
PostgreSQLのFTS機能を使用するには、まずpostsテーブルにいくつかの変更を加える必要があります。最適化された高速検索可能なテキストデータを格納するために、特別な列を作成します。
tsvector列の追加
postsテーブルにtsvector型の新しい列search_vectorを追加します。これは辞書のようなもので、投稿のタイトルとコンテンツを個々の単語(レキセム)に分解して処理します。
ALTER TABLE posts ADD COLUMN "search_vector" tsvector;
tsvector列を自動的に更新するためのトリガーの使用
search_vector列自体にはコンテンツはありません。タイトルとコンテンツをtsvector形式に変換し、この列に書き込む必要があります。
投稿が作成または更新されるたびにsearch_vector列を手動で更新する人は誰もいません。最善の方法は、トリガーを使用してデータベースにこの作業を自動的に実行させることです。
まず、関数を作成します。この関数の役割は、titleとcontentを連結し、それらをtsvector形式に変換することです。
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;
setweight関数を使用すると、異なるフィールドからのテキストに異なる重みを設定できます。ここでは、タイトル('A')の重みをコンテンツ('B')よりも高く設定しています。これは、検索結果において、タイトルにキーワードが含まれる投稿がより高くランク付けされることを意味します。
次に、新しい投稿が挿入(INSERT)または更新(UPDATE)されるたびに、作成した関数を自動的に呼び出すトリガーを作成します。
CREATE TRIGGER post_search_vector_update BEFORE INSERT OR UPDATE ON posts FOR EACH ROW EXECUTE FUNCTION update_post_search_vector();
検索インデックスの作成
検索速度を確保するために、最後のステップはsearch_vector列にGIN(Generalized Inverted Index)インデックスを作成することです。
CREATE INDEX post_search_vector_idx ON posts USING gin(search_vector);
ステップ2:既存データのバックフィル
作成したトリガーは、今後作成または変更される投稿にのみ機能することに注意してください。データベースに既に存在する投稿については、search_vectorフィールドはまだNULLです。
既存のすべての投稿の検索ベクトルを生成するために、1回限りのUPDATEステートメントを実行する必要があります。
-- For all existing posts, backfill the search_vector UPDATE posts SET search_vector = setweight(to_tsvector('english', coalesce(title, '')), 'A') || setweight(to_tsvector('english', coalesce(content, '')), 'B');
データベースがLeapcellを使用して作成された場合、
これらのSQLステートメントをWebベースの操作パネルで直接実行できます。

ステップ3:検索結果ページの作成
検索結果を表示するための新しいHTMLページが必要です。
templatesフォルダーにsearch_results.htmlという名前の新しいファイルを作成します。このページはposts.htmlに非常に似ていますが、ユーザーの検索クエリも追加で表示されます。
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>
検索ボックスのvalue属性にも{{ query }}を配置したことに注意してください。これにより、検索後も検索ボックスにユーザーの検索用語が保持されます。
ステップ4:検索バックエンドルートの実装
データベースとフロントエンドページが準備できたので、main.pyに検索リクエストを処理するバックエンドロジックを追加します。
まず、Postモデルを更新します。
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)
次に、main.pyを修正します。
main.py (新しいルートとインポートを追加)
# ... (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) ... # --- Routes --- # ... (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) ...
新しいGET /searchルートは主に以下のことを行います。
- qパラメータ(検索用語)を読み取り、スペースを- &に置き換えます。これにより、クエリはすべてのキーワードに一致します。
- func.to_tsquery、- func.ts_rank、および- op('@@')を使用して、専門的なFTSクエリを構築し、結果を関連性(- ts_rank)で降順にソートします。
- クエリ結果でsearch_results.htmlテンプレートをレンダリングします。
ステップ5:ホームページへの検索ボックスの追加
最後に、フォーラムのホームページでユーザーに検索エントリポイントを提供する必要があります。
templates/posts.htmlを修正して、<header>に検索フォームを追加します。
templates/posts.html (ヘッダーを更新)
... (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>
また、<h1>タグに<a>リンクを追加し、ユーザーがタイトルをクリックしてホームページに戻れるようにしました。
実行と検証
機能が実装されました。uvicornサーバーを再起動します。
uvicorn main:app --reload
ブラウザを開き、http://127.0.0.1:8000にアクセスします。
ページの上部にあるタイトル横に新しい検索ボックスが表示されます。

検索ボックスに任意の単語を入力してEnterキーを押します。ページは/searchルートにリダイレクトされ、関連する投稿が表示されます。

まとめ
PostgreSQL FTSを活用することで、フォーラムに強力でプロフェッショナルな全文検索機能を追加しました。ユーザーは過去の投稿を簡単に見つけることができるようになりました。
次に、フォーラムの機能をさらに充実させていきましょう。投稿はプレーンテキストのみで、画像を含めることができないことに気づいたかもしれません。
次の記事では、投稿作成時にユーザーが画像をアップロードできるように実装します。
