mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-08-01 00:22:17 +00:00
Feature: run pending LLM index migrations automatically on startup
Add document_llmindex migrate, a cheap check-only path (no reindex) safe to run unconditionally: has_pending_migration() short-circuits to a metadata-only read once the store is current, so a healthy install pays almost nothing. If a pending migration would require re-embedding, it only logs a warning and leaves the index as-is -- re-embedding can be slow and, for a metered embedding backend, cost money, so it stays a deliberate manual action (document_llmindex rebuild), never automatic. Wire it into the Docker image as a new init-llmindex-migrate oneshot (modeled on init-search-index, gated on init-migrations), and document the equivalent manual step for bare-metal upgrades. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
b21d50645d
commit
df8a6cc715
@@ -3,6 +3,7 @@ from typing import Any
|
||||
from documents.management.commands.base import PaperlessCommand
|
||||
from documents.tasks import llmindex_index
|
||||
from paperless_ai.indexing import llm_index_compact
|
||||
from paperless_ai.indexing import llm_index_migrate
|
||||
|
||||
|
||||
class Command(PaperlessCommand):
|
||||
@@ -13,12 +14,18 @@ class Command(PaperlessCommand):
|
||||
|
||||
def add_arguments(self, parser: Any) -> None:
|
||||
super().add_arguments(parser)
|
||||
parser.add_argument("command", choices=["rebuild", "update", "compact"])
|
||||
parser.add_argument(
|
||||
"command",
|
||||
choices=["rebuild", "update", "compact", "migrate"],
|
||||
)
|
||||
|
||||
def handle(self, *args: Any, **options: Any) -> None:
|
||||
if options["command"] == "compact":
|
||||
llm_index_compact()
|
||||
return
|
||||
if options["command"] == "migrate":
|
||||
llm_index_migrate()
|
||||
return
|
||||
llmindex_index(
|
||||
rebuild=options["command"] == "rebuild",
|
||||
iter_wrapper=lambda docs: self.track(
|
||||
|
||||
@@ -8,6 +8,7 @@ if TYPE_CHECKING:
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
_COMPACT = "documents.management.commands.document_llmindex.llm_index_compact"
|
||||
_MIGRATE = "documents.management.commands.document_llmindex.llm_index_migrate"
|
||||
_INDEX = "documents.management.commands.document_llmindex.llmindex_index"
|
||||
|
||||
|
||||
@@ -17,6 +18,11 @@ class TestDocumentLlmindexCommand:
|
||||
call_command("document_llmindex", "compact")
|
||||
mock_compact.assert_called_once_with()
|
||||
|
||||
def test_migrate_calls_llm_index_migrate(self, mocker: MockerFixture) -> None:
|
||||
mock_migrate = mocker.patch(_MIGRATE)
|
||||
call_command("document_llmindex", "migrate")
|
||||
mock_migrate.assert_called_once_with()
|
||||
|
||||
def test_rebuild_calls_llmindex_index_with_rebuild_true(
|
||||
self,
|
||||
mocker: MockerFixture,
|
||||
|
||||
@@ -461,6 +461,31 @@ def llm_index_add_or_update_document(document: Document):
|
||||
store.upsert_document(str(document.id), new_nodes)
|
||||
|
||||
|
||||
def llm_index_migrate() -> None:
|
||||
"""Apply any pending LLM index schema migrations, with no reindex.
|
||||
|
||||
Intended to run unconditionally on every startup (see the
|
||||
init-llmindex-migrate container step and the bare-metal upgrade docs):
|
||||
has_pending_migration() short-circuits to a metadata-only read once the
|
||||
store is current, so a healthy install pays almost nothing here. Only
|
||||
ever applies structural migrations -- a pending re-embed migration is
|
||||
left for the explicit, deliberate rebuild path (``document_llmindex
|
||||
update``/``rebuild``) to resolve, since re-embedding can be slow and,
|
||||
for a metered embedding backend, cost money.
|
||||
"""
|
||||
if not AIConfig().llm_index_enabled:
|
||||
return
|
||||
with write_store() as store:
|
||||
needs_reembed = _check_and_run_migrations(store)
|
||||
if needs_reembed:
|
||||
logger.warning(
|
||||
"LLM index requires re-embedding, which this automatic migration "
|
||||
"check will not do on its own -- it can be slow and, for a "
|
||||
"metered embedding backend, cost money. Run "
|
||||
"'document_llmindex rebuild' manually when ready.",
|
||||
)
|
||||
|
||||
|
||||
def llm_index_compact() -> None:
|
||||
"""Compact the index immediately, rebuilding the table to reclaim space."""
|
||||
with write_store() as store:
|
||||
|
||||
@@ -722,6 +722,110 @@ def test_llm_index_compact_uses_force(
|
||||
mock_store.compact.assert_called_once_with(force=True)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestLlmIndexMigrate:
|
||||
"""llm_index_migrate() is the cheap, startup-safe migration check -- see
|
||||
the init-llmindex-migrate container step and the bare-metal upgrade docs.
|
||||
"""
|
||||
|
||||
def test_skips_when_llm_index_disabled(
|
||||
self,
|
||||
temp_llm_index_dir: Path,
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- The LLM index is disabled
|
||||
WHEN:
|
||||
- llm_index_migrate() is called
|
||||
THEN:
|
||||
- The store is never opened (no stray db file for users who
|
||||
never enabled AI features)
|
||||
"""
|
||||
mock_config = mocker.MagicMock()
|
||||
mock_config.llm_index_enabled = False
|
||||
mocker.patch("paperless_ai.indexing.AIConfig", return_value=mock_config)
|
||||
mock_write_store = mocker.patch("paperless_ai.indexing.write_store")
|
||||
|
||||
indexing.llm_index_migrate()
|
||||
|
||||
mock_write_store.assert_not_called()
|
||||
|
||||
def test_runs_pending_structural_migration(
|
||||
self,
|
||||
temp_llm_index_dir: Path,
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- The LLM index is enabled and a structural migration is pending
|
||||
WHEN:
|
||||
- llm_index_migrate() is called
|
||||
THEN:
|
||||
- check_and_run_migrations() runs, and no re-embed warning is
|
||||
logged (the pending migration was structural, not re-embed)
|
||||
"""
|
||||
mock_config = mocker.MagicMock()
|
||||
mock_config.llm_index_enabled = True
|
||||
mocker.patch("paperless_ai.indexing.AIConfig", return_value=mock_config)
|
||||
mock_store = mocker.MagicMock()
|
||||
mock_store.has_pending_migration.return_value = True
|
||||
mock_store.check_and_run_migrations.return_value = False
|
||||
mocker.patch(
|
||||
"paperless_ai.indexing.write_store",
|
||||
return_value=mocker.MagicMock(
|
||||
__enter__=mocker.MagicMock(return_value=mock_store),
|
||||
__exit__=mocker.MagicMock(return_value=False),
|
||||
),
|
||||
)
|
||||
|
||||
with caplog.at_level("WARNING"):
|
||||
indexing.llm_index_migrate()
|
||||
|
||||
mock_store.check_and_run_migrations.assert_called_once()
|
||||
assert "re-embedding" not in caplog.text
|
||||
|
||||
def test_warns_without_rebuilding_when_reembed_pending(
|
||||
self,
|
||||
temp_llm_index_dir: Path,
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- The LLM index is enabled and a pending migration requires
|
||||
re-embedding
|
||||
WHEN:
|
||||
- llm_index_migrate() is called
|
||||
THEN:
|
||||
- A warning is logged telling the admin to rebuild manually, but
|
||||
no rebuild is triggered automatically -- re-embedding can be
|
||||
slow and, for a metered embedding backend, cost money, so it
|
||||
must be a deliberate user action, never an automatic one
|
||||
"""
|
||||
mock_config = mocker.MagicMock()
|
||||
mock_config.llm_index_enabled = True
|
||||
mocker.patch("paperless_ai.indexing.AIConfig", return_value=mock_config)
|
||||
mock_store = mocker.MagicMock()
|
||||
mock_store.has_pending_migration.return_value = True
|
||||
mock_store.check_and_run_migrations.return_value = True
|
||||
mocker.patch(
|
||||
"paperless_ai.indexing.write_store",
|
||||
return_value=mocker.MagicMock(
|
||||
__enter__=mocker.MagicMock(return_value=mock_store),
|
||||
__exit__=mocker.MagicMock(return_value=False),
|
||||
),
|
||||
)
|
||||
|
||||
with caplog.at_level("WARNING"):
|
||||
indexing.llm_index_migrate()
|
||||
|
||||
assert "re-embedding" in caplog.text
|
||||
mock_store.drop_table.assert_not_called()
|
||||
mock_store.add.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestLlmIndexLocking:
|
||||
"""Index mutation functions must go through write_store(), which holds the lock.
|
||||
|
||||
@@ -671,7 +671,7 @@ class PaperlessSqliteVecVectorStore(BasePydanticVectorStore):
|
||||
if migration.kind == "re-embed":
|
||||
logger.warning(
|
||||
"LLM index schema v%d -> v%d requires re-embedding (%s); "
|
||||
"forcing full rebuild.",
|
||||
"the caller must force a rebuild.",
|
||||
migration.from_version,
|
||||
migration.to_version,
|
||||
migration.description,
|
||||
|
||||
Reference in New Issue
Block a user