11
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

アイレット株式会社 新卒Advent Calendar 2023

Day 5

AWS DynamoDB で超低コストな全文検索を実装しよう 〜実装編〜

Last updated at Posted at 2023-12-04

この記事では、Amazon DynamoDB で全文検索を実装することで、コストを大幅に抑える方法をご紹介します。

Serverless Framework の設定

Amazon DynamoDB と AWS Lambda を構築する必要があるので、Serverless Framework を利用します。

インストールしていない場合は、インストールします。

$ npm i -g serverless serverless-offline

それから、プロジェクトを初期化し、AWS にデプロイします。

$ serverless

Creating a new serverless project

? What do you want to make? AWS - Node.js - Express API with DynamoDB
? What do you want to call this project? dynamodb-fulltext-search

✔ Project successfully created in dynamodb-fulltext-search folder

? What org do you want to add this service to? michinosuke
? What application do you want to add this to? [create a new app]
? What do you want to name this application? dynamodb-fulltext-search

✔ Your project is ready to be deployed to Serverless Dashboard (org: "michinosuke", app: "dynamodb-fulltext-search")

? Do you want to deploy now? Yes

実装に必要なパッケージもいくつかあるので、インストールします。

$ npm i express nanoid serverless-http
$ npm i -D @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb serverless-offline

serverless.yml

AWS に Lambda と DynamoDB が適切に作られるように、serverless.yml を以下の通り編集します。

serverless.yml
org: michinosuke
app: dynamodb-fulltext-search
service: dynamodb-fulltext-search
frameworkVersion: "3"

custom:
  tableName: "fulltext-search-${sls:stage}"

plugins:
  - serverless-offline

provider:
  name: aws
  region: ap-northeast-1
  runtime: nodejs18.x
  iam:
    role:
      statements:
        - Effect: Allow
          Action:
            - dynamodb:Query
            - dynamodb:Scan
            - dynamodb:GetItem
            - dynamodb:PutItem
            - dynamodb:UpdateItem
            - dynamodb:DeleteItem
          Resource:
            - Fn::GetAtt: [MainTable, Arn]
  environment:
    TABLE_NAME: ${self:custom.tableName}

functions:
  api:
    handler: index.handler
    events:
      - httpApi: "*"

resources:
  Resources:
    MainTable:
      Type: AWS::DynamoDB::Table
      Properties:
        AttributeDefinitions:
          - AttributeName: PK
            AttributeType: S
          - AttributeName: PostId
            AttributeType: S
        KeySchema:
          - AttributeName: PK
            KeyType: HASH
          - AttributeName: PostId
            KeyType: RANGE
        GlobalSecondaryIndexes:
          - IndexName: PostId
            KeySchema:
              - AttributeName: PostId
                KeyType: HASH
            Projection:
              ProjectionType: ALL
        BillingMode: PAY_PER_REQUEST
        TableName: ${self:custom.tableName}

次に、全文検索を実現する Lambda のロジックを実装するために、index.js を以下の通り編集します。

index.js
const { DynamoDBClient } = require("@aws-sdk/client-dynamodb");
const {
  DynamoDBDocumentClient,
  GetCommand,
  PutCommand,
  QueryCommand,
  UpdateCommand,
  BatchWriteCommand,
  DeleteCommand,
} = require("@aws-sdk/lib-dynamodb");
const express = require("express");
const serverless = require("serverless-http");
const { nanoid } = require("nanoid");

const app = express();
const TABLE_NAME = process.env.TABLE_NAME;
const client = new DynamoDBClient();
const dynamoDbClient = DynamoDBDocumentClient.from(client);

// bigram で分割した文字列の配列を返す
const bigrams = (str) => {
  str = str.replace(/[  ]/, "_"); // スペースをアンダーバーに置換
  let arr = [...Array(str.length - 1)].map((_, i) => str.slice(i, i + 2));
  arr = [...new Set(arr)]; // 重複削除
  return arr;
};

// 渡された全ての配列に含まれる文字列を配列で返す
const intersection = (arrays) => {
  const map = {};
  for (const array of arrays) {
    for (const bigram of array) {
      if (map.hasOwnProperty(bigram)) map[bigram]++;
      else map[bigram] = 1;
    }
  }
  const set = Object.entries(map).flatMap(([key, value]) => {
    return value === arrays.length ? [key] : [];
  });
  return set;
};

app.use(express.json());

// 検索
app.get("/posts/search", async (req, res) => {
  let { q, target } = req.query;
  target = target === "user" ? "USER" : "BODY";

  const searchQueries = q.length === 1 ? [`_${q}`] : bigrams(q);
  const invertedIndexesPromises = searchQueries.map(async (bigram) => {
    const command = new QueryCommand({
      TableName: TABLE_NAME,
      KeyConditionExpression: "PK = :PK",
      ExpressionAttributeValues: { ":PK": `${target}-${bigram}` },
    });
    const result = await dynamoDbClient.send(command);
    return result.Items?.map((item) => item.PostId) ?? [];
  });
  const invertedIndexes = await Promise.all(invertedIndexesPromises);

  const postIds = intersection(invertedIndexes);

  const postsPromises = postIds.map(async (postId) => {
    const command = new GetCommand({
      TableName: TABLE_NAME,
      Key: { PK: `POST-${postId}`, PostId: postId },
    });
    const { Item } = await dynamoDbClient.send(command);

    return Item
      ? [{ postId: Item.PostId, body: Item.Body, user: Item.User }]
      : [];
  });
  const posts = (await Promise.all(postsPromises)).flatMap((i) => i);

  res.json(posts);
});

// 作成
app.get("/posts/create", async (req, res) => {
  const { user, body } = req.query;

  const id = nanoid();

  const params = {
    TableName: TABLE_NAME,
    Item: { PK: `POST-${id}`, Body: body, User: user, PostId: id },
  };

  await dynamoDbClient.send(new PutCommand(params));

  const bodyPromises = bigrams("_" + body).map(async (bigram) => {
    await dynamoDbClient.send(
      new PutCommand({
        TableName: TABLE_NAME,
        Item: { PK: `BODY-${bigram}`, PostId: id },
      })
    );
  });

  const userPromises = bigrams("_" + user, true).map(async (bigram) => {
    await dynamoDbClient.send(
      new PutCommand({
        TableName: TABLE_NAME,
        Item: { PK: `USER-${bigram}`, PostId: id },
      })
    );
  });

  await Promise.all(bodyPromises);
  await Promise.all(userPromises);

  res.json({ id, body, user });
});

// 更新
app.get("/posts/:postId/update", async (req, res) => {
  const { postId } = req.params;
  const { body } = req.query;

  const queryCommand = new QueryCommand({
    TableName: TABLE_NAME,
    KeyConditionExpression: "PostId = :PostId",
    ExpressionAttributeValues: { ":PostId": postId },
    IndexName: "PostId",
  });
  const { Items } = await dynamoDbClient.send(queryCommand);

  const items = Items.filter(({ PK }) => PK.startsWith("BODY-"));
  for (let i = 0; i <= items.length / 25; i++) {
    const array = items.slice(i * 25, i * 25 + 25);
    if (array.length === 0) break;
    const batchDeleteCommand = new BatchWriteCommand({
      RequestItems: {
        [TABLE_NAME]: array.map(({ PK, PostId }) => ({
          DeleteRequest: { Key: { PK, PostId } },
        })),
      },
    });
    await dynamoDbClient.send(batchDeleteCommand);
  }

  const updateCommand = new UpdateCommand({
    TableName: TABLE_NAME,
    Key: { PK: `POST-${postId}`, PostId: postId },
    UpdateExpression: "SET Body = :Body",
    ExpressionAttributeValues: { ":Body": body },
  });
  await dynamoDbClient.send(updateCommand);

  const bodyBigrams = bigrams("_" + body);

  for (let i = 0; i <= bodyBigrams.length / 25; i++) {
    const array = bodyBigrams.slice(i * 25, i * 25 + 25);
    if (array.length === 0) break;
    const batchPutCommand = new BatchWriteCommand({
      RequestItems: {
        [TABLE_NAME]: array.map((bigram) => ({
          PutRequest: {
            Item: { PK: `BODY-${bigram}`, PostId: postId },
          },
        })),
      },
    });
    await dynamoDbClient.send(batchPutCommand);
  }

  res.json({ id: postId });
});

// 削除
app.get("/posts/:postId/delete", async (req, res) => {
  const { postId } = req.params;

  const queryCommand = new QueryCommand({
    TableName: TABLE_NAME,
    KeyConditionExpression: "PostId = :PostId",
    ExpressionAttributeValues: { ":PostId": postId },
    IndexName: "PostId",
  });
  const { Items } = await dynamoDbClient.send(queryCommand);

  const items = Items.filter(({ PK }) => PK.startsWith("BODY-"));
  for (let i = 0; i <= items.length / 25; i++) {
    const array = items.slice(i * 25, i * 25 + 25);
    if (array.length === 0) break;
    const batchDeleteCommand = new BatchWriteCommand({
      RequestItems: {
        [TABLE_NAME]: array.map(({ PK, PostId }) => ({
          DeleteRequest: { Key: { PK, PostId } },
        })),
      },
    });
    await dynamoDbClient.send(batchDeleteCommand);
  }

  const updateCommand = new DeleteCommand({
    TableName: TABLE_NAME,
    Key: { PK: `POST-${postId}`, PostId: postId },
  });
  await dynamoDbClient.send(updateCommand);

  res.json({ id: null });
});

// 詳細
app.get("/posts/:postId", async (req, res) => {
  const params = {
    TableName: TABLE_NAME,
    Key: { PK: `POST-${req.params.postId}`, PostId: req.params.postId },
  };

  const { Item } = await dynamoDbClient.send(new GetCommand(params));
  if (!Item) return res.status(404);
  const { PostId: id, Body: body, User: user } = Item;
  res.json({ id, body, user });
});

app.use((req, res, next) => {
  return res.status(404).json({ error: "Not Found" });
});

module.exports.handler = serverless(app);

簡略化のために例外処理やバリデーションは省略していたり、全ての操作を GET で実装したりしています。
ざっと一通り動くようにしただけなので、プロダクトに活用するときは注意してください。

AWS にデプロイするときは、次のコマンドを実行します。

$ serverless deploy

ローカルでAPIを立ち上げるときは、次のコマンドを実行します。(DynamoDB は AWS 環境)

$ serverless offline start

動かしてみる

作成

まずは、投稿してみます。

serverless offline start した状態で、以下の URL を叩きます。

http://localhost:3000/posts/create?user=みちのすけ&body=技術記事を投稿するといいことがあります

すると、次のようなレスポンスが返ります。

{
    "id": "loUwXtJPiYkqdBEAcjhP7",
    "body": "技術記事を投稿するといいことがあります",
    "user": "みちのすけ"
}

nanoid で id が払い出されているのがわかります。

もう何個か投稿しておきます。

http://localhost:3000/posts/create?user=みちのすけ&body=Qiitaはとてもいい技術記事投稿サイトです
http://localhost:3000/posts/create?user=ゆうのすけ&body=プリンはとても美味しいです

検索

肝心の全文検索です。

まずは、本文に対して 技術記事 というキーワードで検索してみます。

[
    {
        "postId": "loUwXtJPiYkqdBEAcjhP7",
        "body": "技術記事を投稿するといいことがあります",
        "user": "みちのすけ"
    },
    {
        "postId": "wKzA_gkLiuBl_orXDOE_w",
        "body": "Qiitaはとてもいい技術記事投稿サイトです",
        "user": "みちのすけ"
    }
]

期待通りのレスポンスが返ってきていますね!

とても で検索してみましょう。

http://localhost:3000/posts/search?q=とても

[
    {
        "postId": "EEEWLTFhLqb7gjo7iapFa",
        "body": "プリンはとても美味しいです",
        "user": "ゆうのすけ"
    },
    {
        "postId": "wKzA_gkLiuBl_orXDOE_w",
        "body": "Qiitaはとてもいい技術記事投稿サイトです",
        "user": "みちのすけ"
    }
]

いい感じですね

上の例では本文に対して検索を行いましたが、target=user を指定することでユーザ名でも検索できるように実装してみました。 ユーザ名に対して うの というキーワードで検索してみましょう。

http://localhost:3000/posts/search?q=うの&target=user

[
    {
        "postId": "EEEWLTFhLqb7gjo7iapFa",
        "body": "プリンはとても美味しいです",
        "user": "ゆうのすけ"
    }
]

問題ないですね!

更新・削除

更新と削除はそれぞれ以下のURLで行えるようにしてみました。

更新: http://localhost:3000/posts/EEEWLTFhLqb7gjo7iapFa/update?body=QiitaのマスコットはQiitanです
削除: http://localhost:3000/posts/EEEWLTFhLqb7gjo7iapFa/delete

まとめ

簡易的なものですが、全文検索を実装することができました!
小規模なプロダクトにおいては、これで十分な場面は多いと思います。

実際に製品に組み込んだりする場合は、データが多くなったときのパフォーマンスと、クエリの柔軟性(読点を検索対象に入れるかなど)を考慮する必要があると感じました。

Lambda と DynamoDB で実装すると、API を叩かない限りコストはほぼ 0 円なのが素晴らしいなと思います。

次回以降、実際にフロントエンドを作って連携させて、ユーザ体験はどんな感じなのか確かめてみたいです!

11
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?