惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

N
News and Events Feed by Topic
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
月光博客
月光博客
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
大猫的无限游戏
大猫的无限游戏
T
Tailwind CSS Blog
S
SegmentFault 最新的问题
V
V2EX
阮一峰的网络日志
阮一峰的网络日志
C
Cisco Blogs
博客园 - 叶小钗
P
Privacy International News Feed
Jina AI
Jina AI
Apple Machine Learning Research
Apple Machine Learning Research
T
Threatpost
IT之家
IT之家
博客园 - 聂微东
Know Your Adversary
Know Your Adversary
Help Net Security
Help Net Security
罗磊的独立博客
I
Intezer
S
Schneier on Security
博客园_首页
C
CERT Recently Published Vulnerability Notes
雷峰网
雷峰网
Cisco Talos Blog
Cisco Talos Blog
宝玉的分享
宝玉的分享
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
Webroot Blog
Webroot Blog
TaoSecurity Blog
TaoSecurity Blog
MyScale Blog
MyScale Blog
P
Privacy & Cybersecurity Law Blog
T
The Exploit Database - CXSecurity.com
PCI Perspectives
PCI Perspectives
Security Latest
Security Latest
H
Heimdal Security Blog
S
Secure Thoughts
Hacker News: Ask HN
Hacker News: Ask HN
Y
Y Combinator Blog
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
Microsoft Security Blog
Microsoft Security Blog
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
SecWiki News
SecWiki News
The GitHub Blog
The GitHub Blog
A
Arctic Wolf
A
About on SuperTechFans
aimingoo的专栏
aimingoo的专栏
T
Threat Research - Cisco Blogs
Engineering at Meta
Engineering at Meta
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
RAG без downtime: настраиваем инкрементальное обновление документов на Qdrant и LangChain
Игорь Хмельков · 2026-05-26 · via Все публикации подряд на Хабре

Средний

12 мин

7.3K

PM: Нам нужно актуализировать базу знаний для ИИ-ассистента,
там изменилась инструкция по смене пароля.
DevOps: Не проблема, сейчас запущу скрипт, через два часа всё обновится.
Предупреди Заказчика о недоступности сервиса.

Знакомая ситуация? Полная зачистка векторной базы и реиндексация всех имеющихся документов с остановкой сервиса - решение простое и надёжное, но «прощается» только на этапе прототипа.

В продакшене могут быть сотни тысяч документов, живые пользователи и SLA с требованием по доступности сервиса. Обновилась одна, пусть и очень важная инструкция, и сервис недоступен два часа. А если таких обновлений десятки в неделю? Давайте исправим это и напишем ETL-скрипт, который умеет добавлять, обновлять и удалять отдельные документы без остановки сервиса.

Нам потребуется Qdrant. На мой взгляд это отличный движок для векторного поиска с открытым исходным кодом, удобным API и интеграцией. Установить его можно через docker compose или использовать бинарник. У меня версия 1.14.1, не самая новая, но привычка. Если захотите более свежую, не забудьте про версию qdrant-клиента в окружении.

В репозитории туториала есть ноутбук с кодом и тестами, requirements.txt для установки окружения и короткая инструкция в README.

Чтобы упростить код я отказался от сегментации документов, в туториале один документ равен одному чанку. Как добавить сегментацию в код расскажу в конце статьи, в разделе «Замечания по масштабированию».

Для экспериментов я обычно использую тексты в жанре киберпанк. На этот раз тестовые документы будут содержать фрагменты синтетического законопроекта Директива 401. «О лицензировании корпоративного биологического присутствия и коммерческой эксплуатации белковых носителей». Заранее прошу не удивляться формулировкам.

Архитектура и логика

Система инкрементального обновления это ETL-скрипт на python. Qdrant используется через экземпляр класса QdrantVectorStore из LangChain. Ещё есть рекурсивная функция и две таблицы на pandas. И конечно хеширование. В качестве хеша - md5 дайджест полного пути к файлу с именем. Такой вариант позволяет не загружать сам документ в процессе обхода файловой системы.

Логика работы основана на статусах документа: new, update, delete. Статусы хранятся в мастер-таблице. После успешной загрузки документа в хранилище статус меняется на load, а при удалении документа запись о нём удаляется из мастер-таблицы.

логика работы ETL-скрипта

логика работы ETL-скрипта

Начнём с векторного хранилища. Инициализируем клиент. Векторы в Qdrant хранятся в именованных коллекциях. Клиент нужен чтобы создать коллекцию или удалить. Далее определяем эмбеддинг и создаём экземпляр векторного хранилища.

ETL-процесс включает четыре шага.

Первый шаг - рекурсивный обход директорий с документами. На входе - путь к корневой папке, на выходе таблица с перечнем файлов и их атрибутами: полный путь к файлу, имя файла, хеш-имя и дата модификации, а также поле для хранения статуса, пока пустое. Можно отказаться от рекурсии и сделать обход на базе функции os.walk. Вариант есть в ноутбуке, он закомментирован.

Код для рекурсивного обхода директорий
lst = list()
def list_dir(root: str, lst: List[Any]) -> None:
    """ Recursive traversing over directories """
    for node in os.listdir(root):
        if os.path.isdir(os.path.join(root, node)):
            node = os.path.join(root, node)
            list_dir(node, lst)  # Recursion
        else:
            path = os.path.join(root, node)
            path = path.replace('\\', '/')
            full_path = path.replace(f'{ROOT_DOC_PATH}/', '')
            files_dict = {
                'full_path': full_path,
                'file_name': node,
                'hash_name': md5(full_path.encode('utf-8')).hexdigest(),
                'modified': datetime.fromtimestamp(os.path.getmtime(path)),
                'status': ' ',
            }
            lst.append(files_dict)
    return None

  def scan_dir(directory: str) -> bool:
    """ Recursive scan over directories (file folder) """
    print('Files scanning...')
    lst = list()
    # os.walk implementation is here
    list_dir(directory, lst)  # comment this if os.walk used
    if lst:
        files_data = pd.DataFrame(lst)
        files_data.to_csv(os.path.join(STORAGE_PATH,  'files_data.csv'), sep=';', index=False)
        print(f'Recursive traversing Complete, total files: {files_data.shape[0]}')       
        return True
    else:
        print(f'Recursive traversing Complete. No documents found')       
        return False

Второй шаг - расчёт инкремента обновления или так называемой дельты. На входе таблица с результатами сканирования и мастер таблица. Если это первый запуск, то мастер таблицы ещё нет, и она создаётся копированием таблицы с результатами сканирования. На выходе — мастер-таблица с проставленными статусами (new, update, delete). Статусы определяются по следующей схеме:

1. Строим два списка с хеш-именами, new_hashes на основе сканирования, old_hashes на базе мастер-таблицы:

new_hashes = list(files_data.loc[:, 'hash_name'])
old_hashes = list(master_data.loc[:, 'hash_name'])

2. Помечаем в мастер-таблице статусом delete все документы, хеш-имена которых отсутствуют в списке new_hashes

master_data.loc[~master_data.hash_name.isin(new_hashes), 'status'] = 'delete'

3. Обходим мастер-таблицу в цикле по строкам, пропуская документы со статусом delete, сравниваем даты модификаций для документов с одинаковым хеш-именем из таблицы файлов и мастер-таблицы. Если дата в таблице файлов больше даты в мастер-таблице, устанавливаем статус update и записываем новую дату в мастер-таблицу.

4. Делаем выборку из таблицы файлов для документов, хеш-имена которых отсутствуют в мастер-таблице, устанавливаем для них статус new и добавляем этот срез в мастер-таблицу.

new_data = files_data.loc[~files_data.hash_name.isin(old_hashes), :].copy()
new_data.status = 'new'
master_data = pd.concat([master_data, new_data], axis=0, ignore_index=True)
Весь код расчёта дельты
def create_delta() -> bool:
    """ Updating delta calculation"""
    print('\nIncrement calculation...')
    
    try:  # Attempt to load the scan data
        files_data = pd.read_csv(os.path.join(STORAGE_PATH, 'files_data.csv'), sep=';')
    except FileNotFoundError:
        print(f'Files data not found, exit')
        return False
    
    try:  # Attempt to load master data
        master_data = pd.read_csv(os.path.join(STORAGE_PATH, 'master_data.csv'), sep=';')
    except FileNotFoundError:
        print(f'Master data not found, create new')
        master_data = files_data.copy()  # Copy master data from scan data
        master_data.status = 'new'
        master_data.to_csv(f'{STORAGE_PATH}/master_data.csv', sep=';', index=False)
    
    new_hashes = list(files_data.loc[:, 'hash_name'])
    old_hashes = list(master_data.loc[:, 'hash_name'])

    # Check for deleted files
    master_data.loc[~master_data.hash_name.isin(new_hashes), 'status'] = 'delete'
        
    # Check for updated files
    for i in master_data.index:
        if master_data.loc[i, 'status'] == 'delete':
            continue
    
        master_mod_date = datetime.fromisoformat(master_data.loc[i, 'modified'])
        status = master_data.loc[i, 'status']
        hash_name = master_data.loc[i, 'hash_name']
        file_mod_date_str = files_data.loc[files_data.hash_name == hash_name, 'modified'].values[0]
        file_mod_date = datetime.fromisoformat(file_mod_date_str)
    
        if file_mod_date > master_mod_date:
            master_data.loc[i, 'status'] = 'update'
            master_data.loc[i, 'modified'] = file_mod_date_str

    # Check for new files
    new_data = files_data.loc[~files_data.hash_name.isin(old_hashes), :].copy()
    new_data.status = 'new'
    master_data = pd.concat([master_data, new_data], axis=0, ignore_index=True)

    master_data.to_csv(f'{STORAGE_PATH}/master_data.csv', sep=';', index=False)
    print('Current master data saved')

    print(f"Deleted files: {master_data[master_data.status == 'delete'].shape[0]}")
    print(f"Updated files: {master_data[master_data.status == 'update'].shape[0]}")
    print(f"New files: {master_data[master_data.status == 'new'].shape[0]}")
    return True

Третий шаг - загрузка новых и обновляемых документов. На входе - мастер-таблица. Если в ней нет записей со статусами new, update, delete, процесс останавливается.

Файлы документов загружаются по полному пути из мастер-таблицы, а затем целиком помещаются в контейнер Document из LangChain с необходимым набором метаданных, в частности с хеш-именем. На выходе - бинарный файл с python-списком контейнеров.

Код загрузки новых и обновленных документов
def download_docs() -> bool:
    """ Create list of new and updated documents """
    print('\nLoad docs...')
    try:
        master_data = pd.read_csv(os.path.join(STORAGE_PATH, 'master_data.csv'), sep=';')
    except FileNotFoundError:
        print(f'Master data not found, exit')
        return False

    # Check delta
    delta_size = master_data[master_data.status.isin(['new', 'update', 'delete'])].shape[0]
    if not delta_size:
        print(f'There is nothing to update, exit')
        return False
    
    docs = []
    for i in master_data.index:
        status = master_data.loc[i, 'status']
        full_path = master_data.loc[i, 'full_path']
        if status in ['new', 'update']:
            
            # Loading document(s) text
            with open(os.path.join(ROOT_DOC_PATH, full_path), 'r', encoding='utf-8') as handle:
                doc_text = handle.read()
                doc_title = ''
                if re.search(r'# (.*)\n', doc_text):
                    doc_title = re.search(r'# (.*)\n', doc_text).group(1)
            
            # Chunking should be here
            # splits = ...
            doc = Document(
                page_content=doc_text,
                metadata={
                    'title': doc_title,
                    'hash_name': master_data.loc[i, 'hash_name'],
                    'mod_date': master_data.loc[i, 'modified'],
                    'size': len(doc_text),
                    'status': master_data.loc[i, 'status'],
                }
            )
            docs.append(doc)  # Comment this if splits are used
            # docs.extend(splits)  # if chunking is used
    with open(os.path.join(STORAGE_PATH, 'documens.pkl'), 'wb') as handle:  # Save in binary
        pickle.dump(docs, handle)
    print(f"New and updated docs: {len(docs)}")
    return True

Четвертый шаг - обновление коллекции векторного хранилища. На входе - мастер-таблица и бинарный файл с контейнерами документов. Документы удаляются или добавляются в хранилище по их ID, в качестве которого используется хеш-имя из мастер-таблицы. Если документ с таким хеш-именем уже есть в коллекции, он заново векторизируется и сохраняется на место старого вектора. Так работает обновление по документу. Превращение документа в вектор реализовано внутри объекта vector_store.

Код обновления векторного хранилища
def vstore_update(vector_store: QdrantVectorStore) -> bool:
    """ Update the vectors """
    print('\nUpdating the vector store...')
    try:
        master_data = pd.read_csv(os.path.join(STORAGE_PATH, 'master_data.csv'), sep=';')
    except FileNotFoundError:
        print(f'Master data not found, exit')
        return False
    try:
        with open(os.path.join(STORAGE_PATH, 'documens.pkl'), 'rb') as handle:
            docs = pickle.load(handle)
    except FileNotFoundError:
        print(f'Documents not found, exit')
        return False

    # Create a lookup table to find a document by its hash-name.
    lookup = dict()
    for i in range(len(docs)):
        doc = docs[i]
        lookup[doc.metadata.get('hash_name', 'NF')] = i
    
    deleting = 0
    loading = 0
    for i in master_data.index:
        status = master_data.loc[i, 'status']
        hash_name = master_data.loc[i, 'hash_name']

        # Deleting
        if status == 'delete':
            # If chunking is used:
            # Don't forget to remove the splits for the updated document, 
            # otherwise the content will be duplicated.
            # Here is a cycle through document splits, 
            # or you can use a batch of splits if the documents are small in size.
            try:
                vector_store.delete(ids=[hash_name])
                master_data.loc[i, 'status'] = 'delete'
                deleting += 1
            except Exception as ex:
                print(f"Error while deleting: {ex}")
            
        # Adding new and updating vectors
        if status in ['new', 'update']:
            # Here is a cycle through document splits if chunking is used...
            try:
                doc = docs[lookup[hash_name]]
                vector_store.add_documents(documents=[doc], ids=[hash_name])
                master_data.loc[i, 'status'] = 'load'
                loading += 1
            except Exception as ex:
                print(f"Error while updating: {ex}")

    # Drop records with "delete" status
    master_data = master_data.loc[master_data.status != 'delete', :]
    master_data.to_csv(f'{STORAGE_PATH}/master_data.csv', sep=';', index=False)
    print('Current master data saved')
    print(f"Deleted files: {deleting}")
    print(f"Loaded files: {loading}")
    return True

Собственно, ETL-скрипт это тоже функция, которая последовательно запускает описанные шаги, если шаг возвращает «ложь», процесс останавливается.

Код ETL функции
def updating_script(vector_store: QdrantVectorStore) -> bool:
    scan_state = scan_dir(directory=ROOT_DOC_PATH)
    if not scan_state:
        return False

    delta_state = create_delta()
    if not delta_state:
        return False

    download_state = download_docs()
    if not download_state:
        return False

    update_state = vstore_update(vector_store=vector_store)
    if not download_state:
        return False
    return True

Давайте протестируем то, что у нас получилось.

Сценарий I. Первоначальная загрузка документов

Создадим два документа, один из которых сохраним в корневой папке, а второй в папке yaf (yet another folder) внутри корневой. Так сможем проверить обход директорий. Для демонстрации я подготовил функцию, которая возвращает метаданные первых десяти векторов в коллекции:

def collection_scroll(client: QdrantClient, collection_name: str = "Collection_D401") -> None:
    points, _ = client.scroll(
        collection_name="Collection_D401",
        limit=10, # Number of points to retrieve per page
        offset=None,
        with_payload=True,
        with_vectors=False
    )
    for p in points:
        for k, v in p.payload.get('metadata').items():
            print(f"{k}: {v}")
        print()

Запускаем скрипт, получаем вот такой лог:

Files scanning...
Recursive traversing Complete, total files: 2

Increment calculation...
Master data not found, create new
Current master data saved
Deleted files: 0
Updated files: 0
New files: 2

Load docs...
New and updated docs: 2

Updating the vector store...
Current master data saved
Deleted files: 0
Loaded files: 2
CPU times: total: 3.44 s
Wall time: 701 ms
True

Смотрим на коллекцию:

collection_scroll(client=qdrant_client)

title: Статья 15. Запрет на самолечение и неавторизованную регенерацию
hash_name: 2121124bad94b5b0b8a6641d72d41292
mod_date: 2026-05-23 13:49:06.028412
size: 537
status: new

title: Статья 14. Запрет на несанкционированное саморазвитие и био-взлом
hash_name: 62fc20a3e20bb7b23efac3fe3ee05d08
mod_date: 2026-05-23 13:49:06.027412
size: 601
status: new

Этот сценарий можно запустить повторно (без повторной загрузки текстов), процесс должен остановиться с сообщением «Nothing to update, exit».

Лог скрипта
Files scanning...
Recursive traversing Complete, total files: 2

Increment calculation...
Current master data saved
Deleted files: 0
Updated files: 0
New files: 0

Load docs...
There is nothing to update, exit

False

Как всё вернуть в исходное состояние, если потребуется?

- через клиент удалите коллекцию Collection_D401 в Qdrant
- в проектной папке удалите директории data (корневая папка) и storage (папка с промежуточными результатами ETL)

Сценарий II. Новый документ

Создаем новый документ и сохраняем его в ту же папку yaf. Запускаем скрипт.

Лог скрипта
Files scanning...
Recursive traversing Complete, total files: 3

Increment calculation...
Current master data saved
Deleted files: 0
Updated files: 0
New files: 1

Load docs...
New and updated docs: 1

Updating the vector store...
Current master data saved
Deleted files: 0
Loaded files: 1
CPU times: total: 1.58 s
Wall time: 340 ms

True

Сценарий III. Удаляем документ

Давайте удалим документ из корневой папки:

os.remove(os.path.join(ROOT_DOC_PATH, "D401A14.md"))
Лог скрипта
Files scanning...
Recursive traversing Complete, total files: 2

Increment calculation...
Current master data saved
Deleted files: 1
Updated files: 0
New files: 0

Load docs...
New and updated docs: 0

Updating the vector store...
Current master data saved
Deleted files: 1
Loaded files: 0
CPU times: total: 31.2 ms
Wall time: 80.9 ms

True

Сценарий IV. Обновление документа

Добавим текста в D401A16.md и запустим скрипт.

До обновления:
title: Статья 16. Регламент фильтрации сенсорного восприятия и управления доступом
hash_name: 193226b849538bae28eae4e9e716dce5
mod_date: 2026-05-20 13:02:05.476812
size: 563
status: new
После обновления:
title: Статья 16. Регламент фильтрации сенсорного восприятия и управления доступом
hash_name: 193226b849538bae28eae4e9e716dce5
mod_date: 2026-05-20 13:14:33.882040
size: 1002
status: update

Видим, что время модификации, размер и статус изменились.

Лог скрипта
Files scanning...
Recursive traversing Complete, total files: 2

Increment calculation...
Current master data saved
Deleted files: 0
Updated files: 1
New files: 0

Load docs...
New and updated docs: 1

Updating the vector store...
Current master data saved
Deleted files: 0
Loaded files: 1
CPU times: total: 2.66 s
Wall time: 506 ms

True

Замечания по масштабированию

1. Для обхода папок файловой системы я предпочитаю использовать os.walk. Рекурсивная функция поможет, если вместо файловой системы будет облачное хранилище. Главное, чтобы клиент хранилища поддерживал функции list_dir, is_dir или is_file. Тогда изменения в коде рекурсивной функции будут минимальны. Проверено на NextCloud и YAD. Также потребуется скорректировать операции по загрузке документов.

2. Код работает только c текстами в формате txt или markdown. Парсинг и конвертация - отдельная большая тема. Потребуется дополнительный шаг в ETL-скрипте сразу после калькуляции дельты. Я рекомендую конвертировать новые и обновленные документы в markdown, а затем сохранять их в отдельную папку c хеш-именем вместо названия файла (это можно и нужно делать в несколько потоков). И уже из этой папки загружать на следующем шаге.

3. Функции не используют внешние переменные в основном скрипте, всё взаимодействие организовано через файловую систему, то есть первая функция сохраняет файл, следующая его считывает. Это сделано специально, чтобы можно было быстро построить направленный ациклический граф (DAG), и реализовать ETL в AirFlow.

4. Про чанкинг - я пометил комментариями соответствующие места в функции загрузки документов, где должен быть реализован этот механизм. Нужно учитывать, что каждый фрагмент должен иметь свой уникальный id, обычно это uuid4 из библиотеки uuid. Фрагменты связаны с документом через хеш-имя в метаданных. При обновлении фрагментов одного и того же документа у них будут другие id и векторы в итоге задублируются. Поэтому придётся добавить в процедуру обновления полное удаление фрагментов обновляемого документа.

5. Удалять фрагменты удобно через функцию фильтрации метаданных Qdrant (пример фильтрации есть в конце ноутбука). Добавлять новые можно по одному, если эмбеддинг работает на CPU, списком или батчами, если есть поддержка CUDA.

6. В коде ETL-скрипта не предусмотрена проверка дублирования документов. Если один и тот же документ записать в разные папки, обе копии будут загружены. Проблема может быть решена разными способами. Самый простой - проверять на дублирование прямо в ретривере. Вариант посложнее - отслеживать уникальность файлов при сканировании.

7. В реальной системе лучше использовать asyncio и асинхронные функции для работы с векторным хранилищем (aadd_documents, adelete). Класс QdrantVectorStore поддерживает асинхронное взаимодействие.


Инкрементальное обновление векторной базы по отдельным документам занимает около секунды на документ (у меня ~300 мс на чанк с эмбеддингом deepvk/USER-bge-m3 на CPU). Мы работаем только с инкрементом, поэтому экономим часы компьюта на парсинг и векторизацию всего массива документов.

При высокой динамике обновления (например, логи или сообщения в чате) существует ненулевая вероятность, что в момент обновления релевантные векторы будут отсутствовать в коллекции. Но технически ничего не сломается. Просто LLM выдаст некорректный ответ. Так как векторное хранилище «не зачищается», вопрос остановки сервиса переходит в категорию оценки качества ответа. Я знаю несколько проектов, где сервис не останавливают, а обновление проводят в периоды наименьшей нагрузки, запуская ежедневно по планировщику.

А как в ваших проектах устроено обновление RAG-системы? Останавливаете сервис?
Напишите пожалуйста в комментариях.

Полезные ссылки:
LangChain + Qdrant
Фильтрация в Qdrant
Концепция DAG в Airflow
Код туториала