Индекс документов в SQLite
Лексический поиск уже умеет выбирать кандидатов, но при каждом запуске программы приходится заново читать корпус и строить фрагменты в памяти. В маленькой лаборатории это допустимо; для обновляемого помощника полезно отделить подготовку данных от поиска. Сохраним документы и части в SQLite. Главное новое свойство — согласованная версия индекса: читатель не должен получить половину старого корпуса и половину нового только потому, что обработка прервалась между двумя файлами.
В lesson-05 появляется storage.py. Остальные понятия остаются прежними: идентификаторы источников, URL, видимость, hash и BM25. База knowledge.sqlite3 создаётся лишь при самостоятельном запуске команды; готовой базы в архиве нет. Мы не подключаем векторное хранилище и не меняем смысл поиска. SQLite здесь отвечает за локальное сохранение небольшого подготовленного корпуса, а ранжирование по-прежнему выполняется понятным Python-кодом.
Документ и фрагмент в разных таблицах
Таблица documents хранит устойчивое имя документа, отпечаток текста и JSON метаданных. Таблица fragments хранит имя части, doc_id и JSON полезной нагрузки. Разделение позволяет обновить источник целиком: удалить его старые части и добавить новое разбиение. Если хранить только список фрагментов без связи с документом, обнаружить удалённый источник сложнее. Например, изменение длины разбиения может уменьшить число частей, а последние старые записи останутся и продолжат отвечать за текст, которого больше нет.
В этой лаборатории JSON сохраняется намеренно. Структура фрагмента уже задана предыдущим уроком, и нам удобно перенести её без потери полей. Однако это не означает, что JSON лучше нормализованных столбцов для любого размера базы. Отбор по видимости сейчас произойдёт после чтения записей, а не SQL-условием. При большом корпусе такой подход ограничит производительность и создаст дополнительные требования к памяти процесса. В текущем шаге важнее показать жизненный цикл документа и транзакцию, чем сразу вводить сложный полнотекстовый SQL-индекс.
SQL-запросы используют параметры ?. Значения идентификаторов, hash и JSON не подставляются прямо в строку команды. Даже если название документа содержит кавычку, оно остаётся значением, а не частью SQL-синтаксиса. Этот приём нужен независимо от того, что наш корпус считается доверенным. Он сохраняет ясный контракт между программой и базой, избавляет от ручного экранирования и предотвращает будущую ошибку, когда тот же загрузчик начнёт принимать записи из редакторского интерфейса.
Согласованная замена набора
Функция sync сначала читает текущие документы. Затем в блоке with db удаляет исчезнувшие источники и заменяет остальные. Контекст соединения фиксирует изменения при успешном завершении и откатывает незавершённую транзакцию при исключении. Поэтому ошибка внутри обработки не должна оставлять подтверждённой частичную замену. Создание схемы выполняется заранее: транзакция данных не смешивается с условным появлением таблиц. Такой порядок делает границу согласованности понятнее для небольшого учебного примера.
На этом шаге параметр incremental остаётся ложным. Даже неизменившийся документ обрабатывается заново. Это не самая экономная стратегия, зато проста: каждое выполнение команды строит полный актуальный набор из предоставленного корпуса. Позднее включим сравнение hash и метаданных, но не изменим смысл удаления старых частей. Оптимизация должна сокращать работу, сохраняя результат; она не должна создавать второй, отличающийся вариант индекса. Поэтому сначала полезно понять правильную полную замену.
Для каждого обновляемого документа удаляются все записи fragments с его doc_id. Затем сохраняется строка documents, после чего результат flatten записывается по одной части. Имя sitemap:1 уже знакомо: оно уникально внутри версии разбиения источника. Если новый раздел сдвинул номера, старое состояние полностью удалено. Мы не пытаемся считать порядковый номер постоянным идентификатором абзаца. Позже кэш ответа также должен учитывать версию набора, иначе он сможет вернуть прежнюю цитату после успешной замены базы.
Чтение для запроса
Функция read_rows возвращает фрагменты в порядке id. Для роли reader оставляются только публичные записи. Роль editor станет предметом отдельного урока; сейчас app.py всегда читает публичный набор. Возвращаемые данные совпадают по форме с прежним результатом flatten, поэтому rank не знает, были ли они только что созданы в памяти или прочитаны из SQLite. Это полезная граница: хранение можно менять отдельно от поисковой формулы, если сохраняется контракт объекта фрагмента.
Обратите внимание, что в текущей реализации фильтр видимости применяется в процессе Python после чтения всех payload. Это допустимо лишь для доверенного серверного процесса, которому разрешено держать обе группы в памяти. Служебные записи не уходят в результат read_rows("reader"), но сама база не имеет раздельных пользователей и политик доступа. При настоящей многопользовательской системе нужно защитить файл базы, резервные копии и путь вызова роли. Фильтр функции не превращает общий файл SQLite в самостоятельный механизм разграничения прав.
При запуске python3 app.py "sitemap опубликованных страниц" программа сначала синхронизирует корпус, затем читает публичные части и ранжирует их. Ожидаемый результат похож на предыдущий урок: кандидаты содержат score и coverage. На диске появится knowledge.sqlite3. Эти действия не выполнялись при подготовке. Отсутствие готовой базы в архиве важно: читатель получает исходники и может построить состояние сам, вместо непрозрачного бинарного снимка, происхождение которого нельзя сопоставить с Markdown.
Изменения и ошибки хранения
Представьте, что из манифеста удалили deploy. Полная синхронизация удалит документ и все связанные части. Удаление самого Markdown-файла без изменения манифеста даст ошибку загрузчика ещё до обновления базы. Это два разных намерения: явное исключение источника и случайная потеря файла. Система не должна молча считать второе первым. Такой отказ помогает сохранить прежний согласованный индекс, пока оператор не исправит корпус или не подтвердит удаление записью в манифесте.
SQLite остаётся локальным файлом, а не внешним сервисом. Его не нужно добавлять в source control вместе с исходниками: данные можно восстановить из принятого корпуса. Для реального большого набора понадобятся план миграций, контроль версии схемы, ограничения числа документов и безопасный способ резервного копирования при записи. Мы ещё не реализовали эти эксплуатационные части. Следующий урок сосредоточится на том, как извлечение кандидатов превращается в отдельный контракт запроса и почему доступ к источникам применяется прежде поиска, а не после появления ответа.
Полный файл storage.py из состояния lesson-05:
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()
Оглавление серии · Исходники всех шагов · Учебные маршруты
Пример остаётся локальным черновиком. При подготовке он не выполнялся; все описанные эффекты являются ожидаемыми, а не результатами запущенных проверок.