優れたNest.jsブログを構築する:投稿の全文検索
Emily Parker
Product Engineer · Leapcell

前のチュートリアルでは、ブログ投稿に画像アップロード機能を追加しました。
時間が経つにつれて、ブログにはかなりの数の記事があることを想像できるでしょう。新しい問題が徐々に現れます。読者は読みたい記事をどのように素早く見つけることができるでしょうか?
答えは、もちろん検索です。
このチュートリアルでは、ブログに全文検索機能を追加します。
SQLのLIKE '%keyword%'
クエリを使用して検索を実装できるのではないかと思っているかもしれません。
簡単なシナリオでは、はい。しかし、LIKE
クエリは大量のテキストを扱う場合にはパフォーマンスが悪く、あいまい検索(例:「creation」を検索しても「create」と一致しない)を処理できません。
したがって、より効率的なソリューションを採用します。PostgreSQLに組み込まれている全文検索(FTS)機能を利用します。これは高速であるだけでなく、ステミング、関連性によるランキングもサポートしており、LIKE
よりもはるかに優れた検索機能を提供します。
ステップ1:データベース検索インフラストラクチャ
PostgreSQLのFTS機能を使用するには、まずpost
テーブルにいくつかの変更を加える必要があります。中心的な考え方は、最適化された高速検索可能なテキストデータを保存するために特別に設計された列を作成することです。
コアコンセプト:tsvector
post
テーブルにtsvector
型の新しい列を追加します。これは、記事のタイトルとコンテンツを個々の単語に分解し、正規化します(例:「running」と「ran」を「run」に処理)。これは、後続のクエリのためです。
テーブル構造の変更
PostgreSQLデータベースで次のSQLステートメントを実行して、post
テーブルにsearch_vector
列を追加します。
ALTER TABLE "post" ADD COLUMN "search_vector" tsvector;
データベースがLeapcellで作成された場合、
グラフィカルインターフェイスを使用してSQLステートメントを簡単に実行できます。ウェブサイトのデータベース管理ページに移動し、上記のステートメントをSQLインターフェイスに貼り付けて実行するだけです。
既存の投稿の検索ベクトルの更新
検索ベクトル(search_vector
)を投稿に更新すると、それらが検索可能になります。
ブログにすでにいくつかの記事があるため、次のSQLステートメントを実行してそれらのsearch_vector
データを生成できます。
UPDATE "post" SET search_vector = setweight(to_tsvector('english', coalesce(title, '')), 'A') || setweight(to_tsvector('english', coalesce(content, '')), 'B');
トリガーを使用した自動更新
記事が作成または更新されるたびにsearch_vector
列を手動で更新したい人はいません。最良の方法は、データベースにこの作業を自動的に行わせることです。これはトリガーを作成することで達成できます。
まず、以前と同様に、記事のsearch_vector
データを生成する関数を作成します。
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();
検索インデックスの作成
最後に、search_vector
列にGIN(Generalized Inverted Index)を作成します。
CREATE INDEX post_search_vector_idx ON "post" USING gin(search_vector);
これで、データベースは検索準備が整いました。すべての記事に対して効率的な検索インデックスを自動的に維持します。
ステップ2:Nest.jsでの検索ロジックの構築
データベースレイヤーが準備できたので、Nest.jsプロジェクトに戻って検索リクエストを処理するバックエンドコードを記述しましょう。
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> ) {} // ... 他のメソッドは変更なし 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(); } }
コードの説明:
to_tsquery('english', :query)
:この関数は、ユーザー入力の検索文字列(例:「nestjs blog」)を、tsvector
列と一致させることができる特別なクエリタイプに変換します。&
を使用して複数の単語を接続し、すべての単語が一致する必要があることを示します。@@
演算子:これは全文検索のマッチ演算子です。where("post.search_vector @@ ...")
行は、検索を実行するコア操作です。ts_rank(...)
:この関数は、クエリ用語がブログ投稿とどの程度一致するかを基に「関連性ランク」を計算します。.orderBy('rank', 'DESC')
:最も関連性の高い記事が最初に表示されるように、このランクで降順にソートします。
検索ルートの作成
次に、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, private readonly commentsService: CommentsService, ) {} // ... 他のメソッドは変更なし // 新しい検索ルート @Get('search') @Render('search-results') async search(@Query('q') query: string, @Request() req) { const posts = await this.postsService.search(query); return { posts, user: req.session.user, query }; } // コントローラーは上から下へルートを照合するため、:idルートは最後に配置する必要があります @Get(':id') @Render('post') async post(@Param('id') id: string, @Request() req) { // ... } }
コントローラーはルートを上から下へ一致するため、search
ルートとの競合を避けるために:id
ルートを最後に配置することに注意してください。
ステップ3:フロントエンドへの検索機能の統合
バックエンドAPIの準備ができました。次に、ユーザーインターフェイスに検索ボックスと検索結果ページを追加しましょう。
検索ボックスの追加
views/_header.ejs
ファイルを開き、ナビゲーションバーに検索入力フォームを追加します。
<header> <h1><a href="/">My Blog</a></h1> <form action="/posts/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.username %></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') %>
実行とテスト
アプリケーションを再起動します。
npm run start:dev
ブラウザを開き、以下に移動します:http://localhost:3000/
「testing」というキーワードで新しい記事を書いてみましょう。
記事を保存した後、検索ボックスに「test」と入力して検索を実行します。
検索結果ページに、作成した記事が結果に表示されます。
これでブログは全文検索をサポートするようになりました。どれだけ書いても、読者が迷うことはありません。
過去のチュートリアル: