Проблема: нерелевантный балласт в чанках
Чанкинг делится на куски фиксированного или семантического размера — например, 300–512 токенов. Векторный поиск находит чанк по смысловой близости к запросу в целом. Но внутри чанка нужный фрагмент занимает в среднем 10–30% объёма. Остальное — соседний контекст, который случайно попал в ту же нарезку.
Вот что реально происходит с контекстным окном LLM при стандартном RAG:
Итог без сжатия: LLM получает 1024 токена контекста, из которых полезны ~180.
С compression: те же 180 полезных токенов при 7× меньшем расходе контекстного окна.
При k=5 чанков разница ещё нагляднее — экономия на входном контексте 60–80%.
Что такое contextual compression
Contextual compression — дополнительный этап после retrieval и до generation. На вход поступают retrieved чанки и исходный запрос. На выходе — те же чанки, но укороченные: каждый чанк обрабатывается компрессором, который удаляет нерелевантные предложения или абзацы.
Ключевая идея: извлечение релевантных фрагментов, а не переформулировка. Компрессор не пересказывает чанк своими словами — он вырезает полезное и выбрасывает лишнее. Это важно: генерация может исказить факты, а извлечение сохраняет оригинальный текст.
Схема показывает ключевое отличие от reranking: reranker меняет порядок чанков, compression меняет их содержание. Чанк C в примере отброшен полностью — он не отвечает на вопрос о таймаутах. Чанки A, B, D сжаты с ~500 до ~25–30 токенов каждый.
Три подхода к компрессии
Выбор компрессора — это компромисс между качеством, скоростью и стоимостью. Существует три принципиально разных подхода:
Как устроен prompt для LLM Extractor
LLM Extractor — это, по сути, задача промпт-инжиниринга: нужно сформулировать инструкцию так, чтобы LLM вернул ровно нужный фрагмент, не добавив ничего от себя. Три обязательных элемента промпта:
IRRELEVANT), а не пытаться выдумать ответ.
Это позволяет коду отфильтровать такие чанки.Это минимально необходимый промпт. В продакшне добавляют примеры (few-shot), ограничение длины ответа и язык вывода. Но ядро всегда одно: вопрос + текст + правило «IRRELEVANT».
Embeddings Filter: без LLM-вызовов
Если LLM-вызов на каждый чанк — слишком дорого, есть альтернатива: сравнивать embedding запроса с embedding каждого предложения в чанке по cosine similarity и оставлять только те, что выше порога.
Логика работы:
. , или nltk.sent_tokenize()
для более точного разбиения. Каждое предложение — кандидат на включение.\n — сохраняя исходный порядок.
Не менять порядок предложений: LLM лучше работает с текстом в естественном порядке.Embeddings filter работает быстро, но имеет ограничение: bi-encoder считает семантику предложения в изоляции, без контекста чанка. Предложение «Это значение по умолчанию» без контекста может получить низкий score, хотя вместе с предыдущим предложением было бы полезно.
Реализация: LLM Extractor на Python
Минимальный рабочий LLM Extractor с Anthropic Claude и sentence-transformers для reranking-проверки перед LLM-вызовом:
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()
min_logit=0.0
соответствует вероятности 50% релевантности — хорошая отправная точка.
Реализация: LLM Filter (бинарное решение)
Если задача — только отсечь нерелевантные чанки, а не сжимать их, LLM Filter дешевле: ответ «YES» или «NO» требует минимум токенов.
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-вызовов: нужны только векторы предложений.
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 "—")
0.5–0.55 и проверяйте
на 20–30 реальных примерах. Слишком высокий порог (0.8+) — теряете контекст.
Слишком низкий (0.3–) — балласт остаётся. Если предложения короткие (одно слово,
числа), лучше перейти на LLM Extractor — bi-encoder плохо работает с короткими текстами.
LangChain: ContextualCompressionRetriever
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(transformers=[emb_filter, extractor]).
Такая цепочка дешевле, чем чистый LLMChainExtractor на всех чанках.
Место в RAG pipeline и композиция
Contextual compression — не замена другим retrieval техникам, а дополнение. Оптимальный порядок компонентов в production RAG pipeline:
Цена и задержки: когда применять
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 одного вызова, а не суммарная.
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())
Шпаргалка
Три варианта компрессора:
• 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 срежет нужное
Практика
- Измерьте плотность релевантности в вашем корпусе: возьмите 10 случайных запросов, вручную отметьте в каждом retrieved чанке «полезные» предложения. Посчитайте среднюю долю (обычно 15–35%). Это обоснование для внедрения compression.
- Реализуйте A/B тест: один и тот же RAG pipeline с compression и без. Для каждого варианта посчитайте: среднее число токенов в контексте LLM, и попросите оценить качество ответов (1–5) на 20 вопросах. Ожидаемый результат: compression снижает токены на 50–70%, качество растёт или держится.
- Сделайте каскад Embeddings Filter → LLM Extractor: Embeddings Filter с порогом 0.5 отсекает заведомо нерелевантные предложения, LLM Extractor доводит до финала. Сравните стоимость и качество с чистым LLM Extractor.