現代の大規模言語モデル(LLM)は印象的ですが、大きな制限があります。知識が重みに固定されているため、知識の更新や拡張が困難です。Retrieval-Augmented Generation(RAG)は、この問題に対処するために設計されたアプローチです。2020年にMetaによって導入され、言語モデルを外部の知識ベース(例えば、ドキュメントのセット)に接続し、最新かつ特定の情報を応答に組み込むことができます。実際には、各質問に対して、RAGシステムはまずドキュメントベースから関連コンテンツを抽出し、次にこの取得したコンテキストとLLMの言語能力を組み合わせて応答を生成します。
注: この記事で言及されているサンプルプロジェクトの完全なソースコードはGitHubで入手可能です。
記事の概要
-
RAGとは何か、なぜ使うのか?
- 動作原理
- 古典的なアプローチに対する利点
- 具体的なユースケース
-
RAGシステムのアーキテクチャ
-
TypeScriptによる実践的な実装
- Bunによるプロジェクト設定
- LangChainの統合
- OllamaとQdrantの設定
-
コード分析とベストプラクティス
- ドキュメントのインデックス作成
- セマンティック検索
- 応答生成
-
技術スタックの利点
- Bunのパフォーマンス vs Node.js
- LangChainのシンプルさ
- Ollamaの柔軟性
- Qdrantのスケーラビリティ
-
さらに先へ
RAGとは何か、なぜ使うのか?
Retrieval-Augmented Generation(RAG)は文字通り「検索によって拡張された生成」を意味します。アイデアは、知識をモデルから分離することです。すべての情報をLLMのパラメータに組み込もうとする(コストのかかるファインチューニングを通じて)のではなく、またはデータから応答を予測する古典的なモデルを設計するのではなく、主要なモデルにテキストを生成させ、情報検索の中間ステップでそれを拡張します。典型的なRAGパイプラインは次のように機能します。
- ユーザーのクエリ – ユーザーは自然言語で質問をするか、クエリを提供します(例:「このプロジェクトでクラスXは何に使われていますか?」)。
- 関連ドキュメントの検索 – システムはこの質問をベクトル表現(埋め込み)に変換し、次にベクトルデータベースにクエリを実行して、クエリに意味的に最も類似したドキュメントまたはパッセージを取得します。これにより、関連するコンテキストが特定されます(例:ドキュメント、コード、または質問に対応する記事からの抜粋)。
- コンテキスト+質問の組み合わせ – 取得されたドキュメントまたは抜粋は、言語モデルにコンテキストとして提供されます。実際には、LLMのプロンプトに挿入され、通常はシステムメッセージを介して、またはユーザーの質問の前に見つかったドキュメントのテキストを付加することによって行われます。
- 応答生成 – 言語モデル(LLM)は、質問と提供されたコンテキストの両方に基づいて応答を生成します。応答には、LLMの能力のおかげで一貫して定式化されたドキュメントからの情報が含まれている必要があります。
このプロセスにより、モデルは生成時に特定の外部知識に依存することができ、それを永続的に記憶する必要がありません。これは、質問に直面した人間が、答える前に本や参考資料を参照するのと比較できます。LLMは話す前に「ライブラリを検索」します。
RAGの具体的なユースケース
RAGアプローチは、会話型アシスタントが進化する、または大量の知識ベースを処理する必要がある場合に特に役立ちます。以下は、RAGが古典的な方法と比較して優れている具体的なユースケースの例です。
ドキュメンタリーチャットボット: 企業の技術文書を活用したアシスタントで、マニュアル、内部ナレッジベース、さらにはソースコードから直接情報を引き出して、開発者や顧客からの質問に答えることができます。たとえば、モデルをAPI仕様やオープンソースプロジェクトのコードに接続して、関数の仕組みや特定の設計の理由を説明できます。
動的FAQ: カスタマーサポートの文脈では、RAGチャットボットは最新のポリシーや製品データに基づいて一般的な質問(FAQ)に答えることができます。ポリシー(例:返品条件)が変更された場合、参照ドキュメントを更新するだけで、ボットは再トレーニングを必要とせずに即座にそれを考慮に入れます。これにより、常に最新のFAQが得られ、回答を裏付ける情報源を提供する機能も備わります。
法律アシスタント: アシスタントは、特定の質問に対して法律、判例、または契約のデータベースから関連する箇所を見つけ、自然言語で回答を定式化することにより、弁護士や法律専門家を支援できます。モデルは民法全体を暗記する必要はなく、適切な条文を検索するだけで済みます。同じことが医療アシスタントにも当てはまり、科学出版物や医療プロトコルのデータベースを照会して、最新の臨床知識に基づいた回答を提供できます。
プログラミングアシスタント: これは私たちのサンプルプロジェクトの場合です – コードリポジトリの内容を知っており、このコードに関する質問(アーキテクチャ、モジュールの役割、潜在的なバグなど)に答えることができるアシスタントです。専門的なプログラミングモデルをトレーニングする代わりに、リポジトリ内の関連コードファイルを検索することによって拡張された汎用LLMを使用します。
RAGシステムのアーキテクチャ
必須コンポーネント
完全なRAGシステムには通常、次のコンポーネントが含まれます。
-
インデックス作成とストレージ
- ドキュメントプロセッサ(抽出、クリーニング、チャンキング)
- 埋め込みジェネレータ(ベクトルへの変換)
- ベクトルデータベース(ストレージと検索)
-
クエリパイプライン
- クエリプリプロセッサ
- セマンティック検索エンジン
- プロンプトジェネレータ
-
生成と後処理
- LLMインターフェース
- 応答評価器
- 出力フォーマッタ
データフロー
1// RAGシステムにおけるデータフローの簡略化された例
2async function processQuery(query: string): Promise<string> {
3 // 1. クエリを埋め込みに変換
4 const queryEmbedding = await embedder.embedQuery(query);
5
6 // 2. 関連ドキュメントの検索
7 const relevantDocs = await vectorStore.similaritySearch(queryEmbedding);
8
9 // 3. コンテキスト付きプロンプトの構築
10 const prompt = buildPromptWithContext(query, relevantDocs);
11
12 // 4. LLMで応答を生成
13 const response = await llm.generate(prompt);
14
15 return response;
16}
技術選択
私たちの実装では、モダンでパフォーマンスの高いスタックを選択しました。
- Bun: 超高速JavaScriptランタイム、サーバーアプリケーションに最適
- TypeScript: より良い保守性のための静的型付け
- LangChain: LLMベースのアプリケーションを構築するためのフレームワーク
- Ollama: ローカルで言語モデルを実行するためのツール
- Qdrant: パフォーマンスが高く、デプロイが簡単なベクトルデータベース
この組み合わせは、パフォーマンス、開発の容易さ、柔軟性の間の優れたバランスを提供します。
TypeScriptによる実践的な実装
Bunによるプロジェクト設定
まず、プロジェクトを初期化しましょう。
1# プロジェクト作成
2mkdir rag-code-assistant
3cd rag-code-assistant
4bun init
5
6# 依存関係のインストール
7bun add langchain @langchain/community @langchain/openai
8bun add qdrant-js ollama
9bun add -d typescript @types/node
基本設定
1// src/config.ts
2export const CONFIG = {
3 // Ollama設定
4 OLLAMA_BASE_URL: 'http://localhost:11434',
5 OLLAMA_MODEL_NAME: 'llama3',
6
7 // Qdrant設定
8 QDRANT_URL: 'http://localhost:6333',
9 QDRANT_COLLECTION_NAME: 'code_repository',
10
11 // インデックス作成設定
12 CHUNK_SIZE: 1000,
13 CHUNK_OVERLAP: 200,
14
15 // インデックスを作成するコードリポジトリへのパス
16 REPOSITORY_PATH: './repository',
17};
ドキュメントのインデックス作成
インデックス作成はRAGシステムの重要なステップです。生のドキュメントを適切なサイズのチャンクに変換し、各チャンクの埋め込みを生成することが含まれます。
1// src/indexer.ts
2import { DirectoryLoader } from 'langchain/document_loaders/fs/directory';
3import { RecursiveCharacterTextSplitter } from 'langchain/text_splitter';
4import { OllamaEmbeddings } from '@langchain/community/embeddings/ollama';
5import { QdrantClient } from 'qdrant-js';
6import { CONFIG } from './config';
7
8export async function indexRepository() {
9 // 1. リポジトリファイルの読み込み
10 const loader = new DirectoryLoader(
11 CONFIG.REPOSITORY_PATH,
12 {
13 '.ts': (path) => new TextLoader(path),
14 '.js': (path) => new TextLoader(path),
15 '.tsx': (path) => new TextLoader(path),
16 '.jsx': (path) => new TextLoader(path),
17 '.md': (path) => new TextLoader(path),
18 }
19 );
20
21 const docs = await loader.load();
22 console.log(`${docs.length}個のファイルの読み込みが完了しました`);
23
24 // 2. チャンクへの分割
25 const textSplitter = new RecursiveCharacterTextSplitter({
26 chunkSize: CONFIG.CHUNK_SIZE,
27 chunkOverlap: CONFIG.CHUNK_OVERLAP,
28 });
29
30 const chunks = await textSplitter.splitDocuments(docs);
31 console.log(`${chunks.length}個のチャンクの作成が完了しました`);
32
33 // 3. 埋め込みの生成
34 const embeddings = new OllamaEmbeddings({
35 baseUrl: CONFIG.OLLAMA_BASE_URL,
36 model: CONFIG.OLLAMA_MODEL_NAME,
37 });
38
39 // 4. Qdrantへの保存
40 const qdrant = new QdrantClient({ url: CONFIG.QDRANT_URL });
41
42 // コレクションが存在しない場合は作成
43 try {
44 await qdrant.createCollection(CONFIG.QDRANT_COLLECTION_NAME, {
45 vectors: {
46 size: 1536, // 埋め込みベクトルのサイズ
47 distance: 'Cosine',
48 },
49 });
50 } catch (e) {
51 console.log('コレクションは既に存在するか、エラーが発生しました:', e.message);
52 }
53
54 // ドキュメントとその埋め込みの挿入
55 for (let i = 0; i < chunks.length; i += 100) {
56 const batch = chunks.slice(i, i + 100);
57 const batchEmbeddings = await embeddings.embedDocuments(
58 batch.map(chunk => chunk.pageContent)
59 );
60
61 await qdrant.upsert(CONFIG.QDRANT_COLLECTION_NAME, {
62 points: batchEmbeddings.map((vector, idx) => ({
63 id: `${i + idx}`,
64 vector,
65 payload: {
66 text: batch[idx].pageContent,
67 metadata: batch[idx].metadata,
68 },
69 })),
70 });
71
72 console.log(`バッチ ${i}-${i + batch.length} の処理が完了しました`);
73 }
74
75 console.log('インデックス作成が正常に完了しました!');
76}
検索と応答生成
1// src/rag.ts
2import { OllamaEmbeddings } from '@langchain/community/embeddings/ollama';
3import { QdrantClient } from 'qdrant-js';
4import { Ollama } from '@langchain/community/llms/ollama';
5import { CONFIG } from './config';
6
7export async function generateResponse(query: string): Promise<string> {
8 // 1. コンポーネントの初期化
9 const embeddings = new OllamaEmbeddings({
10 baseUrl: CONFIG.OLLAMA_BASE_URL,
11 model: CONFIG.OLLAMA_MODEL_NAME,
12 });
13
14 const qdrant = new QdrantClient({ url: CONFIG.QDRANT_URL });
15
16 const llm = new Ollama({
17 baseUrl: CONFIG.OLLAMA_BASE_URL,
18 model: CONFIG.OLLAMA_MODEL_NAME,
19 });
20
21 // 2. クエリ埋め込みの生成
22 const queryEmbedding = await embeddings.embedQuery(query);
23
24 // 3. 関連ドキュメントの検索
25 const searchResults = await qdrant.search(CONFIG.QDRANT_COLLECTION_NAME, {
26 vector: queryEmbedding,
27 limit: 5,
28 });
29
30 const relevantDocs = searchResults.map(result => result.payload.text).join('\n\n');
31
32 // 4. コンテキスト付きプロンプトの構築
33 const prompt = `
34 あなたはプロジェクトのソースコードを理解するのを助ける専門のプログラミングアシスタントです。
35 ユーザーの質問に答えるために、以下のコンテキストを使用してください。
36 コンテキストで情報が見つからない場合は、明確にそう述べてください。
37
38 CONTEXT:
39 ${relevantDocs}
40
41 QUESTION: ${query}
42
43 ANSWER:
44 `;
45
46 // 5. 応答の生成
47 const response = await llm.call(prompt);
48
49 return response;
50}
シンプルなユーザーインターフェース
1// src/index.ts
2import { indexRepository } from './indexer';
3import { generateResponse } from './rag';
4
5async function main() {
6 // インデックス作成またはクエリ用の引数
7 const args = process.argv.slice(2);
8
9 if (args[0] === 'index') {
10 console.log('インデックス作成を開始しています...');
11 await indexRepository();
12 } else if (args[0] === 'query') {
13 const query = args.slice(1).join(' ');
14 if (!query) {
15 console.log('使用法: bun run src/index.ts query "コードに関するあなたの質問"');
16 process.exit(1);
17 }
18
19 console.log(`質問: ${query}`);
20 console.log('応答を生成しています...');
21 const response = await generateResponse(query);
22 console.log('\n回答:');
23 console.log(response);
24 } else {
25 console.log('使用法: bun run src/index.ts [index|query "あなたの質問"]');
26 }
27}
28
29main().catch(console.error);
コード分析とベストプラクティス
効率的なチャンキング
ドキュメントをチャンクに分割することは、結果の品質に直接影響する重要なステップです。いくつかのベストプラクティス:
- 適切なサイズ: チャンクはコンテキストを含むのに十分な大きさである必要がありますが、関連性を保つために大きすぎてはいけません(通常500〜1500文字)。
- オーバーラップ: チャンク間のオーバーラップは、境界でコンテキストが失われるのを防ぎます。
- セマンティック分割: 理想的には、分割はドキュメントのセマンティック構造(段落、関数など)を尊重する必要があります。
検索の最適化
セマンティック検索の品質は不可欠です。
- メタデータフィルター: メタデータ(ファイルタイプ、日付、作成者)を使用して検索を絞り込みます。
- 再ランキング: 関連性を向上させるために、第2レベルのフィルタリングを適用します。
- 多様性: 質問のさまざまな側面をカバーするために、結果の多様性を確保します。
高度なプロンプティング
プロンプトの構築は、応答の品質に強く影響する芸術です。
1// より高度なプロンプトの例
2const prompt = `
3# ROLE
4あなたはソースコード分析に特化した専門のプログラミングアシスタントです。
5
6# CONTEXT
7${relevantDocs}
8
9# INSTRUCTIONS
10- 提供されたコンテキストのみに基づいてユーザーの質問に答えてください
11- コンテキストに必要な情報が含まれていない場合は、明確に示してください
12- 回答に関連するコードの抜粋を引用してください
13- 教育的な方法でコードを説明してください
14- 何か改善点があれば提案してください
15
16# QUESTION
17${query}
18`;
技術スタックの利点
Bunのパフォーマンス vs Node.js
Bunはこのタイプのアプリケーションに大きな利点を提供します。
- 高速起動: Node.jsよりも最大4倍高速な起動時間
- 最適化された実行: 特にI/O操作において優れた実行パフォーマンス
- 統合バンドラー: 開発ワークフローの簡素化
...