LangChainで専門分野のチャットボットの精度を上げる試み【②インデックス編】

Featured image of the post

概要

専門分野のチャットボットを作るシリーズの第2回目!

前回のおさらい

✅自社開発している専門的で複雑なソフトのチャットボットを作りたい。

✅しかし専門的すぎて精度を上げるのが難しそう。

✅独自データは「Q&A 4000個」「解説書 4000ページ」を持っている。

今回やること

インデックスの内容を変えることで精度の向上を試みる✅

Image in a image block

インデックスとは

前提として独自データを扱うには、独自データからインデックスを作成する必要がある✅

イメージ図

Image in a image block

今回はインデックスについて理解している前提で進める。

💡
インデックスについてはこちらで解説している。

🚫 ただいま記事作成中です💭

インデックス化するデータ

今回はCSV(Q&Aデータ)とPDF(解説書データ)をインデックス化する✅

Image in a image block

💡
(補足)
図は簡略化しているが、実際はCSV10個、PDF10個もある。

結論ファースト

✅独自データが複雑であるほど、インデックスを工夫する価値がありそう

✅大量の独自データを1つのインデックスにまとめると、適切に独自データから回答を見つけられなかった

カテゴリーごとにインデックスを分けるのもあり(エージェントと併用する)

重複したデータはまとめておくとよさそう

精度向上のために試したこと

前提として「どのようにインデックス化するのがベストか?」は持っている独自データによって変わりそう✅

💡
今回の例を参考に、要件に合わせて試行錯誤してみると◎

そもそもインデックス作成で試行錯誤する必要があるか

もし独自データが「Q&A10題だけ」などのシンプルなデータなら、何も考えずインデックスを作ってもあまり精度は変わらなさそう💭

今回は癖の強い独自データを扱うので工夫する価値があると考えた✅

  • データが大量(Q&A4000題、解説書4000ページ)
  • ファイルが分かれている(合計20個のファイル
  • ファイル形式が複数ある(CSVとPDF
  • CSVとPDFで一部内容が重複している(言い回しが違うけど同じような内容
  • 専門用語が多数出てくる

イメージ

Image in a image block
Image in a image block

インデックス比較結果

先にインデックスごとの比較結果を示す✅

インデックス 精度 結果
案1 そのまま使用 どのファイルに答えがあるか見つけられない。
案2 CSVを1つ、PDFを1つにまとめる どのファイルに答えがあるか見つけられない。
案3 すべてのファイルを1つにまとめる 独自データの中から回答を見つけられない。
案4 CSVとPDFの同じカテゴリーを1つにまとめる 🔺 少し精度がよくなったが、どのファイルに答えが見つけられないことがある。

案1:そのまま使用
✅合計20個のファイルをそのまま使う

(画像は簡略化のため6個にしている)

Image in a image block

✅質問実行プログラムのイメージ
Image in a image block

✅所感
メリット⭕️ デメリット❌ 精度
どのファイルに答えがあるかAIに探してもらうのが難しく、関係ないデータを使ってしまうことが多い。
💡
AIが正しいデータを探すのを想像以上に苦戦している…😫

✅考察
💡
「ファイル数が20個と多すぎる」「Q&AとPDFに一部重複した内容がある」「用語が専門的」などの原因が考えられる💭

案2:CSVを1つ、PDFを1つにまとめる
✅20ファイル → 2ファイルにまとめる

(画像は簡略化のため6個にしている)

Image in a image block

✅質問実行プログラムのイメージ
Image in a image block

✅所感
メリット⭕️ デメリット❌ 精度
どちらのファイルに答えがあるかAIに探してもらうのが難しく、関係ないデータを使ってしまうことが多い。
💡
ファイルを2つにまとめれば、答えを探すのが簡単になる…?と思ったが改善されず😫

✅考察
💡
「Q&AとPDFに一部重複した内容がある」「用語が専門的」の問題は変わりないためあまり改善しなかったのかも💦

案3:すべてのファイルを1つにまとめる

おそらくこれが一番オーソドックスな方法!

✅20ファイル → 1ファイルにまとめる

(画像は簡略化のため6個にしている)

Image in a image block

✅質問実行プログラムのイメージ
Image in a image block

✅所感
メリット⭕️ デメリット❌ 精度
どのファイルに答えがあるか探す処理が不要になった。

・ファイル間で重複した内容がなくなった。
正しい情報を使ってくれず正確な回答にならない。
💡
全データを1つにまとめて渡せば回答に必要なデータを使ってくれる…?と思ったが改善されず😫

✅考察
💡
大量のデータを1つにまとめるとピンポイントの回答を探すのが大変になるのかも💦
💡
「用語が専門的」かつ「データが大量」なのでピンポイントな回答が難しいのかも💦

案4:CSVとPDFの同じカテゴリーを1つにまとめる
✅20ファイル → 10ファイルにして重複した内容を1つのファイル内にまとめる

(画像は簡略化のため6個にしている)

Image in a image block

✅質問実行プログラムのイメージ
Image in a image block

✅所感
メリット⭕️ デメリット❌ 精度
・ファイルを意味のある塊(カテゴリー毎)で分けられた。

・ファイル間で重複した内容がなくなった。
どのファイルに答えがあるかAIに探してもらうのが少し簡単になったが、まだ関係ないデータを使うことがある。 🔺
💡
どのファイルに答えがあるか探しやすくなった気がする😊

✅考察
💡
「ファイルをカテゴリーごと(10個)に分けた」「ファイル間で重複した内容がなくなった」ので答えがどこにあるか探しやすくなったと思われる😊
💡
「用語が専門的」なので、どのファイルに答えがあるか探すのが完璧にできない💦
💡
本来は重複しているデータはすべて削除した方が理想かも💦量が多くて個別に対応するのが非現実的だったので、今回はファイルをまとめるだけで済ませた💦

【補足】その他気をつけたこと

独自データの中でそのまま使うとまずそうな点は事前に修正した

CSVデータは1列にまとめる

Q&Aのデータ(CSV)はQ列とA列の2列に分かれていた。

💡
LangChainで扱うときはデータを1列にまとめる必要がある

(解決策)

Excelやスプレッドシートを使って1列にまとめた。

修正前

Q A
<p>質問テスト1</p> <p>回答テスト1</p>
<p>質問テスト2</p> <p>回答テスト2</p>

修正後

id html
1 Q:<p>質問テスト1</p>
A:<p>回答テスト1</p>
2 Q:<p>質問テスト2</p>
A:<p>回答テスト2</p>

💡
ついでに「Q:」「A:」をつけて、質問と回答の区切りをわかりやすくした!

不要なHTMLタグは削除する

CSVデータの中身がHTML形式だった。

HTMLタグは学習データとして不要💦

(解決策)

HTMLタグを除去する処理を作成した。

docs.map((row) => {
			// 正規表現でHTMLタグを除去
      row.pageContent = row.pageContent.replace(/<([^'">]|"[^"]*"|'[^']*')*>/g,'');
      row.pageContent = decodeHTMLSpecialWord( row.pageContent );
});

💡
これでCSVファイル内のHTMLタグがすべて削除できる!

HTML特殊文字はアンエスケープしておく

CSVデータからHTMLタグを削除したが、まだ&amp;などのHTML特殊文字が残っていた💦

(解決策)

&amp;& のように特殊文字を置換する関数を作成した。

// HTML特殊文字をアンエスケープ
function decodeHTMLSpecialWord(str: string): string {
  return str
  .replace(/&amp;/g, "&")
  .replace(/&lt;/g, "<")
  .replace(/&gt;/g, ">")
  .replace(/&quot;/g, '"')
  .replace(/&#x27;/g, "'")
  .replace(/&comma;/g, ",")
  .replace(/&period;/g, ".")
  .replace(/&colon;/g, ":")
  .replace(/&semi;/g, ";")
  .replace(/&apos;/g, "'")
  .replace(/&ldquor/g, "„")
  .replace(/&prime/g, "′")
  .replace(/&Prime/g, "″")
  .replace(/&tprime/g, "‴")
  .replace(/&qprime/g, "⁗")
  .replace(/&bull;/g, "•")
  .replace(/&uml;/g, "¨")
  .replace(/&hellip/g, "…")
  .replace(/&sol;/g, "/")
  .replace(/&bsol;/g, "\\")
  .replace(/&verbar;/g, "|")
  .replace(/&brvbar;/g, "¦")
  .replace(/&lpar;/g, "(")
  .replace(/&rpar;/g, ")")
  .replace(/&lbrack;/g, "[")
  .replace(/&rbrack;/g, "]")
  .replace(/&lbrace;/g, "{")
  .replace(/&rbrace;/g, "}")
  .replace(/&lsaquo/g, "‹")
  .replace(/&rsaquo/g, "›")
  .replace(/&laquo;/g, "«")
  .replace(/&raquo;/g, "»")
  .replace(/&permil;/g, "‰")
  .replace(/&pertenk;/g, "‱")
  .replace(/&ordf;/g, "ª")
  .replace(/&deg;/g, "°")
  .replace(/&micro;/g, "µ")
  .replace(/&nbsp;/g, " ")
  .replace(/&copy;/g, "©")
  .replace(/&lsquo;/g, "‘")
  .replace(/&rsquo;/g, "’")
  .replace(/&ldquo;/g, "“")
  .replace(/&rdquo;/g, "”")
  .replace(/&Alpha;/g, "Α")
  .replace(/&alpha;/g, "α")
  .replace(/&Beta;/g, "Β")
  .replace(/&beta;/g, "β")
  .replace(/&Gamma;/g, "Γ")
  .replace(/&gamma;/g, "γ")
  .replace(/&Delta;/g, "Δ")
  .replace(/&delta;/g, "δ")
  .replace(/&Epsilon;/g, "Ε")
  .replace(/&epsilon;/g, "ε")
  .replace(/&Zeta;/g, "Ζ")
  .replace(/&zeta;/g, "ζ")
  .replace(/&Eta;/g, "Η")
  .replace(/&eta;/g, "η")
  .replace(/&Theta;/g, "Θ")
  .replace(/&theta;/g, "θ")
  .replace(/&Iota;/g, "Ι")
  .replace(/&iota;/g, "ι")
  .replace(/&Kappa;/g, "Κ")
  .replace(/&kappa;/g, "κ")
  .replace(/&Lambda;/g, "Λ")
  .replace(/&lambda;/g, "λ")
  .replace(/&Mu;/g, "Μ")
  .replace(/&mu;/g, "μ")
  .replace(/&Nu;/g, "Ν")
  .replace(/&nu;/g, "ν")
  .replace(/&Xi;/g, "Ξ")
  .replace(/&xi;/g, "ξ")
  .replace(/&Omicron;/g, "Ο")
  .replace(/&omicron;/g, "ο")
  .replace(/&Pi;/g, "Π")
  .replace(/&pi;/g, "π")
  .replace(/&Rho;/g, "Ρ")
  .replace(/&rho;/g, "ρ")
  .replace(/&Sigma;/g, "Σ")
  .replace(/&sigma;/g, "σ")
  .replace(/&Tau;/g, "Τ")
  .replace(/&tau;/g, "τ")
  .replace(/&Upsilon;/g, "Υ")
  .replace(/&upsilon;/g, "υ")
  .replace(/&Phi;/g, "Φ")
  .replace(/&phi;/g, "φ")
  .replace(/&Chi;/g, "Χ")
  .replace(/&chi;/g, "χ")
  .replace(/&Psi;/g, "Ψ")
  .replace(/&psi;/g, "ψ")
  .replace(/&Omega;/g, "Ω")
  .replace(/&omega;/g, "ω")
  .replace(/&#x60/g, "`");
};

💡
これで主要なHTML特殊文字はすべて置換できる!

【補足】完成したコード
動作イメージ

同じカテゴリーの独自データ2つ「dataA.csv」と「dataA.pdf」から、1つのインデックス「dataA」を作る。

|
|----dataA.csv(独自データ)
|----dataA.pdf(独自データ)
|
|----index
|      |
|      |----dataA(🆕CSVPDFから作られたインデックス)

💡
実際は「dataA.csv」と「dataA.pdf」だけではなく、持っている独自データの数だけこのインデックス作成を行う。

make_index.ts

// ----------------------------------------------------------------
// HTML特殊文字をアンエスケープ
// ----------------------------------------------------------------
function decodeHTMLSpecialWord(str: string): string {
  return str
  .replace(/&amp;/g, "&")
  .replace(/&lt;/g, "<")
  .replace(/&gt;/g, ">")
  .replace(/&quot;/g, '"')
  .replace(/&#x27;/g, "'")
  .replace(/&comma;/g, ",")
  .replace(/&period;/g, ".")
  .replace(/&colon;/g, ":")
  .replace(/&semi;/g, ";")
  .replace(/&apos;/g, "'")
  .replace(/&ldquor/g, "„")
  .replace(/&prime/g, "′")
  .replace(/&Prime/g, "″")
  .replace(/&tprime/g, "‴")
  .replace(/&qprime/g, "⁗")
  .replace(/&bull;/g, "•")
  .replace(/&uml;/g, "¨")
  .replace(/&hellip/g, "…")
  .replace(/&sol;/g, "/")
  .replace(/&bsol;/g, "\\")
  .replace(/&verbar;/g, "|")
  .replace(/&brvbar;/g, "¦")
  .replace(/&lpar;/g, "(")
  .replace(/&rpar;/g, ")")
  .replace(/&lbrack;/g, "[")
  .replace(/&rbrack;/g, "]")
  .replace(/&lbrace;/g, "{")
  .replace(/&rbrace;/g, "}")
  .replace(/&lsaquo/g, "‹")
  .replace(/&rsaquo/g, "›")
  .replace(/&laquo;/g, "«")
  .replace(/&raquo;/g, "»")
  .replace(/&permil;/g, "‰")
  .replace(/&pertenk;/g, "‱")
  .replace(/&ordf;/g, "ª")
  .replace(/&deg;/g, "°")
  .replace(/&micro;/g, "µ")
  .replace(/&nbsp;/g, " ")
  .replace(/&copy;/g, "©")
  .replace(/&lsquo;/g, "‘")
  .replace(/&rsquo;/g, "’")
  .replace(/&ldquo;/g, "“")
  .replace(/&rdquo;/g, "”")
  .replace(/&Alpha;/g, "Α")
  .replace(/&alpha;/g, "α")
  .replace(/&Beta;/g, "Β")
  .replace(/&beta;/g, "β")
  .replace(/&Gamma;/g, "Γ")
  .replace(/&gamma;/g, "γ")
  .replace(/&Delta;/g, "Δ")
  .replace(/&delta;/g, "δ")
  .replace(/&Epsilon;/g, "Ε")
  .replace(/&epsilon;/g, "ε")
  .replace(/&Zeta;/g, "Ζ")
  .replace(/&zeta;/g, "ζ")
  .replace(/&Eta;/g, "Η")
  .replace(/&eta;/g, "η")
  .replace(/&Theta;/g, "Θ")
  .replace(/&theta;/g, "θ")
  .replace(/&Iota;/g, "Ι")
  .replace(/&iota;/g, "ι")
  .replace(/&Kappa;/g, "Κ")
  .replace(/&kappa;/g, "κ")
  .replace(/&Lambda;/g, "Λ")
  .replace(/&lambda;/g, "λ")
  .replace(/&Mu;/g, "Μ")
  .replace(/&mu;/g, "μ")
  .replace(/&Nu;/g, "Ν")
  .replace(/&nu;/g, "ν")
  .replace(/&Xi;/g, "Ξ")
  .replace(/&xi;/g, "ξ")
  .replace(/&Omicron;/g, "Ο")
  .replace(/&omicron;/g, "ο")
  .replace(/&Pi;/g, "Π")
  .replace(/&pi;/g, "π")
  .replace(/&Rho;/g, "Ρ")
  .replace(/&rho;/g, "ρ")
  .replace(/&Sigma;/g, "Σ")
  .replace(/&sigma;/g, "σ")
  .replace(/&Tau;/g, "Τ")
  .replace(/&tau;/g, "τ")
  .replace(/&Upsilon;/g, "Υ")
  .replace(/&upsilon;/g, "υ")
  .replace(/&Phi;/g, "Φ")
  .replace(/&phi;/g, "φ")
  .replace(/&Chi;/g, "Χ")
  .replace(/&chi;/g, "χ")
  .replace(/&Psi;/g, "Ψ")
  .replace(/&psi;/g, "ψ")
  .replace(/&Omega;/g, "Ω")
  .replace(/&omega;/g, "ω")
  .replace(/&#x60/g, "`");
};

// ----------------------------------------------------------------
// CSVからドキュメントを作成(汎用的な関数)
// ----------------------------------------------------------------
async function make_document_from_csv( 
  csvPath      : string,             // ドキュメントの元データのパス
  csvColumn    : string,             // ドキュメントに使用するカラム名
  bDelHtml     : boolean = false,    // CSV内にあるHTMLタグを除去するか
  bSplit       : boolean = true,     // テキストを分割するか
  chunkStrSize : number  = 500,      // 分割する文字数
)
{
  // ドキュメントの読み込み
  const loader = new CSVLoader( csvPath, csvColumn );
  let docs;
  if ( bSplit ){
    // テキスト分割あり
    const textSplitter = new RecursiveCharacterTextSplitter({ chunkSize: chunkStrSize });
    docs = await loader.loadAndSplit(textSplitter);
  }
  else{
    // テキスト分割なし
    docs = await loader.load();
  }

  // HTMLタグを除去する
  if ( bDelHtml ){
    docs.map((row) => {
      row.pageContent = row.pageContent.replace(/<([^'">]|"[^"]*"|'[^']*')*>/g,'');
      row.pageContent = decodeHTMLSpecialWord( row.pageContent );
    });
  }
  return docs;
};

// ----------------------------------------------------------------
// PDFからドキュメントを作成(汎用的な関数)
// ----------------------------------------------------------------
async function make_document_from_pdf ( 
  pdfPath   : string,             // ドキュメントの元データのパス
  bDelHtml  : boolean = false,    // PDF内にあるHTMLタグを除去するか
  bSplit    : boolean = true,     // テキストを分割するか
  chunkStrSize : number  = 500,      // 分割する文字数
): Promise<any>
{
  // ドキュメントの読み込み
  const loader = new PDFLoader( pdfPath );
  let docs;
  if ( bSplit ){
    // テキスト分割あり
    const textSplitter = new RecursiveCharacterTextSplitter({ chunkSize: chunkStrSize });
    docs = await loader.loadAndSplit(textSplitter);
  }
  else{
    // テキスト分割なし
    docs = await loader.load();
  }

  // HTMLタグを除去する
  if ( bDelHtml ){
    docs.map((row) => {
      row.pageContent = row.pageContent.replace(/<([^'">]|"[^"]*"|'[^']*')*>/g,'');
      row.pageContent = decodeHTMLSpecialWord( row.pageContent );
    });
  }

  return docs;
};

// ----------------------------------------------------------------
// インデックス作成
// ----------------------------------------------------------------
async function main() {
	// CSVのドキュメント
	const docs_csv = await make_document_from_csv( "dataA.csv", "html", true, false );
	// PDFのドキュメント
	const docs_pdf = await make_document_from_pdf( "dataA.pdf", false, true );

	// ドキュメントを結合
	const docs = docs_csv.concat( docs_pdf );

	// インデックス作成
	const vectorStore = await HNSWLib.fromDocuments( docs, new OpenAIEmbeddings() );
	await vectorStore.save( "index/dataA" );
}

💡
コピペでOK!
最後のmake_document_from_csvmake_document_from_pdfの第一引数だけ変えれば使える✅

(このコードのポイント)

2つ以上のファイルから1つのインデックスを生成

💡
類似した内容のファイルがある場合、1つのインデックスにまとめた方が精度が上がるかも!

✅ファイル内のHTMLタグを除去可能

💡
レアケースかもだけど、CSVやPDFにHTMLタグがあっても除去可能!
💡
make_document_from_csvmake_document_from_pdfの引数でON、OFFを変えられる!

✅テキスト分割の設定が自由

💡
インデックス化するときのテキスト分割を自由に設定できる!
💡
make_document_from_csvmake_document_from_pdfの引数で設定可能!

次回

今回はここまで!

試した中では「案4:CSVとPDFの同じカテゴリーを1つにまとめる」が一番よさそうだった!

次回はエージェントを使った質問プログラムでもっと精度を上げる〜🙌