白猫のメモ帳

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システムを作ってみることにします。
それでは。