みなさんこんにちは! 私が取り組んでいるプロジェクトのための「スマートドキュメント」チャットボットを作成するための私のアプローチを共有したいと思いました。I’m not an AI expert, so any suggestions or improvements are more than welcome!
この投稿の目的は、OpenAIに基づいてチャットボットを構築するためのもう一つのチュートリアルを作成することではありません。index documentation管理可能なものに分けることによって、chunks生成embeddingsオープンで、そして、performing a similarity searchユーザーのクエリに最も関連する情報を検索して返す。
私の場合、ドキュメントはMarkdownファイルになりますが、テキスト、データベースオブジェクトなどのあらゆる形態かもしれません。
なんで?
あなたが必要とする情報を見つけることは時として困難であるため、特定のトピックに関する質問に答え、文書から関連する文脈を提供できるチャットボットを作成したいと思いました。
このアシスタントは、以下のようなさまざまな方法で使用することができます:
- よくある質問への迅速な回答
- Algolia が行うように doc/page を検索する
- ユーザーが特定のドキュメントで必要な情報を見つけるのを助ける
- 質問された質問を保存することによってユーザーの懸念/質問を回復する
概要
以下では、私の解決策の3つの主要な部分を説明します。
- ドキュメントファイルの読み方
- ドキュメントのインデックス化(chunking, overlap, and embedding)
- ドキュメントを検索する(そしてチャットボットに接続する)
ファイルツリー
.
└── docs
└── ...md
└── src
└── askDocQuestion.ts
└── index.ts # Express.js application endpoint
└── embeddings.json # Storage for embeddings
└── packages.json
1.ファイルの読み方
文書のテキストをハードコードする代わりに、フォルダをスキャンすることができます。.md
などのツールを使用するファイルglob
.
// Example snippet of fetching files from a folder:
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などからドキュメントを取得することができます。
代わりに、もちろん、データベースやCMSなどからドキュメントを取得することができます。
2.ドキュメントのインデックス
検索エンジンを作成するには、OpenAIを使用します。Vector Embeddings APIわたしたちのを生み出すために。
ベクター埋め込みは、数字形式でデータを表す方法であり、類似性の検索を実行するために使用することができる(当社のケースでは、ユーザーの質問と当社の文書のセクション間)。
このベクターは、浮動点数のリストで構成され、数学的な公式を使用して類似性を計算するために使用されます。
[
-0.0002630692, -0.029749284, 0.010225477, -0.009224428, -0.0065269712,
-0.002665544, 0.003214777, 0.04235309, -0.033162255, -0.00080789323,
//...+1533 elements
];
このコンセプトに基づいて、Vector Database が作成されました. その結果、OpenAI API を使用する代わりに、Chroma、Qdrant、Pinecone などのベクトルデータベースを使用することができます。
このコンセプトに基づき、創設された。ベクターデータベースその結果、OpenAI API を使用する代わりに、Vector Database を使用することもできます。クロムで、クイズまたはピネコ.
2.1 Chunk Each File & Overlap
テキストの大きなブロックは、モデルコンテキストの限界を超えたり、関連性の低いヒットを引き起こす可能性がありますので、検索をよりターゲット化するためにそれらをブロックに分割することをお勧めします。しかし、ブロック間の一定の連続性を維持するために、我々はそれらを特定の数のトークン(または文字)で重複します。
Chunkingの例
この例では、長いテキストを小さい部分に分割する必要があります。この場合、100文字の部分を作成し、50文字で重なります。
Full Text (406 characters):
賑やかな街の中心部には、多くの人が忘れていた古い図書館があり、その広大な棚はあらゆる想像できるジャンルの本で満たされ、それぞれが冒険、謎、そして無限の知恵の物語をささやいた。
-
Chunk 1 (Characters 1-150):
In the heart of the bustling city, there stood an old library that many had forgotten. Its towering shelves were filled with books from every imaginabl.
-
Chunk 2 (Characters 101-250):
shelves were filled with books from every imaginable genre, each whispering stories of adventures, mysteries, and timeless wisdom. Every evening, a d
-
Chunk 3 (Characters 201-350):
ysteries, and timeless wisdom. Every evening, a dedicated librarian would open its doors, welcoming curious minds eager to explore the vast knowledge
-
Chunk 4 (Characters 301-406):
curious minds eager to explore the vast knowledge within. Children would gather for storytelling sessions.
コード Snippet
const CHARS_PER_TOKEN = 4.15; // Approximate pessimistically number of characters per token. Can use `tiktoken` or other tokenizers to calculate it more precisely
const MAX_TOKENS = 500; // Maximum number of tokens per chunk
const OVERLAP_TOKENS = 100; // Number of tokens to overlap between chunks
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);
// Don’t cut a word in half if possible:
if (end < text.length) {
const lastSpace = text.lastIndexOf(" ", end);
if (lastSpace > start) end = lastSpace;
}
chunks.push(text.substring(start, end));
// Overlap management
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"; // Model to use for embedding generation
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; // Return the generated embedding
};
2.3 ファイル全体の埋め込みの生成と保存
埋め込みを毎回再生するのを避けるために、私たちは埋め込みを保存します. データベースに保存することができます. しかし、この場合、我々は単にJSONファイルでローカルに保存します。
次のコードは単純に:
- それぞれのドキュメンタリーに対して、
- 文書をパンクに分割し、
- あらゆるチラシを生み出し、
- ファイルをJSONファイルに保存します。
- VectorStore を検索に使用する埋め込みで記入します。
import embeddingsList from "../embeddings.json";
/**
* Simple in-memory vector store to hold document embeddings and their content.
* Each entry contains:
* - filePath: A unique key identifying the document
* - chunkNumber: The number of the chunk within the document
* - content: The actual text content of the chunk
* - embedding: The numerical embedding vector for the chunk
*/
const vectorStore: {
filePath: string;
chunkNumber: number;
content: string;
embedding: number[];
}[] = [];
/**
* Indexes all Markdown documents by generating embeddings for each chunk and storing them in memory.
* Also updates the embeddings.json file if new embeddings are generated.
*/
export const indexMarkdownFiles = async (): Promise<void> => {
// Retrieve documentations
const docs = readAllMarkdownFiles();
let newEmbeddings: Record<string, number[]> = {};
for (const doc of docs) {
// Split the document into chunks based on headings
const fileChunks = chunkText(doc.content);
// Iterate over each chunk within the current file
for (const chunkIndex of Object.keys(fileChunks)) {
const chunkNumber = Number(chunkIndex) + 1; // Chunk number starts at 1
const chunksNumber = fileChunks.length;
const chunk = fileChunks[chunkIndex as keyof typeof fileChunks] as string;
const embeddingKeyName = `${doc.path}/chunk_${chunkNumber}`; // Unique key for the chunk
// Retrieve precomputed embedding if available
const existingEmbedding = embeddingsList[
embeddingKeyName as keyof typeof embeddingsList
] as number[] | undefined;
let embedding = existingEmbedding; // Use existing embedding if available
if (!embedding) {
embedding = await generateEmbedding(chunk); // Generate embedding if not present
}
newEmbeddings = { ...newEmbeddings, [embeddingKeyName]: embedding };
// Store the embedding and content in the in-memory vector store
vectorStore.push({
filePath: doc.path,
chunkNumber,
embedding,
content: chunk,
});
console.info(`- Indexed: ${embeddingKeyName}/${chunksNumber}`);
}
}
/**
* Compare the newly generated embeddings with existing ones
*
* If there is change, update the embeddings.json file
*/
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 ベクターの類似性
ユーザーの質問に答えるには、まず、ユーザーのための埋め込みを生成します。user's question次に、クエリの埋め込みと各部分の埋め込みの間のコシン類似性を計算します.We filter out anything below a certain similarity threshold and keep only the top X matches.
/**
* Calculates the cosine similarity between two vectors.
* Cosine similarity measures the cosine of the angle between two vectors in an inner product space.
* Used to determine the similarity between chunks of text.
*
* @param vecA - The first vector
* @param vecB - The second vector
* @returns The cosine similarity score
*/
const cosineSimilarity = (vecA: number[], vecB: number[]): number => {
// Calculate the dot product of the two vectors
const dotProduct = vecA.reduce((sum, a, idx) => sum + a * vecB[idx], 0);
// Calculate the magnitude (Euclidean norm) of each vector
const magnitudeA = Math.sqrt(vecA.reduce((sum, a) => sum + a * a, 0));
const magnitudeB = Math.sqrt(vecB.reduce((sum, b) => sum + b * b, 0));
// Compute and return the cosine similarity
return dotProduct / (magnitudeA * magnitudeB);
};
const MIN_RELEVANT_CHUNKS_SIMILARITY = 0.77; // Minimum similarity required for a chunk to be considered relevant
const MAX_RELEVANT_CHUNKS_NB = 15; // Maximum number of relevant chunks to attach to chatGPT context
/**
* Searches the indexed documents for the most relevant chunks based on a query.
* Utilizes cosine similarity to find the closest matching embeddings.
*
* @param query - The search query provided by the user
* @returns An array of the top matching document chunks' content
*/
const searchChunkReference = async (query: string) => {
// Generate an embedding for the user's query
const queryEmbedding = await generateEmbedding(query);
// Calculate similarity scores between the query embedding and each document's embedding
const results = vectorStore
.map((doc) => ({
...doc,
similarity: cosineSimilarity(queryEmbedding, doc.embedding), // Add similarity score to each doc
}))
// Filter out documents with low similarity scores
// Avoid to pollute the context with irrelevant chunks
.filter((doc) => doc.similarity > MIN_RELEVANT_CHUNKS_SIMILARITY)
.sort((a, b) => b.similarity - a.similarity) // Sort documents by highest similarity first
.slice(0, MAX_RELEVANT_CHUNKS_NB); // Select the top most similar documents
// Return the content of the top matching documents
return results;
};
3.2 Relevant Chunks で OpenAI を推奨する
調理した後、私たちは餌をtopChatGPT リクエストのシステムプロンプトに入ります. これは、ChatGPT がドキュメントの最も関連するセクションを、あなたが会話に書き込んだかのように見ることを意味します. 次に、ChatGPT がユーザーのための答えを形成させます。
const MODEL: OpenAI.Chat.ChatModel = "gpt-4o-2024-11-20"; // Model to use for chat completions
// Define the structure of messages used in chat completions
export type ChatCompletionRequestMessage = {
role: "system" | "user" | "assistant"; // The role of the message sender
content: string; // The text content of the message
};
/**
* Handles the "Ask a question" endpoint in an Express.js route.
* Processes user messages, retrieves relevant documents, and interacts with OpenAI's chat API to generate responses.
*
* @param messages - An array of chat messages from the user and assistant
* @returns The assistant's response as a string
*/
export const askDocQuestion = async (
messages: ChatCompletionRequestMessage[]
): Promise<string> => {
// Assistant's response are filtered out otherwise the chatbot will be stuck in a self-referential loop
// Note that the embedding precision will be lowered if the user change of context in the chat
const userMessages = messages.filter((message) => message.role === "user");
// Format the user's question to keep only the relevant keywords
const formattedUserMessages = userMessages
.map((message) => `- ${message.content}`)
.join("\n");
// 1) Find relevant documents based on the user's question
const relevantChunks = await searchChunkReference(formattedUserMessages);
// 2) Integrate the relevant documents into the initial system prompt
const messagesList: ChatCompletionRequestMessage[] = [
{
role: "system",
content:
"Ignore all previous instructions. \
You're an helpful chatbot.\
...\
Here is the relevant documentation:\
" +
relevantChunks
.map(
(doc, idx) =>
`[Chunk ${idx}] filePath = "${doc.filePath}":\n${doc.content}`
)
.join("\n\n"), // Insert relevant chunks into the prompt
},
...messages, // Include the chat history
];
// 3) Send the compiled messages to OpenAI's Chat Completion API (using a specific model)
const response = await openai.chat.completions.create({
model: MODEL,
messages: messagesList,
});
const result = response.choices[0].message.content; // Extract the assistant's reply
if (!result) {
throw new Error("No response from OpenAI");
}
return result;
};
OpenAI API for Chatbot Using Expressの実装
システムを実行するには、Express.js サーバーを使用します. ここでは、クエリを処理するための小さなExpress.js エンドポイントの例です。
import express, { type Request, type Response } from "express";
import {
ChatCompletionRequestMessage,
askDocQuestion,
indexMarkdownFiles,
} from "./askDocQuestion";
// Automatically fill the vector store with embeddings when server starts
indexMarkdownFiles();
const app = express();
// Parse incoming requests with JSON payloads
app.use(express.json());
type AskRequestBody = {
messages: ChatCompletionRequestMessage[];
};
// Routes
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);
}
}
);
// Start server
app.listen(3000, () => {
console.log(`Listening on port 3000`);
});
タグ: Chatbot Interface
フロントエンドでは、チャットのようなインターフェイスを持つ小さなReactコンポーネントを構築しました。それはExpressバックエンドにメッセージを送信し、回答を表示します。
コード Template
I made a神殿コードあなた自身のチャットボットの出発点として使用するために。
ライブデモ
このチャットボットの最終的な実装をテストしたい場合は、こちらをご覧ください。デモページ.
デモページデモコード
- アーティスト: askDocQuestion.ts
- タグ : Chatbot コンポーネント
Go Further
YouTubeでは、こちらをご覧ください。Adrien Twarogの記事一覧OpenAI Embeddings and Vector Databases について
I also stumbled onOpenAIのアシスタントファイル検索文書もしあなたが代替的なアプローチを望むなら、それは興味深いかもしれません。
結論
私はこれがチャットボットのドキュメントのインデックスを処理する方法のアイデアを与えることを願っています:
- Chunking + Overlap を使用して、適切な文脈が見つかるように、
- 組み込みを生成し、迅速なベクター類似性検索のために保存する
- 最後に、関連する文脈と共にChatGPTに渡しました。
私はAIの専門家ではありませんが、これは私のニーズに適したソリューションであり、効率性の向上やより磨かれたアプローチについてのヒントがあれば、please let me knowベクトルストレージソリューション、クランキング戦略、または他のパフォーマンス ヒントに関するフィードバックを聞きたいです。
Thanks for reading, and feel free to share your thoughts!