Привет всем! Хочу поделиться своим подходом к созданию чат-бота с "умной документацией" для проекта, над которым я работаю. Я не эксперт в ИИ, так что любые предложения и улучшения приветствуются!
Цель этого поста — не создавать еще один туториал по созданию чат-бота на основе OpenAI. Такого контента и так хватает. Вместо этого основная идея — проиндексировать документацию, разделив её на фрагменты, сгенерировать для них встраивания с помощью OpenAI и выполнять поиск по схожести, чтобы находить и возвращать наиболее релевантную информацию в ответ на запрос пользователя.
В моем случае, документация представлена в виде Markdown-файлов, но это может быть любой текст, объект в базе данных и т.д.

Зачем?
Потому что порой сложно найти нужную информацию, я захотел создать чат-бота, который сможет отвечать на вопросы по определенной теме и предоставлять релевантный контекст из документации.
Такой ассистент может использоваться по-разному:
Быстрые ответы на часто задаваемые вопросы
Поиск по документации / странице как в Algolia
Помощь пользователям в нахождении нужной информации в конкретной документации
Сбор вопросов пользователей для последующего анализа
Сводка
Ниже я опишу три основных части моего решения:
Чтение файлов документации
Индексация документации (деление на фрагменты, перекрытие, встраивания)
Поиск по документации (и подключение к чат-боту)
Структура проекта
.
└── docs
└── ...md
└── src
└── askDocQuestion.ts
└── index.ts # endpoint приложения на Express.js
└── embeddings.json # Хранилище встраиваний
└── packages.json
1. Чтение файлов документации
Вместо того чтобы жестко задавать текст документации, можно просканировать папку с .md
файлами с помощью glob
.
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. Индексация документации
Чтобы создать поисковый движок, мы будем использовать API встраиваний OpenAI для генерации встраиваний.
Векторные встраивания — это способ представления данных в числовом формате, пригодном для выполнения поиска по схожести (в нашем случае — между вопросом пользователя и фрагментами документации).
[
-0.0002630692, -0.029749284, 0.010225477, -0.009224428, -0.0065269712,
-0.002665544, 0.003214777, 0.04235309, -0.033162255, -0.00080789323,
//...+1533 элемента
];
На основе этого принципа были созданы векторные базы данных. Вместо использования OpenAI API, можно применить такие решения как Chroma, Qdrant или Pinecone.
2.1 Деление файлов на фрагменты с перекрытием
Большие блоки текста могут превышать лимит контекста модели или давать менее точные результаты, поэтому рекомендуется разбивать текст на фрагменты. Чтобы сохранить связность между фрагментами, мы перекрываем их на определенное количество токенов (или символов). Это снижает вероятность того, что фрагмент оборвется на середине важной мысли.
Пример деления
В этом примере у нас есть длинный текст, который мы хотим разделить на небольшие фрагменты. Мы создаем фрагменты по 100 символов с перекрытием в 50 символов.
Полный текст (406 символов):
В центре шумного города стояла старая библиотека, о которой многие забыли. Её высокие полки были наполнены книгами всех жанров, каждая из которых нашептывала истории приключений, загадок и вечной мудрости. Каждый вечер преданная библиотекарь открывала двери для любознательных умов. Дети собирались на рассказы.
Фрагмент 1 (Символы 1–150):
В центре шумного города стояла старая библиотека, о которой многие забыли. Её высокие полки были наполнены книгами всех жанров, каждая из кот.
Фрагмент 2 (Символы 101–250):
ки были наполнены книгами всех жанров, каждая из которых нашептывала истории приключений, загадок и вечной мудрости. Каждый вечер преданная б
Фрагмент 3 (Символы 201–350):
загадок и вечной мудрости. Каждый вечер преданная библиотекарь открывала двери для любознательных умов. Дети собирались на рассказы.
Фрагмент 4 (Символы 301–406):
пытливые умы. Дети собирались на рассказы.
Пример кода
const CHARS_PER_TOKEN = 4.15;
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 Генерация встраиваний
После деления файла мы создаем встраивания для каждого фрагмента через API OpenAI (например, 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-файл.
Пример:
Проходит по каждому документу
Делит документ на фрагменты
Генерирует встраивания для каждого
Сохраняет в JSON
Добавляет в
vectorStore
для последующего поиска
const CHARS_PER_TOKEN = 4.15;
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);
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;
};
3. Поиск в документации
3.1 Векторное сходство
Чтобы ответить на вопрос пользователя, мы сначала генерируем эмбеддинг для вопроса пользователя, а затем вычисляем косинусное сходство между эмбеддингом запроса и эмбеддингами каждого фрагмента. Мы отфильтровываем всё, что ниже определённого порога сходства, и оставляем только X наиболее релевантных совпадений.
/**
* Вычисляет косинусное сходство между двумя векторами.
* Косинусное сходство измеряет косинус угла между двумя векторами в евклидовом пространстве.
* Используется для определения сходства между фрагментами текста.
*
* @param vecA - Первый вектор
* @param vecB - Второй вектор
* @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; // Максимальное количество релевантных фрагментов для передачи в контекст chatGPT
/**
* Ищет наиболее релевантные фрагменты документов на основе запроса.
* Использует косинусное сходство для поиска наиболее близких эмбеддингов.
*
* @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");
const relevantChunks = await searchChunkReference(formattedUserMessages);
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"),
},
...messages,
];
const response = await openai.chat.completions.create({
model: MODEL,
messages: messagesList,
});
const result = response.choices[0].message.content;
if (!result) {
throw new Error("No response from OpenAI");
}
return result;
};
4. Подключение OpenAI API через Express
Для запуска нашей системы мы используем сервер Express.js. Ниже пример простого эндпоинта:
import express, { type Request, type Response } from "express";
import {
ChatCompletionRequestMessage,
askDocQuestion,
indexMarkdownFiles,
} from "./askDocQuestion";
indexMarkdownFiles();
const app = express();
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(`Listening on port 3000`);
});
5. UI: Интерфейс чат-бота
На фронтенде я создал простой компонент React с интерфейсом чата. Он отправляет сообщения на бэкенд Express и отображает ответы. Ничего сложного, поэтому детали опущены.
Шаблон кода
Я создал шаблон кода, который можно использовать как отправную точку для создания собственного чат-бота.
Живая демонстрация
Если вы хотите протестировать реализованный чат-бот, зайдите на страницу демо
Мой демонстрационный код
Бэкенд: askDocQuestion.ts
Фронтенд: Компоненты ChatBot
Дальнейшее развитие
На YouTube посмотрите видео Adrien Twarog — хороший материал по эмбеддингам OpenAI и векторным базам данных.
Также стоит взглянуть на документацию OpenAI по File Search, если хотите попробовать альтернативный подход.
Заключение
Надеюсь, теперь у вас есть представление, как реализовать индексирование документации для чат-бота:
Использование разбиения на фрагменты с перекрытием для захвата нужного контекста,
Генерация эмбеддингов и их хранение для быстрого поиска по векторному сходству,
Передача ChatGPT только нужной информации для генерации точного ответа.
Я не эксперт по ИИ, это просто решение, которое хорошо сработало для моих нужд. Если у вас есть советы по повышению эффективности, хранению векторов, стратегий разбиения — буду рад обратной связи.
Спасибо за внимание и делитесь мыслями!
[эта статья была переведена с помощью ИИ — оригинальную статью можно найти на Medium https://dev.to/aypineau/building-a-smart-documentation-based-on-openai-embeddings-chunking-indexing-and-searching-4nam]