Почему контекстного окна недостаточно

Представь агента, который должен отвечать на вопросы о кодовой базе проекта из 500 файлов. Или персонального ассистента, который помнит все разговоры за год. Или корпоративный чат-бот, обученный на 10 000 страницах документации.

Положить всё это в context window невозможно — даже 200 000 токенов это примерно 150 000 слов или ~400 страниц. А каждый дополнительный токен в контексте увеличивает стоимость запроса. Нужна другая архитектура:

  • Хранить знания вне context window — в специализированной базе данных
  • Находить только релевантный фрагмент под конкретный вопрос
  • Подавать найденное в context window только тогда, когда нужно

Это паттерн RAG (Retrieval-Augmented Generation) — генерация с подкреплением через поиск. Поиск здесь не текстовый (по ключевым словам), а семантический — по смыслу. Именно поэтому нужны векторные эмбеддинги.

💡 Долгосрочная ≠ вечная

«Долгосрочная» означает «переживает контекстное окно и перезапуски процесса». Данные хранятся в персистентной базе (файл, SQLite, специализированный vector store) и доступны между сессиями. Срок хранения определяешь ты сам — от часов до лет.

Эмбеддинги: текст как точка в пространстве

Компьютер не понимает «смысл» слов. Но мы можем превратить текст в числовой вектор из 384, 768 или 1536 чисел — эмбеддинг. Обученная нейросеть (embedding model) делает это так: тексты с похожим смыслом получают близкие векторы, тексты о разном — далёкие.

Вот что происходит под капотом: embedding model обучается предсказывать, какие тексты встречаются рядом в реальных документах. В результате «кошка», «кот» и «котёнок» оказываются в одной части пространства, а «автомобиль» и «машина» — в другой. Геометрическая близость = смысловая близость.

Как выглядит эмбеддинг

Эмбеддинг — это просто список чисел, каждое из которых — значение одной «оси» в многомерном пространстве. Вот первые 24 числа из реального эмбеддинга (384-мерного) для двух разных фраз:

«Как приготовить пасту?» — первые 24 из 384 измерений
dim[0..23]  ·  полный вектор: 384 числа типа float32  ·  размер: ~1.5 KB
«Рецепт итальянских макарон» — семантически близко к предыдущему
cosine similarity с первым: 0.91 — почти идентичны по смыслу
«Квантовая запутанность в физике» — семантически далеко
cosine similarity с первым: 0.08 — совершенно другая тема

Косинусное сходство

Расстояние между векторами в пространстве смысла измеряют через косинусное сходство — косинус угла между двумя векторами. Значение 1.0 означает «одинаковые», 0.0 — «перпендикулярные / несвязанные», -1.0 — «противоположные».

Формула: cos(θ) = (A · B) / (|A| × |B|)

На практике это выглядит так:

«Как завести кошку?»
vs
«Советы по уходу за котами»
0.89
«Как завести кошку?»
vs
«Купить автомобиль недорого»
0.34
«Как завести кошку?»
vs
«Квантовая механика Дирака»
0.06
💡 Cosine vs Dot product vs Euclidean

Cosine similarity нечувствительна к длине вектора — короткий и длинный тексты об одном сравниваются справедливо. Dot product быстрее считается, но предпочитает длинные тексты. Euclidean distance — геометрическое расстояние, зависит от нормы. Для большинства задач NLP — используй cosine. ChromaDB и большинство vector store по умолчанию работают с cosine.

Векторное хранилище: архитектура и устройство

Когда эмбеддингов становится миллионы — перебирать все для поиска Top-K слишком медленно. Наивный поиск: O(N × D), где N — число векторов, D — размерность. При N=1M и D=768 — это миллиард операций на один запрос.

Векторные базы данных решают эту проблему через ANN (Approximate Nearest Neighbor) — алгоритм приближённого поиска, который находит близкие векторы за O(log N) или O(√N) вместо O(N). Точность немного падает, зато скорость растёт на порядки.

100%
колёсико — масштаб  ·  зажать и тянуть — перемещение
① ИНДЕКСИРОВАНИЕ VECTOR STORE ② ПОИСК (RETRIEVAL) 📄 Документы / История диалогов файлы, БД, прошлые разговоры чанкинг чанк 1 чанк 2 чанк N векторизация ⚡ Embedding Model all-MiniLM-L6-v2 / text-embedding-3-small [0.12, -0.44, 0.87, ...] сохранение Q вектор-документ Top-K результат query-вектор Запрос агента «Что говорил пользователь про дедлайны?» векторизация ⚡ Embedding Model (та же модель — важно!) ANN поиск Top-3 релевантных чанка score: 0.91 · 0.88 · 0.82 в системный промпт LLM (Claude) ✓ Ответ с контекстом из памяти

Самый распространённый индекс — HNSW (Hierarchical Navigable Small World): строит иерархический граф из векторов, навигация по которому даёт <10 мс на поиск даже при миллионе документов.

Чанкинг: разбивка текста на части

Прежде чем векторизовать документ, его нужно разбить на чанки (chunks) — фрагменты подходящего размера. Слишком большой чанк — размывается смысл, поиск находит нерелевантное. Слишком маленький — теряется контекст, смысл не считывается без соседних предложений.

Вот как выглядит чанкинг с перекрытием (overlap) визуально:

Агент — это система, которая воспринимает окружающую среду через инструменты и действует в ней для достижения цели. для достижения цели. В отличие от простого chatbot, агент сохраняет контекст, планирует шаги и адаптируется к изменениям. и адаптируется к изменениям. Долгосрочная память позволяет агенту помнить прошлые взаимодействия между сессиями.
Чанк 1
Чанк 2
Чанк 3
Перекрытие (overlap)

Перекрытие предотвращает разрыв смысла на границах чанков: одно и то же предложение попадает в два соседних чанка — ни один из них не теряет контекст.

По размеру (fixed-size)

Режем каждые N символов или токенов с перекрытием. Просто, предсказуемо.

Логи, код, структурированные данные
По предложениям

Группируем N предложений вместе. Не разрывает смысл внутри предложения.

Статьи, документация, диалоги
По параграфам / заголовкам

Режем по структуре документа. Максимально сохраняет смысловые блоки.

Markdown, HTML, структурированные доки
⚠️ Размер чанка — главный гиперпараметр

Нет универсального правила. Типичный диапазон: 200–1000 токенов на чанк, overlap 10–20% от размера. Слишком маленькие чанки (менее 100 токенов) теряют контекст — «2023» без окружения бессмысленно. Слишком большие (более 2000 токенов) — размывают релевантность при поиске. Тестируй на своих данных.

Реализация: embedding + in-memory поиск

Embedding model

Anthropic не предоставляет embeddings API. Для локальной разработки и обучения используем sentence-transformers — лучшую опенсорсную библиотеку для текстовых эмбеддингов:

# Установка зависимостей
pip install sentence-transformers chromadb numpy
from sentence_transformers import SentenceTransformer
import numpy as np

# Модель скачивается при первом запуске (~90 MB)
# all-MiniLM-L6-v2 — хороший баланс скорости и качества (384 dims)
model = SentenceTransformer("all-MiniLM-L6-v2")


def embed(text: str) -> list[float]:
    """Превращает текст в вектор-эмбеддинг."""
    return model.encode(text, normalize_embeddings=True).tolist()


def embed_batch(texts: list[str]) -> list[list[float]]:
    """
    Векторизует список текстов за один вызов.
    Batch-обработка в 5-10x быстрее, чем вызывать embed() в цикле.
    """
    return model.encode(texts, normalize_embeddings=True, batch_size=64).tolist()


def cosine_similarity(a: list[float], b: list[float]) -> float:
    """Косинусное сходство двух нормализованных векторов — просто dot product."""
    return float(np.dot(a, b))   # normalize_embeddings=True делает |a|=|b|=1


# Пример
v1 = embed("Как приготовить борщ?")
v2 = embed("Рецепт традиционного борща с говядиной")
v3 = embed("Квантовая механика в физике")

print(f"Похожие тексты:  {cosine_similarity(v1, v2):.3f}")  # → 0.887
print(f"Разные темы:     {cosine_similarity(v1, v3):.3f}")  # → 0.071
💡 Альтернативы для продакшна

sentence-transformers — лучший выбор для обучения и прототипов: бесплатно, локально, быстро. Для продакшна рассмотри API-решения: OpenAI text-embedding-3-small (1536 dims, $0.02/1M tok), Cohere embed-v3 (многоязычный), Voyage AI (специализируется на code/RAG). Главное правило: при индексировании и при поиске используй одну и ту же модель.

Чанкинг

import re


def chunk_by_tokens(
    text: str,
    chunk_size: int = 500,   # символы (не токены — приблизительно)
    overlap: int = 80
) -> list[str]:
    """
    Разбивает текст на чанки фиксированного размера с перекрытием.
    Предпочитает разрыв по пробелу, а не посередине слова.
    """
    chunks = []
    start = 0
    while start < len(text):
        end = min(start + chunk_size, len(text))

        # Ищем ближайший пробел перед концом чанка (не режем слово)
        if end < len(text):
            space = text.rfind(' ', start, end)
            if space > start + chunk_size // 2:
                end = space

        chunk = text[start:end].strip()
        if chunk:
            chunks.append(chunk)
        start = end - overlap

    return chunks


def chunk_by_sentences(
    text: str,
    sentences_per_chunk: int = 5,
    overlap_sentences: int = 1
) -> list[str]:
    """
    Разбивает текст по предложениям — не разрывает смысл в середине фразы.
    """
    # Разбиваем по знакам конца предложения
    sentences = re.split(r'(?<=[.!?])\s+(?=[А-ЯA-Z«"(])', text)
    sentences = [s.strip() for s in sentences if s.strip()]

    chunks = []
    i = 0
    while i < len(sentences):
        chunk_sents = sentences[i : i + sentences_per_chunk]
        chunks.append(' '.join(chunk_sents))
        i += sentences_per_chunk - overlap_sentences

    return chunks


# Пример
text = (
    "Агент — это система, которая воспринимает среду и действует в ней. "
    "В отличие от простого чатбота, агент сохраняет контекст и планирует шаги. "
    "Долгосрочная память позволяет агенту помнить прошлые взаимодействия. "
    "Векторные эмбеддинги — основа семантического поиска по памяти. "
    "RAG объединяет поиск и генерацию в единый пайплайн."
)
chunks = chunk_by_sentences(text, sentences_per_chunk=2, overlap_sentences=1)
for i, c in enumerate(chunks):
    print(f"[{i}] {c[:80]}...")

In-memory vector store

Простейший vector store — список документов с линейным поиском. Подходит для обучения и небольших баз (<10 000 документов):

from dataclasses import dataclass, field


@dataclass
class Document:
    id: str
    content: str
    embedding: list[float]
    metadata: dict = field(default_factory=dict)


class InMemoryVectorStore:
    """
    Простой in-memory vector store с линейным поиском.
    Для продакшна используй ChromaDB, Qdrant или pgvector.
    """
    def __init__(self):
        self._docs: list[Document] = []

    def add(self, doc_id: str, content: str, metadata: dict | None = None) -> None:
        """Добавляет документ: сразу векторизует и сохраняет."""
        self._docs.append(Document(
            id=doc_id,
            content=content,
            embedding=embed(content),
            metadata=metadata or {}
        ))

    def add_texts(self, texts: list[str], metadatas: list[dict] | None = None) -> None:
        """Batch-добавление: векторизует все тексты за один вызов модели."""
        embeddings = embed_batch(texts)
        for i, (text, emb) in enumerate(zip(texts, embeddings)):
            meta = (metadatas[i] if metadatas else {})
            self._docs.append(Document(
                id=f"doc_{len(self._docs)}",
                content=text,
                embedding=emb,
                metadata=meta
            ))

    def search(
        self,
        query: str,
        k: int = 5,
        min_score: float = 0.3
    ) -> list[dict]:
        """
        Семантический поиск Top-K документов.
        Возвращает отсортированный список с полями: id, content, score, metadata.
        """
        if not self._docs:
            return []

        query_emb = embed(query)
        scored = [
            (doc, cosine_similarity(query_emb, doc.embedding))
            for doc in self._docs
        ]
        # Фильтруем ниже порога и сортируем по убыванию
        scored = [(doc, s) for doc, s in scored if s >= min_score]
        scored.sort(key=lambda x: x[1], reverse=True)

        return [
            {
                "id": doc.id,
                "content": doc.content,
                "score": round(score, 4),
                "metadata": doc.metadata
            }
            for doc, score in scored[:k]
        ]

    def __len__(self) -> int:
        return len(self._docs)


# Пример использования
store = InMemoryVectorStore()
store.add_texts([
    "Пользователь предпочитает краткие ответы без лишних слов.",
    "Дедлайн по проекту X — 15 марта 2025 года.",
    "Главная задача на эту неделю: реализовать vector store.",
    "Пользователь работает в стартапе, команда из 4 человек.",
])

results = store.search("Когда дедлайн?", k=2)
for r in results:
    print(f"[{r['score']:.3f}] {r['content']}")

ChromaDB: персистентное хранилище

ChromaDB — лёгкая embedded vector база данных. Не требует отдельного сервера, хранит данные в файле на диске, сохраняет их между перезапусками агента. Идеальный выбор для прототипов и небольших продуктивных агентов (до ~1M документов).

Что ChromaDB делает автоматически:

  • Хранит векторы и оригинальные тексты вместе
  • Строит HNSW-индекс для быстрого ANN-поиска
  • Поддерживает метаданные и фильтрацию по ним
  • Сохраняет коллекции на диск при PersistentClient
import chromadb
from sentence_transformers import SentenceTransformer

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

# PersistentClient сохраняет данные в ./agent_memory_db/
# При следующем запуске — данные на месте
chroma_client = chromadb.PersistentClient(path="./agent_memory_db")

collection = chroma_client.get_or_create_collection(
    name="agent_memory",
    metadata={"hnsw:space": "cosine"}   # метрика сходства
)


def add_to_memory(
    content: str,
    doc_id: str,
    metadata: dict | None = None
) -> None:
    """Добавляет текст в долгосрочную память агента."""
    embedding = model.encode(content, normalize_embeddings=True).tolist()
    collection.add(
        ids=[doc_id],
        embeddings=[embedding],
        documents=[content],
        metadatas=[metadata or {}]
    )


def search_memory(
    query: str,
    k: int = 5,
    where: dict | None = None   # фильтр по метаданным (опционально)
) -> list[dict]:
    """
    Семантический поиск по долгосрочной памяти.
    where: {'type': 'preference'} — ищем только в документах нужного типа.
    """
    query_emb = model.encode(query, normalize_embeddings=True).tolist()

    kwargs: dict = {"query_embeddings": [query_emb], "n_results": k}
    if where:
        kwargs["where"] = where

    results = collection.query(**kwargs)

    if not results["ids"][0]:
        return []

    return [
        {
            "id":       results["ids"][0][i],
            "content":  results["documents"][0][i],
            # ChromaDB возвращает cosine distance (0=одинаковые, 2=противоположные)
            # Переводим в similarity (1=одинаковые, -1=противоположные)
            "score":    round(1 - results["distances"][0][i], 4),
            "metadata": results["metadatas"][0][i]
        }
        for i in range(len(results["ids"][0]))
    ]


# Наполняем память агента разными типами данных
add_to_memory(
    "Пользователь предпочитает краткие ответы без списков",
    doc_id="pref_1",
    metadata={"type": "preference", "user_id": "ivan"}
)
add_to_memory(
    "Дедлайн по проекту Alpha — 15 марта 2025, ответственный Иван",
    doc_id="fact_1",
    metadata={"type": "fact", "date": "2025-01-10"}
)
add_to_memory(
    "В предыдущей сессии обсуждали архитектуру vector store для агента",
    doc_id="ctx_1",
    metadata={"type": "context", "session": "2025-01-09"}
)

# Поиск
results = search_memory("когда дедлайн по проекту?", k=3)
for r in results:
    print(f"[{r['score']:.3f}] ({r['metadata'].get('type', '?')}) {r['content']}")

Фильтрация по метаданным

Семантический поиск иногда находит нерелевантное из-за случайного сходства векторов. Метаданные позволяют сузить область поиска до нужного типа или временного периода:

# Поиск только среди предпочтений пользователя
prefs = search_memory(
    "как отвечать пользователю",
    k=3,
    where={"type": "preference"}
)

# Поиск только контекста из последней сессии
recent_ctx = search_memory(
    "что обсуждали",
    k=5,
    where={"session": "2025-01-09"}
)

# Комбинированный фильтр: тип И пользователь
user_facts = search_memory(
    "дедлайны и задачи",
    k=5,
    where={"$and": [{"type": "fact"}, {"user_id": "ivan"}]}
)
💡 Типология записей в памяти

Разделение по типам через метаданные — хорошая практика. Типичные типы: fact (объективные данные: даты, имена, числа), preference (пользовательские предпочтения по стилю ответов), context (прошлые разговоры, незакрытые задачи), knowledge (доменные знания из документов).

RAG-агент: retrieval как инструмент

Интегрируем долгосрочную память в агентский цикл через tool calling. Агент сам решает, когда нужно обратиться к памяти — это лучше, чем автоматически подставлять результаты поиска в каждый запрос:

01
Пользователь задаёт вопрос
Агент получает вопрос и анализирует — нужен ли поиск по памяти
intent detection
02
Вызов search_memory
Агент формулирует поисковый запрос и вызывает инструмент
tool_use
03
ANN поиск в ChromaDB
Запрос векторизуется, ищутся Top-K похожих чанков
cosine ANN
04
Генерация с контекстом
Найденные фрагменты подставляются в промпт, LLM генерирует ответ
grounded response
import anthropic

client = anthropic.Anthropic()

SEARCH_MEMORY_TOOL = {
    "name": "search_memory",
    "description": (
        "Поиск релевантной информации в долгосрочной памяти агента. "
        "Используй при вопросах о прошлых разговорах, предпочтениях пользователя, "
        "дедлайнах и фактах из предыдущих сессий."
    ),
    "input_schema": {
        "type": "object",
        "properties": {
            "query": {
                "type": "string",
                "description": "Поисковый запрос — что именно нужно найти в памяти"
            },
            "k": {
                "type": "integer",
                "description": "Количество результатов (1-5, по умолчанию 3)"
            },
            "type_filter": {
                "type": "string",
                "enum": ["fact", "preference", "context", "knowledge"],
                "description": "Тип записей для поиска (опционально)"
            }
        },
        "required": ["query"]
    }
}

SAVE_MEMORY_TOOL = {
    "name": "save_to_memory",
    "description": (
        "Сохраняет важную информацию в долгосрочную память. "
        "Используй для фактов, решений, предпочтений пользователя и контекста "
        "который может понадобиться в будущих сессиях."
    ),
    "input_schema": {
        "type": "object",
        "properties": {
            "content": {
                "type": "string",
                "description": "Текст для сохранения — краткий и конкретный факт"
            },
            "type": {
                "type": "string",
                "enum": ["fact", "preference", "context", "knowledge"],
                "description": "Тип записи"
            }
        },
        "required": ["content", "type"]
    }
}

SYSTEM = """Ты — персональный ассистент с долгосрочной памятью.

Используй инструменты памяти:
- search_memory: ищи контекст прошлых разговоров перед ответом на вопросы о них
- save_to_memory: сохраняй важные факты, предпочтения и решения из текущего диалога

При поиске — формулируй запрос чётко. При сохранении — пиши кратко и конкретно."""


def run_memory_agent(user_message: str, conversation: list[dict]) -> str:
    """
    Агент с долгосрочной памятью.
    conversation — краткосрочная история текущей сессии (список messages).
    """
    import uuid

    messages = list(conversation)
    messages.append({"role": "user", "content": user_message})

    while True:
        response = client.messages.create(
            model="claude-opus-4-6",
            max_tokens=1024,
            system=SYSTEM,
            tools=[SEARCH_MEMORY_TOOL, SAVE_MEMORY_TOOL],
            messages=messages
        )

        if response.stop_reason == "end_turn":
            answer = response.content[0].text
            messages.append({"role": "assistant", "content": answer})
            return answer

        # Обрабатываем tool calls
        messages.append({"role": "assistant", "content": response.content})
        tool_results = []

        for block in response.content:
            if block.type != "tool_use":
                continue

            if block.name == "search_memory":
                where = None
                if tf := block.input.get("type_filter"):
                    where = {"type": tf}

                results = search_memory(
                    query=block.input["query"],
                    k=block.input.get("k", 3),
                    where=where
                )
                result_text = "\n\n".join(
                    f"[score={r['score']:.2f}, type={r['metadata'].get('type','?')}]\n{r['content']}"
                    for r in results
                ) or "Ничего не найдено в памяти."

                tool_results.append({
                    "type": "tool_result",
                    "tool_use_id": block.id,
                    "content": result_text
                })

            elif block.name == "save_to_memory":
                doc_id = f"{block.input['type']}_{uuid.uuid4().hex[:8]}"
                add_to_memory(
                    content=block.input["content"],
                    doc_id=doc_id,
                    metadata={"type": block.input["type"]}
                )
                tool_results.append({
                    "type": "tool_result",
                    "tool_use_id": block.id,
                    "content": f"Сохранено: {block.input['content'][:60]}..."
                })

        messages.append({"role": "user", "content": tool_results})

Индексирование документов

Прежде чем агент может что-то найти, нужно наполнить базу. Типичный сценарий — индексирование корпоративной документации:

import uuid
from pathlib import Path


def index_markdown_file(filepath: str | Path) -> int:
    """
    Индексирует один Markdown-файл: разбивает на чанки и сохраняет в ChromaDB.
    Возвращает число созданных чанков.
    """
    path = Path(filepath)
    text = path.read_text(encoding="utf-8")

    # Разбиваем по параграфам (двойной перенос строки)
    paragraphs = [p.strip() for p in text.split("\n\n") if len(p.strip()) > 50]

    # Дополнительно режем слишком длинные параграфы
    chunks = []
    for para in paragraphs:
        if len(para) > 800:
            chunks.extend(chunk_by_tokens(para, chunk_size=600, overlap=80))
        else:
            chunks.append(para)

    # Batch-индексирование
    embeddings = embed_batch(chunks)
    ids = [f"{path.stem}_{i}" for i in range(len(chunks))]
    metadatas = [
        {"source": path.name, "type": "knowledge", "chunk_idx": i}
        for i in range(len(chunks))
    ]

    collection.add(
        ids=ids,
        embeddings=embeddings,
        documents=chunks,
        metadatas=metadatas
    )

    return len(chunks)


def index_conversation_history(messages: list[dict], session_id: str) -> None:
    """
    Сохраняет финальную историю диалога как контекстную память.
    Вызывается в конце каждой сессии.
    """
    # Группируем по парам user+assistant
    pairs = []
    for i in range(0, len(messages) - 1, 2):
        if messages[i]["role"] == "user" and messages[i+1]["role"] == "assistant":
            pairs.append(
                f"User: {messages[i]['content']}\nAssistant: {messages[i+1]['content']}"
            )

    if not pairs:
        return

    embeddings = embed_batch(pairs)
    ids = [f"conv_{session_id}_{i}" for i in range(len(pairs))]
    metadatas = [{"type": "context", "session": session_id} for _ in pairs]

    collection.add(ids=ids, embeddings=embeddings, documents=pairs, metadatas=metadatas)


# Использование
n = index_markdown_file("docs/architecture.md")
print(f"Проиндексировано {n} чанков из architecture.md")

Вот как может выглядеть наполненная долгосрочная память агента-ассистента:

preference Пользователь предпочитает краткие ответы без маркированных списков. Всегда отвечай в прозе, 2–3 предложения максимум.
fact Дедлайн проекта Alpha — 15 марта 2025. Ответственный: Иван. Статус: в работе.
context В сессии 09.01 обсуждали архитектуру vector store. Решили использовать ChromaDB + sentence-transformers. Открытый вопрос: размер чанков.
knowledge HNSW-индекс обеспечивает O(log N) поиск. При N=1M документов — поиск занимает <10 мс. ChromaDB строит HNSW автоматически.

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

Разные embedding-модели при индексировании и поиске
Проиндексировали документы через text-embedding-3-small, а при поиске используем all-MiniLM-L6-v2. Пространства векторов несовместимы — поиск вернёт случайный мусор, а не релевантное. Ошибку сложно заметить: API не ругается, результаты просто плохие.
Храни название модели в метаданных коллекции. При изменении модели — переиндексируй всю базу.
Слишком большие или слишком маленькие чанки
Чанки из 50 символов — слишком маленькие, теряют контекст: «2023» без окружения ничего не значит. Чанки из 3000 символов — слишком большие, размывают релевантность: один вектор пытается представить пять несвязанных идей.
Начни с 400–600 символов и overlap 80–100 символов. Оцени качество поиска на 10–20 реальных вопросах из твоего домена.
Поиск без минимального порога similarity
search_memory("дедлайн") вернёт Top-3 даже если самый релевантный результат имеет score 0.18 — то есть семантически почти нерелевантен. Агент получает бесполезный контекст и начинает «галлюцинировать» на его основе.
Добавь min_score=0.5 в поиск. Если ничего не нашлось — лучше сказать «не знаю», чем подставить нерелевантный чанк.
Индексировать всё подряд без фильтрации
Сохраняем каждый ответ ассистента в память, включая приветствия, извинения и служебные фразы («Конечно! Давай разберёмся...»). База засоряется шумом, релевантные результаты вытесняются.
Сохраняй только фактическую информацию — факты, решения, предпочтения. Используй LLM для оценки «стоит ли это помнить» перед сохранением.
Дублирование документов при переиндексировании
Перезапустили скрипт индексирования — теперь каждый чанк в базе дважды. ChromaDB не проверяет дубликаты автоматически, просто добавляет. Поиск вернёт одно и то же дважды и занизит другие результаты.
Используй стабильные детерминированные ID (хэш содержимого или имя файла + номер чанка). Тогда collection.upsert() вместо add() перезапишет старое.

Шпаргалка

  • Эмбеддинг = список чисел, где смысловая близость = геометрическая близость
  • Cosine similarity: 1.0 — одинаковые, 0.0 — несвязанные, <0.5 — нерелевантно
  • Чанк: 400–600 символов, overlap 10–20% — универсальная точка старта
  • Batch-векторизация (encode(texts)) в 5–10× быстрее поштучной
  • normalize_embeddings=True → cosine similarity = dot product (быстрее)
  • ChromaDB: PersistentClient = данные на диске между сессиями
  • hnsw:space: cosine в метаданных коллекции — обязательно для cosine similarity
  • ChromaDB distance = 1 − similarity: переводи обратно перед показом пользователю
  • Metadata filtering: where={"type": "fact"} сужает поиск до нужного типа
  • upsert() вместо add() + стабильные ID = безопасное переиндексирование
  • min_score ≥ 0.5: не подставляй нерелевантный контекст в промпт
  • Одна embedding model при индексировании = та же при поиске — нельзя смешивать
  • RAG-агент: поиск — это tool, агент сам решает когда его вызвать

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

  1. Персональный ассистент с памятью. Реализуй агента, который в конце каждого диалога сохраняет 3–5 ключевых фактов в ChromaDB. В следующей сессии агент должен сам найти и использовать релевантный контекст. Проверь: спроси агента о чём-то из прошлого разговора без напоминания.
  2. QA по документации. Возьми любую техническую документацию (например, документацию к FastAPI или SQLAlchemy). Проиндексируй её через чанкинг + ChromaDB. Реализуй чат-бота, который отвечает на вопросы по документации, цитируя конкретные фрагменты и указывая источник (имя файла, номер чанка).
  3. Оцени качество retrieval. Составь 10 вопросов к проиндексированным документам и 10 правильных ответов. Для каждого вопроса сделай поиск при разных размерах чанков (200, 500, 1000 символов). Измерь, при каком размере чанка правильный ответ чаще всего попадает в Top-3 результатов.