Skip to content

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
def __init__(self) -> None:
    text_extractor = TextExtractor()
    self._extractors = {
        ".pdf": PdfExtractor(),
        ".docx": DocxExtractor(),
        ".xlsx": XlsxExtractor(),
        ".csv": CsvExtractor(),
        ".txt": text_extractor,
        ".md": text_extractor,
    }

read(path)

Extract one document. Raises InvalidInputException on unsupported extensions; never raises on images (pending_vision).

Source code in backend/src/ingestion/reader.py
def read(self, path: Path | str) -> ExtractedDocument:
    """Extract one document. Raises ``InvalidInputException`` on
    unsupported extensions; never raises on images (``pending_vision``)."""
    path = Path(path)
    extension = path.suffix.lower()

    if extension in IMAGE_EXTENSIONS:
        logger.info(
            f"Document read: {path.name} (format={extension}, "
            f"status=pending_vision, chars=0)"
        )
        return ExtractedDocument(
            markdown="",
            status="pending_vision",
            metadata={
                "extractor": "image",
                "reason": "image awaiting OCR/VLM tier",
            },
        )

    extractor = self._extractors.get(extension)
    if extractor is None:
        supported = ", ".join(sorted(self.supported_extensions))
        raise InvalidInputException(
            f"Unsupported document type '{extension}' ({path.name}). " f"Supported: {supported}"
        )

    document = extractor.extract(path)
    logger.info(
        f"Document read: {path.name} (format={extension}, "
        f"status={document.status}, chars={len(document.markdown)})"
    )
    return document

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:

  1. Split on Markdown headers (h1–h4) — chunks follow document structure.
  2. 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.
  3. 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
def chunk_document(
    document: ExtractedDocument,
    *,
    target_tokens: int = DEFAULT_TARGET_TOKENS,
    overlap_tokens: int = DEFAULT_OVERLAP_TOKENS,
) -> list[Chunk]:
    """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).
    """
    if document.status != "active":
        return []

    if document.pages:
        chunks: list[Chunk] = []
        for page in document.pages:
            chunks.extend(
                chunk_markdown(
                    page.text,
                    target_tokens=target_tokens,
                    overlap_tokens=overlap_tokens,
                    page_number=page.page_number,
                    start_index=len(chunks),
                )
            )
    else:
        chunks = chunk_markdown(
            document.markdown,
            target_tokens=target_tokens,
            overlap_tokens=overlap_tokens,
        )

    if chunks:
        avg_tokens = sum(chunk.token_count for chunk in chunks) / len(chunks)
        logger.info(
            f"Document chunked: {len(chunks)} chunks, "
            f"avg {avg_tokens:.0f} tokens/chunk "
            f"(extractor={document.metadata.get('extractor', '?')})"
        )
    else:
        logger.debug(
            f"Document chunked: 0 chunks " f"(extractor={document.metadata.get('extractor', '?')})"
        )
    return chunks

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
def chunk_markdown(
    text: str,
    *,
    target_tokens: int = DEFAULT_TARGET_TOKENS,
    overlap_tokens: int = DEFAULT_OVERLAP_TOKENS,
    page_number: int | None = None,
    start_index: int = 0,
) -> list[Chunk]:
    """Run the 3-pass chunker over one Markdown text. See module docstring."""
    if not text or not text.strip():
        return []

    # PostgreSQL text columns reject NUL bytes; some parsers emit them.
    text = text.replace("\x00", "")

    from langchain_text_splitters import MarkdownHeaderTextSplitter

    md_splitter = MarkdownHeaderTextSplitter(
        headers_to_split_on=HEADERS_TO_SPLIT_ON,
        strip_headers=False,
    )
    sub_splitter = _make_sub_splitter(target_tokens, overlap_tokens)

    chunks: list[Chunk] = []
    for section in md_splitter.split_text(text):
        breadcrumb = _heading_breadcrumb(section.metadata)

        sub_texts = sub_splitter.split_text(section.page_content)
        sub_texts = _reattach_table_headers(sub_texts)

        for sub_text in sub_texts:
            chunk_text = f"{breadcrumb}\n\n{sub_text}" if breadcrumb else sub_text

            chunks.append(
                Chunk(
                    chunk_index=start_index + len(chunks),
                    text=chunk_text,
                    token_count=count_tokens(chunk_text),
                    page_number=page_number,
                )
            )

    return chunks

count_tokens(text)

Real e5 subword count (no special tokens) — never a chars/N estimate.

Source code in backend/src/ingestion/chunking.py
def count_tokens(text: str) -> int:
    """Real e5 subword count (no special tokens) — never a chars/N estimate."""
    return len(_get_tokenizer().encode(text, add_special_tokens=False))

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
def clean_extracted_text(text: str) -> str:
    """Clean extracted text without destroying meaning. See module docstring."""
    if not text:
        return ""
    text = unicodedata.normalize("NFC", text)
    text = text.replace("\r\n", "\n").replace("\r", "\n")
    text = _HYPHENATION_RE.sub(r"\1", text)
    text = "".join(ch for ch in text if _keep(ch))
    text = _HORIZONTAL_WS_RE.sub(" ", text)
    text = _NEWLINE_TRIM_RE.sub("\n", text)
    text = _PARAGRAPH_RE.sub("\n\n", text)
    return text.strip()

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.

Source code in backend/src/ingestion/embedding_model.py
def download_state() -> dict:
    """The gate's view: on-disk presence + transient background-task flags."""
    return {
        "available": embedding_model_available(),
        "downloading": _state["downloading"],
        "error": _state["error"],
    }

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
def embedding_model_available() -> bool:
    """True iff EVERY required file is fully present in ``CACHE_DIR`` (no network)."""
    for filename in REQUIRED_FILES:
        cached = try_to_load_from_cache(
            EMBEDDING_MODEL_ID, filename, cache_dir=str(config.CACHE_DIR)
        )
        if not isinstance(cached, str):
            return False
    return True

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
def load_sentence_transformer(model_name: str, *, cache_folder: str, local_files_only: bool):
    """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.
    """
    global _MPS_UNUSABLE
    from sentence_transformers import SentenceTransformer

    device = resolve_embedding_device()
    try:
        return SentenceTransformer(
            model_name,
            cache_folder=cache_folder,
            local_files_only=local_files_only,
            device=device,
        )
    except RuntimeError as exc:
        if device != "mps" or not _is_mps_allocation_failure(exc):
            raise
        _MPS_UNUSABLE = True
        logger.warning(
            f"MPS is available but the embedding model could not be allocated on it; "
            f"falling back to CPU: {exc}"
        )

    return SentenceTransformer(
        model_name,
        cache_folder=cache_folder,
        local_files_only=local_files_only,
        device="cpu",
    )

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
def resolve_embedding_device() -> Optional[str]:
    """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.
    """
    if _MPS_UNUSABLE:
        return "cpu"
    if _mps_is_available():
        return "mps"
    return None

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
def start_download() -> dict:
    """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.
    """
    with _lock:
        if not _state["downloading"] and not embedding_model_available():
            _state["downloading"] = True
            _state["error"] = None
            _spawn(_run_download)
    return download_state()

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
def embed_documents(self, texts: list[str]) -> list[list[float]]:
    """Embed indexed chunks (``passage: `` prefix, L2-normalized)."""
    start_s = time.perf_counter()
    vectors = self._get_model().encode(
        [f"passage: {text}" for text in texts],
        normalize_embeddings=True,
        show_progress_bar=False,
    )
    duration_ms = (time.perf_counter() - start_s) * 1000
    # One line per BATCH — never per text.
    logger.info(f"Embedded batch: {len(texts)} texts in {duration_ms:.0f}ms")
    return [vector.tolist() for vector in vectors]

embed_query(text)

Embed a search query (query: prefix, L2-normalized).

Source code in backend/src/ingestion/embeddings.py
def embed_query(self, text: str) -> list[float]:
    """Embed a search query (``query: `` prefix, L2-normalized)."""
    vector = self._get_model().encode(
        [f"query: {text}"],
        normalize_embeddings=True,
        show_progress_bar=False,
    )[0]
    return vector.tolist()

build_embedding_text(*, file_name, chunk_text)

Prefix the chunk with its source document name (R&D POC format).

Source code in backend/src/ingestion/embeddings.py
def build_embedding_text(*, file_name: str, chunk_text: str) -> str:
    """Prefix the chunk with its source document name (R&D POC format)."""
    return f"[document_name:{file_name}]\n{chunk_text}"

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
def rows_to_markdown_table(rows: list[list[str]]) -> str:
    """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.
    """
    if not rows:
        return ""
    width = max(len(row) for row in rows)

    def fmt(row: list[str]) -> str:
        cells = [str(cell).replace("|", "\\|").replace("\n", " ").strip() for cell in row]
        cells += [""] * (width - len(cells))
        return "| " + " | ".join(cells) + " |"

    header, *body = rows
    lines = [fmt(header), "| " + " | ".join(["---"] * width) + " |"]
    lines += [fmt(row) for row in body]
    return "\n".join(lines)

src.ingestion.reader

DocumentReader — the single extraction surface seen by services.

Two-dimensional routing
  1. 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).
  2. 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
def __init__(self) -> None:
    text_extractor = TextExtractor()
    self._extractors = {
        ".pdf": PdfExtractor(),
        ".docx": DocxExtractor(),
        ".xlsx": XlsxExtractor(),
        ".csv": CsvExtractor(),
        ".txt": text_extractor,
        ".md": text_extractor,
    }

read(path)

Extract one document. Raises InvalidInputException on unsupported extensions; never raises on images (pending_vision).

Source code in backend/src/ingestion/reader.py
def read(self, path: Path | str) -> ExtractedDocument:
    """Extract one document. Raises ``InvalidInputException`` on
    unsupported extensions; never raises on images (``pending_vision``)."""
    path = Path(path)
    extension = path.suffix.lower()

    if extension in IMAGE_EXTENSIONS:
        logger.info(
            f"Document read: {path.name} (format={extension}, "
            f"status=pending_vision, chars=0)"
        )
        return ExtractedDocument(
            markdown="",
            status="pending_vision",
            metadata={
                "extractor": "image",
                "reason": "image awaiting OCR/VLM tier",
            },
        )

    extractor = self._extractors.get(extension)
    if extractor is None:
        supported = ", ".join(sorted(self.supported_extensions))
        raise InvalidInputException(
            f"Unsupported document type '{extension}' ({path.name}). " f"Supported: {supported}"
        )

    document = extractor.extract(path)
    logger.info(
        f"Document read: {path.name} (format={extension}, "
        f"status={document.status}, chars={len(document.markdown)})"
    )
    return document

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
def add_kb_chunks(
    *,
    kb_id: int,
    document_id: int,
    source_file: str,
    chunks: list["Chunk"],
) -> list[str]:
    """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)."""
    if not chunks:
        return []
    start_s = time.perf_counter()
    embedding_texts = [
        build_embedding_text(file_name=source_file, chunk_text=chunk.text) for chunk in chunks
    ]
    vectors = E5Embeddings().embed_documents(embedding_texts)
    texts = [chunk.text for chunk in chunks]
    metadatas = [
        {
            "kb_id": kb_id,
            "document_id": document_id,
            "source_file": source_file,
            "page": chunk.page_number,
            "chunk_index": chunk.chunk_index,
        }
        for chunk in chunks
    ]
    ids = get_kb_store().add_embeddings(texts=texts, embeddings=vectors, metadatas=metadatas)
    duration_ms = (time.perf_counter() - start_s) * 1000
    logger.info(
        f"KB chunks added: kb_id={kb_id}, document_id={document_id}, "
        f"source_file={source_file}, n_chunks={len(chunks)}, "
        f"duration_ms={duration_ms:.0f}"
    )
    return ids

close_kb_store()

Release the store's PGEngine (lifespan shutdown, before cluster stop).

Source code in backend/src/ingestion/vector_store.py
def close_kb_store() -> None:
    """Release the store's PGEngine (lifespan shutdown, before cluster stop)."""
    global _pg_engine, _kb_store, _psycopg_url
    if _pg_engine is not None:
        _close_engine(_pg_engine)
    _pg_engine = None
    _kb_store = None
    _psycopg_url = None

get_kb_store()

The resident store (set by the lifespan via init_kb_store).

Source code in backend/src/ingestion/vector_store.py
def get_kb_store() -> PGVectorStore:
    """The resident store (set by the lifespan via ``init_kb_store``)."""
    if _kb_store is None:
        raise RuntimeError(
            "KB vector store not initialized — call init_kb_store() first "
            "(FastAPI lifespan does this at startup)."
        )
    return _kb_store

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
def init_kb_store(handle: "PostgresHandle") -> PGVectorStore:
    """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).
    """
    global _pg_engine, _kb_store, _psycopg_url

    with psycopg.connect(handle.psycopg_url, autocommit=True) as conn:
        conn.execute(f"CREATE SCHEMA IF NOT EXISTS {SCHEMA_NAME}")
        table_exists = conn.execute(
            "SELECT 1 FROM information_schema.tables"
            " WHERE table_schema = %s AND table_name = %s",
            (SCHEMA_NAME, TABLE_NAME),
        ).fetchone()

    engine = PGEngine.from_connection_string(handle.sqlalchemy_url)

    if not table_exists:
        engine.init_vectorstore_table(
            table_name=TABLE_NAME,
            schema_name=SCHEMA_NAME,
            vector_size=EMBEDDING_DIMENSIONS,
            metadata_columns=[
                Column("kb_id", "INTEGER", nullable=False),
                Column("document_id", "INTEGER", nullable=False),
                Column("source_file", "TEXT", nullable=True),
                Column("page", "INTEGER", nullable=True),
                Column("chunk_index", "INTEGER", nullable=True),
            ],
            hybrid_search_config=_hybrid_config(),
        )

    with psycopg.connect(handle.psycopg_url, autocommit=True) as conn:
        _ensure_fk(
            conn,
            "fk_kb_chunks_kb_id",
            f"ALTER TABLE {SCHEMA_NAME}.{TABLE_NAME}"
            " ADD CONSTRAINT fk_kb_chunks_kb_id FOREIGN KEY (kb_id)"
            " REFERENCES public.knowledge_base(id) ON DELETE CASCADE",
        )
        _ensure_fk(
            conn,
            "fk_kb_chunks_document_id",
            f"ALTER TABLE {SCHEMA_NAME}.{TABLE_NAME}"
            " ADD CONSTRAINT fk_kb_chunks_document_id FOREIGN KEY (document_id)"
            " REFERENCES public.knowledge_documents(id) ON DELETE CASCADE",
        )

    store = PGVectorStore.create_sync(
        engine=engine,
        table_name=TABLE_NAME,
        schema_name=SCHEMA_NAME,
        embedding_service=E5Embeddings(),
        metadata_columns=METADATA_COLUMNS,
        hybrid_search_config=_hybrid_config(),
    )

    if not store.is_valid_index(HNSW_INDEX_NAME):
        store.apply_vector_index(
            HNSWIndex(
                name=HNSW_INDEX_NAME,
                distance_strategy=DistanceStrategy.COSINE_DISTANCE,
            )
        )

    # Swap-in last: never leave a half-initialized store visible.
    if _pg_engine is not None:
        _close_engine(_pg_engine)
    _pg_engine, _kb_store, _psycopg_url = engine, store, handle.psycopg_url
    logger.info(f"KB vector store ready ({SCHEMA_NAME}.{TABLE_NAME}, hybrid RRF)")
    return store

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
def search_kb_chunks_scored(
    query: str, *, kb_id: int, pool_k: int = POOL_K
) -> list[tuple["Document", float]]:
    """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.
    """
    start_s = time.perf_counter()
    query_vector = E5Embeddings().embed_query(query)
    documents = get_kb_store().similarity_search_by_vector(
        query_vector,
        k=pool_k,
        filter={"kb_id": kb_id},
        hybrid_search_config=_hybrid_config(fts_query=query, pool_k=pool_k),
    )
    if not documents:
        duration_ms = (time.perf_counter() - start_s) * 1000
        logger.info(
            f"KB search: kb_id={kb_id}, k={pool_k}, hits=0, top_score=n/a, "
            f"duration_ms={duration_ms:.0f}, "
            f"query={truncate_for_log(query, 2000)}"
        )
        return []
    similarities = _dense_similarities([doc.id for doc in documents], query_vector)
    duration_ms = (time.perf_counter() - start_s) * 1000
    top_score = max(similarities.values()) if similarities else float("nan")
    logger.info(
        f"KB search: kb_id={kb_id}, k={pool_k}, hits={len(documents)}, "
        f"top_score={top_score:.4f}, duration_ms={duration_ms:.0f}, "
        f"query={truncate_for_log(query, 2000)}"
    )
    return [(doc, similarities[doc.id]) for doc in documents]

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.