Зачем нужна отдельная база данных для векторов

Допустим, вы уже умеете превращать тексты в векторы. Следующий вопрос: где хранить 200 000 векторов по 1536 чисел каждый? Наивный ответ — numpy-массив в памяти. Давайте посчитаем:

200 000 документов × 1536 float32 × 4 байта = 1.2 ГБ оперативной памяти

Проблемы numpy-подхода:
  ✗  Перезапуск программы — все данные потеряны
  ✗  Нет фильтрации: «покажи только документы за 2024 год»
  ✗  Линейный поиск: каждый запрос = сравнение с 200k векторами
  ✗  Нет обновлений: добавить документ = пересоздать массив

Что нужно:
  ✓  Персистентность — данные живут между перезапусками
  ✓  Metadata-фильтры — WHERE source='wiki' AND date>2024
  ✓  Быстрый ANN-поиск — O(log N), не O(N)
  ✓  CRUD — добавление, обновление, удаление документов
        

ChromaDB решает все четыре проблемы и при этом устанавливается одной командой. Это делает его идеальным для прототипирования и разработки — пока датасет не вырос до десятков миллионов документов.

Архитектура: SQLite + HNSW

Под капотом Chroma — два компонента, каждый занимается своей задачей.

SQLite: метаданные и документы

Все текстовые данные — документы, метаданные, IDs — хранятся в обычном SQLite-файле. SQLite — надёжная, проверенная база данных, встроенная в стандартную библиотеку Python. Именно здесь работают фильтры where и where_document: они транслируются в SQL-запросы до того, как результаты попадают на векторный поиск.

HNSW: векторный индекс

Векторы хранятся в отдельной структуре — HNSW-графе (Hierarchical Navigable Small World). HNSW — это алгоритм приближённого поиска ближайших соседей (ANN).

Идея HNSW: строим многослойный граф поверх векторов. Верхние слои — разреженные, для быстрой навигации «крупными шагами». Нижние слои — плотные, для точного поиска «мелкими шагами». При запросе: спускаемся сверху вниз, на каждом слое двигаясь к ближайшим соседям. Вместо перебора всех N векторов посещаем только O(log N) узлов.

100%
колёсико — масштаб  ·  зажать и тянуть — перемещение
Архитектура ChromaDB Python Client collection.add() / .query() / .get() Collection «my_docs» metric=cosine · M=16 · ef_construction=100 SQLite chroma.sqlite3 IDs, documents metadatas WHERE-фильтры HNSW Index *.bin файлы embedding vectors ANN-граф cos/l2/ip метрика JOIN по IDs 📁 ./chroma_db/ (PersistentClient) ПОТОК ЗАПРОСА: 1. where-фильтры → SQLite: pre-filter IDs 2. HNSW: ANN-поиск среди pre-filtered IDs 3. SQLite: обогатить docs + metadata по IDs HNSW: слоистый граф LAYER 2 — разреженный (entry point) LAYER 1 — средний LAYER 0 — плотный (все векторы) результат Q ПАРАМЕТРЫ ВЛИЯЮТ НА: M=16 → кол-во рёбер на узел; больше = точнее, больше RAM ef_construction → плотность построения; больше = точнее индекс ef_search → ширина поиска; больше = точнее запрос, медленнее

Ключевой момент архитектуры: при запросе SQLite отрабатывает первым. Если вы передаёте where-фильтр, Chroma сначала получает из SQLite список подходящих IDs, и только среди них делает ANN-поиск в HNSW-графе. Это делает фильтрацию метаданных полноценной и быстрой — не постфильтрацией, а предфильтрацией.

Установка и типы клиентов

pip install chromadb

# Для работы с OpenAI-эмбеддерами
pip install chromadb openai

# Для sentence-transformers (локальные модели)
pip install chromadb sentence-transformers

Chroma предлагает три режима работы через разные классы клиентов:

EphemeralClient
chromadb.EphemeralClient()
Данные только в памяти. При выходе из программы — всё удаляется. Идеален для тестов и Jupyter-ноутбуков.
тесты / jupyter
PersistentClient
chromadb.PersistentClient(path="./db")
Сохраняет данные на диск в указанную директорию. Стандартный режим для разработки и небольших проектов.
разработка / прод
HttpClient
chromadb.HttpClient(host="...", port=8000)
Подключение к Chroma-серверу. Сервер запускается отдельно через Docker или chroma run. Тот же API, другой бэкенд.
сервер / docker
Устаревший API: в старых примерах можно встретить chromadb.Client() (ephemeral) и chromadb.Client(Settings(chroma_db_impl="duckdb+parquet", persist_directory="./db")). С Chroma 0.4+ это устарело. Используйте EphemeralClient и PersistentClient.

Коллекции: создание и параметры

Коллекция — это аналог таблицы в реляционной БД. Каждая коллекция хранит свой HNSW-индекс, свою схему метаданных и свою метрику расстояния. Все параметры индекса задаются при создании — изменить их потом нельзя без пересоздания.

import chromadb

client = chromadb.PersistentClient(path="./chroma_db")

# ── Три способа открыть коллекцию ────────────────────────────────

# 1. Создать новую (ошибка если уже существует)
col = client.create_collection("docs")

# 2. Открыть существующую (ошибка если не существует)
col = client.get_collection("docs")

# 3. Открыть или создать — самый частый вариант
col = client.get_or_create_collection("docs")

# ── Параметры при создании ────────────────────────────────────────

col = client.get_or_create_collection(
    name="docs",
    metadata={
        # Метрика расстояния (нельзя изменить после создания!)
        "hnsw:space": "cosine",         # "cosine" | "l2" | "ip"

        # HNSW-параметры (тоже нельзя изменить потом)
        "hnsw:M": 16,                   # связность графа (по умолчанию 16)
        "hnsw:construction_ef": 100,    # точность построения (по умолчанию 100)
        "hnsw:search_ef": 100,          # точность поиска (по умолчанию 10!)
        "hnsw:num_threads": 4,          # потоки индексирования
    },
)

# ── Управление коллекциями ────────────────────────────────────────

# Список всех коллекций
collections = client.list_collections()
print([c.name for c in collections])

# Информация о коллекции
print(col.name)          # "docs"
print(col.metadata)      # {'hnsw:space': 'cosine', ...}
print(col.count())       # количество документов

# Удалить коллекцию (вместе с данными!)
client.delete_collection("old_docs")
hnsw:search_ef по умолчанию = 10 — это критично. Если вы не задаёте этот параметр явно, Chroma ищет среди 10 кандидатов вместо сотен. При n_results=10 качество поиска будет ужасным: HNSW посетит минимум узлов и вернёт далеко не лучших соседей. Всегда устанавливайте "hnsw:search_ef": max(n_results * 10, 100).

HNSW-тюнинг: параметры, которые меняют всё

Три параметра HNSW определяют баланс между точностью, скоростью и памятью. Большинство разработчиков оставляют их по умолчанию — и получают субоптимальное качество поиска.

hnsw:M
default: 16
Количество двунаправленных рёбер на каждый узел графа. Больше рёбер → граф плотнее → поиск точнее → больше RAM. При M=16 каждый вектор связан с ~16 соседями на каждом слое. Влияет и на точность (recall), и на размер индекса на диске.
M=8–16 — быстро, меньше RAM
M=16 — хороший баланс
M=32–64 — высокоточные задачи
Память ≈ M × dim × 4 байта × N
hnsw:construction_ef
default: 100
Ширина поиска при построении индекса — сколько кандидатов рассматривать при добавлении каждого нового вектора. Больше → индекс точнее → индексирование медленнее. После построения на точность поиска уже не влияет. Задаётся один раз при создании коллекции.
100–200 — стандарт
400–800 — для высокой точности
Замедление индексирования: ~линейно
hnsw:search_ef
default: 10 ⚠
Ширина поиска при каждом запросе — сколько кандидатов рассматривать в очереди при обходе графа. Это самый важный параметр для качества выдачи. Значение 10 по умолчанию катастрофически мало при n_results>3. Можно также переопределить прямо в query().
10 — default, плохо для >3 результатов
100–200 — хороший баланс
500+ — максимальный recall
Замедление запроса: ~линейно
import chromadb
import numpy as np

# ── Создание коллекции с правильными параметрами ─────────────────

client = chromadb.PersistentClient(path="./chroma_db")

col = client.get_or_create_collection(
    name="production_docs",
    metadata={
        "hnsw:space": "cosine",
        "hnsw:M": 16,
        "hnsw:construction_ef": 200,   # точный индекс при добавлении
        "hnsw:search_ef": 200,         # точный поиск при запросах
    },
)

# ── Измерение реального recall ────────────────────────────────────

def measure_recall_at_k(
    col,
    query_vecs: np.ndarray,
    ground_truth_ids: list[list[str]],
    k: int = 10,
    ef_search_values: list[int] = [10, 50, 100, 200, 500],
) -> dict:
    """
    Измерить recall@k для разных значений ef_search.
    ground_truth_ids[i] = список ID релевантных документов для запроса i.
    """
    results = {}
    for ef in ef_search_values:
        hits = 0
        total = 0
        for qvec, gt_ids in zip(query_vecs, ground_truth_ids):
            res = col.query(
                query_embeddings=[qvec.tolist()],
                n_results=k,
                include=["distances"],
                # Можно переопределить ef прямо в query:
                # (работает в Chroma >= 0.4.10)
                # query_params={"ef": ef},  # если ваша версия поддерживает
            )
            returned_ids = set(res["ids"][0])
            hits += len(returned_ids & set(gt_ids))
            total += len(gt_ids)
        results[ef] = hits / total if total > 0 else 0.0
        print(f"ef={ef:4d}: recall@{k} = {results[ef]:.3f}")
    return results

CRUD: добавление, обновление, удаление

.add()
Добавляет документы. Ошибка, если ID уже существует.
⚠ При повторном add с тем же ID — исключение
.upsert()
Добавляет или обновляет. Безопасный вариант для пайплайнов.
→ Используйте в production вместо add
.update()
Обновляет только переданные поля. Ошибка, если ID не существует.
Можно обновить doc/metadata/embedding по отдельности
.get()
Получить документы по IDs или фильтрам (без векторного поиска).
Полезно для аудита и проверки наличия документов
.delete()
Удалить по IDs или where-фильтру. Необратимо.
⚠ delete(where={"source": "wiki"}) удалит всё из источника
.count()
Количество документов в коллекции. Быстро (из SQLite).
Не требует поиска, работает мгновенно
import chromadb
from datetime import datetime

client = chromadb.PersistentClient(path="./chroma_db")
col = client.get_or_create_collection("docs", metadata={"hnsw:space": "cosine"})

# ── ADD: добавить документы ───────────────────────────────────────
col.add(
    ids=["doc_001", "doc_002", "doc_003"],
    documents=[
        "FastAPI — async веб-фреймворк для Python",
        "Django — полнофункциональный фреймворк с ORM",
        "Flask — микрофреймворк для небольших приложений",
    ],
    metadatas=[
        {"source": "docs", "category": "web", "year": 2024, "tokens": 42},
        {"source": "docs", "category": "web", "year": 2023, "tokens": 38},
        {"source": "blog", "category": "web", "year": 2022, "tokens": 35},
    ],
    # embeddings=[...],  # если не передать — Chroma эмбеддирует сам
)

# ── UPSERT: добавить или обновить (рекомендуется) ─────────────────
col.upsert(
    ids=["doc_001"],
    documents=["FastAPI: быстрый async REST-фреймворк (обновлено)"],
    metadatas=[{"source": "docs", "category": "web", "year": 2024, "tokens": 48}],
)

# ── UPDATE: частичное обновление метаданных ───────────────────────
col.update(
    ids=["doc_002"],
    metadatas=[{"source": "docs", "category": "web", "year": 2024, "tokens": 38}],
    # documents и embeddings не передаём — они не изменятся
)

# ── GET: получить без поиска ──────────────────────────────────────
# По конкретным IDs
result = col.get(ids=["doc_001", "doc_003"])
print(result["documents"])   # ['FastAPI...', 'Flask...']
print(result["metadatas"])   # [{'source': 'docs', ...}, ...]

# По фильтру
result = col.get(
    where={"category": "web", "year": {"$gte": 2023}},
    include=["documents", "metadatas"],
    limit=10,
    offset=0,   # для пагинации
)

# ── DELETE: удалить ───────────────────────────────────────────────
col.delete(ids=["doc_003"])

# Удалить всё из источника (осторожно!)
col.delete(where={"source": "blog"})

print(f"Документов в коллекции: {col.count()}")

# ── Батчевое добавление (важно для больших корпусов) ──────────────
def batch_upsert(col, documents: list[dict], batch_size: int = 500):
    """
    documents: список словарей {'id', 'text', 'metadata', 'embedding'}
    Chroma падает или замедляется при батчах > 5000.
    500–1000 — оптимальный размер.
    """
    for i in range(0, len(documents), batch_size):
        batch = documents[i : i + batch_size]
        col.upsert(
            ids=[d["id"] for d in batch],
            documents=[d["text"] for d in batch],
            metadatas=[d["metadata"] for d in batch],
            embeddings=[d["embedding"] for d in batch],
        )
        print(f"\rИндексировано: {min(i + batch_size, len(documents))}/{len(documents)}", end="")
    print()

Запросы: полное управление выдачей

Метод query() — центр всей работы с Chroma. Он принимает несколько независимых параметров, каждый из которых отдельно контролирует, что вернуть и как отфильтровать.

Базовый запрос

import chromadb
import numpy as np

client = chromadb.PersistentClient(path="./chroma_db")
col = client.get_collection("docs")

# ── Запрос по тексту (Chroma эмбеддирует сам) ─────────────────────
results = col.query(
    query_texts=["как установить FastAPI"],
    n_results=5,
)

# ── Запрос по готовым эмбеддингам (рекомендуется в production) ────
query_embedding = embed_query("как установить FastAPI")  # ваша функция

results = col.query(
    query_embeddings=[query_embedding.tolist()],   # список векторов!
    n_results=5,
)

# ── Батчевый запрос: несколько запросов за раз ────────────────────
results = col.query(
    query_embeddings=[
        embed_query("установка FastAPI").tolist(),
        embed_query("Django ORM запросы").tolist(),
    ],
    n_results=3,  # топ-3 для каждого запроса
)
# results["ids"][0] → топ-3 для первого запроса
# results["ids"][1] → топ-3 для второго запроса

# ── Структура результата ──────────────────────────────────────────
print(results.keys())
# dict_keys(['ids', 'distances', 'metadatas', 'embeddings', 'documents', 'uris', 'data'])

# Для одного запроса:
print(results["ids"][0])         # ['doc_001', 'doc_002', 'doc_003']
print(results["distances"][0])   # [0.12, 0.34, 0.45] — меньше = ближе (для cosine: 1-cosine)
print(results["documents"][0])   # ['FastAPI...', 'Django...', ...]
print(results["metadatas"][0])   # [{'source': 'docs', ...}, ...]
Cosine distance ≠ cosine similarity. Chroma возвращает distance, а не similarity. Для метрики cosine: distance = 1 − cosine_similarity. Значит 0.0 — идеальное совпадение, 2.0 — противоположное. Не перепутайте при установке порогов: «похоже» → малый distance.

Параметр include: что включить в ответ

По умолчанию Chroma возвращает не всё — это оптимизация трафика. Управляйте явно через include:

"documents"
Тексты документов
on
"metadatas"
Словари метаданных
on
"distances"
Дистанции до запроса
on
"embeddings"
Сами векторы
off
"ids"
ID документов
on
# Только то, что нужно — меньше трафика, быстрее
results = col.query(
    query_embeddings=[query_vec.tolist()],
    n_results=10,
    include=["documents", "metadatas", "distances"],  # без embeddings — они тяжёлые
)

# Получить векторы (например, для дополнительной обработки)
results = col.query(
    query_embeddings=[query_vec.tolist()],
    n_results=5,
    include=["documents", "metadatas", "distances", "embeddings"],
)
# results["embeddings"][0] → list of 5 vectors, каждый 1536-dim

# Только IDs и distances (минимальная нагрузка — для reranking-пайплайна)
results = col.query(
    query_embeddings=[query_vec.tolist()],
    n_results=50,                           # широкий поиск
    include=["ids", "distances"],           # только для reranker
)
ids_for_reranking = results["ids"][0]

Фильтры метаданных: where и where_document

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

Операторы where

Оператор
Типы данных
Описание и пример
$eq
str · int · float · bool
{"source": {"$eq": "wiki"}} — краткая форма: {"source": "wiki"}
$ne
str · int · float · bool
{"source": {"$ne": "blog"}} — не равно
$gt
int · float
{"year": {"$gt": 2022}} — строго больше
$gte
int · float
{"year": {"$gte": 2023}} — больше или равно
$lt
int · float
{"tokens": {"$lt": 512}} — строго меньше
$lte
int · float
{"tokens": {"$lte": 500}} — меньше или равно
$in
list[str · int · float]
{"category": {"$in": ["web", "db", "ml"]}} — вхождение в список
$nin
list[str · int · float]
{"category": {"$nin": ["marketing"]}} — не из списка
$and
list[condition]
{"$and": [{"year": {"$gte": 2023}}, {"source": "docs"}]}
$or
list[condition]
{"$or": [{"source": "wiki"}, {"source": "docs"}]}
import chromadb

client = chromadb.PersistentClient(path="./chroma_db")
col = client.get_collection("docs")

# ── Простые фильтры ───────────────────────────────────────────────

# Только из источника "wiki"
results = col.query(
    query_embeddings=[query_vec.tolist()],
    n_results=5,
    where={"source": "wiki"},  # краткая форма $eq
)

# Только свежие документы
results = col.query(
    query_embeddings=[query_vec.tolist()],
    n_results=5,
    where={"year": {"$gte": 2023}},
)

# ── Диапазон ──────────────────────────────────────────────────────

# Документы 2022–2024 года с небольшим количеством токенов
results = col.query(
    query_embeddings=[query_vec.tolist()],
    n_results=5,
    where={
        "$and": [
            {"year": {"$gte": 2022}},
            {"year": {"$lte": 2024}},
            {"tokens": {"$lte": 400}},
        ]
    },
)

# ── Несколько источников ($in) ─────────────────────────────────────

results = col.query(
    query_embeddings=[query_vec.tolist()],
    n_results=10,
    where={"source": {"$in": ["wiki", "docs", "github"]}},
)

# ── Исключить категорию ($nin) ─────────────────────────────────────

results = col.query(
    query_embeddings=[query_vec.tolist()],
    n_results=5,
    where={"category": {"$nin": ["deprecated", "draft"]}},
)

# ── Комбинация $and + $or ─────────────────────────────────────────

# (source IN [wiki, docs]) AND (year >= 2023) AND (tokens <= 500)
results = col.query(
    query_embeddings=[query_vec.tolist()],
    n_results=10,
    where={
        "$and": [
            {"source": {"$in": ["wiki", "docs"]}},
            {"year": {"$gte": 2023}},
            {"tokens": {"$lte": 500}},
        ]
    },
)

# ── $or: из разных источников разных лет ─────────────────────────

results = col.query(
    query_embeddings=[query_vec.tolist()],
    n_results=10,
    where={
        "$or": [
            {"$and": [{"source": "wiki"}, {"year": {"$gte": 2024}}]},
            {"$and": [{"source": "docs"}, {"verified": True}]},
        ]
    },
)

where_document: поиск по тексту документа

Отдельный фильтр — по содержимому самого документа. Это не векторный поиск, а текстовое совпадение через SQL LIKE. Используйте для точных совпадений терминов, артикулов, имён собственных, которые embedding-модель может «размыть».

# ── where_document: текстовый фильтр по документу ────────────────

# Только документы, содержащие точную строку
results = col.query(
    query_embeddings=[query_vec.tolist()],
    n_results=5,
    where_document={"$contains": "FastAPI"},
)

# НЕ содержит — исключить документы с ошибками/deprecated
results = col.query(
    query_embeddings=[query_vec.tolist()],
    n_results=5,
    where_document={"$not_contains": "deprecated"},
)

# ── Комбинация where + where_document ────────────────────────────

# Из доверенных источников И содержащие нужный термин
results = col.query(
    query_embeddings=[query_vec.tolist()],
    n_results=10,
    where={"source": {"$in": ["wiki", "docs"]}},
    where_document={"$contains": "async"},
)

# ── Практика: точный поиск по артикулу или UUID ───────────────────
# Embedding-модели плохо работают с кодами, UUID, артикулами —
# они «размываются» в семантическом пространстве.
# Решение: сохранять артикулы в метаданных + фильтровать через where.

results = col.query(
    query_embeddings=[query_vec.tolist()],
    n_results=5,
    where={"product_id": "SKU-20481"},    # точное совпадение через SQLite
)
where_document ограничения: поддерживаются только $contains и $not_contains. Нет регулярных выражений, нет сложной логики. Для полнотекстового поиска интегрируйте внешний BM25 (Elasticsearch, Typesense) — и комбинируйте с векторным поиском.

Управление выдачей: порог и постфильтрация

Chroma не имеет встроенного порога отсечения нерелевантных результатов — она всегда возвращает ровно n_results ближайших, даже если все они нерелевантны. Фильтрация по расстоянию делается в Python после запроса.

from dataclasses import dataclass
from typing import Optional
import chromadb


@dataclass
class RetrievedDoc:
    id: str
    document: str
    metadata: dict
    distance: float

    @property
    def cosine_similarity(self) -> float:
        """Chroma возвращает distance = 1 - cosine_sim для метрики cosine."""
        return 1.0 - self.distance


def retrieve(
    col,
    query_embedding: list[float],
    n_results: int = 10,
    max_distance: float = 0.4,   # порог: distance <= 0.4 → cosine_sim >= 0.6
    where: Optional[dict] = None,
    where_document: Optional[dict] = None,
) -> list[RetrievedDoc]:
    """
    Поиск с отсечением нерелевантных результатов.

    max_distance=0.4 при metric=cosine означает:
      cosine_similarity = 1 - 0.4 = 0.6 — минимальная релевантность.
    """
    # Запрашиваем больше, чем нужно — компенсация за фильтрацию
    raw_n = min(n_results * 3, col.count()) if col.count() > 0 else n_results

    kwargs: dict = {
        "query_embeddings": [query_embedding],
        "n_results": raw_n,
        "include": ["documents", "metadatas", "distances"],
    }
    if where:
        kwargs["where"] = where
    if where_document:
        kwargs["where_document"] = where_document

    res = col.query(**kwargs)

    docs = []
    for doc_id, doc_text, meta, dist in zip(
        res["ids"][0],
        res["documents"][0],
        res["metadatas"][0],
        res["distances"][0],
    ):
        if dist > max_distance:
            break  # результаты отсортированы по distance → дальше только хуже
        docs.append(RetrievedDoc(
            id=doc_id,
            document=doc_text,
            metadata=meta,
            distance=dist,
        ))
        if len(docs) >= n_results:
            break

    return docs


# ── Пример использования ──────────────────────────────────────────
client = chromadb.PersistentClient(path="./chroma_db")
col = client.get_collection("docs")

query_vec = embed_query("установка async фреймворка")  # ваша функция

docs = retrieve(
    col,
    query_embedding=query_vec.tolist(),
    n_results=5,
    max_distance=0.35,             # строгий порог: cosine_sim >= 0.65
    where={"source": {"$in": ["wiki", "docs"]}},
)

if not docs:
    print("Релевантных документов не найдено — отвечаем 'не знаю'")
else:
    context = "\n\n".join(
        f"[{d.metadata.get('source', '?')}] {d.document}" for d in docs
    )
    print(f"Найдено {len(docs)} документов, топ: cosine={docs[0].cosine_similarity:.3f}")

Функции эмбеддинга

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

import chromadb
from chromadb.utils.embedding_functions import (
    OpenAIEmbeddingFunction,
    SentenceTransformerEmbeddingFunction,
    DefaultEmbeddingFunction,   # all-MiniLM-L6-v2, бесплатно
)

client = chromadb.PersistentClient(path="./chroma_db")

# ── Встроенные embedding functions ───────────────────────────────

# OpenAI (нужен API key)
openai_ef = OpenAIEmbeddingFunction(
    api_key="sk-...",
    model_name="text-embedding-3-small",
)
col_openai = client.get_or_create_collection(
    "docs_openai",
    embedding_function=openai_ef,
    metadata={"hnsw:space": "cosine"},
)

# Sentence-transformers (локально)
st_ef = SentenceTransformerEmbeddingFunction(
    model_name="intfloat/multilingual-e5-large",
    device="cpu",
)
col_local = client.get_or_create_collection(
    "docs_local",
    embedding_function=st_ef,
    metadata={"hnsw:space": "cosine"},
)

# Default (all-MiniLM-L6-v2) — только для прототипов
col_default = client.get_or_create_collection("docs_dev")
# Без embedding_function — используется DefaultEmbeddingFunction

# ── Кастомная embedding function ──────────────────────────────────
from chromadb import EmbeddingFunction, Embeddings
import numpy as np
from openai import AsyncOpenAI


class BGEEmbeddingFunction(EmbeddingFunction):
    """
    Кастомный эмбеддер для BGE-M3.
    Наследуемся от EmbeddingFunction и реализуем __call__.
    """

    def __init__(self, device: str = "cpu"):
        from FlagEmbedding import BGEM3FlagModel
        self.model = BGEM3FlagModel("BAAI/bge-m3", use_fp16=True)

    def __call__(self, input: list[str]) -> Embeddings:
        """input — список текстов. Возвращаем список list[float]."""
        output = self.model.encode(
            input,
            return_dense=True,
            return_sparse=False,
        )
        vecs = output["dense_vecs"]  # np.ndarray (N, 1024)
        return vecs.tolist()


col_bge = client.get_or_create_collection(
    "docs_bge",
    embedding_function=BGEEmbeddingFunction(),
    metadata={
        "hnsw:space": "cosine",
        "hnsw:M": 16,
        "hnsw:search_ef": 200,
    },
)

# Теперь add/query работают без явных embeddings:
col_bge.add(
    ids=["1", "2"],
    documents=["FastAPI setup", "Django intro"],
    metadatas=[{"source": "docs"}, {"source": "docs"}],
)
# Chroma вызовет BGEEmbeddingFunction автоматически
Эмбеддер при открытии коллекции: если вы закрыли программу и открыли коллекцию снова без передачи embedding_function, Chroma использует DefaultEmbeddingFunction для запросов — несовместимую с той, что была при добавлении. Результат: полная чепуха в поиске. Всегда передавайте функцию явно при get_collection().

Интеграция с LangChain

"""
pip install langchain-chroma langchain-openai
"""

from langchain_chroma import Chroma
from langchain_openai import OpenAIEmbeddings
from langchain.schema import Document

embeddings = OpenAIEmbeddings(model="text-embedding-3-small")

# ── Создание/подключение ──────────────────────────────────────────

# Создать и заполнить из списка документов
vectorstore = Chroma.from_documents(
    documents=[
        Document(page_content="FastAPI async веб-фреймворк", metadata={"source": "docs"}),
        Document(page_content="Django полный стек", metadata={"source": "docs"}),
    ],
    embedding=embeddings,
    persist_directory="./chroma_db",
    collection_name="docs",
    collection_metadata={"hnsw:space": "cosine", "hnsw:search_ef": 200},
)

# Подключиться к существующей
vectorstore = Chroma(
    persist_directory="./chroma_db",
    collection_name="docs",
    embedding_function=embeddings,
    collection_metadata={"hnsw:space": "cosine"},
)

# ── Поиск ─────────────────────────────────────────────────────────

# Similarity search (топ-k)
docs = vectorstore.similarity_search("как установить FastAPI", k=5)

# С оценками (distance)
docs_with_scores = vectorstore.similarity_search_with_score("async фреймворк", k=5)
for doc, score in docs_with_scores:
    print(f"distance={score:.3f}: {doc.page_content[:60]}")

# С фильтрами метаданных
docs = vectorstore.similarity_search(
    "ORM запросы",
    k=5,
    filter={"source": "docs", "year": {"$gte": 2023}},
)

# ── as_retriever() — для Chain/Agent ─────────────────────────────

# Базовый retriever (top-k)
retriever = vectorstore.as_retriever(
    search_type="similarity",
    search_kwargs={"k": 5},
)

# С порогом сходства
retriever = vectorstore.as_retriever(
    search_type="similarity_score_threshold",
    search_kwargs={
        "score_threshold": 0.6,   # cosine_similarity >= 0.6 (не distance!)
        "k": 5,
    },
)

# MMR — максимальная маргинальная релевантность
# Balances relevance vs diversity: убирает дубликаты из результатов
retriever = vectorstore.as_retriever(
    search_type="mmr",
    search_kwargs={
        "k": 5,
        "fetch_k": 20,     # изначально берём 20, потом MMR отбирает 5
        "lambda_mult": 0.5, # 0=максимальное разнообразие, 1=максимальная релевантность
    },
)

# ── RetrievalQA chain ─────────────────────────────────────────────
from langchain.chains import RetrievalQA
from langchain_openai import ChatOpenAI

llm = ChatOpenAI(model="gpt-4o-mini")
qa_chain = RetrievalQA.from_chain_type(
    llm=llm,
    retriever=retriever,
    return_source_documents=True,
)

answer = qa_chain.invoke({"query": "Что такое FastAPI?"})
print(answer["result"])
print(answer["source_documents"])

Chroma в server-режиме

Когда нужно развернуть Chroma как отдельный сервис (несколько воркеров приложения, доступ из разных процессов), используйте HTTP-режим.

# ── Запуск через Docker ───────────────────────────────────────────
docker run -d \
  -p 8000:8000 \
  -v $(pwd)/chroma_data:/chroma/chroma \
  chromadb/chroma:latest

# ── Или через chroma CLI ──────────────────────────────────────────
pip install chromadb
chroma run --path ./chroma_data --port 8000
import chromadb
from chromadb.config import Settings

# ── Подключение к серверу ─────────────────────────────────────────
client = chromadb.HttpClient(
    host="localhost",
    port=8000,
    settings=Settings(
        chroma_client_auth_provider="chromadb.auth.token.TokenAuthClientProvider",
        chroma_client_auth_credentials="your-token",  # если настроена авторизация
    ),
)

# ── Тот же API, другой бэкенд ─────────────────────────────────────
col = client.get_or_create_collection("docs")
col.add(ids=["1"], documents=["..."], metadatas=[{"source": "test"}])
results = col.query(query_texts=["запрос"], n_results=3)

# ── Асинхронный клиент (Chroma >= 0.5) ───────────────────────────
import asyncio
import chromadb

async def async_example():
    client = await chromadb.AsyncHttpClient(host="localhost", port=8000)
    col = await client.get_or_create_collection("docs")

    await col.add(
        ids=["1"],
        documents=["FastAPI tutorial"],
        metadatas=[{"source": "docs"}],
    )

    results = await col.query(
        query_texts=["установка FastAPI"],
        n_results=5,
    )
    return results

asyncio.run(async_example())

Настройки для production

import chromadb
from chromadb.config import Settings
import os

# ── Отключить телеметрию ──────────────────────────────────────────
# По умолчанию Chroma отправляет анонимную телеметрию в Posthog.
# Отключается через переменную окружения или Settings:

os.environ["ANONYMIZED_TELEMETRY"] = "False"

client = chromadb.PersistentClient(
    path="./chroma_db",
    settings=Settings(anonymized_telemetry=False),
)

# ── Параметры коллекции для production ───────────────────────────
COLLECTION_CONFIG = {
    "hnsw:space": "cosine",
    "hnsw:M": 16,
    "hnsw:construction_ef": 200,  # плотный индекс при добавлении
    "hnsw:search_ef": 200,        # точный поиск (не дефолтные 10!)
    "hnsw:num_threads": 4,        # потоки для добавления
}

col = client.get_or_create_collection("docs", metadata=COLLECTION_CONFIG)

# ── Паттерн: изолированные коллекции на тенант ───────────────────

def get_tenant_collection(client, tenant_id: str):
    """Отдельная коллекция на каждого пользователя/клиента."""
    return client.get_or_create_collection(
        name=f"docs_{tenant_id}",
        metadata={**COLLECTION_CONFIG},
    )

# ── Паттерн: версионирование коллекций ───────────────────────────

def reindex_collection(client, old_name: str, new_name: str, new_embed_fn):
    """
    Переиндексация при смене модели.
    1. Создаём новую коллекцию
    2. Переиндексируем все документы
    3. Переименовывание: не поддерживается в Chroma —
       меняем имя в конфиге приложения, старую удаляем
    """
    old_col = client.get_collection(old_name)
    new_col = client.get_or_create_collection(new_name, metadata=COLLECTION_CONFIG)

    # Получаем все данные батчами
    batch_size = 500
    offset = 0
    while True:
        data = old_col.get(limit=batch_size, offset=offset,
                           include=["documents", "metadatas"])
        if not data["ids"]:
            break

        new_embeddings = new_embed_fn(data["documents"])
        new_col.upsert(
            ids=data["ids"],
            documents=data["documents"],
            metadatas=data["metadatas"],
            embeddings=new_embeddings,
        )
        offset += batch_size
        print(f"\rПереиндексировано: {offset}", end="")

    print(f"\nГотово. Можно удалить коллекцию '{old_name}'")

Ограничения и когда переходить на другую БД

Характеристика
Chroma (local)
Chroma (server)
Qdrant / Weaviate
Установка
pip install
Docker
Docker / Cloud
Масштаб документов
до ~1M
до ~10M
100M+
Шардирование
нет
нет
есть
Репликация / HA
нет
нет
есть
Hybrid search
нет (только dense)
нет
есть
Несколько векторов на doc
нет
нет
есть
Авторизация
нет
токен
RBAC
API совместимость
Python
REST + Python
REST + gRPC + SDKs

Chroma — отличный выбор для локальной разработки и корпусов до ~500k документов. Если проект растёт или нужен hybrid search (dense + sparse), переходите на Qdrant.

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

hnsw:search_ef не задан — качество поиска плохое
Дефолтное значение search_ef=10 означает, что HNSW рассматривает только 10 кандидатов при поиске. При n_results=10 recall может быть 40–60%. Ошибок нет — просто тихо возвращается субоптимальный результат.
✓ Всегда задавайте hnsw:search_ef при создании коллекции. Минимум: max(n_results * 10, 100). Типично: 200.
Открытие коллекции без embedding_function
Создали коллекцию с OpenAI-эмбеддером. Перезапустили приложение. Открыли коллекцию без передачи функции. Chroma использует DefaultEmbeddingFunction для запросов — другое векторное пространство. Поиск работает, но возвращает мусор.
✓ Всегда передавайте embedding_function при get_collection(). Инкапсулируйте в фабрику: get_my_collection(client) → всегда с правильным эмбеддером.
col.add() вместо col.upsert() в пайплайне
При повторном запуске индексирования add() бросает исключение на дублирующихся IDs. Останавливает весь пайплайн. Обычно обнаруживается только когда пайплайн запустили второй раз через несколько дней.
✓ В любом пайплайне используйте upsert(). add() — только когда точно знаете, что ID новый (например, генерируете UUID в момент добавления).
Фильтрация после получения результатов
Запросили n_results=5, потом отфильтровали по метаданным в Python — осталось 2. Вернули 2 вместо 5. Chroma умеет делать pre-filtering через where до векторного поиска — это и правильнее, и быстрее.
✓ Все фильтры по метаданным передавайте через where= в query(). Pre-filtering в SQLite = быстрее + корректнее, чем постфильтрация в Python.
Смешивание float16/float32 в embeddings
BGE-M3 с use_fp16=True возвращает float16-массивы. Chroma ожидает float32 (или list[float]). Передача float16 либо вызывает ошибку, либо молча конвертируется неверно.
✓ Всегда приводите к float32: vec.astype(np.float32).tolist(). Или vec.tolist() — Python автоматически конвертирует в float64, что тоже работает.
Хранить bool/datetime в метаданных неправильно
Chroma поддерживает только строки, числа и булевы. datetime.now() — не работает напрямую. None в метаданных — не работает. Попытка сохранить список в метаданных — тоже нет.
✓ datetime → int (UNIX timestamp: int(dt.timestamp())). None → не включать в dict. Список → JSON-строка: json.dumps(my_list).

Шпаргалка

КЛИЕНТЫ:
  EphemeralClient()              → только RAM, для тестов
  PersistentClient(path="./db")  → файлы на диск, для разработки
  HttpClient(host=..., port=...) → подключение к серверу

СОЗДАНИЕ КОЛЛЕКЦИИ (всегда с явными параметрами):
  col = client.get_or_create_collection(
      name="docs",
      metadata={
          "hnsw:space": "cosine",        # метрика (НЕЛЬЗЯ менять после)
          "hnsw:M": 16,                  # связность графа
          "hnsw:construction_ef": 200,   # точность построения
          "hnsw:search_ef": 200,         # точность поиска ← ключевой!
      },
  )

ДОБАВЛЕНИЕ:
  col.upsert(ids=[...], documents=[...], metadatas=[...], embeddings=[...])
  → Всегда upsert, не add — безопаснее в пайплайнах

ЗАПРОС:
  results = col.query(
      query_embeddings=[vec.tolist()],   # или query_texts=[text]
      n_results=10,
      where={"source": "wiki"},          # метаданные (pre-filter, SQLite)
      where_document={"$contains": "FastAPI"},   # текстовый поиск
      include=["documents", "metadatas", "distances"],
  )

ОПЕРАТОРЫ where:
  {"field": "value"}              → $eq (краткая форма)
  {"field": {"$ne/$gt/$gte/$lt/$lte": val}}
  {"field": {"$in": [v1, v2]}}   → вхождение
  {"field": {"$nin": [v1, v2]}}  → не из списка
  {"$and": [{...}, {...}]}
  {"$or":  [{...}, {...}]}

РАССТОЯНИЯ (metric=cosine):
  distance = 1 − cosine_similarity
  distance=0.0 → идеальное совпадение
  distance=0.3 → cosine_sim=0.7 → хорошее совпадение
  distance=0.5 → cosine_sim=0.5 → слабая связь
  → Порог max_distance=0.4 (cosine_sim≥0.6) — стандартная точка отсечения

МЕТАДАННЫЕ (типы данных):
  Допустимо:   str, int, float, bool
  Запрещено:   datetime, None, list, dict
  Конвертация: datetime → int(ts), list → json.dumps(lst)

КОГДА ПЕРЕХОДИТЬ НА QDRANT:
  • > 1M документов
  • Нужен hybrid search (dense + sparse)
  • Нужно несколько векторов на документ
  • Нужна репликация / HA
        

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

  1. Влияние hnsw:search_ef на качество. Создайте коллекцию, добавьте 5000 документов (можно синтетических через np.random). Для 50 случайных запросов сравните результаты при search_ef=10, 50, 200, 500. Используйте brute-force поиск (IndexFlatIP из FAISS) как ground truth. Измерьте recall@10 для каждого значения ef. При каком ef прирост качества перестаёт окупать время?
  2. Многоуровневая фильтрация. Проиндексируйте любую коллекцию документов с метаданными: source, year, category, tokens. Реализуйте функцию smart_retrieve(query, filters), которая принимает словарь пользовательских фильтров и строит where-условие динамически. Проверьте на 5 разных комбинациях фильтров.
  3. Мини-RAG с порогом релевантности. Используя ChromaDB + любую LLM, постройте Q&A систему. Добавьте логику: если ни один документ не преодолевает порог cosine_similarity ≥ 0.65 — модель отвечает «Информации по этому вопросу нет в базе знаний». Протестируйте 10 вопросов: 5 по теме документов, 5 не по теме. Сколько «не по теме» корректно отсечено?