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

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

:::note
この記事は、「[AWS DynamoDB で超低コストな全文検索を実装しよう 〜設計編〜](https://qiita.com/Michinosuke/items/f0406bce43bc243d2629)」　の続きです。
:::

https://qiita.com/Michinosuke/items/f0406bce43bc243d2629

## Serverless Framework の設定

Amazon DynamoDB と AWS Lambda を構築する必要があるので、[Serverless Framework](https://www.serverless.com/) を利用します。

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

```sh
$ npm i -g serverless serverless-offline
```

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

```sh
$ 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
```

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

```sh
$ 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` を以下の通り編集します。

```yaml: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` を以下の通り編集します。

```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);
```

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

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

```sh
$ serverless deploy
```

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

```sh
$ serverless offline start
```

## 動かしてみる

### 作成

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

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

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

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

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

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

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

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

### 検索

肝心の全文検索です。

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

http://localhost:3000/posts/search?q=技術記事

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

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

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

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

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

いい感じですね

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

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

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

問題ないですね！


### 更新・削除

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

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


## まとめ

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

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

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

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