RAG туториал: умный поиск по документам с LangChain и ChromaDB 2026 | AiManual
AiManual Logo Ai / Manual.
28 Мар 2026 Гайд

RAG на практике: пошаговый туториал по созданию умного поиска по документам с LangChain и ChromaDB

Пошаговое руководство по созданию RAG-системы для поиска по документам. Установка, загрузка файлов, векторизация, семантический поиск с актуальными инструментам

Забудьте про Ctrl+F. Вот что происходит на самом деле

Представьте: у вас 500 PDF с технической документацией. Клиент спрашивает про специфичный параметр в договоре от 2023 года. Вы копаетесь в папках, теряете час, находите не то. Знакомо?

Стандартный поиск по тексту ломается на синонимах, контексте, сложных запросах. ИИ вроде GPT-4 умный, но слепой - он не видит ваши документы. Решение? RAG (Retrieval-Augmented Generation). Не модный акроним, а конкретный способ заставить ИИ работать с вашими данными.

К марту 2026 года RAG перестал быть экспериментальной технологией. Это стандарт для корпоративных систем, который работает в 80% компаний из Fortune 500. Но большинство реализаций все еще кривые.

Как работает RAG (без воды)

Разбиваю на атомы:

  1. Загружаете документы - PDF, Word, Excel, даже презентации
  2. Дробите на чанки - кусочки по 500-1000 символов с перекрытием
  3. Превращаете в числа (эмбеддинги) - текстовые векторы, которые понимает ИИ
  4. Кладете в векторную БД - ChromaDB, Pinecone, Weaviate
  5. При запросе ищете похожие чанки - семантический поиск, не точное совпадение
  6. Кормите найденное LLM + вопрос = точный ответ с контекстом

Звучит просто? Так и есть. Сложность в деталях - размер чанков, модель эмбеддингов, стратегия поиска. Ошибетесь на любом этапе - получите бред вместо ответов.

💡
Если хотите глубже в теорию RAG - посмотрите полное руководство по архитектуре и типичным ошибкам. Там разобраны все подводные камни, которые не показывают в туториалах.

1 Ставим инструменты (актуально на март 2026)

LangChain к 2026-му сильно изменился. Если видите туториалы с версией 0.0.XX - бегите. Мы используем LangChain 0.2+ с модульной архитектурой.

# Базовый набор для работы
pip install langchain==0.2.1 langchain-community==0.2.1
pip install chromadb==0.4.22  # Самая стабильная версия на 2026
pip install pypdf python-docx pymupdf  # Для чтения документов
pip install sentence-transformers  # Локальные эмбеддинги
pip install ollama  # Если хотите локальную LLM

Внимание: LangChain 0.2+ ломает обратную совместимость. Старые импорты типа `from langchain.document_loaders` больше не работают. Теперь все через `langchain_community`.

2 Загружаем документы - здесь ломаются 30% проектов

Самая скучная часть. И самая важная. Плохо загрузили - получили мусор в БД.

from langchain_community.document_loaders import PyPDFLoader, Docx2txtLoader, TextLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
import os

class DocumentLoader:
    def __init__(self, directory_path):
        self.directory = directory_path
        
    def load_all(self):
        """Загружает все документы из директории"""
        documents = []
        
        for filename in os.listdir(self.directory):
            filepath = os.path.join(self.directory, filename)
            
            try:
                if filename.endswith('.pdf'):
                    loader = PyPDFLoader(filepath)
                elif filename.endswith('.docx'):
                    loader = Docx2txtLoader(filepath)
                elif filename.endswith('.txt'):
                    loader = TextLoader(filepath)
                else:
                    continue
                    
                docs = loader.load()
                # Добавляем метаданные - критично для отслеживания источника
                for doc in docs:
                    doc.metadata["source"] = filename
                    doc.metadata["file_type"] = filename.split('.')[-1]
                
                documents.extend(docs)
                print(f"Загружен {filename}: {len(docs)} страниц/чанков")
                
            except Exception as e:
                print(f"Ошибка загрузки {filename}: {str(e)}")
        
        return documents

# Пример использования
loader = DocumentLoader("./documents/")
raw_docs = loader.load_all()
print(f"Всего загружено документов: {len(raw_docs)}")

Видите метаданные `source` и `file_type`? Без них потом не поймете, откуда пришел ответ. Типичная ошибка - потерять источник информации.

3 Дробление текста - магия, а не наука

Размер чанка - священный грааль RAG. 500 токенов? 1000? 2000? Ответ: зависит от ваших документов.

def smart_chunking(documents, chunk_size=1000, chunk_overlap=200):
    """Умное дробление с сохранением контекста"""
    
    text_splitter = RecursiveCharacterTextSplitter(
        chunk_size=chunk_size,
        chunk_overlap=chunk_overlap,
        length_function=len,
        separators=["\n\n", "\n", ".", " ", ""],  # Важен порядок!
    )
    
    chunks = text_splitter.split_documents(documents)
    
    # Статистика для отладки
    chunk_lengths = [len(chunk.page_content) for chunk in chunks]
    print(f"Создано {len(chunks)} чанков")
    print(f"Средняя длина: {sum(chunk_lengths)/len(chunks):.0f} символов")
    print(f"Минимум: {min(chunk_lengths)}, Максимум: {max(chunk_lengths)}")
    
    return chunks

# Тестируем разные размеры
for size in [500, 1000, 1500]:
    print(f"\nТест с размером чанка {size}:")
    chunks = smart_chunking(raw_docs, chunk_size=size)
    
    # Смотрим на конкретный чанк
    if chunks:
        print(f"Пример чанка (первые 200 символов): {chunks[0].page_content[:200]}...")
💡
Для очень длинных документов (свыше 100 страниц) стандартное дробление не работает. Нужны иерархические стратегии. Подробности в статье "Когда 128К токенов не хватает".

4 Эмбеддинги: превращаем текст в математику

Здесь выбор модели определяет качество поиска. OpenAI дорого, но точно. Локальные модели бесплатно, но нужна мощность.

from langchain_community.embeddings import HuggingFaceEmbeddings
from chromadb.config import Settings
import chromadb

class VectorStoreManager:
    def __init__(self, persist_directory="./chroma_db"):
        """Инициализация с локальными эмбеддингами"""
        # BAAI/bge-large-en-v1.5 - лучшая открытая модель на 2026 для семантического поиска
        self.embedding_model = HuggingFaceEmbeddings(
            model_name="BAAI/bge-large-en-v1.5",
            model_kwargs={'device': 'cpu'},  # или 'cuda' если есть GPU
            encode_kwargs={'normalize_embeddings': True}  # Критично для косинусного сходства
        )
        
        # Настройки ChromaDB
        self.client = chromadb.PersistentClient(
            path=persist_directory,
            settings=Settings(
                anonymized_telemetry=False,  # Отключаем телеметрию
                allow_reset=True
            )
        )
        
    def create_collection(self, collection_name="documents"):
        """Создаем коллекцию в ChromaDB"""
        collection = self.client.get_or_create_collection(
            name=collection_name,
            metadata={"hnsw:space": "cosine"}  # Косинусное расстояние для семантического поиска
        )
        return collection
        
    def add_documents(self, chunks, collection_name="documents"):
        """Добавляем чанки в векторную БД"""
        collection = self.create_collection(collection_name)
        
        # Подготавливаем данные
        ids = [f"doc_{i}" for i in range(len(chunks))]
        texts = [chunk.page_content for chunk in chunks]
        metadatas = [chunk.metadata for chunk in chunks]
        
        # Генерируем эмбеддинги и добавляем
        embeddings = self.embedding_model.embed_documents(texts)
        
        collection.add(
            embeddings=embeddings,
            documents=texts,
            metadatas=metadatas,
            ids=ids
        )
        
        print(f"Добавлено {len(chunks)} документов в коллекцию '{collection_name}'")
        return collection

# Использование
manager = VectorStoreManager()
chunks = smart_chunking(raw_docs, chunk_size=1000)
collection = manager.add_documents(chunks)

Почему `normalize_embeddings=True`? Без нормализации косинусное сходство работает некорректно. Мелочь, которая ломает весь поиск.

Хотите облачное решение? Попробуйте Pinecone (платно) или Weaviate Cloud. Но помните про стоимость: 1M токенов эмбеддингов = $0.13-$0.80 в зависимости от модели. Локальные модели бесплатны, но требуют GPU для скорости.

5 Поиск и ответ: где LangChain показывает зубы

Собрали базу. Теперь самое интересное - поиск и генерация ответов.

from langchain.chains import RetrievalQA
from langchain.prompts import PromptTemplate
from langchain_community.llms import Ollama  # Локальная LLM
from langchain.callbacks.streaming_stdout import StreamingStdOutCallbackHandler

class RAGSystem:
    def __init__(self, vector_store_manager, llm_model="llama3.1:8b"):
        """Инициализация RAG системы с локальной LLM"""
        self.manager = vector_store_manager
        
        # Используем Ollama с Llama 3.1 (актуально на 2026)
        self.llm = Ollama(
            model=llm_model,
            temperature=0.1,  # Низкая температура для точных ответов
            callbacks=[StreamingStdOutCallbackHandler()]
        )
        
        # Кастомный промпт - секретный соус хорошего RAG
        self.prompt_template = """Ты - помощник по документам. Используй только предоставленный контекст для ответа на вопрос.
        Если в контексте нет информации для ответа, скажи "В предоставленных документах нет информации по этому вопросу."
        Не придумывай информацию.
        
        Контекст: {context}
        
        Вопрос: {question}
        
        Ответ: """
        
        self.prompt = PromptTemplate(
            template=self.prompt_template,
            input_variables=["context", "question"]
        )
        
    def search_similar(self, query, collection_name="documents", k=5):
        """Семантический поиск похожих документов"""
        collection = self.manager.client.get_collection(collection_name)
        
        # Генерируем эмбеддинг для запроса
        query_embedding = self.manager.embedding_model.embed_query(query)
        
        # Ищем похожие
        results = collection.query(
            query_embeddings=[query_embedding],
            n_results=k,
            include=["documents", "metadatas", "distances"]
        )
        
        print(f"\nПоиск по запросу: '{query}'")
        print(f"Найдено {len(results['documents'][0])} результатов")
        
        for i, (doc, metadata, distance) in enumerate(zip(
            results['documents'][0],
            results['metadatas'][0],
            results['distances'][0]
        )):
            print(f"\nРезультат {i+1} (расстояние: {distance:.4f}):")
            print(f"Источник: {metadata.get('source', 'Неизвестно')}")
            print(f"Текст: {doc[:200]}...")
        
        return results
        
    def answer_question(self, query, collection_name="documents", k=3):
        """Полный RAG пайплайн: поиск + генерация"""
        # Сначала ищем релевантные чанки
        search_results = self.search_similar(query, collection_name, k)
        
        if not search_results['documents'][0]:
            return "Не найдено релевантных документов для ответа на вопрос."
        
        # Объединяем контекст
        context = "\n\n".join([
            f"[Документ {i+1} из {metadata.get('source', 'Неизвестно')}]\n{doc}"
            for i, (doc, metadata) in enumerate(zip(
                search_results['documents'][0],
                search_results['metadatas'][0]
            ))
        ])
        
        # Формируем финальный промпт
        final_prompt = self.prompt.format(context=context, question=query)
        
        # Генерируем ответ
        print("\n" + "="*50)
        print("ГЕНЕРАЦИЯ ОТВЕТА:")
        print("="*50 + "\n")
        
        response = self.llm.invoke(final_prompt)
        
        # Добавляем источники
        sources = list(set([m.get('source', '') for m in search_results['metadatas'][0]]))
        response_with_sources = f"{response}\n\nИсточники: {', '.join(sources)}"
        
        return response_with_sources

# Запускаем систему
rag = RAGSystem(manager)

# Тестовый запрос
question = "Какие условия возврата товара указаны в документах?"
answer = rag.answer_question(question)
print("\n" + "="*50)
print("ФИНАЛЬНЫЙ ОТВЕТ:")
print(answer)

Видите промпт? Он заставляет LLM не выдумывать. Без такого строгого шаблона модель начнет галлюцинировать.

💡
LangChain не всегда лучшее решение. Иногда он добавляет ненужную сложность. Если хотите легковесную альтернативу, посмотрите как создать свою RAG-систему за 15 минут без тяжелых фреймворков.

Где все ломается (личный опыт)

Собрали систему? Отлично. Теперь список проблем, которые возникнут через неделю:

  1. Качество чанков - если разрываете предложения, смысл теряется. Решение: overlap 15-20% и проверка границ.
  2. Дублирование - один и тот же текст в разных чанках. Решение: дедупликация перед добавлением в БД.
  3. Скорость эмбеддингов - 1000 документов = часы обработки. Решение: батчинг и GPU.
  4. Актуальность данных - документы обновляются, а векторная БД нет. Решение: версионирование и переиндексация.

Самый опасный сценарий - отравление базы знаний. Злоумышленник может добавить документы с ложной информацией, и система начнет генерировать некорректные ответы. Защитные механизмы рассмотрены в статье "Атака на RAG-системы".

Что дальше? Производственные улучшения

Базовая система работает. Теперь сделаем ее промышленной:

# 1. Гибридный поиск (семантический + ключевые слова)
from langchain.retrievers import BM25Retriever, EnsembleRetriever

# 2. Переранжирование результатов
from langchain.retrievers import ContextualCompressionRetriever
from langchain.retrievers.document_compressors import CrossEncoderReranker
from sentence_transformers import CrossEncoder

# 3. Мультимодальность (если есть сканы/изображения)
# Используйте layoutlm или аналоги для OCR с сохранением структуры

# 4. Кэширование эмбеддингов
import hashlib
import pickle

class EmbeddingCache:
    def __init__(self, cache_file="embeddings_cache.pkl"):
        self.cache_file = cache_file
        self.load_cache()
        
    def get_hash(self, text):
        return hashlib.md5(text.encode()).hexdigest()
        
    def load_cache(self):
        try:
            with open(self.cache_file, 'rb') as f:
                self.cache = pickle.load(f)
        except:
            self.cache = {}
            
    def save_cache(self):
        with open(self.cache_file, 'wb') as f:
            pickle.dump(self.cache, f)
            
    def get_embedding(self, text, embedding_model):
        text_hash = self.get_hash(text)
        
        if text_hash in self.cache:
            return self.cache[text_hash]
        else:
            embedding = embedding_model.embed_documents([text])[0]
            self.cache[text_hash] = embedding
            return embedding

Кэширование эмбеддингов ускоряет повторную обработку в 50-100 раз. Но требует мониторинга памяти.

Вместо заключения: когда RAG не нужен

Парадоксально, но иногда RAG - overkill. Если:

  • У вас меньше 100 документов - проще использовать поиск по ключевым словам с улучшенной логикой
  • Документы сильно структурированы (JSON, XML) - парсите их напрямую
  • Вопросы всегда однотипны - создайте шаблоны ответов
  • Требуется 100% точность - RAG дает 85-95%, остальное - риск

К 2026 году появились более легкие альтернативы. Для браузерных решений смотрите браузерный RAG для юристов - весь пайплайн работает локально без отправки данных.

💡
Хотите увидеть RAG в действии с реальными бизнес-сценариями? RAG-чатбот для корпоративных знаний показывает, как оживить архивы компании с минимальными затратами.

Главный совет: начните с простого. ChromaDB + локальные эмбеддинги + Ollama. Когда поймете pain points вашего кейса - оптимизируйте. Не стройте монстра на 1000 строк кода в первый день.

RAG-система - не черный ящик, а набор компромиссов. Между скоростью и точностью, между стоимостью и качеством, между простотой и функциональностью. Выбирайте осознанно.

Подписаться на канал