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 を使用した実装例を示します。
システム構成と前提条件
今回の実装では、以下の技術スタックを使用します。
# | 技術要素 | 用途 | バージョン |
---|---|---|---|
1 | Next.js | フロントエンド・API 開発 | 14.x |
2 | TypeScript | 型安全な開発 | 5.x |
3 | OpenAI API | LLM(GPT-4)との連携 | latest |
4 | Pinecone | ベクトルデータベース | latest |
5 | Prisma | ログデータの管理 | 5.x |
6 | chokidar | ファイル監視 | 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 を活用したナレッジ検索アシスタントは、企業の知識管理を革新する強力なツールです。本記事で紹介した実装パターンを基に、皆様の組織のニーズに合わせてカスタマイズし、より効率的な情報活用を実現してください。
関連リンク
- article
gpt-oss でナレッジ検索アシスタント:根拠表示・更新検知・検索ログ最適化
- article
gpt-oss で JSON 構造化出力を安定させる:スキーマ提示・検証リトライ・自動修復
- article
gpt-oss のモデルルーティング設計:サイズ別・ドメイン別・コスト別の自動切替
- article
gpt-oss プロンプト設計チートシート:指示・制約・出力フォーマットの即戦力例 100
- article
gpt-oss を最短デプロイ:CPU/単一 GPU/マルチ GPU 別インフラ設計テンプレ
- article
gpt-oss の全体像と導入判断フレーム:適用領域・制約・成功条件を一挙解説
- article
NestJS 監視運用:SLI/SLO とダッシュボード設計(Prometheus/Grafana/Loki)
- article
WebRTC AV1/VP9/H.264 ベンチ比較 2025:画質・CPU/GPU 負荷・互換性を実測
- article
MySQL アラート設計としきい値:レイテンシ・エラー率・レプリカ遅延の基準
- article
Vitest フレーク検知技術の運用:`--retry` / シード固定 / ランダム順序で堅牢化
- article
Motion(旧 Framer Motion)デザインレビュー運用:Figma パラメータ同期と差分共有のワークフロー
- article
esbuild プリバンドルを理解する:Vite の optimizeDeps 深掘り
- blog
iPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
- blog
Googleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
- blog
【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
- blog
Googleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
- blog
Pixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
- blog
フロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
- review
今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
- review
ついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
- review
愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
- review
週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
- review
新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
- review
科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来