quad_rag_core — лёгкое Python-ядро для локального RAG, которое автоматически отслеживает изменения в папках, индексирует их в Qdrant и поддерживает эмбеддинги в актуальном состоянии. Изначально проект задумывался как инструмент MCP (Model Context Protocol), но стал универсальной основой для системы локального семантического поиска.
Зачем это нужно
В процессе работы с кодовой базой через LLM-агентов и при необходимости локального семантического поиска по файлам проекта обнаружилась проблема. Инструменты агентской разработки вроде Kilo Code предоставляют встроенную функцию семантического поиска, но в компании заявляют что в будущем эта функциональность может стать платной. Сразу задумался о том чтобы сделать свою подсистему поиска. Простые запросы к MCP-серверу на поиск и обновление тут не подойдут - система поиска должна иметь полный контроль над контекстом - она должна автоматически узнавать, что файл удалён, функция изменена или добавлен новый документ, без необходимости перезапуска индексации.
От идеи к архитектуре
В начале планировался простой MCP-сервер, который принимает команды поиска и обновления, индексирует текстовые файлы и PDF, использует Qdrant как векторное хранилище и эмбеддит локально.
В ходе проектирования стало понятно: вся логика отслеживания файлов, парсинга, чанкинга и синхронизации с Qdrant — это переиспользуемое ядро, а не часть MCP-протокола.
Так появился quad_rag_core — отдельный Python-модуль, который не знает ничего про MCP или другие внешние интерфейсы, но готов к ним подключаться.
Архитектура: компонент RAG как watch-сервис
Самая важная особенность quad_rag_core — автоматический жизненный цикл индекса.
Вы говорите системе следить за папкой. Она создаёт коллекцию в Qdrant, сканирует файлы и индексирует их. Затем запускается watchdog, который отлавливает события файловой системы: создание, изменение, перемещение, удаление.
При любом изменении старые чанки удаляются, новые пересчитываются и вставляются. Даже при перезапуске система восстанавливает состояние из метаданных в Qdrant.
Это значит, что система всегда работает с актуальной базой знаний без ручного управления.
Компоненты ядра
QdrantManager обёртка над qdrant-client с упрощённым APILocalEmbedder синглтон-эмбеддер на SentenceTransformerRAGFileWatcher реагирует на события файловой системы, разбивает текст на чанки, обновляет точки в QdrantPathWatcherManager оркестратор, который управляет несколькими папками, сериализует данные, восстанавливает отслеживание при стартеФайловый процессор обработчик текстовых файлов и PDF с тремя бэкендами.
в составе модулей:
quad_rag_core/ ├── qdrant_manager.py # работа с Qdrant ├── embedder.py # nomic-embed-text ├── reranker.py # BGE reranker ├── file_processor.py # чанкинг + PDF ├── path_watcher.py # watchdog ├── path_manager.py # оркестрация ├── config.py # настройки по умолчанию └── utils.py # хеши, MIME, нормализация
Singleton Pattern для AI-моделей
Одно из ключевых архитектурных решений — использование паттерна Singleton для LocalEmbedder.
class LocalEmbedder: _instance = None def __new__(cls): if cls._instance is None: cls._instance = super().__new__(cls) cls._instance.model = SentenceTransformer( "nomic-ai/nomic-embed-text-v2-moe", trust_remote_code=True, device="cuda" if torch.cuda.is_available() else "cpu" ) return cls._instance
Преимущества:
Эффективное использование GPU памяти — модель загружается только один раз
Быстродействие — последующие вызовы не требуют загрузки модели
Thread-safety — все потоки используют один и тот же экземпляр модели
Тот же подход применён к LocalReranker с моделью BAAI/bge-reranker-v2-m3.
Dual-Prompt Embedding
Модель nomic-embed-text-v2-moe обучена с разными инструкциями для разных типов текста. При использовании prompt_name="passage" модель понимает, что это фрагмент документа для индексации. При prompt_name="query" модель создаёт эмбеддинг, оптимизированный для поиска по индексированным чанкам.
def embed_document(self, text: str) -> List[float]: """Embed a document (for indexing).""" vector = self.model.encode( text, prompt_name="passage", # Для документов convert_to_tensor=False, show_progress_bar=False, normalize_embeddings=True ) return vector.tolist() def embed_query(self, text: str) -> List[float]: """Embed a search query (for inference).""" vector = self.model.encode( text, prompt_name="query", # Для запросов convert_to_tensor=False, show_progress_bar=False, normalize_embeddings=True ) return vector.tolist()
State Persistence в Qdrant
Конфигурация watcher-компонента, следящего за папкой, хранится как специальная точка метаданных в каждой коллекции Qdrant с фиксированным UUID.
WATCHER_METADATA_ID = str(uuid.UUID("f0f0f0f0f0-0000-0000-0000-000000000001")) # При создании ватчера meta_point = PointStruct( id=WATCHER_METADATA_ID, vector=[0.0] * 768, payload={ "watcher_config": { "folder_path": folder_path, "content_types": content_types or ["text"], "collection_prefix": self.collection_prefix } } )
Что это дает:
1. Автоматическое восстановление при перезапуске сервиса PathWatcherManager считывает все коллекции и восстанавливает watcher'ы из метаданных
2. Позволяет хранить метаданные централизованно в единой базе данных, значит не нужен отдельный конфиг-файл или другая база.
3. Простота миграции — при переносе на другой сервер достаточно скопировать Qdrant данные.
def _restore_from_qdrant(self): """Восстанавливает наблюдателей из метаданных в Qdrant.""" collections = self.qdrant_manager.client.get_collections().collections for col in collections: meta = self.qdrant_manager.client.retrieve( collection_name=col.name, ids=[WATCHER_METADATA_ID], with_payload=True, with_vectors=False ) config = meta[0].payload.get("watcher_config") watcher = RAGFileWatcher( folder_path=config["folder_path"], collection_name=col.name, ... )
Многокомпонентная обработка pdf-файлов
Для извлечения текста из PDF используется стратегия с тремя бэкендами и fallback-механизмом.
def _extract_text_from_pdf(file_path: str) -> str: # Попытка 1: PyPDF2 try: text = _extract_pdf_PyPDF2(file_path) if text.strip(): return text except Exception as e: print(f"[DEBUG] PyPDF2 failed for {file_path}: {e}") # Попытка 2: fitz try: text = _extract_pdf_fitz(file_path) if text.strip(): return text except Exception as e: print(f"[DEBUG] fitz failed for {file_path}: {e}") # Попытка 3: pdfplumber try: text = _extract_pdf_pdfplumber(file_path) if text.strip(): return text except Exception as e: print(f"[DEBUG] pdfplumber failed for {file_path}: {e}") return ""
При этом обеспечивается надежность - если один бэкенд падает, система продолжает работать со следующим.
Обход подводных камней: потоко-безопасность и конфликты с наблюдением папок
Для работы с ватчерами и отслеживания прогресса используем блокировки потоков.
class PathWatcherManager: def __init__(self, ...): self.watchers: Dict[str, RAGFileWatcher] = {} self._lock = threading.Lock() class RAGFileWatcher: def __init__(self, ...): self._progress_lock = threading.Lock()
Значит несколько потоков могут одновременно вызывать методы, а блокировки предотвращают ситуацию гонок и система корректно работает при параллельной обработке файлов.
При попытке добавить папку для отслеживания система проверяет конфликты с уже наблюдаемыми путями.
def _check_conflict(self, new_path: str) -> List[str]: """Returns list of conflicting paths.""" new_path = self._normalize_path(new_path) conflicts = [] for watched in self.watchers: if (new_path.startswith(watched + os.sep) or watched.startswith(new_path + os.sep)): conflicts.append(watched) return conflicts
Какие проблемы это решает:
- Предотвращение дублирования — нельзя отслеживать вложенные папки /project, и /project/src одновременно, то есть мы избегаем ситуаций, когда один файл обрабатывается несколькими ватчерами.
- Пользователь получает ясные сообщения об ошибках и сразу понимает, что пошло не так.
Отложенная обработка
При изменении файла система не обрабатывает его мгновенно, а ждёт заданный интервал времени, по умолчанию 0.5 секунды. Интервал можно настроить под свой стиль и не обрабатывать файл, пока он активно изменяется.
def _delayed_process(self, file_path: str, delay: float = 0.5): """Waits until file stops changing, then processes it.""" time.sleep(delay) self._process_file(file_path)
Конфигурация
Всё, что касается чанкинга, фильтрации, порогов поиска, процент перекрытия чанков вынесено в единый файл конфигурации config.py
CHUNK_SIZE_WORDS = 150 CHUNK_OVERLAP_RATIO = 0.15 SEARCH_SCORE_THRESHOLD = 0.150 CHUNK_CHARACTERS_PREVIEW = 100 RERANK_SCORE_THRESHOLD = 0.35 TEXT_FILE_EXTENSIONS = { '.py', '.js', '.md', '.json', '.txt', '.yaml', '.yml', ... }
Реранкер
Первый этап поиска — векторный. Но он не всегда точен, особенно когда запрос семантически далёк от формулировки в коде или документации. Поэтому добавлен локальный кросс-энкодер реранкер (компонентLocalReranker).
class LocalReranker: _instance = None def __new__(cls): if cls._instance is None: cls._instance = super().__new__(cls) cls._instance.model = CrossEncoder( "BAAI/bge-reranker-v2-m3", max_length=512, device="cuda" if torch.cuda.is_available() else "cpu" ) return cls._instance def rerank(self, query: str, chunks: List[str], top_k: int = 10): """Возвращает список (чанк, score), отсортированный по релевантности.""" pairs = [[query, chunk] for chunk in chunks] scores = self.model.predict(pairs, batch_size=32, show_progress_bar=False) results = list(zip(chunks, scores)) results.sort(key=lambda x: x[1], reverse=True) return results[:top_k]
Реранкер принимает топ-N чанков от Qdrant, оценивает их парную релевантность к запросу и возвращает отсортированный список с новыми скорами. Это повышает точность первых результатов, особенно для сложных запросов.
Реранкер реализован как синглтон, чтобы не загружать GPU память при частых вызовах.
Интеграция
Ядро не зависит, например, от MCP-сервера реализующего внешний интерфейс семантического поиска, но сравнительно легко с ним интегрируется. Конечно, схема обработки данных внешнего сервиса может быть разной. Предварительно устанавливаем quad_rag_core как компонент Питона. Тогда, схематично, MCP-инструменты LLM могут выглядеть так:
from quad_rag_core.path_manager import PathWatcherManager from quad_rag_core.qdrant_manager import QdrantManager from quad_rag_core.embedder import LocalEmbedder pm = PathWatcherManager( QdrantManager(host="localhost", port=6333), LocalEmbedder() ) @mcp.tool() def watch_folder(path: str): pm.watch_folder(path) @mcp.tool() def search(query: str, folder: str): vector = LocalEmbedder().embed_query(query) results = QdrantManager().search(f"rag_{folder}", vector) reranked = LocalReranker().rerank(query, [r.payload for r in results]) return reranked
То есть нашему внешнему сервису достаточно всего двух функций
- отслеживать локальную папку в новом или уже существующем индексе (watch_folder)
- и, собственно, семантический поиск в этой папке (search).
Такой компонентный подход позволяет использовать одно и то же ядро для MCP-сервера, FastAPI-веб-интерфейса, CLI-утилиты или любого другого интерфейса.
Производительность
Система автоматически использует GPU, если доступен CUDA.
device="cuda" if torch.cuda.is_available() else "cpu"
Реранкер поддерживает пакетную обработку для ускорения.
scores = self.model.predict(pairs, batch_size=32, show_progress_bar=False)
Оптимизации индексации включают чанкинг с перекрытием, удаление старых чанков перед вставкой новых и отложенную обработку файлов.
Выводы и результаты
Создан проект (исходники на Gitgub) локального, автономного ядра RAG, которое работает в фоне и автоматически поддерживает индекс векторной БД в актуальном состоянии, например, для систем поиска, которые понимают кодовую базу здесь и сейчас.
Основные преимущества — автоматическое отслеживание изменений в папке, локальная работа без облачных API, поддержка множества форматов файлов и простая интеграция с любыми протоколами. Система сохраняет состояние в Qdrant для автоматического поддержания индекса и применяет multi-backend подход для максимальной совместимости с PDF.
Ядро подходит для MCP-серверов LLM-агентов, веб-интерфейсов документации, CLI-утилит для разработчиков и чат-ботов. Архитектура позволяет расширять функциональность и адаптировать под конкретные задачи.
Особенности проекта включают централизованную конфигурацию, кросс-платформенную работу и защиту от конфликтов путей отслеживания. В дальнейшем можно добавить поддержку дополнительных форматов файлов, внешнюю конфигурацию и метрики для мониторинга.
Quad_rag_core это open-source ядро, которое можно интегрировать с разными внешними интерфейсами RAG, расширить под свои форматы и запустить на локальном компьютере. Протестировал модуль на двух обертках - MCP сервере для LLM и локальном поиске с веб-интерфейсом. В обоих случаях система продемонстрировала высокую скорость реакции - изменения в исходных файлах обновлялись в индексе практически моментально и поиск показывал актуальные результаты. Все три подсистемы разрабатывались с помощью LLM.
Вообще, хочется заметить, что с развитием ИИ роль человека программиста, похоже, трансформируется в подобие менеджера команды AI-разработчиков цифрового контента. "Контент" здесь - в широком смысле, включая и разработку компьютерных программ. Причем, уровень подготовки и квалификация такого менеджера в программистских дисциплинах должны быть очень высокими, ведь его роль будет заключаться в налаживании полного цикла разработки ПО на виртуальной фабрике, сотрудниками которой будут роботы. Почему? Потому что скорость разработки у ИИ-программиста на порядок выше чем у человека и угнаться за ним будет не реально. Искусство программирования перейдет в искусство управления такой "фабрикой". Появятся грейды и уровни сертификации таких менеджеров, а термин CAD (Computer Aided Design) трансформируется в AIAD - Artificial Intelligence Aided Design - Разработка на основе ИИ.
Источник


