feat(documents-ingest): chunk + embed job (module 5 phase 2 step 6)

Adds documents-ingest-embed: chunks source='paperless' envelope content
(paragraph-preferring, ~600 tok/chunk, ~150 tok overlap, hard char-fallback
for oversized paragraphs per plan §2 decision 3), embeds each chunk via
Ollama (bge-m3, dim validated against document_chunk's VECTOR(1024) on
every response) and inserts into document_chunk. Lives in documents-ingest
per the plan's own recommendation (§6 step 6) rather than a new package —
reuses the job family's existing idempotency/stats-balance/dry-run
conventions (paperless_adapter.py, gmail-header-backfill).

A 5-angle multi-agent code review of the initial implementation surfaced
three real bugs, fixed here: hard_split() could infinite-loop if
--chunk-overlap >= --chunk-size (now guarded in both hard_split() and
main()); insert_chunk() wasn't error-isolated like embed_chunk(), so a DB
write failure would crash the whole run instead of being counted and
skipped; and ON CONFLICT DO NOTHING's outcome was discarded, so a silently
skipped row (the known gap where document_chunk's UNIQUE constraint
doesn't include `model`) would have been miscounted as a successful insert
- now tracked separately as chunks_conflict_skipped and treated as a
run failure.

Smoke-tested and run to completion live on SOLARIA against the real Ollama
instance and kb-postgres@PIHA: dry-run matched the known phase-2-step-5
figures exactly (186 fetched, 26 empty_content, 2684 chunks planned), a
--limit 10 apply + idempotent re-run + DB/distance sanity checks all
passed, and the full 186-document run inserted 2683/2684 chunks (1 isolated
error - Ollama's runtime context window rejected one pathological
dot-leader table-of-contents chunk that tokenized far more densely than
estimated; documented as a known limitation, not fixed here given it's a
single-chunk edge case). Timing: ~0.79s/chunk average on CPU (SOLARIA's
Ollama runs GPU-less per the recent GPU-reservation-disabled fix), ~35 min
wall-clock for the full pilot - the real input for scoping the later
mail-corpus embedding phase (plan §7's GPU-based estimate doesn't hold
here).

pytest: 101 passed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
oskar 2026-07-15 20:41:00 +02:00
parent 5218925408
commit 09bb59a1db
4 changed files with 1056 additions and 0 deletions

View file

@ -301,3 +301,177 @@ the live Paperless API, over SSH — **not executed as part of this change**
without operator confirmation (this job reads production Paperless data and
writes production envelope rows on `--apply`). `pytest` passes locally before
this commit.
---
## Phase 2 step 6 — `documents-ingest-embed` (chunk + embed)
Module 5, phase 2, plan step 6 (`docs/kb/modules/05-faza2-plan.md`, §6 step 6,
§2 decision 3). Reads `entities[type=content].text` off every `source='paperless'`
envelope, chunks it, calls Ollama (`POST /api/embeddings`, model `bge-m3`) for
each chunk, and inserts the result into `document_chunk`
(`services/kb-postgres/init/002_chunks.sql`). This job only ever `INSERT`s into
`document_chunk``envelope` is read-only here, and `services/ollama/` is
untouched.
### Where it runs
**On SOLARIA** (that's where Ollama lives), against kb-postgres@PIHA over
Tailscale — the reverse of the other jobs in this package, which run on PIHA.
`--ollama-url` defaults to `http://localhost:11434` (Ollama on the same node);
`--dsn` needs PIHA's Tailscale address, e.g.
`postgresql://kb:<pw>@piha:5433/kb`.
### Install
```bash
pip install -e packages/kb-mail/
pip install -e jobs/documents-ingest/
```
### Usage
```bash
# Dry run (default) — chunk and count only, no Ollama calls, no DB writes:
documents-ingest-embed --dsn postgresql://kb:<pw>@piha:5433/kb
# Smoke-test slice:
documents-ingest-embed --dsn ... --apply --limit 10
# Full run:
documents-ingest-embed --dsn ... --apply
```
### Chunking (plan §2 decision 3)
Paragraph-preferring: splits on blank-line boundaries, greedily packs
paragraphs up to `--chunk-size` characters (default 2400, ≈600 tokens at a
~4 chars/token heuristic — no local bge-m3 tokenizer available offline),
`--chunk-overlap` characters of trailing context carried into the next chunk
(default 600, ≈150 tokens). A paragraph that alone exceeds `--chunk-size`
falls back to a hard character-based sliding window — Paperless OCR text has
no page-break markers (plan §1.2), so there's nothing else to split large,
unbroken text on. A document with empty OCR content (the 26 `empty_content`
documents from phase 2 step 5) yields zero chunks and is counted separately,
not as an error.
### Idempotency
A pre-fetched set of `(envelope_id, chunk_index)` pairs already embedded with
`--model` skips re-embedding on rerun — no wasted Ollama calls.
`document_chunk`'s own `UNIQUE (envelope_id, chunk_index)` +
`ON CONFLICT DO NOTHING` is the second line of defense; `insert_chunk`'s
command tag is checked so a silently-skipped row is counted as
`chunks_conflict_skipped`, never miscounted as `chunks_inserted`. Note that
uniqueness is on `(envelope_id, chunk_index)` only, not `model`
re-embedding with a *different* model hits this path and that embedding is
discarded (wasted work, correctly reported via `chunks_conflict_skipped`,
but not persisted). Out of scope for this single-model pilot; the real fix
for whoever indexes a second model later is `UNIQUE (envelope_id,
chunk_index, model)` at the schema layer.
A DB write failure for one chunk (dropped connection, unexpected bytes) is
isolated the same way an embed failure is — counted as `chunks_errors`,
never aborting the rest of the run.
### Dimension guard
Every embedding response's length is checked against
`document_chunk.embedding`'s `VECTOR(1024)` column. A mismatch raises
`EmbeddingDimensionError` and aborts the whole run immediately — never
silently indexes vectors of the wrong dimension.
### Chunk size/overlap validation
`--chunk-overlap` must be smaller than `--chunk-size` — the sliding-window
hard-split fallback advances by `chunk_size - chunk_overlap` per step, so an
overlap `>=` size would never advance and hang. `main()` rejects this
combination before opening a DB connection; `hard_split()` itself also
raises `ValueError` as a second line of defense for direct callers.
### Stats must balance
```
documents_fetched = empty_content + documents_chunked
chunks_total = chunks_already_embedded + chunks_inserted
+ chunks_conflict_skipped + chunks_errors
```
`main()` exits 1 on `chunks_errors > 0`, `chunks_conflict_skipped > 0`, or if
either balance breaks. The summary line also reports
`avg_embed_seconds_per_chunk` — CPU-only Ollama timing, the input for
deciding whether/how to scale this to the mail corpus later (plan §7).
### Tests
```bash
pip install -e packages/kb-mail/
pip install -e jobs/documents-ingest/
cd jobs/documents-ingest && pytest
```
Pure unit tests, no DB or real HTTP — `run()` is tested by monkeypatching
`asyncpg.connect` (fake connection) and `aiohttp.ClientSession` (fake session
serving a canned embedding vector, or a 500 for a chosen prompt to exercise
error isolation). Covers: chunking (paragraph boundaries, overlap, empty
document, document shorter than one chunk, oversized paragraph hard-fallback,
the overlap-must-be-smaller-than-size guard), `extract_content`, idempotency
(pre-existing keys skipped, no Ollama calls made for them, a second `--apply`
run embeds nothing new, existing keys are correctly scoped to `--model`),
dimension-mismatch abort, isolated per-chunk embed and insert errors,
`ON CONFLICT` no-ops counted separately from real inserts, and the
stats-balance invariant.
### Known limitation — Ollama context-length rejections on pathological chunks
Ollama's *runtime* context window for a model can be smaller than the
model's advertised max (bge-m3 supports 8192 tokens, but Ollama's default
`num_ctx` is lower) — and some OCR text tokenizes far more densely than the
~4-chars/token heuristic this job uses to size chunks. Concretely: a table-
of-contents page made almost entirely of dot-leader formatting
(`". . . . . . ."`, repeated hundreds of times) hit this on the pilot run —
Ollama returned `500 {"error":"the input length exceeds the context
length"}` for one 2400-char chunk that should have been well within budget
by character count alone. The job isolates this exactly like any other embed
failure (`chunks_errors`, logged, run continues), so it never crashes a
run — but it also never automatically shrinks and retries the offending
chunk. Given how rare this was (1 chunk out of 2684 in the full pilot, all
from one document's dot-leader ToC), it's left as a known gap rather than
fixed here; a real fix would be either a smaller/adaptive chunk size for
low-character-entropy text, or a shrink-and-retry loop on this specific
Ollama error.
### Definition of Done
Per `CLAUDE.md`: `pytest` passes locally (101 tests). Smoke-tested and then
run to completion live on SOLARIA against the real Ollama instance and
kb-postgres@PIHA:
- Dry-run: 186 fetched, 26 `empty_content`, 2684 chunks planned — matches
the known phase-2-step-5 figures exactly.
- `--apply --limit 10`: 64 chunks embedded, 0 errors, avg ≈0.83s/chunk on CPU.
- Re-run of the same slice: fully idempotent — 0 Ollama calls, 0 inserts.
- Full `--apply` (all 186 documents): **2683/2684 chunks inserted, 1 isolated
error** (see "Known limitation" above) — `chunks_errors=1` correctly
produced a non-zero exit rather than silently reporting success.
`document_chunk` ends at 2683 rows across 160 distinct envelopes, matching
`documents_chunked`. A `document_chunk_envelope_idx`-backed count and an
`ORDER BY embedding <=> ...` nearest-neighbor sanity query both look
correct (top match is the reference chunk itself at distance 0; next
nearest are chunks of the same source document).
- **Timing (CPU-only, no GPU driver on SOLARIA)**: ≈0.79s/chunk average
across 2683 real embeddings (2115.8s total embed time), ≈13.2s/document
average across the 160 chunked documents, ≈35 minutes wall-clock for the
full 186-document pilot. This is the real-world input for scaling this
pipeline to the much larger mail corpus later (plan §7 assumed GPU-based
"minutes for the whole pilot"; SOLARIA's Ollama currently runs CPU-only
per the recent GPU-reservation-disabled fix). The 186-document pilot's
≈13.2s/document average is dominated by Paperless' long OCR text (≈22k
chars/doc average, per plan §1.2) — 225 030 mail envelopes will have a
very different, likely much shorter, per-envelope chunk count (email
bodies vs. scanned multi-page PDFs), so this number doesn't extrapolate
directly to a mail-corpus estimate. What it does establish: at
≈0.79s/chunk sequential CPU embedding, any corpus with a non-trivial
average chunk count per item will need either a GPU driver fix,
concurrent/batched Ollama calls, or both, before a full mail-corpus run
is practical — flagged for whoever picks up the mail-indexer phase.

View file

@ -16,6 +16,7 @@ dependencies = [
[project.scripts]
documents-ingest = "documents_ingest.extractor:main"
documents-ingest-paperless = "documents_ingest.paperless_adapter:main"
documents-ingest-embed = "documents_ingest.chunk_embed:main"
[tool.setuptools.packages.find]
where = ["src"]

View file

@ -0,0 +1,426 @@
"""Chunk + embed job — module 5 phase 2, plan step 6 (docs/kb/modules/05-faza2-plan.md,
§6 step 6, decision 3).
Pipeline: `envelope(source='paperless').entities[type=content].text` -> chunk (paragraph-
preferring, ~600 tok/chunk, ~150 tok overlap, hard char-fallback for oversized paragraphs)
-> `POST /api/embeddings` (Ollama on SOLARIA, model `bge-m3`) -> `INSERT document_chunk`
(`services/kb-postgres/init/002_chunks.sql`, untouched by this change).
Scope is deliberately narrow to the pilot (plan §6 step 7): only `source='paperless'`
envelopes. Mail chunking/embedding is a later phase (plan §1.1) and will reuse this same
`chunk_text()` + `document_chunk` table, not a new pipeline.
Runs on SOLARIA (needs Ollama on localhost) against kb-postgres@PIHA over Tailscale:
Install (from repo root):
pip install -e packages/kb-mail/
pip install -e jobs/documents-ingest/
Usage:
# Dry run (default) — chunk and count, no Ollama calls, no DB writes:
documents-ingest-embed --dsn postgresql://kb:<pw>@piha:5433/kb
# Real run:
documents-ingest-embed --dsn ... --apply
# Smoke-test slice:
documents-ingest-embed --dsn ... --apply --limit 10
DSN can come from KB_DSN, Ollama URL from OLLAMA_URL (default http://localhost:11434).
Idempotency: a pre-fetched set of existing (envelope_id, chunk_index) pairs for this
`model` skips chunks already embedded no re-embedding, no wasted Ollama calls on rerun.
`insert_chunk`'s own `ON CONFLICT (envelope_id, chunk_index) DO NOTHING` is the second line
of defense; its command tag is checked so a silently-skipped row is counted as
`chunks_conflict_skipped`, never miscounted as `chunks_inserted`. Note: that UNIQUE
constraint is on (envelope_id, chunk_index) only, not including `model` re-embedding the
same pilot with a *different* model would hit this path and its embedding would be
discarded (a real chunk of work wasted, correctly reported, but not persisted). Out of
scope for this pilot (single model, bge-m3); flagged as a follow-up if/when a second model
is ever indexed (the real fix is `UNIQUE (envelope_id, chunk_index, model)` at the schema
layer).
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import re
import sys
import time
from typing import Optional
import aiohttp
import asyncpg
import structlog
_log = structlog.get_logger(__name__)
DEFAULT_OLLAMA_URL = "http://localhost:11434"
DEFAULT_MODEL = "bge-m3"
EXPECTED_DIM = 1024
# Plan decision 3: ~600 tok/chunk, ~150 tok overlap. No local bge-m3 tokenizer available
# offline, so tokens are approximated at ~4 chars/token (common heuristic) -> ~2400 chars
# target, ~600 chars overlap. Paragraph boundaries are preferred; a hard character-based
# sliding window is only used when a single paragraph alone exceeds the target (plan §1.2:
# Paperless OCR text has no page-break markers to split on instead).
CHARS_PER_TOKEN = 4
TARGET_TOKENS = 600
OVERLAP_TOKENS = 150
TARGET_CHARS = TARGET_TOKENS * CHARS_PER_TOKEN
OVERLAP_CHARS = OVERLAP_TOKENS * CHARS_PER_TOKEN
_PARA_SPLIT = re.compile(r"\n\s*\n")
_INSERT_SQL = """
INSERT INTO document_chunk (envelope_id, chunk_index, text, embedding, model)
VALUES ($1, $2, $3, $4::vector, $5)
ON CONFLICT (envelope_id, chunk_index) DO NOTHING
"""
class EmbeddingDimensionError(RuntimeError):
"""Ollama returned a vector of the wrong dimension for the target `document_chunk` schema."""
def split_paragraphs(text: str) -> list[str]:
"""Split on blank-line boundaries; drop empty fragments."""
return [p.strip() for p in _PARA_SPLIT.split(text) if p.strip()]
def hard_split(text: str, size: int = TARGET_CHARS, overlap: int = OVERLAP_CHARS) -> list[str]:
"""Fixed-size sliding window over raw characters — fallback for a paragraph too big to fit."""
if overlap >= size:
# step = size - overlap would be <= 0 below, so `start` would never advance past `n`
# and the while loop would spin forever, growing `chunks` without bound.
raise ValueError(f"chunk_overlap ({overlap}) must be smaller than chunk_size ({size})")
if len(text) <= size:
return [text]
step = size - overlap
chunks = []
start = 0
n = len(text)
while start < n:
end = min(start + size, n)
chunks.append(text[start:end])
if end == n:
break
start += step
return chunks
def chunk_text(text: str, size: int = TARGET_CHARS, overlap: int = OVERLAP_CHARS) -> list[str]:
"""Paragraph-preferring chunker with a hard character-fallback (plan §2 decision 3).
Empty/whitespace-only text (e.g. the 26 empty_content Paperless documents, plan §1.2)
returns []. A document shorter than one chunk returns exactly one chunk (the full text).
"""
text = (text or "").strip()
if not text:
return []
paragraphs = split_paragraphs(text)
chunks: list[str] = []
buffer = ""
for para in paragraphs:
if len(para) > size:
if buffer:
chunks.append(buffer)
buffer = ""
chunks.extend(hard_split(para, size, overlap))
continue
candidate = f"{buffer}\n\n{para}" if buffer else para
if len(candidate) <= size:
buffer = candidate
continue
chunks.append(buffer)
tail = buffer[-overlap:] if buffer else ""
buffer = f"{tail}\n\n{para}" if tail else para
if buffer:
chunks.append(buffer)
return chunks
def extract_content(entities: list) -> str:
"""Pull `entities[type=content].text` out of a document envelope (plan §4.2). Missing
or empty content both yield ""."""
for entity in entities or []:
if isinstance(entity, dict) and entity.get("type") == "content":
return entity.get("text") or ""
return ""
def _decode_jsonb(value: object) -> object:
"""asyncpg may return jsonb as a str or an already-decoded object."""
if value is None:
return None
if isinstance(value, str):
return json.loads(value)
return value
def _vector_literal(embedding: list[float]) -> str:
"""Render a Python float list as a pgvector input literal, e.g. '[0.1,0.2,...]'."""
return "[" + ",".join(repr(v) for v in embedding) + "]"
async def fetch_documents(conn: asyncpg.Connection, limit: Optional[int], offset: Optional[int]) -> list:
"""`source='paperless'` envelopes, ordered by id for stable --limit/--offset slicing."""
query = "SELECT id, entities FROM envelope WHERE source = 'paperless' ORDER BY id"
params: list = []
if limit is not None:
params.append(limit)
query += f" LIMIT ${len(params)}"
if offset:
params.append(offset)
query += f" OFFSET ${len(params)}"
return await conn.fetch(query, *params)
async def fetch_existing_chunk_keys(conn: asyncpg.Connection, model: str) -> set[tuple[str, int]]:
"""(envelope_id, chunk_index) pairs already embedded with this model — idempotency + dry-run preview."""
rows = await conn.fetch(
"SELECT envelope_id, chunk_index FROM document_chunk WHERE model = $1", model
)
return {(r["envelope_id"], r["chunk_index"]) for r in rows}
async def embed_chunk(
session: aiohttp.ClientSession, base_url: str, model: str, text: str
) -> tuple[list[float], float]:
"""POST /api/embeddings on Ollama for one chunk. Returns (embedding, elapsed_seconds)."""
t0 = time.monotonic()
async with session.post(f"{base_url}/api/embeddings", json={"model": model, "prompt": text}) as resp:
resp.raise_for_status()
data = await resp.json()
elapsed = time.monotonic() - t0
embedding = data.get("embedding")
if not embedding:
raise ValueError(f"ollama response missing 'embedding': {data!r}")
return embedding, elapsed
async def insert_chunk(
conn: asyncpg.Connection, envelope_id: str, chunk_index: int, text: str,
embedding: list[float], model: str,
) -> str:
"""Returns asyncpg's command tag (e.g. 'INSERT 0 1' or 'INSERT 0 0' if ON CONFLICT
DO NOTHING skipped the row) so the caller can tell a real insert from a no-op."""
return await conn.execute(
_INSERT_SQL, envelope_id, chunk_index, text, _vector_literal(embedding), model
)
def _rows_affected(command_tag: str) -> int:
"""Parse the row count out of an asyncpg INSERT command tag ('INSERT <oid> <rows>')."""
return int(command_tag.rsplit(" ", 1)[-1])
async def run(
dsn: str,
ollama_url: str = DEFAULT_OLLAMA_URL,
model: str = DEFAULT_MODEL,
limit: Optional[int] = None,
offset: Optional[int] = None,
apply: bool = False,
chunk_size: int = TARGET_CHARS,
chunk_overlap: int = OVERLAP_CHARS,
) -> dict:
"""Chunk + embed one --limit/--offset slice of `source='paperless'` envelopes.
dry-run (apply=False) chunks and counts everything no Ollama calls, no DB writes;
`chunks_inserted` reports what *would* be written (mirrors the rest of this job family).
Returns stats that must always balance:
documents_fetched = empty_content + documents_chunked
chunks_total = chunks_already_embedded + chunks_inserted
+ chunks_conflict_skipped + chunks_errors
`chunks_conflict_skipped` counts inserts where `ON CONFLICT (envelope_id, chunk_index)
DO NOTHING` silently discarded the row (the pre-fetched `existing` set is the first line
of defense against this and should make it rare; see the module docstring's note on the
UNIQUE constraint not including `model`) tracked separately so a silent no-op is never
miscounted as a successful write.
Every embedding response's dimension is checked against `EXPECTED_DIM` (raises
EmbeddingDimensionError and aborts the whole run on mismatch) never silently indexes
a vector that doesn't match the VECTOR(1024) column. A DB write failure for one chunk
(bad bytes, a dropped connection) is isolated and counted as `chunks_errors`, same as an
embed failure it never aborts the run for the rest of the slice.
"""
stats = {
"documents_fetched": 0,
"empty_content": 0,
"documents_chunked": 0,
"chunks_total": 0,
"chunks_already_embedded": 0,
"chunks_inserted": 0,
"chunks_conflict_skipped": 0,
"chunks_errors": 0,
"embed_calls": 0,
"embed_seconds_total": 0.0,
}
conn = await asyncpg.connect(dsn)
try:
docs = await fetch_documents(conn, limit, offset)
existing = await fetch_existing_chunk_keys(conn, model)
session: Optional[aiohttp.ClientSession] = None
if apply:
session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=120))
try:
for row in docs:
stats["documents_fetched"] += 1
envelope_id = row["id"]
entities = _decode_jsonb(row["entities"]) or []
content = extract_content(entities)
chunks = chunk_text(content, chunk_size, chunk_overlap)
if not chunks:
stats["empty_content"] += 1
continue
stats["documents_chunked"] += 1
for idx, chunk in enumerate(chunks):
stats["chunks_total"] += 1
key = (envelope_id, idx)
if key in existing:
stats["chunks_already_embedded"] += 1
continue
if not apply:
stats["chunks_inserted"] += 1
continue
assert session is not None
try:
embedding, elapsed = await embed_chunk(session, ollama_url, model, chunk)
except Exception:
_log.warning(
"skip.embed_error", envelope_id=envelope_id, chunk_index=idx, exc_info=True
)
stats["chunks_errors"] += 1
continue
if len(embedding) != EXPECTED_DIM:
raise EmbeddingDimensionError(
f"ollama model={model!r} returned dim={len(embedding)}, "
f"expected {EXPECTED_DIM} (document_chunk.embedding is VECTOR({EXPECTED_DIM}))"
)
stats["embed_calls"] += 1
stats["embed_seconds_total"] += elapsed
try:
command_tag = await insert_chunk(conn, envelope_id, idx, chunk, embedding, model)
except Exception:
_log.warning(
"skip.insert_error", envelope_id=envelope_id, chunk_index=idx, exc_info=True
)
stats["chunks_errors"] += 1
continue
existing.add(key)
if _rows_affected(command_tag) == 0:
_log.warning(
"chunk.conflict_skipped", envelope_id=envelope_id, chunk_index=idx, model=model
)
stats["chunks_conflict_skipped"] += 1
else:
stats["chunks_inserted"] += 1
finally:
if session is not None:
await session.close()
finally:
await conn.close()
balance_docs = stats["empty_content"] + stats["documents_chunked"]
balance_chunks = (
stats["chunks_already_embedded"] + stats["chunks_inserted"]
+ stats["chunks_conflict_skipped"] + stats["chunks_errors"]
)
if balance_docs != stats["documents_fetched"] or balance_chunks != stats["chunks_total"]:
_log.error("stats_mismatch", **stats)
_log.info("run_complete", apply=apply, model=model, **stats)
return stats
def main() -> None:
parser = argparse.ArgumentParser(
description="Chunk source='paperless' envelope content and embed via Ollama/bge-m3 "
"into document_chunk (module 5, phase 2 — plan §6 step 6)."
)
parser.add_argument("--dsn", default=os.environ.get("KB_DSN"),
help="asyncpg DSN for kb-postgres (or set KB_DSN env var)")
parser.add_argument("--ollama-url", default=os.environ.get("OLLAMA_URL", DEFAULT_OLLAMA_URL),
help=f"Ollama base URL (default: {DEFAULT_OLLAMA_URL}, or set OLLAMA_URL)")
parser.add_argument("--model", default=os.environ.get("OLLAMA_EMBED_MODEL", DEFAULT_MODEL),
help=f"Ollama embedding model (default: {DEFAULT_MODEL})")
parser.add_argument("--limit", type=int, default=None,
help="Max documents to process (default: all)")
parser.add_argument("--offset", type=int, default=0,
help="Slice offset, ordered by envelope id (default: 0)")
parser.add_argument("--chunk-size", type=int, default=TARGET_CHARS,
help=f"Target chunk size in characters (default: {TARGET_CHARS} ~= {TARGET_TOKENS} tok)")
parser.add_argument("--chunk-overlap", type=int, default=OVERLAP_CHARS,
help=f"Chunk overlap in characters (default: {OVERLAP_CHARS} ~= {OVERLAP_TOKENS} tok)")
parser.add_argument("--apply", action="store_true",
help="Actually call Ollama and insert chunks. Default is dry-run (chunk + count only).")
args = parser.parse_args()
if not args.dsn:
_log.error("missing_dsn", hint="pass --dsn or set KB_DSN")
sys.exit(1)
if args.chunk_overlap >= args.chunk_size:
_log.error(
"invalid_chunk_params", chunk_size=args.chunk_size, chunk_overlap=args.chunk_overlap,
hint="--chunk-overlap must be smaller than --chunk-size",
)
sys.exit(1)
try:
stats = asyncio.run(
run(
dsn=args.dsn,
ollama_url=args.ollama_url,
model=args.model,
limit=args.limit,
offset=args.offset,
apply=args.apply,
chunk_size=args.chunk_size,
chunk_overlap=args.chunk_overlap,
)
)
except EmbeddingDimensionError as exc:
_log.error("dim_mismatch_abort", error=str(exc))
sys.exit(1)
mode = "APPLY" if args.apply else "DRY-RUN"
avg_embed = (
stats["embed_seconds_total"] / stats["embed_calls"] if stats["embed_calls"] else 0.0
)
_log.info("summary", mode=mode, avg_embed_seconds_per_chunk=round(avg_embed, 4), **stats)
balanced = (
stats["documents_fetched"] == stats["empty_content"] + stats["documents_chunked"]
and stats["chunks_total"] == (
stats["chunks_already_embedded"] + stats["chunks_inserted"]
+ stats["chunks_conflict_skipped"] + stats["chunks_errors"]
)
)
failed = stats["chunks_errors"] > 0 or stats["chunks_conflict_skipped"] > 0 or not balanced
sys.exit(1 if failed else 0)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,455 @@
"""Unit tests for the chunk + embed job — no DB, no real HTTP, no real Ollama."""
from __future__ import annotations
import json
import pytest
from documents_ingest.chunk_embed import (
EmbeddingDimensionError,
OVERLAP_CHARS,
TARGET_CHARS,
chunk_text,
embed_chunk,
extract_content,
fetch_documents,
fetch_existing_chunk_keys,
hard_split,
run,
split_paragraphs,
_vector_literal,
)
def _paragraph(char: str, length: int) -> str:
return char * length
class TestSplitParagraphs:
def test_splits_on_blank_lines(self):
text = "first para\n\nsecond para\n\nthird para"
assert split_paragraphs(text) == ["first para", "second para", "third para"]
def test_no_blank_lines_returns_single_paragraph(self):
text = "line one\nline two\nline three"
assert split_paragraphs(text) == [text]
def test_drops_empty_fragments(self):
text = "a\n\n\n\n\n\nb"
assert split_paragraphs(text) == ["a", "b"]
class TestHardSplit:
def test_overlap_equal_to_size_raises(self):
# step = size - overlap would be 0 -> `start` never advances -> infinite loop.
with pytest.raises(ValueError):
hard_split("x" * 1000, size=100, overlap=100)
def test_overlap_greater_than_size_raises(self):
with pytest.raises(ValueError):
hard_split("x" * 1000, size=100, overlap=150)
def test_short_text_returns_single_chunk(self):
assert hard_split("short", size=100, overlap=10) == ["short"]
def test_splits_with_overlap(self):
text = "A" * 5000
chunks = hard_split(text, size=2400, overlap=600)
assert len(chunks) == 3
# consecutive windows overlap by exactly `overlap` characters
assert chunks[0][-600:] == chunks[1][:600]
assert chunks[1][-600:] == chunks[2][:600]
# covers the full text, in order, no gaps
assert chunks[0] + chunks[1][600:] + chunks[2][600:] == text
def test_last_chunk_reaches_end_of_text(self):
text = "B" * 5000
chunks = hard_split(text, size=2400, overlap=600)
assert chunks[-1] == text[-len(chunks[-1]):]
assert text.endswith(chunks[-1])
class TestChunkText:
def test_empty_content_returns_no_chunks(self):
assert chunk_text("") == []
assert chunk_text(None) == []
assert chunk_text(" \n\n ") == []
def test_document_shorter_than_one_chunk_returns_single_chunk(self):
text = "A short document."
assert chunk_text(text, size=TARGET_CHARS, overlap=OVERLAP_CHARS) == [text]
def test_multi_paragraph_document_splits_on_boundaries(self):
para1 = _paragraph("a", 1000)
para2 = _paragraph("b", 1000)
para3 = _paragraph("c", 1000)
text = f"{para1}\n\n{para2}\n\n{para3}"
chunks = chunk_text(text, size=2400, overlap=600)
assert len(chunks) == 2
assert para1 in chunks[0]
assert para2 in chunks[0]
assert para3 in chunks[-1]
def test_overlap_between_consecutive_chunks(self):
para1 = _paragraph("a", 1000)
para2 = _paragraph("b", 1000)
para3 = _paragraph("c", 1000)
text = f"{para1}\n\n{para2}\n\n{para3}"
chunks = chunk_text(text, size=2400, overlap=600)
# the tail of chunk[0] (the overlap window) reappears at the start of chunk[1]
assert chunks[0][-600:] == chunks[1][: len(chunks[0][-600:])]
def test_single_oversized_paragraph_falls_back_to_hard_split(self):
text = "X" * 6000 # no blank lines at all
chunks = chunk_text(text, size=2400, overlap=600)
assert len(chunks) > 1
assert all(len(c) <= 2400 for c in chunks)
def test_paragraph_larger_than_target_is_hard_split_within_mixed_document(self):
small = _paragraph("s", 100)
huge = _paragraph("h", 6000)
text = f"{small}\n\n{huge}"
chunks = chunk_text(text, size=2400, overlap=600)
assert chunks[0] == small
assert len(chunks) > 2
assert all(len(c) <= 2400 for c in chunks[1:])
class TestExtractContent:
def test_finds_content_entity(self):
entities = [{"type": "filename", "value": "a.pdf"}, {"type": "content", "text": "hello"}]
assert extract_content(entities) == "hello"
def test_missing_content_entity_returns_empty_string(self):
entities = [{"type": "filename", "value": "a.pdf"}]
assert extract_content(entities) == ""
def test_none_text_returns_empty_string(self):
entities = [{"type": "content", "text": None}]
assert extract_content(entities) == ""
def test_empty_entities_list(self):
assert extract_content([]) == ""
assert extract_content(None) == ""
class TestVectorLiteral:
def test_formats_as_bracketed_csv(self):
assert _vector_literal([0.1, 0.2, -0.3]) == "[0.1,0.2,-0.3]"
class _FakeEmbedResponse:
def __init__(self, payload, status=200):
self._payload = payload
self._status = status
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
return False
async def json(self):
return self._payload
def raise_for_status(self):
if self._status >= 400:
raise RuntimeError(f"HTTP {self._status}")
class _FakeOllamaSession:
"""Serves a fixed embedding vector for every /api/embeddings POST, or errors by filename."""
def __init__(self, dim=1024, fail_for=None):
self._dim = dim
self._fail_for = fail_for or set()
self.requests: list[dict] = []
def post(self, url, json):
self.requests.append({"url": url, "json": json})
if json["prompt"] in self._fail_for:
return _FakeEmbedResponse({}, status=500)
return _FakeEmbedResponse({"embedding": [0.01] * self._dim})
async def close(self):
pass
class TestEmbedChunk:
async def test_returns_embedding_and_elapsed(self):
session = _FakeOllamaSession(dim=1024)
embedding, elapsed = await embed_chunk(session, "http://fake-ollama", "bge-m3", "hello world")
assert len(embedding) == 1024
assert elapsed >= 0
assert session.requests == [
{"url": "http://fake-ollama/api/embeddings", "json": {"model": "bge-m3", "prompt": "hello world"}}
]
async def test_missing_embedding_key_raises(self):
class _EmptyResponse(_FakeEmbedResponse):
pass
class _Session:
def post(self, url, json):
return _EmptyResponse({})
with pytest.raises(ValueError):
await embed_chunk(_Session(), "http://fake-ollama", "bge-m3", "text")
class _FakeConn:
def __init__(self, docs=None, existing_keys=None, existing_model="bge-m3", execute_results=None):
self._docs = docs or []
self._existing_keys = list(existing_keys or [])
self._existing_model = existing_model
# Optional queue of command tags returned by successive execute() calls, in order
# (e.g. ["INSERT 0 0"] to simulate an ON CONFLICT DO NOTHING no-op). Defaults to a
# real insert every time.
self._execute_results = list(execute_results) if execute_results is not None else None
self.execute_calls: list[tuple] = []
async def fetch(self, query, *params):
if "FROM document_chunk" in query:
# Mirrors the real `WHERE model = $1` filter: existing_keys were "written" under
# existing_model, so a query for a different model must not see them.
if params and params[0] != self._existing_model:
return []
return [{"envelope_id": eid, "chunk_index": idx} for eid, idx in self._existing_keys]
return self._docs
async def execute(self, query, *params):
self.execute_calls.append(params)
if self._execute_results is not None:
return self._execute_results.pop(0)
return "INSERT 0 1"
async def close(self):
pass
def _row(envelope_id, content):
return {"id": envelope_id, "entities": json.dumps([{"type": "content", "text": content}])}
class TestFetchHelpers:
async def test_fetch_documents_no_limit_offset(self):
conn = _FakeConn(docs=[_row("paperless:1", "hi")])
docs = await fetch_documents(conn, limit=None, offset=None)
assert docs == [_row("paperless:1", "hi")]
async def test_fetch_existing_chunk_keys(self):
conn = _FakeConn(existing_keys=[("paperless:1", 0), ("paperless:1", 1)])
keys = await fetch_existing_chunk_keys(conn, model="bge-m3")
assert keys == {("paperless:1", 0), ("paperless:1", 1)}
class TestRun:
def _patch(self, monkeypatch, conn, ollama_session):
async def _fake_connect(dsn):
return conn
monkeypatch.setattr("documents_ingest.chunk_embed.asyncpg.connect", _fake_connect)
def _fake_session_factory(*args, **kwargs):
return ollama_session
monkeypatch.setattr("documents_ingest.chunk_embed.aiohttp.ClientSession", _fake_session_factory)
async def test_dry_run_counts_without_calling_ollama_or_db(self, monkeypatch):
conn = _FakeConn(docs=[_row("paperless:1", "short doc")])
ollama = _FakeOllamaSession()
self._patch(monkeypatch, conn, ollama)
stats = await run(dsn="postgresql://fake", apply=False)
assert stats["documents_fetched"] == 1
assert stats["documents_chunked"] == 1
assert stats["chunks_total"] == 1
assert stats["chunks_inserted"] == 1
assert ollama.requests == []
assert conn.execute_calls == []
async def test_empty_content_counted_separately_not_as_error(self, monkeypatch):
conn = _FakeConn(docs=[_row("paperless:1", ""), _row("paperless:2", "some text")])
ollama = _FakeOllamaSession()
self._patch(monkeypatch, conn, ollama)
stats = await run(dsn="postgresql://fake", apply=False)
assert stats["empty_content"] == 1
assert stats["documents_chunked"] == 1
assert stats["documents_fetched"] == 2
assert stats["chunks_errors"] == 0
async def test_apply_embeds_and_inserts(self, monkeypatch):
conn = _FakeConn(docs=[_row("paperless:1", "some real content")])
ollama = _FakeOllamaSession(dim=1024)
self._patch(monkeypatch, conn, ollama)
stats = await run(dsn="postgresql://fake", apply=True)
assert stats["chunks_inserted"] == 1
assert stats["embed_calls"] == 1
assert len(conn.execute_calls) == 1
assert len(ollama.requests) == 1
async def test_idempotent_skips_already_embedded_chunks(self, monkeypatch):
conn = _FakeConn(
docs=[_row("paperless:1", "some real content")],
existing_keys=[("paperless:1", 0)],
)
ollama = _FakeOllamaSession(dim=1024)
self._patch(monkeypatch, conn, ollama)
stats = await run(dsn="postgresql://fake", apply=True)
assert stats["chunks_already_embedded"] == 1
assert stats["chunks_inserted"] == 0
assert ollama.requests == []
assert conn.execute_calls == []
async def test_rerun_after_apply_inserts_nothing_new(self, monkeypatch):
doc = _row("paperless:1", "some real content")
conn1 = _FakeConn(docs=[doc])
ollama1 = _FakeOllamaSession(dim=1024)
self._patch(monkeypatch, conn1, ollama1)
first = await run(dsn="postgresql://fake", apply=True)
assert first["chunks_inserted"] == 1
conn2 = _FakeConn(docs=[doc], existing_keys=[("paperless:1", 0)])
ollama2 = _FakeOllamaSession(dim=1024)
self._patch(monkeypatch, conn2, ollama2)
second = await run(dsn="postgresql://fake", apply=True)
assert second["chunks_inserted"] == 0
assert second["chunks_already_embedded"] == 1
assert ollama2.requests == []
async def test_dimension_mismatch_aborts(self, monkeypatch):
conn = _FakeConn(docs=[_row("paperless:1", "some real content")])
ollama = _FakeOllamaSession(dim=768) # wrong dim
self._patch(monkeypatch, conn, ollama)
with pytest.raises(EmbeddingDimensionError):
await run(dsn="postgresql://fake", apply=True)
async def test_embed_error_is_isolated_and_counted(self, monkeypatch):
# Two chunks: force one to fail via a document long enough to produce 2 chunks
# (chunk_size/overlap kept small to make the test fast and explicit).
big = "a" * 100 + "\n\n" + "b" * 100
conn = _FakeConn(docs=[_row("paperless:1", big)])
ollama = _FakeOllamaSession(dim=1024, fail_for={"a" * 100})
self._patch(monkeypatch, conn, ollama)
stats = await run(dsn="postgresql://fake", apply=True, chunk_size=100, chunk_overlap=20)
assert stats["chunks_errors"] == 1
assert stats["chunks_inserted"] == 1
assert stats["chunks_total"] == (
stats["chunks_already_embedded"] + stats["chunks_inserted"]
+ stats["chunks_conflict_skipped"] + stats["chunks_errors"]
)
async def test_insert_error_is_isolated_and_counted(self, monkeypatch):
# Two chunks; the first chunk's INSERT raises (simulated DB blip) but the run
# continues and the second chunk still gets embedded and inserted normally.
big = "a" * 100 + "\n\n" + "b" * 100
conn = _FakeConn(docs=[_row("paperless:1", big)])
real_execute = conn.execute
async def _flaky_execute(query, *params):
if params[1] == 0: # chunk_index 0
conn.execute_calls.append(params)
raise RuntimeError("simulated db error")
return await real_execute(query, *params)
conn.execute = _flaky_execute
ollama = _FakeOllamaSession(dim=1024)
self._patch(monkeypatch, conn, ollama)
stats = await run(dsn="postgresql://fake", apply=True, chunk_size=100, chunk_overlap=20)
assert stats["chunks_errors"] == 1
assert stats["chunks_inserted"] == 1
assert stats["chunks_total"] == (
stats["chunks_already_embedded"] + stats["chunks_inserted"]
+ stats["chunks_conflict_skipped"] + stats["chunks_errors"]
)
async def test_conflict_skipped_counted_separately_from_inserted(self, monkeypatch):
# ON CONFLICT DO NOTHING no-op: command tag reports 0 rows affected.
conn = _FakeConn(
docs=[_row("paperless:1", "some real content")], execute_results=["INSERT 0 0"]
)
ollama = _FakeOllamaSession(dim=1024)
self._patch(monkeypatch, conn, ollama)
stats = await run(dsn="postgresql://fake", apply=True)
assert stats["chunks_conflict_skipped"] == 1
assert stats["chunks_inserted"] == 0
assert stats["chunks_total"] == (
stats["chunks_already_embedded"] + stats["chunks_inserted"]
+ stats["chunks_conflict_skipped"] + stats["chunks_errors"]
)
async def test_existing_keys_scoped_to_model(self, monkeypatch):
# existing_keys were "written" under a different model — the WHERE model = $1
# filter must not treat them as already-embedded for the current --model.
conn = _FakeConn(
docs=[_row("paperless:1", "some real content")],
existing_keys=[("paperless:1", 0)],
existing_model="some-other-model",
)
ollama = _FakeOllamaSession(dim=1024)
self._patch(monkeypatch, conn, ollama)
stats = await run(dsn="postgresql://fake", apply=True, model="bge-m3")
assert stats["chunks_already_embedded"] == 0
assert stats["chunks_inserted"] == 1
async def test_stats_balance_documents_and_chunks(self, monkeypatch):
conn = _FakeConn(docs=[
_row("paperless:1", "content one"),
_row("paperless:2", ""),
_row("paperless:3", "content three"),
])
ollama = _FakeOllamaSession(dim=1024)
self._patch(monkeypatch, conn, ollama)
stats = await run(dsn="postgresql://fake", apply=True)
assert stats["documents_fetched"] == stats["empty_content"] + stats["documents_chunked"]
assert stats["chunks_total"] == (
stats["chunks_already_embedded"] + stats["chunks_inserted"]
+ stats["chunks_conflict_skipped"] + stats["chunks_errors"]
)
async def test_limit_and_offset_passed_through_to_query(self, monkeypatch):
captured = {}
class _Conn(_FakeConn):
async def fetch(self, query, *params):
if "FROM document_chunk" in query:
return []
captured["query"] = query
captured["params"] = params
return []
conn = _Conn()
ollama = _FakeOllamaSession()
self._patch(monkeypatch, conn, ollama)
await run(dsn="postgresql://fake", apply=False, limit=10, offset=5)
assert "LIMIT $1" in captured["query"]
assert "OFFSET $2" in captured["query"]
assert captured["params"] == (10, 5)