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

推荐订阅源

aimingoo的专栏
aimingoo的专栏
人人都是产品经理
人人都是产品经理
M
MIT News - Artificial intelligence
C
Cybersecurity and Infrastructure Security Agency CISA
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
爱范儿
爱范儿
Spread Privacy
Spread Privacy
P
Palo Alto Networks Blog
Security Archives - TechRepublic
Security Archives - TechRepublic
T
The Blog of Author Tim Ferriss
The Cloudflare Blog
WordPress大学
WordPress大学
小众软件
小众软件
H
Help Net Security
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
Blog — PlanetScale
Blog — PlanetScale
V2EX - 技术
V2EX - 技术
H
Hacker News: Front Page
Last Week in AI
Last Week in AI
D
DataBreaches.Net
V
V2EX
S
Securelist
C
CXSECURITY Database RSS Feed - CXSecurity.com
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
T
Tenable Blog
Cloudbric
Cloudbric
月光博客
月光博客
Y
Y Combinator Blog
博客园 - 三生石上(FineUI控件)
N
News and Events Feed by Topic
K
Kaspersky official blog
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
S
Secure Thoughts
S
Security Affairs
W
WeLiveSecurity
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
The Last Watchdog
The Last Watchdog
AI
AI
有赞技术团队
有赞技术团队
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
博客园 - 【当耐特】
罗磊的独立博客
Google DeepMind News
Google DeepMind News
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
N
Netflix TechBlog - Medium
PCI Perspectives
PCI Perspectives
SecWiki News
SecWiki News
IT之家
IT之家
Microsoft Azure Blog
Microsoft Azure Blog
V
Visual Studio Blog

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

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет 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
Код туториала