LabHub
はじめる
学ぶ 学習パス コース

音声 AI エージェント — 聞いて、調べて、話すパイプライン

誤認識された質問で探し、根拠を検査した答えだけを話す

LabHubで続きを見る

目標

ASRが書き起こした電話のクエリで段落検索を作り、クエリの書き換え・拒否のしきい値・引用の検証を順に付けて、根拠のない発言が口から出ないRAGを作ります。

なぜ重要なのか

音声クエリは、誤認識とフィラーと聞き返しを伴って届きます。小さなLLMは、形式も内容も間違えます。そのため、「何を探すか」と「何を話してよいか」は、コードが決める必要があります。クエリファイル(/opt/lab/fixtures/voice/rag/queries.jsonl)のasr欄は、合成音声をこのイメージのASRが実際に書き起こした結果で、relevantとfactsは手で付けた正解です。採点ツールは、検索・書き換え・検証の関数を呼び出して隠しケースで試し、最終的な発話が根拠に裏付けられているかをもう一度検査します(LLMには尋ねません)。

ステップ

  1. /opt/lab/fixtures/voice/kb/*.mdを空行で分け、タイトル(#)行を除いた段落ごとに{"id": "kb-hours#1", "doc": "kb-hours", "text": …}を/root/voice/rag/chunks.jsonlに書いてください。
  2. 段落をvoicekit.models.Embedderでエンベディングして、/root/voice/rag/embed.npy((段落数, 384))に保存してください。
  3. search(query, k=3)が[(문단 id, 코사인 점수), …]を返す/root/voice/rag/search.pyを作ってください(プレースホルダーは段落idとコサインスコアです)。
  4. フィラーの除去・数字の単語の整理・聞き返しの解決を行うrewrite(asr_text, context=None)を、/root/voice/rag/rewrite.pyに作ってください。
  5. 元のクエリと書き換えたクエリで、top-1・top-3の再現率と、クエリごとの1位のスコアを/root/voice/rag/eval.jsonに書いてください。
  6. 答えのある質問とない質問を分けるスコアのしきい値を、/root/voice/rag/threshold.jsonに書いてください。
  7. しきい値を超えた質問だけをLLMにjson_schemaで尋ねて、答えと出典を/root/voice/rag/answers.jsonlに書いてください。
  8. 答えを検査するverify()を/root/voice/rag/verify.pyに作り、落ちた答えを抽出に置き換えた最終的な発話を/root/voice/rag/final.jsonlに書いてください。

参考

段落に分ける

/opt/lab/fixtures/voice/kb/*.mdを名前順に読み、空行(\n\n)で分けて、空であるか#で始まる断片は除き、段落ごとに、空白を1つにまとめたtextと、id(ドキュメント名#連番、1から)・docを/root/voice/rag/chunks.jsonlに1行ずつ書いてください。

ドキュメント名は、ファイル名から.mdを除いたものです。" ".join(p.split())が、改行と連続した空白を1つにまとめます。

段落をエンベディングする

chunks.jsonlのtextを順にvoicekit.models.Embedder().embed([...])でエンベディングして、np.saveで/root/voice/rag/embed.npyに保存してください((段落数, 384)、行の順序 = chunks.jsonlの順序)。

Embedderは、長さ1に正規化したベクトルを返します。行の順序がずれると、検索結果のidがまったく別の段落を指してしまいます。

コサイン検索関数

/root/voice/rag/search.pyにsearch(query, k=3)を作ってください。chunks.jsonlとembed.npyを読み込んでおき、クエリをエンベディングして、内積(=コサイン)が大きい順に、[(문단 id, 점수), …]をk個返します(プレースホルダーは段落idとスコアです)。モデルは、最初に呼ぶときに1回だけ作ってください。

VEC @ qの1行で、すべての段落とのコサインが出ます。np.argsort(-s)[:k]。ファイルのパスをos.path.dirname(file)基準にしておけば、どこから呼び出しても動きます。

クエリを整える

/root/voice/rag/rewrite.pyにrewrite(asr_text, context=None)を作ってください。小文字にして単語だけを残し、フィラー(um・uh・oh・er・ah・like・okay・ok・o・k・hi・hello・so・well・ohi)を除いて、数字の単語を数字に(eight → 8、twenty four → 24)変換します。結果が「and what about …」・「what about …」・「how about …」でcontextがあれば、contextを同じルールで整えたあと、新しい曜日があればその曜日に置き換え、なければ後ろに付け足します。採点ツールが隠しケースで試します。

聞き返しのcontextもASRの結果なので、同じrewriteを先に通します。数字は、十の位(twenty…)と一の位(one…nine)が続いたら足します。「o」と「k」は、「OK」が「O K」と書き起こされたものです。

再現率とスコアを測る

12個のクエリを、元のasrで(raw)、そしてrewriteで整えて(rewritten、contextは前のクエリのasr)、それぞれsearch(…, 3)したあと、答えのある10個でrecall_at_1・recall_at_3を/root/voice/rag/eval.jsonに書き、書き換えたクエリの1位のスコアを、scoresにクエリidごとに書いてください。

段落idをドキュメント名(doc)に変えて、正解ドキュメントと比べます。q11・q12は答えのない質問なので再現率からは除きますが、スコアは書きます。次のステップのしきい値に使います。

答えのない質問を分けるしきい値

eval.jsonのscoresで、答えのある質問の最低スコアと答えのない質問の最高スコアの間にしきい値tauを定めて、/root/voice/rag/threshold.json(tau・answerable_min・unanswerable_max)に書いてください。答えのあるすべての質問はtau以上、答えのない質問はtau未満になる必要があります。

2つの値のちょうど真ん中が無難です。2つが重なる場合は、どんなしきい値でも両方を正しく分けられません。そのときは、検索ではなくクエリ(書き換えルール)を見直してください。

しきい値を超えた質問だけをモデルに尋ねる

クエリごとに書き換えと検索(top-3)を行い、1位のスコアがtau未満なら、モデルに尋ねず、拒否文(「I'm not sure about that. Let me connect you to the front desk.」)をanswerに、sourceをnoneにします。超えていれば、見つけた段落に[ドキュメントid]を付けてシステムプロンプトに入れ、answer(文字列)・source(見つけたドキュメントidとnoneだけを許可するenum)のjson_schemaで尋ねて、結果を書きます。/root/voice/rag/answers.jsonlに、id・query・retrieved(見つけたドキュメントid、重複なし)・score・answer・source・gatedを1行ずつ書いてください。

schema = {"type": "object", "required": ["answer", "source"], "properties": {"answer": {"type": "string"}, "source": {"type": "string", "enum": retrieved + ["none"]}}}。文法が防いでくれるのは形式だけです。内容は次のステップで検査します。

検査を通過した発言だけを話す

/root/voice/rag/verify.pyにverify(answer, source, retrieved, kb)を作ってください。kbは{ドキュメントid: ドキュメント全文}で、返すのは(通過したかどうか、理由)です。空の答え・sourceがnone・見つけていないドキュメントの引用・ドキュメントにない数字(時刻7:30のような形式を含む)があれば落とします。そのあとanswers.jsonlを検査して、通過した答えはそのまま(statusはllm)、しきい値で拒否された質問は拒否文(refused)、落ちた答えは引用ドキュメント(または1位のドキュメント)から、質問とのエンベディングが最も近い文をそのまま読み上げる形(extractive)に置き換えて、/root/voice/rag/final.jsonl(id・say・source・status)に書いてください。

数字は、re.findall(r"\d+(?::\d+)?", …)で取り出して、ドキュメントの数字のリストにすべてあるかを見ます。文は(?<=[.!?])\s+で分けます。抽出は間違えることがありませんが、質問にぴったり合わないことがあります。その代償を、モジュール9で測ります。