Как реранжирование улучшает генерацию результатов поиска, выводя на экран наиболее релевантные результаты
Делиться

В моей предыдущей публикации мы рассмотрели, как работает механизм поиска в конвейере RAG. В конвейере RAG релевантные документы из базы знаний определяются и извлекаются на основе степени их схожести с запросом пользователя. В частности, сходство каждого текстового фрагмента количественно оценивается с помощью метрики поиска, например, косинусного сходства, расстояния L2 или скалярного произведения. Затем текстовые фрагменты ранжируются на основе их оценок схожести, и, наконец, мы выбираем текстовые фрагменты, наиболее схожие с запросом пользователя.
К сожалению, высокие показатели сходства не всегда гарантируют идеальную релевантность. Другими словами, поисковая система может извлечь фрагмент текста с высоким показателем сходства, но на самом деле он не так уж и полезен — просто не то, что нужно для ответа на вопрос пользователя 🤷🏻♀️. Именно здесь и вводится повторное ранжирование , чтобы уточнить результаты перед отправкой их в LLM.
Как и в моих предыдущих постах, я снова буду использовать в качестве примера текст «Войны и мира», лицензированный как общественное достояние и легко доступный через Project Gutenberg.
🍨 DataCream — это новостная рассылка с историями и обучающими материалами по искусственному интеллекту, данным и технологиям. Если вам интересны эти темы, подпишитесь здесь.
• • •
А как насчет переоценки?
Текстовые фрагменты, извлеченные исключительно на основе метрики поиска (т. е. сырой поиск), могут оказаться не столь полезными по нескольким причинам:
- Полученные фрагменты могут значительно различаться в зависимости от выбранного количества верхних фрагментов k. В зависимости от количества верхних фрагментов k, которые мы извлекаем, мы можем получить совершенно разные результаты.
- Мы можем извлекать фрагменты, которые семантически близки к тому, что мы ищем, но все еще не по теме и, по сути, не подходят для ответа на запрос пользователя.
- Мы можем получить частичные совпадения с определенными словами, включенными в запрос пользователя, что приведет к появлению фрагментов, которые включают эти конкретные слова, но на самом деле нерелевантны.
Возвращаясь к моему любимому вопросу из примера с «Войной и миром», если мы спросим «Кто такая Анна Павловна?» и используем очень малое значение k (например, k = 2), извлеченные фрагменты могут содержать недостаточно информации для исчерпывающего ответа на этот вопрос. И наоборот, если мы допустим извлечение большого количества фрагментов k (например, k = 20), мы, скорее всего, также извлечём несколько нерелевантных фрагментов текста, где «Анна Павловна» просто упоминается, но не является темой фрагмента. Таким образом, значение некоторых из этих фрагментов будет не связано с запросом пользователя и бесполезно для ответа на него. Поэтому нам нужен способ выделить действительно релевантные извлеченные фрагменты текста среди всех извлеченных фрагментов.
Здесь стоит уточнить, что одним из простых решений этой проблемы было бы простое извлечение всех данных и передача их на этап генерации (в LLM). К сожалению, это невозможно по ряду причин, например, из-за того, что у LLM есть определённые контекстные окна, или из-за того, что производительность LLM снижается при переполнении информацией.
Итак, именно эту проблему мы пытаемся решить, вводя этап реранжирования. По сути, реранжирование означает повторную оценку фрагментов, полученных на основе косинусных оценок сходства, с помощью более точного, но при этом более затратного и медленного метода.

Существуют различные методы для этого, например, кросс-кодировщики, использование LLM для переранжирования или эвристики. В конечном счёте, вводя этот дополнительный этап переранжирования, мы, по сути, реализуем так называемый двухэтапный поиск с переранжированием, что является стандартным подходом в отрасли. Это позволяет повысить релевантность извлекаемых фрагментов текста и, как следствие, качество генерируемых ответов.
Итак, давайте рассмотрим более подробно… 🔍
• • •
Переранжирование с помощью кросс-кодера
Кросс-энкодеры — это стандартные модели, используемые для переранжирования в фреймворке RAG. В отличие от функций извлечения, используемых на начальном этапе поиска, которые учитывают только оценки сходства различных текстовых фрагментов, кросс-энкодеры способны выполнять более глубокое сравнение каждого из полученных текстовых фрагментов с запросом пользователя. Более конкретно, кросс-энкодер совместно встраивает документ и запрос пользователя и вычисляет оценку сходства. С другой стороны, при поиске на основе косинусного сходства документ и запрос пользователя встраиваются отдельно друг от друга, а затем вычисляется их сходство. В результате часть информации об исходных текстах теряется при раздельном создании встраиваний, и часть информации сохраняется при совместном встраивании текстов. Следовательно, кросс-энкодер может лучше оценить релевантность между двумя текстовыми фрагментами (то есть запросом пользователя и документом).

Так почему бы изначально не использовать кросс-энкодер? Дело в том, что кросс-энкодер очень медленный. Например, поиск по косинусному сходству примерно для 1000 отрывков занимает меньше миллисекунды. Напротив, использование только кросс-энкодера (например, ms-marco-MiniLM-L-6-v2) для поиска по тому же набору из 1000 отрывков и поиска совпадений по одному запросу будет на порядки медленнее!
Если задуматься, это вполне ожидаемо, поскольку использование кросс-кодировщика означает, что нам приходится сопоставлять каждый фрагмент базы знаний с запросом пользователя и встраивать их сразу же, для каждого нового запроса. В отличие от этого, при поиске на основе косинусного сходства мы создаём все вложения базы знаний заранее и всего один раз, а затем, как только пользователь отправляет запрос, нам остаётся только встроить запрос пользователя и вычислить парное косинусное сходство.
По этой причине мы соответствующим образом настраиваем наш конвейер RAG и получаем лучшее из обоих миров: во-первых, мы сужаем круг потенциальных релевантных фрагментов с помощью поиска по косинусному сходству, а затем, на втором этапе, мы более точно оцениваем сходство извлеченных фрагментов с помощью кросс-кодера.
• • •
Возвращаемся к примеру «Войны и мира»
Давайте теперь посмотрим, как все это проявляется на примере «Войны и мира», ответив еще раз на мой любимый вопрос: «Кто такая Анна Павловна?».
Мой код на данный момент выглядит примерно так:
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 import faiss api_key = «my_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 # нормализовать вложения базы знаний 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 с импортом внутреннего произведения 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 for i, doc in enumerate(documents)} def main(): print(«Добро пожаловать в RAG Assistant. Введите 'exit' для выхода.n») while True: user_input = input(«Вы: «).strip() 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]) # D содержит оценки внутреннего произведения == косинусное сходство (после нормализации) print(«nЛучшие фрагменты и их оценки косинусного сходства:n») for rank, (idx, score) in enumerate(zip(I[0], D[0]), start=1): print(f»Фрагмент {rank}:») print(f»Косинусное сходство: {score:.4f}») print(f»Content:n{vector_store.docstore[idx].page_content}n{'-'*40}») # системное приглашение 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») if __name__ == «__main__»: main()
При k = 2 мы получаем следующие верхние фрагменты.

Но если мы установим k = 6, то получим следующие извлеченные фрагменты и более информативный ответ, содержащий дополнительные данные по нашему вопросу, например, тот факт, что она «фрейлина и фаворитка императрицы Марии Федоровны».

Теперь давайте скорректируем наш код, чтобы переранжировать эти 6 фрагментов и проверить, остались ли первые два прежними. Для этого мы будем использовать модель кросс-кодирования для переранжирования первых k извлеченных документов перед их передачей вашему LLM. В частности, я буду использовать кросс-кодирование cross-encoder/ms-marco-TinyBERT-L2 — простую предобученную модель кросс-кодирования, работающую на базе PyTorch. Для этого нам также потребуется импортировать библиотеки Torch и Transformers.
импортировать горелку из трансформаторов импортировать CrossEncoder
Затем мы можем инициализировать кросс-кодер и определить функцию для переранжирования первых k фрагментов, полученных из векторного поиска:
# инициализация модели кросс-кодировщика cross_encoder = CrossEncoder('cross-encoder/ms-marco-TinyBERT-L-2', device='cuda' if torch.cuda.is_available() else 'cpu') def rerank_with_cross_encoder(query, relevant_docs): pairs = [(query, doc.page_content) for doc in relevant_docs] # пары (query, document) for cross-encoder scores = cross_encoder.predict(pairs) # оценки релевантности из модели кросс-кодировщика ranked_indices = np.argsort(scores)[::-1] # сортировка документов на основе оценки кросс-кодировщика (чем выше, тем лучше) ranked_docs = [relevant_docs[i] for i in ranked_indices] ranked_scores = [scores[i] for i in ranked_indices] вернуть ranked_docs, ranked_scores
… а также настроить функцию следующим образом:
… # поиск по индексу FAISS D, I = index.search(query_embedding, k=6) # получение релевантных документов relevant_docs = [vector_store.docstore[i] for i in I[0]] # повторное ранжирование с помощью нашей функции reranked_docs, reranked_scores = rerank_with_cross_encoder(user_input, relevant_docs) # получение верхних повторно ранжированных фрагментов retrieved_context = «nn».join([doc.page_content for doc in reranked_docs[:2]]) # D содержит оценки внутреннего произведения == косинусные сходства (после нормализации) print(«n6 верхних извлеченных фрагментов: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}») # отобразить верхние переоцененные фрагменты print(«nЛучшие 2 переоцененных фрагмента:n») for rank, (doc, score) in enumerate(zip(reranked_docs[:2], reranked_scores[:2]), start=1): print(f»Ранг {rank}:») print(f»Оценка переоценки: {score:.4f}») print(f»Содержимое:n{doc.page_content}n{'-'*40}») …
… и, наконец, вот два верхних фрагмента и соответствующий ответ, который мы получаем после повторного ранжирования с помощью кросс-кодера:

Обратите внимание, как эти 2 фрагмента отличаются от двух верхних фрагментов, которые мы получили в результате векторного поиска.
Таким образом, важность этапа переранжирования становится очевидной. Мы используем векторный поиск, чтобы сузить круг потенциально релевантных фрагментов из всех доступных документов в базе знаний, а затем применяем этап переранжирования для точного определения наиболее релевантных фрагментов.

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



























