RAG з LangChain з нуля: завантажувачі, частини даних, ембеддинги, оцінка.
Створіть систему Q&A для документів крок за кроком: завантаження файлів, розділення на частини, ембеддинги, етапи індексування та пошуку, а також способи оцінки результатів пошуку та відповідей.
Джерела: Building a RAG System with LangChain
Система, яка читає ваші документи та відповідає на запитання щодо них, — це одна з найкорисніших речей, які ви можете створити за допомогою LLM сьогодні. Цей підхід називається RAG — retrieval-augmented generation. Ось усі складові, для чого вони потрібні, та як з’ясувати, чи є ваш проект ефективним.
Що таке LangChain
LangChain — це не модель і не база даних. Це фреймворк, ближчий до універсального адаптера. Існує десятки постачальників LLM із власними API, десятки векторних баз даних та десятки засобів для завантаження документів. Без проміжного шару використання GPT-4 сьогодні та Claude завтра означатиме переписування половини вашого коду. LangChain забезпечує єдиний послідовний інтерфейс для всього цього.
Назва походить від його другої функції. Справжній додаток на основі LLM рідко є результатом лише одного виклику моделі. Можливо, потрібно завантажити документ, підсумувати його, надіслати ще один запит до цього підсумку та сформатувати результат. Ланцюг об’єднує ці кроки в єдиний процес. У випадку RAG ланцюг починається з запиту користувача. Цей запит піддається ембеддингу, ембеддинг використовується для пошуку в базі даних, а найкращі результати вставляються у шаблон запиту. LLM викликається з цим запитом, його відповідь аналізується, а отримана відповідь надсилається користувачеві. LangChain забезпечує елементи для кожного кроку, тому не потрібно все склеювати вручну.
Завантаження документів
Спочатку документи мають потрапити всередину, причому не у вигляді сирих байтів. Незалежно від того, чи є дані у форматі PDF, веб-сторінки, CSV, файлу Word чи папки з маркдауном, завантажувач перетворює їх на стандартний об’єкт Document із двома полями: page_content для тексту та metadata для інформації про джерело (сторінку, URL чи будь-які корисні дані). Метадані мають важливе значення пізніше, адже вони дозволяють показувати користувачам, звідки походить відповідь.
from langchain.document_loaders import PyPDFLoader
loader = PyPDFLoader("handbook.pdf")
documents = loader.load()
Це дає по одному Document на сторінку PDF, кожен з яких містить власний текст та номер сторінки. Саме таку звичку варто сформувати якомога раніше: завжди зберігайте походження тексту разом із ним, адже кожен наступний крок — від розділення на частини до посилання на джерело у кінцевій відповіді — залежить від цього сліду. Якщо замінити завантажувач на інший тип файлу, формат вихідних даних залишиться незмінним.
Розділення на частини
Документ може бути 200-сторінковим PDF. Навіть за наявності великого вікна контексту вставляння цілих книг у кожен запит є марнотратним, дорогим та часто гіршим за надання моделі фокусованих уривків. Дуже довгі документи розділяються на частини по кілька сотень токенів, і лише найбільш релевантні частини надсилаються до LLM, коли хтось ставить запитання.
Два параметри мають найбільше значення. Розмір частин — це довжина кожної частини тексту: якщо вона занадто мала, ви втрачаєте контекст, а якщо занадто велика, до неї додається нерелевантний текст. Приблизно 500 токенів є розумною відправною точкою. Перекриття — це ступінь спільності сусідніх частин. Якщо перекриття відсутнє, речення, розташоване між двома частинами, втрачає свій сенс, тоді як за наявності перекриття обидві частини бачать зв’язок між ними.
RecursiveCharacterTextSplitter є звичайним вибором. Він віддає перевагу природним межам, спочатку намагаючись розділити текст на абзаци, потім речення, а потім слова, і лише у крайньому випадку розділяє текст посеред слова. Існують також спеціалізовані варіанти: MarkdownHeaderTextSplitter для документів із заголовками та TokenTextSplitter, коли потрібно підраховувати справжні токени, а не символи.
from langchain.text_splitter import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=500,
chunk_overlap=50
)
chunks = splitter.split_documents(documents)
Розділення тексту на частини здається тривіальним, але суттєво впливає на якість. Це також налаштування, яке люди коригують останнім, хоча погане розділення тихо обмежує всі подальші процеси. Якщо частини неправильні, жоден трюк зі збору інформації не зможе врятувати систему.
Ембеддинги
Комп’ютери не можуть запитати «яка частина тексту схожа на моє запитання?» щодо необробленого тексту. Модель ембеддингів, яку також називають кодувачем, перетворює текст на вектор — список чисел, причому ключовою властивістю є те, що текст із схожим значенням отримує схожі вектори.
Уявіть собі, що кожен текст отримує адресу у просторі з сотнями чи тисячами вимірів. Собака, яка гавкає, та цуценя, яке щелкає зубами, мають сусідні адреси, тоді як крах фондового ринку знаходиться десь далеко. Порівняння значень перетворюється на геометрію: наскільки близькі дві точки?
Два уточнення допоможуть. По-перше, вектори створює кодер, а не база даних векторів. Chroma, Pinecone, Weaviate та Qdrant лише зберігають вектори та швидко їх пошукують, причому жоден з них не розуміє мови. Люди постійно плутають це. По-друге, розмір вектора залежить від кодера. Модель OpenAI text-embedding-3-small генерує вектори з 1536 вимірами, тоді як модель з відкритим кодом, наприклад all-MiniLM-L6-v2, — з 384 вимірами. Більше вимірів дозволяє зафіксувати більше нюансів, але це коштує більше місця для зберігання та повільнішого пошуку.
Справжні ембеддинги неможливо уявити, тому люди проєктують їх у 2D чи 3D формат. Якщо ембеддингувати набір слів та проєктувати їх, з’являються кластери: тварини разом, фрукти в іншому місці, транспортні засоби — у ще одному кутку. Моделі ніхто не пояснював ці категорії; вона навчилася на величезних обсягах тексту того, що «собака» та «кіт» з’являються в схожих контекстах.
Саме це забезпечує функціонування RAG. Якщо користувач запитує, що каже компанія про батьківську відпустку, вектор запиту потрапляє поблизу фрагментів про „переваги сімейної відпустки“ чи „відпустку для новоспечених батьків“, хоча ці слова відрізняються.
Процес обробки
Існують дві фази. Індексування відбувається один раз під час налаштування: завантажуються дані, вони діляться на частини, кожна з цих частин вбудовується, а вектори зберігаються. Нові документи пізніше просто вбудовуються та додаються. Отримання інформації виконується з кожним запитом: запит також вбудовується за допомогою того самого кодера, знаходяться найближчі частини в базі даних векторів, вони додаються до запиту у вигляді промпту та надсилаються до ШІ, яке формує свою відповідь на основі ваших документів, а не припущень. Одна база даних векторів обслуговує обидві фази — до неї записуються дані під час індексування, а з неї читаються під час отримання інформації. Ось вся архітектура. Оскільки індексування є одноразовою операцією, повільна частина (вбудовування тисяч частин) не впливає на час очікування користувача. Впливають лише вбудовування окремого запиту, пошук та звернення до ШІ.
Під час виконання беруть участь чотири компоненти: користувач, код вашого додатку, механізм пошуку та LLM. Користувач надсилає запит до вашого коду. Ваш код просить механізм пошуку надати відповідні фрагменти інформації, а механізм пошуку включає запит та шукає його у базі даних. Повертаються K найбільш схожих фрагментів. Ваш код створює запит на основі цих фрагментів разом із початковим запитом та надсилає його до LLM, який генерує відповідь на основі наданого контексту. Ваш код повертає цю відповідь.
LangChain охоплює більшу частину цього процесу за допомогою двох абстракцій. LLM (або ChatModel) обгортає ваш модель, а Retriever — процес пошуку у векторному сховищі. Під’єднавши їх, фреймворк виконує решту завдань:
from langchain.chains import RetrievalQA
qa_chain = RetrievalQA.from_chain_type(
llm=ChatOpenAI(),
retriever=vectorstore.as_retriever(),
return_source_documents=True
)
response = qa_chain.invoke({"query": "What is our parental leave policy?"})
Це працююча система RAG у кількох рядках, і саме тут виникає спокуса зупинитися. Не робіть цього. Система, яка відповідає, ще не є системою, якій можна довіряти.
Чому потрібна оцінка
Пастка полягає у тому, що ставлять одне запитання, отримують правдоподібну відповідь та вважають, що все готово. Система RAG може давати проблеми непомітним чином: механізм пошуку може вибрати неправильні фрагменти, модель LLM може створювати халюцинації навіть за наявності гарного контексту, а розділення тексту на фрагменти може розрізати відповідь навпіл. Оцінка допомагає з’ясувати, що саме не працює: процес пошуку, генерація чи обидва аспекти, адже для кожного з них потрібні різні рішення. Вона також слугує критерієм оцінки, адже ви будете пробувати різні розміри фрагментів, кодери та запити, і без вимірювань кожна зміна залишається припущенням.
Показники ефективності пошуку
Чотири стандартні показники, кожен з яких вказує на щось окреме:
- Recall@K: із документів, які мали з’явитися, скільки потрапило до топ-K? Якщо у корпусі є 4 релевантних документи, а 3 з них у вашому топ-5, то Recall@5 становить 0,75. Високий показник recall означає, що майже нічого не пропущено.
- Precision@K: із K повернутих результатів, скільки з них було релевантними? Три хороших результати з п’яти дають 0,60. Висока точність означає мінімум шуму.
- MRR (Mean Reciprocal Rank): враховується лише позиція першого правильного результату. Перший успішний пошук на позиції 2 оцінюється у 0,50, а на позиції 1 — у 1,0. Цей показник винагороджує швидке знаходження правильної відповіді.
- nDCG (Normalized Discounted Cumulative Gain): найбільш досконалий показник. Він враховує ступінь релевантності кожного документа та його позицію, тому більш високорангові релевантні елементи мають більший вплив, а також порівнюється з найкращою можливою рангіровкою.
На практиці існує два показники: один для оцінки повноти (Recall) та інший — для оцінки якості порядку результатів (MRR або nDCG).
Якість відповіді
Навіть ідеальний пошук може дати погану відповідь, тому оцінюйте їх окремо. Ви починаєте з того самого підготовленого набору тестів, де кожен запис поєднує запитання з фрагментами тексту, які мають бути повернуті, та зразковою відповіддю. Чотири вищезгадані показники пошуку охоплюють першу половину оцінки. Щодо самих відповідей, оцінюйте їх за іншими критеріями:
- Вірність: чи ґрунтується вона на отриманому контексті чи є вигадкою?
- Релевантність: чи відповідає вона запитанню?
- Правильність: як вона порівнюється з істинною відповіддю?
- Повнота: чи охоплює вона все, що мала б?
Звичайний метод — це використання другого LLM у ролі судді. Надайте йому запитання, відповідь та істинний результат та попросіть оцінити кожен аспект. Цей підхід не є ідеальним, але дозволяє масштабуватися.
Набір тестів
Кожен з вищезазначених кроків оцінки ґрунтується на одному: наборі запитань із відомими правильними відповідями.
Це найбільш недооцінений елемент. Без нього неможливо провести жодної оцінки. Мета — мати від 50 до кількох сотень прикладів, кожен з яких має реалістичне запитання, фрагменти з відповіддю та ідеальну відповідь. Джерелом можуть бути справжні запити користувачів, якщо система працює, або синтетичні дані: передайте свої документи LLM, нехай він створить пари запитань та відповідей, а потім перегляньте та виправте їх.
Захищайте цей набір. Як тільки він створений, кожна зміна оцінюється за його мірками, і саме так ви дізнаєтесь, чи допомогла зміна.
Де все ускладнюється
У суті, RAG полягає у наступному: розділяти документи на частини, векторизувати кожну з цих частин, зберігати їх, включати користувацькі запити, знаходити найближчі вектори та передавати ці частини моделі LLM. LangChain надає стандартні елементи для кожного кроку, а оцінки показують ефективність їх роботи. Справжній продукт ставить складніші запитання: який розмір частин слід використовувати для документів, який кодер є найкращим, як підтримувати індекс у актуальному стані, коли потрібно переорієнтувати ранжування, чи варто використовувати гібридний пошук. Саме ці рішення відрізняють демонстраційну версію від корисного інструменту, проте вони ґрунтуються на вищезазначеній основі.