目次

Introduction

私は個人開発を進めており、その一環として検索機能の作成に取り組んでいます。その過程で、「類義語も検索できるようにしたい」という新たな要件が浮上しました。
類義語検索とは、ユーザーが入力した検索ワードと似た用語も検索対象に含める機能のことを指します。例えば、「りんご」と検索すると、「林檎」や「apple」も検索結果に表示されるような仕組みです。
この記事では、個人開発で類義語検索を実装する際の挑戦と、そのための費用抑制策についての私の経験を共有します。
WordNetについては、私自身まだ完全に理解しているわけではないので、より効率的な方法が存在するかもしれません。その点はご了承ください。

環境

MongoDBAtlas: 6.0.12
Node.js: 20
better-sqlite3: 9.2.2
Prisma: 5.7.1

技術選定

全文検索エンジン
私が採用したのは、MongoAtlas Searchです。その理由は次のとおりです。

  • アプリケーションのデータベースにはmongodbを使用しており、プラットフォームはMongoAtlasを利用しています。そのため、親和性が高く、リソースの管理も簡素化しやすい。
  • ElasticSearchやOpenSearchは高価になりやすく、安く抑えようとした場合リソースの管理が複雑化してしまうため個人開発には向かなそうと感じた。

類義語検索ツール
類義語の抽出には、WordNetを選択しました。
理由は、単純に無料だからです。ElasticSearchは上記と同じようにやっぱり高くて管理が大変… WordNetからの抽出は、sqlite(注意: クリックするとダウンロードされます)で行います。
実装で使用するライブラリはsqlite3ではなく、better-sqlite3を使用します。理由は単純に使い易いからです。

実装方針

技術選定であげたMongoDBAtlasとWordNetを使用して実装します。

MongoDBに類義語リストを作成する

WordNetで類義語を抽出し、Mongodbに保存
例) [林檎, りんご, リンゴ, apple]

Wordnetの抽出方法

WordNetで関連する用語全て抽出します。(結構難しい)
個々の単語が保存されているwordテーブルと、単語同士の関連付けを表しているsynsetから類義語を抽出します。
例えば、wordテーブルとsenseテーブルをjoinして「りんご」でSELECTしてみます。

// sample1.js
const Database = require("better-sqlite3");
const db = new Database(__dirname + "/wnjpn.db");

const word = process.argv[2];

const rows1 = db.prepare(`
  SELECT
  word.wordid, word.lemma, sense.synset
  FROM
    word
  INNER JOIN
    sense ON sense.wordid = word.wordid
  WHERE
    lemma = '${word}'
`).all();

console.log("word result.", rows1);

let synsetsString = '';
rows1.forEach((row, index) => {
  synsetsString = (index === (rows1.length - 1))
    ? synsetsString += `'${row.synset}'`
    : synsetsString += `'${row.synset}', `;
});

const rows2 = db.prepare(`
  SELECT
  word.wordid, word.lemma, sense.synset
  FROM
    sense
  INNER JOIN
    word ON word.wordid = sense.wordid
  WHERE
    synset in (${synsetsString})
`).all();

console.log("synset result.", rows2);
$ node sample1.js りんご
word result. [ { wordid: 232488, lemma: 'りんご', synset: '07739125-n' } ]
synset result. [
  { wordid: 32753, lemma: 'apple', synset: '07739125-n' },
  { wordid: 186607, lemma: 'リンゴ', synset: '07739125-n' },
  { wordid: 204247, lemma: '林檎', synset: '07739125-n' },
  { wordid: 232488, lemma: 'りんご', synset: '07739125-n' }
]

これだけで、十分類義語検索できることがわかりました。
さらに取得したsynsetから抽出したwordに関連するwordを抽出します。(難しい…)
例えば、「りんご」を指定して実行すると類義語は上記の例から「apple」「リンゴ」「林檎」「りんご」になりますが、「林檎」を指定して実行するとまた結果が変わってきます。

$ node sample1.js 林檎
word result. [
  { wordid: 204247, lemma: '林檎', synset: '07739506-n' },
  { wordid: 204247, lemma: '林檎', synset: '07739125-n' },
  { wordid: 204247, lemma: '林檎', synset: '12633638-n' }
]
synset result. [
  { wordid: 32753, lemma: 'apple', synset: '07739125-n' },
  { wordid: 186607, lemma: 'リンゴ', synset: '07739125-n' },
  { wordid: 204247, lemma: '林檎', synset: '07739125-n' },
  { wordid: 232488, lemma: 'りんご', synset: '07739125-n' },
  { wordid: 34583, lemma: 'dessert_apple', synset: '07739506-n' },
  { wordid: 106514, lemma: 'eating_apple', synset: '07739506-n' },
  { wordid: 204247, lemma: '林檎', synset: '07739506-n' },
  { wordid: 108310, lemma: 'apple_tree', synset: '12633638-n' },
  { wordid: 202542, lemma: 'リンゴの木', synset: '12633638-n' },
  { wordid: 204247, lemma: '林檎', synset: '12633638-n' }
]

そのため、関連するwordに関連するwordを抽出するという小難しいことをしないと網羅できません。
「dessert_apple」いる?という問いはなしでお願いします。あくまでWordNetに存在する類義語を抽出するという名目なので
では、sample1.jsを少し変更します。

const Database = require("better-sqlite3");
const db = new Database(__dirname + "/wnjpn.db");

const word = process.argv[2];

const rows1 = db.prepare(`
  SELECT
  word.wordid, word.lemma, sense.synset
  FROM
    word
  INNER JOIN
    sense ON sense.wordid = word.wordid
  WHERE
    lemma = '${word}'
`).all();

let synsetsString = '';
rows1.forEach((row, index) => {
  synsetsString = (index === (rows1.length - 1))
    ? synsetsString += `'${row.synset}'`
    : synsetsString += `'${row.synset}', `;
});

const rows2 = db.prepare(`
  SELECT
  word.wordid, word.lemma, sense.synset
  FROM
    sense
  INNER JOIN
    word ON word.wordid = sense.wordid
  WHERE
    synset in (${synsetsString})
`).all();

let wordsString = '';
rows2.forEach((row, index) => {
  wordsString = (index === (rows2.length - 1))
    ? wordsString += `'${row.wordid}'`
    : wordsString += `'${row.wordid}', `;
});

const sql = `
  SELECT
    sense.synset
  FROM
    sense
  INNER JOIN
    word ON word.wordid = sense.wordid
  WHERE
    word.wordid IN (${wordsString})
`;

const row3 = db.prepare(`
  SELECT
    word.wordid, word.lemma, sense.synset
  FROM
    sense
  INNER JOIN
    word ON word.wordid = sense.wordid
  WHERE
    synset in (${sql})
`).all();

console.log('result.', row3);
$ node sample1.js りんご
result. [
  { wordid: 32753, lemma: 'apple', synset: '07739125-n' },
  { wordid: 186607, lemma: 'リンゴ', synset: '07739125-n' },
  { wordid: 204247, lemma: '林檎', synset: '07739125-n' },
  { wordid: 232488, lemma: 'りんご', synset: '07739125-n' },
  { wordid: 34583, lemma: 'dessert_apple', synset: '07739506-n' },
  { wordid: 106514, lemma: 'eating_apple', synset: '07739506-n' },
  { wordid: 204247, lemma: '林檎', synset: '07739506-n' },
  { wordid: 108310, lemma: 'apple_tree', synset: '12633638-n' },
  { wordid: 202542, lemma: 'リンゴの木', synset: '12633638-n' },
  { wordid: 204247, lemma: '林檎', synset: '12633638-n' },
  { wordid: 9112, lemma: 'orchard_apple_tree', synset: '12633994-n' },
  { wordid: 32753, lemma: 'apple', synset: '12633994-n' },
  { wordid: 80858, lemma: 'malus_pumila', synset: '12633994-n' },
  { wordid: 186607, lemma: 'リンゴ', synset: '12633994-n' },
  { wordid: 189620, lemma: 'りんごの木', synset: '12633994-n' }
]

同じキーワードでも同じ結果が得られることがわかります。

$ node sample1.js apple

このロジックをうまく使いmongodbにinsertするバッチを作成していきます。

バッチ実装

では、類義語を出力するバッチファイルを作成していきます。

wordnet

WordNetから類義語を取得する実装になります。

// sample2.js
const Database = require("better-sqlite3");
const db = new Database(__dirname + "/wnjpn.db");

const total = 100000000; // 最後のsynsetレコードだった場合はbreakするため実際のsynsetのレコード数よりも多ければ良い

let words = [];

console.log("import start!!!");

(async () => {
  console.time("batch");
  for (let i = 0; i < total; i++) {
    if (i % 100 === 0) {
      await sleep(1000);
    }

    words = [];
    const synset = getSynsetByOffset(i);
    if (!synset) {
      console.log("finish!!!!!. num: ", i);
      break;
    }
    const wordidsString = getWordIdsBySynset(synset);
    const synsetsString = getSynsetsByWordIds(synset, wordidsString);
    if (!synsetsString) {
      console.timeLog("batch", `skip. num: ${i}`);
      continue;
    }
    const rows = getWordBySynsets(synsetsString, wordidsString);

    rows.forEach((row) => {
      words.push(row.lemma);
    });
    words = uniq(words);

    if (words.length === 1) {
      console.timeLog("batch", `skip. num: ${i}`);
      continue;
    }
  
    console.timeLog("batch", `words:${i}`, words);
  }

  console.timeEnd("batch");
  db.close();
})();

function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

function uniq(array) {
  return [...new Set(array)];
}

function getSynsetByOffset(i) {
  const rows = db.prepare(`
    SELECT
      sense.synset, count(sense.wordid)
    FROM sense
    INNER JOIN word ON word.wordid = sense.wordid
    GROUP BY sense.synset
    ORDER BY sense.synset ASC
    LIMIT 1
    OFFSET ${i}
`).all();
  return rows.length !== 0 ? rows[0].synset : null
}

function getWordIdsBySynset(synset) {
  const rows = db.prepare(`
    SELECT
    sense.wordid, word.lemma
    FROM sense
    INNER JOIN word ON word.wordid = sense.wordid
    WHERE sense.synset = '${synset}'
    LIMIT 100
  `).all();
  let wordidsString = '';
  rows.forEach((row, index) => {
    words.push(row.lemma);
    wordidsString = (index === (rows.length - 1))
      ? wordidsString += `${row.wordid}`
      : wordidsString += `${row.wordid}, `;
  });

  return wordidsString;
}

function getSynsetsByWordIds(synset, wordidsString) {
  const rows = db.prepare(`
    SELECT
    sense.synset
    FROM sense
    JOIN word ON word.wordid = sense.wordid
    WHERE
      sense.wordid IN (${wordidsString})
    GROUP BY sense.synset
    ORDER BY sense.synset ASC
`).all();

  const synsets = [];
  let synsetsString = '';
  rows.forEach((row, index) => {
    synsets.push(row.synset);

    synsetsString = (index === (rows.length - 1))
      ? synsetsString += `'${row.synset}'`
      : synsetsString += `'${row.synset}', `;
  });

  if (synset !== synsets[0]) {
    return null;
  }

  return synsetsString;
}

function getWordBySynsets(synsetsString, wordidsString) {
  const rows = db.prepare(`
    SELECT
      word.wordid, word.lemma
    FROM sense
    JOIN word ON word.wordid = sense.wordid
    WHERE
      sense.synset IN (${synsetsString}) AND
      sense.wordid NOT IN (${wordidsString})
  `).all();

  return rows;
}

実行順序です。

  1. forでsynsetレコードを一件ずつ処理する
  2. getSynsetByOffsetでLIMITとOFFSETを使用して順序が狂わないように一件ずつsynsetを取得。複数レコードあるsynsetはGROUP BYでまとめて同じsynsetは取得しないようにする
  3. getWordIdsBySynsetで2で取得したsynsetから関連wordidを取得
  4. getSynsetsByWordIdsで3で取得したwordidから2で取得したsynset以外にも存在するwordidと関連するsynsetを取得する
  5. getWordBySynsetsでさらに4で取得したsynset群を利用してwordデータを取得する
  6. 取得したwordデータを配列に入れ、重複を排除する

ポイントとしては、

  • getSynsetsByWordIdsでsynset群を取得した時にforで指定したsynsetではなかった場合、既にそのsynsetは処理していると判断しスキップしている。そのためsynsetの重複によるwordの重複を考慮できる
  • Mongodbに負荷がかからないようにsleepで少しdelayさせている
  • 特定のsynsetに関連するwordidを取得しwordidからまた関連するsynsetにとなるべく広く関連するワードをリスト化できるようにした

以下で実行してみてください。類義語のリストが出力されるはずです。

$ node sample2.js

import start!!!
batch: 1.480s words:0 [
  'able',        '可能',     'up_to',
  'equal_to',    'capable',  'adequate_to',
  '適切',        '適当',     '能力のある',
  'できる',      '敏腕',     '有能',
  '優秀',        '尤',       'able-bodied',
  'possible',    'ありうる', 'workable',
  'practicable', 'feasible', 'executable',
  'viable',      '実行可能', 'operable',
  '手術可能'
]
...

mongodbへinsert

今回はORMにPrismaを使用します。Prismaについては説明しないので、わからない人は調べていただけると

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "mongodb"
  url      = env("DATABASE_URL")
}

model Synset {
  id    String           @id @default(auto()) @map("_id") @db.ObjectId
  words String[]
  createdAt        DateTime @default(now())
  updatedAt        DateTime @updatedAt

  @@map("synsets")
  @@index(fields: words, name: "words_ix")
}
$ npx prisma db push

次に、sample2.jsの実装にMongoDBにInsertするコードを追加します。

const Database = require("better-sqlite3");
const db = new Database(__dirname + "/wnjpn.db");
const PrismaClient = require("@prisma/client").PrismaClient;

const prisma = new PrismaClient()

const total = 100000000; // 最後のsynsetレコードだった場合はbreakするため実際のsynsetのレコード数よりも多ければ良い

let words = [];

console.log("import start!!!");

(async () => {
  console.time("batch");
  for (let i = 0; i < total; i++) {
    if (i % 100 === 0) {
      await sleep(1000);
    }

    words = [];
    const synset = getSynsetByOffset(i);
    if (!synset) {
      console.log("finish!!!!!. num: ", i);
      break;
    }
    const wordidsString = getWordIdsBySynset(synset);
    const synsetsString = getSynsetsByWordIds(synset, wordidsString);
    if (!synsetsString) {
      console.timeLog("batch", `skip. num: ${i}`);
      continue;
    }
    const rows = getWordBySynsets(synsetsString, wordidsString);

    rows.forEach((row) => {
      words.push(row.lemma);
    });
    words = uniq(words);

    if (words.length === 1) {
      console.timeLog("batch", `skip. num: ${i}`);
      continue;
    }

    await prisma.synset.create({
      data: {
        words
      },
    }).catch((e) => {
      console.error("error", e);
    });
  
    console.timeLog("batch", `words:${i}`, words);
  }

  console.timeEnd("batch");
  db.close();
})();

function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

function uniq(array) {
  return [...new Set(array)];
}

function getSynsetByOffset(i) {
  const rows = db.prepare(`
    SELECT
      sense.synset, count(sense.wordid)
    FROM sense
    INNER JOIN word ON word.wordid = sense.wordid
    GROUP BY sense.synset
    ORDER BY sense.synset ASC
    LIMIT 1
    OFFSET ${i}
`).all();
  return rows.length !== 0 ? rows[0].synset : null
}

function getWordIdsBySynset(synset) {
  const rows = db.prepare(`
    SELECT
    sense.wordid, word.lemma
    FROM sense
    INNER JOIN word ON word.wordid = sense.wordid
    WHERE sense.synset = '${synset}'
    LIMIT 100
  `).all();
  let wordidsString = '';
  rows.forEach((row, index) => {
    words.push(row.lemma);
    wordidsString = (index === (rows.length - 1))
      ? wordidsString += `${row.wordid}`
      : wordidsString += `${row.wordid}, `;
  });

  return wordidsString;
}

function getSynsetsByWordIds(synset, wordidsString) {
  const rows = db.prepare(`
    SELECT
    sense.synset
    FROM sense
    JOIN word ON word.wordid = sense.wordid
    WHERE
      sense.wordid IN (${wordidsString})
    GROUP BY sense.synset
    ORDER BY sense.synset ASC
`).all();

  const synsets = [];
  let synsetsString = '';
  rows.forEach((row, index) => {
    synsets.push(row.synset);

    synsetsString = (index === (rows.length - 1))
      ? synsetsString += `'${row.synset}'`
      : synsetsString += `'${row.synset}', `;
  });

  if (synset !== synsets[0]) {
    return null;
  }

  return synsetsString;
}

function getWordBySynsets(synsetsString, wordidsString) {
  const rows = db.prepare(`
    SELECT
      word.wordid, word.lemma
    FROM sense
    JOIN word ON word.wordid = sense.wordid
    WHERE
      sense.synset IN (${synsetsString}) AND
      sense.wordid NOT IN (${wordidsString})
  `).all();

  return rows;
}

これでバッチ完成です。

バッチ実行・結果

途中で止まらないようにパブリッククラウドでサーバーを立てて実行することをお勧めします。
私はAWSでm5.xlargeを使用して実行しました。
途中で切れないようにnohupコマンドを利用してバッチを実行します。

# 実行
$ nohup node sample2.js > out.log 2> error.log &
# 途中経過
$ tail out.log

実行結果になります まずはどのくらい時間がかかったかout.logの中身を見てみます

$ tail out.log

batch: 9:27:08.171 (h:mm:ss.mmm) words:117658 [ 'sep_11', 'sept._11', '9/11', 'september_11', '9-11', '9月11日' ]
finish!!!!!. num:  117659
batch: 9:27:08.676 (h:mm:ss.mmm)

9時間30分もかかっていました…
insertされたMongoDBのデータを見てみます

MongoDBAtlasの無料枠が500MBのはずなので余裕はあるかと思います。

MongoDBに入れた類義語を使用した検索実装

const PrismaClient = require("@prisma/client").PrismaClient;

const prisma = new PrismaClient();

const arg = process.argv[2];

(async () => {
  const rows = await prisma.synset.findMany({
    where: {
      words: {
        hasSome: [arg],
      },
    },
  });
  const words = rows.flatMap(row => row.words);

  const JsonValue = await prisma.xxxx.aggregateRaw({
    pipeline: [
      {
        $search: {
          index: "xxxx_search_ix",
          text: {
            query: words.length > 0 ? words : arg,
            path: ["title", "description"]
          }
        }
      }
    ]
  });

  console.log('words', words);
  console.log('searchResult', JsonValue);

  prisma.$disconnect();
})();

簡易的ですが、類義語の検索処理ができました。

デメリット

  • 作成したバッチがsynsetとwordを複数回取得し、関連リストを作成するため、類似度が低いワードもリストに含まれてしまう。
    • 例えば「放出」と「上げる」が同じリストに入ってしまう。
  • バッチがsynsetを1レコードずつしか処理しないため多くの時間を要してしまう。
    • 今回AWSのm5.xlargeで9時間半かかったので、オンデマンドインスタンスで1ドル150円と仮定して計算すると「0.248 * 9.5 * 150 = 353.4円」となります。んーすごい痛くはないですが気になる人は気になるお値段ですね。
  • MongoDBを使用して今回の構成にすると類義語の取得と検索で2つのクエリが必要となり、ElasticSearchと比べ速度や負荷の面で劣る

まとめ

私はWordNetとMongoDBAtlasSearchを利用して、簡易的な検索機能を作成してみました。ElasticSearchと比較すると劣る面もありますが、それでも十分に使える機能が実装できたと自負しています。 今回作成したものは、WordNetで少しでも類似性があるものをリスト化しています。そのため、類似度にはかなりの幅があると思います。もし読者の方が同じような構成を試してみる場合は、類似度が高いものだけで作成してみると、より類似度の高い検索機能が実装できるかもしれません。その場合は重複のワードには気をつけてください。

追記

今回の検索実装した後に「タッチ」って検索したら、取得する関連語が多すぎてSearchクエリ投げる時にPrismaが値が多すぎるとエラーが出てしまいました。

query has expanded into too many sub-queries internally: maxClauseCount is set to 1024

類似度が低いものも含めると取得する値が膨れ上がるので結局synsetごとに紐づくwordを配列にした方が軽量で精度高く検索できそうです(๏д๏)…