はじめに
この記事では、LangChain.js(TypeScript)を使って基本的なRAG(Retrieval-Augmented Generation)システムを構築する方法を解説します。
RAGとは?
RAGは、LLMに外部の知識を与えることで、より正確で最新の情報に基づいた回答を生成できる技術です。
RAGは以下の流れで動作します:
- ドキュメントを準備: テキストを小さな塊(チャンク)に分割
- ベクトル化: 各チャンクを数値ベクトルに変換
- 保存: ベクトルデータベースに保存
- 検索: ユーザーの質問に関連する情報を検索
- 生成: 検索結果を元にLLMが回答を生成
なぜRAGが必要なのか?
- LLMの学習データには含まれない最新情報や独自情報を扱える
- ハルシネーション(幻覚: 事実でない情報の生成)を減らせる
- 社内文書やマニュアルなど、特定のドメイン知識を活用できる
環境構築
プロジェクトの初期化
# プロジェクトディレクトリの作成
mkdir langchain-rag-demo
cd langchain-rag-demo
# package.jsonの作成
npm init -y
# TypeScriptの設定
npm install -D typescript @types/node tsx
npx tsc --init
必要なライブラリのインストール
npm install langchain @langchain/openai @langchain/community @langchain/core @langchain/textsplitters chromadb chromadb-default-embed dotenv --legacy-peer-deps
使用する主なライブラリ
-
langchain: RAGシステムの構築フレームワーク -
@langchain/openai: OpenAI APIとの連携 -
@langchain/community: コミュニティパッケージ -
@langchain/core: LangChainのコア機能 -
@langchain/textsplitters: テキスト分割機能 -
chromadb: ベクトルデータベース(ローカルで動作、永続化可能) -
chromadb-default-embed: Chromaのデフォルト埋め込み機能 -
dotenv: 環境変数の管理
環境変数の設定
プロジェクトのルートに .env ファイルを作成:
OPENAI_API_KEY=your-api-key-here
OpenAIのAPIキーはOpenAIのサイトから取得してください。
Chromaサーバーの起動
Chromaを使用するには、サーバーを起動する必要があります。
ターミナルで以下のコマンドを実行:
npx chroma run
注意:
- Chromaサーバーは起動したままにしておく必要があります
- アプリケーションを終了してもサーバーは起動したまま
- サーバーを停止する場合は
Ctrl + C
実装
Step 1: ドキュメントの準備
プロジェクトのルートに sample_document.txt を作成します。
今回は日本の観光地についての情報をサンプルドキュメントとして使用します:
東京タワーは東京都港区にある総合電波塔です。
1958年に完成し、高さは333メートルあります。
展望台からは東京の街並みを一望することができ、晴れた日には富士山も見えます。
東京タワーは東京のシンボルとして多くの観光客に親しまれています。
富士山は日本最高峰の山で、標高は3,776メートルです。
山梨県と静岡県にまたがっており、2013年に世界文化遺産に登録されました。
登山シーズンは7月から9月で、多くの登山者が山頂を目指します。
富士山は古くから信仰の対象とされ、日本文化に深く根付いています。
京都の清水寺は778年に創建された歴史ある寺院です。
「清水の舞台」として知られる本堂の舞台は、139本の柱で支えられています。
春の桜、秋の紅葉の時期には特に多くの参拝客が訪れます。
清水寺は1994年に世界文化遺産に登録されました。
Step 2: ベクトルデータベースの作成
まず、ドキュメントを読み込んでベクトルデータベースを作成するファイルを作ります。
create.ts を作成:
import "dotenv/config";
import { readFileSync } from "fs";
import { Document } from "@langchain/core/documents";
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
import { OpenAIEmbeddings } from "@langchain/openai";
import { Chroma } from "@langchain/community/vectorstores/chroma";
async function main() {
console.log("ベクトルデータベースを作成中...\n");
// ドキュメントの読み込み
const text = readFileSync("sample_document.txt", "utf-8");
const docs = [new Document({ pageContent: text })];
console.log("✓ ドキュメントを読み込みました");
// テキストを小さなチャンクに分割
const textSplitter = new RecursiveCharacterTextSplitter({
chunkSize: 200, // 1チャンクあたりの文字数
chunkOverlap: 50, // チャンク間の重複文字数
});
const splitDocs = await textSplitter.splitDocuments(docs);
console.log(`✓ ドキュメントを${splitDocs.length}個のチャンクに分割しました`);
// 分割結果を確認
console.log("\n--- 分割されたチャンク ---");
splitDocs.forEach((doc, i) => {
console.log(`\nチャンク ${i + 1}:`);
console.log(doc.pageContent);
});
// Embeddingモデルの初期化
const embeddings = new OpenAIEmbeddings({
modelName: "text-embedding-3-small",
});
// ベクトルデータベースの作成
console.log("\n✓ ベクトル化を開始...");
const vectorStore = await Chroma.fromDocuments(splitDocs, embeddings, {
collectionName: "tourist_spots", // コレクション名
});
console.log("✓ ベクトルデータベースを作成しました");
console.log("\n作成完了! 次は search.ts を実行してください。");
}
main().catch(console.error);
実行:
npx tsx create.ts
ポイント:
- このファイルは最初に一度だけ実行すればOK
- ドキュメントの内容を変更したときにも再実行
-
chunkSize: 大きすぎると検索精度が下がり、小さすぎると文脈が失われる -
chunkOverlap: 文の途中で切れないよう、重複を持たせる -
collectionName: データを識別するための名前
Step 3: 類似度検索のテスト
次に、作成したベクトルデータベースから類似した情報を検索するファイルを作ります。
search.ts を作成:
import "dotenv/config";
import { OpenAIEmbeddings } from "@langchain/openai";
import { Chroma } from "@langchain/community/vectorstores/chroma";
async function main() {
// 保存したベクトルデータベースを読み込み
const embeddings = new OpenAIEmbeddings({
modelName: "text-embedding-3-small",
});
const vectorStore = await Chroma.fromExistingCollection(embeddings, {
collectionName: "tourist_spots",
});
console.log("✓ ベクトルデータベースを読み込みました\n");
// 類似度検索のテスト
const query = "東京タワーの高さは?";
console.log(`検索クエリ: ${query}\n`);
const results = await vectorStore.similaritySearch(query, 2); // 上位2件を取得
console.log("--- 検索結果 ---");
results.forEach((doc, i) => {
console.log(`\n関連文書 ${i + 1}:`);
console.log(doc.pageContent);
});
}
main().catch(console.error);
実行:
npx tsx search.ts
実行結果の例:
✓ ベクトルデータベースを読み込みました
検索クエリ: 東京タワーの高さは?
--- 検索結果 ---
関連文書 1:
東京タワーは東京都港区にある総合電波塔です。
1958年に完成し、高さは333メートルあります。
展望台からは東京の街並みを一望することができ、晴れた日には富士山も見えます。
関連文書 2:
東京タワーは東京のシンボルとして多くの観光客に親しまれています。
富士山は日本最高峰の山で、標高は3,776メートルです。
ポイント:
-
fromExistingCollection: 既存のコレクションを読み込む -
similaritySearch(query, k): 質問に類似した上位k件の文書を検索 - この段階ではLLMは使わず、単純に類似文書を取得するだけ
Step 4: LLMとの統合
検索結果を使ってLLMに回答を生成させます。search.ts を以下のように修正:
import "dotenv/config";
import { OpenAIEmbeddings } from "@langchain/openai";
import { Chroma } from "@langchain/community/vectorstores/chroma";
import { ChatOpenAI } from "@langchain/openai";
async function main() {
// ベクトルデータベースの読み込み
const embeddings = new OpenAIEmbeddings({
modelName: "text-embedding-3-small",
});
const vectorStore = await Chroma.fromExistingCollection(embeddings, {
collectionName: "tourist_spots",
});
console.log("✓ ベクトルデータベースを読み込みました");
// LLMの初期化
const model = new ChatOpenAI({
modelName: "gpt-3.5-turbo",
temperature: 0, // 回答の一貫性を高めるため0に設定
});
console.log("✓ LLMを初期化しました\n");
// Retrieverの作成(上位2件の関連文書を取得)
const retriever = vectorStore.asRetriever(2);
// 質問
const query = "東京タワーの高さは何メートルですか?";
console.log(`質問: ${query}\n`);
// 関連文書を取得
const relevantDocs = await retriever.invoke(query);
console.log("--- 参照した文書 ---");
relevantDocs.forEach((doc, i) => {
console.log(`${i + 1}. ${doc.pageContent}\n`);
});
// プロンプトを構築
const context = relevantDocs.map(doc => doc.pageContent).join("\n\n");
const prompt = `以下の文脈を使用して質問に答えてください。
答えがわからない場合は、わからないと言ってください。
文脈:
${context}
質問: ${query}
回答:`;
// LLMに質問
const response = await model.invoke(prompt);
console.log(`回答: ${response.content}`);
}
main().catch(console.error);
実行:
npx tsx search.ts
実行結果の例:
✓ ベクトルデータベースを読み込みました
✓ LLMを初期化しました
質問: 東京タワーの高さは何メートルですか?
--- 参照した文書 ---
1. 東京タワーは東京都港区にある総合電波塔です。
1958年に完成し、高さは333メートルあります。
展望台からは東京の街並みを一望することができ、晴れた日には富士山も見えます。
2. 東京タワーは東京のシンボルとして多くの観光客に親しまれています。
富士山は日本最高峰の山で、標高は3,776メートルです。
回答: 東京タワーの高さは333メートルです。
ポイント:
-
retriever.invoke(query): 関連文書を取得 - プロンプトを手動で構築することで、回答の形式を細かくコントロールできる
-
model.invoke(prompt): LLMに質問して回答を取得 -
temperature: 0: 回答の一貫性を高める(創造的な回答が欲しい場合は0.7-1.0)
Step 5: 複数の質問を試す
複数の質問に対して回答させてみます。search.ts を以下のように修正:
import "dotenv/config";
import { OpenAIEmbeddings } from "@langchain/openai";
import { Chroma } from "@langchain/community/vectorstores/chroma";
import { ChatOpenAI } from "@langchain/openai";
async function main() {
// ベクトルデータベースの読み込み
const embeddings = new OpenAIEmbeddings({
modelName: "text-embedding-3-small",
});
const vectorStore = await Chroma.fromExistingCollection(embeddings, {
collectionName: "tourist_spots",
});
// LLMとRetrieverの初期化
const model = new ChatOpenAI({
modelName: "gpt-3.5-turbo",
temperature: 0,
});
const retriever = vectorStore.asRetriever(2);
console.log("RAGシステムの準備が完了しました\n");
// 複数の質問を試す
const queries = [
"東京タワーはいつ完成しましたか?",
"富士山の標高を教えてください",
"清水寺はいつ世界遺産に登録されましたか?",
];
for (const query of queries) {
console.log("=".repeat(60));
console.log(`質問: ${query}\n`);
// 関連文書を取得
const relevantDocs = await retriever.invoke(query);
// プロンプトを構築
const context = relevantDocs.map(doc => doc.pageContent).join("\n\n");
const prompt = `以下の文脈を使用して質問に答えてください。
答えがわからない場合は、わからないと言ってください。
文脈:
${context}
質問: ${query}
回答:`;
// LLMに質問
const response = await model.invoke(prompt);
console.log(`回答: ${response.content}\n`);
}
}
main().catch(console.error);
実行結果の例:
RAGシステムの準備が完了しました
============================================================
質問: 東京タワーはいつ完成しましたか?
回答: 東京タワーは1958年に完成しました。
============================================================
質問: 富士山の標高を教えてください
回答: 富士山の標高は3,776メートルです。
============================================================
質問: 清水寺はいつ世界遺産に登録されましたか?
回答: 清水寺は1994年に世界文化遺産に登録されました。
よくあるエラーと対処法
エラー1: OpenAI API Key not found
Error: OpenAI API key not found
対処法: .envファイルが正しく読み込まれているか確認
console.log(process.env.OPENAI_API_KEY); // undefinedでなければOK
エラー2: Chromaサーバーに接続できない
Error: connect ECONNREFUSED 127.0.0.1:8000
対処法: Chromaサーバーが起動していません
# 別のターミナルウィンドウでChromaサーバーを起動
npx chroma run --path ./chroma_data
サーバーが起動したら、元のターミナルでアプリケーションを実行してください。
改善のポイント
1. チャンクサイズの調整
// 小さめのチャンク(詳細な検索向け)
const textSplitter = new RecursiveCharacterTextSplitter({
chunkSize: 100,
chunkOverlap: 20,
});
// 大きめのチャンク(文脈重視)
const textSplitter = new RecursiveCharacterTextSplitter({
chunkSize: 500,
chunkOverlap: 100,
});
2. 検索結果の件数調整
// より多くの関連文書を取得
const chain = RetrievalQAChain.fromLLM(
model,
vectorStore.asRetriever(5), // 上位5件
{ returnSourceDocuments: true }
);
3. カスタムプロンプトの使用
プロンプトをカスタマイズして、回答の形式を変更できます:
// より詳しい説明を求めるプロンプト
const prompt = `以下の文脈を使用して、質問に対して詳しく説明してください。
文脈に含まれる情報は全て使用してください。
文脈:
${context}
質問: ${query}
回答:`;
// 簡潔な回答を求めるプロンプト
const prompt = `以下の文脈から、質問に対する答えを1文で簡潔に答えてください。
文脈:
${context}
質問: ${query}
回答:`;
まとめ
この記事では、LangChain.js(TypeScript)を使った基本的なRAGシステムの構築方法を学びました。
学んだこと:
- RAGの基本的な仕組みと流れ
- ドキュメントの分割とベクトル化
- Chromaを使ったベクトルデータベースの構築
次のステップ:
- 複数のテキストファイルを扱う
- PDFやMarkdownファイルの読み込み
- Next.jsアプリケーションへの統合
- ストリーミングレスポンスの実装