Перший RAG-демо, який я зібрав, зайняв увечері. Я спарсив сайт з документацією, створив ембединги сторінок через text-embedding-3-small, скинув вектори в індекс Pinecone і підключив чат-ендпоінт, який витягував 5 найближчих чанків і підставляв їх у промпт. На тестових запитаннях він відповідав ідеально. Я надіслав посилання команді. Вони почали друкувати справжні запитання. За десять хвилин відповіді були хибними, вигаданими або «на основі документації» — документація більше не згадувала цієї функції, але модель цього не повідомила.
Цей момент є універсальним. RAG — найбільш перехвалена архітектура в AI просто тому, що hello-world версія надзвичайно проста. Складнощі починаються тоді, коли реальні користувачі з реальними запитаннями потрапляють на твою реальну базу документів, і ти виявляєш, що «створити ембединги для всього й витягнути top-k» — це приблизно 30% робочого продукту. Решта 70% — це серія непривабливих рішень про те, як ти розбиваєш документи, як ранжуєш кандидатів, як складаєш промпт і як дозволяєш моделі відмовляти.
Ось що змінюється, коли ти виходиш за межі туторіалу.
Розбивка на чанки — рішення, яке тримає всю систему
Якщо ти створиш ембединги для 50-сторінкового PDF одним вектором, ти усередниш значення 50 сторінок в одну точку у просторі. Конкретне запитання користувача про сторінку 42 не знайде цей документ — вектор надто загальний. Розбивка на чанки — це те, що дає шару пошуку за що зачепитися.
Три правила, які я тепер застосовую за замовчуванням:
- Спочатку розбивай за структурою, потім за довжиною. Markdown-заголовки, HTML-секції, визначення функцій, тіла тікетів — майже завжди є природна одиниця. Використовуй її. Розбивка суто за кількістю символів розрізає ідеї навпіл і погіршує і пошук, і генерацію.
- Перекриття. Завжди. Найважливіше речення в документі часто знаходиться поблизу межі. Якщо твої чанки починаються заново кожні 800 символів, це речення виявляється половиною в чанку A і половиною в чанку B, а пошук повертає лише одну половину. Перекриття в 10–20% коштує майже нічого і рятує граничні випадки.
- Тримай чанки в межах, доступних для читання моделлю. 300–800 токенів — корисний діапазон. Менше — і чанку бракує контексту; більше — і ти знову усереднюєш.
Семантично-орієнтований розбивач для Markdown-документації виглядає так — розбивка по H2/H3, потім забезпечення цільового вікна з перекриттям:
export function chunkMarkdown(doc: string, target = 700, overlap = 120) {
const sections = doc.split(/(?=^#{2,3}\s)/m); // split on H2/H3 boundaries
const chunks: string[] = [];
for (const section of sections) {
if (section.length <= target) {
chunks.push(section.trim());
continue;
}
let i = 0;
while (i < section.length) {
chunks.push(section.slice(i, i + target).trim());
i += target - overlap;
}
}
return chunks.filter(Boolean);
}
Це не RecursiveCharacterTextSplitter від LangChain і не SemanticSplitterNodeParser від LlamaIndex, але він достатньо малий, щоб його прочитати, і ти можеш розширити його під свою форму. Що б ти не використовував, зберігай поряд з кожним чанком достатньо метаданих, щоб відобразити корисне посилання в UI — ідентифікатор документа, заголовок, заголовок секції, URL.
Гібридний пошук перемагає тільки-векторний майже у кожному реальному корпусі
Чистий векторний пошук чудово справляється з «як скасувати підписку?» і дуже погано — з «код помилки 404X-99». Він навчений ігнорувати токени на рівні поверхні. Реальні користувачі поєднують обидва типи запитів в одній сесії.
У production RAG запускай векторний пошук і пошук за ключовими словами паралельно та об'єднуй результати за допомогою Reciprocal Rank Fusion. Якщо твій корпус живе в Postgres, тобі не потрібен другий сервіс — pgvector обробляє ембединги, tsvector обробляє індекс у стилі BM25 за ключовими словами, а об'єднання — це кілька рядків:
async function retrieveCandidates(query: string, k = 25) {
const [vector, keyword] = await Promise.all([
vectorTopK(query, k),
keywordTopK(query, k),
]);
return rrf([vector, keyword], { k: 60 });
}
Якщо ти вже інвестував у Pinecone, Weaviate, Qdrant або Turbopuffer — усі вони тепер нативно надають примітиви гібридного пошуку, і тобі більше не потрібно об'єднувати вручну. Але розумій, що відбувається всередині: два ранжування, одне об'єднання.
Reranker-и — найдешевший виграш у якості, яким ти не користуєшся
Після гібридного пошуку у тебе є ~25 чанків-кандидатів. Якщо ти підставиш усіх їх у промпт, відбудуться три речі: виклик стане дорогим, модель загубить релевантний чанк посередині контекстного вікна (добре задокументований ефект «загублений посередині»), і якість відповіді впаде.
Reranker — це маленька спеціалізована модель, єдина задача якої — оцінити наскільки кожен чанк релевантний конкретному запиту. Cohere Rerank, rerank-2 від Voyage AI та open-source cross-encoder-и на кшталт bge-reranker-v2-m3 роблять одне й те саме: приймають запит плюс список пасажів, повертають оцінки. Вони швидкі, дешеві та значно кращі за векторну подібність у визначенні релевантності.
import { CohereClient } from 'cohere-ai';
const cohere = new CohereClient({ token: process.env.COHERE_API_KEY! });
async function rerank(query: string, candidates: { id: string; text: string }[]) {
const { results } = await cohere.v2.rerank({
model: 'rerank-v3.5',
query,
documents: candidates.map((c) => c.text),
topN: 5,
});
return results.map((r) => candidates[r.index]);
}
Пайплайн стає таким: витягни 25 дешево, відсортуй точно до 5, надішли 3–5 до генеруючої моделі. Вартість затримки reranking-у зазвичай 100–300 мс; виграш у якості — це різниця між «цей продукт працює» і «цей продукт — іграшка».
Заземлені промпти відмовляють — ввічливо
Причина, чому демо відповідало впевнено, коли не мало, — промпт не наказував йому визнавати незнання. За замовчуванням LLM — це корисний розпізнавач шаблонів; якщо надати контекст, який не містить відповіді, він корисно її вигадає. Виправлення — у промпті та в тому, як ти прив'язуєш відповідь до цитат.
const system = [
'You are a documentation assistant.',
'Answer ONLY using the passages inside <context>...</context>.',
'Every factual statement must cite at least one passage by its id, e.g. [doc:42].',
'If the answer is not in the context, reply: "I could not find this in the docs." — nothing else.',
'Do not use general knowledge. Do not infer from outside the context.',
].join('\n');
const user = `<context>
${chunks.map((c) => `<passage id="${c.id}">${c.text}</passage>`).join('\n')}
</context>
Question: ${query}`;
Тут дві деталі виправдовують своє місце. XML-теговані пасажі дають моделі чіткий маркер для цитування і водночас захищають від непрямої ін'єкції промпту з боку шкідливого чанку. Явна фраза відмови («I could not find this in the docs.») — це те, що ти можеш шукати grep-ом у своєму UI, щоб відобразити інший стан — кнопку «все одно пошукати в документах» або переадресацію на підтримку — замість спроби інтерпретувати довільну невідповідь.
Для самої генеруючої моделі gpt-4o, claude-sonnet-4-5 та лінійка Gemini 2.x — всі вони добре справляються із заземленими відповідями. Менші моделі — gpt-4o-mini або локальні варіанти Llama — працюють для вузьких корпусів, але ламаються на складніших запитаннях; звертай увагу на найгірший випадок, а не на середній.
Оцінка важливіша за відчуття
Останнє, що відрізняє робочий RAG від демо: невеликий реальний набір для оцінки, який ти запускаєш перед кожною зміною. 30–50 запитань, кожне з правильною відповіддю та ідентифікатором документа, на який має бути посилання. Перезапускай його щоразу, коли змінюєш розбивач, замінюєш модель для ембедингів, налаштовуєш reranker або переписуєш системний промпт.
Для початку тобі не потрібен складний фреймворк — таблиця Markdown і скрипт достатні. Відстежуй три числа: чи знайшов пошук правильний документ взагалі (recall), чи він був у топ-3 (precision-at-3), і чи модель сформувала правильну заземлену відповідь, отримавши правильний контекст (faithfulness). Якщо зміна погіршить одне з них, ти дізнаєшся одразу, а не від клієнта через два тижні.
Фреймворки Ragas, LangSmith та Braintrust автоматизують ту саму ідею, коли ти виростаєш із саморобного скрипту.
Де RAG перестає бути правильним інструментом
Коротка нотатка, якої бракує в більшості RAG-матеріалів. RAG — правильна відповідь, коли твій корпус великий, мінливий і побудований навколо документів, які люди читають. Це неправильна відповідь, коли твої дані дуже реляційні (граф замовлень, клієнтів і SKU), коли запитання користувача вимагає агрегації по багатьох записах, або коли актуальність потрібна посекундно, а твій індексер не встигає.
У таких випадках тобі потрібні tool calls, що звертаються до твоїх реальних API — getCustomerOrders, searchInventory — і дозволяють моделі їх оркеструвати. Результат більше не «підсумуй цей абзац», а «ось дані, які ти запитав». Поєднання обох підходів в одному продукті — це нормально і все частіше зустрічається; просто будь чесним щодо того, який підхід підходить для якого запиту.
Ментальна модель в одному реченні
Робочий RAG витягує широко за допомогою гібридного пошуку, звужує точно за допомогою reranker-а і відповідає із заземленого промпту, який відмовляє, коли контекст відсутній — розбивка на чанки задає стелю, reranking розкриває більшість якості, а набір із 50 запитань для оцінки захищає від деплою регресій, яких ти не бачиш.






