Ingestion¶
Knowledge base ingestion pipeline: document reading, per-format extractors, cleaning, token-accurate chunking, embeddings and the pgvector store.
src.ingestion
¶
Document ingestion pipeline (extraction → cleaning → chunking → embeddings).
Public surface
DocumentReader— the extraction façade services talk to.ExtractedDocument/ExtractedPage— its return types.
DocumentReader()
¶
Route a file to its extractor and return an ExtractedDocument.
Source code in backend/src/ingestion/reader.py
read(path)
¶
Extract one document. Raises InvalidInputException on
unsupported extensions; never raises on images (pending_vision).
Source code in backend/src/ingestion/reader.py
ExtractedDocument(markdown, status, pages=None, metadata=dict())
dataclass
¶
What DocumentReader.read returns — the only shape services see.
Attributes:
| Name | Type | Description |
|---|---|---|
markdown |
str
|
Full cleaned Markdown content ("" when pending_vision). |
status |
str
|
"active" (readable now) or "pending_vision" (image / scanned PDF accepted but awaiting the OCR/VLM tiers of a later release). |
pages |
list[ExtractedPage] | None
|
Per-page texts for paginated formats (PDF), else None. Lets the chunker carry page numbers into chunk metadata. |
metadata |
dict
|
Extractor name, page counts, pending reasons, … |
ExtractedPage(page_number, text)
dataclass
¶
One physical page of a paginated document (PDF).
src.ingestion.chunking
¶
3-pass Markdown chunking, token-accurate against the real e5 tokenizer.
Ported from the R&D ingestion POC and re-targeted at multilingual-e5-small:
- Split on Markdown headers (h1–h4) — chunks follow document structure.
- Sub-split large sections with a token-accurate recursive splitter (separators favor paragraph > line > sentence > word boundaries). The FAISS-era chunker estimated ~3 chars/token against a model truncating at 128 tokens — two thirds of every chunk was invisible to dense retrieval.
- Prefix each chunk with its heading breadcrumb ("# A > ## B") and re-attach Markdown table headers lost mid-split.
Budget [N6]: the full embedded text is
passage: [document_name:NAME]\n<breadcrumb>\n\n<chunk>; with the
default target (~180 tokens) plus breadcrumb and name the total stays far
inside e5's 512-token window. The R&D sweeps found 150–200-token chunks the
most consistent — big chunks measurably HURT dense retrieval.
Chunk(chunk_index, text, token_count, page_number=None)
dataclass
¶
One retrieval unit, ready for build_embedding_text + embedding.
chunk_document(document, *, target_tokens=DEFAULT_TARGET_TOKENS, overlap_tokens=DEFAULT_OVERLAP_TOKENS)
¶
Chunk an ExtractedDocument — page by page when paginated (PDF),
so each chunk carries its page_number; whole-Markdown otherwise.
pending_vision documents yield no chunks (indexed by the OCR/VLM
tiers of a later release).
Source code in backend/src/ingestion/chunking.py
chunk_markdown(text, *, target_tokens=DEFAULT_TARGET_TOKENS, overlap_tokens=DEFAULT_OVERLAP_TOKENS, page_number=None, start_index=0)
¶
Run the 3-pass chunker over one Markdown text. See module docstring.
Source code in backend/src/ingestion/chunking.py
count_tokens(text)
¶
Real e5 subword count (no special tokens) — never a chars/N estimate.
src.ingestion.cleaning
¶
Non-destructive text cleaning for extracted documents (D4).
The FAISS-era cleaner stripped accents and non-ASCII characters, which made the indexed corpus diverge from raw user queries and would have killed the sparse (tsvector) branch of hybrid retrieval outright. This cleaner only removes what carries no meaning:
- Unicode NFC normalization (precompose combining accents),
- NUL bytes (PostgreSQL text columns reject them) and other control/format characters — newlines and tabs excepted,
- PDF end-of-line hyphenation rejoined ("informa-\ntion" → "information"),
- whitespace collapsed (runs of spaces/tabs → one space, 3+ newlines → a paragraph break).
Accents, currency signs, CJK — everything meaningful — are PRESERVED.
clean_extracted_text(text)
¶
Clean extracted text without destroying meaning. See module docstring.
Source code in backend/src/ingestion/cleaning.py
src.ingestion.embedding_model
¶
On-demand availability + download of the KB embedding model (#146).
The Knowledge Base needs intfloat/multilingual-e5-small for BOTH embeddings
(E5Embeddings) and token-accurate chunking (chunking._get_tokenizer).
Both load it from config.CACHE_DIR (data/models_cache), so a single
download serves both consumers.
This module gates that download behind an explicit user action instead of the implicit lazy Hugging Face fallback, so a fresh/offline install no longer fails its first Knowledge Base use silently.
Design invariants:
- Presence is filesystem-driven, never a DB flag. embedding_model_available
checks EVERY file a SentenceTransformer load needs (#164) — huggingface_hub
only links a file into snapshots/ once it is fully downloaded, so a
partial/interrupted download reads as unavailable and the gate re-appears.
This also makes the state self-heal across backend restarts (in-memory flags
are lost, the file check is not).
- A complete cache means OFFLINE loads (#164). Without local_files_only=True
huggingface_hub HEAD-revalidates every file against the hub on each load —
offline, the failed DNS probe surfaces as a bogus load error despite a fully
pre-downloaded model, defeating the whole point of the gate. Consumers
(E5Embeddings, chunking._get_tokenizer) apply the same rule.
- Download == the real load path (SentenceTransformer(..., cache_folder=CACHE_DIR)),
not a parallel snapshot_download — so the download target is ISO with where
the app loads from, by construction, and it warms the resident singleton.
- The download runs in a background thread, decoupled from the HTTP request,
so leaving the KB page never loses it; the UI polls a boolean status.
download_state()
¶
The gate's view: on-disk presence + transient background-task flags.
embedding_model_available()
¶
True iff EVERY required file is fully present in CACHE_DIR (no network).
Source code in backend/src/ingestion/embedding_model.py
load_sentence_transformer(model_name, *, cache_folder, local_files_only)
¶
Load a SentenceTransformer on the best device the machine can actually use.
Single entry point for every embedding-model load (the #146 download gate
and the resident E5Embeddings singleton), so the MPS fallback is decided
once and applies to both.
Source code in backend/src/ingestion/embedding_model.py
resolve_embedding_device()
¶
Device for the embedding model. None = let sentence-transformers pick.
Apple Silicon is the primary desktop platform, so MPS stays preferred
whenever Metal genuinely works. is_available() is only a support flag
though: a virtualised macOS host (the macos-14 CI runner) answers True
and then refuses the model's ~366 MiB allocation, and a real Mac under
memory pressure fails the same way (#335). load_sentence_transformer
turns that failure into this cached CPU verdict.
Off Apple Silicon the choice is left untouched (None), so a CUDA host
keeps auto-selecting its GPU.
Source code in backend/src/ingestion/embedding_model.py
start_download()
¶
Idempotently kick off a background download. Returns the current state.
No-op (returns current state) if the model is already present or a download is already running — guards double-clicks and concurrent callers.
Source code in backend/src/ingestion/embedding_model.py
src.ingestion.embeddings
¶
E5Embeddings — resident multilingual-e5-small behind the LangChain
Embeddings interface.
multilingual-e5-small (384d, MIT, 512-token window) REQUIRES asymmetric
prefixes: query: for searches, passage: for indexed chunks —
without them retrieval quality collapses. sentence-transformers handles
mean pooling; vectors are L2-normalized so cosine similarity is a plain
dot product (pgvector COSINE / HNSW friendly).
The model is a RESIDENT class-level singleton: the FAISS-era embedder reloaded ~470 MB from disk on every single operation and freed it after.
Embedded-text format [N6], frozen:
passage: [document_name:<file>]\n<breadcrumb>\n\n<chunk>
(passage: added here; [document_name:…] by build_embedding_text;
breadcrumb by the chunker.)
E5Embeddings
¶
Bases: Embeddings
LangChain Embeddings over a resident e5-small singleton.
embed_documents(texts)
¶
Embed indexed chunks (passage: prefix, L2-normalized).
Source code in backend/src/ingestion/embeddings.py
embed_query(text)
¶
Embed a search query (query: prefix, L2-normalized).
Source code in backend/src/ingestion/embeddings.py
build_embedding_text(*, file_name, chunk_text)
¶
Prefix the chunk with its source document name (R&D POC format).
src.ingestion.markdown
¶
Markdown rendering helpers shared by the Tier-0 extractors.
rows_to_markdown_table(rows)
¶
Render rows as a GitHub-style Markdown table (first row = header).
Cells are flattened (newlines → spaces) and pipes escaped so the table survives the Markdown-header chunking pass intact.
Source code in backend/src/ingestion/markdown.py
src.ingestion.reader
¶
DocumentReader — the single extraction surface seen by services.
Two-dimensional routing
- By document TYPE (extension): pdf, docx, xlsx, csv, txt, md → the
matching Tier-0 deterministic extractor; png/jpg/jpeg/webp → accepted
as
pending_vision(no OCR/VLM tier bundled yet). - By DIFFICULTY, inside the PDF extractor: native text layer →
deterministic fast-path; scanned →
pending_vision.
Services stay 100 % agnostic: read(path) -> ExtractedDocument is the
whole contract. Later OCR/VLM tiers register as additional extractors on
this façade without touching any caller.
Naming note: "Engine" is reserved for inference engines in Erudi — this is a reader backed by extractors.
DocumentReader()
¶
Route a file to its extractor and return an ExtractedDocument.
Source code in backend/src/ingestion/reader.py
read(path)
¶
Extract one document. Raises InvalidInputException on
unsupported extensions; never raises on images (pending_vision).
Source code in backend/src/ingestion/reader.py
src.ingestion.types
¶
Shared datatypes for the document ingestion pipeline.
ExtractedDocument(markdown, status, pages=None, metadata=dict())
dataclass
¶
What DocumentReader.read returns — the only shape services see.
Attributes:
| Name | Type | Description |
|---|---|---|
markdown |
str
|
Full cleaned Markdown content ("" when pending_vision). |
status |
str
|
"active" (readable now) or "pending_vision" (image / scanned PDF accepted but awaiting the OCR/VLM tiers of a later release). |
pages |
list[ExtractedPage] | None
|
Per-page texts for paginated formats (PDF), else None. Lets the chunker carry page numbers into chunk metadata. |
metadata |
dict
|
Extractor name, page counts, pending reasons, … |
ExtractedPage(page_number, text)
dataclass
¶
One physical page of a paginated document (PDF).
src.ingestion.vector_store
¶
Hybrid KB vector store — rag.kb_chunks via langchain-postgres.
One PGVectorStore-managed table for ALL knowledge bases, filtered by the
typed kb_id column at query time (D2). Retrieval is hybrid from day
one (D3): dense HNSW (cosine, e5 vectors) + sparse tsvector
(pg_catalog.simple — language-neutral, no stemming, exact IDs intact;
the dense branch compensates) fused by Reciprocal Rank Fusion (k=60,
rank-only algebra — NOT a reranker model).
Lifecycle: init_kb_store(handle) runs in the FastAPI lifespan AFTER
create_tables (the cross-schema FKs below reference the business
tables) and the resident store is reached through module-level accessors
(same pattern as config.LLM_Engine). close_kb_store() runs on
shutdown BEFORE the cluster stops.
⚠ langchain-postgres 0.0.17 bug: asimilarity_search writes the first
query's fts_query onto the SHARED HybridSearchConfig — every later
sparse search reuses the first query's tsquery. search_kb_chunks_scored
works around it by passing a FRESH config (with the right fts_query)
on every call. Do not "simplify" this away.
add_kb_chunks(*, kb_id, document_id, source_file, chunks)
¶
Embed and store one document's chunks (sync — delegates to the PGEngine's internal loop; the P0bis-validated ingestion bridge).
The [document_name:…] prefix is EMBEDDING-time text only (it boosts
retrieval, POC pattern): vectors are computed over the prefixed text,
but the STORED content is the clean chunk — it goes verbatim into the
LLM prompt at generation time, and small models loop on the bracketed
prefix (live-E2E finding).
Source code in backend/src/ingestion/vector_store.py
close_kb_store()
¶
Release the store's PGEngine (lifespan shutdown, before cluster stop).
Source code in backend/src/ingestion/vector_store.py
get_kb_store()
¶
The resident store (set by the lifespan via init_kb_store).
Source code in backend/src/ingestion/vector_store.py
init_kb_store(handle)
¶
Create/join rag.kb_chunks and build the resident hybrid store.
Idempotent: safe on every boot (schema/table/FKs/HNSW all guarded). Must run AFTER the business tables exist (cross-schema FKs).
Source code in backend/src/ingestion/vector_store.py
search_kb_chunks_scored(query, *, kb_id, pool_k=POOL_K)
¶
Hybrid candidate pool over one KB: documents in RRF order, each with its calibrated dense cosine similarity.
The fusion overwrites per-row scores with RRF harmonics (rank algebra, no semantic scale), so the dense similarity is re-read from the stored vectors — that is what the adaptive cut upstream operates on. Fresh config per call (lib bug — see module docstring); the query is embedded once and shared by the search and the similarity read.
Source code in backend/src/ingestion/vector_store.py
Extractors¶
src.ingestion.extractors
¶
Tier-0 deterministic extractors (one per document format).
Each extractor exposes extract(path) -> ExtractedDocument and is
registered on the DocumentReader façade by extension. The OCR/VLM tiers
of a later release plug in as additional extractors on the same façade.
src.ingestion.extractors.docx
¶
DOCX extractor — python-docx, structure-preserving.
Heading styles map to Markdown headers (Heading 1 → # … Heading 4 →
####) so the 3-pass chunker can split on real document structure;
tables become Markdown tables. Paragraphs and tables are walked in document
order via Document.iter_inner_content.
src.ingestion.extractors.pdf
¶
PDF extractor — deterministic fast-path over the native text layer.
Difficulty routing happens HERE (second dimension of the reader's 2-D
routing): a PDF with a usable text layer is extracted page by page with
pypdf; a scanned PDF (no/negligible text layer) is accepted as
pending_vision — zero chunks now, indexed by the OCR/VLM tiers of a
later release.
src.ingestion.extractors.tabular
¶
CSV extractor — stdlib csv with dialect sniffing → one Markdown table.
src.ingestion.extractors.text
¶
Plain-text / Markdown extractor.
.md files are already the pipeline's pivot format — cleaned passthrough
(headers intact for the chunker). .txt gets the same non-destructive
cleaning. UTF-8 first, latin-1 fallback for legacy files.
src.ingestion.extractors.xlsx
¶
XLSX extractor — openpyxl, every sheet, computed values.
Each worksheet becomes a ## <sheet name> section holding one Markdown
table (first non-empty row = header). data_only=True reads cached
formula RESULTS, not formula strings.