Языковые модели обладают огромными знаниями, но ограничены датой завершения обучения и ничего не знают о закрытой базе знаний вашей компании. Попытка вставить всю корпоративную документацию в системный промпт приводит к переполнению контекстного окна и резкому росту расходов на API. Инженерное решение — архитектура RAG. Ее фундамент состоит из трех этапов: декомпозиция документов (чанкование), преобразование текста в числовые векторы (эмбеддинги) и поиск ближайших соседей по индексу HNSW.
─── Этап 1: Подготовка базы знаний (Offline Ingestion Pipeline) ───
[ Исходный документ (Markdown / PDF) ]
│
▼ Recursive Character Chunking
[ Чанк 1 (Размер: 512, Перекрытие: 64) ] ──> Генератор эмбеддингов ──> [ 0.12, -0.84, ... ]
[ Чанк 2 (Размер: 512, Перекрытие: 64) ] ──> (text-embedding-3-small) │ Вектор 1536d
│ │
└─────────────────────────┬───────────────────────────┘
▼ Сохранение в векторную БД
┌─────────────────────────────────┐
│ Векторный индекс HNSW (Graph) │
└────────────────┬────────────────┘
│
─── Этап 2: Поиск при запросе пользователя (Online Query) ─────────
[ Вопрос: "Как настроить JWT?" ]
│
▼ Создание вектора запроса
[ Вектор запроса: 0.14, -0.81... ]
│
▼ Cosine Similarity Search по графу HNSW (Top-3 чанка)
[ Контекст: "Раздел авторизации Fastify..." ]
│
▼ Инъекция в системный промпт LLM
[ Модель формулирует точный ответ со ссылкой на источник ]Создание алгоритма декомпозиции текста по естественным границам: сначала по двойным переносам строк ( ), затем по параграфам, предложениям и пробелам.
Настройка буфера перекрытия (например, 10–15% от размера чанка) для сохранения связности терминов на границах фрагментов.
Интеграция с API эмбеддингов (OpenAI или локальная модель) для перевода текстовых чанков в массивы float[].
Математическая функция вычисления скалярного произведения векторов, нормализованного на произведение их длин.
Организация иерархических слоев графа для логарифмического поиска Top-K наиболее релевантных фрагментов.
Зачем при разбиении большого текста на чанки размером 500 символов обязательно задавать перекрытие (chunk overlap, например, 50 символов)?
Алгоритм ищет естественные границы разделения: абзацы (#, ##), предложения (.) и знаки препинания, избегая разрезания слов.
Каждый чанк содержит самодостаточную законченную мысль.
Текст нормализуется (lowercase, strip extra spaces) и отправляется в модель эмбеддингов, возвращающую вектор единичной длины.
Векторы нормализованы: скалярное произведение равно косинусному расстоянию.
Поиск начинается с верхнего разреженного слоя графа с быстрым сужением области до нижнего плотного слоя.
Находит ближайших соседей среди 1 000 000 векторов менее чем за 5 мс.
Извлеченные Top-3 фрагмента форматируются в блоке <context> с указанием заголовков и метаданных для передачи в LLM.
Модель отвечает строго по предоставленным фактам с указанием номеров строк.
export interface TextSplitterOptions {
chunkSize: number;
chunkOverlap: number;
separators?: string[];
}
export class RecursiveCharacterTextSplitter {
private chunkSize: number;
private chunkOverlap: number;
private separators: string[];
constructor(options: TextSplitterOptions) {
this.chunkSize = options.chunkSize;
this.chunkOverlap = options.chunkOverlap;
this.separators = options.separators ?? ['\n\n', '\n', '. ', ' ', ''];
}
/**
* Разбивает сплошной текст на массив смысловых чанков
*/
public splitText(text: string): string[] {
const finalChunks: string[] = [];
const splits = this.splitOnSeparator(text, this.separators);
let currentChunk = '';
for (const split of splits) {
if ((currentChunk + split).length <= this.chunkSize) {
currentChunk += split;
} else {
if (currentChunk.trim().length > 0) {
finalChunks.push(currentChunk.trim());
}
// Формируем начало следующего чанка с учетом перекрытия
const overlapStart = Math.max(0, currentChunk.length - this.chunkOverlap);
const overlap = currentChunk.slice(overlapStart);
currentChunk = overlap + split;
}
}
if (currentChunk.trim().length > 0) {
finalChunks.push(currentChunk.trim());
}
return finalChunks;
}
private splitOnSeparator(text: string, separators: string[]): string[] {
const separator = separators[0];
if (separators.length === 1 || separator === '') {
return text.split(separator);
}
const parts = text.split(separator);
const result: string[] = [];
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (part.length <= this.chunkSize) {
result.push(part + (i < parts.length - 1 ? separator : ''));
} else {
// Если фрагмент все еще слишком длинный — делим следующим по приоритету разделителем
const subSplits = this.splitOnSeparator(part, separators.slice(1));
result.push(...subSplits);
}
}
return result;
}
}export interface DocumentChunk {
id: string;
text: string;
metadata: Record<string, any>;
embedding: number[];
}
export interface SearchResult {
chunk: DocumentChunk;
score: number;
}
export class InMemoryVectorStore {
private chunks: DocumentChunk[] = [];
public addChunks(chunks: DocumentChunk[]): void {
this.chunks.push(...chunks);
}
/**
* Вычисляет косинусное сходство между двумя числовыми векторами.
* Значение 1.0 — абсолютное совпадение, 0.0 — ортогональность, -1.0 — противоположность.
*/
public static cosineSimilarity(a: number[], b: number[]): number {
if (a.length !== b.length) {
throw new Error(`Несовпадение размерности векторов: ${a.length} vs ${b.length}`);
}
let dotProduct = 0;
let normA = 0;
let normB = 0;
for (let i = 0; i < a.length; i++) {
dotProduct += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
if (normA === 0 || normB === 0) return 0;
return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
}
/**
* Выполняет поиск Top-K наиболее релевантных чанков для входящего вектора
*/
public similaritySearch(queryEmbedding: number[], topK: number = 3): SearchResult[] {
const scoredChunks: SearchResult[] = this.chunks.map((chunk) => ({
chunk,
score: InMemoryVectorStore.cosineSimilarity(queryEmbedding, chunk.embedding),
}));
// Сортировка по убыванию релевантности
scoredChunks.sort((a, b) => b.score - a.score);
return scoredChunks.slice(0, topK);
}
}Если индекс базы данных настроен на размерность 1536 (OpenAI text-embedding-3-small), а вы попытаетесь сохранить вектор размерностью 768 (например, от модели Gemini или Nomic), база данных выбросит фатальную ошибку dimension mismatch: expected 1536, got 768. Все документы в индексе обязаны генерироваться строго одной моделью эмбеддингов.
Сохранение только текста без метаданных (URL страницы, название файла, дата обновления, ID автора) не позволяет фильтровать результаты по правам доступа (RBAC) или временным диапазонам. Всегда прикрепляйте структурированный объект metadata к каждому вектору.
Если чанк содержит 2 000 токенов, векторное представление «размывается»: в одном векторе смешиваются десятки тем, и точность поиска падает в разы. Оптимальный размер чанка для большинства технических баз знаний — от 300 до 600 токенов (примерно 1 000–2 500 символов).
Если все векторы эмбеддингов в базе данных предварительно нормализованы к единичной длине (L2 Norm = 1), как можно существенно ускорить расчет сходства?