Перейти к содержанию

Обновление и удаление документов из индекса

Тексты сайта меняются: редактор исправляет правило, уточняет адрес или переводит документ в служебное состояние. Помощник должен перестать использовать старую версию. В пятом уроке мы уже реализовали полную синхронизацию; теперь включим инкрементальное обновление. Оно пропускает неизменившиеся документы, заменяет изменённые части и удаляет источники, которых больше нет в манифесте. Результат останется тем же, а работа сократится. Это оптимизация жизненного цикла, не изменение поискового смысла.

Состояние lesson-10 вызывает sync(load_documents(), incremental=True). В storage.py уже есть сравнение текущего hash и сериализованных метаданных. Мы сохраняли это разделение заранее, потому что изменение visibility или URL должно обновлять индекс даже без изменения текста. Прежние документы urls, sitemap и templates продолжают служить примерами. В архиве нет готового файла базы или выполненной миграции; все эффекты обсуждаются как ожидаемые при самостоятельной работе.

Два признака изменения

Hash описывает нормализованное содержимое Markdown. Метаданные описывают title, url и visibility. Для сравнения JSON метаданных сериализуется с сортировкой ключей. Тогда изменение порядка полей в Python-словаре не выглядит как изменение смысла. Совпадение обеих величин разрешает пропустить документ. Если сравнивать только hash, перевод публичного источника в staff не применится, а помощник продолжит выдавать прежние части. Если сравнивать только время файла, перенос между машинами или восстановление архива может дать ложный результат.

Идентификатор документа остаётся первичным ключом. Переименование urls.md при неизменных id и тексте само по себе не меняет содержимое индекса. Путь нужен загрузчику, но не включён в метаданные поиска: объект после чтения уже не зависит от имени локального файла. Такое решение полезно, когда редактор переупорядочивает каталоги исходников, сохраняя постоянные адреса. Если происхождение импорта требует хранить путь, его можно включить отдельным полем, но тогда нужно решить, должно ли его изменение действительно вызывать пересоздание фрагментов.

При изменении текста старые части документа удаляются целиком. После этого новое разбиение создаёт текущие номера. Не пытаемся угадать, какой абзац сохранился по похожести. Для маленького корпуса полная замена одного документа проще и надёжнее. Сложное сопоставление полезно лишь при большой стоимости индексации или желании сохранять устойчивые ссылки на фрагменты. В нашей серии публичный URL принадлежит статье, а внутренний номер части имеет смысл только внутри текущей версии.

Удаление как явное действие

Множество live строится из документов, которые загрузчик успешно прочитал из текущего манифеста. Для каждого прежнего id, отсутствующего в live, удаляются строка документа и все его части. Значит, исключение источника из манифеста является подтверждённым изменением состава корпуса. Отсутствие файла при сохранённой записи не превращается в удаление: загрузчик выдаёт ошибку прежде начала синхронизации. Это удерживает различие между намеренным снятием материала и случайно неполной копией каталога.

Представьте, что оператор запускает импорт из временного каталога, где по ошибке остались только два документа. В текущей модели этот каталог будет считаться полным авторитетным корпусом, и остальные источники удалятся. Инкрементальность не защищает от неверно выбранной базы импорта. Для реального процесса полезны manifest-version, подтверждение полного снимка и журнал изменений. Здесь роль авторитетного набора играет наш небольшой манифест, который оператор контролирует сам. Не следует переносить такой режим на неполную сетевую выгрузку без дополнительного контракта.

Все изменения выполняются в одной транзакции. Если обработка нового документа вызывает исключение, уже проведённые удаления и вставки не должны считаться завершённой синхронизацией. Для читателя важен согласованный набор, а не последовательное наблюдение процесса переиндексации. В промышленной версии полезно строить новый индекс отдельно и менять указатель после успешной подготовки. Наш SQLite-пример показывает более простой вариант одной локальной транзакции, достаточный для объяснения атомарности небольшого корпуса.

Адрес и видимость без изменения текста

Поменяйте URL документа templates в манифесте, сохранив тот же Markdown. Hash останется прежним, но сериализованные метаданные изменятся, поэтому фрагменты заменятся. Следующий ответ должен содержать новый заданный адрес. При реальной работе такое изменение требует редакционного основания: сохранение исторических URL обычно предпочтительнее произвольного переименования. Помощник лишь переносит принятый адрес; он не решает за владельца сайта, какой путь канонический и нужен ли редирект со старого материала.

Теперь рассмотрим visibility. Если публичный документ стал staff, его данные останутся в общем локальном индексе, но read_rows("reader") перестанет возвращать их. Статистика публичного BM25 также изменится, потому что она вычисляется на уже разрешённом наборе. Такое обновление должно затронуть кэш ответов, иначе ранее сохранённая публичная цитата может пережить закрытие источника. Кэш добавим позже и свяжем его ключ с ревизией разрешённых фрагментов, а не только со строкой вопроса.

Функция sync возвращает счётчики changed и removed. В CLI мы пока не выводим их в основной ответ: это сведения оператора об обновлении, а не часть ответа посетителю. При первом построении changed должен соответствовать количеству документов; при повторе без изменений ожидается ноль. Удаление одного id даст removed равным единице. Эти значения полезны для журнала импорта, но нельзя выдавать их за выполненные измерения: программа и сценарии не запускались при подготовке, поэтому речь идёт о следствии кода.

Последовательность самостоятельного разбора

Полный storage.py ниже совпадает с исходником состояния. Сначала создайте индекс обычной командой app.py, затем измените одну фразу в sitemap.md и вызовите команду снова. Ожидается замена только этого документа. После этого удалите запись deploy из манифеста, оставив файл на диске: источник должен исчезнуть из индекса. Обратный случай — удалённый файл при прежней записи — должен остановить загрузку, а не молча уменьшить корпус. Такой набор вариантов раскрывает контракт синхронизации.

Инкрементальный импорт не решает обновление алгоритма разбиения. Если изменить chunks.py, но оставить текст и метаданные прежними, документы будут пропущены, хотя старые части больше не соответствуют новой политике. В таком случае нужно явно пересобрать индекс с incremental=False или добавить версию схемы обработки к признаку изменения. Это существенная граница текущего примера. Следующий урок объяснит доверенную роль и доступ к источникам; затем кэш и эксплуатационный контракт объединят версии данных с версиями обработки.

Полный файл storage.py из состояния lesson-10:

import hashlib
import json
import sqlite3
from chunks import flatten

def connect(path="knowledge.sqlite3"):
    db = sqlite3.connect(path)
    db.row_factory = sqlite3.Row
    db.executescript("""
        CREATE TABLE IF NOT EXISTS documents (id TEXT PRIMARY KEY, hash TEXT, metadata TEXT);
        CREATE TABLE IF NOT EXISTS fragments (id TEXT PRIMARY KEY, doc_id TEXT, payload TEXT);
    """)
    return db

def sync(documents, incremental=False):
    db = connect()
    current = {row["id"]: (row["hash"], row["metadata"])
               for row in db.execute("SELECT * FROM documents")}
    changed = removed = 0
    with db:
        live = {document["id"] for document in documents}
        for doc_id in current.keys() - live:
            db.execute("DELETE FROM fragments WHERE doc_id=?", (doc_id,))
            db.execute("DELETE FROM documents WHERE id=?", (doc_id,))
            removed += 1
        for document in documents:
            metadata = json.dumps({key: document[key] for key in
                                   ("title", "url", "visibility")}, sort_keys=True)
            if incremental and current.get(document["id"]) == (document["hash"], metadata):
                continue
            db.execute("DELETE FROM fragments WHERE doc_id=?", (document["id"],))
            db.execute("INSERT OR REPLACE INTO documents VALUES (?,?,?)",
                       (document["id"], document["hash"], metadata))
            for chunk in flatten([document]):
                db.execute("INSERT INTO fragments VALUES (?,?,?)",
                           (chunk["id"], document["id"], json.dumps(chunk, ensure_ascii=False)))
            changed += 1
    db.close()
    return {"changed": changed, "removed": removed}

def read_rows(role="reader"):
    if role not in {"reader", "editor"}:
        raise ValueError("Unknown trusted role")
    db = connect()
    rows = [json.loads(row[0]) for row in db.execute("SELECT payload FROM fragments ORDER BY id")]
    db.close()
    return [row for row in rows if row["visibility"] == "public" or role == "editor"]

def revision(role="reader"):
    payload = json.dumps(read_rows(role), sort_keys=True, ensure_ascii=False)
    return hashlib.sha256(payload.encode()).hexdigest()

Оглавление серии · Исходники всех шагов · Учебные маршруты

Пример остаётся локальным черновиком. При подготовке он не выполнялся; все описанные эффекты являются ожидаемыми, а не результатами запущенных проверок.