ragsage
API reference

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)

[source]

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

[source]

class ragsage.ports.PageClassifier(*args, **kwargs)

[source]

Bases: Protocol

Decides, per page, whether to trust its text layer or send it to vision.

classify(page: Page) → PageRoute

[source]

class ragsage.ports.Chunker(*args, **kwargs)

[source]

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]

[source]

class ragsage.ports.Contextualizer(*args, **kwargs)

[source]

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

[source]

class ragsage.ports.Embedder(*args, **kwargs)

[source]

Bases: Protocol

Maps texts to dense vectors. Batched, because real embedders are.

async embed(texts: Sequence[str]) → Sequence[tuple[float, ...]]

[source]

class ragsage.ports.Reranker(*args, **kwargs)

[source]

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]

[source]

class ragsage.ports.LLMClient(*args, **kwargs)

[source]

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]

[source]

async transcribe(image: PageImage) → str

[source]

class ragsage.ports.QueryRewriter(*args, **kwargs)

[source]

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

[source]

class ragsage.ports.VectorStore(*args, **kwargs)

[source]

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

[source]

async search(scope: Scope, query_vector: tuple[float, ...], *, k: int) → Sequence[ScoredChunk]

[source]

async delete(scope: Scope, document_id: str) → None

[source]

class ragsage.ports.LexicalStore(*args, **kwargs)

[source]

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

[source]

async search(scope: Scope, query: str, *, k: int) → Sequence[ScoredChunk]

[source]

async delete(scope: Scope, document_id: str) → None

[source]

class ragsage.ports.DocumentStore(*args, **kwargs)

[source]

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

[source]

async find_by_hash(scope: Scope, content_hash: str) → Document | None

[source]

async get(scope: Scope, document_id: str) → Document | None

[source]

async list(scope: Scope) → Sequence[Document]

[source]

async delete(scope: Scope, document_id: str) → None

[source]

class ragsage.ports.Cache(*args, **kwargs)

[source]

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

[source]

async set(key: str, value: str) → None

[source]

class ragsage.ports.Span(*args, **kwargs)

[source]

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

[source]

child(name: str, **attributes: object) → Span

[source]

class ragsage.ports.Tracer(*args, **kwargs)

[source]

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

[source]

span(name: str, **attributes: object) → Span

[source]

class ragsage.ports.NullSpan

[source]

Bases: object

A Span that records nothing — the no-op the null tracer hands out.

set(**attributes: object) → None

[source]

child(name: str, **attributes: object) → NullSpan

[source]

class ragsage.ports.NullTracer

[source]

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

[source]

span(name: str, **attributes: object) → NullSpan

[source]

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

[source]

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

[source]

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.

On this page

class ragsage.ports.DocumentParser(*args, **kwargs)parse(source: RawSource) → ParsedDocumentclass ragsage.ports.PageClassifier(*args, **kwargs)classify(page: Page) → PageRouteclass ragsage.ports.Chunker(*args, **kwargs)chunk(document: Document, pages: Sequence[Page], *, size: int, overlap: int) → Sequence[Chunk]class ragsage.ports.Contextualizer(*args, **kwargs)async contextualize(document: Document, chunk: Chunk, *, full_text: str) → strclass ragsage.ports.Embedder(*args, **kwargs)async embed(texts: Sequence[str]) → Sequence[tuple[float, ...]]class ragsage.ports.Reranker(*args, **kwargs)async rerank(query: str, candidates: Sequence[ScoredChunk], *, top_k: int) → Sequence[ScoredChunk]class ragsage.ports.LLMClient(*args, **kwargs)generate(prompt: str) → AsyncIterator[str]async transcribe(image: PageImage) → strclass ragsage.ports.QueryRewriter(*args, **kwargs)async rewrite(question: str, history: Sequence[Turn]) → strclass ragsage.ports.VectorStore(*args, **kwargs)async upsert(scope: Scope, records: Sequence[EmbeddedChunk]) → Noneasync search(scope: Scope, query_vector: tuple[float, ...], *, k: int) → Sequence[ScoredChunk]async delete(scope: Scope, document_id: str) → Noneclass ragsage.ports.LexicalStore(*args, **kwargs)async index(scope: Scope, chunks: Sequence[Chunk]) → Noneasync search(scope: Scope, query: str, *, k: int) → Sequence[ScoredChunk]async delete(scope: Scope, document_id: str) → Noneclass ragsage.ports.DocumentStore(*args, **kwargs)async save(scope: Scope, document: Document, chunks: Sequence[Chunk]) → Noneasync find_by_hash(scope: Scope, content_hash: str) → Document | Noneasync get(scope: Scope, document_id: str) → Document | Noneasync list(scope: Scope) → Sequence[Document]async delete(scope: Scope, document_id: str) → Noneclass ragsage.ports.Cache(*args, **kwargs)async get(key: str) → str | Noneasync set(key: str, value: str) → Noneclass ragsage.ports.Span(*args, **kwargs)set(**attributes: object) → Nonechild(name: str, **attributes: object) → Spanclass ragsage.ports.Tracer(*args, **kwargs)event(name: str, **fields: object) → Nonespan(name: str, **attributes: object) → Spanclass ragsage.ports.NullSpanset(**attributes: object) → Nonechild(name: str, **attributes: object) → NullSpanclass ragsage.ports.NullTracerevent(name: str, **fields: object) → Nonespan(name: str, **attributes: object) → NullSpanSmall talkragsage.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'})ragsage.smalltalk.normalise(message: str) → strragsage.smalltalk.is_small_talk(message: str) → bool