Почему синтаксических границ недостаточно

Возьмём фрагмент технической статьи:

[1] «Нейронные сети обучаются с помощью метода обратного распространения ошибки.»
[2] «Градиент функции потерь вычисляется относительно каждого веса в сети.»
[3] «Оптимизатор Adam адаптирует скорость обучения для каждого параметра.»
    ────────────── смена темы ──────────────
[4] «Трансформеры используют механизм внимания для обработки последовательностей.»
[5] «Self-attention позволяет модели учитывать зависимости на любой дистанции.»
[6] «Позиционное кодирование добавляет информацию о порядке токенов.»
        

Предложения [1–3] — одна тема: обучение нейронных сетей. Предложения [4–6] — другая тема: архитектура трансформеров. Граница между ними не отмечена никаким синтаксическим признаком: нет двойного переноса, нет заголовка. Recursive splitter с chunk_size=500 может легко объединить [3] и [4] в один чанк — и embedding этого чанка окажется «усреднённым» между двумя разными темами.

Semantic chunking решает именно это: вычисляет cosine similarity между каждой парой соседних предложений и находит места, где сходство резко падает. Падение схожести = смена темы = граница чанка.

Теория: как embeddings измеряют смысловую близость

Embedding-модель отображает текст в числовой вектор фиксированной размерности (например, 1536 чисел). Ключевое свойство: тексты с близким смыслом получают векторы, которые направлены в похожую сторону в этом пространстве.

Мера близости — cosine similarity: косинус угла между двумя векторами. Значение от −1 до 1, где 1 = идентичный смысл, 0 = несвязанные темы, −1 = противоположный смысл.

cos(θ) = (A · B) / (|A| × |B|)   где · — скалярное произведение

«Оптимизатор Adam» ──→ вектор A = [0.2, -0.8, 0.5, ...]
«Скорость обучения» ──→ вектор B = [0.3, -0.7, 0.4, ...]
cos(A, B) = 0.94  ← очень близко, одна тема

«Оптимизатор Adam» ──→ вектор A = [0.2, -0.8, 0.5, ...]
«Механизм внимания» ──→ вектор C = [-0.1, 0.3, -0.6, ...]
cos(A, C) = 0.21  ← далеко, разные темы

Алгоритм: ищем места где cos резко падает — там граница чанка
        

Окно усреднения: зачем нужен buffer

Вычислять similarity между отдельными предложениями нестабильно: одно короткое предложение («Да.», «Именно так.») даёт случайный вектор. Надёжнее брать скользящее окно: для каждого предложения берём его плюс несколько соседних и эмбеддируем их вместе (или усредняем векторы). Это сглаживает шум.

Без буфера (buffer_size=0):
  sim([1],[2]) = 0.91   ← высокое, та же тема
  sim([2],[3]) = 0.88
  sim([3],[4]) = 0.23   ← РЕЗКОЕ ПАДЕНИЕ → граница!
  sim([4],[5]) = 0.89

С буфером (buffer_size=1): каждая «точка» = предложение + сосед
  sim([1,2],[2,3]) = 0.93  ← усреднение снижает шум
  sim([2,3],[3,4]) = 0.61  ← всё ещё виден переход
  sim([3,4],[4,5]) = 0.31  ← ГРАНИЦА чётче
  sim([4,5],[5,6]) = 0.91
        

Пайплайн semantic chunking

100%
колёсико — масштаб  ·  зажать и тянуть — перемещение
1. ВХОД 2. ПРЕДЛОЖЕНИЯ 3. EMBEDDINGS 4. SIMILARITY 5. ЧАНКИ Текст N символов сплошной текст Sentence Splitter Предложение 1 Предложение 2 Предложение 3 Предложение N regex / spaCy + buffer_size=1 Embedding Model [0.2, −0.8, 0.5 …] [0.3, −0.7, 0.4 …] [0.3, −0.6, 0.3 …] [−0.1, 0.3, −0.6 …] text-emb-3-small 1536-dim Cosine Similarity + Порог (percentile) sim(1,2) 0.93 sim(2,3) 0.88 ↓ ГРАНИЦА ЧАНКА sim(3,4) 0.23 sim(4,5) 0.91 порог = percentile(95) всех sim-значений list[Document] смысловые чанки Chunk 1 предл. 1 + 2 + 3 «обучение нейронных сетей» Chunk 2 предл. 4 + 5 + 6 «архитектура трансформеров» границы = смена темы размер переменный

Сигнал схожести: как найти границу

Получив вектор similarity между каждой парой соседних предложений, нужно решить: какое значение считать «слишком низким» — то есть сигналом смены темы. Вот как выглядит этот сигнал на реальном тексте:

Пример: статья о машинном обучении (16 предложений)
1→2
«Backprop» → «Градиент потерь» — та же тема
0.93
2→3
«Градиент» → «Оптимизатор Adam» — тесно связаны
0.88
⚡ РАЗРЫВ — sim ниже порога (0.35) → граница чанка
3→4
«Оптимизатор» → «Self-attention» — смена темы
0.23
4→5
«Self-attention» → «Позиционное кодирование» — связаны
0.91
⚡ РАЗРЫВ → граница второго чанка
5→6
«Позиционное кодирование» → «Датасеты для обучения» — смена
0.28

Как выбрать порог: три подхода

Порог — самый важный параметр semantic chunking. Слишком низкий порог: мало границ, чанки огромные, темы смешиваются. Слишком высокий: каждое предложение — отдельный чанк, контекст теряется.

Percentile
Рекомендуется
Принцип
Берём все N–1 значений similarity. Вычисляем P-й перцентиль. Переходы ниже этого значения — границы чанков.

threshold = np.percentile(similarities, 5) означает: граница там, где similarity попадает в нижние 5% всех переходов.
Почему работает
Автоматически адаптируется к документу. Технический текст имеет в среднем более высокие similarity, художественный — ниже. Percentile находит относительные разрывы, не требуя ручной калибровки. Параметр percentile_threshold (5–15%) работает стабильно для большинства текстов.
Absolute
Для калиброванных данных
Принцип
Фиксированное значение: если similarity < 0.5 — граница. Одинаково для всех документов корпуса.
Почему не всегда работает
Зависит от модели: text-embedding-3-small и multilingual-e5 дают разные диапазоны значений. Требует ручной подборки под конкретную модель и тип текстов. Хрупко при смене корпуса.
Gradient
Для плотных текстов
Принцип
Граница ставится не там где similarity низкое, а там где оно резко падает: diff[i] = sim[i] − sim[i+1]. Большой отрицательный градиент = резкая смена темы.
Применение
Хорошо для академических и юридических текстов, где базовый уровень similarity везде высокий (0.7–0.9), и абсолютный порог не работает. Ловит именно «переломы», а не «низкий уровень».

Реализация

Шаг 1: разбивка на предложения

Точная разбивка на предложения — нетривиальная задача для русского языка: «г. Москва», «д-р Петров», «т.е.» содержат точку, но не заканчивают предложение. Используем regex-эвристику с защитой от ложных срабатываний, а для production — spaCy с русской моделью.

import re
from dataclasses import dataclass, field


@dataclass
class Document:
    page_content: str
    metadata: dict = field(default_factory=dict)


# Аббревиатуры, после которых точка не означает конец предложения
_ABBREV_RU = re.compile(
    r"\b(г|ул|пр|д|кв|т|тел|факс|руб|коп|млн|млрд|тыс|чел|стр|с|п|пп|ст|ч|мин|сек"
    r"|т\.е|т\.д|т\.п|т\.к|напр|др|проф|д-р|канд|акад|рис|табл|рим)\.",
    re.IGNORECASE,
)

# Граница предложения: [.!?] + пробел + заглавная буква (RU/EN) или кавычка
_SENT_BOUNDARY = re.compile(r"(?<=[.!?])\s+(?=[А-ЯЁA-Z\"«])")


def split_sentences(text: str) -> list[str]:
    """
    Быстрая regex-разбивка на предложения для RU/EN текста.
    Защита от ложных разрывов по аббревиатурам.
    """
    # Заменяем точки в аббревиатурах на placeholder
    protected = _ABBREV_RU.sub(lambda m: m.group(0).replace(".", ""), text)
    # Делим по границам предложений
    raw_sents = _SENT_BOUNDARY.split(protected)
    # Возвращаем placeholder обратно
    return [s.replace("", ".").strip() for s in raw_sents if s.strip()]


def split_sentences_spacy(text: str, model: str = "ru_core_news_sm") -> list[str]:
    """
    Точная разбивка через spaCy (требует: pip install spacy && python -m spacy download ru_core_news_sm).
    Медленнее в 10–30×, но правильно обрабатывает все крайние случаи.
    """
    import spacy
    nlp = spacy.load(model)
    doc = nlp(text)
    return [sent.text.strip() for sent in doc.sents if sent.text.strip()]


# Пример
text = """Нейронные сети обучаются с помощью backprop. Градиент функции потерь
вычисляется относительно каждого веса. Оптимизатор Adam адаптирует скорость обучения.

Трансформеры используют механизм внимания. Self-attention учитывает зависимости."""

sentences = split_sentences(text)
for i, s in enumerate(sentences):
    print(f"[{i+1}] {s}")

Шаг 2: embedding с буфером

import asyncio
import numpy as np
from openai import AsyncOpenAI

client = AsyncOpenAI()


async def embed_batch(texts: list[str], model: str = "text-embedding-3-small") -> np.ndarray:
    """
    Эмбеддирует список текстов батчем.
    Возвращает матрицу (N, dim).
    """
    response = await client.embeddings.create(input=texts, model=model)
    vectors = [item.embedding for item in response.data]
    return np.array(vectors, dtype=np.float32)


def build_buffered_sentences(
    sentences: list[str],
    buffer_size: int = 1,
) -> list[str]:
    """
    Строит «буферизованные» предложения: каждое предложение объединяется
    с buffer_size соседями с обеих сторон.

    buffer_size=0 → эмбеддируем отдельные предложения
    buffer_size=1 → предложение + 1 сосед слева + 1 сосед справа
    buffer_size=2 → ещё шире окно
    """
    buffered = []
    n = len(sentences)
    for i in range(n):
        lo = max(0, i - buffer_size)
        hi = min(n, i + buffer_size + 1)
        buffered.append(" ".join(sentences[lo:hi]))
    return buffered


async def embed_sentences(
    sentences: list[str],
    buffer_size: int = 1,
    model: str = "text-embedding-3-small",
    batch_size: int = 100,       # API limit для одного запроса
) -> np.ndarray:
    """
    Эмбеддирует предложения с буфером, обрабатывая большие тексты батчами.
    Возвращает матрицу (N, dim) — по одному вектору на предложение.
    """
    buffered = build_buffered_sentences(sentences, buffer_size)

    # Батчами по batch_size
    all_vectors = []
    for start in range(0, len(buffered), batch_size):
        batch = buffered[start:start + batch_size]
        vectors = await embed_batch(batch, model=model)
        all_vectors.append(vectors)

    return np.vstack(all_vectors)

Шаг 3: cosine similarity и поиск границ

import numpy as np


def cosine_similarity_matrix(vectors: np.ndarray) -> np.ndarray:
    """
    Вычисляет cosine similarity между каждой парой соседних векторов.
    Возвращает массив длины N-1.
    """
    # Нормализуем каждый вектор до единичной длины
    norms = np.linalg.norm(vectors, axis=1, keepdims=True)
    norms = np.maximum(norms, 1e-9)          # защита от деления на ноль
    normalized = vectors / norms

    # Скалярное произведение соседних пар = cosine similarity
    similarities = np.einsum("id,id->i", normalized[:-1], normalized[1:])
    return similarities.astype(np.float64)


def find_breakpoints(
    similarities: np.ndarray,
    method: str = "percentile",
    percentile_threshold: float = 5.0,
    absolute_threshold: float = 0.5,
) -> list[int]:
    """
    Находит индексы разрывов (границ чанков).

    method="percentile":  граница где sim < percentile(similarities, percentile_threshold)
    method="absolute":    граница где sim < absolute_threshold
    method="gradient":    граница где резкое падение sim (нижний percentile_threshold% градиентов)

    Возвращает список индексов i таких, что граница стоит ПОСЛЕ предложения i.
    """
    if method == "percentile":
        threshold = float(np.percentile(similarities, percentile_threshold))
        return [i for i, s in enumerate(similarities) if s < threshold]

    if method == "absolute":
        return [i for i, s in enumerate(similarities) if s < absolute_threshold]

    if method == "gradient":
        if len(similarities) < 2:
            return []
        # Падение: sim[i] - sim[i+1] > 0 → тема переключилась
        gradients = np.diff(similarities)         # длина N-2
        neg_grads = -gradients                    # инвертируем: большой = резкое падение
        threshold  = float(np.percentile(neg_grads, 100 - percentile_threshold))
        return [i + 1 for i, g in enumerate(neg_grads) if g > threshold]

    raise ValueError(f"Неизвестный метод: {method!r}")


def sentences_to_chunks(
    sentences: list[str],
    breakpoints: list[int],
    min_chunk_size: int = 50,
) -> list[str]:
    """
    Собирает предложения в чанки по найденным границам.
    Слишком маленькие чанки (< min_chunk_size символов) объединяются с соседом.
    """
    if not sentences:
        return []

    breakpoint_set = set(breakpoints)
    chunks: list[str] = []
    current: list[str] = []

    for i, sent in enumerate(sentences):
        current.append(sent)
        # Граница стоит ПОСЛЕ предложения i
        if i in breakpoint_set and i < len(sentences) - 1:
            chunk_text = " ".join(current).strip()
            if len(chunk_text) >= min_chunk_size:
                chunks.append(chunk_text)
                current = []
            # Иначе продолжаем накапливать (чанк слишком маленький)

    # Последний чанк
    if current:
        last = " ".join(current).strip()
        if chunks and len(last) < min_chunk_size:
            chunks[-1] = chunks[-1] + " " + last  # прикрепляем к предыдущему
        elif last:
            chunks.append(last)

    return chunks

Шаг 4: полный SemanticSplitter

import asyncio
from dataclasses import dataclass, field

import numpy as np


@dataclass
class SemanticSplitterConfig:
    """Конфигурация семантического сплиттера."""
    # Параметры эмбеддинга
    embedding_model:  str   = "text-embedding-3-small"
    buffer_size:      int   = 1        # окно усреднения вокруг каждого предложения
    batch_size:       int   = 100      # предложений за один API-запрос

    # Параметры порога
    breakpoint_method:        str   = "percentile"  # percentile | absolute | gradient
    percentile_threshold:     float = 5.0           # нижние 5% переходов → граница
    absolute_threshold:       float = 0.5           # только для method="absolute"

    # Постобработка
    min_chunk_size:   int   = 50       # символов, маленькие чанки склеиваются
    max_chunk_size:   int   = 3000     # символов, большие чанки дополнительно режутся


class SemanticSplitter:
    """
    Async семантический сплиттер.
    Разбивает текст на чанки по семантическим границам через embedding-модель.
    """

    def __init__(self, config: SemanticSplitterConfig | None = None):
        self.cfg = config or SemanticSplitterConfig()

    async def split_text(self, text: str) -> list[str]:
        """Разбивает один текст на семантические чанки."""
        # 1. Предложения
        sentences = split_sentences(text)
        if len(sentences) <= 1:
            return [text.strip()] if text.strip() else []

        # 2. Embeddings с буфером
        vectors = await embed_sentences(
            sentences,
            buffer_size=self.cfg.buffer_size,
            model=self.cfg.embedding_model,
            batch_size=self.cfg.batch_size,
        )

        # 3. Cosine similarity между соседними
        similarities = cosine_similarity_matrix(vectors)

        # 4. Поиск границ
        breakpoints = find_breakpoints(
            similarities,
            method=self.cfg.breakpoint_method,
            percentile_threshold=self.cfg.percentile_threshold,
            absolute_threshold=self.cfg.absolute_threshold,
        )

        # 5. Сборка чанков
        chunks = sentences_to_chunks(
            sentences, breakpoints,
            min_chunk_size=self.cfg.min_chunk_size,
        )

        # 6. Дополнительное разбиение слишком больших чанков
        final: list[str] = []
        for chunk in chunks:
            if len(chunk) > self.cfg.max_chunk_size:
                # Fallback: recursive splitting для больших чанков
                from recursive_splitting import RecursiveCharacterSplitter
                sub = RecursiveCharacterSplitter(
                    chunk_size=self.cfg.max_chunk_size,
                    chunk_overlap=self.cfg.max_chunk_size // 10,
                ).split_text(chunk)
                final.extend(sub)
            else:
                final.append(chunk)

        return final

    async def split_documents(self, documents: list[Document]) -> list[Document]:
        """Разбивает список документов, параллельно обрабатывая их."""
        # Параллельная обработка всех документов
        results = await asyncio.gather(*[
            self._split_document(doc) for doc in documents
        ])
        return [chunk for doc_chunks in results for chunk in doc_chunks]

    async def _split_document(self, doc: Document) -> list[Document]:
        chunks = await self.split_text(doc.page_content)
        return [
            Document(
                page_content=chunk,
                metadata={
                    **doc.metadata,
                    "chunk_index": i,
                    "chunk_total": len(chunks),
                    "chunk_size":  len(chunk),
                    "chunking_method": "semantic",
                },
            )
            for i, chunk in enumerate(chunks)
        ]


# Использование
async def demo():
    text = """
    Нейронные сети обучаются с помощью метода обратного распространения ошибки.
    Градиент функции потерь вычисляется относительно каждого веса в сети.
    Оптимизатор Adam адаптирует скорость обучения для каждого параметра.

    Трансформеры используют механизм внимания для обработки последовательностей.
    Self-attention позволяет модели учитывать зависимости на любой дистанции.
    Позиционное кодирование добавляет информацию о порядке токенов.
    """.strip()

    splitter = SemanticSplitter(SemanticSplitterConfig(
        percentile_threshold=10.0,
        buffer_size=1,
    ))
    chunks = await splitter.split_text(text)

    for i, chunk in enumerate(chunks):
        print(f"── Chunk {i+1} ({len(chunk)} симв) ──")
        print(chunk[:150])
        print()


if __name__ == "__main__":
    asyncio.run(demo())

Выбор embedding-модели

Качество semantic chunking напрямую зависит от модели эмбеддингов: она должна хорошо различать близкие, но разные темы в коротких предложениях.

Модель
Dim
$/1M tok
Скорость
Примечание
text-embedding-3-small
1536
$0.02
Быстро
Хороший баланс цена/качество. Рекомендуется для старта.
text-embedding-3-large
3072
$0.13
Быстро
Лучше на сложных доменных текстах. 6× дороже small.
multilingual-e5-large
1024
Бесплатно
Медленно
Локальная модель. Хороший RU-результат, ~1 с/батч на CPU.
cohere embed-v3
1024
$0.10
Быстро
Лучший результат для коротких предложений. Input type = "clustering".
sentence-transformers/paraphrase-multilingual
768
Бесплатно
Средне
Быстрее e5-large на GPU. Хорош для разработки без API-ключей.

Локальный вариант без API

from sentence_transformers import SentenceTransformer
import numpy as np


class LocalEmbedder:
    """
    Локальный эмбеддер через sentence-transformers.
    Без API-ключей, без стоимости за токены.
    Для production: использовать GPU-инстанс или ONNX-runtime.
    """

    def __init__(
        self,
        model_name: str = "intfloat/multilingual-e5-large",
        device: str = "cpu",            # "cuda" если есть GPU
        batch_size: int = 32,
    ):
        self.model = SentenceTransformer(model_name, device=device)
        self.batch_size = batch_size

    def embed(self, texts: list[str]) -> np.ndarray:
        """
        Эмбеддирует список текстов.
        multilingual-e5 требует префикс "query: " для поиска и "passage: " для документов.
        При чанкинге используем "passage: " — мы индексируем, а не ищем.
        """
        prefixed = [f"passage: {t}" for t in texts]
        return self.model.encode(
            prefixed,
            batch_size=self.batch_size,
            show_progress_bar=False,
            normalize_embeddings=True,     # уже нормализованы → dot product = cosine sim
        )

    async def embed_async(self, texts: list[str]) -> np.ndarray:
        """Обёртка для использования в async-пайплайне через executor."""
        import asyncio
        loop = asyncio.get_event_loop()
        return await loop.run_in_executor(None, self.embed, texts)


# Интеграция с SemanticSplitter
class SemanticSplitterLocal(SemanticSplitter):
    """Версия с локальной моделью вместо OpenAI API."""

    def __init__(self, config: SemanticSplitterConfig | None = None):
        super().__init__(config)
        self._embedder = LocalEmbedder()

    async def _get_vectors(self, sentences: list[str]) -> np.ndarray:
        buffered = build_buffered_sentences(sentences, self.cfg.buffer_size)
        return await self._embedder.embed_async(buffered)

Оптимизация: кешировать embeddings при переиндексации

Semantic chunking делает N API-запросов на документ (по одному на батч предложений). При переиндексации — те же расходы заново. Кешируем векторы по SHA-256 хешу текста.

import hashlib
import json
import struct
from pathlib import Path

import numpy as np


class EmbeddingCache:
    """
    Дисковый кеш для embedding-векторов.
    Ключ: SHA-256(text + model_name).
    Формат: бинарный (numpy save) для скорости.
    """

    def __init__(self, cache_dir: str = ".embedding_cache"):
        self.cache_dir = Path(cache_dir)
        self.cache_dir.mkdir(exist_ok=True)

    def _key(self, texts: list[str], model: str) -> str:
        payload = json.dumps({"texts": texts, "model": model}, ensure_ascii=False)
        return hashlib.sha256(payload.encode()).hexdigest()[:16]

    def get(self, texts: list[str], model: str) -> np.ndarray | None:
        path = self.cache_dir / f"{self._key(texts, model)}.npy"
        if path.exists():
            return np.load(str(path))
        return None

    def set(self, texts: list[str], model: str, vectors: np.ndarray) -> None:
        path = self.cache_dir / f"{self._key(texts, model)}.npy"
        np.save(str(path), vectors)


# Патчим embed_batch для использования кеша
_cache = EmbeddingCache()

async def embed_batch_cached(
    texts: list[str],
    model: str = "text-embedding-3-small",
) -> np.ndarray:
    cached = _cache.get(texts, model)
    if cached is not None:
        return cached

    vectors = await embed_batch(texts, model=model)
    _cache.set(texts, model, vectors)
    return vectors

Когда использовать semantic chunking

Длинные тексты с несколькими темами без явных заголовков. Академические статьи, новостные материалы, интервью — автор переходит от одной идеи к другой без синтаксических маркеров. Semantic chunking улавливает эти переходы там, где recursive splitter их пропустит.
Подходит
Высокое качество поиска критично, стоимость — второстепенна. В production RAG для аналитики, юридических документов, медицины — каждый нерелевантный чанк стоит доверия пользователя. Semantic chunking даёт существенно более «чистые» чанки.
Подходит
⚠️
Большой объём документов при ограниченном бюджете. 100 000 документов × 200 предложений × $0.02/1M токенов ≈ $400 на первую индексацию. С кешем переиндексация дешевле, но первый раз нужно учитывать. Для прототипов — использовать локальную модель.
С осторожностью
Короткие, однородные документы (FAQ, карточки товаров). Если каждый документ уже посвящён одной теме и занимает 2–5 предложений, semantic chunking не даёт преимущества — но добавляет API-расходы. Достаточно fixed-size или recursive.
Не нужно
Текст без пунктуации или с очень короткими предложениями. Транскрипты речи, чат-логи, social media посты — sentence splitter плохо работает на таких данных, векторы предложений нестабильны. Лучше recursive splitting или document-aware chunking.
Не подходит

Типичные ошибки

Слишком высокий percentile_threshold → слишком много чанков
percentile_threshold=50 означает «граница там, где similarity ниже медианы» — каждый второй переход становится границей. Документ нарезается на сотни однопредложенных чанков без контекста.
✓ Стартуйте с percentile_threshold=5–10%. Проверьте среднее число предложений в чанке: хорошо — 3–8, плохо — 1–2 или 20+.
Не устанавливать max_chunk_size
Если в тексте длинный раздел без смены темы (например, подробное техническое описание), semantic chunking объединит его в один чанк на 5000+ токенов, который не влезет в контекст embedding-модели.
✓ Всегда задавайте max_chunk_size (например, 2000 символов). Большие чанки дополнительно режьте recursive splitter как fallback.
Эмбеддировать отдельные предложения без буфера
Короткое предложение («Да.», «Это важно.») даёт вырожденный вектор. Соседние предложения кажутся «несвязанными» — появляются ложные границы.
✓ Используйте buffer_size=1 (умолчание). Для коротких предложений — buffer_size=2.
Не кешировать embeddings при разработке
Каждый запуск тестирования с разными threshold — новые API-вызовы. При 50 итерациях настройки на корпусе из 500 документов — $10+ только на эксперименты.
✓ Добавьте EmbeddingCache с первого дня. Это 20 строк кода, которые окупаются уже на второй итерации настройки.
Смешивать языки в одном батче без multilingual модели
text-embedding-3-small работает с разными языками, но в пространстве embeddings русские и английские векторы могут быть далеко друг от друга. Документ с кодом (EN) + объяснением (RU) даст неестественные разрывы на языковой границе, а не на смысловой.
✓ Для смешанных документов используйте multilingual-e5-large или cohere embed-v3, которые хорошо выравнивают межъязыковые пространства.

Шпаргалка

Быстрый старт:
  1. Разбей на предложения: split_sentences(text)
  2. Embedding с буфером: buffer_size=1, модель text-embedding-3-small
  3. Cosine similarity между соседними: cosine_similarity_matrix(vectors)
  4. Найди разрывы: find_breakpoints(sims, method="percentile", percentile_threshold=5)
  5. Собери чанки: sentences_to_chunks(sentences, breakpoints)
  6. Режь слишком большие: max_chunk_size=2000 через recursive splitter
ПАРАМЕТРЫ И ИХ ВЛИЯНИЕ:

buffer_size:
  0 → нестабильно (короткие предложения = шумные векторы)
  1 → хорошо для большинства текстов (рекомендуется)
  2 → лучше для очень коротких предложений или поэтического текста

percentile_threshold (method="percentile"):
  1–3%  → мало чанков, крупные блоки (мало разрывов)
  5–10% → хорошо для большинства задач (рекомендуется)
  15%+  → много мелких чанков

min_chunk_size:
  50    → склеивает однословные «случайные» чанки
  100   → более агрессивное склеивание

max_chunk_size:
  2000  → хороший лимит для большинства embedding-моделей
  4000  → если модель поддерживает 8192 токенов

СТОИМОСТЬ НА 1000 ДОКУМЕНТОВ (≈200 предложений каждый):
  text-embedding-3-small  ≈ $0.8   (с кешем: $0 при переиндексации)
  multilingual-e5-large   ≈ $0     (локально, ~5 мин на CPU)

КОГДА SEMANTIC ЛУЧШЕ RECURSIVE:
  ✓ Нет структурных маркеров (заголовков, абзацев)
  ✓ Много тем в одном тексте
  ✓ Нужен максимальный precision/recall при поиске

КОГДА RECURSIVE ЛУЧШЕ SEMANTIC:
  ✓ Есть явная структура (MD-заголовки, HTML)
  ✓ Большой объём, бюджет ограничен
  ✓ Нужен детерминированный результат без API
        

Практические задания

  1. Сравнение методов порога. Возьмите статью из Википедии (10+ разделов). Нарежьте её SemanticSplitter с тремя конфигурациями: percentile=5, percentile=15, absolute=0.5. Для каждой выведите количество чанков и первые 2 предложения каждого чанка. Оцените: совпадают ли границы чанков с реальными разделами статьи?
  2. Visualizer сигнала similarity. Реализуйте функцию plot_similarity(text, model), которая строит матплотлиб-график: ось X — индекс перехода между предложениями, ось Y — cosine similarity, горизонтальная линия — выбранный порог. Найдите оптимальный percentile_threshold визуально для вашего корпуса.
  3. Гибридный сплиттер. Напишите HybridSplitter, который сначала применяет RecursiveCharacterSplitter чтобы получить черновые чанки, затем к каждому черновому чанку применяет SemanticSplitter для дополнительного дробления. Это дешевле чем семантический сплиттер на весь документ, но точнее чем только recursive.