Проблема: нерелевантный балласт в чанках

Чанкинг делится на куски фиксированного или семантического размера — например, 300–512 токенов. Векторный поиск находит чанк по смысловой близости к запросу в целом. Но внутри чанка нужный фрагмент занимает в среднем 10–30% объёма. Остальное — соседний контекст, который случайно попал в ту же нарезку.

Вот что реально происходит с контекстным окном LLM при стандартном RAG:

Запрос: «Как настроить таймаут в httpx?» → чанк A (512 токенов)512 токенов, ~15% полезно
Запрос: «Как работает retry в requests?» → чанк B (512 токенов)512 токенов, ~25% полезно
После contextual compression — только релевантные фрагменты~140 токенов, 100% полезно

Итог без сжатия: LLM получает 1024 токена контекста, из которых полезны ~180. С compression: те же 180 полезных токенов при 7× меньшем расходе контекстного окна. При k=5 чанков разница ещё нагляднее — экономия на входном контексте 60–80%.

Проблема «потери в середине» (lost in the middle). Исследования показывают: LLM хуже использует информацию, которая стоит в середине длинного контекста. Если все 5 чанков залиты целиком, релевантный ответ «тонет». Сжатие до коротких фрагментов напрямую улучшает качество финального ответа.

Что такое contextual compression

Contextual compression — дополнительный этап после retrieval и до generation. На вход поступают retrieved чанки и исходный запрос. На выходе — те же чанки, но укороченные: каждый чанк обрабатывается компрессором, который удаляет нерелевантные предложения или абзацы.

Ключевая идея: извлечение релевантных фрагментов, а не переформулировка. Компрессор не пересказывает чанк своими словами — он вырезает полезное и выбрасывает лишнее. Это важно: генерация может исказить факты, а извлечение сохраняет оригинальный текст.

100%
колёсико — масштаб  ·  зажать и тянуть — перемещение
Запрос пользователя «Как настроить timeout?» ANN Retrieval векторный поиск top-k Чанки (k=4) Чанк A · 512 токенов …http-клиент… timeout… keep-alive… Чанк B · 480 токенов …retry logic… backoff… timeout… Чанк C · 510 токенов …async client… headers… cookies… Чанк D · 490 токенов …connection pool… limits… Compressor для каждого чанка: LLM Extractor LLM Filter Embeddings Filter запрос тоже передаётся Сжатые фрагменты Фрагмент A · 28 токенов Timeout(connect=5, read=10) Фрагмент B · 31 токен timeout совпадает с connect Чанк C → отброшен не релевантен запросу Фрагмент D · 22 токена pool_timeout отдельный параметр LLM Generation ~80 токенов контекста ~1992 токена всего в чанках обработка каждого чанка k × LLM вызовов

Схема показывает ключевое отличие от reranking: reranker меняет порядок чанков, compression меняет их содержание. Чанк C в примере отброшен полностью — он не отвечает на вопрос о таймаутах. Чанки A, B, D сжаты с ~500 до ~25–30 токенов каждый.

Три подхода к компрессии

Выбор компрессора — это компромисс между качеством, скоростью и стоимостью. Существует три принципиально разных подхода:

LLM Extractor
ПринципLLM извлекает релевантные предложения
Качество★★★★★
Стоимостьk × LLM-вызовов
Латентность+500–1500 мс
Когдакачество критично
LLM Filter
ПринципLLM решает: релевантен/нет
Качество★★★★☆
Стоимостьk × LLM-вызовов
Латентность+300–800 мс
Когдаотсечь нерелевантные чанки
Embeddings Filter
Принципcosine similarity ≥ threshold
Качество★★★☆☆
Стоимостьтолько embeddings
Латентность+20–50 мс
Когдаhigh-load, бюджет ограничен
LLM Extractor vs LLM Filter — в чём разница. Filter даёт бинарный ответ: весь чанк релевантен или нет целиком. Extractor идёт глубже: из релевантного чанка вырезает только нужные предложения. Filter дешевле по токенам (короткий ответ), Extractor даёт меньший выходной контекст. На практике: Filter → для первичного отсева нерелевантных чанков, Extractor → когда каждый токен на счету.

Как устроен prompt для LLM Extractor

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

1
Запрос пользователя — якорь релевантности
LLM должен знать, к чему извлекать релевантное. Без запроса он не знает, что считать полезным, и может вернуть весь чанк.
2
Явный сигнал: «только извлечение, без генерации»
LLM стремится «помочь» и переформулировать ответ. Нужна чёткая инструкция: возвращай только фрагменты из текста дословно, не добавляй своих слов.
3
Сигнал «нерелевантно» — специальный токен
Если в чанке нет ничего полезного, LLM должен вернуть условный маркер (например, IRRELEVANT), а не пытаться выдумать ответ. Это позволяет коду отфильтровать такие чанки.
# Структура промпта LLM Extractor Задача: Из текста ниже извлеки только фрагменты, которые отвечают на вопрос пользователя. Правила: - Возвращай дословные фрагменты из текста, не перефразируй - Если ничего не подходит — ответь одним словом: IRRELEVANT - Без пояснений, без заголовков, только сам фрагмент Вопрос пользователя: {query} Текст: {chunk}

Это минимально необходимый промпт. В продакшне добавляют примеры (few-shot), ограничение длины ответа и язык вывода. Но ядро всегда одно: вопрос + текст + правило «IRRELEVANT».

Осторожно: over-compression. Если промпт слишком строгий («извлекай только предложение с прямым ответом»), LLM может выбросить контекст, нужный для понимания. Например, «5.0» без пояснения «это connect timeout в секундах» — не информативно. Добавь в промпт: «извлекай достаточно контекста, чтобы ответ был понятен без исходного текста».

Embeddings Filter: без LLM-вызовов

Если LLM-вызов на каждый чанк — слишком дорого, есть альтернатива: сравнивать embedding запроса с embedding каждого предложения в чанке по cosine similarity и оставлять только те, что выше порога.

Логика работы:

1
Разбить чанк на предложения
Простой сплит по . , или nltk.sent_tokenize() для более точного разбиения. Каждое предложение — кандидат на включение.
2
Векторизовать запрос и предложения
Embedding запроса уже есть из первого поиска — можно переиспользовать. Предложения кодируются той же bi-encoder моделью батчем.
3
Отфильтровать по порогу similarity
Оставить предложения с cosine ≥ threshold (обычно 0.5–0.7). Порог подбирается экспериментально: слишком высокий — потеря информации, слишком низкий — балласт остаётся.
4
Собрать отфильтрованные предложения обратно
Соединить через \n — сохраняя исходный порядок. Не менять порядок предложений: LLM лучше работает с текстом в естественном порядке.

Embeddings filter работает быстро, но имеет ограничение: bi-encoder считает семантику предложения в изоляции, без контекста чанка. Предложение «Это значение по умолчанию» без контекста может получить низкий score, хотя вместе с предыдущим предложением было бы полезно.

Реализация: LLM Extractor на Python

Минимальный рабочий LLM Extractor с Anthropic Claude и sentence-transformers для reranking-проверки перед LLM-вызовом:

python — LLM Extractor с pre-filter по cross-encoder
from anthropic import Anthropic
from sentence_transformers import CrossEncoder

client  = Anthropic()
reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")

EXTRACT_PROMPT = """\
Из текста ниже извлеки только фрагменты, которые отвечают на вопрос пользователя.
Извлекай дословно из текста — не перефразируй и не добавляй своих слов.
Оставь достаточно контекста, чтобы фрагмент был понятен без исходного текста.
Если ничего не подходит — ответь одним словом: IRRELEVANT

Вопрос: {query}

Текст:
{chunk}"""


def llm_extract(query: str, chunk: str, min_logit: float = 0.0) -> str | None:
    """
    Извлекает из чанка релевантный фрагмент с помощью LLM.

    Предварительно проверяет релевантность через cross-encoder:
    если logit < min_logit, LLM не вызывается → экономия токенов.
    """
    # Быстрая проверка: стоит ли вообще вызывать LLM
    logit = float(reranker.predict([[query, chunk]])[0])
    if logit < min_logit:
        return None  # чанк нерелевантен — отбрасываем без LLM-вызова

    response = client.messages.create(
        model="claude-haiku-4-5-20251001",  # дешёвая модель для preprocessing
        max_tokens=512,
        messages=[{
            "role": "user",
            "content": EXTRACT_PROMPT.format(query=query, chunk=chunk),
        }],
    )

    result = response.content[0].text.strip()
    return None if result == "IRRELEVANT" else result


def compress_chunks(
    query: str,
    chunks: list[str],
) -> list[dict]:
    """
    Применяет LLM Extractor ко всем чанкам параллельно.
    Возвращает только те, из которых удалось извлечь фрагмент.
    """
    results = []
    for i, chunk in enumerate(chunks):
        extracted = llm_extract(query, chunk)
        if extracted:
            results.append({
                "index":    i,
                "original": chunk,
                "compressed": extracted,
                "ratio":    round(len(extracted) / len(chunk), 2),
            })
    return results


# ── Пример использования ───────────────────────────────────────────
query = "как настроить timeout в httpx"

chunks = [
    "httpx — современный HTTP-клиент для Python. Поддерживает HTTP/1.1 и HTTP/2. "
    "Совместим с asyncio. Для настройки таймаутов используйте объект Timeout: "
    "httpx.Timeout(connect=5.0, read=10.0). Можно указать разные значения для "
    "connect, read, write и pool. Значение None отключает таймаут.",

    "requests — популярная библиотека для HTTP-запросов. Имеет простой API. "
    "Поддерживает сессии через requests.Session(). Connection pooling включён "
    "автоматически. Позволяет задавать заголовки, куки и параметры запроса.",

    "httpx.AsyncClient поддерживает тот же интерфейс таймаутов: "
    "async with httpx.AsyncClient(timeout=httpx.Timeout(5.0)) as client: ... "
    "По умолчанию connect timeout = 5 секунд, read timeout = 5 секунд.",
]

compressed = compress_chunks(query, chunks)

for item in compressed:
    original_len = len(item["original"].split())
    compressed_len = len(item["compressed"].split())
    print(f"Чанк {item['index']}: {original_len} слов → {compressed_len} слов")
    print(f"  Фрагмент: {item['compressed'][:120]}...")
    print()
Cross-encoder pre-filter. Вызов LLM для каждого чанка стоит денег и времени. Добавив предварительную проверку через cross-encoder (latency ~5 мс на CPU), можно отсечь заведомо нерелевантные чанки до LLM. Порог min_logit=0.0 соответствует вероятности 50% релевантности — хорошая отправная точка.

Реализация: LLM Filter (бинарное решение)

Если задача — только отсечь нерелевантные чанки, а не сжимать их, LLM Filter дешевле: ответ «YES» или «NO» требует минимум токенов.

python — LLM Filter: оставить или выбросить чанк целиком
from anthropic import Anthropic

client = Anthropic()

FILTER_PROMPT = """\
Тебе дан текст и вопрос пользователя.
Ответь YES, если текст содержит информацию, полезную для ответа на вопрос.
Ответь NO, если текст не содержит ничего полезного.
Отвечай только одним словом: YES или NO.

Вопрос: {query}

Текст:
{chunk}"""


def llm_filter(query: str, chunks: list[str]) -> list[str]:
    """
    Фильтрует чанки: оставляет только те, где LLM ответил YES.
    Чанки передаются целиком (без извлечения фрагментов).
    """
    relevant = []
    for chunk in chunks:
        response = client.messages.create(
            model="claude-haiku-4-5-20251001",
            max_tokens=5,   # нам нужен только YES/NO
            messages=[{
                "role": "user",
                "content": FILTER_PROMPT.format(query=query, chunk=chunk),
            }],
        )
        verdict = response.content[0].text.strip().upper()
        if verdict.startswith("YES"):
            relevant.append(chunk)

    return relevant


# ── Пример ─────────────────────────────────────────────────────────
query = "как работает retry в httpx"

chunks = [
    "httpx поддерживает transport-уровень retry через httpx.HTTPTransport(retries=3). "
    "Это автоматически повторяет запрос при сетевых ошибках (ConnectError, ReadError).",

    "FastAPI — фреймворк для построения REST API. Использует Pydantic для валидации "
    "данных. Поддерживает async/await из коробки.",

    "Для сложной логики retry (экспоненциальный backoff, условия) используют tenacity: "
    "@retry(wait=wait_exponential(), stop=stop_after_attempt(3)) над функцией с httpx.",
]

result = llm_filter(query, chunks)
print(f"Осталось {len(result)} из {len(chunks)} чанков")
# → Осталось 2 из 3 чанков (FastAPI-чанк отброшен)

Реализация: Embeddings Filter (без LLM)

Embeddings filter — самый быстрый подход. Работает без LLM-вызовов: нужны только векторы предложений.

python — Embeddings Filter на уровне предложений
import re
import numpy as np
from sentence_transformers import SentenceTransformer

model = SentenceTransformer("all-MiniLM-L6-v2")


def split_sentences(text: str) -> list[str]:
    """Простой сплит на предложения с сохранением нормальной длины."""
    # Разбиваем по точке/восклицательному/вопросительному знаку + пробел или перенос
    sentences = re.split(r'(?<=[.!?])\s+', text.strip())
    # Убираем совсем короткие (< 10 символов) — обычно это артефакты
    return [s for s in sentences if len(s) > 10]


def embeddings_filter(
    query: str,
    chunks: list[str],
    threshold: float = 0.55,
    min_sentences: int = 1,
) -> list[str]:
    """
    Фильтрует предложения в каждом чанке по cosine similarity с запросом.

    threshold: минимальная cosine similarity (0.55 — хорошая точка отсчёта)
    min_sentences: если после фильтрации осталось меньше — чанк отбрасывается целиком
    """
    # Один раз векторизуем запрос
    query_vec = model.encode([query], normalize_embeddings=True)[0]

    result = []
    for chunk in chunks:
        sentences = split_sentences(chunk)
        if not sentences:
            continue

        # Векторизуем все предложения чанка батчем
        sent_vecs = model.encode(sentences, normalize_embeddings=True)

        # Cosine similarity = dot product для нормализованных векторов
        scores = sent_vecs @ query_vec

        # Оставляем предложения выше порога, сохраняя исходный порядок
        kept = [s for s, score in zip(sentences, scores) if score >= threshold]

        if len(kept) >= min_sentences:
            result.append(" ".join(kept))

    return result


# ── Пример ─────────────────────────────────────────────────────────
query = "как настроить timeout в httpx"

chunk = (
    "httpx — современный HTTP-клиент для Python. "
    "Поддерживает HTTP/1.1 и HTTP/2. "
    "Для настройки таймаутов используйте httpx.Timeout(connect=5.0, read=10.0). "
    "Можно задать разные значения для connect, read, write и pool. "
    "Библиотека совместима с asyncio и trio. "
    "Connection pooling включён автоматически."
)

compressed = embeddings_filter(query, [chunk], threshold=0.55)
print("До:", len(chunk.split()), "слов")
print("После:", len(compressed[0].split()) if compressed else 0, "слов")
print("Результат:", compressed[0] if compressed else "—")
Подбор threshold. Начинайте с 0.5–0.55 и проверяйте на 20–30 реальных примерах. Слишком высокий порог (0.8+) — теряете контекст. Слишком низкий (0.3–) — балласт остаётся. Если предложения короткие (одно слово, числа), лучше перейти на LLM Extractor — bi-encoder плохо работает с короткими текстами.

LangChain: ContextualCompressionRetriever

LangChain предоставляет готовую обёртку ContextualCompressionRetriever, которая принимает любой базовый ретривер и компрессор. Три варианта компрессора:

python — LangChain ContextualCompressionRetriever (три варианта)
from langchain_anthropic import ChatAnthropic
from langchain_community.vectorstores import Chroma
from langchain_community.embeddings import HuggingFaceEmbeddings
from langchain.retrievers import ContextualCompressionRetriever
from langchain.retrievers.document_compressors import (
    LLMChainExtractor,   # LLM Extractor
    LLMChainFilter,      # LLM Filter
    EmbeddingsFilter,    # Embeddings Filter
)

# ── Базовый ретривер ────────────────────────────────────────────────
embeddings = HuggingFaceEmbeddings(model_name="BAAI/bge-small-en-v1.5")
vectorstore = Chroma(embedding_function=embeddings, persist_directory="./chroma_db")
base_retriever = vectorstore.as_retriever(search_kwargs={"k": 5})

# ── Вариант 1: LLM Extractor ────────────────────────────────────────
llm = ChatAnthropic(model="claude-haiku-4-5-20251001", temperature=0)

extractor = LLMChainExtractor.from_llm(llm)
compression_retriever_extract = ContextualCompressionRetriever(
    base_compressor=extractor,
    base_retriever=base_retriever,
)

# ── Вариант 2: LLM Filter ───────────────────────────────────────────
llm_filter = LLMChainFilter.from_llm(llm)
compression_retriever_filter = ContextualCompressionRetriever(
    base_compressor=llm_filter,
    base_retriever=base_retriever,
)

# ── Вариант 3: Embeddings Filter (без LLM) ──────────────────────────
emb_filter = EmbeddingsFilter(
    embeddings=embeddings,
    similarity_threshold=0.55,
)
compression_retriever_emb = ContextualCompressionRetriever(
    base_compressor=emb_filter,
    base_retriever=base_retriever,
)

# ── Использование в chain ───────────────────────────────────────────
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough
from langchain_core.output_parsers import StrOutputParser

prompt = ChatPromptTemplate.from_template(
    "Ответь на вопрос, используя только предоставленный контекст.\n\n"
    "Контекст:\n{context}\n\n"
    "Вопрос: {question}"
)

def format_docs(docs):
    return "\n\n".join(doc.page_content for doc in docs)

# Цепочка с LLM Extractor (лучшее качество)
chain = (
    {
        "context":  compression_retriever_extract | format_docs,
        "question": RunnablePassthrough(),
    }
    | prompt
    | ChatAnthropic(model="claude-sonnet-4-6", temperature=0)
    | StrOutputParser()
)

answer = chain.invoke("как настроить timeout в httpx?")
print(answer)
DocumentCompressorPipeline — можно комбинировать компрессоры в pipeline. Например: сначала EmbeddingsFilter (быстрый отсев), затем LLMChainExtractor (тонкое извлечение). DocumentCompressorPipeline(transformers=[emb_filter, extractor]). Такая цепочка дешевле, чем чистый LLMChainExtractor на всех чанках.

Место в RAG pipeline и композиция

Contextual compression — не замена другим retrieval техникам, а дополнение. Оптимальный порядок компонентов в production RAG pipeline:

1
Hybrid Search (BM25 + semantic)
Широкий fetch: top-20 кандидатов. Keyword-matching важен для точных терминов, embedding — для семантики.
2
Metadata Filtering
До или вместе с поиском: ограничить область поиска по дате, источнику, категории. Уменьшает шум ещё до подачи кандидатов в pipeline.
3
Reranking (cross-encoder)
top-20 → top-5: cross-encoder пересортирует кандидатов по настоящей релевантности. Дорогой шаг, поэтому работает на меньшем числе кандидатов.
4
MMR (при необходимости разнообразия)
top-5 → top-5 с диверсификацией: убирает дубли, если чанки из одного раздела документа.
5
Contextual Compression
top-5 сжатых чанков → 5 коротких фрагментов. Финальный контекст для LLM: максимум пользы, минимум токенов.
6
LLM Generation
Генерирует ответ на основе сжатых, диверсифицированных, переранжированных фрагментов. Контекст плотный — качество ответа выше.

Цена и задержки: когда применять

Contextual compression — единственный шаг в pipeline, который добавляет k дополнительных LLM-вызовов (при LLM Extractor/Filter). Это напрямую влияет на латентность и стоимость.

Компрессор Доп. LLM-вызовов Доп. латентность Стоимость/запрос Рекомендация
LLM Extractor k (параллельно) +800–2000 мс $$ Асинхронные/batch сценарии
LLM Filter k (параллельно) +400–1000 мс $ Нужно только отсечь шум
Embeddings Filter 0 +20–60 мс ¢ Realtime, latency-sensitive
Emb + LLM pipeline k' < k (отфильтровано) +300–900 мс $–$$ Компромисс качество/цена

Ключевой трюк для снижения латентности — параллельные вызовы. При k=5 чанках все 5 LLM-вызовов запускаются одновременно через asyncio.gather, итоговая задержка = latency одного вызова, а не суммарная.

python — Параллельные вызовы через asyncio
import asyncio
from anthropic import AsyncAnthropic

client = AsyncAnthropic()

EXTRACT_PROMPT = """\
Извлеки из текста только фрагменты, релевантные вопросу.
Ответь дословными цитатами или словом IRRELEVANT.

Вопрос: {query}
Текст:
{chunk}"""


async def extract_one(query: str, chunk: str) -> str | None:
    """Один асинхронный вызов для одного чанка."""
    response = await client.messages.create(
        model="claude-haiku-4-5-20251001",
        max_tokens=512,
        messages=[{
            "role": "user",
            "content": EXTRACT_PROMPT.format(query=query, chunk=chunk),
        }],
    )
    result = response.content[0].text.strip()
    return None if result == "IRRELEVANT" else result


async def compress_parallel(query: str, chunks: list[str]) -> list[str]:
    """
    Параллельное сжатие всех чанков.
    Latency = max(один вызов), а не sum(все вызовы).
    """
    tasks = [extract_one(query, chunk) for chunk in chunks]
    results = await asyncio.gather(*tasks)
    # Фильтруем None (нерелевантные чанки)
    return [r for r in results if r is not None]


# ── Запуск ──────────────────────────────────────────────────────────
async def main():
    query = "как настроить timeout в httpx"
    chunks = [...]  # список из 5 чанков

    import time
    t0 = time.perf_counter()
    compressed = await compress_parallel(query, chunks)
    elapsed = time.perf_counter() - t0

    print(f"Сжато {len(compressed)} чанков за {elapsed:.2f}с")

asyncio.run(main())
На практике: 5 параллельных вызовов к claude-haiku занимают ~600–800 мс суммарно (не 5 × 600 мс = 3000 мс). Это укладывается в приемлемую задержку для большинства RAG-приложений. Если нужно быстрее — переходите на Embeddings Filter или комбинируйте подходы.

Шпаргалка

Что такое contextual compression: постобработка retrieved чанков — вырезаем только релевантные фрагменты, остальное выбрасываем.

Три варианта компрессора:
LLM Extractor — лучшее качество, k×LLM-вызовов, +800–2000 мс. Промпт: запрос + чанк + «IRRELEVANT если нечего»
LLM Filter — бинарный отсев (YES/NO), дешевле по токенам, +400–1000 мс
Embeddings Filter — без LLM, cosine threshold по предложениям, +20–60 мс

Место в pipeline: после Hybrid Search → Reranking → MMR, перед LLM generation

Ключевые трюки:
• Cross-encoder pre-filter перед LLM Extractor — экономит токены на нерелевантных чанках
asyncio.gather — параллельные вызовы, latency = один вызов
DocumentCompressorPipeline — Embeddings Filter → LLM Extractor каскадом
• Threshold embeddings: 0.5–0.55 — хорошая точка отсчёта, настраивается на данных

Когда НЕ применять:
• Realtime < 200 мс → только Embeddings Filter или отказаться от compression
• Чанки уже маленькие (≤ 100 токенов) → overhead не окупится
• Вопрос требует всего контекста чанка → Extractor срежет нужное

Практика

  1. Измерьте плотность релевантности в вашем корпусе: возьмите 10 случайных запросов, вручную отметьте в каждом retrieved чанке «полезные» предложения. Посчитайте среднюю долю (обычно 15–35%). Это обоснование для внедрения compression.
  2. Реализуйте A/B тест: один и тот же RAG pipeline с compression и без. Для каждого варианта посчитайте: среднее число токенов в контексте LLM, и попросите оценить качество ответов (1–5) на 20 вопросах. Ожидаемый результат: compression снижает токены на 50–70%, качество растёт или держится.
  3. Сделайте каскад Embeddings Filter → LLM Extractor: Embeddings Filter с порогом 0.5 отсекает заведомо нерелевантные предложения, LLM Extractor доводит до финала. Сравните стоимость и качество с чистым LLM Extractor.