Skip to content

Phase 7: Ingestion Pipeline Orchestrator #48

Description

@tinkermonkey

Implement core/pipeline.py with the IngestionPipeline class that orchestrates the full ingest and re-ingest flow from adapter through storage, coordinating all previously implemented components.

Requirements

  • Implement IngestionPipeline class in core/pipeline.py
  • Constructor: __init__(self, document_store: DocumentStore, embedder: Embedder, differ: Differ, vector_store_path: str | Path)
  • Implement ingest(adapter: BaseAdapter, domain_chunker: BaseDomain) -> dict method:
    1. Register adapter: adapter.register(self.document_store) (idempotent)
    2. Iterate adapter.fetch() to get NormalizedContent items
    3. For each NormalizedContent item:
      a. Look up latest version: self.document_store.get_latest_version(content.source_id)
      b. Chunk via domain_chunker.chunk(content)list[Chunk]
      c. Compute curr_chunk_hashes = {chunk.chunk_hash for chunk in chunks}
      d. Run differ: self.differ.diff(prev_markdown, curr_markdown, prev_hashes, curr_hashes)
      e. If diff_result.changed is False: update last_fetched_at only, skip all writes, increment unchanged counter
      f. If diff_result.changed is True:
      • Register source if new: self.document_store.register_source(...)
      • Determine new version number (prev_version + 1, or 1 if first ingest)
      • Create source version: self.document_store.create_source_version(...)source_version_id
      • Filter to only added chunks: added_chunks = [c for c in chunks if c.chunk_hash in diff_result.added_hashes]
      • Embed added chunks: vectors = self.embedder.embed([c.content for c in added_chunks])
      • Build LineageRecord for each added chunk using source_version_id, adapter.adapter_id, adapter.domain, content.normalizer_version, self.embedder.model_id
      • Write chunks + lineage: self.document_store.write_chunks(added_chunks, lineage_records)
      • Write vectors to LanceDB for added chunks
      • Retire removed chunks: self.document_store.retire_chunks(diff_result.removed_hashes); remove from LanceDB
      • Write sync log: self.document_store.write_sync_log(added_hashes, "insert"); delete sync log for removed: self.document_store.delete_sync_log(removed_hashes)
    4. Return summary dict: {"sources_processed": N, "chunks_added": N, "chunks_removed": N, "chunks_unchanged": N}
  • On first ingest of a source, all chunks are written and embedded
  • On re-ingest of unchanged source, no new records are created
  • On re-ingest with changes, only added/modified chunks are re-embedded; unchanged chunks are referenced by hash in the new version record without re-embedding

Design Guidance

  • core/pipeline.py already exists as a stub — implement IngestionPipeline there
  • Imports: from context_library.storage.document_store import DocumentStore, from context_library.storage.vector_store import VectorStore (or use lancedb directly), from context_library.storage.models import LineageRecord, SourceVersion, from context_library.core.embedder import Embedder, from context_library.core.differ import Differ, from context_library.adapters.base import BaseAdapter, from context_library.domains.base import BaseDomain
  • LanceDB write pattern (from storage/vector_store.py): open table, create ChunkVector instances with chunk_hash, content, source_id, embedding (the vector), then table.add(chunk_vectors)
  • LanceDB delete pattern: table.delete(f"chunk_hash IN {list(removed_hashes)}") — use LanceDB's SQL-like delete API
  • Write ordering invariant: ALWAYS write SQLite first, then LanceDB. If LanceDB write fails, data is safe in SQLite.
  • prev_markdown for the differ is prev_version.markdown if get_latest_version() returns a result, else None
  • prev_chunk_hashes for the differ is set(prev_version.chunk_hashes) if a previous version exists, else None
  • fetch_timestamp for create_source_version(): datetime.now(timezone.utc).isoformat()
  • The pipeline is the only place that writes to both SQLite and LanceDB in the same operation — this is intentional
  • Integration test approach: use DocumentStore(":memory:") and a temp LanceDB directory; use a real (or mock) embedder

Acceptance Criteria

  • First ingest of a directory: all .md files are chunked, embedded, and stored in both SQLite and LanceDB
  • lancedb_sync_log has one entry per chunk vector written after first ingest
  • Re-ingest of unchanged directory: no new source version records, no new chunk records, return dict shows chunks_added=0
  • Re-ingest after modifying one chunk: new source version created, only the modified chunk is re-embedded, unchanged chunks are not re-embedded, removed chunks are retired in SQLite and deleted from LanceDB
  • Re-ingest after adding a new file: new source record + source version created for the new file, all other files unchanged
  • Re-ingest after deleting a chunk: chunk retired in SQLite, removed from LanceDB, sync log updated
  • Return dict keys: sources_processed, chunks_added, chunks_removed, chunks_unchanged
  • Integration test in tests/core/test_pipeline.py passes: first ingest → re-ingest unchanged → re-ingest with change

Dependencies

Phase 2: Document Store (SQLite), Phase 3: Adapter Contract and Filesystem Adapter, Phase 4: Notes Domain Chunker, Phase 5: Differ, Phase 6: Embedder

Parent Issue

Part of #40

Discussion

This work is detailed in discussion 41

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions