The Round

合同会社ナイツオの開発ブログ

[PR] 5分から相談できるGCP™ 開発コンサル!→こちら

Firebaseでチャットアプリを作る日記(10日目)〜 メッセージの検索(撃沈編)

まず最初に述べておきますが本日の記事は、「想定していた機能が作れなかった」撃沈編となりますorz
最後まで読んでも検索機能は完成しませんのでご容赦下さい。🙇🙇🙇

やりたかったこと

  • メッセージを全文検索したい
  • 自分の所属する複数ルームを跨いで検索可能
  • 投稿日降順でソート

https://firebase.google.com/docs/firestore/solutions/search などを見るにFirestoreには全文検索の機能がなく、AlgoliaやElasticSearchなどの3rd partyツールを使うのが定石の様です。

しかし、以前よりCloud Datastore上でやってしまいたい病に長く罹患していた私はFirestoreだけで機能が実現できないかをまず考えました。(繰り返しますが最終的に撃沈しています)

作戦

実を言うとCloud Datastoreでは全文検索機能を実装した前歴がありました。

昨年のAdvent Calendarで書いた下のあたりのハナシになります。

qiita.com

Firestore(natvive mode)用語でざっくり言うと「テキストを形態素解析n-gramで分割してarrayフィールドに突っ込んでおき、それをarray-containsで検索する」になります。 詳しいひとはこの時点でどう撃沈したのかピンとくるかもしれませんが続けます。

実装

テキスト分割

Javascript上で形態素解析を行うのは面倒そうなのでとりあえずbigram(テキストを2文字ずつに分割)でインデックスを作成することにしました。

Cloud Functionsをつかえばドキュメント保存をトリガーにして形態素解析を行なってインデックス保存することも容易にできそうな気がしますが、ここでは行いません。

まず、bigram関数です。

function bigram(s) {
  let resultSet = new Set();

  let prev;
  for (let i = 0; i < s.length; i++) {
    if (i > 0 && prev != ' ' && s[i] != ' ') {
        resultSet.add(s[i-1] + s[i]);
    }
    prev = s[i];
  }
  return Array.from(resultSet);
}

次にメッセージの追加です。

  // 検索用インデックス作成
  const memberIdx = message.members.map(token => `m ${token}`);
  const textIdx = bigram(message.text).map(token => `t ${token}`);
  message.indexes = Array.from(new Set(memberIdx.concat(textIdx)));

  return db.collection("rooms").doc(room.id).collection("messages").add(message)
  .then(function(docRef) {
    let added = docRef.data();
    added.id = docRef.id;

    return added;
  });
}

arrayフィールドに対するインデックスを複合インデックスで組み合わせるとそれらの組み合わせ分インデックスが作られてしまう問題があります(俗に言うインデックス爆発)。

これを避ける為、単一のindexesフィールドに複数の異なる検索インデックスをまとめて保存しています。どのインデックスかはプレフィックスで区別つけられる様にしています。

上記ではmembersフィールドの値をプレフィクス m をつけて保存、さらにtextフィールドの値をbigramで分割してプレフィクス t をつけて保存しています。

次に検索コードです。

export function searchMessages(roomId, text) {

  let user = get(currentUser);

  let db = firebase.firestore();

  let indexes = bigram(text).map(token => `t ${token}`);
  indexes.push(`m ${user.email}`);
  indexes = Array.from(new Set(indexes));

  let query = db.collectionGroup("messages")
  indexes.forEach(idx => {query = query.where("indexes", "array-contains", idx);});

  return query.orderBy("createdAt", "desc").get().then(function (querySnapshot) {
      let _messages = [];

      querySnapshot.forEach(function (msgRef) {
        let msg = msgRef.data({serverTimestamps: "estimate"});
        msg.id = msgRef.id;
        _messages = _messages.push(msg);
      });

      return _messages;;
    })
}

ユーザーの入力値もbigramで分割して全てをarray-containsで検索しています。

「membersフィールドにサインインユーザーが含まれていること」と、「ユーザー入力のbigramが全てインデックスに含まれていること」を一つのクエリでフィルタしています。

最後にインデックスを追加します。

    {
      "collectionGroup": "messages",
      "queryScope": "COLLECTION_GROUP",
      "fields": [
        {
          "fieldPath": "indexes",
          "arrayConfig": "CONTAINS"
        },
        {
          "fieldPath": "createdAt",
          "order": "DESCENDING"
        }
      ]
    },

実際はルールも書き換えないといけないですが、ここでは(最終的に別の問題で失敗しており本質的でないので)割愛します。

実行!!

Uncaught FirebaseError: Invalid query. You cannot use more than one 'array-contains' filter.

ガーン!😱

複合インデックス作成するときに複数のarray-containsを指定できない制約だと覚えていたのですが、同一フィールドに繰り返しarray-contains検索することもできないのですね・・ (ちなみにDatastore modeでは可能です)

まとめ

と言うわけで軽く撃沈してしまいましたorz

やっぱり3rd party頼った方がよさそうですね(高機能ですし)。

と思いながら今年のFirebase Advent Calendar眺めてたらちょうど良い記事がありましたのでリンク貼らせてもらいます。

qiita.com