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:
parent
5218925408
commit
09bb59a1db
|
|
@ -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
|
without operator confirmation (this job reads production Paperless data and
|
||||||
writes production envelope rows on `--apply`). `pytest` passes locally before
|
writes production envelope rows on `--apply`). `pytest` passes locally before
|
||||||
this commit.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ dependencies = [
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
documents-ingest = "documents_ingest.extractor:main"
|
documents-ingest = "documents_ingest.extractor:main"
|
||||||
documents-ingest-paperless = "documents_ingest.paperless_adapter:main"
|
documents-ingest-paperless = "documents_ingest.paperless_adapter:main"
|
||||||
|
documents-ingest-embed = "documents_ingest.chunk_embed:main"
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
where = ["src"]
|
where = ["src"]
|
||||||
|
|
|
||||||
426
jobs/documents-ingest/src/documents_ingest/chunk_embed.py
Normal file
426
jobs/documents-ingest/src/documents_ingest/chunk_embed.py
Normal 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()
|
||||||
455
jobs/documents-ingest/tests/test_chunk_embed.py
Normal file
455
jobs/documents-ingest/tests/test_chunk_embed.py
Normal 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)
|
||||||
Loading…
Reference in a new issue