【Firebase】FirestoreとCloud Functionsで検索機能を強化する

本記事で扱うFirestoreは全てNative modeを指します。
Datastore modeを使った場合の検索機能強化に関しては過去に記事を書いているのでそちらを参照下さい。
[Cloud Datastoreのクエリでがんばるハナシ](https://qiita.com/hogedigo/items/175d5a4afd0fd0fe9de6)
[Cloud Datastoreのクエリでがんばるハナシ2 〜 ライブラリ作った 〜](https://qiita.com/hogedigo/items/02dcce80f6197faae1fb)
本記事内容は↑を一部Native mode用にアレンジしたものとなります。Native modeユーザーも設計思想に興味ある方は読んでもらえると嬉しいです。

# 最初に

Firestoreは一般的なRDBよりもクエリの制約が厳しく、全文検索やIN、OR検索などが必要な場合はElasticsearchやAlgoliaなど別のツールやサービスを併用するのが定石になっているかと思います。
外部ツール・サービスはかなり高機能ですが、少々追加コストがかかるという問題があります。

本記事ではFirestoreのみを使って、より安価に、そしてFirestoreの標準機能よりも強力な検索を行う方法を紹介します。

なおCloud Functionsは必須ではありませんが、検索インデックス保存トリガーとして便利そうだったのと、個人的に利用経験がなく試してみたかったので採用しました^^

# 実現する機能

- 部分一致検索
- IN検索
- 範囲検索
- ソート

IN検索に関しては先日Firestoreで正式にサポートされましたね😀→[公式リファレンス](https://cloud.google.com/firestore/docs/query-data/queries#in_not-in_and_array-contains-any)
カーソルも使えるしかなり有用な機能ですが、いくつか制限もあります。

- クエリ上1回しか使えない（1フィールドしか指定できない）
- INに指定したフィールドでソート出来ない

本記事で紹介する手法では上記制限は発生しないものとなっています。
（但し、選択肢があまり多くないフィールドに限る、という別の制限はあります。詳しくは後述します）

# 作戦

ドキュメントを検索する為のインデックス情報を別のドキュメントに保存しておきます。
保存はCloud FunctionsのCloud Firestore writeトリガーを利用して行います。

部分一致検索用インデックスはN-gram(uni-gramとbig-gramの複合）を利用します。
もうちょっと頑張れば形態素解析を利用することも可能かと思いますが本記事では扱いません。

IN検索は、組み合わせを全てインデックスに保存しておくことで実現します。
選択肢が増えると指数関数的にインデックス量も増えるので、あまり選択肢が多い場合は使えません。
8個（組み合わせ255通り）くらいまでが現実的な制約かと思います。

範囲検索（<、<=、>、>=、BETWEEN）は、本記事の手法を使うとそのままではサポートできません。
ただし予め「a未満」「aからb」「bからc」など範囲を固定してインデックス化しておくことで `==` オペレータで検索できる様になります。
（検索UIの仕様を調整する必要があります）

ソートも、本記事の手法を使うとそのままではサポートできません。
ですので、クエリのOrderを指定しなかった場合にIDでソートされる性質を利用します。
検索インデックスドキュメントのIDにソートしたいフィールドの値を埋め込むことで、任意の値でソートさせることができます。
降順でソートしたい場合は値を反転させる必要があります。

# 実装

Firebaseのプロジェクト作成や、初期化などの手順は割愛します。

## スキーマ

下記bookドキュメントの検索を想定します。

| field    | type   | memo                                      |
|----------|--------|-------------------------------------------|
| title    | String |                                           |
| price    | Integer |                                          |
| category | string | magazin &#124; novel &#124; hobby &#124; business &#124; comic |
| status   | string | unpublished &#124; published &#124; outofprint     |
| createdAt| Date and time |                                    |

## ドキュメント保存

bookドキュメントを保存します。
これは通常のFirestore保存処理と変わりません。

```JavaScript

    let db = firebase.firestore();

    const titleWords = ['hoge', 'fuga', 'piyo', 'foo', 'bar']
    const categories = ['magazine', 'hobby', 'business', 'comic', 'novel'];
    const statuses = ['unpublished', 'published', 'outofprint'];

    let promisses = [];
    for (let i = 1; i <= 500; i++) {
        let titleWord1 = titleWords[Math.floor(Math.random() * Math.floor(titleWords.length))];
        let titleWord2 = titleWords[Math.floor(Math.random() * Math.floor(titleWords.length))];
        let book = {
            title: `${titleWord1} ${titleWord2} vol.${i}`,
            category: categories[i % categories.length],
            status: statuses[i % statuses.length],
            price: i * 100,
            createdAt: firebase.firestore.FieldValue.serverTimestamp(),
            version: 0
        };

        let p = db.collection('books').doc('books' + i).set(book);
        promisses.push(p);
    }

    Promise.all(promisses).then(function () {
        window.alert('saved.');
    })
        .catch(function (error) {
            console.log(error);
            err = error;
            window.alert(`Error submitting record!: ${error}`);
        });
```

検索テスト用にフィールドの値を変更しながら500件保存しています。

### Cloud Functions、フロント共通関数

```JavaScript
function bigram(s) {
    s = s.trim().replace(/\s\s*/g, ' ');

    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]).toLowerCase());
        }
        prev = s[i];
    }
    return Array.from(resultSet);
}

function unigram(s) {
    s = s.trim().replace(/\s\s*/g, ' ');

    let resultSet = new Set();

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

function biunigram(s) {
    return bigram(s).concat(unigram(s));
}

const bookStatusBitMap = {
    "unpublished": 1,
    "published": 1 << 1,
    "outofprint": 1 << 2
};
```

## インデックス保存

Cloud Functions Firestore writeトリガーを利用してインデックスを保存します。

```JavaScript
const functions = require('firebase-functions');

// The Firebase Admin SDK to access Cloud Firestore.
const admin = require('firebase-admin');
admin.initializeApp();

const db = admin.firestore();

exports.saveBookIndexes = functions.firestore.document('/books/{bookId}')
    .onWrite(async (change, context) => {
        const book = change.after.data();

        if (!book.createdAt) {
            functions.logger.log('skip saving indexes due to no createdAt saved for ', context.params.bookId, book);
            return null;
        }

        // 既存インデックスを削除（Keyが普遍で完全上書きならば不要）
        await db.collectionGroup('search-indexes')
            .where('collection', '=', 'books')
            .where('bookId', '=', context.params.bookId)
            .get()
            .then(function (querySnapshot) {
                querySnapshot.forEach(function (doc) {
                    doc.ref.delete();
                });
            });

        functions.logger.log('Saving indexes for ', context.params.bookId, book);

        const index = {
            collection: 'books',
            bookId: context.params.bookId,
            bookVersion: book.version,
            bookCreatedAt: book.createdAt,
            text: {}, // 部分一致検索用トークン 
            statusIn: {}, // status IN条件インデックス
            priceRange: '' // 価格帯
        };

        // titleとcategory部分一致検索用トークンを保存
        biunigram(`${book.title} ${book.category}`).forEach((token) => { index.text[token] = true });

        // status IN条件インデックス保存
        const ceiling = 1 << Object.keys(bookStatusBitMap).length;
        for (let i = 1; i < ceiling; i++) {
            if (i & bookStatusBitMap[book.status] != 0) {
                index.statusIn['' + i] = true;
            }
        }

        // price範囲保存
        if (book.price < 1000) {
            index.priceRange = 'p<1000';
        } else if (book.price < 3000) {
            index.priceRange = '1000<=p<3000';
        } else if (book.price < 5000) {
            index.priceRange = '3000<=p<5000';
        } else if (book.price < 10000) {
            index.priceRange = '5000<=p<10000';
        } else {
            index.priceRange = '10000<=p';
        }
 
        // 検索結果を降順にソートする為に反転させたタイムスタンプ文字列をプレフィクスに付与する
        const epoch3000 = 32503680000000; // 3000.1.1のUNIXミリ秒
        const createdAtDesc = new Date(32503680000000 - book.createdAt.toMillis()).toISOString();
        const createdAtDescId = `${createdAtDesc} ${context.params.bookId}`;

        return db.collection('books-indexes/createdAtDesc/search-indexes').doc(createdAtDescId).set(index);
    });
```

## 検索（フロント）

```JavaScript

    let db = firebase.firestore();

    const text = 'hoge foo';
    const priceRange = '3000<=p<5000';
    const bookStatusBits = '' + (bookStatusBitMap['unpublished'] | bookStatusBitMap['published']);

    let query = db.collection('books-indexes/createdAtDesc/search-indexes')
        .where('priceRange', '==', priceRange)
        .where(new firebase.firestore.FieldPath('statusIn', bookStatusBits), '==', true)

    if (text.length == 1) {
        query = query.where(new firebase.firestore.FieldPath('text', text), '==', true)
    } else if (text.length > 1) {
        bigram(text).forEach((token) => {
            query = query.where(new firebase.firestore.FieldPath('text', token), '==', true)
        });
    }

    query.limit(20)
        .get()
        .then(function (querySnapshot) {
            let booksPromisses = [];

            querySnapshot.forEach(function (doc) {
                let bookId = doc.data().bookId;
                booksPromisses.push(db.collection("books").doc(doc.data().bookId).get());
            });

            Promise.all(booksPromisses).then(function (values) {
                values.forEach(bookRef => {
                    let book = bookRef.data();
                    console.log(book);
                });
            });
        })
        .catch(function (error) {
            console.log("Error getting documents: ", error);
        });
```

## 実行！！

![スクリーンショット 2020-12-25 23.41.44.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/31437/c3812b59-ed2c-7cab-1290-60f2a5f4ecb0.png)

検索結果返って来ました！！ ＼(^o^)／

# 最後に

Firebase Advent Calendarに空きがあるのを見つけて穴埋めを思い立ち、（現在12/25 23:54）ギリギリ滑り込みました。
サンプルコードを作成するのに思ったより時間がかかってしまい解説が乏しくなってしまいました。ごめんなさい🙇‍♂️
今年中にこちらの記事にもう少しコードコメントや解説を追加していくつもりなので、興味ある方はしばらくしてからまたチェックしてもらえると嬉しいです。

また、思っていたより面倒な実装になってしまいました。
2021年の目標として、もっと容易に実装できるようなOSSライブラリを作成できたらな、と思っていますq(^ω^)p

# 参考資料

[Cloud Fuctions - Get started](https://firebase.google.com/docs/functions/get-started)