T-CREATOR

gpt-oss でナレッジ検索アシスタント:根拠表示・更新検知・検索ログ最適化

gpt-oss でナレッジ検索アシスタント:根拠表示・更新検知・検索ログ最適化

企業の知識管理において、蓄積された膨大な情報から必要な知識を迅速に引き出すことは、業務効率化の重要な課題です。GPT-OSS(GPT-based Open Source System)を活用することで、根拠を明示しながら回答する高精度なナレッジ検索アシスタントを構築できるようになりました。本記事では、根拠表示機能、ナレッジの更新検知、そして検索ログの最適化という 3 つの重要な機能を実装する方法を、初心者の方にもわかりやすく解説していきます。

背景

ナレッジ管理の現状と課題

従来の企業内ナレッジ管理システムでは、文書検索やキーワードマッチングが主流でした。しかし、これらの方式では以下のような問題がありました。

  • 検索キーワードの選定が難しく、求める情報にたどり着けない
  • 検索結果が大量に表示され、必要な情報を見つけ出すのに時間がかかる
  • 情報の信頼性や出典が不明確で、回答の根拠が示されない
  • ナレッジベースの更新に気づきにくく、古い情報を参照してしまう

GPT-OSS による解決アプローチ

GPT-OSS は、大規模言語モデル(LLM)とベクトル検索を組み合わせた、オープンソースのナレッジ管理フレームワークです。自然言語での質問に対して、AI が文脈を理解した上で適切な回答を生成してくれます。

以下の図は、GPT-OSS を用いたナレッジ検索の基本的な流れを示しています。

mermaidflowchart LR
    user["ユーザー"] -->|質問| assistant["検索アシスタント"]
    assistant -->|埋め込み生成| vectordb[("ベクトルDB")]
    vectordb -->|類似文書検索| assistant
    assistant -->|プロンプト生成| llm["LLM<br/>(GPT)"]
    llm -->|回答+根拠| assistant
    assistant -->|結果表示| user

このシステムでは、ユーザーの質問を理解し、関連する文書を検索した上で、根拠を示しながら回答を生成します。

GPT-OSS の主な特徴は以下の通りです。

  • セマンティック検索: キーワードではなく、意味的な類似性で文書を検索
  • 根拠の明示: 回答に使用した元文書を明確に提示
  • カスタマイズ性: オープンソースのため、独自の要件に合わせて拡張可能
  • プライバシー保護: オンプレミスやプライベートクラウドでの運用が可能

課題

ナレッジ検索アシスタントを実運用する上で、以下の 3 つの重要な課題があります。

課題 1:回答の信頼性確保

AI が生成した回答は、その根拠が不明確だと信頼性に欠けてしまいます。

特に業務利用では、間違った情報に基づいて判断すると、深刻な問題につながる可能性があります。そのため、回答がどの文書のどの部分から導き出されたのかを、ユーザーが確認できる仕組みが必要不可欠です。

課題 2:ナレッジの鮮度管理

企業のナレッジベースは日々更新されていきます。

新しいドキュメントが追加されたり、既存の文書が修正されたりした際に、検索アシスタントがそれらの変更を即座に反映できなければ、古い情報を提供し続けることになってしまいます。更新の検知と自動的なインデックス再構築が求められます。

課題 3:検索精度の継続的改善

ユーザーの検索パターンや求める情報の傾向は、時間とともに変化していきます。

検索ログを分析せずに放置すると、ユーザーのニーズとシステムの挙動がずれていき、検索精度が低下してしまいます。ログデータを活用した継続的な最適化の仕組みが必要です。

以下の図は、これらの課題がナレッジ検索システムに与える影響を示しています。

mermaidflowchart TD
    search["ナレッジ検索システム"]

    issue1["課題1:根拠不明"]
    issue2["課題2:古い情報"]
    issue3["課題3:精度低下"]

    impact1["信頼性の欠如"]
    impact2["誤った判断"]
    impact3["利用率の低下"]

    search --> issue1
    search --> issue2
    search --> issue3

    issue1 --> impact1
    issue2 --> impact2
    issue3 --> impact3

    impact1 --> result["システムの<br/>実用性低下"]
    impact2 --> result
    impact3 --> result

これらの課題を解決することで、信頼性が高く、常に最新の情報を提供できる実用的なナレッジ検索アシスタントを実現できます。

解決策

前述の 3 つの課題に対して、具体的な技術的アプローチを示します。

解決策 1:根拠表示機能の実装

回答とともに、参照元の文書情報を提示する仕組みを実装します。

ベクトル検索の結果として取得した文書のメタデータ(文書名、ページ番号、セクション名など)を、回答テキストと一緒に表示することで、ユーザーが元の情報源を確認できるようにします。さらに、引用箇所をハイライト表示したり、元文書へのリンクを提供したりすることで、より透明性の高いシステムを実現できます。

解決策 2:更新検知とインデックス再構築

ファイルシステムの監視や Webhook を活用して、ナレッジベースの変更を自動検知します。

変更が検知されたら、該当する文書のベクトル埋め込みを再生成し、ベクトルデータベースを更新します。これにより、常に最新の情報に基づいた検索が可能になります。大規模なナレッジベースでは、変更された部分のみを更新する差分更新の仕組みを導入することで、処理時間とコストを最適化できます。

解決策 3:検索ログの分析と最適化

検索クエリ、検索結果、ユーザーのフィードバックをログとして記録します。

これらのログを定期的に分析することで、検索精度の改善点を特定できます。例えば、ユーザーが何度も似た質問を繰り返している場合は、その分野のドキュメントが不足していることを示唆しています。また、検索結果が選ばれなかったケースを分析することで、ランキングアルゴリズムの調整ポイントが見えてきます。

以下の図は、これら 3 つの解決策を統合したシステムアーキテクチャを示しています。

mermaidflowchart TB
    subgraph frontend["フロントエンド層"]
        ui["検索UI"]
    end

    subgraph backend["バックエンド層"]
        api["API サーバー"]
        search_engine["検索エンジン"]
        logger["ログ記録"]
    end

    subgraph data["データ層"]
        vectordb[("ベクトルDB")]
        logdb[("ログDB")]
        knowledge["ナレッジ<br/>ベース"]
    end

    subgraph worker["ワーカー層"]
        watcher["ファイル監視"]
        indexer["インデックス更新"]
        analyzer["ログ分析"]
    end

    ui -->|質問| api
    api --> search_engine
    api --> logger

    search_engine <-->|検索| vectordb
    logger -->|記録| logdb

    knowledge -->|監視| watcher
    watcher -->|変更通知| indexer
    indexer -->|更新| vectordb

    logdb -->|データ| analyzer
    analyzer -->|改善提案| search_engine

    search_engine -->|回答+根拠| api
    api -->|結果| ui

このアーキテクチャでは、フロントエンドからの検索リクエストを処理しながら、バックグラウンドでナレッジの更新検知とログ分析を継続的に実行します。

具体例

実際のコードを使って、3 つの機能を実装していきましょう。TypeScript と Next.js を使用した実装例を示します。

システム構成と前提条件

今回の実装では、以下の技術スタックを使用します。

#技術要素用途バージョン
1Next.jsフロントエンド・API 開発14.x
2TypeScript型安全な開発5.x
3OpenAI APILLM(GPT-4)との連携latest
4Pineconeベクトルデータベースlatest
5Prismaログデータの管理5.x
6chokidarファイル監視3.x

プロジェクトのセットアップ

まず、必要なパッケージをインストールします。

typescript// package.jsonへの追加(抜粋)

Yarn を使用してパッケージをインストールしましょう。

bashyarn add next react react-dom typescript
yarn add openai @pinecone-database/pinecone
yarn add @prisma/client chokidar
yarn add -D @types/node @types/react prisma

次に、TypeScript の型定義を準備します。

typescript// types/knowledge.ts
// ナレッジ検索に関する型定義

// 検索結果の型
export interface SearchResult {
  id: string;
  content: string;
  score: number;
  metadata: DocumentMetadata;
}

// 文書メタデータの型
export interface DocumentMetadata {
  title: string;
  source: string;
  page?: number;
  section?: string;
  updatedAt: string;
}
typescript// types/knowledge.ts(続き)
// 検索レスポンスの型

// AIからの回答
export interface AssistantResponse {
  answer: string;
  sources: SourceReference[];
  confidence: number;
}

// 根拠となる情報源
export interface SourceReference {
  documentId: string;
  title: string;
  excerpt: string;
  url?: string;
  relevance: number;
}

機能 1:根拠表示機能の実装

検索結果に根拠を付加する仕組みを実装していきます。

typescript// lib/search/knowledge-search.ts
// ナレッジ検索のコア機能

import { OpenAI } from 'openai';
import { Pinecone } from '@pinecone-database/pinecone';
import type {
  SearchResult,
  AssistantResponse,
} from '@/types/knowledge';

// OpenAI クライアントの初期化
const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
});

// Pinecone クライアントの初期化
const pinecone = new Pinecone({
  apiKey: process.env.PINECONE_API_KEY || '',
});

次に、質問文をベクトルに変換する関数を実装します。

typescript// lib/search/knowledge-search.ts(続き)
// 質問文のベクトル化

/**
 * 質問文をベクトル埋め込みに変換
 * @param query - ユーザーの質問文
 * @returns ベクトル配列
 */
async function generateEmbedding(
  query: string
): Promise<number[]> {
  const response = await openai.embeddings.create({
    model: 'text-embedding-3-small',
    input: query,
  });

  return response.data[0].embedding;
}

ベクトルデータベースから類似文書を検索する関数を実装します。

typescript// lib/search/knowledge-search.ts(続き)
// ベクトル検索の実行

/**
 * ベクトルDBから類似文書を検索
 * @param embedding - 質問のベクトル
 * @param topK - 取得する文書数
 * @returns 検索結果の配列
 */
async function searchSimilarDocuments(
  embedding: number[],
  topK: number = 5
): Promise<SearchResult[]> {
  const index = pinecone.index(
    process.env.PINECONE_INDEX_NAME || ''
  );

  const queryResponse = await index.query({
    vector: embedding,
    topK,
    includeMetadata: true,
  });

  // Pineconeの結果を統一型に変換
  return queryResponse.matches.map((match) => ({
    id: match.id,
    content: (match.metadata?.content as string) || '',
    score: match.score || 0,
    metadata: {
      title: (match.metadata?.title as string) || '',
      source: (match.metadata?.source as string) || '',
      page: match.metadata?.page as number | undefined,
      section: match.metadata?.section as
        | string
        | undefined,
      updatedAt:
        (match.metadata?.updatedAt as string) || '',
    },
  }));
}

検索結果を元に LLM で回答を生成し、根拠を付加する関数を実装します。

typescript// lib/search/knowledge-search.ts(続き)
// LLMによる回答生成と根拠付け

/**
 * LLMで回答を生成し、根拠情報を付加
 * @param query - ユーザーの質問
 * @param documents - 検索された関連文書
 * @returns 回答と根拠のセット
 */
async function generateAnswerWithSources(
  query: string,
  documents: SearchResult[]
): Promise<AssistantResponse> {
  // コンテキストの構築
  const context = documents
    .map((doc, index) => {
      return `[文書${index + 1}] ${
        doc.metadata.title
      }\n出典: ${doc.metadata.source}\n内容: ${
        doc.content
      }`;
    })
    .join('\n\n');

  // プロンプトの構築
  const prompt = `
以下の文書を参考に、質問に回答してください。
回答には必ず参照した文書番号を明記してください。

質問: ${query}

参考文書:
${context}

回答形式:
- 簡潔で正確な回答を提供
- 参照した文書を [文書1] のように明記
- 確信度が低い場合はその旨を伝える
`;

  const completion = await openai.chat.completions.create({
    model: 'gpt-4',
    messages: [
      {
        role: 'system',
        content:
          'あなたは企業のナレッジベースに基づいて正確な情報を提供するアシスタントです。',
      },
      {
        role: 'user',
        content: prompt,
      },
    ],
    temperature: 0.3, // 正確性を重視
  });

  const answer =
    completion.choices[0].message.content || '';

  // 根拠情報の構築
  const sources = documents.map((doc) => ({
    documentId: doc.id,
    title: doc.metadata.title,
    excerpt: doc.content.substring(0, 200) + '...',
    url: doc.metadata.source,
    relevance: doc.score,
  }));

  return {
    answer,
    sources,
    confidence: calculateConfidence(documents),
  };
}

確信度を計算する補助関数を実装します。

typescript// lib/search/knowledge-search.ts(続き)
// 確信度の計算

/**
 * 検索結果のスコアから確信度を計算
 * @param documents - 検索結果
 * @returns 0-1の確信度
 */
function calculateConfidence(
  documents: SearchResult[]
): number {
  if (documents.length === 0) return 0;

  // トップスコアの平均を確信度とする
  const topScores = documents
    .slice(0, 3)
    .map((doc) => doc.score);
  const avgScore =
    topScores.reduce((sum, score) => sum + score, 0) /
    topScores.length;

  return Math.min(avgScore, 1.0);
}

これまでの関数を統合したメイン検索関数を実装します。

typescript// lib/search/knowledge-search.ts(続き)
// メイン検索関数

/**
 * ナレッジ検索のメイン処理
 * @param query - ユーザーの質問
 * @returns 回答と根拠情報
 */
export async function searchKnowledge(
  query: string
): Promise<AssistantResponse> {
  // 1. 質問をベクトル化
  const embedding = await generateEmbedding(query);

  // 2. 類似文書を検索
  const documents = await searchSimilarDocuments(embedding);

  // 3. LLMで回答生成+根拠付け
  const response = await generateAnswerWithSources(
    query,
    documents
  );

  return response;
}

機能 2:更新検知とインデックス再構築

ナレッジベースの変更を監視し、自動的にインデックスを更新する仕組みを実装します。

typescript// lib/indexing/file-watcher.ts
// ファイル監視の実装

import chokidar from 'chokidar';
import path from 'path';
import { indexDocument, deleteDocument } from './indexer';

// 監視対象のディレクトリ
const KNOWLEDGE_BASE_DIR =
  process.env.KNOWLEDGE_BASE_DIR || './knowledge';

// 対象ファイルの拡張子
const SUPPORTED_EXTENSIONS = ['.md', '.txt', '.pdf'];

ファイル監視を開始する関数を実装します。

typescript// lib/indexing/file-watcher.ts(続き)
// ファイル監視の開始

/**
 * ナレッジベースディレクトリの監視を開始
 */
export function startWatching(): void {
  console.log(
    `ナレッジベースの監視を開始: ${KNOWLEDGE_BASE_DIR}`
  );

  const watcher = chokidar.watch(KNOWLEDGE_BASE_DIR, {
    ignored: /(^|[\/\\])\../, // 隠しファイルを無視
    persistent: true,
    ignoreInitial: false, // 初回スキャンを実行
  });

  // ファイル追加時の処理
  watcher.on('add', async (filePath) => {
    if (isSupportedFile(filePath)) {
      console.log(`新規ファイル検知: ${filePath}`);
      await handleFileAdded(filePath);
    }
  });

  // ファイル変更時の処理
  watcher.on('change', async (filePath) => {
    if (isSupportedFile(filePath)) {
      console.log(`ファイル変更検知: ${filePath}`);
      await handleFileChanged(filePath);
    }
  });

  // ファイル削除時の処理
  watcher.on('unlink', async (filePath) => {
    if (isSupportedFile(filePath)) {
      console.log(`ファイル削除検知: ${filePath}`);
      await handleFileDeleted(filePath);
    }
  });
}

ファイル形式をチェックする補助関数を実装します。

typescript// lib/indexing/file-watcher.ts(続き)
// サポート対象ファイルの判定

/**
 * ファイルがサポート対象かチェック
 * @param filePath - ファイルパス
 * @returns サポート対象ならtrue
 */
function isSupportedFile(filePath: string): boolean {
  const ext = path.extname(filePath).toLowerCase();
  return SUPPORTED_EXTENSIONS.includes(ext);
}

各イベントに対するハンドラー関数を実装します。

typescript// lib/indexing/file-watcher.ts(続き)
// イベントハンドラーの実装

/**
 * ファイル追加時の処理
 */
async function handleFileAdded(
  filePath: string
): Promise<void> {
  try {
    await indexDocument(filePath);
    console.log(`インデックス作成完了: ${filePath}`);
  } catch (error) {
    console.error(
      `インデックス作成エラー: ${filePath}`,
      error
    );
  }
}

/**
 * ファイル変更時の処理
 */
async function handleFileChanged(
  filePath: string
): Promise<void> {
  try {
    // 既存のインデックスを削除してから再作成
    await deleteDocument(filePath);
    await indexDocument(filePath);
    console.log(`インデックス更新完了: ${filePath}`);
  } catch (error) {
    console.error(
      `インデックス更新エラー: ${filePath}`,
      error
    );
  }
}

/**
 * ファイル削除時の処理
 */
async function handleFileDeleted(
  filePath: string
): Promise<void> {
  try {
    await deleteDocument(filePath);
    console.log(`インデックス削除完了: ${filePath}`);
  } catch (error) {
    console.error(
      `インデックス削除エラー: ${filePath}`,
      error
    );
  }
}

文書のインデックス化処理を実装します。

typescript// lib/indexing/indexer.ts
// インデックス作成・更新・削除の実装

import fs from 'fs/promises';
import path from 'path';
import { OpenAI } from 'openai';
import { Pinecone } from '@pinecone-database/pinecone';

const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
});

const pinecone = new Pinecone({
  apiKey: process.env.PINECONE_API_KEY || '',
});

/**
 * 文書を読み込んでベクトルDBにインデックス
 * @param filePath - ファイルパス
 */
export async function indexDocument(
  filePath: string
): Promise<void> {
  // ファイル内容の読み込み
  const content = await fs.readFile(filePath, 'utf-8');

  // メタデータの抽出
  const metadata = extractMetadata(filePath, content);

  // チャンク分割(長い文書を分割)
  const chunks = splitIntoChunks(content, 500);

  // 各チャンクをベクトル化してインデックス
  const index = pinecone.index(
    process.env.PINECONE_INDEX_NAME || ''
  );

  for (let i = 0; i < chunks.length; i++) {
    const chunk = chunks[i];

    // ベクトル化
    const embedding = await generateEmbedding(chunk);

    // ユニークIDの生成
    const id = generateDocumentId(filePath, i);

    // Pineconeへ保存
    await index.upsert([
      {
        id,
        values: embedding,
        metadata: {
          ...metadata,
          content: chunk,
          chunkIndex: i,
        },
      },
    ]);
  }
}

メタデータ抽出とチャンク分割の補助関数を実装します。

typescript// lib/indexing/indexer.ts(続き)
// 補助関数の実装

/**
 * ファイルからメタデータを抽出
 */
function extractMetadata(
  filePath: string,
  content: string
) {
  const fileName = path.basename(filePath);
  const title = extractTitle(content) || fileName;

  return {
    title,
    source: filePath,
    updatedAt: new Date().toISOString(),
  };
}

/**
 * タイトルを抽出(Markdownの場合は# 見出し)
 */
function extractTitle(content: string): string | null {
  const match = content.match(/^#\s+(.+)$/m);
  return match ? match[1] : null;
}

/**
 * 文書をチャンクに分割
 * @param text - 元テキスト
 * @param maxLength - チャンクの最大長
 */
function splitIntoChunks(
  text: string,
  maxLength: number
): string[] {
  const chunks: string[] = [];
  const paragraphs = text.split('\n\n');

  let currentChunk = '';

  for (const paragraph of paragraphs) {
    if ((currentChunk + paragraph).length > maxLength) {
      if (currentChunk) {
        chunks.push(currentChunk.trim());
      }
      currentChunk = paragraph;
    } else {
      currentChunk +=
        (currentChunk ? '\n\n' : '') + paragraph;
    }
  }

  if (currentChunk) {
    chunks.push(currentChunk.trim());
  }

  return chunks;
}

ベクトル化と削除の処理を実装します。

typescript// lib/indexing/indexer.ts(続き)
// ベクトル化と削除

/**
 * テキストをベクトル埋め込みに変換
 */
async function generateEmbedding(
  text: string
): Promise<number[]> {
  const response = await openai.embeddings.create({
    model: 'text-embedding-3-small',
    input: text,
  });

  return response.data[0].embedding;
}

/**
 * ドキュメントIDの生成
 */
function generateDocumentId(
  filePath: string,
  chunkIndex: number
): string {
  const normalized = filePath.replace(/[^a-zA-Z0-9]/g, '_');
  return `${normalized}_chunk_${chunkIndex}`;
}

/**
 * 文書をインデックスから削除
 * @param filePath - ファイルパス
 */
export async function deleteDocument(
  filePath: string
): Promise<void> {
  const index = pinecone.index(
    process.env.PINECONE_INDEX_NAME || ''
  );

  // このファイルに関連する全チャンクを削除
  // Pineconeではプレフィックスマッチで削除可能
  const prefix = generateDocumentId(filePath, 0).split(
    '_chunk_'
  )[0];

  // 実装注: Pineconeの実際のAPIに応じて調整が必要
  // ここでは概念的な実装を示す
  await index.deleteMany({
    filter: {
      source: { $eq: filePath },
    },
  });
}

機能 3:検索ログの最適化

検索ログを記録し、分析する仕組みを実装します。まず Prisma スキーマを定義します。

prisma// prisma/schema.prisma
// ログデータベースのスキーマ定義

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

// 検索ログ
model SearchLog {
  id           String   @id @default(cuid())
  query        String   // 検索クエリ
  userId       String?  // ユーザーID(任意)
  resultCount  Int      // 結果件数
  topScore     Float    // 最高スコア
  confidence   Float    // 確信度
  responseTime Int      // 応答時間(ミリ秒)
  createdAt    DateTime @default(now())

  // 関連するフィードバック
  feedback     SearchFeedback?

  @@index([createdAt])
  @@index([userId])
}

フィードバックとクエリパターンのスキーマを定義します。

prisma// prisma/schema.prisma(続き)
// フィードバックとクエリパターン

// ユーザーフィードバック
model SearchFeedback {
  id         String   @id @default(cuid())
  searchLogId String  @unique
  searchLog  SearchLog @relation(fields: [searchLogId], references: [id])
  rating     Int      // 1-5の評価
  helpful    Boolean  // 役に立ったか
  comment    String?  // コメント
  createdAt  DateTime @default(now())

  @@index([rating])
  @@index([helpful])
}

// クエリパターン分析結果
model QueryPattern {
  id            String   @id @default(cuid())
  pattern       String   @unique // クエリのパターン
  frequency     Int      // 出現頻度
  avgConfidence Float    // 平均確信度
  avgRating     Float?   // 平均評価
  lastSeen      DateTime // 最終出現日時

  @@index([frequency])
  @@index([lastSeen])
}

Prisma クライアントを初期化します。

typescript// lib/db/prisma.ts
// Prismaクライアントの初期化

import { PrismaClient } from '@prisma/client';

// グローバルな型定義
declare global {
  var prisma: PrismaClient | undefined;
}

// シングルトンパターンでPrismaクライアントを作成
export const prisma = global.prisma || new PrismaClient();

if (process.env.NODE_ENV !== 'production') {
  global.prisma = prisma;
}

検索ログを記録する関数を実装します。

typescript// lib/logging/search-logger.ts
// 検索ログの記録

import { prisma } from '@/lib/db/prisma';
import type { AssistantResponse } from '@/types/knowledge';

/**
 * 検索ログを記録
 * @param query - 検索クエリ
 * @param response - アシスタントの応答
 * @param responseTime - 応答時間(ミリ秒)
 * @param userId - ユーザーID(オプション)
 */
export async function logSearch(
  query: string,
  response: AssistantResponse,
  responseTime: number,
  userId?: string
): Promise<string> {
  const log = await prisma.searchLog.create({
    data: {
      query,
      userId,
      resultCount: response.sources.length,
      topScore: response.sources[0]?.relevance || 0,
      confidence: response.confidence,
      responseTime,
    },
  });

  return log.id;
}

フィードバックを記録する関数を実装します。

typescript// lib/logging/search-logger.ts(続き)
// フィードバックの記録

/**
 * ユーザーフィードバックを記録
 * @param searchLogId - 検索ログID
 * @param rating - 評価(1-5)
 * @param helpful - 役に立ったか
 * @param comment - コメント
 */
export async function logFeedback(
  searchLogId: string,
  rating: number,
  helpful: boolean,
  comment?: string
): Promise<void> {
  await prisma.searchFeedback.create({
    data: {
      searchLogId,
      rating,
      helpful,
      comment,
    },
  });
}

ログ分析の関数を実装します。

typescript// lib/logging/log-analyzer.ts
// ログ分析の実装

import { prisma } from '@/lib/db/prisma';

/**
 * クエリパターンを分析・更新
 * 定期実行を想定(例: 1日1回)
 */
export async function analyzeQueryPatterns(): Promise<void> {
  // 過去7日間のログを取得
  const sevenDaysAgo = new Date();
  sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);

  const logs = await prisma.searchLog.findMany({
    where: {
      createdAt: {
        gte: sevenDaysAgo,
      },
    },
    include: {
      feedback: true,
    },
  });

  // クエリをグループ化して分析
  const patternMap = new Map<
    string,
    {
      count: number;
      totalConfidence: number;
      totalRating: number;
      ratingCount: number;
      lastSeen: Date;
    }
  >();

  for (const log of logs) {
    // クエリを正規化(小文字化、記号除去など)
    const normalized = normalizeQuery(log.query);

    const existing = patternMap.get(normalized) || {
      count: 0,
      totalConfidence: 0,
      totalRating: 0,
      ratingCount: 0,
      lastSeen: log.createdAt,
    };

    existing.count++;
    existing.totalConfidence += log.confidence;

    if (log.feedback) {
      existing.totalRating += log.feedback.rating;
      existing.ratingCount++;
    }

    if (log.createdAt > existing.lastSeen) {
      existing.lastSeen = log.createdAt;
    }

    patternMap.set(normalized, existing);
  }

  // パターンをDBに保存
  for (const [pattern, stats] of patternMap) {
    await prisma.queryPattern.upsert({
      where: { pattern },
      update: {
        frequency: stats.count,
        avgConfidence: stats.totalConfidence / stats.count,
        avgRating:
          stats.ratingCount > 0
            ? stats.totalRating / stats.ratingCount
            : null,
        lastSeen: stats.lastSeen,
      },
      create: {
        pattern,
        frequency: stats.count,
        avgConfidence: stats.totalConfidence / stats.count,
        avgRating:
          stats.ratingCount > 0
            ? stats.totalRating / stats.ratingCount
            : null,
        lastSeen: stats.lastSeen,
      },
    });
  }
}

クエリ正規化と問題検出の関数を実装します。

typescript// lib/logging/log-analyzer.ts(続き)
// 補助関数と問題検出

/**
 * クエリを正規化
 * @param query - 元のクエリ
 * @returns 正規化されたクエリ
 */
function normalizeQuery(query: string): string {
  return query
    .toLowerCase()
    .replace(/[^\w\s]/g, '') // 記号除去
    .trim();
}

/**
 * 問題のあるクエリパターンを検出
 * @returns 問題のあるパターンのリスト
 */
export async function detectProblematicPatterns() {
  // 確信度が低いクエリパターン
  const lowConfidencePatterns =
    await prisma.queryPattern.findMany({
      where: {
        avgConfidence: {
          lt: 0.5,
        },
        frequency: {
          gte: 3, // 3回以上出現
        },
      },
      orderBy: {
        frequency: 'desc',
      },
    });

  // 評価が低いクエリパターン
  const lowRatingPatterns =
    await prisma.queryPattern.findMany({
      where: {
        avgRating: {
          lt: 3.0,
        },
        frequency: {
          gte: 3,
        },
      },
      orderBy: {
        frequency: 'desc',
      },
    });

  return {
    lowConfidence: lowConfidencePatterns,
    lowRating: lowRatingPatterns,
  };
}

API エンドポイントの実装

フロントエンドから呼び出す API エンドポイントを実装します。

typescript// app/api/search/route.ts
// 検索APIエンドポイント

import { NextRequest, NextResponse } from 'next/server';
import { searchKnowledge } from '@/lib/search/knowledge-search';
import { logSearch } from '@/lib/logging/search-logger';

/**
 * POST /api/search
 * ナレッジ検索を実行
 */
export async function POST(request: NextRequest) {
  try {
    const { query, userId } = await request.json();

    if (!query || typeof query !== 'string') {
      return NextResponse.json(
        { error: 'クエリが必要です' },
        { status: 400 }
      );
    }

    // 応答時間の計測開始
    const startTime = Date.now();

    // 検索実行
    const response = await searchKnowledge(query);

    // 応答時間の計測終了
    const responseTime = Date.now() - startTime;

    // ログ記録
    const logId = await logSearch(
      query,
      response,
      responseTime,
      userId
    );

    return NextResponse.json({
      ...response,
      logId, // フィードバック用のログID
      responseTime,
    });
  } catch (error) {
    console.error('検索エラー:', error);
    return NextResponse.json(
      { error: '検索中にエラーが発生しました' },
      { status: 500 }
    );
  }
}

フィードバック記録用の API エンドポイントを実装します。

typescript// app/api/feedback/route.ts
// フィードバックAPIエンドポイント

import { NextRequest, NextResponse } from 'next/server';
import { logFeedback } from '@/lib/logging/search-logger';

/**
 * POST /api/feedback
 * 検索結果へのフィードバックを記録
 */
export async function POST(request: NextRequest) {
  try {
    const { logId, rating, helpful, comment } =
      await request.json();

    if (!logId || typeof rating !== 'number') {
      return NextResponse.json(
        { error: 'ログIDと評価が必要です' },
        { status: 400 }
      );
    }

    if (rating < 1 || rating > 5) {
      return NextResponse.json(
        { error: '評価は1-5の範囲で指定してください' },
        { status: 400 }
      );
    }

    await logFeedback(logId, rating, helpful, comment);

    return NextResponse.json({ success: true });
  } catch (error) {
    console.error('フィードバック記録エラー:', error);
    return NextResponse.json(
      {
        error:
          'フィードバックの記録中にエラーが発生しました',
      },
      { status: 500 }
    );
  }
}

フロントエンド実装

検索 UI コンポーネントを実装します。

typescript// components/KnowledgeSearchUI.tsx
// 検索UIコンポーネント

'use client';

import { useState } from 'react';
import type { AssistantResponse } from '@/types/knowledge';

export default function KnowledgeSearchUI() {
  const [query, setQuery] = useState('');
  const [response, setResponse] =
    useState<AssistantResponse | null>(null);
  const [logId, setLogId] = useState<string | null>(null);
  const [loading, setLoading] = useState(false);

  // 検索実行
  const handleSearch = async () => {
    if (!query.trim()) return;

    setLoading(true);

    try {
      const res = await fetch('/api/search', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ query }),
      });

      const data = await res.json();
      setResponse(data);
      setLogId(data.logId);
    } catch (error) {
      console.error('検索エラー:', error);
      alert('検索中にエラーが発生しました');
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className='max-w-4xl mx-auto p-6'>
      {/* 検索フォーム */}
      <div className='mb-8'>
        <div className='flex gap-2'>
          <input
            type='text'
            value={query}
            onChange={(e) => setQuery(e.target.value)}
            onKeyDown={(e) =>
              e.key === 'Enter' && handleSearch()
            }
            placeholder='質問を入力してください...'
            className='flex-1 px-4 py-2 border rounded-lg'
            disabled={loading}
          />
          <button
            onClick={handleSearch}
            disabled={loading}
            className='px-6 py-2 bg-blue-600 text-white rounded-lg'
          >
            {loading ? '検索中...' : '検索'}
          </button>
        </div>
      </div>

      {/* 検索結果の表示は次のブロックで実装 */}
    </div>
  );
}

検索結果と根拠を表示する部分を実装します。

typescript// components/KnowledgeSearchUI.tsx(続き)
// 検索結果表示部分

export default function KnowledgeSearchUI() {
  // ... 前のコードに続けて ...

  return (
    <div className='max-w-4xl mx-auto p-6'>
      {/* ... 検索フォーム ... */}

      {/* 検索結果表示 */}
      {response && (
        <div className='space-y-6'>
          {/* 回答セクション */}
          <div className='bg-white p-6 rounded-lg shadow'>
            <h2 className='text-xl font-bold mb-4'>回答</h2>
            <p className='text-gray-800 whitespace-pre-wrap'>
              {response.answer}
            </p>

            {/* 確信度表示 */}
            <div className='mt-4 flex items-center gap-2'>
              <span className='text-sm text-gray-600'>
                確信度:
              </span>
              <div className='flex-1 bg-gray-200 rounded-full h-2'>
                <div
                  className='bg-blue-600 h-2 rounded-full'
                  style={{
                    width: `${response.confidence * 100}%`,
                  }}
                />
              </div>
              <span className='text-sm font-medium'>
                {(response.confidence * 100).toFixed(0)}%
              </span>
            </div>
          </div>

          {/* 根拠(情報源)セクション */}
          <div className='bg-white p-6 rounded-lg shadow'>
            <h2 className='text-xl font-bold mb-4'>
              参照した情報源({response.sources.length}件)
            </h2>

            <div className='space-y-4'>
              {response.sources.map((source, index) => (
                <div
                  key={source.documentId}
                  className='border-l-4 border-blue-500 pl-4 py-2'
                >
                  <div className='flex items-start justify-between'>
                    <div className='flex-1'>
                      <h3 className='font-semibold text-gray-900'>
                        [{index + 1}] {source.title}
                      </h3>
                      <p className='text-sm text-gray-600 mt-1'>
                        {source.excerpt}
                      </p>
                      {source.url && (
                        <a
                          href={source.url}
                          className='text-sm text-blue-600 hover:underline mt-2 inline-block'
                          target='_blank'
                          rel='noopener noreferrer'
                        >
                          元の文書を表示
                        </a>
                      )}
                    </div>
                    <div className='ml-4 text-sm text-gray-500'>
                      関連度:{' '}
                      {(source.relevance * 100).toFixed(0)}%
                    </div>
                  </div>
                </div>
              ))}
            </div>
          </div>

          {/* フィードバックセクションは次のブロックで実装 */}
        </div>
      )}
    </div>
  );
}

フィードバック機能を実装します。

typescript// components/KnowledgeSearchUI.tsx(続き)
// フィードバック機能

export default function KnowledgeSearchUI() {
  // ... 既存のstate ...
  const [feedbackSubmitted, setFeedbackSubmitted] =
    useState(false);

  // フィードバック送信
  const handleFeedback = async (
    rating: number,
    helpful: boolean
  ) => {
    if (!logId) return;

    try {
      await fetch('/api/feedback', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ logId, rating, helpful }),
      });

      setFeedbackSubmitted(true);
    } catch (error) {
      console.error('フィードバック送信エラー:', error);
    }
  };

  return (
    <div className='max-w-4xl mx-auto p-6'>
      {/* ... 検索フォームと結果表示 ... */}

      {response && !feedbackSubmitted && (
        <div className='bg-white p-6 rounded-lg shadow'>
          <h2 className='text-xl font-bold mb-4'>
            この回答は役に立ちましたか?
          </h2>

          <div className='flex gap-4'>
            <button
              onClick={() => handleFeedback(5, true)}
              className='px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700'
            >
              はい、役に立ちました
            </button>
            <button
              onClick={() => handleFeedback(2, false)}
              className='px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700'
            >
              いいえ、役に立ちませんでした
            </button>
          </div>
        </div>
      )}

      {feedbackSubmitted && (
        <div className='bg-green-50 p-4 rounded-lg text-green-800'>
          フィードバックをありがとうございました!
        </div>
      )}
    </div>
  );
}

バックグラウンドタスクの設定

定期的なログ分析を実行するスクリプトを作成します。

typescript// scripts/analyze-logs.ts
// 定期実行するログ分析スクリプト

import {
  analyzeQueryPatterns,
  detectProblematicPatterns,
} from '@/lib/logging/log-analyzer';

/**
 * ログ分析のメイン処理
 */
async function main() {
  console.log('ログ分析を開始します...');

  try {
    // クエリパターンの分析
    await analyzeQueryPatterns();
    console.log('✓ クエリパターンの分析が完了しました');

    // 問題のあるパターンの検出
    const problems = await detectProblematicPatterns();

    if (problems.lowConfidence.length > 0) {
      console.log('\n⚠ 確信度が低いクエリパターン:');
      problems.lowConfidence.forEach((pattern) => {
        console.log(
          `  - "${pattern.pattern}" (${
            pattern.frequency
          }回, 確信度: ${(
            pattern.avgConfidence * 100
          ).toFixed(0)}%)`
        );
      });
    }

    if (problems.lowRating.length > 0) {
      console.log('\n⚠ 評価が低いクエリパターン:');
      problems.lowRating.forEach((pattern) => {
        console.log(
          `  - "${pattern.pattern}" (${
            pattern.frequency
          }回, 評価: ${pattern.avgRating?.toFixed(1)})`
        );
      });
    }

    console.log('\nログ分析が完了しました');
  } catch (error) {
    console.error(
      'ログ分析中にエラーが発生しました:',
      error
    );
    process.exit(1);
  }
}

main();

ファイル監視を起動するスクリプトを作成します。

typescript// scripts/start-watcher.ts
// ファイル監視の起動スクリプト

import { startWatching } from '@/lib/indexing/file-watcher';

console.log('ナレッジベースのファイル監視を起動します...');

try {
  startWatching();
  console.log('✓ ファイル監視が開始されました');
  console.log('終了するには Ctrl+C を押してください');
} catch (error) {
  console.error('ファイル監視の起動に失敗しました:', error);
  process.exit(1);
}

package.json にスクリプトコマンドを追加します。

json{
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "watch": "ts-node scripts/start-watcher.ts",
    "analyze-logs": "ts-node scripts/analyze-logs.ts"
  }
}

環境変数の設定

必要な環境変数を.env ファイルに設定します。

bash# .env
# OpenAI API設定
OPENAI_API_KEY=your_openai_api_key_here

# Pinecone設定
PINECONE_API_KEY=your_pinecone_api_key_here
PINECONE_INDEX_NAME=knowledge-base

# データベース設定
DATABASE_URL=postgresql://user:password@localhost:5432/knowledge_db

# ナレッジベースディレクトリ
KNOWLEDGE_BASE_DIR=./knowledge

システムの起動と運用

これまでの実装を統合して、システムを起動する手順を説明します。

まず、データベースのマイグレーションを実行します。

bash# Prismaのマイグレーション実行
yarn prisma migrate dev --name init

# Prismaクライアントの生成
yarn prisma generate

次に、ナレッジベースの初期インデックスを作成します。

bash# ファイル監視を起動(別のターミナルで実行)
yarn watch

開発サーバーを起動します。

bash# Next.jsアプリケーションの起動
yarn dev

定期的なログ分析を cron などで設定します。

bash# crontabの例(毎日午前2時に実行)
0 2 * * * cd /path/to/project && yarn analyze-logs

これで、根拠表示、更新検知、検索ログ最適化の 3 つの機能を持つナレッジ検索アシスタントが完成しました。

以下の図は、実装したシステム全体の動作フローを示しています。

mermaidsequenceDiagram
    actor User as ユーザー
    participant UI as フロントエンドUI
    participant API as 検索API
    participant Search as 検索エンジン
    participant VectorDB as ベクトルDB
    participant LLM as GPT-4
    participant Logger as ログ記録
    participant DB as ログDB

    User->>UI: 質問を入力
    UI->>API: POST /api/search
    API->>Search: searchKnowledge()

    Search->>Search: 質問をベクトル化
    Search->>VectorDB: 類似文書検索
    VectorDB-->>Search: 関連文書を返却

    Search->>LLM: プロンプト送信
    LLM-->>Search: 回答+根拠を生成

    Search-->>API: 回答+根拠を返却
    API->>Logger: ログ記録
    Logger->>DB: 検索ログ保存

    API-->>UI: 結果表示
    UI-->>User: 回答+根拠を表示

    User->>UI: フィードバック
    UI->>API: POST /api/feedback
    API->>DB: フィードバック保存

このシーケンス図から、ユーザーの質問から回答表示、フィードバック収集までの一連の流れが理解できます。

まとめ

本記事では、GPT-OSS を活用したナレッジ検索アシスタントの実装方法を、3 つの重要な機能に焦点を当てて解説しました。

実装した 3 つの主要機能

根拠表示機能により、AI の回答に対する信頼性が大幅に向上します。ユーザーは提示された情報源を確認することで、回答の妥当性を自ら判断できるようになりました。

更新検知機能により、ナレッジベースの変更を自動的に検知し、インデックスを最新の状態に保つことができます。これにより、常に正確で最新の情報を提供できる検索システムを実現できました。

検索ログ最適化により、ユーザーの検索パターンを分析し、継続的にシステムを改善できます。問題のあるクエリパターンを特定することで、ナレッジベースの充実や検索アルゴリズムの調整に活かせます。

システムの拡張可能性

今回実装した基本システムは、以下のような拡張が可能です。

  • 多言語対応: 埋め込みモデルを多言語対応のものに変更することで、複数言語での検索が可能
  • 権限管理: ユーザーごとにアクセス可能なドキュメントを制限する機能
  • カスタムフィルタ: 日付範囲や文書タイプによる検索結果のフィルタリング
  • A/B テスト: 異なるプロンプトやモデルの効果を比較検証
  • リアルタイム通知: 重要なナレッジが更新された際の通知機能

運用上の注意点

実運用では、以下の点に注意が必要です。

コストの管理には十分な配慮が必要です。OpenAI API やベクトルデータベースの利用料金は、使用量に応じて増加します。大規模なナレッジベースを扱う場合は、キャッシュ機構の導入や、埋め込み生成の最適化を検討しましょう。

セキュリティとプライバシーの保護も重要です。機密情報を含むドキュメントを扱う場合は、適切なアクセス制御と暗号化を実装する必要があります。また、検索ログに個人情報が含まれる可能性がある場合は、適切な匿名化処理を施しましょう。

パフォーマンスの最適化も忘れてはいけません。検索応答時間が長すぎると、ユーザー体験が損なわれます。ベクトル検索のチューニングや、頻繁に検索されるクエリのキャッシュなどを検討してください。

今後の技術トレンド

ナレッジ検索の分野は急速に進化しています。

RAG(Retrieval-Augmented Generation)技術は、より高度な文脈理解と回答生成を可能にします。ファインチューニングされた専門モデルとの組み合わせにより、特定のドメインに特化した高精度な検索システムの構築が期待されます。

マルチモーダル検索も注目されています。テキストだけでなく、画像や音声、動画などの様々な形式のナレッジを統合的に検索できるシステムが実現しつつあります。

エッジコンピューティングとの融合により、より高速でプライベートなナレッジ検索が可能になります。オンデバイスで動作する小型の言語モデルを活用することで、クラウドへのデータ送信を最小限に抑えられます。

GPT-OSS を活用したナレッジ検索アシスタントは、企業の知識管理を革新する強力なツールです。本記事で紹介した実装パターンを基に、皆様の組織のニーズに合わせてカスタマイズし、より効率的な情報活用を実現してください。

関連リンク