Разработка Embedding Pipeline для индексации данных
Без правильно построенного пайплайна эмбеддингов RAG-система выдаёт нерелевантные ответы или теряет смысл из-за некорректного chunking. Мы строим production-ready пайплайны, которые обеспечивают латентность p99 менее 50 мс при индексации 1 миллиона документов. Наш опыт — 5+ лет в ML-инженерии и более 15 внедрённых систем семантического поиска. При индексации через OpenAI API text-embedding-3-small затраты составляют около $2 на 1M документов, а self-hosted решения на A10G дают почти вдвое большую пропускную способность.
Почему важен правильный chunking?
Разбивка текста на чанки — критический этап. Слишком короткие чанки теряют контекст, слишком длинные — размывают семантику. Оптимальный размер — 256–512 токенов для английского, 128–256 для русского. Мы используем рекурсивное разбиение с перекрытием 10–20% для сохранения граничных смыслов. Без этого даже лучшие модели эмбеддингов дают мисклик до 30%.
Как выбрать модель эмбеддингов?
| Модель | Размерность | Скорость | Языки | Хостинг |
|---|---|---|---|---|
| text-embedding-3-small | 1536 | 8K tokens/sec | 100+ | OpenAI API |
| text-embedding-3-large | 3072 | 3K tokens/sec | 100+ | OpenAI API |
| E5-large-v2 | 1024 | 2K tokens/sec | EN | Self-hosted |
| multilingual-e5-large | 1024 | 1.5K tokens/sec | 100+ | Self-hosted |
| BGE-M3 | 1024 | 1K tokens/sec | 100+ | Self-hosted |
| Cohere Embed v3 | 1024 | 5K tokens/sec | 100+ | Cohere API |
Self-hosted модели (BGE-M3) в 1.5–2 раза быстрее OpenAI API на больших объёмах, но требуют выделенного GPU. Для проектов с конфиденциальными данными это единственный вариант.
Как обеспечить качество эмбеддингов?
Мы комбинируем несколько техник:
- Дедупликация по хешу содержимого — исключаем двойную индексацию одинаковых документов.
- Усечение слишком длинных текстов — не более 8000 токенов на вход модели.
- Добавление метаданных — каждое векторное представление содержит текстовую превью и атрибуты для фильтрации.
- Мониторинг дрейфа — каждую неделю пересчитываем распределение эмбеддингов и при необходимости переиндексируем.
Как настроить Embedding Pipeline: 5 шагов
- Выбор модели и хранилища. Определитесь, будете ли вы использовать API (OpenAI, Cohere) или self-hosted (BGE-M3). Выберите векторную БД: Qdrant, Pinecone, Weaviate.
- Проектирование chunking. Настройте размер чанка и перекрытие под ваш язык и тип контента. Протестируйте на выборке из 1000 документов.
- Реализация дедупликации. Добавьте хеширование SHA-256 для пропуска дубликатов при инкрементальной индексации.
- Батчевая обработка. Настройте размер батча (100 для API, 500–1000 для self-hosted). Реализуйте retry с экспоненциальной задержкой.
- Мониторинг и тестирование. Запустите нагрузочное тестирование на 100K+ запросов, измерьте p99 latency и процент ошибок.
Базовый пайплайн
import asyncio import hashlib from typing import Any import numpy as np from openai import AsyncOpenAI import qdrant_client from qdrant_client.models import Distance, VectorParams, PointStruct class EmbeddingPipeline: def __init__(self, collection_name: str, embedding_model: str = "text-embedding-3-small"): self.oai = AsyncOpenAI() self.qdrant = qdrant_client.QdrantClient(host="localhost", port=6333) self.model = embedding_model self.collection = collection_name self.batch_size = 100 self._init_collection() def _init_collection(self): dims = {"text-embedding-3-small": 1536, "text-embedding-3-large": 3072} dim = dims.get(self.model, 1536) existing = [c.name for c in self.qdrant.get_collections().collections] if self.collection not in existing: self.qdrant.create_collection( collection_name=self.collection, vectors_config=VectorParams(size=dim, distance=Distance.COSINE) ) async def embed_batch(self, texts: list[str]) -> list[list[float]]: """Батч-генерация эмбеддингов с retry""" max_retries = 3 for attempt in range(max_retries): try: response = await self.oai.embeddings.create( input=texts, model=self.model ) return [item.embedding for item in response.data] except Exception as e: if attempt == max_retries - 1: raise await asyncio.sleep(2 ** attempt) async def index_documents(self, documents: list[dict], text_field: str = "content") -> dict: """ documents: список словарей с текстом и метаданными text_field: ключ с текстом для эмбеддинга """ total = len(documents) indexed = 0 skipped = 0 # Проверка уже проиндексированных (дедупликация по хешу) existing_hashes = self._get_existing_hashes() # Фильтрация дубликатов new_docs = [] for doc in documents: content_hash = hashlib.sha256( doc.get(text_field, "").encode() ).hexdigest()[:16] if content_hash not in existing_hashes: doc['_hash'] = content_hash new_docs.append(doc) else: skipped += 1 # Батчевая обработка for i in range(0, len(new_docs), self.batch_size): batch = new_docs[i:i + self.batch_size] texts = [doc.get(text_field, "") for doc in batch] # Усечение слишком длинных текстов texts = [t[:8000] for t in texts] embeddings = await self.embed_batch(texts) points = [] for j, (doc, embedding) in enumerate(zip(batch, embeddings)): point_id = int(hashlib.sha256( doc.get('id', str(i + j)).encode() ).hexdigest()[:8], 16) % (2**63) payload = {k: v for k, v in doc.items() if k != text_field and not k.startswith('_')} payload['text_preview'] = doc.get(text_field, "")[:200] payload['_hash'] = doc['_hash'] points.append(PointStruct( id=point_id, vector=embedding, payload=payload )) self.qdrant.upsert( collection_name=self.collection, points=points ) indexed += len(batch) return {'indexed': indexed, 'skipped': skipped, 'total': total} def _get_existing_hashes(self) -> set: """Получение хешей уже проиндексированных документов""" try: scroll_result = self.qdrant.scroll( collection_name=self.collection, scroll_filter=None, with_payload=["_hash"], limit=10000 ) return {point.payload.get('_hash', '') for point in scroll_result[0]} except Exception: return set() Продвинутый пайплайн с поддержкой разных типов данных
class MultimodalEmbeddingPipeline: """Индексация текстов, таблиц и изображений""" def __init__(self): self.text_pipeline = EmbeddingPipeline("text_collection") self.table_pipeline = EmbeddingPipeline("table_collection") async def process_table(self, df: pd.DataFrame, table_name: str, description: str = "") -> list[dict]: """Таблица → набор индексируемых записей""" documents = [] # Описание схемы таблицы schema_text = f"Table: {table_name}. {description}\n" schema_text += f"Columns: {', '.join(df.columns.tolist())}\n" schema_text += f"Sample data:\n{df.head(3).to_string()}" documents.append({ 'id': f"{table_name}_schema", 'content': schema_text, 'type': 'table_schema', 'table_name': table_name }) # Каждая строка как отдельная запись (для маленьких таблиц) if len(df) <= 1000: for idx, row in df.iterrows(): row_text = f"Row {idx} in {table_name}: " + \ ", ".join([f"{col}={val}" for col, val in row.items()]) documents.append({ 'id': f"{table_name}_row_{idx}", 'content': row_text, 'type': 'table_row', 'table_name': table_name, 'row_index': idx }) return documents async def process_structured_content(self, content: dict, content_type: str) -> list[dict]: """JSON/structured data → эмбеддинги""" import json # Плоское текстовое представление flat_text = self._flatten_dict(content) return [{ 'id': content.get('id', hashlib.md5(flat_text.encode()).hexdigest()), 'content': flat_text, 'type': content_type, 'raw': json.dumps(content, ensure_ascii=False)[:1000] }] def _flatten_dict(self, d: dict, prefix: str = "") -> str: """Рекурсивное преобразование словаря в текст""" parts = [] for key, value in d.items(): full_key = f"{prefix}.{key}" if prefix else key if isinstance(value, dict): parts.append(self._flatten_dict(value, full_key)) elif isinstance(value, list): parts.append(f"{full_key}: {', '.join(str(v) for v in value[:10])}") else: parts.append(f"{full_key}: {value}") return ". ".join(parts) Поиск по проиндексированным данным
async def semantic_search(self, query: str, limit: int = 5, score_threshold: float = 0.7) -> list[dict]: query_embedding = (await self.text_pipeline.embed_batch([query]))[0] results = self.text_pipeline.qdrant.search( collection_name=self.text_pipeline.collection, query_vector=query_embedding, limit=limit, score_threshold=score_threshold, with_payload=True ) return [ { 'score': hit.score, 'text': hit.payload.get('text_preview', ''), 'metadata': {k: v for k, v in hit.payload.items() if k not in ['text_preview', '_hash']} } for hit in results ] Производительность пайплайна
| Параметр | OpenAI API (text-embedding-3-small) | Self-hosted BGE-M3 на A10G |
|---|---|---|
| Пропускная способность | 500–800 док/мин | 1200–1500 док/мин |
| Затраты на 1M док | ~$2 | ~$0.5/ч GPU |
| Латентность p99 | <50 мс | <30 мс |
| Экономия при инкрементальном обновлении | до 90% | до 90% |
Инкрементальное обновление через хеширование позволяет экономить до 90% затрат при ежедневных обновлениях.
Типичные ошибки при построении пайплайна
- Использование модели эмбеддингов, не подходящей под язык данных — мультиязычные модели (multilingual-e5-large) нужны для неанглийского контента.
- Слишком большие чанки (>512 токенов) снижают точность поиска на 15–20%.
- Отсутствие дедупликации ведёт к дублированию векторов и раздуванию хранилища.
- Игнорирование метаданных — без фильтрации по атрибутам поиск становится медленнее и менее точным.
Что входит в нашу работу
Мы разрабатываем пайплайн под ключ:
- Анализ данных – определение оптимального chunking и модели.
- Проектирование архитектуры – выбор векторного хранилища, конфигурация батчей.
- Реализация – написание production-кода с обработкой ошибок и мониторингом.
- Тестирование – нагрузочное тестирование (100K+ запросов) и проверка качества эмбеддингов.
- Документация и обучение – Model Card, инструкция по эксплуатации, обучение команды.
Сроки: от 2 недель до 2 месяцев в зависимости от сложности. Стоимость рассчитывается индивидуально после аудита ваших данных.
Свяжитесь с нами для консультации и оценки проекта. Закажите разработку пайплайна – получите готовую инфраструктуру для семантического поиска. Получите консультацию по вашему проекту.
Согласно документации Qdrant, выбор векторного хранилища критичен для задержек. Мы используем Qdrant как наиболее производительное решение для production.







