白猫のメモ帳

C#とかJavaとかJavaScriptとかHTMLとか機械学習とか。

TypeScriptでLangChainを使ってみる その2 検索編

こんばんは。
無印で売り切れになってたカレンダーが復活してたので、買って帰ったら4月始まりでした。

前回は基礎編でしたが、今回は検索編です。
shironeko.hateblo.jp

RAGパターンもそうですが、結局どこかからデータを集めたりLLMに渡したりしないと独自データは利用できないので、このあたりはとても大事です。
今回のコードも引き続きGitHubに公開しています。
ちなみに自動テストで書くようにしたんですが、langchainjsがESMじゃないと動かなかったりするものがあってだいぶ混乱しました。(結局スキップしたけど…)
github.com

データの読み込み(DocumentLoader)

まずは簡単にCSVファイルを読み込んでみます。
基本的にLangChainではDocumentという型でデータを扱うので、任意のデータソースからドキュメントを読み込むということでDocumentLoaderという名前です。(たぶん)

こんな感じのCSVを読み込むとして、

都道府県,都道府県(ローマ字),県庁所在地,県庁所在地(ローマ字)
北海道,hokkaido,札幌市,sapporo
青森県,aomori,青森市,aomori
岩手県,iwate,盛岡市,morioka
宮城県,miyagi,仙台市,sendai

CSVLoaderというクラスを使ってこんな感じのコードになります。

const loader = new CSVLoader("./sample_data/sample.csv");
const docs = await loader.load();
console.log(docs);

結果はこんな感じ。デフォだと「見出し: データ」を改行で繋いで1行を1つのデータにしてくれるようです。

[
  Document {
    pageContent: '都道府県: 北海道\n都道府県(ローマ字): hokkaido\n県庁所在地: 札幌市\n県庁所在地(ローマ字): sapporo',
    metadata: { source: './sample_data/sample.csv', line: 1 }
  },
  Document {
    pageContent: '都道府県: 青森県\n都道府県(ローマ字): aomori\n県庁所在地: 青森市\n県庁所在地(ローマ字): aomori',
    metadata: { source: './sample_data/sample.csv', line: 2 }
  },
  Document {
    pageContent: '都道府県: 岩手県\n都道府県(ローマ字): iwate\n県庁所在地: 盛岡市\n県庁所在地(ローマ字): morioka',
    metadata: { source: './sample_data/sample.csv', line: 3 }
  },
  Document {
    pageContent: '都道府県: 宮城県\n都道府県(ローマ字): miyagi\n県庁所在地: 仙台市\n県庁所在地(ローマ字): sendai',
    metadata: { source: './sample_data/sample.csv', line: 4 }
  }
]

カラムの設定をすると、

const loader = new CSVLoader("./sample_data/sample.csv", { column: "都道府県" });
const docs = await loader.load();
console.log(docs);

そのカラムをコンテンツにしてくれるみたい。

[
  Document {
    pageContent: '北海道',
    metadata: { source: './sample_data/sample.csv', line: 1 }
  },
  Document {
    pageContent: '青森県',
    metadata: { source: './sample_data/sample.csv', line: 2 }
  },
  Document {
    pageContent: '岩手県',
    metadata: { source: './sample_data/sample.csv', line: 3 }
  },
  Document {
    pageContent: '宮城県',
    metadata: { source: './sample_data/sample.csv', line: 4 }
  }
]

自分で作る場合にはBaseDocumentLoaderを継承します。例えばデータを読み込む関数を指定する場合だとこんな感じ。

type Load = () => Promise<string>;
export class MyDocumentLoader extends BaseDocumentLoader {
    
    readonly loadFunction: Load;
    
    constructor(loadFunction: Load) {
        super();
        this.loadFunction = loadFunction;
    }

    async load(): Promise<Document[]> {
        const text = await this.loadFunction();
        return text.split("\n").map((line, i) => new Document({ 
            pageContent: line,
            metadata: { lineNumber: i + 1 }
        }));
    }
}

で、実行すると、

const func = async () => `今日は
朝から
とても
いい天気なので
散歩に
行ったよ`;
const loader = new MyDocumentLoader(func);
const docs = await loader.load();
console.log(docs);

こんな感じです。

[
  Document { pageContent: '今日は', metadata: { lineNumber: 1 } },
  Document { pageContent: '朝から', metadata: { lineNumber: 2 } },
  Document { pageContent: 'とても', metadata: { lineNumber: 3 } },
  Document { pageContent: 'いい天気なので', metadata: { lineNumber: 4 } },
  Document { pageContent: '散歩に', metadata: { lineNumber: 5 } },
  Document { pageContent: '行ったよ', metadata: { lineNumber: 6 } }
]

テキストの分割(TextSplitter)

例えばWebページやPDFなんかを1つのデータを読み込むと、とても大きなサイズになってしまいます。
ということはLLMにそのまま渡してしまったりした日にはtoken数がものすごいことになります。
これはなかなかによろしくないので、長いテキストを適度なサイズのドキュメントに分割する仕組みがあると便利そうです。

CharacterTextSplitterを利用すると区切り文字を指定した上でドキュメントに分割することができます。

const text = "吾輩は猫である。名前はまだない。どこで生れたか頓と見当がつかぬ。何でも薄暗いじめじめした所でニャーニャー泣いていた事だけは記憶している。";
const splitter = new CharacterTextSplitter({ 
    separator: "。", // 区切り文字
    chunkSize: 5,    // チャンクの文字数
    chunkOverlap: 0  // チャンクのオーバーラップ
});
const result = await splitter.createDocuments([text]);
console.log(result);

区切り文字自体は含まれないみたいですね。

[
  Document { pageContent: '吾輩は猫である', metadata: { loc: [Object] } },
  Document { pageContent: '名前はまだない', metadata: { loc: [Object] } },
  Document {
    pageContent: 'どこで生れたか頓と見当がつかぬ',
    metadata: { loc: [Object] }
  },
  Document {
    pageContent: '何でも薄暗いじめじめした所でニャーニャー泣いていた事だけは記憶している',
    metadata: { loc: [Object] }
  }
]

チャンクサイズを大きくすると、ドキュメントが過度に小さく分割されるのを防げそうです。

const text = "吾輩は猫である。名前はまだない。どこで生れたか頓と見当がつかぬ。何でも薄暗いじめじめした所でニャーニャー泣いていた事だけは記憶している。";
const splitter = new CharacterTextSplitter({ 
    separator: "。", // 区切り文字
    chunkSize: 30,    // チャンクの文字数(これを超えない限りは区切り文字があっても1つになるみたい)
    chunkOverlap: 10  // チャンクのオーバーラップ(効いてる気がしない)
});
const result = await splitter.createDocuments([text]);
console.log(result);

30文字になるまでちゃんと1つになってる。

[
  Document {
    pageContent: '吾輩は猫である。名前はまだない。どこで生れたか頓と見当がつかぬ',
    metadata: { loc: [Object] }
  },
  Document {
    pageContent: '何でも薄暗いじめじめした所でニャーニャー泣いていた事だけは記憶している',
    metadata: { loc: [Object] }
  }
]

RecursiveCharacterTextSplitterは区切り文字を使わずにサイズで分割します。チャンクオーバーラップを指定すると、コンテンツを重複させることができます。
実際にRAGで利用する際にはある程度の重複をさせたほうが前後の文脈を拾いやすいので、ある程度のオーバーラップをさせるのが良いらしいです。

const text = "吾輩は猫である。名前はまだない。どこで生れたか頓と見当がつかぬ。何でも薄暗いじめじめした所でニャーニャー泣いていた事だけは記憶している。";
const splitter = new RecursiveCharacterTextSplitter({ 
    chunkSize: 20,    // チャンクの文字数(これを超えない限りは区切り文字があっても1つになるみたい)
    chunkOverlap: 10  // チャンクのオーバーラップ(こっちは効いてる)
});
const result = await splitter.createDocuments([text]);
console.log(result);

この長さでオーバーラップしてる意味はないけど、ちゃんとできてますね。

[
  Document {
    pageContent: '吾輩は猫である。名前はまだない。どこで生',
    metadata: { loc: [Object] }
  },
  Document {
    pageContent: 'はまだない。どこで生れたか頓と見当がつか',
    metadata: { loc: [Object] }
  },
  Document {
    pageContent: 'れたか頓と見当がつかぬ。何でも薄暗いじめ',
    metadata: { loc: [Object] }
  },
  Document {
    pageContent: 'ぬ。何でも薄暗いじめじめした所でニャーニ',
    metadata: { loc: [Object] }
  },
  Document {
    pageContent: 'じめした所でニャーニャー泣いていた事だけ',
    metadata: { loc: [Object] }
  },
  Document {
    pageContent: 'ャー泣いていた事だけは記憶している。',
    metadata: { loc: [Object] }
  }
]

自分で作る場合にはTextSplitterを継承します。例えばセパレータを受け取って、それぞれのドキュメントに語尾をつける場合はこうなります。

export interface MyTextSplitterParams extends TextSplitterParams {
    separator: string;
    suffix: string;
}

export class MyTextSplitter extends TextSplitter {

    readonly separator: string;
    readonly suffix: string;

    constructor(fields?: Partial<MyTextSplitterParams>) {
        super(fields);
        this.separator = fields?.separator ?? "";
        this.suffix = fields?.suffix ?? "";
    }

    splitText(text: string): Promise<string[]> {
        return Promise.resolve(
            text.split(this.separator)
                .filter(line => line)
                .map(line => line + this.suffix)
        );
    }
}

まぁ実用性はないですけどね。

const text = "吾輩は猫である。名前はまだない。どこで生れたか頓と見当がつかぬ。何でも薄暗いじめじめした所でニャーニャー泣いていた事だけは記憶している。";
const splitter = new MyTextSplitter({
    separator: "。", // 区切り文字
    suffix: "にゃ"     // 区切り文字の後に付ける文字
});
const result = await splitter.createDocuments([text]);
console.log(result);

こうなります。

[
  Document { pageContent: '吾輩は猫であるにゃ', metadata: { loc: [Object] } },
  Document { pageContent: '名前はまだないにゃ', metadata: { loc: [Object] } },
  Document {
    pageContent: 'どこで生れたか頓と見当がつかぬにゃ',
    metadata: { loc: [Object] }
  },
  Document {
    pageContent: '何でも薄暗いじめじめした所でニャーニャー泣いていた事だけは記憶しているにゃ',
    metadata: { loc: [Object] }
  }
]

埋め込み(Embedding)

Embeddingを利用するとテキストをベクトルに変換することができます。これはざっくり言えばテキストを意味としての数値表現にする手法です。
例えばOpenAIの「text-embedding-ada-002」モデルでは1536次元のベクトルに変換されます。
最近リリースされた「text-embedding-3-large」では3072次元にできるらしいので、そのうち表現力はどんどん上がっていきそうですね。

const embeddings = new OpenAIEmbeddings();
const result = await embeddings.embedQuery("吾輩は猫である");
console.log(result);

人間が見てもその意味はさっぱりわかりません。

[
  -0.0017636258,  -0.0072244154, -0.0052609853,   -0.015052963,  -0.024114948,
    0.013240566,   -0.012416177,  -0.018337931,   -0.002806698,  -0.022755649,
  ... 略
]

ベクトルがどれくらい近しいかの計算にはコサイン類似度を利用することが多いので試してみます。
(コサイン類似度の計算式ってどんなだっけなと思いながら関数名とシグネチャ書いたらGitHub Copilotが全部書いてくれた)

function cosineSimilarity(vector1: number[], vector2: number[]) {
    const dotProduct = vector1.reduce((acc, value, index) => acc + value * vector2[index], 0);
    const magnitude1 = Math.sqrt(vector1.reduce((acc, value) => acc + value * value, 0));
    const magnitude2 = Math.sqrt(vector2.reduce((acc, value) => acc + value * value, 0));
    return dotProduct / (magnitude1 * magnitude2);
}

const embeddings = new OpenAIEmbeddings();

const result1 = await embeddings.embedQuery("吾輩は猫である");
const result2 = await embeddings.embedQuery("吾輩は犬である");
const result3 = await embeddings.embedQuery("クロネコヤマト");

const similarity1 = cosineSimilarity(result1, result2);
const similarity2 = cosineSimilarity(result1, result3);
const similarity3 = cosineSimilarity(result2, result3);

console.log(similarity1);
console.log(similarity2);
console.log(similarity3);

吾輩は猫である」と「吾輩は犬である」>「吾輩は猫である」と「クロネコヤマト」>「吾輩は犬である」と「クロネコヤマト」の順になったのでなんとなくそれっぽいです。

0.9502395478294273
0.8155650228855305
0.796804328626663

ベクターストア(VectorStore)

ChromaDBの記事で書いたのでベクターストア自体については特に書きませんが、LangChainではChromaDBを含めベクターストアを簡単に組み込めるようになっています。
shironeko.hateblo.jp

const embeddings = new OpenAIEmbeddings();
const chroma = new Chroma(embeddings, {
    url: process.env.CHROMADB_URL,
    collectionName: "sample"
});

const result = await chroma.similaritySearch("ネコカワイイ", 2);
console.log(result);

すでに入れてあったデータがDocumentとしてサクッと取れました。

[
  Document { pageContent: '猫がとてもかわいい', metadata: { source: 'sample' } },
  Document { pageContent: '犬もとてもかわいい', metadata: { source: 'sample' } }
]

データを追加するときはaddDocumentsを使います。

const embeddings = new OpenAIEmbeddings();
const chroma = new Chroma(embeddings, {
    url: process.env.CHROMADB_URL,
    collectionName: "sample"
});

// ドキュメント追加
const text = "吾輩は猫である。名前はまだない。どこで生れたか頓と見当がつかぬ。何でも薄暗いじめじめした所でニャーニャー泣いていた事だけは記憶している。";
const splitter = new CharacterTextSplitter({ 
    separator: "。",
    chunkSize: 30,
    chunkOverlap: 10
});
const docs = await splitter.createDocuments([text]);
await chroma.addDocuments(docs);

const result = await chroma.similaritySearch("ネコカワイイ", 10);
console.log(result);

「ニャーニャー」はそんなに猫っぽくないんですかね。お寿司に負けてる…。

[
  Document { pageContent: '猫がとてもかわいい', metadata: { source: 'sample' } },
  Document { pageContent: '犬もとてもかわいい', metadata: { source: 'sample' } },
  Document {
    pageContent: '吾輩は猫である。名前はまだない。どこで生れたか頓と見当がつかぬ',
    metadata: { loc: [Object] }
  },
  Document { pageContent: 'お寿司が食べたい', metadata: { source: 'sample' } },
  Document {
    pageContent: '何でも薄暗いじめじめした所でニャーニャー泣いていた事だけは記憶している',
    metadata: { loc: [Object] }
  },
  Document { pageContent: 'とっても眠い', metadata: { source: 'sample' } }
]

検索機(Retriever)

Retrieverはそのままレトリバーと書かれていることが多い気がするのですが、それ自体が馴染みがなくて「犬」?ってなってしまうと思ったのであえて検索機としましたが、適切かどうか怪しいところです。
(ちなみにゴールデンレトリバーとかのレトリバーと一緒です。獲物の回収が上手だからレトリバーっていうらしい)

VectorStoreはasRetrieverメソッドで簡単にRetrieverに変換することができます。
VectorStoreを利用して検索を行うためのクラスって感じですね。これ単体だとあんまり意味を感じないですが、Chainに組み込むにはRetrieverにする必要があります。

const embeddings = new OpenAIEmbeddings();
const chroma = new Chroma(embeddings, {
    url: process.env.CHROMADB_URL,
    collectionName: "sample"
});

const retriever = chroma.asRetriever(3);
const result = await retriever.getRelevantDocuments("ネコカワイイ");
console.log(result);

自分で作る場合にはBaseRetrieverを継承します。例えばWikipediaAPIを使って検索するRetrieverだとこうなります。
(実際には毎回アクセスするのではなくベクターストアとかに入れておいたほうがいいとは思います)

export interface MyRetrieverInput extends BaseRetrieverInput {}

export class MyRetriever extends BaseRetriever {

    lc_namespace: string[] = [];

    constructor(fields: MyRetrieverInput) {
        super(fields);
    }

    async _getRelevantDocuments(query: string): Promise<Document[]> {

        // Wikipediaにアクセスするための便利なクラスがあるので使う
        const wikipediaQuery = new WikipediaQueryRun({
            baseUrl: "https://ja.wikipedia.org/w/api.php",
            maxDocContentLength: 1024
        });
        const result = await wikipediaQuery.call(query);

        // 適当なサイズに分割
        const textSplitter = new TokenTextSplitter({
            chunkSize: 256,
            chunkOverlap: 0
        });

        return textSplitter.createDocuments([result]);
    }
}

で、こうですね。

const retriever = new MyRetriever({});
const result = await retriever.getRelevantDocuments("すみっコぐらし");
console.log(result);

ちゃんと取れていそうです。

[
  Document {
    pageContent: 'Page: すみっコぐらし\n' +
      'Summary: すみっコぐらしは、2012年に発表されたサンエックスのキャラクター。サブタイトルは「ここがおちつくんです」。作者は元サンエックス制作本部デ
ザイン室所属デザイナーで現在フリーイラストレーターのよこみぞゆり。ひとりで絵と文どちらも担当している。平仮名の「すみっこ」は場所、カタカナの「すみっコ
」はキャラクターで使い分けられている。誕生のきっかけは学生時代にノートの隅に描いた落書き。 \n' +
      '',
    metadata: { loc: [Object] }
  },
  Document {
    pageContent: '連商品の売り上げ額は年間約200億円(2019年時点)。愛らしさと親しみやすさに定評があり、幅広い世代に支持されている。\n' +
      '2019年度「日本キャラクター大賞」グランプリ受賞。\n' +
      '\n' +
      '\n' +
      '== 設定 ==\n' +
      'すみっコとは、すみっこがおちつく、すみっこにあつまってくるなかまたちの総称である。キャラクターの大半はコンプレックスを抱えるため少々ネガティブ 
だが、それとは対照的に物事の捉え方がポジティブなタイプ',
    metadata: { loc: [Object] }
  },
  ...略
]

長い

なんかまとめたらとても長くなってしまった。もしかしたら分割するかもしれないです。
一応これでRAGを作るための要素はさらっと一通り網羅できたのではないかと思います。
もちろんLangChainには便利な機能がいろいろあるのですが、だいたい後は組み合わせというか…。(Agent系以外だとまたちょっと違ってくる)

次回はここまでの要素を組み合わせて実際に簡単なRAGシステムを作ってみることにします。
それでは。

未使用のラベル(unused label)と返ってこない値

こんにちは。
最近の太巻きはだんだん豪華になって太くなっていますが、切らずに無言で食べるにはもはや苦行なんじゃないかなと思います。

さて、罠を踏んだので備忘録。
わかるひとはタイトル見たら「あー、あれね」ってわかりそう。

先に結論

おそらくこういうコードを書いていると思うので、

const func = () => { hoge: "fuga" };

こうしましょうというお話です。

const func = () => ({ hoge: "fuga" });

なんでこんなことになるのかをちょっと見ていきます。

関数の宣言の仕方あれこれ

まずJavaScriptには関数定義の仕方がいろいろあるのでそれを見ていきます。

関数宣言

一番一般的なのがこれですかね。

function sum(a, b) {
    return a + b;
}

functionキーワードに続いて関数の名前を書くタイプです。TypeScriptだと型も書けます。

function sum(a: number, b number) : number {
    return a + b;
}
関数式

関数宣言は文ですが、関数は式にもできます。関数を第一級オブジェクトとして扱える言語だとよくあるやつですね。

const func1 = function sum(a, b) {
    return a + b;
};

ちなみに名前は省略できます。名前のない関数は無名関数とか匿名関数と言います。

const func2 = function (a, b) {
    return a + b;
};

というか式としては普通は省略する気がします。名前つけるとしたら再帰呼び出しするときとかですかね。自分の名前がわからないので。

console.log(func1.name);  // sum
console.log(func2.name);  // func2

ちなみに無名関数のまま即時実行もできて、こういう時は即時実行関数とか呼ばれる気がします。

const sum = (function (a, b) {
    return a + b;
})(1, 2);
アロー関数

いわゆるラムダ式とか呼ばれるあれです。ちなみにJavaのアロー演算子は「->」でJavaScriptは「=>」。

const sum = (a, b) => {
    return a + b;
};

返す値が1つの時にはカッコが省略できて、直接戻り値が書けます。
戻り値がなくて処理だけする場合には素直にカッコを書きましょう。

const sum = (a, b) => a + b;

引数が一つの時には引数のカッコも省略できますが、複数だったり引数なしの場合は省略できません。

const increment = a => a + 1;

オブジェクトの作り方あれこれ

次にオブジェクトの作り方を確認してみます。

Objectクラス

一番基本の形です。(まぁリテラル使うのであんまり使わないですが…)
ここでのtypeとかnameはプロパティになります。

const obj = new Object();
obj.type = "猫";
obj.name = "たま";
console.log(obj);  // { type: '猫', name: 'たま' }

ちなみにプリミティブ型はオブジェクト扱いできませんが、関数型はオブジェクトなのでプロパティが普通に使えます。
余談ですが、ES6まではclassという文法自体がJavaScriptになかったのでfunctionを使ってクラスの表現をしていたのでまぁそういうことです。

const num = 3;
num.prop = "さん";
console.log(num.prop);  // undefined

const func = () => {};
num.prop = "かんすう";
console.log(func.prop);  // かんすう
オブジェクトリテラル

実はnew Object()の代わりに{}を使うと簡単にオブジェクトが作れます。

const obj = {};
obj.type = "猫";
obj.name = "たま";
console.log(obj);  // { type: '猫', name: 'たま' }

で、そのまま初期化もできちゃいます。ので、だいたいこっちを使いますね。

const obj = {
    type: "犬",
    name: "ぽち"
};
console.log(obj); // { type: '犬', name: 'ぽち' }

TypeScriptでもclassのコンストラクタを使って初期化とかせずに、型推論やasを使ってtypeやinterfaceに変換するとかありがちです。

type Animal = { 
    type: "猫" | "犬"; 
    name: string; 
};
const cat: Animal = {
    type: "猫",
    name: "たま"
};
const dog = {
    type: "犬",
    name: "ぽち"
} as Animal;

ラベル

めったに使わない機能としてラベルというものがあります。
goto構文を普通に使うような時代のものなので、現代のプログラミングにおいては積極的に使うべきものではないとは思います。
ちなみにJavaScriptにgotoはないので、breakとcontinueに使えます。
なんかこんな感じ。正直使わないのでよくわからないです…。

let cnt = 0;
outer: for (let i = 0; i < 100; i++) {
    inner: for (let j = 0; j < 100; j++) {
        cnt++;
        if (cnt % 10 === 0) {
            continue outer;
        }
    }
}
console.log(cnt); // 1000

breakだとこう。

let cnt = 0;
outer: for (let i = 0; i < 100; i++) {
    inner: for (let j = 0; j < 100; j++) {
        cnt++;
        if (cnt % 10 === 0) {
            break outer;
        }
    }
}
console.log(cnt); // 10

こんなこともできる。うれしくないけど。

hoge: {
    console.log(1);
    break hoge;
    console.log(2); // ここは呼ばれない
}
console.log(3);

で、なんで?

長くなってしまいましたが最初のコードに戻ります。

const func = () => { hoge: "fuga" };

これはアロー関数を使ってオブジェクトリテラルを返そうとしているコードです。
返却したい値が一つだけなのでカッコとreturnを省略しました。

が、残念なことにオブジェクトリテラルのカッコとアロー関数のカッコが同じ「{}」を利用しています。
そしてややこしいことにラベルという機能があるせいで「hoge: "fuga"」がエラーにならずに解釈することができます。
(これ単品でのラベルってどうやって使うんでしょうか…?正直よくわかりません…。)

するとなんとhogeというラベルを定義した謎の関数ができあがり、当然これは何の戻り値も返しません。(undefined)
なので、よく見るとIDEが「未使用のラベル」とかいう不思議な警告を出すわけです。

const func = () => { 
    hoge:    // 特に意味のないラベル
        "fuga"  // 特に意味のない通りすがりの文字列
    // なにもreturnしていない
};

というわけでこれはオブジェクトリテラルだよってことを明示するためにカッコをつけてあげます。

const func = () => ({ hoge: "fuga" });

整理すると難しいね

なんか思ったより長くなってしまった。
JavaScriptって融通が利くのでこういう不思議なことがたまに起こる気がします。
なんか知らんけどカッコつけとこうじゃなくてちゃんと理解できると次回から間違えなくて済みそうです。

それでは。

TypeScriptでLangChainを使ってみる その1 基礎編

こんばんは。
リボンライトの接触がおかしくなって部屋の片隅が妖しく赤く光っています。私です。

今回から何回かに渡ってLangChainを触っていこうかと思います。
LangChainはPython版とJavaScript(TypeScript)版があるのですが、Pythonの方が開発が先行しているためJS版は影が薄めです。
検索してもPythonのコードばかりがヒットしてとてもつらい。ので、自分で少しでも書こうかなと。

今回から触るコードはこのリポジトリで公開しています。
github.com

LangChainとは

LangChainはざっくりいうと大規模言語モデル(LLM)を使ったアプリケーションを簡単に作れるようにしたフレームワークです。
更新もとても活発で、迷ったらこれでいいとは思います。
www.langchain.com

ちなみにLangChain以外にもいくつかフレームワークがありますが、LlamaIndexとSemantic Kernelあたりが有名かなと思います。
www.llamaindex.ai
learn.microsoft.com

とりあえず動かしてみる(Model)

インストールするパッケージは詳しくはGitHubリポジトリを参照してください。
環境変数の利用のために「dotenv」、LangChain自体は「langchain」「@langchain/community」「@langchain/openai」「@langchain/google-genai」とかそのあたりです。
OpenAIやGeminiのAPIキーなどは.envファイルに記載して参照できるようにしておきましょう。

OPENAI_API_KEY="ほにゃらら"
GOOGLE_API_KEY="ふにゃらら"

まずはこんな感じで書くととりあえず簡単にチャットができます。モデルはたぶん「gpt-3.5-turbo」ですかね。

const chat = new ChatOpenAI({
    openAIApiKey: process.env.OPENAI_API_KEY
});

const result = await chat.invoke("こんばんは");
console.log(result);

結果はこんな感じ。

AIMessage {
  lc_kwargs: {
    content: 'こんばんは!今日はどのようなお話しをしましょうか?',
    additional_kwargs: { function_call: undefined, tool_calls: undefined }
  },
  lc_namespace: [ 'langchain_core', 'messages' ],
  content: 'こんばんは!今日はどのようなお話しをしましょうか?',
  name: undefined,
  additional_kwargs: { function_call: undefined, tool_calls: undefined }
}

実は環境変数の名前を「OPENAI_API_KEY」にしていると特にコンストラクタで何も渡さなくても勝手にそれを使ってくれます。
なので、これでも同じ結果になります。

const chat = new ChatOpenAI();

const result = await chat.invoke("こんばんは");
console.log(result);

で、便利なのがたったこれだけでGoogle Generative AIへのリクエストができてしまいます。たぶんモデルは「gemini-pro」。
いろいろ余計なものがついてるので、result.contentに変えてみました。

const chat = new ChatGoogleGenerativeAI();

const result = await chat.invoke("こんばんは");
console.log(result.content);

Geminiの方が詩的…なんですかね。

こんばんは。あなたの夜をより良くするために何かお手伝いできることはありますか?私はあなたとチャットしたり、ジョークを言ったり、質問に答えたりすることができます。

プロンプトを作ってみる(Prompt)

LLMに渡すプロンプトは固定の文字列ではなく、ユーザーの入力など何らかの変数を埋め込むことが良くあります。
そんな場合にはPromptTemplateの機能を使います。

const promptTemplate1 = new PromptTemplate({
    template: "今日は{day_of_week}、明日は?",
    inputVariables: [ "day_of_week" ]   // 変数の定義を書く
});
const prompt1 = await promptTemplate1.format({ day_of_week: "金曜日" });
console.log(prompt1);

// fromTemplateを使うと変数は勝手にいい感じにしてくれる
const promptTemplate2 = PromptTemplate.fromTemplate("今日は{day_of_week}、明日は?");
const prompt2 = await promptTemplate2.format({ day_of_week: "金曜日" });
console.log(prompt2);

チャットによる会話を作る場合にはChatPromptTemplateを使うと良いです。
ロールごとのPromptTemplateもあります。

const chatTemplate1 = ChatPromptTemplate.fromMessages([
    [ "system", "あなたは{role}です" ],
    [ "human", "好きな{kinds}は何ですか?" ]
]);
const prompt1 = await chatTemplate1.format({ role: "賢い猫", kinds: "食べ物" });
console.log(prompt1);

const chatTemplate2 = ChatPromptTemplate.fromMessages([
    SystemMessagePromptTemplate.fromTemplate("あなたは{role}です"),
    HumanMessagePromptTemplate.fromTemplate("好きな{kinds}は何ですか?")
]);
const prompt2 = await chatTemplate2.format({ role: "賢い猫", kinds: "食べ物" });
console.log(prompt2);

出力を制御してみる(OutputParser)

result.contentに変えないと邪魔なものがみたいなことを書きましたが、StringOutputParserを使うと結果の文字列だけを取り出すことができます。

const message = new AIMessage({
    content: 'こんばんは!なんかご用ですか?'
});

const outputParser = new StringOutputParser();
const result = await outputParser.invoke(message);
console.log(result);

CommaSeparatedListOutputParserを使うとカンマ区切りの文字列を配列で返してくれます。

const message = new AIMessage({
    content: '春,夏,秋,冬'
});

const outputParser = new CommaSeparatedListOutputParser();
const result = await outputParser.invoke(message);
console.log(result);

つなげてみよう(Chain)

ここまで作ったものをつなげてみます。

const chatTemplate = ChatPromptTemplate.fromMessages([
    SystemMessagePromptTemplate.fromTemplate("あなたは{role}です"),
    HumanMessagePromptTemplate.fromTemplate("好きな{kinds}は何ですか?")
]);
const prompt = await chatTemplate.format({ role: "賢い猫", kinds: "食べ物" });

const chat = new ChatGoogleGenerativeAI();
const chatResult = await chat.invoke(prompt);

const outputParser = new StringOutputParser();
const result = await outputParser.invoke(chatResult);

console.log(result);

いや…賢いけどちょっとイメージと違う…。

* **魚:** マグロ、サケ、タラ、ティラピア、イワシなど、ほとんどの猫は魚が大好きです。魚は猫にとって優れたタンパク質、オメガ3脂肪酸、その他の栄養素の供給源です。
* **鶏肉:** 鶏肉は、猫にとってもう一つの人気のあるタンパク質源です。鶏肉は調理しているか、生であり、味付けや調味料は含まれていないことを確認してください。
* **牛肉:** 牛肉は、猫が食べることができるもう一つのタンパク質源です。牛肉は調理しているか、生であり、味付けや調味料は含まれていないことを確認してください。
* **ラム肉:** ラム肉は、猫に人気のあるもう一つのタンパク質源です。ラム肉は調理しているか、生であり、味付けや調味料は含まれていないことを確認してください。
* **臓器肉:** 肝臓、心臓、腎臓などの臓器肉は、猫にとって優れたビタミン、ミネラル、その他の栄養素の供給源です。しかし、臓器肉は少量で与えてください。
* **卵:** 卵は、猫にとって優れたタンパク質、ビタミン、ミネラルの供給源です。卵は調理されているか、生であり、味付けや調味料は含まれていないことを確認してください。
* **乳製品:** 猫は一部の乳製品を食べることができますが、多くは乳糖不耐症です。猫に乳製品を与える場合は、牛乳よりもヨーグルトやケフィアなどの発酵乳製品を選ぶようにしましょう。
* **野菜:** 猫は一部の野菜を食べることができますが、ほとんどの猫は肉食動物です。猫に野菜を与える場合は、少量で、生よりも調理しているものを選ぶようにしましょう。
* **果物:** 猫は一部の果物を食べることができますが、ほとんどの猫は肉食動物です。猫に果物を与える場合は、少量で、砂糖が少ないものを選ぶようにしましょう。

実はpipe関数を使ってこんな風に書けます。まさにChainですね。

const chatTemplate = ChatPromptTemplate.fromMessages([
    SystemMessagePromptTemplate.fromTemplate("あなたは{role}です"),
    HumanMessagePromptTemplate.fromTemplate("好きな{kinds}は何ですか?")
]);
const chat = new ChatGoogleGenerativeAI();
const outputParser = new StringOutputParser();

const result = await chatTemplate.pipe(chat).pipe(outputParser).invoke({ role: "賢い猫", kinds: "食べ物" });

console.log(result);

で、そういうのってよく使うでしょってことで、LLMChainという名前であらかじめ用意されたりしています。
他にもいろいろなChainがあります。

const chatTemplate = ChatPromptTemplate.fromMessages([
    SystemMessagePromptTemplate.fromTemplate("あなたは{role}です"),
    HumanMessagePromptTemplate.fromTemplate("好きな{kinds}は何ですか?")
]);

const chat = new ChatGoogleGenerativeAI();
const outputParser = new StringOutputParser();

const chain = new LLMChain({
    prompt: chatTemplate,
    llm: chat,
    outputParser: outputParser
});

const result = await chain.invoke({ role: "賢い猫", kinds: "食べ物" });
console.log(result);

スマートだ

とりあえず今回は基本編ということでここまで。
ライブラリとしての出来が良くて参考になります。
LLMを使ったプログラムが簡単に書けるのは素敵ですね。

それでは。