FastAPIで実践する全文検索・管理画面検索API設計入門:LIKE検索からPostgreSQL FTS・SQLite FTS5・インデックス設計まで
要約
- 管理画面やSaaSの検索APIは、単なる
LIKE '%keyword%'ではなく、検索条件・絞り込み・並び順・ページネーション・権限・監査まで含めて設計する必要があります。 - FastAPIでは、
Queryによるクエリパラメータの型定義やバリデーション、依存関数による検索条件の共通化がしやすく、検索APIの入口をきれいに整えられます。 - 小規模なら
LIKE/ILIKEと通常インデックスから始めても十分ですが、データ量が増えると PostgreSQL の全文検索(tsvector/tsquery)や SQLite FTS5、外部検索エンジンの検討が必要になります。 - 管理画面検索では、全文検索だけでなく「状態」「日付範囲」「テナント」「担当者」「並び順」などのフィルタが重要です。検索APIは、検索ボックスだけでなく業務オペレーション全体を支える機能として設計します。
- 本記事では、FastAPIでの検索API設計を、基本方針 → クエリパラメータ設計 → SQLAlchemy検索 → PostgreSQL全文検索 → SQLite FTS5 → 権限・監査・テストの順で整理します。
誰が読んで得をするか
個人開発・学習者さん
FastAPIでCRUD APIを作れるようになり、「次は一覧画面に検索ボックスを付けたい」と考えている方に向いています。
最初は name LIKE '%q%' のような実装で十分に見えますが、少しデータが増えると、検索速度や条件の増え方に悩みやすくなります。この記事では、最初のシンプルな検索から、将来の全文検索まで段階的に進める道筋をお伝えします。
小規模チームのバックエンドエンジニアさん
社内管理画面やSaaS管理画面で、ユーザー検索、注文検索、問い合わせ検索、監査ログ検索などを作っている方に向いています。
検索条件が画面ごとにバラバラになり、「どのパラメータが使えるのか」「ページングやソートをどう統一するのか」が曖昧になっている場合、共通設計を作るきっかけになります。
SaaS開発チーム・スタートアップの皆さま
テナント数やデータ量が増え、単純な LIKE 検索では遅くなってきたチームに向いています。
PostgreSQL全文検索、SQLite FTS5、将来的なOpenSearch/Elasticsearch系への移行などを見据えつつ、FastAPI側のAPI設計をどう安定させるかを整理できます。検索はCS、営業、経理、運営の業務効率に直結するため、早めに基盤として扱う価値があります。
アクセシビリティ評価
- 冒頭に要約を置き、その後に対象読者、基本方針、実装例、運用設計、テストへ進む構成にしています。
- 「LIKE」「全文検索」「インデックス」「ランキング」などの用語は、初出時に短く補足しています。
- コード例は小さな責務ごとに分け、1ブロックで1つの考え方だけを扱っています。
- 管理画面利用者の視点も含め、検索結果の見つけやすさ、エラー時の分かりやすさ、絞り込み条件の保持など、アクセシビリティと業務効率の両方を意識しています。
- 目標レベルはAA相当です。
1. 検索APIは「便利機能」ではなく業務基盤
検索APIは、最初は小さな機能に見えます。
たとえば、ユーザー一覧に検索ボックスを付けて、メールアドレスや名前で絞り込むだけなら、実装はそれほど難しくありません。
しかし、実務ではすぐに次のような要件が増えます。
- メールアドレス、名前、会社名で検索したい
- ステータスで絞り込みたい
- 作成日や最終ログイン日で範囲指定したい
- テナントごとに検索結果を分けたい
- 関連テーブルの値でも検索したい
- 検索結果をCSV出力したい
- 権限のあるデータだけ見せたい
- 検索が遅いので改善したい
つまり、検索APIは単なる q パラメータではなく、業務オペレーションを支える基盤です。
特に社内管理画面では、CS担当者が問い合わせ対応をするとき、経理担当者が請求対象を探すとき、運営担当者が不正調査をするとき、検索の使いやすさがそのまま作業時間に影響します。
FastAPIは、クエリパラメータの型定義やバリデーションを宣言的に書けます。公式ドキュメントでも、FastAPIはパスパラメータとクエリパラメータを名前から判別でき、クエリパラメータには型やデフォルト値を付けられると説明されています。
参考: FastAPI Query Parameters
2. 検索には種類がある:完全一致、部分一致、全文検索、ファセット
検索APIを設計するときは、まず「どんな検索をしたいのか」を分けて考えると整理しやすくなります。
2.1 完全一致検索
ID、メールアドレス、注文番号、外部決済IDなど、値が完全に一致するものを探す検索です。
例:
email = "user@example.com"
order_code = "ORD-2026-0001"
この種類の検索は、通常のB-treeインデックスと相性がよく、比較的高速にできます。
2.2 部分一致検索
名前やタイトルの一部を含むものを探す検索です。
例:
name LIKE '%山田%'
title ILIKE '%fastapi%'
小規模では便利ですが、前方に % が付く検索は通常インデックスが効きにくく、データ量が増えると遅くなりやすいです。
2.3 全文検索
文章や長いテキストの中から、単語や関連語をもとに探す検索です。
PostgreSQLでは全文検索機能があり、tsvector と tsquery を使って文書と検索クエリを扱えます。公式ドキュメントでは、全文検索は自然言語文書を検索し、必要に応じて関連度順に並べる機能として説明されています。
参考: PostgreSQL Full Text Search
2.4 ファセット検索
検索結果に対して、状態、カテゴリ、担当者、日付範囲などで絞り込む検索です。
例:
q = "請求"
status = "open"
assignee_id = 3
created_from = "2026-05-01"
created_to = "2026-05-31"
管理画面では、全文検索よりもこのファセット検索のほうが重要になることも多いです。
検索ボックスだけでなく、絞り込み条件をどう設計するかが、管理画面の使いやすさを大きく左右します。
3. FastAPIで検索パラメータを設計する
まずは、検索APIの入口となるクエリパラメータを整理します。
3.1 最小の検索API
from fastapi import APIRouter, Query
router = APIRouter(prefix="/admin/users", tags=["admin-users"])
@router.get("")
def search_users(
q: str | None = Query(default=None, description="名前・メールアドレスで検索"),
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
):
return {
"items": [],
"meta": {
"q": q,
"limit": limit,
"offset": offset,
},
}
ここでは、q、limit、offset を定義しています。
FastAPIの Query を使うことで、説明、最小値、最大値などをOpenAPIにも反映できます。検索APIは管理画面フロントと密接に連携するため、OpenAPIで条件が見えることは大きな利点です。
3.2 条件が増えたらモデル化する
検索条件が増えてくると、ルーターの引数が長くなります。
その場合は、依存関数として検索条件オブジェクトを作ると読みやすくなります。
from dataclasses import dataclass
from typing import Literal
from fastapi import Depends, Query
@dataclass
class UserSearchParams:
q: str | None
status: str | None
sort: str
limit: int
offset: int
def get_user_search_params(
q: str | None = Query(default=None, description="名前・メールアドレスで検索"),
status: Literal["active", "suspended", "invited"] | None = Query(default=None),
sort: Literal["created_desc", "created_asc", "email_asc"] = Query(default="created_desc"),
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
) -> UserSearchParams:
return UserSearchParams(
q=q,
status=status,
sort=sort,
limit=limit,
offset=offset,
)
ルーター側はすっきりします。
@router.get("")
def search_users(
params: UserSearchParams = Depends(get_user_search_params),
):
return {"items": [], "meta": params.__dict__}
この形にしておくと、同じ検索条件をCSV出力や管理画面一覧で再利用しやすくなります。
4. ページネーションとソートは最初から統一する
検索APIで後からつらくなりやすいのが、ページネーションとソートです。
4.1 offset/limit方式
管理画面では、offset / limit が分かりやすいです。
GET /admin/users?q=yamada&limit=50&offset=0
メリットは、ページ番号UIを作りやすいことです。
デメリットは、データ量が非常に多くなると深いページで遅くなりやすいことです。
4.2 cursor方式
フィードや大量データでは、cursor 方式が向いています。
GET /admin/audit-logs?cursor=eyJpZCI6...
ただし、管理画面で「3ページ目へ移動」のようなUIを作りたい場合は、offset のほうが扱いやすいこともあります。
4.3 ソートキーを自由入力にしない
ソート条件をそのままSQLへ入れるのは危険です。
必ず許可リストから選ばせます。
SORT_MAP = {
"created_desc": ("created_at", "desc"),
"created_asc": ("created_at", "asc"),
"email_asc": ("email", "asc"),
}
ユーザー入力の sort をそのまま ORDER BY に入れず、内部の対応表に変換するのが安全です。
5. SQLAlchemyで基本検索を組み立てる
SQLAlchemy 2.0系では、select() を使ったスタイルが中心です。公式ドキュメントでも、ORM Querying Guide は 2.0 style の select() を前提に説明されています。
参考: SQLAlchemy ORM Querying Guide
5.1 モデル例
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import Integer, String, DateTime
from datetime import datetime
from app.db.base import Base
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
tenant_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False)
email: Mapped[str] = mapped_column(String(255), index=True, nullable=False)
name: Mapped[str] = mapped_column(String(255), index=True, nullable=False)
status: Mapped[str] = mapped_column(String(50), index=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
5.2 検索クエリを作る
from sqlalchemy import select, or_
from sqlalchemy.orm import Session
def search_users_query(
db: Session,
tenant_id: int,
params: UserSearchParams,
):
stmt = select(User).where(User.tenant_id == tenant_id)
if params.q:
keyword = f"%{params.q}%"
stmt = stmt.where(
or_(
User.email.ilike(keyword),
User.name.ilike(keyword),
)
)
if params.status:
stmt = stmt.where(User.status == params.status)
if params.sort == "created_asc":
stmt = stmt.order_by(User.created_at.asc())
elif params.sort == "email_asc":
stmt = stmt.order_by(User.email.asc())
else:
stmt = stmt.order_by(User.created_at.desc())
stmt = stmt.limit(params.limit).offset(params.offset)
return db.execute(stmt).scalars().all()
ポイントは、必ず tenant_id を条件に含めることです。
マルチテナント環境では、検索APIこそ境界漏れが起きやすいので、検索条件の最初にテナント条件を入れる設計を徹底します。
6. LIKE / ILIKE の限界を理解する
LIKE や ILIKE は手軽ですが、万能ではありません。
向いているケース
- 小規模データ
- 管理画面の簡易検索
- メールアドレスや名前の部分一致
- 速度要件が厳しくない一覧
苦しくなるケース
- 数十万件以上のテーブル
- 長い本文の検索
- 関連度順で並べたい
- 日本語や複数言語の自然文検索
- 複数カラムをまたぐ検索
特に %keyword% のような前方ワイルドカード付き検索は、通常のB-treeインデックスが効きにくくなります。
最初は ILIKE で十分でも、データ量が増えたら全文検索や専用検索基盤へ進む判断が必要です。
7. PostgreSQL全文検索の考え方
PostgreSQLの全文検索では、文書を tsvector に変換し、検索語を tsquery に変換して照合します。公式ドキュメントでは、全文検索には文書から tsvector を作る関数、ユーザークエリから tsquery を作る関数、関連度順に並べるための関数が必要だと説明されています。
参考: PostgreSQL Controlling Text Search
7.1 基本イメージ
SELECT *
FROM articles
WHERE to_tsvector('english', title || ' ' || body)
@@ plainto_tsquery('english', 'fastapi search');
ここでは、title と body を検索対象にしています。
plainto_tsquery は、ユーザーが入力した自然な検索文字列を検索クエリへ変換する用途で使われます。
7.2 関連度順で並べる
SELECT *,
ts_rank(
to_tsvector('english', title || ' ' || body),
plainto_tsquery('english', 'fastapi search')
) AS rank
FROM articles
WHERE to_tsvector('english', title || ' ' || body)
@@ plainto_tsquery('english', 'fastapi search')
ORDER BY rank DESC;
全文検索では、「含まれるか」だけでなく「どれくらい関連していそうか」を扱えるのが大きな利点です。
8. SQLAlchemyからPostgreSQL全文検索を使う例
SQLAlchemyからPostgreSQLの関数を呼ぶ場合、func を使います。
from sqlalchemy import select, func
def search_articles_fulltext(db: Session, tenant_id: int, q: str):
document = func.to_tsvector(
"english",
Article.title + " " + Article.body,
)
query = func.plainto_tsquery("english", q)
rank = func.ts_rank(document, query).label("rank")
stmt = (
select(Article, rank)
.where(Article.tenant_id == tenant_id)
.where(document.op("@@")(query))
.order_by(rank.desc())
.limit(50)
)
return db.execute(stmt).all()
この例は概念を見せるためのものです。
実務では、毎回 to_tsvector を動的に計算すると重くなりやすいため、生成列や専用カラム、GINインデックスなどを検討します。PostgreSQL公式ドキュメントでも、全文検索では文書・クエリ・ランキングの関数を組み合わせる前提が説明されています。
参考: PostgreSQL Full Text Search
9. SQLite FTS5の考え方
学習用や小規模アプリ、ローカルツールではSQLiteを使うことも多いです。
SQLiteにはFTS5という全文検索拡張があります。公式ドキュメントでは、FTS5はアプリケーションに全文検索機能を提供する仮想テーブルモジュールだと説明されています。
参考: SQLite FTS5 Extension
9.1 FTS5テーブルの例
CREATE VIRTUAL TABLE articles_fts USING fts5(
title,
body
);
9.2 検索例
SELECT rowid, title
FROM articles_fts
WHERE articles_fts MATCH 'fastapi';
SQLite FTS5は、軽量な全文検索をアプリ内で完結させたい場合に便利です。
ただし、マルチテナントSaaSや大規模検索では、PostgreSQLや外部検索エンジンの方が運用しやすい場面もあります。
10. 検索APIと権限:見えてよいものだけ返す
検索APIは、権限漏れが起きやすい場所です。
詳細APIでは権限チェックしているのに、検索一覧では条件が漏れて他テナントのデータが見える、という事故は避けなければなりません。
10.1 必ず最初にスコープ条件を入れる
stmt = select(Project).where(Project.tenant_id == tenant_id)
このあとに検索条件を追加します。
tenant_id、owner_id、visibility、role など、権限に関わる条件は検索条件よりも前提として扱うのが安全です。
10.2 管理者検索でも範囲を意識する
社内管理画面では、superadminが全テナント横断検索できることもあります。
その場合でも、通常管理者、CS担当、経理担当などで検索可能範囲を分ける設計が必要です。
例:
- CS担当: 担当テナントだけ
- 経理担当: 請求関連データだけ
- superadmin: 全テナント横断
- テナント管理者: 自テナント内だけ
検索APIは便利な分、情報を広く取れる機能です。
通常の詳細APIよりも、むしろ厳しめに権限を考えるくらいが安全です。
11. 検索条件を監査ログに残すべきか
検索は閲覧系の操作なので、すべてを監査ログに残すとログ量が大きくなります。
ただし、次のような検索は監査対象にする価値があります。
- 個人情報を含む検索
- 請求情報の検索
- 監査ログ検索
- 全テナント横断検索
- CSV出力につながる検索
- 不正調査や管理者操作の前段となる検索
監査ログに残す場合は、少なくとも次の情報を残すと便利です。
actor_id
tenant_id
action = "user.search"
query_params
result_count
request_id
created_at
ただし、検索語そのものに個人情報や機密情報が含まれる場合もあります。
ログに残す値は、マスキングや保持期間も含めて検討するのがおすすめです。
12. CSV出力と検索条件を共有する
前回の記事で扱ったCSV出力と、検索APIはとても相性が良いです。
管理画面では、「今の検索条件でCSVを出したい」という要件がほぼ必ず出ます。
そのため、検索条件は再利用できる形にしておくと便利です。
@router.get("")
def search_users(
params: UserSearchParams = Depends(get_user_search_params),
):
...
@router.get("/export")
def export_users(
params: UserSearchParams = Depends(get_user_search_params),
):
...
同じ get_user_search_params を使うことで、一覧表示とCSV出力の条件ズレを防げます。
さらに、CSV出力時には limit を無視するのか、最大件数を別で制限するのかも決めておきましょう。
13. 検索結果のレスポンス設計
管理画面検索では、単に items を返すだけでなく、検索条件や総件数を含めるとフロントが扱いやすくなります。
from pydantic import BaseModel
class PageMeta(BaseModel):
total: int
limit: int
offset: int
class SearchResponse(BaseModel):
items: list[dict]
meta: PageMeta
レスポンス例:
{
"items": [
{
"id": 1,
"email": "a@example.com",
"status": "active"
}
],
"meta": {
"total": 123,
"limit": 50,
"offset": 0
}
}
total を毎回計算すると重い場合もあります。
その場合は、検索種別によって total を省略したり、概算値にしたり、最初のページだけ返したりする設計もあります。
14. パフォーマンス改善の順番
検索が遅いとき、いきなり全文検索エンジンを入れる前に、次の順で確認するとよいです。
- 必要なWHERE条件にインデックスがあるか
tenant_idやstatusなど絞り込みの強い条件が先に効いているか- 不要なJOINをしていないか
- 一覧で不要なカラムまで返していないか
COUNT(*)が重くなっていないかLIKE '%keyword%'がボトルネックになっていないか- PostgreSQL FTSやSQLite FTS5を検討する
- それでも足りなければ外部検索エンジンを検討する
PostgreSQL全文検索やSQLite FTS5は強力ですが、まずは通常のクエリ設計とインデックスを見直すだけで改善することも多いです。
検索APIは、DB設計、権限、レスポンス設計が絡むため、段階的に改善するのが現実的です。
15. 外部検索エンジンへ進む判断
データ量や検索要件が大きくなると、OpenSearchやElasticsearch、Meilisearch、Typesenseなどの外部検索エンジンを検討することがあります。
外部検索エンジンが向くのは、たとえば次のようなケースです。
- 複数フィールドをまたぐ高速検索が必要
- 関連度調整を細かくしたい
- サジェストや補完が必要
- タイポ許容が必要
- 大量データでDB検索が限界
- 検索専用のランキングを作りたい
ただし、外部検索エンジンを入れると、同期や整合性の問題が増えます。
DBに書いたデータが検索インデックスへ反映されるまでの遅延、再インデックス、障害時の復旧なども考える必要があります。
最初から外部検索エンジンを入れるより、まずはFastAPI側のAPI契約を安定させておき、内部実装を後で差し替えられるようにするのが実務的です。
16. API契約を安定させる:内部検索方式を隠す
検索方式は、最初は ILIKE、次にPostgreSQL全文検索、将来は外部検索エンジンへ変わるかもしれません。
このとき、APIレスポンスやパラメータが頻繁に変わると、フロントや利用者が困ります。
そのため、検索APIの外側は安定させ、内側だけ差し替える設計が大切です。
class UserSearchService:
def search(self, tenant_id: int, params: UserSearchParams):
# 最初はSQL LIKE
# 将来はPostgreSQL FTS
# さらに将来は外部検索エンジン
...
ルーターは UserSearchService だけを呼ぶようにしておけば、検索方式の変更がフロントへ漏れにくくなります。
17. テスト方針:検索APIは条件の組み合わせを守る
検索APIのテストでは、単一条件だけでなく、条件の組み合わせが重要です。
最低限、次のテストがあると安心です。
qなしで一覧が返るqで名前検索できるqでメール検索できるstatusで絞り込めるtenant_id境界を越えない- 権限のないデータが出ない
limitの最大値を超えると422になるsortに不正値を入れると422になる- CSV出力と一覧検索で同じ条件が使われる
FastAPIの Query によるバリデーションを使うと、不正な limit や sort は自動的に422として扱えます。
この挙動もテストしておくと、検索条件の安全性が保ちやすくなります。
18. よくある失敗パターン
18.1 検索条件をルーターごとにバラバラに書く
画面ごとに q、status、sort の意味が変わると、フロントもバックエンドも混乱します。
検索条件は依存関数や専用モデルへまとめるのがおすすめです。
18.2 sort をSQLへそのまま入れる
SQLインジェクションや想定外クエリにつながる危険があります。
必ず許可リストから内部カラムへ変換します。
18.3 tenant_id 条件を入れ忘れる
マルチテナント環境で最も危険な失敗です。
検索APIは広くデータを取るため、詳細API以上に注意が必要です。
18.4 いきなり外部検索エンジンを入れる
検索エンジンは便利ですが、同期や運用のコストもあります。
まずはDB検索、インデックス、PostgreSQL FTSやSQLite FTS5で足りるかを確認するとよいです。
18.5 COUNT(*) の重さを見落とす
一覧の total を毎回正確に返すと、データ量によっては重くなります。
必要に応じて、概算、上限付きカウント、最初のページだけ計算などを検討します。
19. 読者別ロードマップ
個人開発・学習者さん
q,limit,offsetの検索APIを作るQueryでバリデーションを付ける- SQLAlchemyで
LIKE/ILIKE検索を実装する - 検索条件を依存関数にまとめる
- データが増えたらインデックスや全文検索を試す
小規模チームのエンジニアさん
- 管理画面の検索条件を棚卸しする
- 一覧検索とCSV出力で検索条件を共通化する
- ソートキーを許可リスト化する
- 権限・テナント境界を検索サービス層に組み込む
- 検索条件と結果件数の監査・ログ方針を決める
SaaS開発チーム・スタートアップの皆さま
- 検索APIを業務基盤として再設計する
- PostgreSQL全文検索やSQLite FTS5の適用範囲を見極める
- 外部検索エンジンへ移行しやすいサービス層を作る
- 検索レイテンシ、検索回数、ゼロ件率をメトリクス化する
- 権限・監査・CSV出力・管理画面UXまで含めて検索基盤を育てる
参考リンク
-
FastAPI
-
SQLAlchemy
-
PostgreSQL
-
SQLite
まとめ
- FastAPIでの検索APIは、
qを受け取ってLIKEするだけではなく、絞り込み、並び順、ページネーション、権限、監査、CSV出力まで含めて設計すると安定します。 - 小規模では
LIKE/ILIKEと通常インデックスから始めて十分です。ただし、長文検索や関連度順が必要になったら、PostgreSQL全文検索やSQLite FTS5を検討する価値があります。 - 検索条件は依存関数や専用モデルにまとめ、一覧APIとCSV出力で共通化すると、画面と出力の条件ズレを防げます。
- マルチテナント環境では、検索APIに必ず
tenant_idや権限条件を入れることが重要です。検索は広くデータを見られる機能なので、詳細API以上に境界漏れへ注意が必要です。 - 将来、検索方式が
ILIKEからPostgreSQL FTS、外部検索エンジンへ変わっても、API契約が安定していればフロントや運用への影響を小さくできます。
次の記事としては、この流れと相性が良い「FastAPIで作るCS向け問い合わせ対応API設計」や、「FastAPIで実践する通知・メール送信基盤設計」が自然につながります。

