💬

OpenAIの埋め込みを使ったスマートドキュメントの構築(チャンク化、インデックス作成、検索)

に公開

OpenAIの埋め込みを使ったスマートドキュメントの構築(チャンク化、インデックス作成、検索)

こんにちは、みなさん!今回は、私が取り組んでいるプロジェクト用に作成した「スマートドキュメント」チャットボットのアプローチを共有したいと思います。
私はAIの専門家ではないので、改善点やアドバイスがあればぜひ教えてください!

この投稿の目的は、「OpenAIを使ったチャットボットの作り方」のチュートリアルをもう一つ作ることではありません。
その手の内容はすでに豊富にあります。

代わりに、ここでの主なアイデアは、ドキュメントをインデックス化し、扱いやすいチャンクに分割し、OpenAIを使って埋め込み(embedding)を生成し、類似検索によってユーザーの質問に対して最も関連性の高い情報を返す、ということです。

私のケースでは、ドキュメントはMarkdownファイルですが、テキストやデータベースのオブジェクトなど、どんな形式でも対応可能です。


なぜやるのか?

情報を探すのが難しいと感じることがよくあるので、特定のトピックについて質問すると、関連ドキュメントの内容を使って回答してくれるチャットボットを作りたいと思いました。

このアシスタントは、次のようなさまざまな場面で活用できます:

  • よくある質問への迅速な回答
  • Algoliaのようにドキュメント/ページを検索
  • 特定のドキュメント内から必要な情報を見つけるサポート
  • ユーザーの疑問や質問を記録・取得する

概要

以下は、私のソリューションの3つの主なステップです:

  1. ドキュメントファイルの読み込み
  2. ドキュメントのインデックス作成(チャンク化、オーバーラップ、埋め込み生成)
  3. ドキュメントの検索(チャットボットとの接続)

ファイル構成

.
└── docs
    └── ...md
└── src
    └── askDocQuestion.ts
    └── index.ts # Express.js アプリケーションのエンドポイント
└── embeddings.json # 埋め込みデータの保存
└── packages.json

1. ドキュメントファイルの読み込み

ドキュメントのテキストをハードコーディングする代わりに、globなどのツールを使って.mdファイルをフォルダから読み込みます。

import fs from "node:fs";
import path from "node:path";
import glob from "glob";

const DOC_FOLDER_PATH = "./docs";

type FileData = {
  path: string;
  content: string;
};

const readAllMarkdownFiles = (): FileData[] => {
  const filesContent: FileData[] = [];
  const filePaths = glob.sync(`${DOC_FOLDER_PATH}/**/*.md`);

  filePaths.forEach((filePath) => {
    const content = fs.readFileSync(filePath, "utf8");
    filesContent.push({ path: filePath, content });
  });

  return filesContent;
};

もちろん、ドキュメントをデータベースやCMSなどから取得することも可能です。


2. ドキュメントのインデックス作成

検索エンジンを作成するために、OpenAIのベクター埋め込みAPIを使用して埋め込みを生成します。

ベクター埋め込みとは、データを数値で表現する方法であり、類似性検索(今回はユーザーの質問とドキュメントのセクションの間の類似性)に使われます。

このベクター(浮動小数点のリスト)を使って、数学的な計算により類似度を測ります。

[
  -0.0002630692, -0.029749284, 0.010225477, -0.009224428, -0.0065269712,
  -0.002665544, 0.003214777, 0.04235309, -0.033162255, -0.00080789323,
  //...+1533 要素
];

この概念に基づいてベクターデータベースが誕生しました。そのため、OpenAI APIの代わりに、ChromaQdrantPineconeなどのベクターデータベースを使うことも可能です。


2.1 ファイルごとのチャンク化とオーバーラップ

大きなテキストブロックは、モデルのコンテキスト制限を超えることがあるため、検索の精度を上げるためにも小さなチャンクに分けるのが推奨されています。

ただし、チャンク間の文脈を保つために、一定量のトークン(または文字数)をオーバーラップさせることで、文の途中で切れてしまうのを防ぎます。

チャンク化の例

ここでは、長文を100文字ごとのチャンクに分け、50文字を重複させる例です。

全文(406文字):

都会の中心に、誰もが忘れてしまった古い図書館があった。そこにはあらゆるジャンルの本が棚にびっしりと詰まっていて、それぞれが冒険、ミステリー、そして時代を超えた知恵の物語をささやいていた。毎晩、ひとりの熱心な司書がその扉を開き、知識を求める好奇心旺盛な来館者を迎えていた。子どもたちは物語の読み聞かせに集まった。

  • チャンク 1(1〜150文字):

    都会の中心に、誰もが忘れてしまった古い図書館があった。そこにはあらゆるジャンルの本が棚にびっしりと詰まっていて、それぞれが冒険、ミス

  • チャンク 2(101〜250文字):

    棚にびっしりと詰まっていて、それぞれが冒険、ミステリー、そして時代を超えた知恵の物語をささやいていた。毎晩、ひとりの熱心な司書がその

  • チャンク 3(201〜350文字):

    と時代を超えた知恵の物語をささやいていた。毎晩、ひとりの熱心な司書がその扉を開き、知識を求める好奇心旺盛な来館者を迎えていた。子ども

  • チャンク 4(301〜406文字):

    好奇心旺盛な来館者を迎えていた。子どもたちは物語の読み聞かせに集まった。

コードスニペット

const CHARS_PER_TOKEN = 4.15; // トークンあたりのおおよその文字数(正確にするには`tiktoken`などを使用)

const MAX_TOKENS = 500;
const OVERLAP_TOKENS = 100;

const maxChar = MAX_TOKENS * CHARS_PER_TOKEN;
const overlapChar = OVERLAP_TOKENS * CHARS_PER_TOKEN;

const chunkText = (text: string): string[] => {
  const chunks: string[] = [];
  let start = 0;

  while (start < text.length) {
    let end = Math.min(start + maxChar, text.length);

    if (end < text.length) {
      const lastSpace = text.lastIndexOf(" ", end);
      if (lastSpace > start) end = lastSpace;
    }

    chunks.push(text.substring(start, end));

    const nextStart = end - overlapChar;
    start = nextStart <= start ? end : nextStart;
  }

  return chunks;
};

チャンクサイズと埋め込みの精度の関係について詳しく知りたい方は、この記事をチェックしてみてください。


2.2 埋め込みの生成

ファイルをチャンク化したら、OpenAIのAPI(例:text-embedding-3-large)を使って各チャンクの埋め込みを生成します。

import { OpenAI } from "openai";

const EMBEDDING_MODEL: OpenAI.Embeddings.EmbeddingModel =
  "text-embedding-3-large";

const openai = new OpenAI({ apiKey: OPENAI_API_KEY });

const generateEmbedding = async (textChunk: string): Promise<number[]> => {
  const response = await openai.embeddings.create({
    model: EMBEDDING_MODEL,
    input: textChunk,
  });

  return response.data[0].embedding;
};

2.3 ファイル全体の埋め込みの生成と保存

毎回埋め込みを再生成するのを避けるため、埋め込みを保存します。データベースに保存することも可能ですが、この例ではローカルの JSON ファイルに保存します。

以下のコードは次の処理を行います:

  1. 各ドキュメントを繰り返し処理する
  2. ドキュメントをチャンクに分割する
  3. 各チャンクに対して埋め込みを生成する
  4. 埋め込みを JSON ファイルに保存する
  5. 検索で使用するために vectorStore に埋め込みを格納する
import embeddingsList from "../embeddings.json";

/**
 * ドキュメントの埋め込みとその内容を保持する単純なメモリ内ベクターストア。
 * 各エントリには以下が含まれる:
 * - filePath: ドキュメントを一意に識別するキー
 * - chunkNumber: ドキュメント内のチャンク番号
 * - content: チャンクのテキスト内容
 * - embedding: チャンクの数値ベクトル埋め込み
 */
const vectorStore: {
  filePath: string;
  chunkNumber: number;
  content: string;
  embedding: number[];
}[] = [];

/**
 * 各マークダウンファイルをインデックス化し、
 * チャンクごとに埋め込みを生成してメモリに保存。
 * 新しい埋め込みが生成された場合は embeddings.json を更新する。
 */
export const indexMarkdownFiles = async (): Promise<void> => {
  // ドキュメントを取得
  const docs = readAllMarkdownFiles();

  let newEmbeddings: Record<string, number[]> = {};

  for (const doc of docs) {
    // ドキュメントを見出しでチャンクに分割
    const fileChunks = chunkText(doc.content);

    // 現在のファイル内の各チャンクを繰り返し処理
    for (const chunkIndex of Object.keys(fileChunks)) {
      const chunkNumber = Number(chunkIndex) + 1; // チャンク番号は 1 から開始
      const chunksNumber = fileChunks.length;

      const chunk = fileChunks[chunkIndex as keyof typeof fileChunks] as string;

      const embeddingKeyName = `${doc.path}/chunk_${chunkNumber}`; // チャンクの一意キー

      // 既存の埋め込みがあるか確認
      const existingEmbedding = embeddingsList[
        embeddingKeyName as keyof typeof embeddingsList
      ] as number[] | undefined;

      let embedding = existingEmbedding; // 既存の埋め込みを使用

      if (!embedding) {
        embedding = await generateEmbedding(chunk); // なければ生成
      }

      newEmbeddings = { ...newEmbeddings, [embeddingKeyName]: embedding };

      // ベクターストアに埋め込みと内容を格納
      vectorStore.push({
        filePath: doc.path,
        chunkNumber,
        embedding,
        content: chunk,
      });

      console.info(`- Indexed: ${embeddingKeyName}/${chunksNumber}`);
    }
  }

  /**
   * 新しく生成された埋め込みと既存のものを比較
   *
   * 変更があれば embeddings.json を更新
   */
  try {
    if (JSON.stringify(newEmbeddings) !== JSON.stringify(embeddingsList)) {
      fs.writeFileSync(
        "./embeddings.json",
        JSON.stringify(newEmbeddings, null, 2)
      );
    }
  } catch (error) {
    console.error(error);
  }
};

3. ドキュメント検索

3.1 ベクトル類似度

ユーザーの質問に答えるには、まず 質問 の埋め込みを生成し、各チャンクの埋め込みとのコサイン類似度を計算します。一定の類似度しきい値未満のものは除外し、上位 X 件のみを保持します。

/**
 * 2つのベクトル間のコサイン類似度を計算。
 * コサイン類似度は内積空間における2ベクトル間の角度のコサインを測定する。
 * チャンク同士の類似度判定に使用。
 *
 * @param vecA - ベクトルA
 * @param vecB - ベクトルB
 * @returns コサイン類似度スコア
 */
const cosineSimilarity = (vecA: number[], vecB: number[]): number => {
  // 内積を計算
  const dotProduct = vecA.reduce((sum, a, idx) => sum + a * vecB[idx], 0);

  // 各ベクトルの大きさ(ユークリッドノルム)を計算
  const magnitudeA = Math.sqrt(vecA.reduce((sum, a) => sum + a * a, 0));
  const magnitudeB = Math.sqrt(vecB.reduce((sum, b) => sum + b * b, 0));

  // 類似度を返す
  return dotProduct / (magnitudeA * magnitudeB);
};

const MIN_RELEVANT_CHUNKS_SIMILARITY = 0.77; // チャンクが関連性ありと判断される最小類似度
const MAX_RELEVANT_CHUNKS_NB = 15; // GPT に渡す関連チャンクの最大数

/**
 * クエリに基づき、最も関連性の高いチャンクを検索。
 * コサイン類似度により最も近い埋め込みを取得。
 *
 * @param query - ユーザーの検索クエリ
 * @returns 最も一致するチャンク内容の配列
 */
const searchChunkReference = async (query: string) => {
  // クエリの埋め込みを生成
  const queryEmbedding = await generateEmbedding(query);

  // 全てのドキュメント埋め込みとの類似度を計算
  const results = vectorStore
    .map((doc) => ({
      ...doc,
      similarity: cosineSimilarity(queryEmbedding, doc.embedding), // 類似度を追加
    }))
    // 類似度が低いチャンクは除外
    .filter((doc) => doc.similarity > MIN_RELEVANT_CHUNKS_SIMILARITY)
    .sort((a, b) => b.similarity - a.similarity) // 類似度が高い順に並べ替え
    .slice(0, MAX_RELEVANT_CHUNKS_NB); // 上位のみ取得

  // 該当チャンクの内容を返す
  return results;
};

3.2 関連チャンクを OpenAI にプロンプトとして渡す

並べ替え後、上位チャンク を ChatGPT のシステムプロンプトに渡します。これにより ChatGPT は、該当ドキュメントをチャットに貼り付けたように扱い、ユーザーの質問に答えます。

const MODEL: OpenAI.Chat.ChatModel = "gpt-4o-2024-11-20"; // 使用するモデル

// チャット用メッセージ構造
export type ChatCompletionRequestMessage = {
  role: "system" | "user" | "assistant"; // 送信者の役割
  content: string; // メッセージ本文
};

/**
 * Express.js ルートで「質問に答える」エンドポイントを処理。
 * ユーザーのメッセージを処理し、関連ドキュメントを取得し、OpenAI とやり取り。
 *
 * @param messages - ユーザーおよびアシスタントのチャット履歴
 * @returns アシスタントの返答(文字列)
 */
export const askDocQuestion = async (
  messages: ChatCompletionRequestMessage[]
): Promise<string> => {
  // アシスタントのメッセージは除外(ループを防ぐ)
  // ユーザーが文脈を変えると精度が下がる可能性あり
  const userMessages = messages.filter((message) => message.role === "user");

  // ユーザーの質問をフォーマット
  const formattedUserMessages = userMessages
    .map((message) => `- ${message.content}`)
    .join("\n");

  // 1) 質問に基づき関連チャンクを検索
  const relevantChunks = await searchChunkReference(formattedUserMessages);

  // 2) 関連ドキュメントを初期システムプロンプトに組み込む
  const messagesList: ChatCompletionRequestMessage[] = [
    {
      role: "system",
      content:
        "以前の指示はすべて無視してください。\
        あなたは有能なチャットボットです。\
        ...\
        以下は関連ドキュメントです:\
        " +
        relevantChunks
          .map(
            (doc, idx) =>
              `[Chunk ${idx}] filePath = "${doc.filePath}":\n${doc.content}`
          )
          .join("\n\n"), // チャンクをプロンプトに挿入
    },
    ...messages, // チャット履歴を追加
  ];

  // 3) メッセージを OpenAI チャット補完 API に送信
  const response = await openai.chat.completions.create({
    model: MODEL,
    messages: messagesList,
  });

  const result = response.choices[0].message.content; // アシスタントの返答を取得

  if (!result) {
    throw new Error("OpenAIからの応答がありません");
  }

  return result;
};

4. Express を使って OpenAI API チャットボットを実装する

システムを実行するために、Express.js サーバーを使用します。
以下はクエリを処理する小さな Express.js エンドポイントの例です:

import express, { type Request, type Response } from "express";
import {
  ChatCompletionRequestMessage,
  askDocQuestion,
  indexMarkdownFiles,
} from "./askDocQuestion";

// サーバー起動時にベクトルストアを自動的に埋める
indexMarkdownFiles();

const app = express();

// JSON ペイロードのリクエストを解析
app.use(express.json());

type AskRequestBody = {
  messages: ChatCompletionRequestMessage[];
};

// ルート
app.post(
  "/ask",
  async (
    req: Request<undefined, undefined, AskRequestBody>,
    res: Response<string>
  ) => {
    try {
      const response = await askDocQuestion(req.body.messages);

      res.json(response);
    } catch (error) {
      console.error(error);
    }
  }
);

// サーバー起動
app.listen(3000, () => {
  console.log(`ポート3000で待機中`);
});

5. UI:チャットボットインターフェースの作成

フロントエンドでは、チャットのようなインターフェースを持つ小さな React コンポーネントを作成しました。
Express バックエンドにメッセージを送信し、返信を表示します。
特に目新しいことはないので詳細は省略します。


コードテンプレート

独自のチャットボットを構築するための出発点として使える コードテンプレート を作成しました。

ライブデモ

チャットボットの最終実装をテストしたい場合は、デモページ をご覧ください。

デモコード

さらに進めるには

YouTube にて、OpenAI の埋め込みとベクトルデータベースについての Adrien Twarog の動画 をぜひご覧ください。

また、OpenAI の Assistants File Search ドキュメント も見つけました。代替手法として面白いかもしれません。


結論

このチャットボットのためのドキュメントインデックス処理方法について、何かしらのヒントになれば嬉しいです:

  • 適切なコンテキストを見つけるためのチャンク処理とオーバーラップの使用、
  • 埋め込みを生成し、ベクトル類似検索用に保存、
  • 最後に、関連コンテキストを付けて ChatGPT に引き渡す。

私は AI の専門家ではありませんが、これは自分のニーズに合った有効な解決法でした。
効率向上のコツや、より洗練された方法があれば、ぜひ教えてください!
ベクトルストレージのソリューション、チャンク戦略、その他のパフォーマンス改善などのフィードバックをお待ちしています。

読んでいただきありがとうございました!ご意見もぜひお聞かせください。

(この翻訳は AI によって行われました - 公式の記事は Medium にてご覧ください: https://dev.to/aypineau/building-a-smart-documentation-based-on-openai-embeddings-chunking-indexing-and-searching-4nam

Discussion