Головна / Статті / Гібридний метод пошуку RAG з використанням pgvector, BM25 та крос-енкодера для переурівнювання результатів

Гібридний метод пошуку RAG з використанням pgvector, BM25 та крос-енкодера для переурівнювання результатів

Дізнайтеся, чому простий векторний пошук не знаходить номери деталей та коди помилок, і як поєднати pgvector, BM25 та механізм переранжування в LangChain для точного пошуку за принципом RAG.

1524 слів

Прототип генерації з підсиленням через пошук даних (RAG), створений на основі звичайного векторного пошуку, зазвичай справляє враження під час демонстрацій, але потім розчаровує справжніх користувачів. Якщо запитати його про графік технічного обслуговування певної моделі насоса, воно надасть загальні поради щодо насосів; якщо шукати точний код помилки, відповідний крок усунення несправностей так і не з’явиться. У цьому посібнику пояснюється, чому щільний пошук не функціонує з точними ідентифікаторами, та показано, як вирішити цю проблему за допомогою гібридної схеми: pgvector для семантичного пошуку, BM25 для зіставлення ключових слів та алгоритм переранжування для визначення того, які фрагменти даних потраплять до LLM.

Чому векторний пошук пропускає точні ідентифікатори

Ембеддинги чудово справляються з передачею значення. Запит на слово „автомобіль“ потрапляє поблизу документів про „автомобілі“ та „транспортні засоби“, оскільки модель ембеддингів розташовує пов’язані поняття близько одне до одного у високовимірному просторі. Саме ця властивість є й слабкістю. Схожість стосується семантичної близькості, а не точних послідовностей символів.

Коли користувач шукає номер деталі, наприклад TX-99402, або код помилки, наприклад E-404, ембеддинг цього рядка може знаходитися дуже близько до TX-99401 або до загального тексту з інструкціями щодо усунення несправностей. Механізм пошуку повертає документи, які „стосуються того самого“, а не той, що містить саме той токен, який ввів користувач. Для технічних посібників, каталогів продукції та баз знань підтримки, де ідентифікатори несуть основну частину значення, це є основним способом виникнення проблем.

Гібридне пошукове забезпечення: щільний та розріджений підхід паралельно

Рішенням є використання двох взаємодоповнювальних пошукових механізмів та об’єднання результатів, які вони знаходять:

  1. Щільний пошук (векторний пошук) дозволяє враховувати контекст та значення. Замість окремої векторної бази даних можна зберігати ембеддинги в PostgreSQL за допомогою розширення pgvector. Багато додатків вже працюють на Postgres, тож додавання векторного стовпця зберігає архітектуру простою та забезпечує звичні функції резервного копіювання, контролю доступу та транзакцій.
  2. Розріджений пошук (пошук за ключовими словами) дозволяє знаходити точні збіги, абревіатури та спеціалізовану термінологію. Стандартним алгоритмом є BM25 — давно відома функція ранжування, яка оцінює документи за частотою зустрічання запитованих термінів, враховуючи їх рідкісність у корпусі та нормалізуючи результати за довжиною документа.

Корисна ментальна модель: векторний пошук знаходить правильний район, а BM25 — точний номер будинку. Ви берете найкращі результати з кожного підходу та об’єднуєте їх. Щоб дізнатися більше про ситуації, коли кожен підхід є кращим, прочитайте нашу статтю про гібридний пошук для технічних знань.

Чому об’єднані результати потребують переурочування

Гібридний пошук створює негайну проблему. Тепер у вас є два ранжовані списки, оцінки яких не піддаються порівнянню. Оцінка BM25 залежить від частоти термінів та є необмеженою, тоді як схожість векторів визначається за допомогою косинусової відстані у зовсім іншій шкалі. Семантична оцінка 0.82 не є кращою чи гіршою за оцінку BM25 14.5; сортування об’єднаного набору за первинною оцінкою є безглуздим.

Реранкер вирішує цю проблему, ігноруючи початкові оцінки. Це окрема модель, зазвичай крос-енкодер, яка читає запит та кандидатський документ разом та формує єдиний показник релевантності для цієї пари. Оскільки вона бачить обидва тексти одночасно, вона може оцінити релевантність набагато точніше, ніж порівнюючи два окремо обчислені ембеддинги.

Алгоритм роботи такий:

  1. Отримати 10 кандидатів від кожного з пошукових інструментів – pgvector та BM25.
  2. Оцінити кожен фрагмент щодо запиту за допомогою реранкера.
  3. Залишити найкращі 3 фрагменти та передати їх лише до LLM.

Кількість кращих фрагментів також означає коротший запит, менше «шуму», який потрібно проігнорувати моделі, та нижчу вартість обробки токенів.

Витрати на затримку

Крос-енкодери є дорогими. Обробка 20 документів значно подовжує час виконання кожного запиту, а у API потокових чатів (наприклад, створеному за допомогою FastAPI) це затримує отримання першого токена. Вимірюйте цей етап окремо під час відстеження затримок. Підвищення точності зазвичай варте цього, але налаштовуйте кількість кандидатів відповідно до вашого бюджету; наша стаття про те, чому reranking мусить компенсувати свою затримку, детальніше розглядає цей компроміс.

Впровадження конвеєра з використанням LangChain

LangChain надає елементи для кожної складової, тому весь конвеєр поміщається у дві короткі функції на Python. Наведені нижче приклади є концептуальними; адаптуйте рядки підключення, моделі та шляхи до файлів під ваше середовище.

Прийом даних: розділення на частини, інкорпорування та індексування двічі

Функція обробки завантажує текстовий файл, ділить його на частини по 1 000 символів із 100-символьним перекриттям, а потім створює індекс цих же частин двома способами. Спочатку вона інтегрує їх за допомогою моделі OpenAI text-embedding-3-small та зберігає їх у колекції pgvector за допомогою PGVector.from_documents. Далі вона застосовує до цих частин алгоритм BM25Retriever та серіалізує результат за допомогою pickle, оскільки BM25 створює свій індекс у пам’яті.

import pickle
from langchain_community.document_loaders import TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings
from langchain_postgres.vectorstores import PGVector
from langchain_community.retrievers import BM25Retriever

CONNECTION_STRING = "postgresql+psycopg://user:password@localhost:5432/mydb"
COLLECTION_NAME = "hybrid_docs"

def ingest_documents(file_path: str):
    # 1. Load and chunk the document
    loader = TextLoader(file_path)
    docs = loader.load()

    text_splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=100)
    chunks = text_splitter.split_documents(docs)

    # 2. Store dense embeddings in pgvector
    embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
    PGVector.from_documents(
        embedding=embeddings,
        documents=chunks,
        collection_name=COLLECTION_NAME,
        connection=CONNECTION_STRING,
    )

    # 3. Fit and save the BM25 sparse retriever
    bm25_retriever = BM25Retriever.from_documents(chunks)
    with open("bm25_retriever.pkl", "wb") as f:
        pickle.dump(bm25_retriever, f)

    print(f"Successfully ingested {len(chunks)} chunks.")

# Example usage:
# ingest_documents("technical_manual.txt")

Що потрібно пам’ятати:

  • Індекс BM25 є моментальною копією даних. Коли документи змінюються, його необхідно перестворити та знову зберегти, інакше він втратить синхронізацію з базою векторних даних.
  • Завантажуйте лише файли, створені вами самими. Завантаження файлу у форматі pickle виконує код, тому фальсифікований файл становить ризик для безпеки.
  • Рядок підключення містить облікові дані; завантажуйте його з конфігурації, а не вводьте як жорстко закодовані дані.
  • Якщо ви хочете зберегти пошук за ключовими словами також усередині бази даних, вбудований пошук повного тексту PostgreSQL є альтернативою індексу BM25, який працює всередині процесу, і має іншу логіку ранжування.
  • Отримання даних: ансамбль, потім переранжування

    Функція отримання даних перебудовує як засоби пошуку, так і об’єднує їх. Засіб пошуку pgvector повертає 10 найкращих семантичних результатів (k=10), а засіб пошуку BM25 також налаштований повертати 10 результатів. EnsembleRetriever об’єднує їх із однаковими вагами — по 0.5 кожен. Компресор CohereRerank з параметром top_n=3 обгортає цей ансамбль всередину ContextualCompressionRetriever, тож кожен запит проходить через отримання даних, об’єднання та переранжування в один виклик invoke.

    import pickle
    from langchain_openai import OpenAIEmbeddings
    from langchain_postgres.vectorstores import PGVector
    from langchain.retrievers import EnsembleRetriever, ContextualCompressionRetriever
    from langchain_cohere import CohereRerank
    
    CONNECTION_STRING = "postgresql+psycopg://user:password@localhost:5432/mydb"
    COLLECTION_NAME = "hybrid_docs"
    
    def setup_hybrid_retriever():
        # 1. Initialize Vector Retriever
        embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
        vectorstore = PGVector(
            connection=CONNECTION_STRING,
            embeddings=embeddings,
            collection_name=COLLECTION_NAME,
        )
        # Fetch top 10 semantic matches
        pgvector_retriever = vectorstore.as_retriever(search_kwargs={"k": 10})
    
        # 2. Load Keyword Retriever (BM25)
        with open("bm25_retriever.pkl", "rb") as f:
            bm25_retriever = pickle.load(f)
        # Fetch top 10 exact keyword matches
        bm25_retriever.k = 10
    
        # 3. Merge pools with EnsembleRetriever (50/50 weighting)
        hybrid_retriever = EnsembleRetriever(
            retrievers=[bm25_retriever, pgvector_retriever],
            weights=[0.5, 0.5]
        )
    
        # 4. Rerank the combined 20 chunks to output the absolute top 3
        reranker = CohereRerank(cohere_api_key="YOUR_COHERE_API_KEY", top_n=3)
        advanced_retriever = ContextualCompressionRetriever(
            base_compressor=reranker,
            base_retriever=hybrid_retriever
        )
    
        return advanced_retriever
    
    def query_system(query: str):
        retriever = setup_hybrid_retriever()
        best_docs = retriever.invoke(query)
    
        for i, doc in enumerate(best_docs):
            print(f"\n--- Result {i+1} ---")
            print(doc.page_content)
    
    # Example usage:
    # query_system("What is the warranty period for the TX-99402 sensor?")
    

    Деякі деталі, які легко пропустити:

    • EnsembleRetriever не додає первинні оцінки. Він об’єднує списки за рангом за допомогою вагованої фузії обернених рангів, що усуває проблему розбіжностей масштабу, описану вище. Крім того, він видаляє дублікати, тому інструмент переранжування може отримати менше 20 фрагментів, якщо обидва знаходжувачі виявляють один і той самий фрагмент.
    • Ніколи не включайте ключ API у вихідний код. Читайте ключ Cohere з змінної середовища або менеджера секретів.
    • setup_hybrid_retriever() виконується під час кожного запиту, щоразу встановлюючи з’єднання з Postgres та розпаковуючи модель BM25. У справжньому сервісі слід створити знаходжувач один раз під час запуску та використовувати його знову.
  • LangChain переорганізував свої пакети між різними версіями, тож класи на кшталт EnsembleRetriever та ContextualCompressionRetriever у вашій версії можуть знаходитися в іншому пакеті. Перевірте актуальну документацію LangChain, якщо виникає проблема з імпортом.
  • Основні висновки

    • Чистий векторний пошук є неефективним для точних токенів, таких як номери деталей, SKU та коди помилок; BM25 заповнює цю прогалину.
    • pgvector дозволяє додати функцію інтенсивного пошуку до наявної структури PostgreSQL без необхідності окремої векторної бази даних.
    • Оцінки від різних засобів пошуку не є порівнянними, тому об’єднуйте їх за рангом та дозвольте крос-кодеру здійснити остаточне сортування.
    • Поновне сортування підвищує точність, але збільшує затримку; ретельно плануйте обсяг кандидатів та стежте за ним.
    • Розглядайте індекс BM25 як елемент конфігурації, який необхідно оновлювати разом із даними, та не включайте облікові дані безпосередньо у код.

    Гібридний метод пошуку не гарантує ідеальних відповідей, але усуває найпоширенішу причину, чому системи RAG повертають правдоподібний, але неправильний контекст.

    Пов’язана література