js 版の LangChain で LCEL を試す
2024/01/19

最近、私たちのチームは ChatGPT を活用する PoC (Proof of Concept) 案件を受注し、数名で開発に取り組みました。

このプロジェクトでは、主に Python で LangChain、LlamaIndex、Ragas などを活用して開発検証を行っています。 今、LLM を使った開発は、このあたりのライブラリを使うのが主流であろうと思います。

私の役割は、調査のシナリオ立案と結果のレポート作成だったので、コードを書く機会は無く、 そのため、LLM 関連のプログラムを書くときの大変さとか難易度を肌感覚で掴めておらず、 やや、もどかしさを感じていたので、サンデープログラミングで実装の体験をすることにしました。

業務では、Python 版の LangChain を使用していましたが、日曜大工では、JavaScript版の LangChain を試してみることにしました。 さらに、LangChain は昨年あたりから LCEL という機能を推進しているので、これも試します。

PoC 案件と同じものを作っても面白くないので、私の手習いの実装テーマは「MD の英文テキストを日本語に翻訳する cli ツール」とします。そして、LangChain のマニュアルが、まだ日本語訳がないので、これを翻訳します。

LangChain のドキュメントは、コードのリポジトリの中に一緒にコミットされていて、主に MD と Jupyterノートブックで書かれています。 それらを、Docusaurus というドキュメントシステムで公開するようになっています。 手習いのゴールは、この英文のドキュメントを日本語訳して、Docusaurus でビルドして、日本語でドキュメントシステムが表示されるところまでとします。

Markdown パーサー

ChatGPT に MD のテキストを丸々投げると、max_token を超えることが懸念されるので、分割して翻訳します。 単純に文字数で chunk 分割すると、翻訳結果がおかしくなるので、文単位で分割するのは必須要件となります。 このために、MD ファイルを抽象構文木(AST)に変換し、文の正確な抽出を行います。 この変換には、 remark を使いました。

実際、MD を AST に変換してみると、どのドキュメントも、適度に空行が入っているので、AST の 1 つのテキストノードの中に、 max_token を超えるほどの長い文は、ほとんどありませんでした。 また、コードのブロックもノードタイプで識別できるので、翻訳対象から除外できます。 テストした中では、唯一、MDのテーブルが、テーブル全体で1つのテキストになるので、これだけは、改行を区切り文字として、分割する必要がありました。

翻訳処理

本記事の主題です。 LangChain を使って、 LCEL (LangChain Expression Language)で翻訳処理を実装します。

その前に、翻訳処理の仕様を簡単に説明すると、最初のプロンプトで英文から日本語に翻訳して、その結果を受けて、その翻訳が正しいかを、添削するプロンプトを実行します。添削結果には、翻訳の正確さを数値で出力するように指示して、その数値が閾値を超えるまで、添削を繰り返します。

最初の英文から日本語への翻訳 Chain

const etojPrompt = await DEFAULT_ETOJ_PROMPT;
const etojOutput = (await etojPrompt
  .pipe(model)
  .pipe(new JsonOutputParser())
  .withRetry({ stopAfterAttempt: MAX_LLM_RETRY_ATTEMPTS })
  .invoke(etojInput)) as EtojOutput;

この手習いをやり始めたときには、LCELじゃなくて、従来方式で実装していたのですが、 翻訳されたマニュアルを読んでいるうちに、LCEL で実装することが推奨されていることを知ったので、 せっかくなので、 LCEL での実装に切り替えてみました。 やってることは変わらないのだけど、コードの見通しがよくなったような気がします。

Python で実装すると、たぶん、こんな感じだと思います。

model = ChatOpenAI()
etojPrompt = ChatPromptTemplate.from_template(DEFAULT_ETOJ_PROMPT)
chain = etojPrompt | model | JsonOutputParser()
etojOutput = chain.invoke(etojInput)

|pipe() かの違いはあるけど、機能的には同じです。

余談ですが、プロンプトの実装で、Python 版では、jinja2 のテンプレートエンジンを使えるんですが、 js 版では、テンプレートエンジンが使えません。 プロンプトの文字列をリアクティブに変数展開したいことがあるので ejs でもよいのでテンプレートエンジンを使えるようにしてほしいです。 今回の実装では、添削を何回か繰り返すときに、前に添削した内容と同じ添削結果を返してくることがあったので、 過去の添削結果を、添削用プロンプトの中に含めて、同じ添削結果を返さないように指示するのですが、 その処理の実装をしているときに、python だったら jinja2 が使えるのにな・・・と思いました。

添削 Chain

最初のチェーンと添削チェーンも、1つの LCEL でつなげた実装にしようかとも思ったのだけど、 正確さが閾値を超えたら終了する仕様を実装するには、カスタムチェーンを作る必要がありそうなので、 そこまではやらずに、チェーンを分けて、ループさせることにしました。

const proofreadChain = proofreadPrompt
  .pipe(model)
  .pipe(new JsonOutputParser())
  .withRetry({ stopAfterAttempt: MAX_LLM_RETRY_ATTEMPTS });

const proofreadInput: ProofreadInput = {
  ...etojInput,
  ...etojOutput,
  histories: [
    { proofreadText: etojOutput.ja, correctness: 0.0, error: '' },
  ],
};
let answer = '';
for (let i = 0; i < MAX_PROOFREAD_ATTEMPTS; i++) {
  const proofreadOutput = (await proofreadChain
    .invoke(proofreadInput)) as ProofreadOutput;
  answer = proofreadOutput.proofreadText;
  if (proofreadOutput.correctness >= TRANSLATION_CORRECTNESS_THRESHOLD) {
    break;
  }
  proofreadInput.histories.push(proofreadOutput);
}
return answer;

LCEL の batch で、効率の良い並列処理を実行するには、カスタムな添削チェーンで、1本につなげた方がよいです。

まとめ

手習いのゴールとした LangChain のマニュアルを日本語訳してドキュメントシステムをローカルで動かすのは、うまく行きました。 LangChain のドキュメントシステムは、amazon linux 2 で実行するようになっていて、ビルドも、それ用になっているので、 開発環境とした ubuntu の devcontainer でビルドできるようにするのに、ひと手間必要でした。

さて、LCEL についてですが、慣れるまで、少し時間がかかりました。 もう少し "らしさ" を出せるかな・・・と思っていたのだけど、 LCEL にするために知恵を絞らないといけないのが、やや面倒だなと感じましたが、おそらく、その知恵を絞ることで、コードが改善されて可読性が上がるのかもしれないです。 でも、LCEL だろうが、LCEL じゃなかろうが、やってることは変わらないので、コスト見合いで、コダワリ過ぎには注意した方がよいかもしれません。

それから、反省点というか注意点ですが、実行時間と、APIの使用料が想定外にかかりました。 LangChain のマニュアルは、1000 ドキュメントくらいあって、何度か止まって再起動していて、 実行するだけで、トータルで 3 日以上かかり、API の利用料が、$500 ちょっとでした。 1ドキュメントを翻訳するのに $0.5 です。

$0.5 が高いか安いかは、人によると思いますが、私の場合、日本語の技術情報が少ない開発をするときに、 英語のマニュアルを、クラウドソーシングなどで翻訳してもらって、 日本語でザックリと読んで概略を掴んでから開発に入ることがあるのですが、 クラウドソーシングに比べると、全然、安いので、個人の財布で実行しようとは思いませんが、 今回作成した翻訳ツールは、実際に使っていこうと思います。

WEB で参照できる英文のマニュアルなら、Google 翻訳で、無料で翻訳することができますが、 日本語で検索しても、ヒットしないので、あらかじめ日本語訳しておくことは、有用だと思います。

少し面白そうな使い道としては、翻訳した MD ファイルを独自に追記編集するとかも有りだと思います。 開発を始めたばかりのときに読んだマニュアルは、まだ、十分に理解できないことがありますが、 開発が進んで、実際に動作を確認していくと、マニュアルに記載されている内容が、理解できたりします。 このときに、おらがチームのマニュアルに、理解の手助けになるようなメモを追加するとか、 実プロジェクトのコード片を追記したりして、独自にドキュメントを育てていくと、Google翻訳よりも、価値があるように思います。

実装した翻訳ツールは、こちらで公開しています。使ってみたい方は、APIの費用に注意して、ご利用なさってください。

https://github.com/minr-dev/md-translation-gpt

最後に、このツールを追加改良したいことを書いておきます。

改良点

  • 過去の翻訳結果をコンテキストとして加えてプロンプトを作成する

    翻訳した結果を RAG にして refine 法でプロンプトを作成すると、一貫性のある翻訳ができるようになるのではないかと思います。

  • 並列化

    バッチ処理なので、 LCEL の batch で並列化したいです。実行時間も短縮化できますし。

  • chatbot

    翻訳されたマニュアルで RAG を駆使して chatbot を動かしたいです。

  • rst の翻訳

    LlamaIndex のマニュアルも翻訳しようとしたら、こちらは rst (reStructuredText)で書かれていました。 Sphinx です。python の docutils でパースする必要があります。

MIT で公開しているので、有志のご参加をお待ちしています。

最近の記事タグ

\(^▽^*) 私たちと一緒に働いてみませんか? (*^▽^)/

少しでも興味をお持ちいただけたら、お気軽に、お問い合わせください。

採用応募受付へ

(採用応募じゃなく、ただ、会ってみたいという方も、大歓迎です。)