T-CREATOR

社内ナレッジ QA を Ollama で構築:出典リンクとアクセス制御で信頼性向上

社内ナレッジ QA を Ollama で構築:出典リンクとアクセス制御で信頼性向上

社内に蓄積された膨大なナレッジを効率的に活用したい、そんな要望をお持ちではありませんか?

近年、大規模言語モデル(LLM)を活用した QA システムの構築が注目されています。特に、ローカル環境で動作する Ollama を使えば、機密情報を外部に送信することなく、社内独自の質問応答システムを構築できるのです。

本記事では、Ollama を使った社内ナレッジ QA システムの構築方法を詳しく解説します。単なる回答生成だけでなく、回答の信頼性を高める「出典リンク」の提供と、情報漏洩を防ぐ「アクセス制御」の実装方法まで、実践的な内容をお届けしますね。

背景

社内ナレッジ管理の現状

多くの企業では、社内ドキュメント、議事録、技術仕様書などが様々な場所に分散して保管されています。これらの情報を必要な時に素早く見つけ出すことは、業務効率化の重要な課題となっていますね。

従来の検索システムでは、キーワードマッチングによる検索が中心でしたが、欲しい情報を的確に見つけるには適切なキーワードを知っている必要がありました。一方、最近注目されている LLM を活用した QA システムでは、自然な質問文から関連情報を抽出し、わかりやすく回答を生成できます。

Ollama とは

Ollama は、ローカル環境で大規模言語モデルを簡単に実行できるツールです。以下のような特徴があります:

#特徴説明
1ローカル実行サーバー不要でローカル環境で完結
2多様なモデルLlama 2、Mistral、Gemma など複数モデルに対応
3API サポート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

このアーキテクチャでは、ユーザーの質問を受け取ってから回答を返すまでの一連の流れで、認証・権限チェック・出典情報の付与を行っています。

ベクトルデータベースとアクセス制御

社内ドキュメントをベクトルデータベースに保存する際、各ドキュメントにメタデータとして権限情報を付与します。これにより、検索時にユーザーの権限に応じたフィルタリングが可能になるのです。

#メタデータ項目説明
1document_idドキュメント固有 ID"doc_20240115_001"
2source_url元文書の URL"https://wiki.company.com/..."
3allowed_rolesアクセス可能な役割["developer", "admin"]
4department所属部署"engineering"
5created_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

各パッケージの役割は以下の通りです:

#パッケージ用途
1expressWeb サーバーフレームワーク
2chromadbベクトルデータベース
3ollamaOllama との連携
4jsonwebtokenJWT 認証トークン処理

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 システムは、適切な設計と実装により、業務効率化と情報セキュリティの両立を実現できる強力なツールとなります。本記事の内容を参考に、ぜひ御社の環境に合わせたシステム構築にチャレンジしてみてください。

関連リンク