Python API¶
Not a stable interface
kennis.engine exports nothing from its __init__, and v0.1 has no
curated public API. What is shown here is the surface a caller
plausibly reaches for. Everything else under kennis.engine is
internal and will change without a deprecation path, because
this project keeps none.
The command line is the supported interface. See Commands.
The docstrings below are essays about why a mechanism has the shape it does,
because that is what they are in the source. Parameter types come from the
annotations, which are complete - kennis is checked with mypy in strict mode
and has no bare dict and no type: ignore.
Searching¶
search
¶
Ranked search over a built index.
Two retrievers run over one ordered chunk list and are combined by
reciprocal rank fusion: a chunk scores sum 1 / (k + rank) over the
lists it appears in, and only its position in each list is used.
Fusing positions rather than scores is the whole point. BM25 returns an
unbounded, corpus-relative term score; cosine similarity returns a number in
[-1, 1]. Normalising one onto the other means knowing both distributions,
which change with every build. Positions need no such knowledge, and with one
retriever available the result is simply that retriever's ordering - so a
lexical-only index, or a query with no lexical overlap, degrades cleanly
rather than as a special case.
DocumentSpan
dataclass
¶
A contiguous run of one document's chunks, stitched back into text.
Every coordinate here is one search also surfaces, so a hit's
document_id and chunk_index go straight back into a read request.
Source code in src/kennis/engine/rag/search.py
ChunkRange
dataclass
¶
Which chunks of a document to read, as a half-open range.
None at either end means "as far as the document goes", and a negative
index counts from the end, both exactly as a python slice does - because
resolving one is a python slice, so the semantics are borrowed rather
than reimplemented.
There is no step. A stepped selection would stitch non-adjacent chunks
into one passage: holes presented as continuous prose, and char_start
and char_end describing a range the text does not fill. A passage that
is not contiguous is not a passage. Concern #205.
Source code in src/kennis/engine/rag/search.py
resolve
¶
The concrete [start, stop) over a document of count chunks.
search
¶
search(index: LoadedIndex, question: str, *, top_k: int = _DEFAULT_TOP_K, filters: list[Filter] | None = None, mode: Mode = 'hybrid', embedder: Embedder | None = None, host: str | None = None, events: EventSink | None = None) -> list[SearchResult]
The best top_k chunks for question, best first.
The query is embedded with the index's own binding, read back from the index rather than taken from the caller's current configuration. A query vector computed with a different model is meaningless against these rows, and would not error - it would return confident nonsense.
host is the exception, and it is not one: an address is not part of
what the vectors are, so it is not in the stored binding and has to come
from the configuration in force now. Without it an ollama index cannot be
searched at all, because validate_binding refuses a daemon backend with
nowhere to reach. Concern #210.
filters are applied to each leg's candidate list before fusion. That is
the cheap choice, and it has a consequence worth knowing: a filter
selective enough that its matches rank below the candidate window returns
fewer results than exist, or none. Nothing false is returned; the answer
is incomplete rather than wrong.
Source code in src/kennis/engine/rag/search.py
parse_chunk_range
¶
parse_chunk_range(text: str) -> ChunkRange
--chunks as a range, or InputError naming the forms that work.
Engine-side rather than in the command line, because the syntax is
kennis's own: an MCP tool taking chunks="0:3" has to get the same
answer, and a second parser is how two front ends come to disagree about
what -1 means.
Source code in src/kennis/engine/rag/search.py
read_span
¶
read_span(index: LoadedIndex, document_id: str, *, chunks: ChunkRange) -> DocumentSpan
One contiguous run of a document's chunks, stitched back into text.
The range is resolved against the chunks this document actually has, in
the order it has them, so -1 is its last chunk rather than the last row
of the index.
Source code in src/kennis/engine/rag/search.py
Settings¶
The Settings model itself is on the
Configuration page.
The model itself is on the Configuration page.
settings
¶
What can be tuned, and where an answer comes from.
Precedence is the environment, then config.toml, then the field's default.
The environment is read by exact name, not by splitting on a delimiter.
pydantic-settings offers env_nested_delimiter="_", and design section 13
specifies against it for a reason that is easy to verify and easy to miss:
KENNIS_EMBEDDING_BACKEND -> embedding.backend works
KENNIS_EMBEDDING_API_KEY_ENV -> embedding.api.key.env matches nothing
The second is dropped in silence. No error, no warning; the field keeps
its default, and a user who set the variable and watched it do nothing has no
way to find out why. So the source below walks the model's own sections and
fields and looks up KENNIS_<SECTION>_<FIELD> by exact name.
Only settings something reads are offered. A setting nothing honours is
worse than a missing one: it is a promise. ingestion.use_mcp_sampling,
which the design marks deferred, is deliberately absent.
Credentials are not settings. config show writes this model to standard
output by design, so a user debugging will paste it into an issue. A key here
would make every printing path need redaction, and redaction is the step that
gets forgotten when a new printing path is added. Keys live outside this
model, and credential below is the one function that reads them.
config_path
¶
credentials_path
¶
load_settings
¶
load_settings() -> Settings
Every setting, resolved.
A value that is refused is named with where it came from, and the
remedy is the one that removes it: pointing at config show for an
exported variable sent the reader to a file with nothing wrong in it.
Source code in src/kennis/engine/settings.py
Events¶
An operation reports progress by emitting events. A front end subscribes; nothing in the engine knows what a progress bar is.
events
¶
What a long-running engine operation reports while it runs.
Rather than returning only a final result, an operation emits a sequence of typed events: an item was started, an item finished with a particular outcome and reason, progress advanced, a diagnostic was produced, the operation finished with a summary. A front end subscribes and decides what to do with them.
One mechanism, four consumers. The command line prints operation lines and detail markers and drives the progress bar; the MCP server accumulates events and returns a summary; a graphical interface updates widgets; and logging is a subscriber that writes every event to the log file. That last one is the reason to prefer a stream over a progress callback: the log has to record what the report omits - which fifteen documents were skipped and why each one - and with a stream the logging subscriber receives every event by construction, so the asymmetry between the log and the report is a difference in filtering rather than a difference in instrumentation.
Events are frozen: an event is a record of something that already happened, so no subscriber may edit one before the next sees it.
Outcome
¶
Bases: Enum
What became of one item.
The same vocabulary the report uses: ADDED, REMOVED and CHANGED are
what the detail markers +, - and ~ render, UNCHANGED is the =
that carries no colour because it is the absence of news, and SKIPPED
and FAILED are the two ways an item can be passed over - one deliberate,
one not.
Source code in src/kennis/engine/events.py
Severity
¶
Bases: Enum
How much attention a diagnostic is asking for.
Three, matching the three shapes the terminal has for one: warning: and
error: at the margin, and hint: indented under the line it follows.
Source code in src/kennis/engine/events.py
ItemStarted
dataclass
¶
Work began on one item. Named so a front end can show what is in flight before there is an outcome to report.
Source code in src/kennis/engine/events.py
ItemFinished
dataclass
¶
Work on one item ended, with an outcome and optionally why.
The refusal is what the log keeps and the report usually drops: the
report says a fetch skipped fifteen documents, the log says which
fifteen and why each one. kennis corpus add is the exception and
prints its failures, because one refused paper is not fifteen.
refusal is typed rather than a sentence for the reason
Diagnostic.detail is - the engine names the condition and carries
its facts, and render/refusals.py chooses the words. A caller whose
reason is genuinely a name produced elsewhere wraps it in Quoted.
Source code in src/kennis/engine/events.py
Progress
dataclass
¶
How far through the operation is.
total is absent where it is not yet known - chunking cannot say how many
chunks there are until it has finished - and a front end drawing a bar
renders that as an indeterminate spinner until a later event supplies one.
Source code in src/kennis/engine/events.py
DocumentUnsearchable
dataclass
¶
A corpus document an indexer could not read, so it is left out.
problem is what the reader could say about it and is None when it
could say nothing, which is a different sentence rather than an
empty one.
Source code in src/kennis/engine/events.py
FrontmatterUnreadable
dataclass
¶
A bundle file whose header would not parse. Indexed without it.
Source code in src/kennis/engine/events.py
TitleDotStripped
dataclass
¶
A title that looked like a dotfile name, so the dot was dropped.
Source code in src/kennis/engine/events.py
DocumentSkipped
dataclass
¶
A document that could not be read while adding, and was not touched.
Distinct from DocumentUnsearchable although both are an unreadable
document: the consequence differs, and the consequence is what a
reader needs. One will not be found by a search; this one was left
exactly as it was.
Source code in src/kennis/engine/events.py
MetadataUnavailable
dataclass
¶
A paper whose metadata lookup returned nothing.
The document is still written - refusing to store a paper because a service was unreachable trades a small loss for a total one - so this says what was lost rather than that something failed.
Source code in src/kennis/engine/events.py
NoPagesDiscovered
dataclass
¶
A documentation site that yielded nothing.
path_prefix is the surprising half and is None when there was
none: it is derived from the URL, so a link deeper than the
documentation root narrows the crawl without anyone asking.
Source code in src/kennis/engine/events.py
PassagesCutShort
dataclass
¶
Passages an index build embedded that model does not read whole
(#577).
count of the total embedded in this build were longer than limit
tokens. shortened says who cut them: True when kennis shortened what
it sent, so a request could not be refused for length (OpenAI); False
when the model truncates by itself and only the count is kennis's
(fastembed). Either way the keyword index holds the whole passage.
Source code in src/kennis/engine/events.py
Diagnostic
dataclass
¶
Something worth saying that is not an item's outcome.
The engine names the condition; it does not phrase it. detail
says what happened and carries the facts as fields, and render/
turns one into a sentence. This used to be a message: str the
engine composed and the command line printed unchanged, which was
the one event type that bypassed render/ entirely - harmless with
a single front end and wrong the moment there are two. Concern #285.
severity stays here rather than on each condition because it is
orthogonal: it says how loudly to say a thing, not what the thing
is, and it is what a front end switches on to choose its prefix.
resolution is the command that would fix it, carried for the same
reason KennisError carries one: the advice is chosen where the
problem is found, not reconstructed afterwards from state.
Source code in src/kennis/engine/events.py
OperationFinished
dataclass
¶
The operation ended. counts is the per-outcome tally the report
summarises, and elapsed_seconds is what the operation line prints as
in 1.4s.
Source code in src/kennis/engine/events.py
EventSink
¶
Bases: Protocol
What an operation is handed to report through.
One method, because the engine's side of this is deliberately trivial: a front end that wants to batch, filter or forward does so in its own implementation rather than by being asked for a richer interface.
Source code in src/kennis/engine/events.py
DiscardingSink
¶
Recorder
¶
An EventSink that keeps everything, so a test can assert what an
operation reported without driving a front end.