1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

辞書とSQLの実装・設計思想(No.02)

1
Last updated at Posted at 2026-10-16

呪文工房(日本語からNovelAIのタグを作るWebツール)の連載記事です。
この記事では、辞書をどこに置いて、日本語をどうタグに当てているかを書きます。
ツールの紹介は「サービス概要(No.01)」に書きました。

辞書の材料

辞書の材料は、Danbooruのタグの一覧とWikiの別名、Wikidata、公開されている日英の対訳を用いました。
タグは60万件ほど、日本語の見出しは28万件ほどあります。

Danbooruのタグの使用理由は、NovelAIの画像のモデルがDanbooruのタグで学習しているとされているためです。

D1に配置

辞書をSQLのファイルにすると50MBほどになるので、ブラウザに配るのは現実的ではありませんでした。
現在はサーバー側のデータベースに辞書を置き、照合の結果だけをブラウザへ返しています。

置き場所はCloudflare D1です。
D1はCloudflareの上で動くSQLiteのデータベースで、画面とAPIを配っているWorkerからそのまま引けます。
辞書を更新するたびにページを作り直さなくていいのも地味な助かりポイントです。

D1で気をつけたのは、1つのSQL文に置ける値の上限(100個)です。
画面の行をまとめて引くので、90個ずつに分けてbatchで投げるようにしました。

src/worker/d1.ts(抜粋)
const CHUNK = 90;

async byKeys(keys) {
  const stmts = chunks(keys).map((c) =>
    db.prepare(
      `SELECT j.key AS key, j.src AS src, ${COLS}
         FROM ja j JOIN tags t ON t.id = j.tag
        WHERE j.key IN (${marks(c.length)})`,
    ).bind(...c),
  );
  for (const res of await db.batch(stmts)) {
    // キーごとにタグの行をまとめる(省略)
  }
}

照合の順番

1行ごとに次の順で試して、見つかった時点で照合を終了としています。

  1. 完全一致(表記の揺れをそろえてから)
  2. 色+もの(「紫の目」→purple eyes)
  3. 機械翻訳した英語が、そのままタグとして実在するか
  4. 語句の分解(「夕焼けの海」→sunset, ocean)
  5. 意味の近さ(Workers AIのbge-m3で語を数字の並びに変え、Vectorizeで近いタグを探す)
  6. 機械翻訳の英語を、タグの一覧に当て直す(red eye→red eyes)
  7. それでもだめなら英語のまま

最初は機械翻訳を最後に回していたのですが、機械翻訳まで含めた本番と同じ手順で測り直したら、翻訳した英語がそのままタグに当たる語は意味の近さより先に採ったほうが当たりが良かったため、今の順番に落ち着きました。

語句の分解

手こずったのは語句の分解でした。
短い部品ほど珍しいキャラ名に当たってしまったり、助詞の「の」が誰かの別名に当たったりで、でたらめな分解になったこともありました。
対症療法的ですが、短い部品ほど投稿数の下限を厳しくすることで着地させました。

src/worker/translate.ts(抜粋)
function splitOk(t: TagRow, keyLen: number): boolean {
  if (t.category === 1) return false; // 絵師のタグは部品にしない
  if (t.category === 4) return keyLen >= 3 && t.posts >= 500; // キャラ
  return keyLen >= 2 ? t.posts >= 50 : t.posts >= 1000;
}

作品とキャラの照合

「ボンバーガールのシルヴァ」のように作品名とキャラ名を続けて書くと、別作品の同名のキャラが出るという外れもありました。
今は、作品のタグが一緒にあるときはその作品のキャラを優先しています。

ただ、これだけでは足りませんでした。
姓と名のあいだに区切りの無い名前(宝鐘マリンなど)は、「マリン」だけの見出しが辞書に無いので、作品で絞ってもキャラが候補に上がってこなかったんですよね。

そこで、作品が分かったときは、その作品のキャラを一覧で引き、見出しの後ろの部分と照らすようにしました。
カップリングの略し方(2人の名前の頭をつないだ見出し)に当たらないよう、照らすのは前の部分に漢字がある見出しだけにしています。

なお、ここまで設計しても考慮漏れが出てしまいました。
というのも、最初は作品のキャラを探すたびにタグの表を端から読んでいたために、作品名とキャラ名を含む8行の翻訳に7.8秒もかかってしまっていました。
今は作品とキャラの対応表を辞書に足して、1.3秒まで縮めています。

作品とキャラを一緒に書いた語で実測したところ、直す前は1位の候補が正解だったのが72%、直した後は100%でした。

候補の並び順

候補が複数あるときは、Danbooruの投稿数が多い順に並べています。
投稿が多いタグほどモデルもよく知っているはずだと考えてそうしましたが、モデルの中は外から測れないので、ここは仮定のままです。

設計ミス、考慮漏れ

最初は無料枠に収まる数千のタグで意味の近さを引いていて、有料プランにしてから20万タグに広げたところ、正解率が一気に落ちてしまいました。

調べてみると、「髪が長い」とlong hairの類似度が閾値を割ったようです。
原因は、Vectorizeが既定で返す近似のスコア。
returnValues: trueを付けると元のベクトルで計算し直してくれるので、少し遅くはなりますが、閾値がちゃんと効くようになりました。

src/worker/index.ts(抜粋)
const emb = await ai.run('@cf/baai/bge-m3', { text: [text] });
const vec = emb.data?.[0];
if (!vec) return [];
// 既定の近似のスコアだと、20万件でしきい値が効かなくなった
const res = await index.query(vec, { topK: 5, returnValues: true });
return res.matches.map((m) => ({ id: Number(m.id), score: m.score }));

件数を増やしたらtall hairのような珍しいタグがlong hairより上に来るようにもなったので、類似度に投稿数の補正をほんの少しだけ足しています。

連載

呪文工房について

日本語の語句を1行ずつ書くと、NovelAIで使える英語のタグに変換するWebツールです。
強弱は「+」「-」で付けられ、倍率が数字で表示されます。
キャラごとの欄、トークン数の表示、1語だけ違うプロンプトの作成にも対応しています。
無料で、登録は不要です。

呪文工房はこちら→ https://jumon-koubou.com/

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?