Compare commits

...
Author SHA1 Message Date
stumpylog f1a858e032 Performance: use small vec0 chunks and larger pages for the LLM index
The vec0 default of 1024 rows per vector chunk makes each chunk a
multi-megabyte blob. On spinning disks, indexing a document took about
2.5 seconds and re-indexing one about 13 seconds. With 32 rows per
chunk the same operations take about 35 and 180 milliseconds, with the
write-ahead log a third of the size and no cost in query speed or file
size.

Pair that with 64 KiB SQLite pages, which made cold scans about 35%
faster and warm ones about 45% faster at no write cost once the chunks
are small. The page size is set before the file enters WAL mode, so it
applies to new files.

Neither setting changes the schema or any query, so there is no
migration. An existing index picks up both the next time it is rebuilt
through compact or rebuild.
2026-11-03 09:48:51 -08:00
Trenton H cc15aa0180 Fix: make index and llmindex init wait for tesseract language install (#14670)
* Fix: make index and llmindex init wait for tesseract language install

* Updates this out of date diagram and makes it Mermaid
2026-11-03 06:59:44 -08:00
GitHub Actions e6ea046e31 Auto translate strings 2026-11-03 06:12:15 +00:00
shamoon 04bae0af62 Fix: prevent loss of save view name on save + close (#14667) 2026-11-02 22:11:09 -08:00
8 changed files with 209 additions and 9 deletions

No files matched your search

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

+57
View File
@@ -0,0 +1,57 @@
# Container init flow
Startup order of the s6-rc oneshot services under `docker/rootfs/etc/s6-overlay/s6-rc.d/`. An arrow `A --> B` means B lists A in its `dependencies.d/`, so B waits for A. Redundant (transitive) edges are omitted.
Each labelled box is a group of services that can run in parallel: every service in it starts as soon as its own dependencies finish, so services in the same box run in parallel. A service does not wait for the rest of its group, only for the arrows pointing at it. Long-running services such as `init-tesseract-langs` (package installs) overlap with several later steps.
```mermaid
flowchart TD
subgraph w3 ["Run in parallel"]
init-tesseract-langs
init-modify-user
init-wait-for-db
init-wait-for-redis
end
subgraph w4 ["Run in parallel"]
init-folders
init-compile-bytecode
end
subgraph w6 ["Run in parallel"]
init-search-index
init-llmindex-migrate
init-superuser
end
base --> init-start --> init-env-file
init-env-file --> init-tesseract-langs
init-env-file --> init-modify-user
init-env-file --> init-wait-for-db
init-env-file --> init-wait-for-redis
init-start --> init-compile-bytecode
init-modify-user --> init-folders
init-modify-user --> init-compile-bytecode
init-folders --> init-migrations
init-compile-bytecode --> init-migrations
init-wait-for-db --> init-migrations
init-migrations --> init-search-index
init-migrations --> init-llmindex-migrate
init-migrations --> init-superuser
init-tesseract-langs --> init-search-index
init-tesseract-langs --> init-llmindex-migrate
init-tesseract-langs --> init-system-checks
init-superuser --> init-system-checks
init-search-index --> init-custom-init
init-system-checks --> init-custom-init
init-wait-for-redis --> init-custom-init
init-custom-init --> init-complete
init-llmindex-migrate --> init-complete
```
`init-search-index` and `init-llmindex-migrate` run `manage.py`, which runs the Django system checks, including the check that the configured OCR languages are installed, so they wait for `init-tesseract-langs`.
+6 -6
View File
@@ -10404,31 +10404,31 @@
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2155249406916744630" datatype="html"> <trans-unit id="2155249406916744630" datatype="html">
<source>View &quot;<x id="PH" equiv-text="this.list.activeSavedViewTitle"/>&quot; saved successfully.</source> <source>View &quot;<x id="PH" equiv-text="view.name ?? title"/>&quot; saved successfully.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
<context context-type="linenumber">422</context> <context context-type="linenumber">423</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4646273665293421938" datatype="html"> <trans-unit id="4646273665293421938" datatype="html">
<source>Failed to save view &quot;<x id="PH" equiv-text="this.list.activeSavedViewTitle"/>&quot;.</source> <source>Failed to save view &quot;<x id="PH" equiv-text="title"/>&quot;.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
<context context-type="linenumber">428</context> <context context-type="linenumber">429</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6837554170707123455" datatype="html"> <trans-unit id="6837554170707123455" datatype="html">
<source>View &quot;<x id="PH" equiv-text="savedView.name"/>&quot; created successfully.</source> <source>View &quot;<x id="PH" equiv-text="savedView.name"/>&quot; created successfully.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
<context context-type="linenumber">497</context> <context context-type="linenumber">498</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6028096992841030074" datatype="html"> <trans-unit id="6028096992841030074" datatype="html">
<source>View &quot;<x id="PH" equiv-text="savedView.name"/>&quot; created successfully, but could not update visibility settings.</source> <source>View &quot;<x id="PH" equiv-text="savedView.name"/>&quot; created successfully, but could not update visibility settings.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
<context context-type="linenumber">503</context> <context context-type="linenumber">504</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="739880801667335279" datatype="html"> <trans-unit id="739880801667335279" datatype="html">
@@ -411,6 +411,7 @@ export class DocumentListComponent
display_mode: this.list.displayMode, display_mode: this.list.displayMode,
display_fields: this.activeDisplayFields, display_fields: this.activeDisplayFields,
} }
const title = this.list.activeSavedViewTitle
this.savedViewService this.savedViewService
.patch(savedView) .patch(savedView)
.pipe(first()) .pipe(first())
@@ -419,13 +420,13 @@ export class DocumentListComponent
this.activeSavedView = view this.activeSavedView = view
this.unmodifiedSavedView = view this.unmodifiedSavedView = view
this.toastService.showSuccess( this.toastService.showSuccess(
$localize`View "${this.list.activeSavedViewTitle}" saved successfully.` $localize`View "${view.name ?? title}" saved successfully.`
) )
this.unmodifiedFilterRules.set(this.list.filterRules) this.unmodifiedFilterRules.set(this.list.filterRules)
}, },
error: (err) => { error: (err) => {
this.toastService.showError( this.toastService.showError(
$localize`Failed to save view "${this.list.activeSavedViewTitle}".`, $localize`Failed to save view "${title}".`,
err err
) )
}, },
+126
View File
@@ -20,8 +20,10 @@ from paperless_ai.tables import DocumentChunksTable
from paperless_ai.tables import DocumentMetaTable from paperless_ai.tables import DocumentMetaTable
from paperless_ai.vector_store import _MAX_IN_VALUES from paperless_ai.vector_store import _MAX_IN_VALUES
from paperless_ai.vector_store import DB_FILENAME from paperless_ai.vector_store import DB_FILENAME
from paperless_ai.vector_store import DB_PAGE_SIZE
from paperless_ai.vector_store import DEFAULT_TABLE_NAME from paperless_ai.vector_store import DEFAULT_TABLE_NAME
from paperless_ai.vector_store import SCHEMA_VERSION from paperless_ai.vector_store import SCHEMA_VERSION
from paperless_ai.vector_store import VEC0_CHUNK_SIZE
from paperless_ai.vector_store import PaperlessSqliteVecVectorStore from paperless_ai.vector_store import PaperlessSqliteVecVectorStore
from paperless_ai.vector_store import _build_where from paperless_ai.vector_store import _build_where
from paperless_ai.vector_store import _pack from paperless_ai.vector_store import _pack
@@ -1128,3 +1130,127 @@ class TestV1ToV2Migration:
assert "DocumentChunksTable.create" not in source assert "DocumentChunksTable.create" not in source
assert "DocumentMetaTable.create" not in source assert "DocumentMetaTable.create" not in source
assert "_create_vec_table(" not in source assert "_create_vec_table(" not in source
LEGACY_PAGE_SIZE = 4096
def vec_table_sql(db_path: Path) -> str:
conn = sqlite3.connect(db_path)
try:
return conn.execute(
"SELECT sql FROM sqlite_master WHERE name = ?",
(DEFAULT_TABLE_NAME,),
).fetchone()[0]
finally:
conn.close()
def page_size_of(db_path: Path) -> int:
conn = sqlite3.connect(db_path)
try:
return conn.execute("PRAGMA page_size").fetchone()[0]
finally:
conn.close()
def create_legacy_vec_table(conn: sqlite3.Connection, dim: int) -> None:
"""The table definition from before chunk_size was set explicitly."""
conn.execute(
f"CREATE VIRTUAL TABLE {DEFAULT_TABLE_NAME} USING vec0("
"id TEXT PRIMARY KEY, document_id INTEGER, +node_content TEXT, "
f"embedding float[{dim}] distance_metric=cosine)",
)
@pytest.fixture
def legacy_page_size_db(tmp_path: Path) -> Path:
"""An existing index file created before the page size was set."""
db_path = tmp_path / DB_FILENAME
conn = sqlite3.connect(db_path)
conn.execute(f"PRAGMA page_size={LEGACY_PAGE_SIZE}")
conn.execute("CREATE TABLE _seed(x)")
conn.execute("DROP TABLE _seed")
conn.close()
assert page_size_of(db_path) == LEGACY_PAGE_SIZE
return db_path
class TestVecChunkSize:
def test_new_table_uses_configured_chunk_size(
self,
store: PaperlessSqliteVecVectorStore,
tmp_path: Path,
) -> None:
"""
GIVEN an empty store
WHEN the first document is indexed and the vec0 table is created
THEN the table definition carries the configured chunk size
"""
store.upsert_document(1, [make_node("1-a", 1)])
assert f"chunk_size={VEC0_CHUNK_SIZE}" in vec_table_sql(tmp_path / DB_FILENAME)
def test_compact_moves_legacy_table_to_configured_chunk_size(
self,
store: PaperlessSqliteVecVectorStore,
tmp_path: Path,
mocker: MockerFixture,
) -> None:
"""
GIVEN an index whose vec0 table was created with the default chunk size
WHEN the index is compacted
THEN the rebuilt table uses the configured chunk size
AND no indexed chunks are lost
"""
legacy = mocker.patch.object(
PaperlessSqliteVecVectorStore,
"_create_vec_table",
staticmethod(create_legacy_vec_table),
)
store.upsert_document(1, [make_node("1-a", 1), make_node("1-b", 1)])
store.upsert_document(2, [make_node("2-a", 2)])
mocker.stop(legacy)
assert "chunk_size" not in vec_table_sql(tmp_path / DB_FILENAME)
store.compact(force=True)
assert f"chunk_size={VEC0_CHUNK_SIZE}" in vec_table_sql(tmp_path / DB_FILENAME)
assert DocumentChunksTable.count(store.client) == 3
class TestIndexPageSize:
def test_new_index_file_uses_configured_page_size(
self,
store: PaperlessSqliteVecVectorStore,
tmp_path: Path,
) -> None:
"""
GIVEN no existing index file
WHEN a store is opened and a document is indexed
THEN the database file uses the configured page size
"""
store.upsert_document(1, [make_node("1-a", 1)])
assert page_size_of(tmp_path / DB_FILENAME) == DB_PAGE_SIZE
def test_compact_moves_legacy_file_to_configured_page_size(
self,
legacy_page_size_db: Path,
) -> None:
"""
GIVEN an existing index file created with a different page size
WHEN the index is compacted
THEN the rebuilt file uses the configured page size
AND no indexed chunks are lost
"""
with PaperlessSqliteVecVectorStore(
uri=str(legacy_page_size_db.parent),
) as store:
store.upsert_document(1, [make_node("1-a", 1), make_node("1-b", 1)])
assert page_size_of(legacy_page_size_db) == LEGACY_PAGE_SIZE
store.compact(force=True)
assert page_size_of(legacy_page_size_db) == DB_PAGE_SIZE
assert DocumentChunksTable.count(store.client) == 2
+17 -1
View File
@@ -47,6 +47,20 @@ SCHEMA_VERSION = 2
# a rebuild copies the live rows into a fresh table. # a rebuild copies the live rows into a fresh table.
COMPACT_BLOAT_RATIO = 2.0 COMPACT_BLOAT_RATIO = 2.0
# Rows per vec0 vector chunk (vec0's default is 1024, which makes each chunk a
# multi-megabyte blob). On spinning disks, inserts and re-indexing were dozens
# of times faster with small chunks, at no cost in query speed or file size.
# Only applies to tables created from here on; an existing index picks it up
# the next time it is rebuilt (compact or rebuild).
VEC0_CHUNK_SIZE = 32
# SQLite page size for the index file, in bytes. With the small vec0 chunks above,
# 64 KiB pages made cold scans about 35% faster and warm ones about 45% faster,
# at the cost of a larger write-ahead log (up to about 70 MB). Only takes effect
# on a newly created file, so like the chunk size it reaches an existing index
# when it is rebuilt.
DB_PAGE_SIZE = 65536
# Number of rows fetched/copied per batch whenever this module streams rows # Number of rows fetched/copied per batch whenever this module streams rows
# instead of materializing them all at once, keeping memory bounded regardless # instead of materializing them all at once, keeping memory bounded regardless
# of index size -- used by compact()'s rebuild, m0001_v1_to_v2's migration # of index size -- used by compact()'s rebuild, m0001_v1_to_v2's migration
@@ -222,6 +236,7 @@ class PaperlessSqliteVecVectorStore(BasePydanticVectorStore):
conn.enable_load_extension(True) # noqa: FBT003 conn.enable_load_extension(True) # noqa: FBT003
sqlite_vec.load(conn) sqlite_vec.load(conn)
conn.enable_load_extension(False) # noqa: FBT003 conn.enable_load_extension(False) # noqa: FBT003
conn.execute(f"PRAGMA page_size={DB_PAGE_SIZE}")
conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=NORMAL") conn.execute("PRAGMA synchronous=NORMAL")
IndexMetaTable.create(conn) IndexMetaTable.create(conn)
@@ -329,7 +344,8 @@ class PaperlessSqliteVecVectorStore(BasePydanticVectorStore):
+ " +node_content TEXT," + " +node_content TEXT,"
+ " embedding float[" + " embedding float["
+ str(int(dim)) + str(int(dim))
+ "] distance_metric=cosine" + "] distance_metric=cosine, chunk_size="
+ str(VEC0_CHUNK_SIZE)
+ ")", + ")",
) )