Я прошёл через создание простого конвейера RAG с использованием API OpenAI, LangChain и локальных файлов, а также эффективно разделил большие текстовые файлы на части.
Эти посты охватывают основы настройки конвейера RAG, способного генерировать ответы на основе содержимого локальных файлов.
Итак, что такое вложения (embeddings)?
Чтобы понять, как работает этап извлечения в системе RAG, важно сначала разобраться, как текст преобразуется и представляется во вложениях. Для того чтобы модели обработки естественного языка (LLM) могли работать с любым текстом, он должен быть представлен в виде вектора, и для этого преобразования необходимо использовать модель вложений.
Вложение — это векторное представление данных (в нашем случае текста), которое отражает его семантическое значение. Каждое слово или предложение исходного текста сопоставляется с высокоразмерным вектором. Модели вложений, используемые для выполнения этого преобразования, разработаны таким образом, что схожие по смыслу векторы оказываются близко друг к другу в векторном пространстве. Например, векторы для слов happy и joyful будут близки друг к другу в векторном пространстве, тогда как вектор для слова sad будет от них далёк.
Чтобы создать высококачественные вложения, которые эффективно работают в конвейере RAG, необходимо использовать предварительно обученные модели вложений, такие как модели вложений OpenAI. Существует несколько типов вложений:
- Вложения слов (Word Embeddings): в них каждому слову присваивается фиксированный вектор независимо от контекста. Популярными моделями для создания этого типа вложений являются Word2Vec и GloVe.
- Контекстуальные вложения (Contextual Embeddings): они учитывают, что значение слова может меняться в зависимости от контекста. Например, the bank of a river и opening a bank account. Некоторые модели, которые можно использовать для создания контекстуальных вложений, — это BERT и модели вложений OpenAI.
- Вложения предложений (Sentence Embeddings): это вложения, которые захватывают смысл полных предложений. Популярной моделью для создания вложений предложений является Sentence-BERT.
В любом случае текст должен быть преобразован в векторы, чтобы его можно было использовать в вычислениях. Эти векторы — это просто представления текста. Другими словами, векторы и числа сами по себе не имеют никакого смысла. Вместо этого они полезны, поскольку в математической форме фиксируют сходства и отношения между словами или фразами.
Например, мы могли бы представить себе небольшой словарь, состоящий из слов king, queen, woman и man, и присвоить каждому из них произвольный вектор.
king = [0.25, 0.75]
queen = [0.23, 0.77]
man = [0.15, 0.80]
woman = [0.13, 0.82]
Затем мы могли бы попробовать выполнить некоторые векторные операции, например:
king - man + woman
= [0.25, 0.75] - [0.15, 0.80] + [0.13, 0.82]
= [0.23, 0.77]
≈ queen
Обратите внимание, как семантика слов и отношения между ними сохраняются после сопоставления их с векторами, что позволяет нам выполнять операции.
Таким образом, вложение — это просто сопоставление слов с векторами, направленное на сохранение смысла и отношений между словами и позволяющее выполнять с ними вычисления. Мы даже можем визуализировать эти фиктивные векторы в векторном пространстве, чтобы увидеть, как связанные слова группируются вместе.
Разница между этими простыми векторными примерами и реальными векторами, создаваемыми моделями вложений, заключается в том, что реальные модели вложений генерируют векторы с сотнями измерений. Двумерные векторы полезны для построения интуиции о том, как смысл может быть отображён в векторное пространство, но они слишком низкоразмерны, чтобы отразить сложность реального языка и словарного запаса. Поэтому реальные модели вложений работают с гораздо более высокими размерностями, часто в сотнях или даже тысячах. Например, Word2Vec создаёт 300-мерные векторы, а BERT Base — 768-мерные векторы.
Оценка сходства вложений
После того как текст преобразован во вложения, вывод становится векторной математикой. Именно это позволяет нам идентифицировать и извлекать соответствующие документы на этапе извлечения в системе RAG. Как только мы превратим и запрос пользователя, и документы из базы знаний в векторы с помощью модели вложений, мы сможем вычислить, насколько они похожи, используя соответствующую метрику, такую как косинусное сходство, евклидово расстояние (L2-расстояние) или скалярное произведение.
Косинусное сходство — это мера того, насколько похожи два вектора (вложения). Даны два вектора A и B, косинусное сходство рассчитывается следующим образом:
Просто говоря, косинусное сходство рассчитывается как косинус угла между двумя векторами, и оно варьируется от 1 до -1. Более конкретно:
- 1 указывает на то, что векторы семантически идентичны (например, car и automobile).
- 0 указывает на то, что векторы не имеют семантической связи (например, banana и justice).
- -1 указывает на то, что векторы идеально противоположны, но на практике вложения не дают отрицательных значений сходства даже для антонимов, таких как hot и cold.
Это связано с тем, что даже семантически противоположные слова (например, hot и cold) часто встречаются в похожих контекстах (например, it’s getting hot и it’s getting cold). Для того чтобы косинусное сходство достигло -1, слова и их контексты должны быть идеально противоположными — что-то, что на самом деле не происходит в естественном языке. В результате даже противоположные слова обычно имеют вложения, которые всё ещё несколько близки по смыслу. На практике значения сходства обычно положительны.
Другие метрики сходства, помимо косинусного сходства, включают скалярное произведение (внутренний продукт) и евклидово расстояние (L2-расстояние). В отличие от косинусного сходства, скалярное произведение и евклидово расстояние зависят от величины, то есть длина вектора влияет на результат. Чтобы использовать скалярное произведение в качестве меры сходства, эквивалентной косинусному сходству, необходимо сначала нормализовать векторы до единичной длины. Это связано с тем, что косинусное сходство математически равно скалярному произведению двух нормализованных векторов. Таким образом, аналогично косинусному сходству, более похожие векторы будут иметь большее скалярное произведение.
С другой стороны, евклидово расстояние измеряет прямолинейное расстояние между двумя векторами в пространстве вложений. В этом случае более похожие векторы будут иметь меньшее евклидово расстояние.
Вернувшись к нашему конвейеру RAG, мы вычисляем оценки сходства между вложениями запроса пользователя и вложениями базы знаний. Таким образом, для каждого текстового фрагмента базы знаний мы получаем оценку между 1 и -1, указывающую на схожесть фрагмента с запросом пользователя.
После получения оценок сходства мы сортируем их в порядке убывания и выбираем топ-k фрагментов. Эти топ-k фрагментов затем передаются на этап генерации конвейера 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 = ChatOpenAI(openai_api_key=api_key, model="gpt-4o-mini", temperature=0.3)
embeddings = OpenAIEmbeddings(openai_api_key=api_key)
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 = []
for doc in documents:
chunks = splitter.split_text(doc.page_content)
for chunk in chunks:
split_docs.append(Document(page_content=chunk))
documents = split_docs
vector_store = FAISS.from_documents(documents, embeddings)
retriever = vector_store.as_retriever()
def main():
print("Welcome to the RAG Assistant. Type 'exit' to quit.\n")
while True:
user_input = input("You: ").strip()
if user_input.lower() == "exit":
print("Exiting…")
break
relevant_docs = retriever.invoke(user_input)
retrieved_context = "\n\n".join([doc.page_content for doc in relevant_docs])
system_prompt = (
"You are a helpful assistant. "
"Use ONLY the following knowledge base context to answer the user. "
"If the answer is not in the context, say you don't know.\n\n"
f"Context:\n{retrieved_context}"
)
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")
if __name__ == "__main__":
main()
В этом скрипте я использую объект-извлекатель LangChain retriever = 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)
import faiss
dimension = doc_embeddings.shape[1]
index = faiss.IndexFlatIP(dimension)
index.add(doc_embeddings)
vector_store = FAISS(embedding_function=embeddings, index=index, docstore=None, index_to_docstore_id=None)
vector_store.docstore = {i: doc for i, doc in enumerate(documents)}
retriever = vector_store.as_retriever()
...
Поскольку мы делаем всё вручную, мы можем пока опустить retriever = vector_store.as_retriever(). Нам также нужно добавить следующую часть в нашу функцию main(), чтобы нормализовать запрос пользователя:
...
if user_input.lower() == "exit":
print("Exiting…")
break
query_embedding = embeddings.embed_query(user_input)
query_embedding = normalize([query_embedding])
D, I = index.search(query_embedding, k=2)
relevant_docs = [vector_store.docstore[i] for i in I[0]]
retrieved_context = "\n\n".join([doc.page_content for doc in relevant_docs])
...
Обратите внимание, как мы можем явно определить количество извлекаемых фрагментов k, теперь установленное как k=2.
Кроме того, чтобы вывести косинусные сходства, я собираюсь также добавить следующую часть в функцию main():
...
retrieved_context = "\n\n".join([doc.page_content for doc in relevant_docs])
print("\nTop 5 chunks and their cosine similarity scores:\n")
for rank, (idx, score) in enumerate(zip(I[0], D[0]), start=1):
print(f"Chunk {rank}:")
print(f"Cosine similarity: {score:.4f}")
print(f"Content:\n{vector_store.docstore[idx].page_content}\n{'-'*40}")
...
Наконец, мы можем снова задать вопрос и получить ответ:
…
… но теперь мы также можем увидеть текстовые фрагменты, на основе которых был создан этот ответ, и соответствующие оценки косинусного сходства…
Очевидно, что разные параметры могут привести к разным ответам. Например, мы получим несколько разные ответы при извлечении топ-k=2, k=4 и k=10 результатов. Принимая во внимание дополнительные параметры, которые используются на этапе разделения на фрагменты, такие как размер фрагмента и перекрытие фрагментов, становится очевидно, что параметры играют решающую роль в получении хороших результатов от конвейера RAG.
