Ports
The thirteen Protocols every adapter conforms to.
Thirteen Protocols, in pipeline order. An adapter conforms by shape: it imports and
subclasses nothing from ragsage, so the dependency arrow only ever points inward.
The ports — every seam where a real provider plugs into the engine.
These are the entire contract between ragsage and the outside world.
Everything model-shaped or storage-shaped lives behind one of them, so the
library depends on no provider SDK and no database driver. The backend (or the
CLI, or a test) supplies an adapter for each; the pipelines in
ragsage.ingestion and ragsage.query orchestrate them and nothing
else.
Every one is a typing.Protocol on purpose: an adapter conforms by shape,
so it need not import or subclass anything from ragsage. That keeps the
dependency arrow pointing strictly inward — adapters know about the engine, the
engine never knows about them.
I/O-bound ports (models, stores, cache) are async; CPU-bound local ports
(parser, classifier, chunker, tracer) are synchronous, mirroring how their real
implementations behave — Docling parses on a thread, an embedder awaits the
gateway.
class ragsage.ports.DocumentParser(*args, **kwargs)
Bases: Protocol
Turns raw bytes into a structured document with per-page content.
Extracts the born-digital text layer where one exists and surfaces a
PageImage where a page must be read by vision instead. It does not
itself run OCR — routing that decision is the classifier’s and the
pipeline’s job, keeping parsers (Docling, others) swappable.
parse(source: RawSource) → ParsedDocument
class ragsage.ports.PageClassifier(*args, **kwargs)
Bases: Protocol
Decides, per page, whether to trust its text layer or send it to vision.
classify(page: Page) → PageRoute
class ragsage.ports.Chunker(*args, **kwargs)
Bases: Protocol
Splits a document’s resolved page text into structure-aware chunks.
Receives the page text after vision transcription has filled in image pages, so it never has to care which route a page took.
chunk(document: Document, pages: Sequence[Page], *, size: int, overlap: int) → Sequence[Chunk]
class ragsage.ports.Contextualizer(*args, **kwargs)
Bases: Protocol
Writes a short context sentence situating a chunk in its parent document.
Returns the text to embed (context + chunk). Implemented with a cheap, prompt-cached model in production; the engine stores this separately from the verbatim chunk so display stays clean.
async contextualize(document: Document, chunk: Chunk, *, full_text: str) → str
class ragsage.ports.Embedder(*args, **kwargs)
Bases: Protocol
Maps texts to dense vectors. Batched, because real embedders are.
async embed(texts: Sequence[str]) → Sequence[tuple[float, ...]]
class ragsage.ports.Reranker(*args, **kwargs)
Bases: Protocol
Reorders candidates against the query with a cross-encoder, keeping top-k.
async rerank(query: str, candidates: Sequence[ScoredChunk], *, top_k: int) → Sequence[ScoredChunk]
class ragsage.ports.LLMClient(*args, **kwargs)
Bases: Protocol
The generation (and vision) model, behind an OpenAI-compatible shape.
generate streams answer tokens so the backend can relay them over SSE;
the engine assembles them into an Answer.
transcribe is the vision call the two-path parser routes image pages to
— modelled here because, like every other model call, it goes through the
same gateway rather than a bespoke OCR dependency.
generate(prompt: str) → AsyncIterator[str]
async transcribe(image: PageImage) → str
class ragsage.ports.QueryRewriter(*args, **kwargs)
Bases: Protocol
Condenses a follow-up plus conversation history into a standalone question.
Multi-turn chat asks things like “what about its pricing?” whose meaning
lives in earlier turns. Given the latest question and the history
before it, this returns a self-contained question that names its own
referents, so retrieval matches on resolved terms rather than a bare pronoun.
It is its own port (not folded into LLMClient) because production
condensation runs on a cheap, fast model — the same reason
Contextualizer is separate from generation — and because a caller
with no conversation history can skip it entirely.
async rewrite(question: str, history: Sequence[Turn]) → str
class ragsage.ports.VectorStore(*args, **kwargs)
Bases: Protocol
Dense-vector persistence and nearest-neighbour search, scoped.
Every method takes a Scope; the store keys on scope.namespace
and honours scope.filters as part of the query, never post-hoc. In
production this is one pgvector table under Row-Level Security.
async upsert(scope: Scope, records: Sequence[EmbeddedChunk]) → None
async search(scope: Scope, query_vector: tuple[float, ...], *, k: int) → Sequence[ScoredChunk]
async delete(scope: Scope, document_id: str) → None
class ragsage.ports.LexicalStore(*args, **kwargs)
Bases: Protocol
Keyword/BM25 persistence and search, scoped like the vector store.
Its results are fused with the vector store’s to form hybrid retrieval.
async index(scope: Scope, chunks: Sequence[Chunk]) → None
async search(scope: Scope, query: str, *, k: int) → Sequence[ScoredChunk]
async delete(scope: Scope, document_id: str) → None
class ragsage.ports.DocumentStore(*args, **kwargs)
Bases: Protocol
The document-level record: dedup, listing, and purge-on-delete.
find_by_hash powers content-hash dedup so re-uploading a file is a
no-op; delete here removes the document row while the vector and lexical
stores purge their own copies — together that is a true delete, not a hide.
async save(scope: Scope, document: Document, chunks: Sequence[Chunk]) → None
async find_by_hash(scope: Scope, content_hash: str) → Document | None
async get(scope: Scope, document_id: str) → Document | None
async list(scope: Scope) → Sequence[Document]
async delete(scope: Scope, document_id: str) → None
class ragsage.ports.Cache(*args, **kwargs)
Bases: Protocol
A best-effort key/value cache — e.g. to skip recomputing contextualisation.
A miss returns None; the engine treats the cache as an optimisation and
is always correct without it.
async get(key: str) → str | None
async set(key: str, value: str) → None
class ragsage.ports.Span(*args, **kwargs)
Bases: Protocol
One node in an end-to-end trace — a single pipeline stage, timed.
Opened by Tracer.span() (the request root) or Span.child() (a
nested stage) and used as a context manager, so the stage’s duration is the
with block. set() attaches attributes discovered mid-stage (how
many candidates survived rerank, which chunk ids reached the prompt, whether
the answer was grounded) — exactly the breadcrumbs that let a bad answer be
tracked to the stage that produced it. A child inherits its parent’s tenant
tag, so every node in a trace is tenant-scoped and no trace can straddle two.
Like Tracer, no method may raise: observability must never break a
query, even when the backing sink (Langfuse, a log) is down.
set(**attributes: object) → None
child(name: str, **attributes: object) → Span
class ragsage.ports.Tracer(*args, **kwargs)
Bases: Protocol
A structured sink for observability across pipeline stages.
Two shapes, one sink. event() records a flat point event (page routed,
query rewritten) — the lightweight breadcrumb the CLI and tests read back.
span() opens the root of an end-to-end trace, tagged with the caller’s
tenant, under which the query engine nests one child Span per stage
(retrieval → rerank → prompt → generation); an adapter fans the whole tree to
Langfuse. Neither may raise — observability is not allowed to break a query.
event(name: str, **fields: object) → None
span(name: str, **attributes: object) → Span
class ragsage.ports.NullSpan
Bases: object
A Span that records nothing — the no-op the null tracer hands out.
set(**attributes: object) → None
child(name: str, **attributes: object) → NullSpan
class ragsage.ports.NullTracer
Bases: object
A Tracer that discards every event and span.
The pipelines default to this when no tracer is injected, so tracing is always optional and never a required dependency of running the engine.
event(name: str, **fields: object) → None
span(name: str, **attributes: object) → NullSpan
Small talk
The gate in front of retrieval. A greeting was never a question about the corpus, so reporting a miss on it would be a lie about what the engine did.
Recognising the messages that were never questions about the corpus.
A grounded question-answering engine that answers only from retrieved sources has one embarrassing failure: say “hi” to it and it solemnly reports that your greeting could not be found in your documents. The message was never a question about the corpus, so searching it was the wrong thing to do and reporting a miss is a lie about what happened.
This module is the gate in front of retrieval. It is deliberately narrow and deterministic: a curated set of whole-message phrases, matched after normalisation, with no model call, no latency, and no cost. Anything it doesn’t recognise falls through to the grounded path unchanged — so the honest not-found guarantee is untouched for every real question, and the cost of the gate not firing is only that a greeting gets the old reply.
Matching is whole-message on purpose. “hi” is small talk; “hi, what does the contract say about termination?” is a question that happens to open politely, and must still be retrieved and cited. A rule that fired on a greeting prefix would quietly route real questions away from the corpus — the one failure mode worth engineering against here, because it turns a citable answer into a chatty non-answer.
The gate is a hint, not a verdict: ragsage.query still runs the reply past
the model with a prompt that refuses to state facts, so a false positive degrades
to the honest not-found rather than to an invented answer.
ragsage.smalltalk.SMALL_TALK : frozenset[str] = frozenset({'appreciate it', 'are you there', 'bye', 'cheers', 'cool', 'good afternoon', 'good bye', 'good day', 'good evening', 'good morning', 'good night', 'goodbye', 'goodnight', 'got it', 'great', 'greetings', 'hello', 'hello there', 'help', 'hey', 'hey there', 'heya', 'hi', 'hi there', 'hiya', 'how are you', 'how are you doing', 'how do i use this', 'how do you work', 'how does this work', 'how is it going', 'howdy', 'hows it going', 'makes sense', 'many thanks', 'much appreciated', 'nice', 'ok', 'okay', 'perfect', 'see ya', 'see you', 'sup', 'test', 'testing', 'thank you', 'thank you so much', 'thank you very much', 'thanks', 'thanks a lot', 'thanks so much', 'ty', 'understood', 'what are you', 'what can i ask', 'what can i ask you', 'what can you do', 'what do you do', 'what is this', 'what should i ask', 'whats up', 'who are you', 'yo', 'you there'})
Whole messages that are conversational rather than questions about the corpus.
Curated and closed on purpose. Adding a phrase here should pass one test: could this ever be a question whose answer lives in someone’s documents? If yes, it does not belong — a false negative costs a chatty reply, a false positive costs a citable answer.
ragsage.smalltalk.normalise(message: str) → str
Reduce a message to bare lowercase words for matching.
Strips case, punctuation, and apostrophes and collapses whitespace, so
"Hi!!", " hi " and "Hi." are all the same message, and
"how's it going?" matches "hows it going".
ragsage.smalltalk.is_small_talk(message: str) → bool
Whether message is conversational rather than a question about the corpus.
True only for a whole message in SMALL_TALK. An empty message is
not small talk — it has nothing to reply to and belongs on the normal path.