homelab-codex-ws/jobs/documents-ingest/src/documents_ingest/chunk_embed.py

427 lines
17 KiB
Python
Raw Normal View History

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>
2026-07-15 20:41:00 +02:00
"""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()