社内ナレッジ QA を Ollama で構築:出典リンクとアクセス制御で信頼性向上
社内に蓄積された膨大なナレッジを効率的に活用したい、そんな要望をお持ちではありませんか?
近年、大規模言語モデル(LLM)を活用した QA システムの構築が注目されています。特に、ローカル環境で動作する Ollama を使えば、機密情報を外部に送信することなく、社内独自の質問応答システムを構築できるのです。
本記事では、Ollama を使った社内ナレッジ QA システムの構築方法を詳しく解説します。単なる回答生成だけでなく、回答の信頼性を高める「出典リンク」の提供と、情報漏洩を防ぐ「アクセス制御」の実装方法まで、実践的な内容をお届けしますね。
背景
社内ナレッジ管理の現状
多くの企業では、社内ドキュメント、議事録、技術仕様書などが様々な場所に分散して保管されています。これらの情報を必要な時に素早く見つけ出すことは、業務効率化の重要な課題となっていますね。
従来の検索システムでは、キーワードマッチングによる検索が中心でしたが、欲しい情報を的確に見つけるには適切なキーワードを知っている必要がありました。一方、最近注目されている LLM を活用した QA システムでは、自然な質問文から関連情報を抽出し、わかりやすく回答を生成できます。
Ollama とは
Ollama は、ローカル環境で大規模言語モデルを簡単に実行できるツールです。以下のような特徴があります:
| # | 特徴 | 説明 |
|---|---|---|
| 1 | ローカル実行 | サーバー不要でローカル環境で完結 |
| 2 | 多様なモデル | Llama 2、Mistral、Gemma など複数モデルに対応 |
| 3 | API サポート | REST API を通じた統合が容易 |
| 4 | セキュリティ | データが外部に送信されない |
下記の図は、従来の検索システムと Ollama を使った QA システムの違いを示しています。
mermaidflowchart LR
subgraph traditional["従来の検索"]
user1["ユーザー"] -->|キーワード検索| search["検索エンジン"]
search -->|マッチング| docs1[("ドキュメント")]
docs1 -->|検索結果一覧| user1
end
subgraph ollama_system["Ollama QA システム"]
user2["ユーザー"] -->|自然な質問| qa["QA システム"]
qa -->|意味理解| retrieval["ベクトル検索"]
retrieval -->|関連文書取得| docs2[("ドキュメント")]
docs2 -->|コンテキスト| llm["Ollama<br/>LLM"]
llm -->|回答生成| user2
end
図から分かるように、Ollama QA システムでは質問の意味を理解し、関連する情報を取得してから回答を生成するため、より精度の高い回答が得られるのです。
RAG(Retrieval-Augmented Generation)の役割
社内ナレッジ QA システムでは、RAG という手法が重要な役割を果たします。RAG とは、外部の知識ベースから関連情報を取得(Retrieval)し、その情報を元に LLM が回答を生成(Generation)する手法です。
RAG を使うことで、LLM の学習データに含まれていない最新の社内情報や専門的な知識にも対応できるようになります。
課題
生成 AI の回答における信頼性の問題
LLM を使った QA システムには、以下のような課題がありました:
| # | 課題 | 詳細 |
|---|---|---|
| 1 | ハルシネーション | 事実と異なる内容を自信を持って回答してしまう |
| 2 | 出典不明 | 回答の根拠となる情報源が分からない |
| 3 | 情報の鮮度 | 最新情報への対応が不十分 |
| 4 | アクセス制御不足 | 権限のない情報へのアクセスを制御できない |
特に企業の社内ナレッジシステムでは、回答の信頼性が業務に直接影響を与えるため、これらの課題への対応が不可欠です。
社内情報のセキュリティリスク
社内ナレッジには、機密情報や部署限定の情報も含まれています。適切なアクセス制御なしに QA システムを構築すると、以下のようなリスクが発生しますね:
以下の図は、アクセス制御がない場合のリスクを示しています。
mermaidflowchart TD
user_a["営業部 ユーザーA"] -->|質問| qa_system["QA システム"]
user_b["開発部 ユーザーB"] -->|質問| qa_system
qa_system -->|検索| all_docs[("全社ドキュメント")]
all_docs -.->|機密情報も含む| dev_docs["開発部限定<br/>設計書"]
all_docs -.->|機密情報も含む| sales_docs["営業部限定<br/>顧客情報"]
all_docs -.->|機密情報も含む| exec_docs["役員限定<br/>経営情報"]
qa_system -->|権限関係なく<br/>回答| user_a
qa_system -->|権限関係なく<br/>回答| user_b
style dev_docs fill:#ffcccc
style sales_docs fill:#ffcccc
style exec_docs fill:#ffcccc
この図が示すように、アクセス制御がないと、本来閲覧権限のない情報まで QA システムが回答に含めてしまう可能性があります。
出典リンクの必要性
回答の信頼性を高めるには、「どの情報源から回答を生成したか」を明示することが重要です。出典リンクがあることで、以下のメリットが得られます:
- 回答の正確性をユーザー自身で確認できる
- 詳細情報が必要な場合に元文書を参照できる
- 誤った回答があった場合の原因特定が容易になる
- システムへの信頼性が向上する
解決策
システムアーキテクチャ全体像
Ollama を使った社内ナレッジ QA システムは、以下のコンポーネントで構成されます。
下記の図は、システム全体のアーキテクチャを示しています。
mermaidflowchart TB
user["ユーザー"] -->|1. 質問| frontend["フロントエンド<br/>Next.js"]
frontend -->|2. API リクエスト| backend["バックエンド<br/>Node.js + Express"]
backend -->|3. 認証確認| auth["認証サービス"]
auth -->|4. ユーザー権限| backend
backend -->|5. ベクトル化| embedder["埋め込みモデル"]
embedder -->|6. クエリベクトル| backend
backend -->|7. 類似検索<br/>権限フィルタ| vectordb[("ベクトル DB<br/>Chroma")]
vectordb -->|8. 関連ドキュメント<br/>+ 出典情報| backend
backend -->|9. プロンプト生成| ollama["Ollama<br/>LLM"]
ollama -->|10. 回答生成| backend
backend -->|11. 回答 + 出典リンク| frontend
frontend -->|12. 表示| user
このアーキテクチャでは、ユーザーの質問を受け取ってから回答を返すまでの一連の流れで、認証・権限チェック・出典情報の付与を行っています。
ベクトルデータベースとアクセス制御
社内ドキュメントをベクトルデータベースに保存する際、各ドキュメントにメタデータとして権限情報を付与します。これにより、検索時にユーザーの権限に応じたフィルタリングが可能になるのです。
| # | メタデータ項目 | 説明 | 例 |
|---|---|---|---|
| 1 | document_id | ドキュメント固有 ID | "doc_20240115_001" |
| 2 | source_url | 元文書の URL | "https://wiki.company.com/..." |
| 3 | allowed_roles | アクセス可能な役割 | ["developer", "admin"] |
| 4 | department | 所属部署 | "engineering" |
| 5 | created_at | 作成日時 | "2024-01-15T10:00:00Z" |
出典リンク付き回答の生成
LLM が回答を生成する際、取得したドキュメントの出典情報を含めるようにプロンプトを設計します。これにより、回答と出典が紐付いた形で返却できますね。
具体例
環境構築
まずは、必要なパッケージをインストールしましょう。
パッケージのインストール
以下のコマンドで、プロジェクトに必要なパッケージをインストールします。
bash# プロジェクトディレクトリを作成
mkdir ollama-knowledge-qa
cd ollama-knowledge-qa
# package.json を初期化
yarn init -y
次に、必要な依存パッケージをインストールします。
bash# 必要なパッケージをインストール
yarn add express chromadb ollama dotenv jsonwebtoken
yarn add -D typescript @types/node @types/express @types/jsonwebtoken
各パッケージの役割は以下の通りです:
| # | パッケージ | 用途 |
|---|---|---|
| 1 | express | Web サーバーフレームワーク |
| 2 | chromadb | ベクトルデータベース |
| 3 | ollama | Ollama との連携 |
| 4 | jsonwebtoken | JWT 認証トークン処理 |
TypeScript 設定ファイル
プロジェクトルートに tsconfig.json を作成します。
typescript{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
この設定により、TypeScript のトランスパイル環境が整います。
Ollama のセットアップ
Ollama のインストール
ローカル環境に Ollama をインストールします。
bash# macOS の場合
brew install ollama
# Linux の場合
curl -fsSL https://ollama.ai/install.sh | sh
モデルのダウンロード
日本語対応が優れている Llama 2 モデルをダウンロードしましょう。
bash# Llama 2 7B モデルをダウンロード
ollama pull llama2
# または日本語特化モデル
ollama pull elyza:jp-llama-2-7b
Ollama サーバーの起動
以下のコマンドで Ollama サーバーを起動します。
bash# Ollama サーバーを起動(バックグラウンド)
ollama serve
正常に起動すると、http://localhost:11434 で API が利用可能になります。
ベクトルデータベースの構築
ChromaDB クライアントの初期化
src/database/vectorStore.ts を作成し、ChromaDB クライアントを初期化します。
typescriptimport { ChromaClient } from 'chromadb';
// ChromaDB クライアントを初期化
export const chromaClient = new ChromaClient({
path: process.env.CHROMA_URL || 'http://localhost:8000',
});
// コレクション名を定義
export const COLLECTION_NAME = 'company_knowledge';
このコードで、ChromaDB への接続設定を行います。
ドキュメント保存関数の実装
ドキュメントをベクトル化して保存する関数を実装しましょう。
typescriptimport { Collection } from 'chromadb';
// ドキュメントデータの型定義
interface DocumentData {
id: string;
content: string;
sourceUrl: string;
allowedRoles: string[];
department: string;
}
次に、ドキュメントを追加する関数を作成します。
typescript// ドキュメントをベクトルデータベースに追加
export async function addDocument(
collection: Collection,
doc: DocumentData
): Promise<void> {
await collection.add({
ids: [doc.id],
documents: [doc.content],
metadatas: [
{
source_url: doc.sourceUrl,
allowed_roles: JSON.stringify(doc.allowedRoles),
department: doc.department,
created_at: new Date().toISOString(),
},
],
});
}
このコードでは、ドキュメント本文とともにメタデータ(権限情報、出典 URL など)を保存しています。
コレクションの作成
アプリケーション起動時にコレクションを作成または取得する関数を実装します。
typescript// コレクションを取得または作成
export async function getOrCreateCollection(): Promise<Collection> {
const collections = await chromaClient.listCollections();
const exists = collections.some(
(c) => c.name === COLLECTION_NAME
);
if (exists) {
return await chromaClient.getCollection({
name: COLLECTION_NAME,
});
}
return await chromaClient.createCollection({
name: COLLECTION_NAME,
});
}
既存のコレクションがあればそれを使用し、なければ新規作成するという安全な実装ですね。
認証とアクセス制御の実装
JWT トークンの検証
src/auth/authMiddleware.ts を作成し、JWT トークンを検証するミドルウェアを実装します。
typescriptimport { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
// ユーザー情報の型定義
export interface UserPayload {
userId: string;
roles: string[];
department: string;
}
次に、トークン検証のミドルウェアを実装します。
typescript// JWT トークンを検証するミドルウェア
export function authenticateToken(
req: Request,
res: Response,
next: NextFunction
): void {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
res.status(401).json({ error: 'Unauthorized: No token provided' });
return;
}
トークンが存在する場合、その検証を行います。
typescript // トークンの検証
jwt.verify(
token,
process.env.JWT_SECRET as string,
(err, decoded) => {
if (err) {
return res.status(403).json({
error: 'Forbidden: Invalid token',
code: 'INVALID_TOKEN'
});
}
// リクエストにユーザー情報を追加
(req as any).user = decoded as UserPayload;
next();
}
);
}
このミドルウェアにより、すべての API リクエストで認証チェックが行われます。
権限ベースのフィルタリング
ユーザーの権限に基づいてドキュメントをフィルタリングする関数を実装しましょう。
typescript// src/search/permissionFilter.ts
import { UserPayload } from '../auth/authMiddleware';
// 権限に基づく検索フィルタを生成
export function buildPermissionFilter(
user: UserPayload
): Record<string, any> {
return {
$or: [
// ユーザーの役割に一致するドキュメント
{ allowed_roles: { $contains: user.roles } },
// ユーザーの部署に一致するドキュメント
{ department: user.department },
],
};
}
このフィルタにより、ユーザーがアクセス可能なドキュメントのみが検索対象となります。
QA システムの実装
検索機能の実装
ユーザーの質問から関連ドキュメントを検索する関数を実装します。
typescript// src/search/search.ts
import { Collection } from 'chromadb';
import { UserPayload } from '../auth/authMiddleware';
import { buildPermissionFilter } from './permissionFilter';
// 検索結果の型定義
interface SearchResult {
id: string;
content: string;
sourceUrl: string;
distance: number;
}
次に、実際の検索ロジックを実装します。
typescript// ベクトル検索を実行(権限フィルタ付き)
export async function searchDocuments(
collection: Collection,
query: string,
user: UserPayload,
topK: number = 3
): Promise<SearchResult[]> {
const filter = buildPermissionFilter(user);
const results = await collection.query({
queryTexts: [query],
nResults: topK,
where: filter
});
検索結果を整形して返却します。
typescript // 結果を整形
const documents = results.documents[0] || [];
const metadatas = results.metadatas[0] || [];
const distances = results.distances?.[0] || [];
return documents.map((doc, index) => ({
id: results.ids[0][index],
content: doc,
sourceUrl: metadatas[index]?.source_url as string,
distance: distances[index]
}));
}
このコードにより、ユーザーの権限に応じた検索結果のみが返されるのです。
Ollama との連携
検索で取得したドキュメントを元に、Ollama で回答を生成します。
typescript// src/llm/ollamaClient.ts
import { Ollama } from 'ollama';
// Ollama クライアントを初期化
const ollama = new Ollama({
host: process.env.OLLAMA_URL || 'http://localhost:11434',
});
次に、プロンプトを構築する関数を実装します。
typescript// 回答生成用のプロンプトを構築
function buildPrompt(
query: string,
documents: Array<{ content: string; sourceUrl: string }>
): string {
const context = documents
.map((doc, index) => `
[参考資料${index + 1}]
内容: ${doc.content}
出典: ${doc.sourceUrl}
`.trim())
.join('\n\n');
プロンプトのメイン部分を構築します。
typescript return `
以下の参考資料を基に、ユーザーの質問に回答してください。
回答には必ず出典番号を含めてください。
${context}
質問: ${query}
回答の際は、以下のルールに従ってください:
1. 参考資料の内容のみを基に回答する
2. 回答の根拠となる資料番号を明記する
3. 参考資料に情報がない場合は、正直にその旨を伝える
4. 日本語で分かりやすく回答する
回答:
`.trim();
}
このプロンプトにより、LLM は出典を明示した回答を生成できます。
回答生成関数
Ollama を使って回答を生成する関数を実装しましょう。
typescript// 回答を生成(出典情報付き)
export async function generateAnswer(
query: string,
documents: Array<{ content: string; sourceUrl: string }>
): Promise<string> {
const prompt = buildPrompt(query, documents);
const response = await ollama.generate({
model: process.env.OLLAMA_MODEL || 'llama2',
prompt: prompt,
stream: false,
});
return response.response;
}
このコードで、検索結果を基にした回答が生成されます。
API エンドポイントの実装
Express アプリケーションのセットアップ
src/server.ts を作成し、Express アプリケーションをセットアップします。
typescriptimport express from 'express';
import dotenv from 'dotenv';
import { authenticateToken } from './auth/authMiddleware';
// 環境変数を読み込む
dotenv.config();
// Express アプリケーションを初期化
const app = express();
app.use(express.json());
const PORT = process.env.PORT || 3000;
次に、ヘルスチェック用のエンドポイントを作成します。
typescript// ヘルスチェック用エンドポイント
app.get('/health', (req, res) => {
res.json({
status: 'ok',
message: 'QA System is running',
});
});
QA エンドポイントの実装
質問を受け付けて回答を返すエンドポイントを実装します。
typescriptimport { searchDocuments } from './search/search';
import { generateAnswer } from './llm/ollamaClient';
import { getOrCreateCollection } from './database/vectorStore';
// QA エンドポイント(認証必須)
app.post('/api/qa', authenticateToken, async (req, res) => {
try {
const { question } = req.body;
const user = (req as any).user;
if (!question) {
return res.status(400).json({
error: 'Bad Request: question is required',
code: 'MISSING_QUESTION'
});
}
検索とドキュメント取得を実行します。
typescript// コレクションを取得
const collection = await getOrCreateCollection();
// 関連ドキュメントを検索(権限フィルタ付き)
const documents = await searchDocuments(
collection,
question,
user,
3 // 上位3件を取得
);
検索結果を基に回答を生成します。
typescriptif (documents.length === 0) {
return res.json({
answer: 'ご質問に関連する情報が見つかりませんでした。',
sources: [],
});
}
// Ollama で回答を生成
const answer = await generateAnswer(question, documents);
最後に、回答と出典リンクを返却します。
typescript // 回答と出典リンクを返却
res.json({
answer,
sources: documents.map((doc, index) => ({
number: index + 1,
url: doc.sourceUrl,
relevance: (1 - doc.distance).toFixed(2) // 関連度スコア
}))
});
} catch (error) {
console.error('Error in QA endpoint:', error);
res.status(500).json({
error: 'Internal Server Error',
code: 'QA_PROCESSING_ERROR'
});
}
});
このエンドポイントにより、認証されたユーザーのみが質問でき、その回答には出典リンクが含まれるのです。
サーバーの起動
Express サーバーを起動する処理を追加します。
typescript// サーバーを起動
app.listen(PORT, () => {
console.log(
`QA System server is running on port ${PORT}`
);
console.log(
`Health check: http://localhost:${PORT}/health`
);
});
export default app;
環境変数の設定
.env ファイルを作成し、必要な環境変数を設定します。
bash# サーバー設定
PORT=3000
# Ollama 設定
OLLAMA_URL=http://localhost:11434
OLLAMA_MODEL=llama2
# ChromaDB 設定
CHROMA_URL=http://localhost:8000
# JWT 設定
JWT_SECRET=your-secret-key-here-change-in-production
本番環境では、JWT_SECRET を必ず変更してください。
ドキュメント登録スクリプト
社内ドキュメントをベクトルデータベースに登録するスクリプトを作成しましょう。
typescript// src/scripts/importDocuments.ts
import {
getOrCreateCollection,
addDocument,
} from '../database/vectorStore';
// サンプルドキュメントデータ
const sampleDocuments = [
{
id: 'doc_001',
content:
'Next.js のデプロイには Vercel を推奨します。環境変数の設定方法は...',
sourceUrl: 'https://wiki.company.com/nextjs-deployment',
allowedRoles: ['developer', 'admin'],
department: 'engineering',
},
{
id: 'doc_002',
content:
'顧客データの取り扱いに関するガイドライン。個人情報は...',
sourceUrl: 'https://wiki.company.com/data-handling',
allowedRoles: ['sales', 'admin'],
department: 'sales',
},
];
ドキュメントを一括登録する関数を実装します。
typescript// ドキュメントを一括登録
async function importDocuments() {
try {
console.log('Starting document import...');
const collection = await getOrCreateCollection();
for (const doc of sampleDocuments) {
await addDocument(collection, doc);
console.log(`Imported: ${doc.id}`);
}
console.log('Document import completed successfully');
} catch (error) {
console.error('Error importing documents:', error);
process.exit(1);
}
}
// スクリプトを実行
importDocuments();
このスクリプトを実行することで、社内ドキュメントがベクトルデータベースに登録されます。
フロントエンドの実装例
Next.js を使った簡単なフロントエンド例を紹介します。
QA コンポーネント
components/QAInterface.tsx を作成します。
typescriptimport { useState } from 'react';
// 出典情報の型定義
interface Source {
number: number;
url: string;
relevance: string;
}
// 回答データの型定義
interface QAResponse {
answer: string;
sources: Source[];
}
QA インターフェースコンポーネントを実装します。
typescriptexport default function QAInterface() {
const [question, setQuestion] = useState('');
const [response, setResponse] = useState<QAResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!question.trim()) {
setError('質問を入力してください');
return;
}
API リクエストを実行します。
typescript setLoading(true);
setError('');
try {
const token = localStorage.getItem('authToken');
const res = await fetch('/api/qa', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ question })
});
レスポンスを処理します。
typescript if (!res.ok) {
const errorData = await res.json();
throw new Error(errorData.error || 'エラーが発生しました');
}
const data = await res.json();
setResponse(data);
} catch (err) {
setError(err instanceof Error ? err.message : '予期しないエラー');
} finally {
setLoading(false);
}
};
UI を返却します。
typescript return (
<div className="qa-container">
<form onSubmit={handleSubmit}>
<textarea
value={question}
onChange={(e) => setQuestion(e.target.value)}
placeholder="質問を入力してください"
disabled={loading}
/>
<button type="submit" disabled={loading}>
{loading ? '処理中...' : '質問する'}
</button>
</form>
エラーメッセージと回答を表示します。
typescript {error && <div className="error">{error}</div>}
{response && (
<div className="response">
<h3>回答</h3>
<p>{response.answer}</p>
{response.sources.length > 0 && (
<div className="sources">
<h4>参考資料</h4>
<ul>
{response.sources.map((source) => (
<li key={source.number}>
<a href={source.url} target="_blank" rel="noopener">
[{source.number}] 関連度: {source.relevance}
</a>
</li>
))}
</ul>
</div>
)}
</div>
)}
</div>
);
}
このコンポーネントにより、ユーザーは質問を入力し、出典リンク付きの回答を受け取れます。
システムの動作フロー
実装したシステムの動作フローを図で確認しましょう。
mermaidsequenceDiagram
participant User as ユーザー
participant FE as フロントエンド
participant API as API サーバー
participant Auth as 認証ミドルウェア
participant Search as 検索エンジン
participant VDB as ベクトル DB
participant LLM as Ollama
User->>FE: 質問を入力
FE->>API: POST /api/qa<br/>(JWT トークン付き)
API->>Auth: トークン検証
Auth-->>API: ユーザー情報
API->>Search: 検索リクエスト<br/>(権限情報含む)
Search->>VDB: ベクトル検索<br/>(権限フィルタ)
VDB-->>Search: 関連ドキュメント<br/>(出典 URL 含む)
Search-->>API: 検索結果
API->>LLM: プロンプト送信<br/>(コンテキスト含む)
LLM-->>API: 回答生成
API-->>FE: 回答 + 出典リンク
FE-->>User: 画面に表示
この図から、認証・検索・回答生成のプロセスが連携して動作していることが分かりますね。
テストの実行
実装したシステムをテストしてみましょう。
サーバーの起動
以下のコマンドで各コンポーネントを起動します。
bash# ChromaDB を起動(別ターミナル)
docker run -p 8000:8000 chromadb/chroma
# Ollama を起動(別ターミナル)
ollama serve
# ドキュメントをインポート
yarn ts-node src/scripts/importDocuments.ts
次に、API サーバーを起動します。
bash# API サーバーを起動
yarn ts-node src/server.ts
API のテスト
curl コマンドで API をテストできます。
bash# 認証トークンを取得(実際の認証システムに応じて調整)
TOKEN="your-jwt-token-here"
# QA エンドポイントをテスト
curl -X POST http://localhost:3000/api/qa \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{"question": "Next.jsのデプロイ方法は?"}'
正常に動作すると、以下のようなレスポンスが返ってきます。
json{
"answer": "Next.js のデプロイには Vercel を推奨します。[参考資料1]によると、環境変数の設定方法は...",
"sources": [
{
"number": 1,
"url": "https://wiki.company.com/nextjs-deployment",
"relevance": "0.89"
}
]
}
エラーハンドリングの実装
実際の運用では、様々なエラーケースに対応する必要があります。
エラータイプの定義
カスタムエラークラスを定義しましょう。
typescript// src/errors/CustomErrors.ts
// 基底エラークラス
export class AppError extends Error {
constructor(
public statusCode: number,
public code: string,
message: string
) {
super(message);
this.name = this.constructor.name;
}
}
具体的なエラークラスを定義します。
typescript// 認証エラー
export class AuthenticationError extends AppError {
constructor(message = 'Authentication failed') {
super(401, 'AUTHENTICATION_ERROR', message);
}
}
// 権限エラー
export class PermissionError extends AppError {
constructor(message = 'Permission denied') {
super(403, 'PERMISSION_ERROR', message);
}
}
// Ollama 接続エラー
export class OllamaConnectionError extends AppError {
constructor(message = 'Failed to connect to Ollama') {
super(503, 'OLLAMA_CONNECTION_ERROR', message);
}
}
グローバルエラーハンドラ
Express のエラーハンドリングミドルウェアを実装します。
typescript// src/middleware/errorHandler.ts
import { Request, Response, NextFunction } from 'express';
import { AppError } from '../errors/CustomErrors';
export function errorHandler(
err: Error,
req: Request,
res: Response,
next: NextFunction
): void {
// カスタムエラーの場合
if (err instanceof AppError) {
res.status(err.statusCode).json({
error: err.message,
code: err.code
});
return;
}
その他のエラーを処理します。
typescript // 予期しないエラーの場合
console.error('Unexpected error:', err);
res.status(500).json({
error: 'Internal Server Error',
code: 'INTERNAL_ERROR',
message: process.env.NODE_ENV === 'development' ? err.message : undefined
});
}
このエラーハンドラを server.ts に追加します。
typescriptimport { errorHandler } from './middleware/errorHandler';
// ... 他のミドルウェアとルート定義 ...
// エラーハンドラは最後に追加
app.use(errorHandler);
エラーコードを明示することで、フロントエンド側でも適切なエラー処理が可能になります。
セキュリティ対策
レート制限の実装
API への過度なリクエストを防ぐため、レート制限を実装しましょう。
typescript// src/middleware/rateLimiter.ts
import rateLimit from 'express-rate-limit';
// QA エンドポイント用のレート制限
export const qaRateLimiter = rateLimit({
windowMs: 60 * 1000, // 1分間
max: 10, // 最大10リクエスト
message: {
error: 'Too many requests',
code: 'RATE_LIMIT_EXCEEDED',
},
standardHeaders: true,
legacyHeaders: false,
});
このミドルウェアを QA エンドポイントに適用します。
typescriptimport { qaRateLimiter } from './middleware/rateLimiter';
app.post(
'/api/qa',
qaRateLimiter,
authenticateToken,
async (req, res) => {
// ... QA 処理 ...
}
);
入力値のサニタイゼーション
悪意のある入力から保護するため、バリデーションを追加します。
typescript// src/validation/qaValidation.ts
import { body, validationResult } from 'express-validator';
import { Request, Response, NextFunction } from 'express';
// QA リクエストのバリデーションルール
export const qaValidationRules = [
body('question')
.trim()
.notEmpty()
.withMessage('Question is required')
.isLength({ max: 1000 })
.withMessage('Question too long (max 1000 chars)')
.escape(), // XSS対策
];
バリデーション結果をチェックするミドルウェアを作成します。
typescript// バリデーション結果をチェック
export function validateRequest(
req: Request,
res: Response,
next: NextFunction
): void {
const errors = validationResult(req);
if (!errors.isEmpty()) {
res.status(400).json({
error: 'Validation failed',
code: 'VALIDATION_ERROR',
details: errors.array(),
});
return;
}
next();
}
これらをエンドポイントに適用します。
typescriptapp.post(
'/api/qa',
qaRateLimiter,
qaValidationRules,
validateRequest,
authenticateToken,
async (req, res) => {
// ... QA 処理 ...
}
);
パフォーマンス最適化
キャッシュの実装
頻繁に質問される内容についてはキャッシュを活用しましょう。
typescript// src/cache/responseCache.ts
import NodeCache from 'node-cache';
// キャッシュインスタンス(TTL: 1時間)
const responseCache = new NodeCache({
stdTTL: 3600,
checkperiod: 600,
});
キャッシュキーを生成する関数を作成します。
typescript// キャッシュキーを生成(質問 + ユーザー権限)
function generateCacheKey(
question: string,
userRoles: string[]
): string {
const normalizedQuestion = question.toLowerCase().trim();
const roleString = [...userRoles].sort().join(',');
return `qa:${normalizedQuestion}:${roleString}`;
}
キャッシュを活用した QA 関数を実装します。
typescript// キャッシュ付き QA 処理
export async function getAnswerWithCache(
question: string,
user: UserPayload
): Promise<QAResponse> {
const cacheKey = generateCacheKey(question, user.roles);
// キャッシュを確認
const cached = responseCache.get<QAResponse>(cacheKey);
if (cached) {
console.log('Cache hit:', cacheKey);
return cached;
}
キャッシュになければ通常の処理を実行します。
typescript // 通常の QA 処理を実行
const collection = await getOrCreateCollection();
const documents = await searchDocuments(collection, question, user, 3);
const answer = await generateAnswer(question, documents);
const response: QAResponse = {
answer,
sources: documents.map((doc, index) => ({
number: index + 1,
url: doc.sourceUrl,
relevance: (1 - doc.distance).toFixed(2)
}))
};
// キャッシュに保存
responseCache.set(cacheKey, response);
return response;
}
キャッシュにより、同じ質問への応答時間が大幅に短縮されます。
まとめ
本記事では、Ollama を活用した社内ナレッジ QA システムの構築方法を詳しく解説しました。
実装したシステムの主な特徴は以下の通りです:
| # | 特徴 | 実現方法 |
|---|---|---|
| 1 | ローカル実行 | Ollama によるプライバシー保護 |
| 2 | 出典リンク | ドキュメントメタデータの活用 |
| 3 | アクセス制御 | JWT 認証と権限ベースフィルタリング |
| 4 | 高速検索 | ベクトルデータベース(ChromaDB)の活用 |
| 5 | 信頼性向上 | RAG による正確な回答生成 |
導入のメリット
このシステムを導入することで、以下のメリットが得られます:
まず、業務効率が大幅に向上します。従業員は自然な質問文で社内ナレッジを検索でき、複数のドキュメントを探し回る時間が削減されますね。
次に、回答の信頼性が確保されます。出典リンクが明示されるため、回答の根拠を確認でき、誤った情報に基づく判断を防げるのです。
さらに、セキュリティが担保されます。アクセス制御により、権限のない情報への不正アクセスを防止し、機密情報の漏洩リスクが軽減されます。
今後の拡張案
さらなる機能向上のため、以下のような拡張が考えられます:
多言語対応では、英語や中国語などの社内ドキュメントにも対応することで、グローバル企業での活用が可能になるでしょう。
会話履歴の保持により、前の質問を踏まえた対話的な QA が実現し、より自然なやり取りができます。
フィードバック機能を実装すれば、回答の正確性をユーザーが評価でき、システムの継続的な改善につながりますね。
ファイルアップロード対応により、PDF や Word ファイルを直接アップロードして検索対象に追加できるようになります。
運用時の注意点
実際に運用する際は、以下の点に注意してください:
定期的なドキュメント更新により、常に最新の情報が提供されるよう、ドキュメントの追加・更新フローを整備しましょう。
モデルの選択では、Llama 2 以外にも、用途に応じて Mistral や Gemma などのモデルを検討すると良いでしょう。
リソース管理として、Ollama の実行には十分な CPU/メモリが必要なため、サーバースペックを適切に設定してください。
モニタリングの実施により、システムのパフォーマンスやエラー率を監視し、問題の早期発見に努めることが重要です。
Ollama を活用した社内ナレッジ QA システムは、適切な設計と実装により、業務効率化と情報セキュリティの両立を実現できる強力なツールとなります。本記事の内容を参考に、ぜひ御社の環境に合わせたシステム構築にチャレンジしてみてください。
関連リンク
article社内ナレッジ QA を Ollama で構築:出典リンクとアクセス制御で信頼性向上
articleはじめての Ollama:`ollama run llama3` でチャットを動かす 10 分チュートリアル
articleOllama で RAG を設計する:埋め込みモデル選定・再ランキング・出典表示の定石
articleOllama コマンドチートシート:`run`/`pull`/`list`/`ps`/`stop` の虎の巻
articleOllama のインストール完全ガイド:macOS/Linux/Windows(WSL)対応手順
articleOllama とは?ローカル LLM を数分で動かす仕組みと強みを徹底解説
articleTauri vs Electron vs Flutter デスクトップ:UX・DX・配布のしやすさ徹底比較
articleRuby と Python を徹底比較:スクリプト・Web・データ処理での得意分野
articleshadcn/ui のバンドルサイズ影響を検証:Tree Shaking・Code Split の実測データ
articleRedis Docker Compose 構築:永続化・監視・TLS まで 1 ファイルで
articleRemix を選ぶ基準:認証・API・CMS 観点での要件適合チェック
articleReact で管理画面を最短構築:テーブル・フィルタ・権限制御の実例
blogiPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
blogGoogleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
blog【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
blogGoogleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
blogPixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
blogフロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
review今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
reviewついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
review愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
review週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
review新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
review科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来