素晴らしいNest.jsブログを構築する:投稿の全文検索
Emily Parker
Product Engineer · Leapcell

前のチュートリアルでは、ブログ投稿に画像アップロード機能を追加しました。
時間が経つにつれて、ブログにはかなりの記事が蓄積されていることでしょう。新しい問題が徐々に浮上します。読者が読みたい記事をすばやく見つけるにはどうすればよいでしょうか?
答えは、もちろん、検索です。
このチュートリアルでは、ブログに全文検索機能を追加します。
SQLのLIKE '%keyword%'
クエリを使用して検索を実装できるのではないかと考えているかもしれません。簡単なシナリオでは、確かに可能です。しかし、LIKE
クエリは、大量のテキストを扱う場合にパフォーマンスが悪く、言語的な複雑さを理解できません(たとえば、「create」を検索しても「creating」に一致しません)。
したがって、より専門的で効率的なソリューションを採用します。PostgreSQLの組み込み全文検索(FTS)機能を利用します。これは、非常に高速であるだけでなく、ステミングを処理し、ストップワードを無視し、関連性によってソートするため、LIKE
よりもはるかに優れた検索エクスペリエンスを提供します。
ステップ1:データベース検索インフラストラクチャ
PostgreSQLのFTS機能を使用するには、まずpost
テーブルにいくつかの変更を加える必要があります。中心的な考え方は、高速で検索可能な最適化されたテキストデータを格納するための専用列を作成することです。
1. コアコンセプト:tsvector
post
テーブルにtsvector
型の新しい列を追加します。これは「検索辞書」と考えることができます。記事のタイトルとコンテンツを個々の単語(レキシメ)に分解し、それらを標準化します(たとえば、「running」と「ran」の両方を「run」に処理します)。これは、後続のクエリのためです。
2. テーブル構造の変更
PostgreSQLデータベースに接続し、次のSQLステートメントを実行して、post
テーブルにsearch_vector
列を追加します。
ALTER TABLE "post" ADD COLUMN "search_vector" tsvector;
3. トリガーによる自動更新
もちろん、投稿を作成または更新するたびに、このsearch_vector
列を自分で更新したくはありません。最善の方法は、データベースにこの作業を自動的に行わせることです。トリガーを作成することでこれを達成します。
まず、タイトルとコンテンツを連結して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 "post" FOR EACH ROW EXECUTE FUNCTION update_post_search_vector();
4. 検索インデックスの作成
検索を非常に高速にするために、最終ステップは、新しいsearch_vector
列にGIN(Generalized Inverted Index)を作成することです。
CREATE INDEX post_search_vector_idx ON "post" USING gin(search_vector);
これで、データベースの準備が整いました!すべての記事に対して効率的な検索インデックスを自動的に維持します。
ステップ2:Nest.jsで検索ロジックを構築する
データベースレイヤーの準備ができたので、Nest.jsプロジェクトに戻って検索リクエストを処理するバックエンドコードを記述しましょう。
1. PostsService
の更新
src/posts/posts.service.ts
を開き、新しいsearch
メソッドを追加する必要があります。
// src/posts/posts.service.ts import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Post } from './post.entity'; @Injectable() export class PostsService { constructor( @InjectRepository(Post) private postsRepository: Repository<Post> ) {} // ... findAll, findOne, create メソッドは変更されません async search(query: string): Promise<Post[]> { if (!query) { return []; } // より複雑なクエリを構築するためにQueryBuilderを使用 return this.postsRepository .createQueryBuilder('post') .select() .addSelect("ts_rank(post.search_vector, to_tsquery('english', :query))", 'rank') .where("post.search_vector @@ to_tsquery('english', :query)", { query: `${query.split(' ').join(' & ')}` }) .orderBy('rank', 'DESC') .getMany(); } }
コードの説明:
- TypeORMの
QueryBuilder
を使用します。これは、複雑なSQLクエリを記述するためのより大きな柔軟性を提供するためです。 to_tsquery('english', :query)
:この関数は、ユーザーの入力検索文字列(例:「nestjs blog」)を、tsvector
列と一致させることができる特別なクエリタイプに変換します。すべての単語が一致する必要があることを示すために、複数の単語を&
で結合します。@@
演算子:これは全文検索のマッチ演算子です。where("post.search_vector @@ ...")
という行は、検索操作の核心です。ts_rank(...)
:この関数は、クエリ用語がテキストとどの程度一致するか、に基づいて「関連性ランク」を計算します。.orderBy('rank', 'DESC')
:最も関連性の高い記事が上位に表示されるように、このランクで降順にソートします。
2. 検索ルートの作成
次に、src/posts/posts.controller.ts
に検索リクエストを処理する新しいルートを追加します。
// src/posts/posts.controller.ts import { Controller, Get, Render, Param, Post, Body, Res, UseGuards, Request, Query } from '@nestjs/common'; // ... その他のインポート @Controller() // 'posts' を個々のメソッドに移動 export class PostsController { constructor(private readonly postsService: PostsService) {} @Get() // ルートパス @Render('index') async root(@Request() req) { // ... } @Get('posts') // ホームページリダイレクト @Render('index') async findAll(@Request() req) { const posts = await this.postsService.findAll(); return { posts, user: req.user }; } // 新しい検索ルート @Get('search') @Render('search-results') async search(@Query('q') query: string, @Request() req) { const posts = await this.postsService.search(query); return { posts, user: req.user, query }; } @UseGuards(AuthenticatedGuard) @Get('posts/new') // ... // ... その他のメソッド }
注: /search
ルートを機能させるために、PostsController
の構造をわずかに調整しました。@Controller('posts')
を@Controller()
に変更し、各メソッドにルートパスを明示的に指定しました(例:@Get('posts')
)。
ステップ3:検索機能をフロントエンドに統合する
バックエンドAPIの準備ができたので、ユーザーインターフェイスに検索ボックスと検索結果ページを追加しましょう。
1. 検索ボックスの追加
views/_header.ejs
ファイルを開き、ナビゲーションバーに検索フォームを追加します。
<header> <h1><a href="/">My Blog</a></h1> <form action="/search" method="GET" class="search-form"> <input type="search" name="q" placeholder="Search posts..." /> <button type="submit">Search</button> </form> <div class="user-actions"> <% if (user) { %> <span>Welcome, User</span> <a href="/posts/new" class="new-post-btn">New Post</a> <a href="/auth/logout">Logout</a> <% } else { %> <a href="/auth/login">Login</a> <a href="/users/register">Register</a> <% } %> </div> </header>
2. 検索結果ページの作成
views
ディレクトリにsearch-results.ejs
という名前の新しいファイルを作成します。このページは検索結果を表示するために使用されます。
<%- include('_header', { title: 'Search Results' }) %> <div class="search-results-container"> <h2>Search Results for: "<%= query %>"</h2> <% if (posts.length > 0) { %> <div class="post-list"> <% posts.forEach(post => { %> <article class="post-item"> <h2><a href="/posts/<%= post.id %>"><%= post.title %></a></h2> <p><%= post.content.substring(0, 150) %>...</p> <small><%= new Date(post.createdAt).toLocaleDateString() %></small> </article> <% }) %> </div> <% } else { %> <p>No posts found matching your search. Please try different keywords.</p> <% } %> </div> <%- include('_footer') %>
このテンプレートはシンプルです。まずユーザーの検索クエリを表示し、次にposts
配列をチェックします。配列が空でない場合、それを反復処理して記事のリストを表示します(ホームページと同じように)。空の場合は、「投稿が見つかりませんでした」というメッセージを表示します。
実行とテスト
これで完了です!アプリケーションを再起動します。
npm run start:dev
ブラウザを開くと、ページの上部に新しい検索ボックスが表示されるはずです。
- 投稿のタイトルまたはコンテンツに存在する単語を入力してEnterキーを押します。
- ページは検索結果ページに移動し、関連する記事が表示されるはずです。
- 存在しない単語を検索してみてください。親切に「投稿が見つかりませんでした」というメッセージが表示されます。
- 記事のいずれかに「creating」という単語が含まれている場合、「create」を検索して、PostgreSQLの強力なステミング機能が結果を正しく一致させるかどうかを確認してください!
これで、ブログは全文検索機能をサポートするようになりました。どれだけ書いても、読者が迷うことはなくなります。