railtracks.retrieval

Retrieval subsystem.

The runtime (RetrievalRuntime) orchestrates loading, chunking, embedding, storage, and retrieval. The ~railtracks.retrieval.stores.Store protocol is the storage contract; ~railtracks.retrieval.stores.VectorStore is the canonical implementation. The rest of the pipeline is provided by railtracks.retrieval.loaders, railtracks.retrieval.chunking, and railtracks.retrieval.embedding.

 1"""Retrieval subsystem.
 2
 3The runtime (:class:`RetrievalRuntime`) orchestrates loading, chunking,
 4embedding, storage, and retrieval. The :class:`~railtracks.retrieval.stores.Store`
 5protocol is the storage contract; :class:`~railtracks.retrieval.stores.VectorStore`
 6is the canonical implementation. The rest of the pipeline is provided
 7by :mod:`railtracks.retrieval.loaders`, :mod:`railtracks.retrieval.chunking`,
 8and :mod:`railtracks.retrieval.embedding`.
 9"""
10
11from .content_extraction import (
12    ContentExtractor,
13    JsonExtractor,
14    ProseExtractor,
15    StrExtractor,
16)
17from .embedding.models import EmbeddingFailure
18from .errors import EmbeddingModelMismatchError
19from .models import (
20    Chunk,
21    Document,
22    DocumentType,
23    EmbeddedChunk,
24    OCRResult,
25    RetrievalResult,
26    RetrievedChunk,
27)
28from .runtime import (
29    BatchIngested,
30    DocumentFailed,
31    DocumentSkipped,
32    IngestionStats,
33    RetrievalRuntime,
34)
35from .stores import Store, StoreEntry, StoreQuery, StoreScope, VectorStore
36
37__all__ = [
38    "BatchIngested",
39    "Chunk",
40    "ContentExtractor",
41    "Document",
42    "DocumentFailed",
43    "DocumentSkipped",
44    "DocumentType",
45    "EmbeddedChunk",
46    "EmbeddingFailure",
47    "EmbeddingModelMismatchError",
48    "IngestionStats",
49    "JsonExtractor",
50    "OCRResult",
51    "ProseExtractor",
52    "RetrievalResult",
53    "RetrievalRuntime",
54    "RetrievedChunk",
55    "Store",
56    "StoreEntry",
57    "StoreQuery",
58    "StoreScope",
59    "StrExtractor",
60    "VectorStore",
61]
@dataclass
class BatchIngested:
25@dataclass
26class BatchIngested:
27    """A batch of chunks that finished embedding and was written to the store.
28
29    ``batch_index`` is **per-document**: it starts at 0 for each document and
30    counts that document's batches (both successful and failed) in order. It
31    is not a run-global counter — to track overall progress, count events or
32    read ``IngestionStats``.
33
34    ``metrics`` carries the per-batch usage and timing reported by the
35    embedder (tokens, dollar cost, latency, vector count). Use it to track
36    per-ingest cost without having to wrap the embedder.
37    """
38
39    document_id: UUID
40    embedded_chunks: list[EmbeddedChunk]
41    batch_index: int
42    metrics: EmbeddingMetrics | None = None

A batch of chunks that finished embedding and was written to the store.

batch_index is per-document: it starts at 0 for each document and counts that document's batches (both successful and failed) in order. It is not a run-global counter — to track overall progress, count events or read IngestionStats.

metrics carries the per-batch usage and timing reported by the embedder (tokens, dollar cost, latency, vector count). Use it to track per-ingest cost without having to wrap the embedder.

BatchIngested( document_id: uuid.UUID, embedded_chunks: list[EmbeddedChunk], batch_index: int, metrics: railtracks.retrieval.embedding.models.EmbeddingMetrics | None = None)
document_id: uuid.UUID
embedded_chunks: list[EmbeddedChunk]
batch_index: int
metrics: railtracks.retrieval.embedding.models.EmbeddingMetrics | None = None
@dataclass
class Chunk:
74@dataclass
75class Chunk:
76    content: str
77    document_id: UUID
78    id: UUID = field(default_factory=uuid4)
79    index: int = 0
80    parent_chunk_id: UUID | None = None
81    offsets: tuple[int, int] | None = None
82    metadata: dict[str, Any] = field(default_factory=dict)
Chunk( content: str, document_id: uuid.UUID, id: uuid.UUID = <factory>, index: int = 0, parent_chunk_id: uuid.UUID | None = None, offsets: tuple[int, int] | None = None, metadata: dict[str, typing.Any] = <factory>)
content: str
document_id: uuid.UUID
id: uuid.UUID
index: int = 0
parent_chunk_id: uuid.UUID | None = None
offsets: tuple[int, int] | None = None
metadata: dict[str, typing.Any]
class ContentExtractor(typing.Protocol):
10class ContentExtractor(Protocol):
11    """Convert one loader field value into searchable text."""
12
13    def __call__(self, value: Any, /) -> str: ...

Convert one loader field value into searchable text.

ContentExtractor(*args, **kwargs)
1431def _no_init_or_replace_init(self, *args, **kwargs):
1432    cls = type(self)
1433
1434    if cls._is_protocol:
1435        raise TypeError('Protocols cannot be instantiated')
1436
1437    # Already using a custom `__init__`. No need to calculate correct
1438    # `__init__` to call. This can lead to RecursionError. See bpo-45121.
1439    if cls.__init__ is not _no_init_or_replace_init:
1440        return
1441
1442    # Initially, `__init__` of a protocol subclass is set to `_no_init_or_replace_init`.
1443    # The first instantiation of the subclass will call `_no_init_or_replace_init` which
1444    # searches for a proper new `__init__` in the MRO. The new `__init__`
1445    # replaces the subclass' old `__init__` (ie `_no_init_or_replace_init`). Subsequent
1446    # instantiation of the protocol subclass will thus use the new
1447    # `__init__` and no longer call `_no_init_or_replace_init`.
1448    for base in cls.__mro__:
1449        init = base.__dict__.get('__init__', _no_init_or_replace_init)
1450        if init is not _no_init_or_replace_init:
1451            cls.__init__ = init
1452            break
1453    else:
1454        # should not happen
1455        cls.__init__ = object.__init__
1456
1457    cls.__init__(self, *args, **kwargs)
@dataclass
class Document:
24@dataclass
25class Document:
26    """A unit of source content produced by a loader.
27
28    Attributes:
29        content: The decoded textual content of the document. Always a string;
30            binary loaders are responsible for their own decoding.
31        type: A :class:`DocumentType` describing the content format. Cloud
32            loaders infer it from the object's file extension; structured
33            loaders (CSV, SQL) set it explicitly.
34        id: Unique identifier. If not provided and ``source`` is set, derived
35            deterministically from ``source`` via UUID5 (RFC 4122 URL
36            namespace) so the same source yields the same id across processes
37            — required for the runtime's upsert (``delete_where`` on
38            ``document_id``) to find and clear the prior version when content
39            changes. Sourceless documents get a random UUID4 (no stable
40            identity → no upsert semantics).
41        source: The natural identifier of where this document came from —
42            a URI (``s3://bucket/key``, ``gs://bucket/name``, ``https://...``),
43            a file path, or a relational id. Cloud loaders always set this;
44            user-constructed documents may leave it ``None``.
45
46            Writers that derive a storage key (when no ``key_fn`` is supplied)
47            look here first; the cloud writers also strip their own URI prefix
48            so that "load from S3, write back to S3" produces a clean key
49            rather than a nested URI.
50        content_hash: SHA-256 of ``content``. Computed by the runtime at
51            ingest time; loaders should leave this ``None``. Used by
52            staleness-detection to skip re-embedding unchanged documents.
53        metadata: Arbitrary provider-specific or user-attached key-value data.
54            Loaders use this to expose details like ``bucket``, ``key``,
55            ``page``, ``row_index``, etc.
56    """
57
58    content: str
59    type: DocumentType = DocumentType.TEXT
60    id: UUID = _UNSET_DOCUMENT_ID
61    source: str | None = None
62    content_hash: str | None = None
63    metadata: dict[str, Any] = field(default_factory=dict)
64
65    def __post_init__(self) -> None:
66        if self.id == _UNSET_DOCUMENT_ID:
67            self.id = (
68                uuid5(NAMESPACE_URL, self.source)
69                if self.source is not None
70                else uuid4()
71            )

A unit of source content produced by a loader.

Attributes:
  • content: The decoded textual content of the document. Always a string; binary loaders are responsible for their own decoding.
  • type: A DocumentType describing the content format. Cloud loaders infer it from the object's file extension; structured loaders (CSV, SQL) set it explicitly.
  • id: Unique identifier. If not provided and source is set, derived deterministically from source via UUID5 (RFC 4122 URL namespace) so the same source yields the same id across processes — required for the runtime's upsert (delete_where on document_id) to find and clear the prior version when content changes. Sourceless documents get a random UUID4 (no stable identity → no upsert semantics).
  • source: The natural identifier of where this document came from — a URI (s3://bucket/key, gs://bucket/name, https://...), a file path, or a relational id. Cloud loaders always set this; user-constructed documents may leave it None.

    Writers that derive a storage key (when no key_fn is supplied) look here first; the cloud writers also strip their own URI prefix so that "load from S3, write back to S3" produces a clean key rather than a nested URI.

  • content_hash: SHA-256 of content. Computed by the runtime at ingest time; loaders should leave this None. Used by staleness-detection to skip re-embedding unchanged documents.
  • metadata: Arbitrary provider-specific or user-attached key-value data. Loaders use this to expose details like bucket, key, page, row_index, etc.
Document( content: str, type: DocumentType = <DocumentType.TEXT: 'text'>, id: uuid.UUID = UUID('00000000-0000-0000-0000-000000000000'), source: str | None = None, content_hash: str | None = None, metadata: dict[str, typing.Any] = <factory>)
content: str
type: DocumentType = <DocumentType.TEXT: 'text'>
id: uuid.UUID = UUID('00000000-0000-0000-0000-000000000000')
source: str | None = None
content_hash: str | None = None
metadata: dict[str, typing.Any]
@dataclass
class DocumentFailed:
45@dataclass
46class DocumentFailed:
47    """A document that had at least one failed embedding batch.
48
49    Note: successful batches for the same document *are* written to the
50    store. ``DocumentFailed`` is an informational signal that the document
51    is now partial — callers may want to retry, delete, or accept the
52    partial state.
53    """
54
55    document_id: UUID
56    source: str | None
57    errors: list[Exception]

A document that had at least one failed embedding batch.

Note: successful batches for the same document are written to the store. DocumentFailed is an informational signal that the document is now partial — callers may want to retry, delete, or accept the partial state.

DocumentFailed(document_id: uuid.UUID, source: str | None, errors: list[Exception])
document_id: uuid.UUID
source: str | None
errors: list[Exception]
@dataclass
class DocumentSkipped:
60@dataclass
61class DocumentSkipped:
62    """A document skipped during ingest because the store already has an
63    entry with the same ``source_path`` and ``content_hash``."""
64
65    document_id: UUID
66    source: str | None
67    reason: str = "unchanged"

A document skipped during ingest because the store already has an entry with the same source_path and content_hash.

DocumentSkipped( document_id: uuid.UUID, source: str | None, reason: str = 'unchanged')
document_id: uuid.UUID
source: str | None
reason: str = 'unchanged'
class DocumentType(builtins.str, enum.Enum):
15class DocumentType(str, Enum):
16    TEXT = "text"
17    MARKDOWN = "markdown"
18    PDF = "pdf"
19    CSV = "csv"
20    JSON = "json"
21    JSONL = "jsonl"

An enumeration.

TEXT = <DocumentType.TEXT: 'text'>
MARKDOWN = <DocumentType.MARKDOWN: 'markdown'>
PDF = <DocumentType.PDF: 'pdf'>
CSV = <DocumentType.CSV: 'csv'>
JSON = <DocumentType.JSON: 'json'>
JSONL = <DocumentType.JSONL: 'jsonl'>
@dataclass
class EmbeddedChunk:
85@dataclass
86class EmbeddedChunk:
87    chunk: Chunk
88    vector: list[float]
89    embedding_model: str
90    embedding_version: str | None = None
EmbeddedChunk( chunk: Chunk, vector: list[float], embedding_model: str, embedding_version: str | None = None)
chunk: Chunk
vector: list[float]
embedding_model: str
embedding_version: str | None = None
@dataclass
class EmbeddingFailure:
72@dataclass
73class EmbeddingFailure:
74    """A failed batch embedding attempt.
75
76    Attributes:
77        chunks: Source chunks that could not be embedded.
78        errors: Exceptions raised during embedding.
79    """
80
81    chunks: list[Chunk]
82    errors: list[Exception]

A failed batch embedding attempt.

Attributes:
  • chunks: Source chunks that could not be embedded.
  • errors: Exceptions raised during embedding.
EmbeddingFailure( chunks: list[Chunk], errors: list[Exception])
chunks: list[Chunk]
errors: list[Exception]
class EmbeddingModelMismatchError(builtins.RuntimeError):
 7class EmbeddingModelMismatchError(RuntimeError):
 8    """Raised when the runtime's embedder model differs from the store's.
 9
10    Mixing vectors from different embedding models silently produces
11    meaningless similarity scores, so the runtime fails loudly before
12    issuing the search.
13    """

Raised when the runtime's embedder model differs from the store's.

Mixing vectors from different embedding models silently produces meaningless similarity scores, so the runtime fails loudly before issuing the search.

@dataclass
class IngestionStats:
70@dataclass
71class IngestionStats:
72    """Summary of a complete ingest run.
73
74    ``total_metrics`` accumulates per-batch ``EmbeddingMetrics`` (tokens,
75    dollar cost, latency, vector count) across every successful batch in
76    the run, so callers can read a single total for billing/observability.
77    """
78
79    documents_loaded: int = 0
80    documents_failed: int = 0
81    documents_skipped: int = 0
82    chunks_created: int = 0
83    chunks_embedded: int = 0
84    batches_failed: int = 0
85    batch_failures: list[EmbeddingFailure] = field(default_factory=list)
86    failed_documents: list[DocumentFailed] = field(default_factory=list)
87    total_metrics: EmbeddingMetrics = field(default_factory=EmbeddingMetrics)

Summary of a complete ingest run.

total_metrics accumulates per-batch EmbeddingMetrics (tokens, dollar cost, latency, vector count) across every successful batch in the run, so callers can read a single total for billing/observability.

IngestionStats( documents_loaded: int = 0, documents_failed: int = 0, documents_skipped: int = 0, chunks_created: int = 0, chunks_embedded: int = 0, batches_failed: int = 0, batch_failures: list[EmbeddingFailure] = <factory>, failed_documents: list[DocumentFailed] = <factory>, total_metrics: railtracks.retrieval.embedding.models.EmbeddingMetrics = <factory>)
documents_loaded: int = 0
documents_failed: int = 0
documents_skipped: int = 0
chunks_created: int = 0
chunks_embedded: int = 0
batches_failed: int = 0
batch_failures: list[EmbeddingFailure]
failed_documents: list[DocumentFailed]
total_metrics: railtracks.retrieval.embedding.models.EmbeddingMetrics
class JsonExtractor:
23class JsonExtractor:
24    """Render values as valid JSON while preserving Unicode text."""
25
26    def __call__(self, value: Any) -> str:
27        return json.dumps(value, ensure_ascii=False)

Render values as valid JSON while preserving Unicode text.

@dataclass
class OCRResult:
110@dataclass
111class OCRResult:
112    """Structured output from an OCR engine.
113
114    Attributes:
115        markdown: Full page text as markdown. Tables and headings are
116            represented in markdown syntax where the engine supports it.
117        json_result: Raw structured layout data from the engine, if available.
118            Schema is engine-specific; ``None`` when the engine does not
119            return structured layout data. For GLM-OCR this is the value of
120            ``PipelineResult.to_dict()["json_result"]``, which contains
121            bounding boxes, tables, and reading order.
122    """
123
124    markdown: str
125    json_result: dict[str, Any] | list[Any] | None = None
126
127    def to_text(self) -> str:
128        return self.markdown

Structured output from an OCR engine.

Attributes:
  • markdown: Full page text as markdown. Tables and headings are represented in markdown syntax where the engine supports it.
  • json_result: Raw structured layout data from the engine, if available. Schema is engine-specific; None when the engine does not return structured layout data. For GLM-OCR this is the value of PipelineResult.to_dict()["json_result"], which contains bounding boxes, tables, and reading order.
OCRResult( markdown: str, json_result: dict[str, typing.Any] | list[typing.Any] | None = None)
markdown: str
json_result: dict[str, typing.Any] | list[typing.Any] | None = None
def to_text(self) -> str:
127    def to_text(self) -> str:
128        return self.markdown
class ProseExtractor:
30class ProseExtractor:
31    """Flatten nested JSON-like values into readable, labelled text."""
32
33    def __call__(self, value: Any) -> str:
34        return self._render(value, nested=False)
35
36    def _render(self, value: Any, *, nested: bool = True) -> str:
37        if isinstance(value, dict):
38            rendered = "; ".join(
39                f"{key}: {self._render(item)}" for key, item in value.items()
40            )
41            return f"{{{rendered}}}" if nested else rendered
42        if isinstance(value, (list, tuple)):
43            return f"[{', '.join(self._render(item) for item in value)}]"
44        if value is None:
45            return "null"
46        if isinstance(value, bool):
47            return "true" if value else "false"
48        return str(value)

Flatten nested JSON-like values into readable, labelled text.

@dataclass
class RetrievalResult:
102@dataclass
103class RetrievalResult:
104    query: str
105    chunks: list[RetrievedChunk]
106    total_candidates: int | None = None
107    metadata: dict[str, Any] = field(default_factory=dict)
RetrievalResult( query: str, chunks: list[RetrievedChunk], total_candidates: int | None = None, metadata: dict[str, typing.Any] = <factory>)
query: str
chunks: list[RetrievedChunk]
total_candidates: int | None = None
metadata: dict[str, typing.Any]
class RetrievalRuntime:
106class RetrievalRuntime:
107    """Orchestrates loading, chunking, embedding, storage, and retrieval.
108
109    The runtime captures *how* to process documents (chunker + embedder +
110    store); the loader passed to :meth:`ingest` decides *what* to
111    process. A single runtime can ingest from multiple sources, mix
112    chunking strategies via separate runtimes against the same store,
113    and update existing documents by re-ingesting them. Multi-tenant
114    callers share one runtime and pass ``scope`` per :meth:`ingest` or
115    :meth:`retrieve` call.
116
117    Args:
118        chunker: Splits documents into chunks.
119        embedder: Embeds chunk text into vectors.
120        store: Receives written ``StoreEntry``s and serves similarity search.
121        batch_size: Items per embedding batch. Falls back to
122            ``embedder.default_batch_size`` when omitted; raises
123            ``ValueError`` at construction if neither is set.
124        on_ingest: Synchronous callback invoked with each ``IngestionEvent``
125            as it is yielded. Wrap in ``asyncio.create_task`` for async logging.
126        on_retrieve: Synchronous callback invoked with the query string and
127            the ``RetrievalResult`` after each retrieve call.
128        max_tokens: When set, chunks whose token count exceeds this limit
129            are dropped before embedding and reported via
130            ``EmbeddingFailure`` rather than being sent to the provider.
131            Requires ``tokenizer`` (defaults to ``TiktokenTokenizer``).
132        tokenizer: Tokenizer used to enforce ``max_tokens``. Defaults to
133            ``TiktokenTokenizer`` lazily when ``max_tokens`` is set.
134    """
135
136    def __init__(
137        self,
138        chunker: Chunker,
139        embedder: Embedding,
140        store: Store,
141        *,
142        batch_size: int | None = None,
143        on_ingest: Callable[
144            [BatchIngested | EmbeddingFailure | DocumentFailed | DocumentSkipped], None
145        ]
146        | None = None,
147        on_retrieve: Callable[[str, RetrievalResult], None] | None = None,
148        max_tokens: int | None = None,
149        tokenizer: Tokenizer | None = None,
150    ) -> None:
151        self._chunker = chunker
152        self._embedder = embedder
153        self._store = store
154        self._batch_size = self._resolve_batch_size(batch_size, embedder)
155        self._on_ingest = on_ingest
156        self._on_retrieve = on_retrieve
157        self._max_tokens = max_tokens
158        if max_tokens is not None and tokenizer is None:
159            from .chunking.tokenization import TiktokenTokenizer
160
161            tokenizer = TiktokenTokenizer()
162        self._tokenizer = tokenizer
163        # Captured on the first successful embedded batch and checked at
164        # retrieve time; survives process restarts by lazy-seeding from
165        # an existing store entry on the first ingest/retrieve.
166        self._captured_model: str | None = None
167        self._seed_attempted: bool = False
168
169    @property
170    def store(self) -> Store:
171        return self._store
172
173    @property
174    def embedder(self) -> Embedding:
175        return self._embedder
176
177    @property
178    def chunker(self) -> Chunker:
179        return self._chunker
180
181    @property
182    def batch_size(self) -> int:
183        return self._batch_size
184
185    @property
186    def max_tokens(self) -> int | None:
187        return self._max_tokens
188
189    @staticmethod
190    def _resolve_batch_size(batch_size: int | None, embedder: Embedding) -> int:
191        bs = batch_size if batch_size is not None else embedder.default_batch_size
192        if bs is None:
193            raise ValueError(
194                f"{type(embedder).__name__} does not declare a "
195                "default_batch_size. Pass batch_size= to RetrievalRuntime "
196                "or set default_batch_size on the embedder class."
197            )
198        return bs
199
200    async def ingest(
201        self,
202        loader: BaseDocumentLoader,
203        *,
204        scope: StoreScope | None = None,
205    ) -> AsyncGenerator[
206        BatchIngested | EmbeddingFailure | DocumentFailed | DocumentSkipped, None
207    ]:
208        """Stream loader → chunker → embedder → store, yielding per-batch events.
209
210        Args:
211            loader: Source of ``Document`` objects to ingest.
212            scope: Tag written onto every ``StoreEntry`` produced by this
213                call. Single-tenant callers can leave this ``None``.
214
215        Yields:
216            ``BatchIngested`` after each successful batch finishes writing,
217            ``EmbeddingFailure`` for any failed batch, and ``DocumentFailed``
218            once at end-of-document for each document that had any failed
219            batch. Successful batches for a partially-failed document are
220            still written; ``DocumentFailed`` signals the partial state.
221        """
222        stats = IngestionStats()
223        async for event in self._ingest_with_stats(loader, stats, scope):
224            if self._on_ingest is not None:
225                self._on_ingest(event)
226            yield event
227
228    async def ingest_all(
229        self,
230        loader: BaseDocumentLoader,
231        *,
232        scope: StoreScope | None = None,
233    ) -> IngestionStats:
234        """Drain `ingest` and return aggregate counts."""
235        stats = IngestionStats()
236        async for event in self._ingest_with_stats(loader, stats, scope):
237            if self._on_ingest is not None:
238                self._on_ingest(event)
239        return stats
240
241    async def _ingest_with_stats(
242        self,
243        loader: BaseDocumentLoader,
244        stats: IngestionStats,
245        scope: StoreScope | None,
246    ) -> AsyncGenerator[
247        BatchIngested | EmbeddingFailure | DocumentFailed | DocumentSkipped, None
248    ]:
249        async for doc in loader.astream():
250            async for event in self._ingest_document(doc, stats, scope):
251                yield event
252
253    async def _ingest_document(
254        self, doc: Document, stats: IngestionStats, scope: StoreScope | None
255    ) -> AsyncGenerator[
256        BatchIngested | EmbeddingFailure | DocumentFailed | DocumentSkipped, None
257    ]:
258        stats.documents_loaded += 1
259        doc.content_hash = _content_hash(doc.content)
260
261        await self._ensure_captured_model_seeded()
262
263        if await self._is_complete_duplicate(doc, scope):
264            stats.documents_skipped += 1
265            yield DocumentSkipped(document_id=doc.id, source=doc.source)
266            return
267
268        chunks = await self._chunker.achunk(doc)
269        stats.chunks_created += len(chunks)
270        if not chunks:
271            return
272
273        self._stamp_staleness_metadata(doc, chunks)
274
275        # Token-size guard: drop oversized chunks before embedding to avoid
276        # provider 4xx errors. Each oversize chunk surfaces as an
277        # EmbeddingFailure carried into the document's accumulated errors.
278        doc_errors: list[Exception] = []
279        chunks, failures = self._split_oversized(chunks, stats)
280        for failure in failures:
281            doc_errors.extend(failure.errors)
282            yield failure
283        if not chunks:
284            if doc_errors:
285                yield self._record_document_failed(doc, doc_errors, stats)
286            return
287
288        # Stamp the final (post-token-guard) chunk count onto every chunk so a
289        # later staleness check can tell a complete document from a
290        # partially-written one. Every chunk carries the same total, so reading
291        # any one persisted chunk reveals how many were expected.
292        for chunk in chunks:
293            chunk.metadata["doc_chunk_count"] = len(chunks)
294
295        async for event in self._embed_and_store(doc, chunks, stats, doc_errors, scope):
296            yield event
297
298        if doc_errors:
299            yield self._record_document_failed(doc, doc_errors, stats)
300
301    async def _is_complete_duplicate(
302        self, doc: Document, scope: StoreScope | None
303    ) -> bool:
304        """Whether the store already holds a *complete* copy of ``doc``.
305
306        Skip re-embedding only when as many chunks are present as the last
307        write expected. A partially-written document (some chunks present after
308        an interrupted ingest) has fewer than expected and is re-ingested rather
309        than left broken. find() is metadata-only (no vector search) and only
310        fetches a single entry; the presence check is a count() so no payloads
311        are transferred and large documents never require a single oversized
312        read (Chroma Cloud caps the per-get ``limit`` value).
313        """
314        if doc.source is None:
315            return False
316        stale_filters = {
317            "source_path": doc.source,
318            "content_hash": doc.content_hash,
319            **(scope.to_payload_filters() if scope is not None else {}),
320        }
321        existing = await self._store.find(stale_filters, limit=1)
322        if not existing:
323            return False
324        expected = existing[0].chunk_metadata.get("doc_chunk_count")
325        if expected is None:
326            # Not written by this runtime (every ingest stamps the count):
327            # completeness can't be verified, so re-ingest.
328            return False
329        return await self._store.count(stale_filters) >= expected
330
331    @staticmethod
332    def _stamp_staleness_metadata(doc: Document, chunks: list[Chunk]) -> None:
333        """Inject staleness-detection metadata into every chunk so future
334        `find` calls can identify whether this document has changed."""
335        for chunk in chunks:
336            if doc.source is not None:
337                chunk.metadata.setdefault("source_path", doc.source)
338            if doc.content_hash is not None:
339                chunk.metadata.setdefault("content_hash", doc.content_hash)
340
341    def _split_oversized(
342        self, chunks: list[Chunk], stats: IngestionStats
343    ) -> tuple[list[Chunk], list[EmbeddingFailure]]:
344        """Partition chunks into embeddable ones and per-chunk failures.
345
346        Returns ``(ok_chunks, failures)``; each oversize chunk becomes a
347        single-chunk ``EmbeddingFailure`` and is recorded in ``stats``.
348        """
349        if self._max_tokens is None or self._tokenizer is None:
350            return chunks, []
351        ok_chunks: list[Chunk] = []
352        failures: list[EmbeddingFailure] = []
353        for chunk in chunks:
354            tokens = self._tokenizer.count(chunk.content)
355            if tokens > self._max_tokens:
356                err = ValueError(
357                    f"chunk {chunk.id} has {tokens} tokens "
358                    f"(>{self._max_tokens}); dropped before embedding"
359                )
360                stats.batches_failed += 1
361                failure = EmbeddingFailure(chunks=[chunk], errors=[err])
362                stats.batch_failures.append(failure)
363                failures.append(failure)
364            else:
365                ok_chunks.append(chunk)
366        return ok_chunks, failures
367
368    async def _embed_and_store(
369        self,
370        doc: Document,
371        chunks: list[Chunk],
372        stats: IngestionStats,
373        doc_errors: list[Exception],
374        scope: StoreScope | None,
375    ) -> AsyncGenerator[
376        BatchIngested | EmbeddingFailure | DocumentFailed | DocumentSkipped, None
377    ]:
378        # batch_index is per-document: it counts batches (successful and
379        # failed) within this document and resets for the next one.
380        batch_index = 0
381        delete_done = False
382        async for batch in self._embedder.astream_batches(
383            chunks, batch_size=self._batch_size
384        ):
385            if isinstance(batch, EmbeddingResult):
386                # Check model BEFORE delete_where / write — a mismatch here
387                # must not corrupt the store by clearing prior chunks first.
388                self._check_model(batch.metrics.model)
389                if not delete_done:
390                    await self._store.delete_where(
391                        {
392                            "document_id": str(doc.id),
393                            **(scope.to_payload_filters() if scope is not None else {}),
394                        }
395                    )
396                    delete_done = True
397                for embedded in batch.chunks:
398                    self._capture_model(embedded)
399                    entry = StoreEntry.from_chunk(embedded, scope=scope)
400                    await self._store.write(entry)
401                stats.chunks_embedded += len(batch.chunks)
402                stats.total_metrics = stats.total_metrics + batch.metrics
403                yield BatchIngested(
404                    document_id=doc.id,
405                    embedded_chunks=batch.chunks,
406                    batch_index=batch_index,
407                    metrics=batch.metrics,
408                )
409            else:
410                doc_errors.extend(batch.errors)
411                stats.batches_failed += 1
412                stats.batch_failures.append(batch)
413                yield batch
414            batch_index += 1
415
416    def _capture_model(self, embedded: EmbeddedChunk) -> None:
417        """Record the embedding model from the first successful chunk so later
418        retrieve() calls can enforce model consistency."""
419        if self._captured_model is None and embedded.embedding_model:
420            self._captured_model = embedded.embedding_model
421            logger.info(
422                "RetrievalRuntime captured embedding model %r "
423                "from first successful batch; subsequent retrieve() "
424                "calls will enforce this model.",
425                self._captured_model,
426            )
427
428    async def _ensure_captured_model_seeded(self) -> None:
429        """Lazily seed ``_captured_model`` from an existing store entry so the
430        guard survives across process restarts. ``StoreEntry.embedding_model``
431        is recorded on every persisted entry, so a single ``find`` call is
432        enough — no schema change required. Runs at most once per runtime;
433        a miss against an empty store sets ``_seed_attempted`` so we don't
434        re-query on every doc."""
435        if self._captured_model is not None or self._seed_attempted:
436            return
437        self._seed_attempted = True
438        existing = await self._store.find({}, limit=1)
439        if existing and existing[0].embedding_model:
440            self._captured_model = existing[0].embedding_model
441            logger.info(
442                "RetrievalRuntime seeded captured embedding model %r from "
443                "an existing store entry; mismatched embedders will raise.",
444                self._captured_model,
445            )
446
447    def _check_model(self, embed_model: str | None) -> None:
448        """Raise if ``embed_model`` disagrees with the captured model."""
449        if (
450            self._captured_model is not None
451            and embed_model
452            and embed_model != self._captured_model
453        ):
454            raise EmbeddingModelMismatchError(
455                f"Embedder produced vectors with model {embed_model!r} but "
456                f"store was built with {self._captured_model!r}. Similarity "
457                "scores across models are meaningless; rebuild the store "
458                "with the correct embedder or switch embedders."
459            )
460
461    @staticmethod
462    def _record_document_failed(
463        doc: Document, doc_errors: list[Exception], stats: IngestionStats
464    ) -> DocumentFailed:
465        failed = DocumentFailed(
466            document_id=doc.id,
467            source=doc.source,
468            errors=doc_errors,
469        )
470        stats.documents_failed += 1
471        stats.failed_documents.append(failed)
472        return failed
473
474    async def delete_document(self, document_id: UUID) -> None:
475        """Remove all chunks for a document from the store.
476
477        Convenience wrapper around ``store.delete_where({"document_id": ...})``
478        so callers don't need to know the metadata key.
479        """
480        await self._store.delete_where({"document_id": str(document_id)})
481
482    async def retrieve(
483        self,
484        query: str,
485        top_k: int = 5,
486        metadata_filters: dict[str, Any] | None = None,
487        scope: StoreScope | None = None,
488    ) -> RetrievalResult:
489        """Embed ``query`` and return the top ``top_k`` matches from the store.
490
491        Args:
492            query: The text to embed and search with.
493            top_k: Maximum number of results.
494            metadata_filters: Additional equality filters on chunk metadata.
495            scope: Restricts the search to entries written with the same
496                scope. Leave ``None`` to search across all scopes.
497
498        Raises:
499            EmbeddingModelMismatchError: When the embedder reports a model
500                different from the one captured on first ingest.
501        """
502        await self._ensure_captured_model_seeded()
503        text_result = await self._embedder.aembed([query])
504        self._check_model(text_result.metrics.model)
505
506        store_query = StoreQuery(
507            text=query,
508            scope=scope,
509            embedding=text_result.vectors[0],
510            top_k=top_k,
511            metadata_filters=metadata_filters,
512        )
513        store_hits = await self._store.read(store_query)
514        chunks = [
515            RetrievedChunk(
516                chunk=_entry_to_chunk(hit.entry),
517                score=hit.score,
518                rank=hit.rank,
519                source_retriever=hit.source_retriever,
520                rerank_score=hit.rerank_score,
521            )
522            for hit in store_hits
523        ]
524        result = RetrievalResult(query=query, chunks=chunks)
525        if self._on_retrieve is not None:
526            self._on_retrieve(query, result)
527        return result

Orchestrates loading, chunking, embedding, storage, and retrieval.

The runtime captures how to process documents (chunker + embedder + store); the loader passed to ingest() decides what to process. A single runtime can ingest from multiple sources, mix chunking strategies via separate runtimes against the same store, and update existing documents by re-ingesting them. Multi-tenant callers share one runtime and pass scope per ingest() or retrieve() call.

Arguments:
  • chunker: Splits documents into chunks.
  • embedder: Embeds chunk text into vectors.
  • store: Receives written StoreEntrys and serves similarity search.
  • batch_size: Items per embedding batch. Falls back to embedder.default_batch_size when omitted; raises ValueError at construction if neither is set.
  • on_ingest: Synchronous callback invoked with each IngestionEvent as it is yielded. Wrap in asyncio.create_task for async logging.
  • on_retrieve: Synchronous callback invoked with the query string and the RetrievalResult after each retrieve call.
  • max_tokens: When set, chunks whose token count exceeds this limit are dropped before embedding and reported via EmbeddingFailure rather than being sent to the provider. Requires tokenizer (defaults to TiktokenTokenizer).
  • tokenizer: Tokenizer used to enforce max_tokens. Defaults to TiktokenTokenizer lazily when max_tokens is set.
RetrievalRuntime( chunker: railtracks.retrieval.chunking.base.Chunker, embedder: railtracks.retrieval.embedding.base.Embedding, store: Store, *, batch_size: int | None = None, on_ingest: Optional[Callable[[BatchIngested | EmbeddingFailure | DocumentFailed | DocumentSkipped], NoneType]] = None, on_retrieve: Optional[Callable[[str, RetrievalResult], NoneType]] = None, max_tokens: int | None = None, tokenizer: railtracks.retrieval.chunking.tokenization.Tokenizer | None = None)
136    def __init__(
137        self,
138        chunker: Chunker,
139        embedder: Embedding,
140        store: Store,
141        *,
142        batch_size: int | None = None,
143        on_ingest: Callable[
144            [BatchIngested | EmbeddingFailure | DocumentFailed | DocumentSkipped], None
145        ]
146        | None = None,
147        on_retrieve: Callable[[str, RetrievalResult], None] | None = None,
148        max_tokens: int | None = None,
149        tokenizer: Tokenizer | None = None,
150    ) -> None:
151        self._chunker = chunker
152        self._embedder = embedder
153        self._store = store
154        self._batch_size = self._resolve_batch_size(batch_size, embedder)
155        self._on_ingest = on_ingest
156        self._on_retrieve = on_retrieve
157        self._max_tokens = max_tokens
158        if max_tokens is not None and tokenizer is None:
159            from .chunking.tokenization import TiktokenTokenizer
160
161            tokenizer = TiktokenTokenizer()
162        self._tokenizer = tokenizer
163        # Captured on the first successful embedded batch and checked at
164        # retrieve time; survives process restarts by lazy-seeding from
165        # an existing store entry on the first ingest/retrieve.
166        self._captured_model: str | None = None
167        self._seed_attempted: bool = False
store: Store
169    @property
170    def store(self) -> Store:
171        return self._store
embedder: railtracks.retrieval.embedding.base.Embedding
173    @property
174    def embedder(self) -> Embedding:
175        return self._embedder
chunker: railtracks.retrieval.chunking.base.Chunker
177    @property
178    def chunker(self) -> Chunker:
179        return self._chunker
batch_size: int
181    @property
182    def batch_size(self) -> int:
183        return self._batch_size
max_tokens: int | None
185    @property
186    def max_tokens(self) -> int | None:
187        return self._max_tokens
async def ingest( self, loader: railtracks.retrieval.loaders.base.BaseDocumentLoader, *, scope: StoreScope | None = None) -> AsyncGenerator[BatchIngested | EmbeddingFailure | DocumentFailed | DocumentSkipped, None]:
200    async def ingest(
201        self,
202        loader: BaseDocumentLoader,
203        *,
204        scope: StoreScope | None = None,
205    ) -> AsyncGenerator[
206        BatchIngested | EmbeddingFailure | DocumentFailed | DocumentSkipped, None
207    ]:
208        """Stream loader → chunker → embedder → store, yielding per-batch events.
209
210        Args:
211            loader: Source of ``Document`` objects to ingest.
212            scope: Tag written onto every ``StoreEntry`` produced by this
213                call. Single-tenant callers can leave this ``None``.
214
215        Yields:
216            ``BatchIngested`` after each successful batch finishes writing,
217            ``EmbeddingFailure`` for any failed batch, and ``DocumentFailed``
218            once at end-of-document for each document that had any failed
219            batch. Successful batches for a partially-failed document are
220            still written; ``DocumentFailed`` signals the partial state.
221        """
222        stats = IngestionStats()
223        async for event in self._ingest_with_stats(loader, stats, scope):
224            if self._on_ingest is not None:
225                self._on_ingest(event)
226            yield event

Stream loader → chunker → embedder → store, yielding per-batch events.

Arguments:
  • loader: Source of Document objects to ingest.
  • scope: Tag written onto every StoreEntry produced by this call. Single-tenant callers can leave this None.
Yields:

BatchIngested after each successful batch finishes writing, EmbeddingFailure for any failed batch, and DocumentFailed once at end-of-document for each document that had any failed batch. Successful batches for a partially-failed document are still written; DocumentFailed signals the partial state.

async def ingest_all( self, loader: railtracks.retrieval.loaders.base.BaseDocumentLoader, *, scope: StoreScope | None = None) -> IngestionStats:
228    async def ingest_all(
229        self,
230        loader: BaseDocumentLoader,
231        *,
232        scope: StoreScope | None = None,
233    ) -> IngestionStats:
234        """Drain `ingest` and return aggregate counts."""
235        stats = IngestionStats()
236        async for event in self._ingest_with_stats(loader, stats, scope):
237            if self._on_ingest is not None:
238                self._on_ingest(event)
239        return stats

Drain ingest and return aggregate counts.

async def delete_document(self, document_id: uuid.UUID) -> None:
474    async def delete_document(self, document_id: UUID) -> None:
475        """Remove all chunks for a document from the store.
476
477        Convenience wrapper around ``store.delete_where({"document_id": ...})``
478        so callers don't need to know the metadata key.
479        """
480        await self._store.delete_where({"document_id": str(document_id)})

Remove all chunks for a document from the store.

Convenience wrapper around store.delete_where({"document_id": ...}) so callers don't need to know the metadata key.

async def retrieve( self, query: str, top_k: int = 5, metadata_filters: dict[str, typing.Any] | None = None, scope: StoreScope | None = None) -> RetrievalResult:
482    async def retrieve(
483        self,
484        query: str,
485        top_k: int = 5,
486        metadata_filters: dict[str, Any] | None = None,
487        scope: StoreScope | None = None,
488    ) -> RetrievalResult:
489        """Embed ``query`` and return the top ``top_k`` matches from the store.
490
491        Args:
492            query: The text to embed and search with.
493            top_k: Maximum number of results.
494            metadata_filters: Additional equality filters on chunk metadata.
495            scope: Restricts the search to entries written with the same
496                scope. Leave ``None`` to search across all scopes.
497
498        Raises:
499            EmbeddingModelMismatchError: When the embedder reports a model
500                different from the one captured on first ingest.
501        """
502        await self._ensure_captured_model_seeded()
503        text_result = await self._embedder.aembed([query])
504        self._check_model(text_result.metrics.model)
505
506        store_query = StoreQuery(
507            text=query,
508            scope=scope,
509            embedding=text_result.vectors[0],
510            top_k=top_k,
511            metadata_filters=metadata_filters,
512        )
513        store_hits = await self._store.read(store_query)
514        chunks = [
515            RetrievedChunk(
516                chunk=_entry_to_chunk(hit.entry),
517                score=hit.score,
518                rank=hit.rank,
519                source_retriever=hit.source_retriever,
520                rerank_score=hit.rerank_score,
521            )
522            for hit in store_hits
523        ]
524        result = RetrievalResult(query=query, chunks=chunks)
525        if self._on_retrieve is not None:
526            self._on_retrieve(query, result)
527        return result

Embed query and return the top top_k matches from the store.

Arguments:
  • query: The text to embed and search with.
  • top_k: Maximum number of results.
  • metadata_filters: Additional equality filters on chunk metadata.
  • scope: Restricts the search to entries written with the same scope. Leave None to search across all scopes.
Raises:
  • EmbeddingModelMismatchError: When the embedder reports a model different from the one captured on first ingest.
@dataclass
class RetrievedChunk:
93@dataclass
94class RetrievedChunk:
95    chunk: Chunk
96    score: float
97    rank: int
98    source_retriever: str | None = None
99    rerank_score: float | None = None
RetrievedChunk( chunk: Chunk, score: float, rank: int, source_retriever: str | None = None, rerank_score: float | None = None)
chunk: Chunk
score: float
rank: int
source_retriever: str | None = None
rerank_score: float | None = None
@runtime_checkable
class Store(typing.Protocol):
10@runtime_checkable
11class Store(Protocol):
12    async def write(self, entry: StoreEntry) -> str: ...
13    async def read(self, query: StoreQuery) -> list[RetrievedStoreEntry]: ...
14    async def delete(self, id: UUID) -> None: ...
15    async def clear(self, scope: StoreScope) -> None: ...
16    async def delete_where(self, filters: dict[str, Any]) -> None: ...
17    async def find(
18        self, filters: dict[str, Any], limit: int = 1
19    ) -> list[StoreEntry]: ...
20    async def count(self, filters: dict[str, Any] | None = None) -> int: ...

Base class for protocol classes.

Protocol classes are defined as::

class Proto(Protocol):
    def meth(self) -> int:
        ...

Such classes are primarily used with static type checkers that recognize structural subtyping (static duck-typing), for example::

class C:
    def meth(self) -> int:
        return 0

def func(x: Proto) -> int:
    return x.meth()

func(C())  # Passes static type check

See PEP 544 for details. Protocol classes decorated with @typing.runtime_checkable act as simple-minded runtime protocols that check only the presence of given attributes, ignoring their type signatures. Protocol classes can be generic, they are defined as::

class GenProto(Protocol[T]):
    def meth(self) -> T:
        ...
Store(*args, **kwargs)
1431def _no_init_or_replace_init(self, *args, **kwargs):
1432    cls = type(self)
1433
1434    if cls._is_protocol:
1435        raise TypeError('Protocols cannot be instantiated')
1436
1437    # Already using a custom `__init__`. No need to calculate correct
1438    # `__init__` to call. This can lead to RecursionError. See bpo-45121.
1439    if cls.__init__ is not _no_init_or_replace_init:
1440        return
1441
1442    # Initially, `__init__` of a protocol subclass is set to `_no_init_or_replace_init`.
1443    # The first instantiation of the subclass will call `_no_init_or_replace_init` which
1444    # searches for a proper new `__init__` in the MRO. The new `__init__`
1445    # replaces the subclass' old `__init__` (ie `_no_init_or_replace_init`). Subsequent
1446    # instantiation of the protocol subclass will thus use the new
1447    # `__init__` and no longer call `_no_init_or_replace_init`.
1448    for base in cls.__mro__:
1449        init = base.__dict__.get('__init__', _no_init_or_replace_init)
1450        if init is not _no_init_or_replace_init:
1451            cls.__init__ = init
1452            break
1453    else:
1454        # should not happen
1455        cls.__init__ = object.__init__
1456
1457    cls.__init__(self, *args, **kwargs)
async def write(self, entry: StoreEntry) -> str:
12    async def write(self, entry: StoreEntry) -> str: ...
async def read( self, query: StoreQuery) -> list[railtracks.retrieval.stores.models.RetrievedStoreEntry]:
13    async def read(self, query: StoreQuery) -> list[RetrievedStoreEntry]: ...
async def delete(self, id: uuid.UUID) -> None:
14    async def delete(self, id: UUID) -> None: ...
async def clear(self, scope: StoreScope) -> None:
15    async def clear(self, scope: StoreScope) -> None: ...
async def delete_where(self, filters: dict[str, typing.Any]) -> None:
16    async def delete_where(self, filters: dict[str, Any]) -> None: ...
async def find( self, filters: dict[str, typing.Any], limit: int = 1) -> list[StoreEntry]:
17    async def find(
18        self, filters: dict[str, Any], limit: int = 1
19    ) -> list[StoreEntry]: ...
async def count(self, filters: dict[str, typing.Any] | None = None) -> int:
20    async def count(self, filters: dict[str, Any] | None = None) -> int: ...
@dataclass
class StoreEntry:
 44@dataclass
 45class StoreEntry:
 46    # Required fields
 47    id: UUID
 48    content: str
 49    vector: list[float] | None
 50    embedding_model: str
 51    chunk_id: UUID
 52    document_id: UUID
 53    # Optional enrichment fields
 54    abstract: str | None = None
 55    summary: str | None = None
 56    scope: StoreScope | None = None
 57    # Optional chunk provenance
 58    chunk_index: int = 0
 59    parent_chunk_id: UUID | None = None
 60    chunk_offsets: tuple[int, int] | None = None
 61    chunk_metadata: dict = field(default_factory=dict)
 62    # Optional embedding provenance
 63    embedding_version: str | None = None
 64    # Optional store metadata
 65    entities: list[Entity] | None = None
 66    valid_from: datetime | None = None
 67    valid_until: datetime | None = None
 68    created_at: datetime = field(default_factory=lambda: datetime.now(tz=timezone.utc))
 69
 70    @classmethod
 71    def from_chunk(
 72        cls,
 73        embedded_chunk: EmbeddedChunk,
 74        *,
 75        scope: StoreScope | None = None,
 76        abstract: str | None = None,
 77        summary: str | None = None,
 78        entities: list[Entity] | None = None,
 79        valid_from: datetime | None = None,
 80        valid_until: datetime | None = None,
 81    ) -> StoreEntry:
 82        chunk = embedded_chunk.chunk
 83        return cls(
 84            id=chunk.id,
 85            content=chunk.content,
 86            vector=embedded_chunk.vector,
 87            embedding_model=embedded_chunk.embedding_model,
 88            embedding_version=embedded_chunk.embedding_version,
 89            chunk_id=chunk.id,
 90            document_id=chunk.document_id,
 91            chunk_index=chunk.index,
 92            parent_chunk_id=chunk.parent_chunk_id,
 93            chunk_offsets=chunk.offsets,
 94            chunk_metadata=chunk.metadata,
 95            scope=scope,
 96            abstract=abstract,
 97            summary=summary,
 98            entities=entities,
 99            valid_from=valid_from,
100            valid_until=valid_until,
101        )
StoreEntry( id: uuid.UUID, content: str, vector: list[float] | None, embedding_model: str, chunk_id: uuid.UUID, document_id: uuid.UUID, abstract: str | None = None, summary: str | None = None, scope: StoreScope | None = None, chunk_index: int = 0, parent_chunk_id: uuid.UUID | None = None, chunk_offsets: tuple[int, int] | None = None, chunk_metadata: dict = <factory>, embedding_version: str | None = None, entities: list[railtracks.retrieval.stores.models.Entity] | None = None, valid_from: datetime.datetime | None = None, valid_until: datetime.datetime | None = None, created_at: datetime.datetime = <factory>)
id: uuid.UUID
content: str
vector: list[float] | None
embedding_model: str
chunk_id: uuid.UUID
document_id: uuid.UUID
abstract: str | None = None
summary: str | None = None
scope: StoreScope | None = None
chunk_index: int = 0
parent_chunk_id: uuid.UUID | None = None
chunk_offsets: tuple[int, int] | None = None
chunk_metadata: dict
embedding_version: str | None = None
entities: list[railtracks.retrieval.stores.models.Entity] | None = None
valid_from: datetime.datetime | None = None
valid_until: datetime.datetime | None = None
created_at: datetime.datetime
@classmethod
def from_chunk( cls, embedded_chunk: EmbeddedChunk, *, scope: StoreScope | None = None, abstract: str | None = None, summary: str | None = None, entities: list[railtracks.retrieval.stores.models.Entity] | None = None, valid_from: datetime.datetime | None = None, valid_until: datetime.datetime | None = None) -> StoreEntry:
 70    @classmethod
 71    def from_chunk(
 72        cls,
 73        embedded_chunk: EmbeddedChunk,
 74        *,
 75        scope: StoreScope | None = None,
 76        abstract: str | None = None,
 77        summary: str | None = None,
 78        entities: list[Entity] | None = None,
 79        valid_from: datetime | None = None,
 80        valid_until: datetime | None = None,
 81    ) -> StoreEntry:
 82        chunk = embedded_chunk.chunk
 83        return cls(
 84            id=chunk.id,
 85            content=chunk.content,
 86            vector=embedded_chunk.vector,
 87            embedding_model=embedded_chunk.embedding_model,
 88            embedding_version=embedded_chunk.embedding_version,
 89            chunk_id=chunk.id,
 90            document_id=chunk.document_id,
 91            chunk_index=chunk.index,
 92            parent_chunk_id=chunk.parent_chunk_id,
 93            chunk_offsets=chunk.offsets,
 94            chunk_metadata=chunk.metadata,
 95            scope=scope,
 96            abstract=abstract,
 97            summary=summary,
 98            entities=entities,
 99            valid_from=valid_from,
100            valid_until=valid_until,
101        )
@dataclass
class StoreQuery:
113@dataclass
114class StoreQuery:
115    text: str
116    scope: StoreScope | None = None
117    embedding: list[float] | None = None
118    top_k: int = 10
119    metadata_filters: dict[str, Any] | None = None
StoreQuery( text: str, scope: StoreScope | None = None, embedding: list[float] | None = None, top_k: int = 10, metadata_filters: dict[str, typing.Any] | None = None)
text: str
scope: StoreScope | None = None
embedding: list[float] | None = None
top_k: int = 10
metadata_filters: dict[str, typing.Any] | None = None
@dataclass(frozen=True)
class StoreScope:
21@dataclass(frozen=True)
22class StoreScope:
23    """Equality-filter namespace for store entries.
24
25    Each entry in ``labels`` becomes a mandatory equality filter on every
26    write and read. The retrieval module is agnostic about what dimensions
27    you scope by — pick whichever axes fit your tenancy model::
28
29        StoreScope(labels={"user_id": "alice"})  # SaaS tenancy
30        StoreScope(labels={"organization": "acme", "environment": "prod"})  # B2B
31        StoreScope(labels={"agent_id": "docs-bot", "session_id": "s1"})  # agent context
32        StoreScope(labels={"account_id": 42, "is_prod": True})  # non-string scalars
33
34    The ``scope_`` prefix applied in :meth:`to_payload_filters` avoids key
35    collisions in flat payload dicts that also carry content fields.
36    """
37
38    labels: Mapping[str, Any] = field(default_factory=dict)
39
40    def to_payload_filters(self) -> dict[str, Any]:
41        return {f"scope_{k}": v for k, v in self.labels.items()}

Equality-filter namespace for store entries.

Each entry in labels becomes a mandatory equality filter on every write and read. The retrieval module is agnostic about what dimensions you scope by — pick whichever axes fit your tenancy model::

StoreScope(labels={"user_id": "alice"})  # SaaS tenancy
StoreScope(labels={"organization": "acme", "environment": "prod"})  # B2B
StoreScope(labels={"agent_id": "docs-bot", "session_id": "s1"})  # agent context
StoreScope(labels={"account_id": 42, "is_prod": True})  # non-string scalars

The scope_ prefix applied in to_payload_filters() avoids key collisions in flat payload dicts that also carry content fields.

StoreScope(labels: Mapping[str, typing.Any] = <factory>)
labels: Mapping[str, typing.Any]
def to_payload_filters(self) -> dict[str, typing.Any]:
40    def to_payload_filters(self) -> dict[str, Any]:
41        return {f"scope_{k}": v for k, v in self.labels.items()}
class StrExtractor:
16class StrExtractor:
17    """Render values with Python's built-in string conversion."""
18
19    def __call__(self, value: Any) -> str:
20        return str(value)

Render values with Python's built-in string conversion.

class VectorStore:
184class VectorStore:
185    """Cosine similarity search over StoreEntry vectors.
186
187    Satisfies the Store protocol. Does not inherit from any base class.
188    """
189
190    def __init__(self, backend: VectorBackend) -> None:
191        self._backend = backend
192
193    async def write(self, entry: StoreEntry) -> str:
194        if entry.vector is None:
195            raise ValueError(
196                f"VectorStore.write requires entry.vector to be set "
197                f"(entry_id={entry.id}); embed the chunk before writing."
198            )
199        await self._backend.upsert(
200            str(entry.id), entry.vector, _entry_to_payload(entry)
201        )
202        return str(entry.id)
203
204    async def read(self, query: StoreQuery) -> list[RetrievedStoreEntry]:
205        if query.embedding is None:
206            raise ValueError(
207                "VectorStore.read requires query.embedding to be set; "
208                "caller must supply a pre-computed embedding."
209            )
210
211        filters: dict[str, Any] = (
212            query.scope.to_payload_filters() if query.scope is not None else {}
213        )
214        if query.metadata_filters:
215            filters.update(query.metadata_filters)
216
217        raw_hits = await self._backend.search(query.embedding, query.top_k, filters)
218
219        results: list[RetrievedStoreEntry] = []
220        for rank, (hit_id, score, payload) in enumerate(raw_hits):
221            entry = _payload_to_entry(hit_id, payload)
222            results.append(
223                RetrievedStoreEntry(
224                    entry=entry,
225                    score=score,
226                    rank=rank,
227                    source_retriever="dense",
228                )
229            )
230        return results
231
232    async def delete(self, id: UUID) -> None:
233        await self._backend.delete(str(id))
234
235    async def clear(self, scope: StoreScope) -> None:
236        await self._backend.delete_where(scope.to_payload_filters())
237
238    async def delete_where(self, filters: dict[str, Any]) -> None:
239        await self._backend.delete_where(filters)
240
241    async def find(self, filters: dict[str, Any], limit: int = 1) -> list[StoreEntry]:
242        raw_hits = await self._backend.list_where(filters, limit)
243        return [_payload_to_entry(hit_id, payload) for hit_id, payload in raw_hits]
244
245    async def count(self, filters: dict[str, Any] | None = None) -> int:
246        return await self._backend.count(filters or {})
247
248    async def nearest_neighbors(
249        self,
250        embedding: list[float],
251        k: int,
252        scope: StoreScope | None = None,
253    ) -> list[RetrievedStoreEntry]:
254        filters = scope.to_payload_filters() if scope is not None else {}
255        raw_hits = await self._backend.search(embedding, k, filters)
256
257        results: list[RetrievedStoreEntry] = []
258        for rank, (hit_id, score, payload) in enumerate(raw_hits):
259            entry = _payload_to_entry(hit_id, payload)
260            results.append(
261                RetrievedStoreEntry(
262                    entry=entry,
263                    score=score,
264                    rank=rank,
265                    source_retriever="dense",
266                )
267            )
268        return results

Cosine similarity search over StoreEntry vectors.

Satisfies the Store protocol. Does not inherit from any base class.

VectorStore(backend: railtracks.retrieval.stores.vector.base.VectorBackend)
190    def __init__(self, backend: VectorBackend) -> None:
191        self._backend = backend
async def write(self, entry: StoreEntry) -> str:
193    async def write(self, entry: StoreEntry) -> str:
194        if entry.vector is None:
195            raise ValueError(
196                f"VectorStore.write requires entry.vector to be set "
197                f"(entry_id={entry.id}); embed the chunk before writing."
198            )
199        await self._backend.upsert(
200            str(entry.id), entry.vector, _entry_to_payload(entry)
201        )
202        return str(entry.id)
async def read( self, query: StoreQuery) -> list[railtracks.retrieval.stores.models.RetrievedStoreEntry]:
204    async def read(self, query: StoreQuery) -> list[RetrievedStoreEntry]:
205        if query.embedding is None:
206            raise ValueError(
207                "VectorStore.read requires query.embedding to be set; "
208                "caller must supply a pre-computed embedding."
209            )
210
211        filters: dict[str, Any] = (
212            query.scope.to_payload_filters() if query.scope is not None else {}
213        )
214        if query.metadata_filters:
215            filters.update(query.metadata_filters)
216
217        raw_hits = await self._backend.search(query.embedding, query.top_k, filters)
218
219        results: list[RetrievedStoreEntry] = []
220        for rank, (hit_id, score, payload) in enumerate(raw_hits):
221            entry = _payload_to_entry(hit_id, payload)
222            results.append(
223                RetrievedStoreEntry(
224                    entry=entry,
225                    score=score,
226                    rank=rank,
227                    source_retriever="dense",
228                )
229            )
230        return results
async def delete(self, id: uuid.UUID) -> None:
232    async def delete(self, id: UUID) -> None:
233        await self._backend.delete(str(id))
async def clear(self, scope: StoreScope) -> None:
235    async def clear(self, scope: StoreScope) -> None:
236        await self._backend.delete_where(scope.to_payload_filters())
async def delete_where(self, filters: dict[str, typing.Any]) -> None:
238    async def delete_where(self, filters: dict[str, Any]) -> None:
239        await self._backend.delete_where(filters)
async def find( self, filters: dict[str, typing.Any], limit: int = 1) -> list[StoreEntry]:
241    async def find(self, filters: dict[str, Any], limit: int = 1) -> list[StoreEntry]:
242        raw_hits = await self._backend.list_where(filters, limit)
243        return [_payload_to_entry(hit_id, payload) for hit_id, payload in raw_hits]
async def count(self, filters: dict[str, typing.Any] | None = None) -> int:
245    async def count(self, filters: dict[str, Any] | None = None) -> int:
246        return await self._backend.count(filters or {})
async def nearest_neighbors( self, embedding: list[float], k: int, scope: StoreScope | None = None) -> list[railtracks.retrieval.stores.models.RetrievedStoreEntry]:
248    async def nearest_neighbors(
249        self,
250        embedding: list[float],
251        k: int,
252        scope: StoreScope | None = None,
253    ) -> list[RetrievedStoreEntry]:
254        filters = scope.to_payload_filters() if scope is not None else {}
255        raw_hits = await self._backend.search(embedding, k, filters)
256
257        results: list[RetrievedStoreEntry] = []
258        for rank, (hit_id, score, payload) in enumerate(raw_hits):
259            entry = _payload_to_entry(hit_id, payload)
260            results.append(
261                RetrievedStoreEntry(
262                    entry=entry,
263                    score=score,
264                    rank=rank,
265                    source_retriever="dense",
266                )
267            )
268        return results