LangChain + Ollamaで作る完全ローカルな社内文書Q&Aチャットボット
0. はじめにの前に
うぐいすソリューションズ Advent Calendar 2025 の10日目担当のNakaeです。先日首を寝違えてしまい、あまりの激痛故に記事執筆を何度も諦めようとしていましたが無事なんとか投稿しています。
いやー、昨今はAI関連で、「~のモデルがつぉい」とか「~のIDEがすごい、時代が変わる」とかSNS上でも弊社内でも盛り上がってますが、私は目の前のClaudeCodeを使うことに手一杯でなかなか他を試す気力が湧きません。
ただ今更ClaudeCodeの記事を書いても他の方たちが素晴らしい記事をたくさん書いているので、私はどちらかというとマイナーめなローカルLLMについて記事を書いていこうと思います。
1. はじめに
生成AIを業務に活用する場面が増えてきましたが、
クラウドのLLM API を使う場合、どうしても コスト や 情報漏洩リスク が気になります。
そこで今回は、自社の就業規則(PDF)を対象に、完全オフラインで動作する RAG チャットボットを LangChain.js × Ollamaを使って作ってみました。
クラウドに一切データを送らず、LLM・埋め込みモデル・ベクトル検索すべてをローカルで完結できます。
ローカルLLMの実力を測りたい方や、まずは低コストで社内文書検索を試したい方におすすめの構成です。


2. この記事で作るもの
- PDFドキュメントを読み込み、質問に回答するCLIチャットボット
- ベクトル検索とキーワード検索を組み合わせたハイブリッド検索
- 完全ローカル実行(API料金なし、データ漏洩リスクなし)
3. 技術スタック
| 技術 | 役割 |
|---|---|
| LangChain.js | RAGパイプラインの構築 |
| Ollama | ローカルLLM実行環境 |
| gemma3:12b | 回答生成用LLM |
| nomic-embed-text | テキスト埋め込みモデル |
| TypeScript | 型安全な開発 |
4. 前提条件
4.1. PC要件
ローカルLLMはクラウドLLMと違い、推論処理をすべて手元のPCで実行します。
そのため、ある程度の計算リソース(特にGPUメモリ)が必要になります。
以下、今回のアプリ作成をしたPCのスペックを記載しておきます。
| 項目 | 内容 |
|---|---|
| OS | Windows 11 |
| CPU | Intel(R) Core(TM) i7-14700F |
| GPU | NVIDIA GeForce RTX 4070 Ti SUPER |
| メモリ | 64GB |
4.2. Ollamaのセットアップ
# Ollamaをインストール後、必要なモデルをダウンロード
ollama pull nomic-embed-text
ollama pull gemma3:12b
4.3. ライブラリのインストール
npm install langchain @langchain/core @langchain/community @langchain/ollama @langchain/textsplitters @langchain/classic pdf-parse && npm install -D typescript tsx @types/node
5. ディレクトリ構成
documentQa/
├── docs/
│ └── guide.pdf # 対象のPDFドキュメント
├── src/
│ ├── index.ts # エントリーポイント
│ ├── loaders/
│ │ └── loadDocs.ts # PDFローダー
│ └── rag/
│ ├── questions.ts # Q&Aロジック
│ └── vectorstore.ts # ベクトルストア
├── package.json
└── tsconfig.json
6. 実装コード&解説
6.1. PDFの読み込みとチャンク分割(src/loaders/loadDocs.ts)
まず、PDFを読み込み、検索しやすいサイズに分割します。
import { PDFLoader } from "@langchain/community/document_loaders/fs/pdf";
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
export async function loadDocs() {
const pdf = new PDFLoader("./docs/guide.pdf");
const pdfDocs = await pdf.load();
// テキストを小さなチャンクに分割
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 1000,
chunkOverlap: 200,
});
const splitDocs = await splitter.splitDocuments(pdfDocs);
return splitDocs;
}
ポイント:
- chunkSize: 1000 - 1チャンクあたり約1000文字
- chunkOverlap: 200 - チャンク間で200文字重複させ、文脈の分断を防止
このあたりはRAGの対象とするPDFのサイズ次第で値が変わってくると思います。
6.2. ベクトルストアの初期化(src/rag/vectorstore.ts)
import { MemoryVectorStore } from "@langchain/classic/vectorstores/memory";
import { OllamaEmbeddings } from "@langchain/ollama";
import { Document } from "@langchain/core/documents";
export async function initVectorStore(docs: Document[]) {
const embeddings = new OllamaEmbeddings({
model: "nomic-embed-text",
});
const store = await MemoryVectorStore.fromDocuments(docs, embeddings);
return store;
}
ポイント:
- nomic-embed-text は軽量で日本語にも対応した埋め込みモデル
- MemoryVectorStore はシンプルで外部DBが不要
6.3. ハイブリッド検索の実装とスコアリングによる結果の最適化(src/rag/questions.ts
import { ChatOllama } from "@langchain/ollama";
import { VectorStore } from "@langchain/core/vectorstores";
function extractKeywords(question: string): string[] {
const keywords: string[] = [];
// 既知のキーワード拡張マップ(同義語や関連語を追加)
const keywordMap: { [key: string]: string[] } = {
始業: ["始業時刻", "始業"],
終業: ["終業時刻", "終業"],
休日: ["休日", "土曜日", "日曜日", "祝日", "国民の祝日"],
給与: ["給与", "賃金"],
有給: ["有給", "年次有給休暇"],
副業: ["副業", "兼業"],
賞与: ["賞与", "ボーナス"],
退職: ["退職", "退職金"],
休憩: ["休憩", "休憩時間"],
残業: ["残業", "時間外", "所定外労働"],
育児: ["育児", "育児休業"],
介護: ["介護", "介護休業"],
};
// マップに登録されているキーワードを検索
for (const [key, values] of Object.entries(keywordMap)) {
if (question.includes(key)) {
keywords.push(...values);
}
}
// 汎用的なキーワード抽出:2文字以上の漢字・カタカナの単語を抽出
const generalPattern = /[一-龯ァ-ヶー]{2,}/g;
const generalMatches = question.match(generalPattern);
if (generalMatches) {
// ストップワード(除外する一般的な単語)
const stopWords = ["です", "ます", "ください", "ありがとう", "について", "いつ", "どこ", "なに", "なぜ", "どう"];
generalMatches.forEach((word) => {
// ストップワードでなく、まだ追加されていないキーワードを追加
if (!stopWords.includes(word) && !keywords.includes(word)) {
keywords.push(word);
}
});
}
return keywords;
}
export async function askQuestion(store: VectorStore, question: string) {
// ベクトル検索
let results = await store.similaritySearch(question, 10);
// キーワード検索: 質問に特定のキーワードが含まれている場合、追加検索
const keywords = extractKeywords(question);
if (keywords.length > 0) {
// MemoryVectorStoreからすべてのドキュメントを取得
const allDocs = await store.similaritySearch("", 1000); // 大きな数で全ドキュメント取得
const keywordMatches = allDocs.filter((doc) =>
keywords.some((keyword: string) => doc.pageContent.includes(keyword))
);
// すべてのキーワードマッチをスコアリング
const allScoredMatches = keywordMatches.map((doc) => {
const matchCount = keywords.filter((keyword) =>
doc.pageContent.includes(keyword)
).length;
return { doc, score: matchCount };
});
allScoredMatches.sort((a, b) => b.score - a.score);
// キーワードマッチを最優先で使用
results = allScoredMatches.slice(0, 3).map((item) => item.doc);
}
const context = results.slice(0, 3).map((d) => d.pageContent).join("\n\n");
const ollama = new ChatOllama({
model: "gemma3:12b",
temperature: 0,
});
const prompt = `あなたは以下の文書から情報を抽出するアシスタントです。文書に書かれている内容をそのまま答えてください。
文書:
${context}
質問: ${question}
答え(文書から該当箇所を抜き出してください):`;
return await ollama.invoke(prompt);
}
6.3.1. ハイブリッド検索の実装(function extractKeywords(question: string): string[])
ベクトル検索だけでは日本語の専門用語や固有名詞の検索精度が低いため、キーワード検索を組み合わせます。
ちなみにconst generalPattern = /[一-龯ァ-ヶー]{2,}/g;は文字化けではありません(笑)
漢字と全角カタカナ(ァからヶ)、そして長音符(ー)**を含む、日本語の主要な文字(漢字と全角カタカナ)を網羅的にマッチさせるパターンです。
6.3.2. スコアリングによる結果の最適化(export async function askQuestion(store: VectorStore, question: string))
キーワードマッチの数でスコアリングし、最も関連性の高いドキュメントを選択します。
ポイント:
- temperature: 0 で創造性を抑え、文書に忠実な回答を生成
- 上位3件のドキュメントをコンテキストとして使用
6.4. 対話型CLIの実装(src/index.ts)
import * as readline from "readline";
async function main() {
console.log("\n" + "=".repeat(60));
console.log(" うぐいすソリューションズ社内規定ChatBot");
console.log("=".repeat(60) + "\n");
console.log("ドキュメントを読み込んでいます...");
const docs = await loadDocs();
console.log("ベクトルストアを初期化しています...");
const store = await initVectorStore(docs);
console.log("準備完了!質問を入力してください(Ctrl+Cで終了)\n");
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const askLoop = () => {
rl.question("質問: ", async (question) => {
if (!question.trim()) {
askLoop();
return;
}
console.log("\n回答を生成中...\n");
const answer = await askQuestion(store, question);
console.log("回答:", answer.content);
console.log("\n" + "-".repeat(50) + "\n");
askLoop();
});
};
askLoop();
}
main();
7. 実装中にハマった点と解決策
7.1. ベクトル検索だけでは不十分
ハマりポイント: 「休日はいつですか?」という質問に対して、給与や勤務時間の情報が返ってきてしまう。
原因: ベクトル検索は意味的な類似性を見るため、「休日」と「勤務」が関連トピックとして近い位置に配置されてしまう。
解決策: キーワード検索を組み合わせてハイブリット検索にして、「休日」「土曜日」「日曜日」などの具体的な単語を含むチャンクを優先。
7.2. キーワードマップの重要性
ハマりポイント: 「休日」で検索しても、文書内では「土曜日」「日曜日」と表記されている。
解決策: キーワード拡張マップを作成し、同義語・関連語を自動展開。
7.3. LLMの回答精度
ハマりポイント: 正しいコンテキストを渡しても「該当する情報はありません」と回答されることがある。
解決策:プロンプトをシンプルに「文書から該当箇所を抜き出す」タスクに特化
temperature: 0 で確定的な回答を生成
8. 今後の改善策
8.1. 永続化: MemoryVectorStoreからChromaDB等に移行し、起動時間を短縮
現在は MemoryVectorStore を使っているため、起動のたびに PDF を読み込み → チャンク分割 → 埋め込み生成 → メモリに展開 というフローが毎回走るので、永続化ベクトルDBに移行して、都度同じ処理が走らないようにする必要があります。
8.2. マルチドキュメント対応: 複数PDFの同時読み込み
現状では 1つの PDF のみを対象として検索していますが、複数PDFを一度に処理できるようにパラメータを調節する必要があります。
8.3. Web UI: Next.js等でブラウザから利用可能に
今回の CLI ベースは検証用途としては十分ですが、
実際に使用することを考えると ブラウザからアクセスできる Web UI の方が便利で良いです。
9. まとめ
今回紹介した LangChain.js × Ollama の構成を使えば、
クラウドに一切データを送らず、完全ローカルで社内文書のQ&Aシステムを構築できます。
レスポンスの速度も遅くても10秒ほどで返ってくるので十分使えるパフォーマンスは出ているのではないでしょうか?
クラウドLLMの API を使う場合、
- 実行するたびにコストが発生する
- 社内文書をクラウドに送るリスクがある
- オフライン環境では使えない
といった課題があり、気軽に検証しづらい場面があります。
しかしローカルLLMなら、
- API料金ゼロでいくらでも検証できる
- 社内文書を外部に出さず安全に扱える
- オフラインでも動作する
というメリットがあり、社内規定や手順書などを扱う業務と非常に相性が良いと感じました。
また、日本語ドキュメントの検索精度を高めるために導入したベクトル検索 × キーワード検索のハイブリッド検索も効果的でした。日本語は専門用語や固有名詞が多く、意味的検索だけでは拾いきれないケースがありますが、キーワードマッチとスコアリングを組み合わせることで実用レベルの精度が出せます。
ローカルLLMの性能を確かめたい人、あるいはクラウド依存せずに社内ナレッジ検索を作りたい企業にとって、この構成はとても導入しやすい選択肢だと思います。
さて、明日は農家を目指しているパワー系エンジニアの@tetsutaroさんの担当です。どうやら弊社の@kpbrmskさんと深夜に怪しいハドルを繋いで何かの作業をしているのでその成果をまとめて記事にしてくれると思います。
それでは
ノシ
Discussion