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?

「残りがある間は繰り返す」ループが、埋め込めない1件で止まらなくなっていました

1
Posted at

「残りが0より多い間、次の処理を頼む」というループを書いたことはありませんか。

自作の WordPress プラグイン Rapls AI Chatbot で、そのループが止まらなくなる不具合を 1.18.0 で直しました。原因は、どうやっても処理できない1件が残ると、残りの数が永遠に0にならないことでした。

直す前(1.17)と直した後(1.18)の判定を写して、120件の文書で数えた結果です。

状況 1.17 の往復回数 1.17 で埋め込めた数 1.18 の往復回数 1.18 で埋め込めた数
全部正常 3 120 3 120
10番目が大きすぎる 200以上(打ち切り) 0 3 119
110番目が大きすぎる 200以上(打ち切り) 100 3 119
10番目にテキストがない 200以上(打ち切り) 119 3 119
レート制限が続く 200以上(打ち切り) 0 1 0

直し方は2つの条件の組み合わせでした。

どこで 何を足したか
サーバー側 何度やっても直らない失敗(上限超え・テキストがない)を、次の対象と残りの数から外す
ブラウザ側 1周して1件も進まなかったら、残りがあってもループを抜ける

先に断っておくと、進まないループを止めるという考え方は新しいものではありません。ページ送りの API で、次のカーソルが前と同じなら止める、というのと同じです。ここで書くのは、それを入れていなかった自分の実装で何が起きていたかです。

止まる条件は、残りの数だけだと思っていた

ナレッジベースに登録した文書は、埋め込みの API に送って、ベクトルに変えてから検索に使います。管理画面のボタンを押すと、ブラウザが AJAX でサーバーに「次を頼む」と送り、サーバーは埋め込みが済んでいない文書を50件取り出して API に送り、何件できたかと、あと何件残っているかを返します。ブラウザは、残りが0より多ければもう一度頼みます。

1.17 までのブラウザ側は、こうでした。

if (response.data.remaining > 0) {
    processBatch(source);
}

サーバー側で取り出す50件は、id の小さい順です。

SELECT id, title, content FROM ... WHERE embedding IS NULL AND is_active = 1 ORDER BY id ASC LIMIT 50

どちらも、それだけを見ればおかしくありません。残りがあれば続ける。済んでいないものを、古い順に取る。

問題は、埋め込めない文書が1件あるときです。

1件がバッチ全体を道連れにしていた

埋め込みのモデルには、1回に受け付ける長さの上限があります。上限を超える文書が1件でもバッチに入っていると、OpenAI はリクエスト全体を拒否します。同じバッチの残り49件も、巻き添えで埋め込まれません。

ここで、取り出し方が効いてきます。済んでいない文書を id の小さい順に50件なので、次の周も同じ50件が出てきます。同じ1件が入っているので、また全体が拒否されます。残りの数は減りません。ブラウザは、残りが0より多いので、また頼みます。

上限を超える文書が10番目にあると、1件も埋め込まれないまま、同じリクエストを繰り返し続けます。表の「10番目が大きすぎる」の行がそれです。110番目なら、最初の100件は通りますが、最後の20件で同じことが起きます。

課金される API キーなら、そのぶんの料金もかかります。しかも、画面には何のエラーも出ていませんでした。大きな文書が黙って0件で終わる、と利用者の方が原因まで調べて報告してくれたのが、直すきっかけでした。

テキストが取り出せない文書(文字のない PDF など)でも、ループは止まりません。こちらは空の文書を API に送らないので料金はかかりませんが、ブラウザとサーバーのあいだの往復が終わらなくなります。

サーバー側で、直らない失敗を外した

1.18.0 では、まず失敗の理由を5つに分けて記録するようにしました。大きすぎる(too_large)、テキストがない(no_text)、API キーの問題(auth)、レート制限(rate_limit)、その他(api_error)です。

このうち too_large(上限超え)と no_text は、文書の中身が変わらない限り、何度送っても結果が同じです。この2つが付いた文書は、次に取り出す対象から外し、残りの数からも外しました。編集されたら、また対象に戻ります。

$skip_ids = RAPLSAICH_Knowledge::get_permanent_embed_failure_ids();
$pending  = RAPLSAICH_Knowledge::get_unembedded_entries(50, $skip_ids);

あわせて、バッチが拒否されたときは1件ずつ送り直すようにしました。10番目が上限を超える場合、最初の周で 1+50 回の API 呼び出しが走り、49件が通って、10番目だけに too_large が付きます。表で 1.18 の API 呼び出しが 53 回になっているのは、この送り直しの分です。

auth と rate_limit は、1件ずつ送り直しても全部同じ理由で失敗するので、一括で理由を付けて終わります。

ブラウザ側で、進まなかったら抜ける

サーバー側だけでは、まだ穴があります。レート制限のように、時間がたてば直るかもしれない失敗は、対象から外していません。外すと、あとで直ったときに拾えなくなるからです。

そこでブラウザ側に、もう1つ条件を足しました。

var madeProgress = (response.data.processed || 0) > 0;
if (response.data.remaining > 0 && madeProgress) {
    processBatch(source);
}

1周して1件も埋め込めなかったら、残りがあっても止めます。レート制限が続く場合、1.18 は1回目の往復で止まりました。止まったあとは、埋め込めなかった件数を表示して、理由はナレッジベースの一覧で見られるようにしています。

2つの条件は、片方ずつでは足りませんでした。サーバー側だけだと、一時的な失敗でループします。ブラウザ側だけだと、直らない失敗が毎回同じ50件の先頭を占めて、その後ろに進めなくなります。外すことと、止まることの両方が要りました。

数え方

表の数字は、実際の API を叩いたものではありません。1.17 と 1.18 のサーバー側とブラウザ側の判定だけを JavaScript に写し、120件の文書で、ループが止まるまでの往復回数と API の呼び出し回数を数えたシミュレーションです。200回で打ち切っています。1.17 の「200以上」は、打ち切らなければ続いていたという意味です。

「残りの数」を止まる条件にしているループがあったら、その残りが減らない場合を一度考えてみてください。自分の場合は、利用者の方の報告を受けて直すまで、その場合を考えていませんでした。

シミュレーションのコード

sim.js
// 1.18.0 前後のループを、サーバー側とブラウザ側の判定だけ写して数える
const CAP = 200;
function makeDocs(n, bad) { return Array.from({length:n},(_,i)=>({id:i+1, kind: bad[i+1]||'ok', emb:false})); }
function api(batch, s) { // 1回の埋め込みリクエスト
  s.api++;
  if (s.rateLimited) return {ok:false, why:'rate_limit'};
  if (batch.some(d=>d.kind==='too_large')) return {ok:false, why:'too_large'}; // バッチ全体が拒否される
  return {ok:true};
}
function serverOld(docs, s) {
  const pending = docs.filter(d=>!d.emb).slice(0,50);          // ORDER BY id LIMIT 50
  if (!pending.length) return {processed:0, remaining:0};
  const texts = pending.filter(d=>d.kind!=='no_text');           // 空文字は送らない
  let processed=0;
  if (texts.length && api(texts,s).ok) { texts.forEach(d=>d.emb=true); processed=texts.length; }
  return {processed, remaining: docs.filter(d=>!d.emb).length};
}
function serverNew(docs, s) {
  const skip = new Set(Object.keys(s.err).filter(id=>['too_large','no_text'].includes(s.err[id])).map(Number));
  const pending = docs.filter(d=>!d.emb && !skip.has(d.id)).slice(0,50);
  if (!pending.length) return {processed:0, remaining:0};
  let processed=0;
  const texts = pending.filter(d=>{ if (d.kind==='no_text'){ s.err[d.id]='no_text'; return false;} return true; });
  if (texts.length) {
    const r = api(texts,s);
    if (r.ok) { texts.forEach(d=>d.emb=true); processed=texts.length; }
    else if (r.why==='rate_limit') { texts.forEach(d=>s.err[d.id]='rate_limit'); } // 全件共通の失敗は一括で理由付け
    else for (const d of texts) { const r1 = api([d],s); if (r1.ok){d.emb=true;processed++;} else s.err[d.id]=r1.why; } // 1件ずつ入れ直す
  }
  const skip2 = new Set(Object.keys(s.err).filter(id=>['too_large','no_text'].includes(s.err[id])).map(Number));
  return {processed, remaining: docs.filter(d=>!d.emb && !skip2.has(d.id)).length};
}
function run(server, clientNew, docs, rateLimited=false) {
  const s={api:0, ajax:0, err:{}, rateLimited};
  while (true) {
    if (s.ajax >= CAP) return {...s, stopped:false};
    s.ajax++;
    const r = server(docs, s);
    const cont = clientNew ? (r.remaining>0 && r.processed>0) : r.remaining>0;
    if (!cont) return {...s, stopped:true};
  }
}
const cases = [
  ['全部正常', 120, {}, false],
  ['id 10 が大きすぎる', 120, {10:'too_large'}, false],
  ['id 110 が大きすぎる', 120, {110:'too_large'}, false],
  ['id 10 がテキストなし', 120, {10:'no_text'}, false],
  ['レート制限が続く', 120, {}, true],
];
for (const [name,n,bad,rl] of cases) {
  for (const [label, srv, cli] of [['1.17', serverOld, false], ['1.18', serverNew, true]]) {
    const docs = makeDocs(n,bad); const r = run(srv, cli, docs, rl);
    console.log(`${name.padEnd(14)} ${label}  AJAX=${String(r.ajax).padStart(3)}${r.stopped?'':'(打ち切り)'}  API=${String(r.api).padStart(3)}  埋め込み済み=${docs.filter(d=>d.emb).length}/${n}`);
  }
}

ふだんはraplsworks.comで、WordPressプラグイン開発やClaude Codeまわりのことを書いています。
https://raplsworks.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?