2022年8月25日木曜日

Icon Search の DB をつよつよにした

以前作った Icon Search のDB をつよつよにして、Cloudflare Pages に移行しました。



DB が爆発 そして Cloudflare へ

移行の理由は単純で GitHub Pages の上限に到達したからです。 最近まで icon-db のサイズは 630MB くらいで、GitHub Pages の上限はまだまだ余裕と思っていたのですが、 最近登場した Fluent Emoji の容量が凄まじく、ローカル環境で一気に上限の 1GB をオーバーしました。 このせいもあって改良に滞りが起きていました。 今後も高画質なアイコンが出てくると、思ったよりファイルサイズの増大が早くなるかも知れません。 これは何とかせねばと思い Cloudflare Pages への移行を決めました。 Cloudflare Pages なら 50GB まで運用できるので、まだまだやれます。

移行にあたっては 2つの問題をクリアする必要がありました。 Cloudflare Pages は (1) 20,000ファイルまでしか管理できず、(2) 25MB 以上のファイルは管理できません。 どちらもバックエンドには重要な問題で、ファイル数は inode 枯渇問題、ファイルサイズは CPU/メモリ/ネットワークの圧迫に繋がります。 Cloudflare は CDN 屋さんですから、この 2点へシビアになるのも頷けます。 逆に言えばシビアだからこそディスクの容量を他より大きくできると言っても良いと思います。 フロントエンドの価値が高くなると、その傾向が強くなるかも知れません。

JSON DB を改良した

先ほどの問題を解決するためには JSON DB を改良しないといけません。 今回魔改造した DB を以下にまとめます。 まずバックエンドでは、JSON DB を以下のように管理します。
  • 10MB を超えたら JSON を分割して fetch (a.json --> a.1.json, a.2.json)
  • 5KB 以下のファイルは可能な限り 10MB 単位でまとめて Accept-Ranges fetch
フロントエンドでは heavy.json, light.json を最初にロードします。
  • heavy.json: 10MB を超えるタグのリストと、ファイル分割数
  • light.json: 5KB 以下のタグのリストと、Accept-Ranges のポインタ

heavy.json, light.json のタグにマッチする場合は、fetch の方法を切り替えます。 すべての fetch 情報を事前に読み込んでも良いのですが、たぶん 1MB を超えるのでやや微妙です。 他にも機能を追加したいとも思っているので、転送量は増やさないようにします。 また DB のファイル数を減らそうと思えば徹底的に減らせるのですが、どれだけ減らせるかはあまり意味のない議論です。 これは Accept-Ranges fetch より通常の gzip 圧縮の効いた fetch のほうが効率的だからです。 ほとんど使われないタグを Accept-Ranges fetch にして、使われるタグは通常の fetch にするほうが効率が良いです。 なんにしても、これで最初に 300KB ほどの heavy.json, light.json をロードするだけで、 後は O(1) で検索できるようになりました。ファイル数も 10,000 減って管理しやすくなりました。

割と最強の静的 DB なのでは?

sql.js-httpvfs の仕組みにかなり近づいているので比較すると、以下のような利点があります。 (1) JSON なので git で管理できる、(2) 将来は一緒になるかもしれないけど現状では通信量が〜1/3 で済む、(3) O(1) で検索できる、 (4) 1.5MB の sql.js-httpvfs をロードしないで済む、(5) 難しい実装に依存せず改良できる、 (6) 不均衡データの実装がしやすい、(7) 10MB 単位以外のブロックにも設定変更しやすい、 (8) Accept-Ranges fetch するだけで転用できる。

欠点は (1) タグ数が増えて light.json の容量が大きくなった時に通信量で困ること、 (2) light.json に関連するデータが same-origin でないと動かないだけのように思います。 欠点があるとはいえ sql.js-httpvfs の容量がかなり大きいので、light.json でタグ数 2万くらいまでは普通に管理できます。 ほぼ JSON DB のほうが有利です。 10万くらいになると大差なくなるので sql.js-httpvfs を利用するべきかも知れませんが、 アイコンに関しては超えるとは思えないので、JSON DB が正解そうです。 英語に最適化すればたいていのデータにも適用できるので、意外と応用も効きます。

雑に作った JSON DB も、気付けば一丁前の DB になってきました。 フロントエンドでは write が不要なので自前でも実装でき、 また SQL を使う必要性もないので、複雑な実装も必要ありません。 案外このような DB が最強かも知れません。

DB をつよつよにしたついでに、細かな機能もつよつよにしておきました。

キーワードが浮かばない人のためのコマンドを作った

DB の改良によって類似検索の真似事のようなことができるようになりました。 低頻度のキーワードをまとめた JSON ファイルを作ったので、その JSON ファイルを検索できるようにしました。 検索キーワードが思い付かない人は、キーワード @rare で検索すると、大量にいろいろなアイコンが見られるので、良さげな SVG を簡単に探せます。 類似検索なども作ろうかなとは思ってはいるのですが、 何もキーワードが浮かばないなら一度にたくさんのアイコンを見て、 そこから良いものを選ぶほうが早いかも知れません。 低頻度のヒットしないアイコンにも目が向きますし、なかなか良い案と思いました。

タグ補完を頻度順にした

低頻度のタグが補完で邪魔してくるのが、個人的には多少気になりました。 そこで頻度順に表示してみたところ、結構使いやすくなった気がします。 頻度順に表示しているのでキーワードに困ることも減るかも知れません。 頻度が低すぎるタグは削除してもいいような気がしましたが、ひとまず残しておきました。

SVG の ID 重複排除を高速化した (その1)

Fluent Emoji をレンダリングすると、かなりのアイコンで時間が掛かることがわかりました。 原因としては ID の重複排除ですべての SVG 要素をチェックするときに、ID ごとにすべての要素をチェックしていました。 ID 数と要素数が多くなると酷いことになります。 そこで一度だけすべての要素をチェックして、ID が含まれていそうな要素と属性をリストアップし、 そのリストに対して ID 重複排除処理を実行することで、15倍ほど高速化しました。 ID 重複排除の実装は何度も書き直している気がします。案外難しいですね。

SVG の ID 重複排除を高速化した (その2)

Fluent Emoji はそれでも重い SVG がありました。 特に重い SVG を開いてみると、要素数が 1万、ID も 3000 近くあり、DOM に書き込むことすら時間がかかるし、cloneNode(true) するだけで 1秒近く掛かります。 色々計測してみると、属性名を複数回に分けて置換している処理が重いとわかりました。 ID が 3000ともなると複数回に分けて置換するだけで大変なことになります。 そこで SVG のフォーマットを事前に整え、正規表現で処理してみたところ、100倍ほど早くなりました。

SVG の ID 重複排除を高速化した (その3)

アイコンの表示は十分な速度が出るようになったのですが、今度はアイコンの詳細を表示するときが問題になりました。 要素数が 1万ある SVG をcloneNode(true) したり、再描画しないとアイコンの詳細を表示できないのですが、それだけで 1秒近く掛かります。 これは DOM ノードが多すぎることが原因なので、DOM ノードを減らすには Data URI などを使った表示にすべきという結論になりました。 そういえばその技が使えたな…と気付いてしまったので、いろいろ豪快に実装し直しました。 Benchmark 記事も見つけたのですが、これが一番早いらしいです。 苦労して作った ID 重複処理がいらないとわかったのはショックでしたが、良い気付きになりました。 ちなみに何も考えずに appendChild すると体感的に遅くなるので、XML Parser を使わなくても無理やり Worker に投げて描画する実装は残しています。 初めての人が見るとまったく意味がわからないコードなのですが、Promise もやや遅く感じます。 このへんの実装ってどうするのが良いんですかねえ。

何にしても、この変更でアイコンの詳細表示は耐えられるレベルになりました。 ちなみにまだ微妙に重いためもっと非同期に動作させたいのですが、どうにも難しいです。 原因としては cloneNode の代わりに img.src を使っていて、原理上は async に動くのですが、内部では Data URL のパースで DOMParser が sync に動いてそうなところに問題がありそうです。 直感的には画像のキャッシュがあるので、ctx.drawImage(img, ...) すると高速そうとも思ったのですが全然駄目です。 既にレンダリングしている画像の width/height だけ変えてパースを回避することでごまかそうとも思いましたが、これさえ重いので、width/height を変更した時の再レンダリングが予想以上に大変なのかも知れません。 いずれにしてもあまり良い解決案はなさそうです。 まあ、そもそも要素数が 1万 あるアイコンはさすがにちょっとなと思うので、path で書いて欲しい気はします。

レンダリング順序に自由度をもたせた

DB を 10MB のブロック単位の JSON にしたことで、レンダリング順序に自由度をもたせることができるようになりました。 これまでは一つの JSON にまとまっていたため表示順序に損得が生じており、古めのアイコンのほうが得をするようになっていました。 データ数が増えた時に顕著になっていましたが、ブロック単位にして、ブロックごとにランダム表示するようにしました。 これによって、より様々なアイコンを見つけやすくなったと思います。

今後もちまちま改善を続けたいと思っています。

2022年8月8日月曜日

漢字読み取り音読を作った

漢字読み取り音読を作りました。 出題された漢字を音読することで回答できる、漢字練習アプリです。



タブレットがあれば 手書き漢字読み取り も良いですが、 スマホだと UI が厳しいので音読できると良いなと思って作りました。 手で書いたほうが頭に入る気がしますが、解答可能速度はこちらのほうが圧倒的に上です。 海外の日本語学習者の方が、発音の練習に使うことも意識して、多言語対応もしています。

以前作った ダジャレ音読早口音読 の内部で使っている、読み方解析器を使っています。 そのまま転用できるかと思ったのですが、データのパターンが数百から数万まで増えるとさすがに見逃していたバグが出てきたので、きっちり直してからリリースしました。 こういったゴリゴリ系のアルゴリズムは何百回もテストしながら作る必要がある上、最近ほとんど見かけなくなったので割と苦労しました。

問題の解決には、結構色々な基盤ライブラリの改善が必要でした (下)。 ちなみに応用アプリのほうもすべて更新済みです。 形態素解析の仕組みを隅々まで理解するのは大変なので、やはりクロスチェックしないとなかなか良いものは作れないですね。 そしてチェックすればするほど SudachiDict の使いやすさが際立ちます。
読み方解析の精度は 100% なのですが、音声認識で特有の認識ミスが発生するので、多少精度が落ちます。 たとえば「量産→リョーサン」のように「ー」に書き換えてくるケースはそれなりにあって、これは認識に失敗します。 あと形態素解析辞書には登録されていない、間違いの読み結果「差し出し→差し出」を返却されると認識に失敗します。 間違い方は 以前書いた記事 とだいたい一緒です。 体感認識精度は 95% でした。

「ー」の問題は、今回のような単語単位のマッチングなら先頭以外の「あいうえお」を「ー」に変換して完全一致検索すれば良さそうだったので、対応しました。 これで体感認識精度は 98% くらいになったような気がします。 音声認識の間違いは SudachiDict でわざと使っていない誤用ルールを使えば部分的に対応できるかも、 ただ完璧は難しそうだし、面倒くさそう。 許せる認識率にはなっているので、ひとまずは良いかな…。

最近の音声認識技術は適当に考えても読み方解析は弱いので、個人的にはひらがな/カタカナで返却する API が欲しいところです。 ただ日本語の優先度は低いでしょうから期待はできません。今回作ったような仕組みで対応するのが一番良さそうです。 日本語の音声認識の応用は、簡単そうに見えても作り込むのが割と難しいです。