Давайте подробнее рассмотрим, как работает механизм поиска.
Делиться

В своих последних публикациях я рассмотрел создание простого конвейера RAG с использованием API OpenAI, LangChain и локальных файлов, а также эффективное разделение больших текстовых файлов на фрагменты. В этих публикациях рассматриваются основы настройки конвейера RAG, способного генерировать ответы на основе содержимого локальных файлов.

Итак, до сих пор мы говорили о чтении документов из любого места их хранения, разбиении их на текстовые фрагменты и создании вложений для каждого фрагмента. После этого мы каким-то волшебным образом подбираем вложения, соответствующие запросу пользователя, и генерируем релевантный ответ. Но важно также понять, как на самом деле работает этап извлечения данных в RAG.
🍨 DataCream — это новостная рассылка с историями и обучающими материалами по искусственному интеллекту, данным и технологиям. Если вам интересны эти темы, подпишитесь здесь.
Итак, в этой публикации мы пойдём ещё дальше, подробно изучив механизм поиска и проанализировав его. Как и в предыдущей публикации, в качестве примера я буду использовать текст «Войны и мира», лицензированный как общественное достояние и легко доступный через Project Gutenberg.
А как насчет встраивания?
Чтобы понять, как работает этап извлечения данных в рамках RAG, крайне важно сначала понять, как текст преобразуется и представляется во вложениях. Чтобы LLM могли обрабатывать текст, он должен быть представлен в форме вектора, а для выполнения этого преобразования необходимо использовать модель вложения.
Эмбеддинг — это векторное представление данных (в нашем случае текста), отражающее их семантическое значение. Каждое слово или предложение исходного текста сопоставляется с многомерным вектором. Модели эмбеддинга, используемые для выполнения этого преобразования, разработаны таким образом, что сходные значения приводят к векторам, близким друг к другу в векторном пространстве. Например, векторы для слов «happy» и «joyful» будут близки друг к другу в векторном пространстве, тогда как вектор для слова «sad» будет далёк от них.
Для создания высококачественных встраиваемых моделей, эффективно работающих в конвейере RAG, необходимо использовать предобученные модели встраиваемых моделей, например, модели OpenAI. Существуют различные типы встраиваемых моделей, которые можно создать, и соответствующие им модели. Например:
- Эмбеддинги слов : при эмбеддингах слов каждое слово имеет фиксированный вектор независимо от контекста. Популярные модели для создания таких эмбеддингов — Word2Vec и GloVe.
- Контекстные встраивания : Контекстные встраивания учитывают, что значение слова может меняться в зависимости от контекста. Возьмём, например, берег реки и открытие банковского счёта. Для создания контекстных встраиваний можно использовать модели BERT и OpenAI (например, text-embedding-ada-002).
- Встроенные предложения : это вложения, передающие смысл полных предложений. Популярной моделью для создания встраиваемых предложений является Sentence-BERT.
В любом случае, чтобы текст можно было использовать в вычислениях, его необходимо преобразовать в векторы. Эти векторы — просто представления текста. Другими словами, векторы и числа сами по себе не имеют никакого смысла. Их польза заключается в том, что они отражают сходства и взаимосвязи между словами или фразами в математической форме.
Например, мы могли бы представить себе небольшой словарь, состоящий из слов «король», «королева», «женщина» и «мужчина», и присвоить каждому из них произвольный вектор.
король = [0,25, 0,75] королева = [0,23, 0,77] мужчина = [0,15, 0,80] женщина = [0,13, 0,82]
Затем мы могли бы попробовать выполнить некоторые векторные операции, например:
король — мужчина + женщина = [0,25, 0,75] — [0,15, 0,80] + [0,13, 0,82] = [0,23, 0,77] ≈ королева 👑
Обратите внимание, как семантика слов и связи между ними сохраняются после преобразования их в векторы, что позволяет нам выполнять операции.
Итак, вложение — это всего лишь преобразование слов в векторы, направленное на сохранение смысла и взаимосвязей между словами и позволяющее выполнять с ними вычисления. Мы даже можем визуализировать эти фиктивные векторы в векторном пространстве, чтобы увидеть, как связанные слова группируются вместе.

Разница между этими простыми векторными примерами и реальными векторами, созданными моделями встраивания, заключается в том, что реальные модели встраивания генерируют векторы с сотнями измерений . Двумерные векторы полезны для построения интуитивного представления о том, как значение может быть отображено в векторном пространстве, но они слишком низкоразмерны, чтобы охватить сложность реального языка и лексики. Вот почему реальные модели встраивания работают с гораздо более высокими измерениями, часто в сотнях или даже тысячах. Например, Word2Vec создает 300-мерные векторы, в то время как BERT Base создает 768-мерные векторы. Эта более высокая размерность позволяет встраиваниям фиксировать множественные измерения реального языка, такие как значение, использование, синтаксис и контекст слов и фраз. В конечном счете, этот упрощенный двумерный пример позволяет нам построить некоторое интуитивное представление о том, что такое встраивание — тем не менее, оно довольно упрощенное и не обязательно то, что следует ожидать увидеть в реальных моделях.
Оценка сходства вложений
После преобразования текста в вложения, вывод становится векторной математикой. Именно это позволяет нам идентифицировать и извлекать релевантные документы на этапе извлечения в рамках фреймворка RAG. Преобразовав запрос пользователя и документы базы знаний в векторы с помощью модели вложения, мы можем вычислить степень их сходства, используя подходящую метрику, например, косинусное сходство, евклидово расстояние (расстояние L2) или скалярное произведение.
Косинусное сходство — это мера сходства двух векторов (вложений). Для двух векторов A и B косинусное сходство вычисляется следующим образом:

Проще говоря, косинусное подобие рассчитывается как косинус угла между двумя векторами и принимает значения от 1 до -1. Более конкретно:
- 1 указывает на то, что векторы семантически идентичны (например, car и automobile).
- 0 указывает на то, что векторы не имеют семантической связи (например, банан и правосудие).
- -1 указывает на то, что векторы совершенно противоположны, но на практике вложения не приводят к отрицательным сходствам, даже для таких антонимов, как «горячий» и «холодный».
Это связано с тем, что даже семантически противоположные слова (например, «горячо» и «холодно») часто встречаются в схожих контекстах (например, «становится жарко» и «становится холодно»). Чтобы косинусное сходство достигало -1, сами слова и их контексты должны быть абсолютно противоположными, чего в естественном языке обычно не происходит. В результате даже противоположные слова обычно имеют вложения, которые всё ещё довольно близки по значению. На практике оценки сходства обычно положительны.
Помимо косинусного сходства, к другим метрикам сходства относятся скалярное произведение (внутреннее произведение) и евклидово расстояние (расстояние L2). В отличие от косинусного сходства, скалярное произведение и евклидово расстояние зависят от величины, а это означает, что длина вектора влияет на результат. Чтобы использовать скалярное произведение в качестве меры сходства, эквивалентной косинусному сходству, необходимо сначала нормализовать векторы до единичной длины. Это связано с тем, что косинусное сходство математически равно скалярному произведению двух нормализованных векторов. Таким образом, аналогично косинусному сходству, более похожие векторы будут иметь большее скалярное произведение.
С другой стороны, евклидово расстояние измеряет расстояние по прямой между двумя векторами в пространстве вложений. В этом случае более похожие векторы будут иметь меньшее евклидово расстояние.
Возвращаясь к нашему конвейеру RAG, вычисляя оценки сходства между встраиваниями запроса пользователя и встраиваниями базы знаний, мы можем определить фрагменты текста, которые наиболее похожи (и, следовательно, контекстно релевантны) вопросу пользователя, извлечь их, а затем использовать для генерации ответа.
Поиск первых k похожих фрагментов
Итак, после получения вложений из базы знаний и вложений текста запроса пользователя, происходит настоящее чудо. По сути, мы вычисляем косинусное сходство между вложением запроса пользователя и вложением базы знаний. Таким образом, для каждого фрагмента текста базы знаний мы получаем оценку от 1 до -1, указывающую на сходство фрагмента с запросом пользователя.
Получив оценки сходства, мы сортируем их по убыванию и выбираем k лучших фрагментов. Эти k лучших фрагментов затем передаются на этап генерации конвейера RAG, что позволяет ему эффективно извлекать релевантную информацию по запросу пользователя.
Для ускорения этого процесса часто используется поиск по приближенному методу ближайшего соседа (ANN). ANN находит векторы, наиболее похожие друг на друга, выдавая результаты, близкие к истинному топ-N, но гораздо быстрее, чем методы точного поиска. Конечно, точный поиск точнее; тем не менее, он также требует больших вычислительных затрат и может плохо масштабироваться в реальных приложениях, особенно при работе с большими наборами данных.
Кроме того, к оценкам сходства может быть применено пороговое значение, чтобы отфильтровать фрагменты, не соответствующие минимальному уровню релевантности. Например, в некоторых случаях фрагмент может быть рассмотрен только в том случае, если его оценка сходства превышает определённый порог (например, косинусное сходство > 0,3).
Так кто же такая Анна Павловна?
В примере «Войны и мира», как показано в моей предыдущей публикации, мы разбиваем весь текст на фрагменты и затем создаём соответствующие вложения для каждого фрагмента. Затем, когда пользователь отправляет запрос, например, «Кто такая Анна Павловна?», мы также создаём соответствующие вложения для текста запроса пользователя.
import os from langchain.chat_models import ChatOpenAI from langchain.document_loaders import TextLoader from langchain.embeddings import OpenAIEmbeddings from langchain.vectorstores import FAISS from langchain.text_splitter import RecursiveCharacterTextSplitter from langchain.docstore.document import Document api_key = 'your_api_key' # инициализация LLM llm = ChatOpenAI(openai_api_key=api_key, model=»gpt-4o-mini», temperature=0.3) # инициализация встраиваний model embeddings = OpenAIEmbeddings(openai_api_key=api_key) # загрузка документов для использования в RAG text_folder = «RAG files» documents = [] for filename in os.listdir(text_folder): if filename.lower().endswith(«.txt»): file_path = os.path.join(text_folder, filename) loader = TextLoader(file_path) documents.extend(loader.load()) splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=100) split_docs = [] для doc в documents: chunks = splitter.split_text(doc.page_content) для chunk в chunks: split_docs.append(Document(page_content=chunk)) documents = split_docs # создать базу данных векторов w FAISS vector_store = FAISS.from_documents(documents, embeddings) retriever = vector_store.as_retriever() def main(): print(«Добро пожаловать в RAG Assistant. Введите 'exit' для выхода.n») while True: user_input = input(«Вы: «).strip() if user_input.lower() == «exit»: print(«Выход…») break # получить соответствующие документы relevant_docs = retriever.invoke(user_input) retrieved_context = «nn».join([doc.page_content for doc in relevant_docs]) # системное приглашение system_prompt = ( «Вы полезный помощник. » «Используйте ТОЛЬКО следующий контекст базы знаний, чтобы ответить пользователю. » «Если ответа нет в контексте, скажите, что вы не знаете.nn» f»Context:n{retrieved_context}» ) # сообщения для LLM messages = [ {«role»: «system», «content»: system_prompt}, {«role»: «user», «content»: user_input} ] # сформировать ответ response = llm.invoke(messages) assistant_message = response.content.strip() print(f»nAssistant: {assistant_message}n») если __name__ == «__main__»: main()
В этом скрипте я использую объект-ретривер LangChain: retriver = vector_store.as_retriever(), который по умолчанию использует метрику сходства базового индекса FAISS. FAISS предоставляет два индекса:
- IndexFlatL2 использует расстояние L2. При использовании LangChain с FAISS (как мы) индекс по умолчанию обычно — IndexFlatL2.
- IndexFlatIP, который использует скалярное произведение (внутреннее произведение)
Таким образом, в исходном скрипте фрагменты данных извлекаются с использованием расстояния L2 в качестве метрики. Этот скрипт также по умолчанию извлекает k=4 наиболее похожих фрагментов. Другими словами, мы извлекаем k наиболее релевантных запросу пользователя фрагментов данных на основе расстояния L2.
Таким образом, чтобы использовать косинусное сходство в качестве метрики поиска вместо L2, которая использовалась по умолчанию, нам нужно немного доработать наш исходный код. В частности, нам нужно нормализовать вложения (как вложения запроса пользователя, так и вложения базы знаний) и настроить хранилище векторов на использование скалярного произведения (внутреннего произведения) в качестве меры сходства вместо расстояния L2. Чтобы нормализовать вложения базы знаний, мы можем добавить следующий фрагмент после этапа фрагментации:
… documents = split_docs # нормализовать вложения базы знаний import numpy as np def normalize(vectors): vectors = np.array(vectors) norms = np.linalg.norm(vectors, axis=1, keepdims=True) return vectors / norms doc_texts = [doc.page_content for doc in documents] doc_embeddings = embeddings.embed_documents(doc_texts) doc_embeddings = normalize(doc_embeddings) # индекс faiss со внутренним произведением import faiss dimension = doc_embeddings.shape[1] index = faiss.IndexFlatIP(dimension) # индекс внутреннего произведения index.add(doc_embeddings) # создать базу данных векторов w FAISS vector_store = FAISS(embedding_function=embeddings, index=index, docstore=None, index_to_docstore_id=None) vector_store.docstore = {i: doc для i, doc в enumerate(documents)} retriever = vector_store.as_retriever() …
Поскольку мы делаем всё вручную, мы можем пока опустить функцию Retriever = Vector_store.as_retriever(). Также нам нужно добавить следующую часть в нашу функцию main(), чтобы нормализовать запрос пользователя:
… if user_input.lower() == «exit»: print(«Выход…») break # встраивание + нормализация запроса query_embedding = embeddings.embed_query(user_input) query_embedding = normalize([query_embedding]) # поиск по индексу FAISS D, I = index.search(query_embedding, k=2) # получение релевантных документов relevant_docs = [vector_store.docstore[i] for i in I[0]] retrieved_context = «nn».join([doc.page_content for doc in relevant_docs]) …
Обратите внимание, как мы можем явно определить количество извлеченных фрагментов k, теперь заданное как k=2.
Вдобавок к этому, чтобы вывести косинусные подобия, я также добавлю следующую часть в функцию main():
… retrieved_context = «nn».join([doc.page_content for doc in relevant_docs]) # D содержит оценки внутреннего произведения == косинусное сходство (после нормализации) print(«n5 лучших фрагментов и их оценки косинусного сходства:n») for rank, (idx, score) in enumerate(zip(I[0], D[0]), start=1): print(f»Фрагмент {rank}:») print(f»Косинусное сходство: {score:.4f}») print(f»Содержимое:n{vector_store.docstore[idx].page_content}n{'-'*40}») …
Наконец, мы снова можем спросить и получить ответ:

… но теперь мы также можем видеть текстовые фрагменты, на основе которых создан этот ответ, и соответствующие им оценки косинусного сходства…

Очевидно, что разные параметры могут приводить к разным результатам. Например, мы получим немного разные результаты при извлечении первых результатов с k=2, k=4 и k=10. Учитывая дополнительные параметры, используемые на этапе фрагментации, такие как размер фрагмента и перекрытие фрагментов, становится очевидно, что параметры играют решающую роль в получении хороших результатов от конвейера RAG.
• • •
Понравился этот пост? Давайте дружить! Присоединяйтесь ко мне:
📰 Substack 💌 Medium 💼 LinkedIn ☕ Купите мне кофе!
• • •
А как насчет пиалгоритмов?
Хотите использовать возможности RAG в своей организации?
pialgorithms может сделать это для вас 👉 закажите демонстрацию сегодня!
Источник: towardsdatascience.com



























