ef
ef (Embedding Flow) — a facade for semantic-embeddings user journeys.
ef makes the modern semantic-search pipeline — corpus, segmenter, embedder,
vector store, retriever — usable with progressive disclosure: the light case
(a list of strings, search in one or two lines) and the heavy case (huge
corpora, many segmentations/embedders, varied sources and vector DBs) share one
facade. ef is not RAG — it returns ranked segments; bring your own LLM.
The embedder facade is the core surface and is always importable (it costs only numpy):
Embedder— the structural protocol for a batchIterable[str] -> ndarray(n, dim)callable.HashingEmbedder— the dependency-free default embedder (the feature-hashing trick, numpy only); whatingest()resolves to when given no embedder, so the light path needs nothing to install.as_embedder()— the dependency-injection seam (string / callable / URL / existing embedder →Embedder).Adapters:
openai_embedder(),sentence_transformers_embedder(),http_embedder(),cohere_embedder(),voyage_embedder(),gemini_embedder().Composition wrappers:
CachedEmbedder,RetryingEmbedder,MultiEmbedder,NormalizingEmbedder.
The segmenter facade is the other always-importable core surface (it needs no numpy at all):
Segment— the canonical segment data model (theTypedDictinterchange type;SegmentRecordis its dataclass convenience surface).Segmenter— the structural protocol for astr | Mapping -> Iterable[Segment]callable.RecursiveCharacterSegmenter— the default splitter;line_segmenter()a builtin line splitter.as_segmenter()— the dependency-injection seam.Composition helpers:
with_overlap(),hierarchical(),materialise().
The corpus facade is ef’s source layer (L0) — also always importable:
Corpus/Source— the type aliases: a corpus is just aMutableMapping[source_id, Source](anydolstore).as_corpus()— the dependency-injection seam (None/ mapping / directory path / iterable of sources → a corpus).content_hash()— the content hash of a source.ChangeDetectingCorpus— a corpus wrapper that detects and reports changes (ChangeEvent/CorpusDiff).
The artifact graph is ef’s corpus-indexing core (also always importable)
— a content-addressed producer graph in which cascade invalidation and config
branching are the same operation:
ProducerSpec— the declarative recipe for one produced artifact;artifact_id()is its content hash;producer_spec()builds one ergonomically.ArtifactGraph— the graph:materialize(lazy backward),mark_stale/delete_cascade(forward) andfreshness.
The search facade is where the layers come together — corpus → segment →
embed → vd → ranked search, with progressive disclosure:
ingest()— the one-shot light path: a corpus in, aSearchableCorpusout,search(query)ready.SearchableCorpus— the thin ready-search object;search()returns scoredSearchHits,retrieve()returns plain rankedSegments — the RAG-plug-in shape.SourceManager— the heavy facade: multi-config corpus indexing, where configs sharing a step share its artifacts for free.PipelineSpec/TransformSpec/config_id()— a pipeline as serializable, content-hashed data.
The refresh layer keeps an indexed corpus in sync as its sources change:
SourceManager.diagnose— the four staleness conditions, as aStalenessReport.SourceManager.refresh— re-sync the index, returning aRefreshReport; one of fourRefreshModes.SourceManager(auto_refresh=True)keeps the index live as the corpus is edited.
The RAG-plug-in & evaluation layer hands a corpus to an external RAG/agent
framework and measures its quality — ef returns ranked context, it does not
synthesize answers:
SourceManager.retrievereturns plain rankedSegments — the RAG-plug-in shape;hits_to_segments()is the projection fromSearchHits.evaluate_retrieval()— BEIR/MTEB-shaped retrieval scoring (primary metric NDCG@10);read_beir()/write_beir()move the corpus/queries/qrels triple to disk.evaluate_rag()— deterministic lexical RAG metrics overRagSamples;as_ragas_dataset()bridges to Ragas for the LLM-judged metrics.with_reranker()— a two-stage reranking decorator over any retriever;cross_encoder_reranker()is a ready reranker.
The explore layer (L5) is ef’s visualization heritage, kept as a
secondary “see the shape of the corpus” surface — search/RAG/indexing is the
primary one. It imports numpy-only; UMAP, HDBSCAN and imbed are lazy extras:
project()— reduce embeddings to 2-D/3-D coordinates (PCA → UMAP);cluster()— group them (k-means / HDBSCAN);label_clusters()— name clusters with an LLM viaimbed.explore()— the orchestrator: project + cluster (+ label) in one call, returning a structured, JSON-friendlyExploreResult(ids/coords/labels).
The service layer bridges ef’s stateful objects to a stateless
transport — hand its methods to qh.mk_app() to serve ef over HTTP:
EfService— a facade holding a{corpus_id: SourceManager}handle registry:create_corpus()indexes a corpus,search()/retrieve()query it by id.CorpusInfois its JSON-friendly summary.
Example — wrap a plain function and embed two strings:
>>> import numpy as np
>>> from ef import as_embedder
>>> embedder = as_embedder(
... lambda texts: np.array([[len(t)] for t in texts]), model_id='len@1'
... )
>>> embedder(['hello', 'hi']).ravel().tolist()
[5.0, 2.0]
- class ef.ArtifactGraph(*, store: MutableMapping[str, Any] | None = None, producers: MutableMapping[str, ProducerSpec] | None = None, edges: MutableMapping[str, tuple[str, ...]] | None = None, ops: MutableMapping[str, Callable[[...], Any]] | None = None)[source]
A content-addressed producer graph — the corpus-indexing & refresh core.
The graph holds two kinds of node:
Leaves — externally content-addressed inputs (an L0 source, hashed by
ef.corpus.content_hash()). A leaf has a value but no recipe; it is supplied viaput().Produced artifacts — a node with a
ProducerSpecrecipe, registered viaadd(). Its id isartifact_id()of its recipe.
and four small
MutableMappingstores, every one injectable so the graph can be backed by RAM (the default) or by a persistentdolstore:store— the content-addressed cache:artifact_id -> value.producers— the recipe registry:artifact_id -> ProducerSpec.edges— the reverse index:artifact_id -> (dependent ids, ...), so the downstream cone of any node is one lookup away.ops— the op registry:op_key -> callable. The one store that is not persisted (callables are not serializable) — repopulate it withregister_op()each process.
The four contract operations:
materialize()— lazy backward: compute a node by recursively materializing its inputs, then memoize.mark_stale()— forward: drop the cached values of a node’s produced downstream cone, keeping the recipes (they will rebuild).delete_cascade()— forward: remove a node and its whole downstream cone entirely (values, recipes and edges).freshness()— query a node’s state (Freshness).
plus the reachability queries
descendants()(the downstream cone) andancestors()(lineage — what an artifact was produced from).- Parameters:
store – the content-addressed value cache. Defaults to a fresh
dict.producers – the recipe registry. Defaults to a fresh
dict. If passed already-populated (a resumed/persisted graph) andedgesis left empty, the reverse index is rebuilt from it on construction.edges – the reverse-dependency index. Defaults to a fresh
dict; if passed already-populated it is trusted as-is (not rebuilt).ops – the op registry. Defaults to a fresh
dict;register_op()adds to it.
>>> graph = ArtifactGraph() >>> _ = graph.register_op('join', lambda a, b, *, sep: f'{a}{sep}{b}') >>> x = graph.put('x', 'hello') >>> y = graph.put('y', 'world') >>> both = graph.add(producer_spec('join', x, y, op_version='1', sep=', ')) >>> graph.materialize(both) 'hello, world' >>> sorted(graph.descendants('x')) [...] >>> graph.delete_cascade('y') # the 'y' source is removed frozenset(...) >>> graph.freshness(both) # its consumer is gone too 'unknown'
- add(spec: ProducerSpec) str[source]
Register a producer recipe; return its content-addressed id.
Idempotent: the id is the recipe, so adding an equal
ProducerSpectwice (or adding the shared upstream step of two branching configs) registers one node and returns the same id — the basis of free config branching. Inputs need not exist yet; the graph is declarative andmaterialize()resolves them lazily.>>> graph = ArtifactGraph() >>> a = graph.add(producer_spec('op', 'leaf', op_version='1')) >>> a == graph.add(producer_spec('op', 'leaf', op_version='1')) # idempotent True >>> len(graph.producers) 1
- ancestors(key: str) frozenset[str][source]
The lineage of
key— every artifact it was transitively produced from.Excludes
keyitself. Answers “what produced this?” — provenance / lineage queries. The leaves of the returned set are the original sources the artifact ultimately derives from.>>> graph = ArtifactGraph() >>> _ = graph.register_op('id', lambda x: x) >>> a = graph.add(producer_spec('id', 'src', op_version='1')) >>> b = graph.add(producer_spec('id', a, op_version='1')) >>> graph.ancestors(b) == {'src', a} True
- delete_cascade(key: str) frozenset[str][source]
Remove
keyand its whole downstream cone — forward; return what was removed.Deletes every artifact in
{key} ∪ descendants(key)entirely — cached value, recipe and edges — and detaches the cone from any upstream inputs that survive it. Unlikemark_stale()this removes the recipes too: use it when an artifact can never be rebuilt, the canonical case being a deleted source (its segments, vectors and index entries cascade away with it).Returns the ids actually removed (those that had a value or a recipe).
>>> graph = ArtifactGraph() >>> _ = graph.register_op('id', lambda x: x) >>> graph.put('s', 'v') 's' >>> a = graph.add(producer_spec('id', 's', op_version='1')) >>> graph.delete_cascade('s') == {'s', a} True >>> 's' in graph, a in graph (False, False)
- descendants(key: str) frozenset[str][source]
The downstream cone of
key— every artifact transitively produced from it.Excludes
keyitself. This is the set of artifacts a change tokeywould invalidate;mark_stale()anddelete_cascade()are built on it.>>> graph = ArtifactGraph() >>> _ = graph.register_op('id', lambda x: x) >>> a = graph.add(producer_spec('id', 'src', op_version='1')) >>> b = graph.add(producer_spec('id', a, op_version='1')) >>> graph.descendants('src') == {a, b} True
- freshness(key: str) Literal['materialized', 'stale', 'unknown'][source]
Report the state of
key—Freshness."materialized"if a value is cached;"stale"if a recipe exists but no value (amaterialize()will build it);"unknown"if the graph has neither.Because the graph is content-addressed, a cached value is always correct for its id — staleness here means “not yet computed”, never “computed wrong”. The richer staleness conditions (orphan / missing / misconfigured, computed against
vdindex metadata) belong to a later diagnostics phase, not to the graph core.>>> graph = ArtifactGraph() >>> _ = graph.register_op('id', lambda x: x) >>> graph.put('s', 'v') 's' >>> a = graph.add(producer_spec('id', 's', op_version='1')) >>> graph.freshness('s'), graph.freshness(a), graph.freshness('?') ('materialized', 'stale', 'unknown')
- mark_stale(key: str) frozenset[str][source]
Invalidate
key’s produced downstream cone — forward; return that cone.Drops the cached values of every produced artifact in
{key} ∪ descendants(key), keeping the recipes — so the nextmaterialize()recomputes them. Leaf nodes (inputs, no recipe) are never touched: they cannot be recomputed, so dropping their value would be data loss — usedelete_cascade()to remove an input.This is the invalidation half of a refresh: when an op’s implementation changes, or to force a recompute. (A source change is usually a
delete_cascade()of the old content hash — the new content yields a new leaf id.) Returns the produced artifacts that are now stale.>>> graph = ArtifactGraph() >>> _ = graph.register_op('id', lambda x: x) >>> graph.put('s', 'v') 's' >>> a = graph.add(producer_spec('id', 's', op_version='1')) >>> _ = graph.materialize(a) >>> graph.mark_stale('s') == {a} # the leaf 's' itself is not "stale" True >>> graph.freshness(a) 'stale'
- materialize(key: str) Any[source]
Compute (or fetch the cached value of) the artifact
key— lazy backward.If
keyis already cached instoreits value is returned directly. Otherwise its recipe’s inputs are materialized recursively (depth-first), the op is calledop(*input_values, **params), and the result is memoized instorebefore being returned — so a secondmaterialize()is a cache hit, and a shared upstream artifact is computed exactly once however many configs depend on it.- Raises:
KeyError – if
keyis neither cached nor has a producer recipe (an unresolved leaf — supply it withput()).LookupError – if a recipe’s
opis not in theopsregistry.ValueError – if the producer graph contains a cycle (a content- addressed graph cannot — this guards a hand-corrupted registry).
>>> graph = ArtifactGraph() >>> _ = graph.register_op('double', lambda x: x * 2) >>> graph.put('n', 21) 'n' >>> out = graph.add(producer_spec('double', 'n', op_version='1')) >>> graph.materialize(out) 42
- put(key: str, value: Any) str[source]
Store a leaf
value(an external input) underkey; returnkey.A leaf has a value but no recipe — typically an L0 source keyed by its
ef.corpus.content_hash(). Returnskeyso the call composes:src = graph.put(content_hash(text), text)
>>> graph = ArtifactGraph() >>> graph.put('doc-1', 'some text') 'doc-1' >>> graph.freshness('doc-1') 'materialized'
- register_op(name: str, fn: Callable[[...], Any]) Callable[[...], Any][source]
Register the callable
fnunder the op keyname; returnfn.materialize()resolves aProducerSpec’s stringopthrough this registry. The op is calledfn(*input_values, **params): the materialized inputs positionally, the spec’sparamsas keywords.>>> graph = ArtifactGraph() >>> graph.register_op('upper', str.upper) <method 'upper' of 'str' objects>
- ef.ArtifactId
alias of
str
- class ef.BaseEmbedder[source]
Implementation base for embedders (optional —
Embedderis structural).Subclasses set
model_id/dim/normalized/honored_input_typesand implement__call__; they then satisfyEmbedderstructurally. In return they get:a default synchronous
embed_batch()(ready_handle(self(...))) — override it only for a genuinely async provider Batch API;a readable
repr.
- embed_batch(texts: Iterable[str], *, input_type: Literal['query', 'document', 'classification', 'clustering'] | None = None, **backend: Any) BatchHandle[source]
Default: embed synchronously and return a finished handle.
- class ef.BaseSegmenter[source]
Implementation base for segmenters (optional —
Segmenteris structural).A subclass implements
__call__; in return it gets a defaultbatch()(one-document-at-a-time — override it for a genuinely batched backend) and a readablerepr. Subclassing is purely a convenience; satisfyingSegmenterstructurally is what matters.
- class ef.BatchHandle(poll: ~typing.Callable[[], ~typing.Literal['pending', 'done', 'failed']], result: ~typing.Callable[[], ~numpy.ndarray], cancel: ~typing.Callable[[], None] = <function BatchHandle.<lambda>>)[source]
A poll/result handle over an in-flight (or already-finished) embed job.
Batch-and-poll is just a special case of async. A synchronous backend returns a ready handle (via
ready_handle()); a provider Batch API returns a genuinely pending one. Either way the caller sees one shape.- poll
() -> BatchStatus— non-blocking status check.- Type:
Callable[[], Literal[‘pending’, ‘done’, ‘failed’]]
- result
() -> np.ndarray— blocks until done, returns(n, dim).- Type:
Callable[[], numpy.ndarray]
- cancel
() -> None— best-effort cancellation.- Type:
Callable[[], None]
- class ef.BatchedSegmenter(*args, **kwargs)[source]
Optional trait: a segmenter that can process many documents at once.
Plain
Segmenterhandles one document per call. A segmenter that can amortize per-call cost across documents (a shared model load, a vectorized pass) also offersbatch.BaseSegmentersupplies a correct — if un-optimized — default, so everyBaseSegmentersubclass is already aBatchedSegmenter.
- class ef.CachedEmbedder(inner: Embedder, store: MutableMapping[str, ndarray], *, cache_queries: bool = False)[source]
Memoize embeddings in a
MutableMappingstore.On each call, texts already in the store are served from it; only the misses are forwarded to the inner embedder (in a single batch) and then written back. The store can be any
dol-style mapping — an in-RAMdict, a directory of.npyfiles, a key-value DB.Document embeddings are cached unconditionally; query embeddings are skipped by default (they are rarely repeated) — pass
cache_queries=Trueto cache them too.- Parameters:
inner – The embedder to memoize.
store – A
MutableMapping[str, ndarray]keyed bycache_key().cache_queries – Whether to also cache
input_type="query"calls.
- property honored_input_types: tuple[Literal['query', 'document', 'classification', 'clustering'], ...]
Built-in immutable sequence.
If no argument is given, the constructor returns an empty tuple. If iterable is specified the tuple is initialized from iterable’s items.
If the argument is a tuple, the return value is the same object.
- property normalized: bool
bool(x) -> bool
Returns True when the argument x is true, False otherwise. The builtins True and False are the only two instances of the class bool. The class bool is a subclass of the class int, and cannot be subclassed.
- class ef.ChangeDetectingCorpus(corpus: Any = None, *, on_change: Callable[[ChangeEvent], Any] | None = None, hashes: MutableMapping[str, str] | None = None, content_keys: Iterable[str] | None = None, fingerprint: Callable[[str], Hashable] | None = None)[source]
A corpus wrapper that content-hashes values and reports what changed.
Wraps any inner corpus (a
MutableMapping[source_id, Source]) and adds change detection without altering its mapping behavior — reads, writes and deletes pass straight through to the inner store. It is to a corpus whatCachedEmbedderis to an embedder: a transparent, composable layer.Changes are noticed two ways:
Through the wrapper. A
corpus[id] = sourceordel corpus[id]that actually changes content fires the injectedon_changecallback with aChangeEvent. Writing identical content is a no-op (no event) — the basis of idempotent ingestion.Out of band. When the backing store is edited directly (a file changed on disk, an S3 object replaced),
scan()re-reads every source, compares hashes, fires events for what differs and returns aCorpusDiff.diff()runs the same comparison with no side effects.
The
on_changecallback is the seam the downstreamArtifactGraphplugs into: a"modified"/"deleted"event is exactly the trigger for a cascade invalidation.On construction a baseline is established: the current content hashes of the inner corpus are recorded silently (no events). Pass an already-populated
hashesregistry to instead resume from a persisted baseline — then callscan()to reconcile any drift.- Parameters:
corpus – the inner corpus to wrap. Coerced via
as_corpus(), so a directory path /dict/ iterable of sources /Noneall work.on_change – called with each
ChangeEvent. Defaults to a no-op. It should be cheap and not raise; an exception propagates to the caller after the corpus and registry are already consistent.hashes – the hash registry — a
MutableMapping[source_id, str]of the last-known content hash per source. Injectable so it can be persisted (e.g. adolstore); defaults to an in-RAMdict. If passed already-populated it is trusted as the baseline.content_keys – forwarded to
content_hash()— metadata keys that participate in the hash.fingerprint – an optional cheap
source_id -> Hashablecallback (an mtime / etag prefilter). When given,scan()/diff()skip re-hashing a source whose fingerprint is unchanged. The content hash always remains the source of truth — the fingerprint is only a speed optimization, never trusted to declare a change.
>>> backing = {'a': 'one', 'b': 'two'} >>> corpus = ChangeDetectingCorpus(backing) # baseline adopts 'a' and 'b' >>> backing['a'] = 'ONE' # edited behind the wrapper's back >>> backing['c'] = 'three' >>> del backing['b'] >>> d = corpus.scan() >>> (d.added, d.modified, d.deleted) (('c',), ('a',), ('b',))
- diff() CorpusDiff[source]
Compare current corpus content against the registry — no side effects.
Detects out-of-band edits (changes made to the backing store directly) without firing events or updating the registry.
scan()is the effectful counterpart.
- scan() CorpusDiff[source]
Reconcile the registry with the corpus, fire events, return the diff.
Use this to pick up out-of-band edits — changes made to the backing store directly rather than through this wrapper. Every detected change fires a
ChangeEventand the registry (and fingerprints) are advanced to the new state, so a secondscan()with nothing further changed is a no-op.diff()is the side-effect-free version.
- class ef.ChangeEvent(source_id: str, kind: Literal['added', 'modified', 'deleted'], old_hash: str | None = None, new_hash: str | None = None)[source]
A single detected change to a source in a corpus.
Emitted by
ChangeDetectingCorpusto itson_changecallback.old_hashisNonefor an"added"event,new_hashisNonefor a"deleted"one.- source_id
the key of the source that changed.
- Type:
str
- kind
"added","modified"or"deleted".- Type:
Literal[‘added’, ‘modified’, ‘deleted’]
- old_hash
the source’s previous content hash, if any.
- Type:
str | None
- new_hash
the source’s new content hash, if any.
- Type:
str | None
- ef.ConfigId
alias of
str
- class ef.CorpusDiff(added: tuple[str, ...] = (), modified: tuple[str, ...] = (), deleted: tuple[str, ...] = (), unchanged: tuple[str, ...] = ())[source]
The result of comparing a corpus’s content against known content hashes.
The four fields partition the corpus’s source ids; each is a sorted tuple, so a
CorpusDiffis deterministic and JSON-friendly. Truthiness reports whether anything changed.>>> d = CorpusDiff(added=('b',), modified=('a',)) >>> bool(d), d.changed (True, ('b', 'a')) >>> bool(CorpusDiff(unchanged=('a', 'b'))) False
- property changed: tuple[str, ...]
The source ids that are new or modified — the set to (re-)materialize.
- class ef.CorpusInfo[source]
A JSON-friendly summary of one registered corpus.
The return shape shared by
EfService.create_corpus(),EfService.corpus_info()and (as a list)EfService.list_corpora()— a plaindictan HTTP client andqh’s schema both understand.- Keys:
corpus_id: the registry handle — how every other method addresses it. n_sources: the number of source documents in the corpus. n_segments: the number of indexed segments (sources cut by the segmenter). embedder: the embedder’s
model_id(e.g."hashing:v1@512"). dim: the embedding dimensionality. config_id: the content hash of the segmenter+embedder pipeline.
- class ef.EfService(*, default_embedder: str | None = None)[source]
A registry-backed facade bridging stateful
efto a stateless transport.Construct one
EfServiceper process, register corpora withcreate_corpus(), then query them bycorpus_id. Hand the seven bound methods toqh.mk_app()to expose them over HTTP — see the module docstring.The handle registry is private instance state — never a module global. Corpora are isolated: each
create_corpus()builds its ownSourceManagerover its own in-memoryvdbackend, so one corpus’s vectors never leak into another’s search.default_embeddersets the embeddercreate_corpus()resolves when its ownembedderargument isNone— the per-instance hook a host (e.g.app_ef) uses to pick a policy default without passingembedder=on every call. It defaults toDEFAULT_EMBEDDER("hashing"), so a bareEfService()stays dependency-free and offline.>>> service = EfService() >>> len(service) 0 >>> _ = service.create_corpus(['hello world'], corpus_id='greetings') >>> 'greetings' in service True >>> len(service) 1
A host can swap the default embedder at construction (the string is resolved lazily, per corpus, by the DI seam — constructing the service touches no network):
>>> EfService(default_embedder='openai:text-embedding-3-small') <EfService: 0 corpus(es) registered>
- corpus_info(corpus_id: str) CorpusInfo[source]
Return the
CorpusInfoof a registered corpus.- Raises:
KeyError – if
corpus_idis not registered.
- create_corpus(sources: list[str], *, embedder: str | None = None, segmenter: str | None = None, corpus_id: str | None = None, embedder_api_key: str | None = None) CorpusInfo[source]
Index
sourcesinto a new corpus, register it, return itsCorpusInfo.- Parameters:
sources – the corpus — a list of text documents.
embedder – the embedder, as a string the DI seam resolves (
as_embedder()) —"hashing","openai:text-embedding-3-small","cohere:...", anhttp(s)://URL, ….None→ the service’sdefault_embedder(chosen at construction; itselfDEFAULT_EMBEDDER, the dependency-freeHashingEmbedder, unless a host overrode it).segmenter – the segmenter, as a string (
as_segmenter()) —None→ the recursive-character default.corpus_id – the handle to register the corpus under;
None→ a fresh random id. Reusing a live id is an error.embedder_api_key – an optional API key for the embedder — the bring-your-own-key seam. When given and
embedderis a hosted-API spec ("openai:…","cohere:…", …), the key is forwarded to that adapter, so the service itself never needs to hold one.None→ the adapter’s usual environment-variable resolution. Ignored for key-less embedders (e.g."hashing"). The corpus’s embedder keeps the key for the life of the corpus, sosearch()/retrieve()/explore_corpus()need no key of their own.
- Returns:
the
CorpusInfoof the freshly indexed corpus.- Raises:
ValueError – if
corpus_idis already registered.
- delete_corpus(corpus_id: str) None[source]
Drop a corpus from the registry, releasing its index.
- Raises:
KeyError – if
corpus_idis not registered.
- explore_corpus(corpus_id: str, *, dims: int = 2, projection_method: str = 'auto', cluster_method: str = 'kmeans', n_clusters: int = 8, label: bool = False) ExploreResult[source]
Project & cluster a registered corpus — the corpus-map surface.
Runs
ef.exploration.explore()over the corpus: every indexed segment is projected todimscoordinates and assigned a cluster, returned as a row-alignedExploreResult(ids/coords/labels/cluster_titles) — the JSON-friendly shape anapp_efcorpus map consumes.- Parameters:
corpus_id – the corpus to explore.
dims – projection target dimensionality —
2or3.projection_method –
"auto"/"umap"/"pca".cluster_method –
"kmeans"/"hdbscan".n_clusters – number of k-means clusters.
label – when
True, also name each cluster with an LLM (needs theef[imbed]extra and a key); defaultFalse.
- Raises:
KeyError – if
corpus_idis not registered.ValueError – if the corpus has fewer than 2 indexed segments.
- list_corpora() list[CorpusInfo][source]
Return the
CorpusInfoof every registered corpus.
- retrieve(corpus_id: str, query: str, *, limit: int = 10) list[Segment][source]
Retrieve ranked
Segments — the RAG-plug-in shape.Like
search(), but returns plain segments in rank order (thescoredropped, thesource_idfolded intometadata["source"]) — clean context to hand to an external RAG/agent framework.efreturns context; it does not synthesize answers.- Raises:
KeyError – if
corpus_idis not registered.
- search(corpus_id: str, query: str, *, limit: int = 10) list[SearchHit][source]
Search a registered corpus — up to
limitrankedSearchHits.Each hit carries the matched
Segment, its similarityscore(higher = closer) and thesource_idit was cut from.- Raises:
KeyError – if
corpus_idis not registered.
- class ef.Embedder(*args, **kwargs)[source]
Structural type for an embedder: a batch callable plus metadata.
An embedder maps
Iterable[str]to anndarrayof shape(n, dim). The four metadata attributes are the single source of truth on the object itself — no global registry, no config singleton.- model_id
Identity that bakes in everything affecting the vector —
"openai:text-embedding-3-large@1024"(provider:model@dim). Works directly as a cache namespace.- Type:
str
- dim
Output dimensionality. May be
Noneuntil first call for lazily-probed embedders (e.g. a bare callable of unknown width).- Type:
int | None
- normalized
Trueiff||v|| == 1by construction.- Type:
bool
- honored_input_types
The
InputTypevalues this embedder acts on (empty if it ignores the task hint).- Type:
tuple[Literal[‘query’, ‘document’, ‘classification’, ‘clustering’], …]
- embed_batch(texts: Iterable[str], *, input_type: Literal['query', 'document', 'classification', 'clustering'] | None = None, **backend: Any) BatchHandle[source]
Submit an embed job, returning a
BatchHandle.
- exception ef.EmbedderError[source]
Raised for embedder-specific failures (shape mismatch, batch job failure).
- class ef.ExploreResult[source]
A structured, JSON-friendly corpus-exploration result.
What
explore()returns andef.service.EfService.explore_corpus()serves. Every list is row-aligned:ids[i],coords[i]andlabels[i]all describe the same item.- Keys:
- ids: the per-item identifiers — a corpus’s keys, or positional
"0", "1", … for an id-less vector matrix.- coords: the projected coordinates — one
[x, y](or[x, y, z]) row per item.
labels: the cluster id of each item (HDBSCAN marks noise
-1). cluster_titles:{cluster_id: title}— empty unlessexplore()was called with
label=True.- ids: the per-item identifiers — a corpus’s keys, or positional
- class ef.FunctionEmbedder(func: Callable[[...], Any], *, model_id: str | None = None, dim: int | None = None, normalized: bool = False, honored_input_types: Iterable[Literal['query', 'document', 'classification', 'clustering']] = (), pass_backend: bool = True)[source]
Promote a bare
texts -> vectorscallable to a fullEmbedder.This is the bridge for embedder functions that carry no metadata — most importantly
imbed’s registered embedders. The wrapped callable is asked only forfunc(texts)(it knows nothing ofinput_type); the wrapper advertiseshonored_input_types=()accordingly.- Parameters:
func – A callable mapping a list of strings to an array-like of shape
(n, dim)(a numpy array or a nested sequence — both accepted).model_id – Vector-identity string. Defaults to
"function:<name>".dim – Output width. If
Noneit is inferred (and frozen) on the first call.normalized – Whether
funcalready returns unit vectors.honored_input_types – Task hints
funchonors (usually none).pass_backend – If
True(default), forward any**backendkwargs tofunc; setFalsefor callables with a strict(texts,)signature.
>>> import numpy as np >>> e = FunctionEmbedder(lambda ts: np.ones((len(ts), 4)), model_id='ones@4') >>> e(['a', 'b', 'c']).shape (3, 4)
- class ef.FunctionSegmenter(func: ~typing.Callable[[...], ~typing.Any], *, pass_doc: bool = False, count_tokens: ~typing.Callable[[str], int] | None = <function approx_token_count>, tokenizer: str = 'char-approx')[source]
Promote a bare
text -> piecescallable to a fullSegmenter.The bridge for segmenter functions that carry no segment schema — most importantly
imbed’s registered segmenters, which map text to an iterable of plain strings. Each piece the callable yields is normalized:a
strpiece becomes aSegment; its character offsets are recovered by locating it in the source text, and a token count plustokenizermetadata are attached;a
Mapping/SegmentRecordpiece is coerced withas_segment(), gaining only anindex/parent_idwhere it lacks them.
- Parameters:
func – The callable to wrap. By default it receives the document’s text; with
pass_doc=Trueit receives the whole document.pass_doc – Pass the raw document instead of its text.
count_tokens – Token counter for
strpieces (Nonedisables thetokens/tokenizerannotation).tokenizer – Name recorded as the
tokenizermetadata key.
>>> fs = FunctionSegmenter(lambda text: text.split(',')) >>> [s['text'] for s in fs('a,b,c')] ['a', 'b', 'c'] >>> s = list(fs('a,b,c'))[1] >>> (s['start'], s['end'], s['index']) (2, 3, 1)
- class ef.HashingEmbedder(*, dim: int = 512, ngram_range: tuple[int, int] = (1, 2), sublinear_tf: bool = True)[source]
A dependency-free embedder — the feature-hashing trick, numpy only.
HashingEmbedderisef’s zero-install default: the embedderingest()resolves to when none is given, so the headlineingest([...]).search(query)works on a barepip install ef— no torch, no model download, no network.It is a lexical embedder, not a neural one. Each text is tokenized into lowercased word n-grams; every token is hashed into one of
dimbuckets with a sign (the hashing trick — no vocabulary, no fitting, fixed memory); the signed, sublinear-tf-weighted bucket counts are L2-normalized. The cosine similarity of two vectors then ranks by shared vocabulary — enough for demos, doctests and offline tests, and a sane fallback in production. For genuine semantic quality install a real embedder (ef[sentence-transformers],ef[openai]) and pass it explicitly.Every parameter that changes a vector is baked into
model_id, so aHashingEmbedder’s artifacts stay correctly content-addressed in theArtifactGraph.- Parameters:
dim – Output width. Wider → fewer hash collisions, larger vectors.
ngram_range – Inclusive
(min, max)word-n-gram sizes. The default(1, 2)mixes single words with adjacent pairs — a little phrase sensitivity at no real cost.sublinear_tf – Weight a token by
1 + log(count)rather than its rawcount, damping a word repeated many times in one text.
>>> e = HashingEmbedder() >>> e.model_id 'hashing:v1@512' >>> import numpy as np >>> v = e(['the quick brown fox']) >>> v.shape (1, 512) >>> bool(np.isclose(np.linalg.norm(v[0]), 1.0)) # rows are L2-normalized True >>> a, b = e(['hello world']), e(['hello world']) >>> round(float(a[0] @ b[0]), 5) # identical text → cosine similarity 1.0 1.0 >>> isinstance(e, Embedder) True
- class ef.IndexedSource(source_id: str, doc_ids: tuple[str, ...], source_hashes: frozenset[str], config_hashes: frozenset[str])[source]
The provenance a
vdcollection records for one source document.A source is segmented into several documents; they all carry the same
source_idand should carry onesource_hash/config_hasheach.indexed_state()groups a collection’s documents into one of these per source — collecting the hashes as sets so a partially re-indexed source (documents disagreeing on a hash) is detectable rather than papered over.- source_id
the corpus key of the source these documents were cut from.
- Type:
str
- doc_ids
the ids of every
vddocument belonging to the source.- Type:
tuple[str, …]
- source_hashes
the distinct
source_hashvalues its documents record — a single-element set for a cleanly indexed source.- Type:
frozenset[str]
- config_hashes
likewise the distinct
config_hashvalues.- Type:
frozenset[str]
- class ef.MultiEmbedder(routes: Mapping[Hashable, Embedder], predicate: Callable[[str], Hashable], *, default: Embedder | None = None)[source]
Route each text to one of several embedders by a predicate.
Useful for domain-specific models (e.g. a code embedder for code, a prose embedder for prose). All routes must share the same
dim— the output is a single(n, dim)array, with each row produced by its text’s route.- Parameters:
routes –
{route_key: Embedder}.predicate –
text -> route_key— picks a route per text.default – Embedder for texts whose
route_keyis not inroutes(otherwise an unknown key raisesEmbedderError).
>>> import numpy as np >>> from ef.embedders import FunctionEmbedder >>> short = FunctionEmbedder(lambda ts: np.zeros((len(ts), 2)), model_id='s@2') >>> long = FunctionEmbedder(lambda ts: np.ones((len(ts), 2)), model_id='l@2') >>> m = MultiEmbedder({'s': short, 'l': long}, ... predicate=lambda t: 's' if len(t) < 4 else 'l') >>> m(['hi', 'hello']).tolist() [[0.0, 0.0], [1.0, 1.0]]
- class ef.NormalizingEmbedder(inner: Embedder)[source]
L2-normalize an inner embedder’s output.
Use this for MRL-truncated vectors (which lose unit norm when sliced to a smaller dimension) or any backend that does not normalize by construction. Reports
normalized=True;cache_key()keys onnormalized, so a normalized and an un-normalized view of the same model never collide in a shared cache.- Parameters:
inner – The embedder whose output to renormalize.
- property honored_input_types: tuple[Literal['query', 'document', 'classification', 'clustering'], ...]
Built-in immutable sequence.
If no argument is given, the constructor returns an empty tuple. If iterable is specified the tuple is initialized from iterable’s items.
If the argument is a tuple, the return value is the same object.
- ef.OpKey
alias of
str
- class ef.PipelineSpec(segment: TransformSpec, embed: TransformSpec)[source]
A whole indexing pipeline as data — the segment→embed chain.
A
PipelineSpecis the declarative description of one config: aTransformSpecfor the segmenter and one for the embedder. It is fully serializable (as_dict()/from_dict()) and content-hashed byconfig_id(). Registering a secondPipelineSpecthat differs only in its embed step shares the segment artifacts in theArtifactGraph— that is config branching, and the graph gives it for free.The
segment``→``embedordering is the input wiring: the embed step consumes the segment step’s output. (Phase 5 fixes this two-step shape; a later phase may generalize it to an arbitrary step list.)>>> seg = TransformSpec(op='segment:r', op_version='1', params={}) >>> emb = TransformSpec(op='embed:m@8', op_version='1', params={'input_type': 'document'}) >>> spec = PipelineSpec(segment=seg, embed=emb) >>> spec.embed.params['input_type'] 'document' >>> PipelineSpec.from_dict(spec.as_dict()) == spec True
- as_dict() dict[str, Any][source]
Return a plain-
dict(JSON-serializable) form of the pipeline.>>> seg = TransformSpec(op='segment:r', op_version='1', params={}) >>> emb = TransformSpec(op='embed:m', op_version='1', params={}) >>> PipelineSpec(segment=seg, embed=emb).as_dict()['segment']['op'] 'segment:r'
- classmethod from_dict(d: Mapping[str, Any]) PipelineSpec[source]
Rebuild a
PipelineSpecfrom itsas_dict()form.
- class ef.ProducerSpec(op: str, op_version: str, inputs: tuple[str, ...]=(), params: Mapping[str, ~typing.Any]=<factory>)[source]
A declarative recipe for one produced artifact — the graph’s node type.
A
ProducerSpecsays: this artifact is produced by opopat versionop_version, applied to the artifactsinputs(passed positionally), with the keyword parametersparams. It does not hold the producer callable — only the stringopkey — so a spec is fully serializable (as_dict()/from_dict()) and the graph’sproducersregistry can be persisted.The recipe is the artifact’s identity:
artifact_id()hashes exactly these four fields. Treat an instance as immutable — it is frozen, and mutatingparamsin place would silently desynchronize it from its id.- op
the op key, resolved to a callable via the graph’s
opsregistry.- Type:
str
- op_version
the op’s version string. It participates in the id, so a behavior change to an op must be paired with a version bump — reusing a version for changed behavior silently serves stale cached values (the graph trusts the version; it cannot inspect the code).
- Type:
str
- inputs
the input artifact ids, in the order the op receives them positionally. Order is significant — it is part of the id.
- Type:
tuple[str, …]
- params
the op’s keyword arguments. Key order is not significant (the id is computed over canonical, sorted-key JSON); pass defaults-filled, fully-named params so that two semantically equal calls hash equal.
- Type:
collections.abc.Mapping[str, Any]
>>> spec = producer_spec('embed', 'seg-1', op_version='3', model='small') >>> spec.op, spec.inputs, spec.op_version ('embed', ('seg-1',), '3') >>> spec.params == {'model': 'small'} True
- as_dict() dict[str, Any][source]
Return a plain-
dictform of the spec — JSON-serializable.The inverse of
from_dict(). Use it to persist aproducersregistry to adolJSON store.>>> producer_spec('embed', 'a', 'b', op_version='1', dim=256).as_dict() {'op': 'embed', 'op_version': '1', 'inputs': ['a', 'b'], 'params': {'dim': 256}}
- classmethod from_dict(d: Mapping[str, Any]) ProducerSpec[source]
Rebuild a
ProducerSpecfrom itsas_dict()form.>>> spec = producer_spec('embed', 'a', 'b', op_version='1', dim=256) >>> ProducerSpec.from_dict(spec.as_dict()) == spec True
- class ef.RagEvalReport(metrics: Mapping[str, float], coverage: Mapping[str, int], n_samples: int)[source]
The outcome of
evaluate_rag()— lexical RAG metrics over a sample set.metricsmaps each metric to its mean over the samples it applied to;coveragerecords how many samples that was (a metric needing areferenceskips samples without one).n_samplesis the total scored.>>> report = RagEvalReport( ... metrics={'exact_match': 0.5}, coverage={'exact_match': 4}, n_samples=4) >>> report.metrics['exact_match'] 0.5
- class ef.RagSample[source]
One end-to-end RAG interaction — the unit
evaluate_rag()scores.The field names match Ragas’
SingleTurnSampleexactly, so aRagSampleis the interchange shapeapp_ef/srag/raglabproduce andas_ragas_dataset()forwards unchanged.efitself fills onlyretrieved_contexts(fromretrieve());responseis the caller’s LLM output —efdoes not synthesize it.- Keys:
user_input: the user’s question. response: the generated answer (the caller’s LLM produced it). retrieved_contexts: the context passages handed to the LLM — what
retrieve()returned, as text.reference: the ground-truth answer, if known. reference_contexts: the ground-truth context passages, if known.
- class ef.RecursiveCharacterSegmenter(*, chunk_size: int = 512, chunk_overlap: int = 64, separators: ~typing.Sequence[str] = ('\n\n', '\n', '. ', ', ', ' ', ''), count_tokens: ~typing.Callable[[str], int] = <function approx_token_count>, tokenizer: str = 'char-approx')[source]
The default segmenter — recursive character splitting.
Splits a document on a descending ladder of separators (paragraph → line → sentence → clause → word → character), merging the resulting pieces into chunks of about
chunk_sizetokens withchunk_overlaptokens shared between neighbours. This “good enough” default is, on realistic corpora, within a point or two of far more elaborate methods — upgrade only if you can measure the win.Sizes are measured with
count_tokens. The default counter is the dependency-freeapprox_token_count(); inject a real tokenizer (and itstokenizername) when an exact count matters. Every emitted segment records the tokenizer in its metadata and carries exactstart/endcharacter offsets into the source.- Parameters:
chunk_size – Target chunk size, in
count_tokensunits.chunk_overlap – Tokens of overlap between consecutive chunks (must be smaller than
chunk_size).separators – The separator ladder, coarsest first; the last should be
""so an unsplittable run is still broken between characters.count_tokens – Maps a string to a token count.
tokenizer – Name recorded as the
tokenizermetadata key.
>>> seg = RecursiveCharacterSegmenter(chunk_size=3, chunk_overlap=1) >>> [c['text'] for c in seg('one two three four')] ['one two three', 'three four']
- class ef.RefreshPlan(to_materialize: tuple[str, ...] = (), to_delete: tuple[str, ...] = ())[source]
What a refresh will do —
plan_refresh()’s output.The two fields are disjoint in intent but may overlap in membership: a stale source is in both — its old documents are deleted and it is re-materialized. An orphan is only in
to_delete; a brand-new source only into_materialize.- to_materialize
source ids to (re-)run through the pipeline.
- Type:
tuple[str, …]
- to_delete
source ids whose currently-indexed documents are removed (before re-materializing, for the ones that also reappear above).
- Type:
tuple[str, …]
- class ef.RefreshReport(config: str, mode: Literal['none', 'incremental', 'full', 'scoped_full'], added: tuple[str, ...] = (), modified: tuple[str, ...] = (), deleted: tuple[str, ...] = (), unchanged: tuple[str, ...] = (), documents_written: int = 0, documents_removed: int = 0, artifacts_removed: int = 0)[source]
The outcome of a refresh — what
SourceManager.refresh()returns.added/modified/deleted/unchangedpartition the source ids the refresh considered (mirroringCorpusDifffield names): newly indexed, re-indexed (was stale/misconfigured), removed (orphan), and already-fresh-left-alone. The three counts report the work done. Truthiness reports whether anything changed.>>> r = RefreshReport(config='c0', mode='full', added=('a',), modified=('b',)) >>> bool(r), r.changed (True, ('a', 'b')) >>> bool(RefreshReport(config='c0', mode='full', unchanged=('a',))) False
- property changed: tuple[str, ...]
The source ids that were added, modified or deleted.
- class ef.Reranker(*args, **kwargs)[source]
A callable that re-scores
(query, segments)— higher means more relevant.The structural contract a reranker satisfies: given the query and a list of candidate
Segments, return one score per segment, in the same order.rerank()sorts by these scores. It is a@runtime_checkable Protocol, not a base class — a plain function of the right shape already is aReranker.>>> def by_length(query, segments): ... return [len(s['text']) for s in segments] >>> isinstance(by_length, Reranker) True
- class ef.RetrievalEvalReport(metrics: Mapping[str, float], per_query: Mapping[str, Mapping[str, float]], n_queries: int, k_values: tuple[int, ...])[source]
The outcome of
evaluate_retrieval()— metrics over a query set.metricsmaps a"<name>@<k>"key (e.g."ndcg@10") to the mean of that metric over every scored query;per_querykeeps the same keys per query for drill-down.n_queriesis how many queries were actually scored (a query with no positive judgement is skipped).>>> report = RetrievalEvalReport( ... metrics={'ndcg@10': 0.83}, per_query={}, n_queries=12, k_values=(10,)) >>> report.primary 0.83
- class ef.RetryPolicy(max_attempts: int = 4, base_delay: float = 1.0, max_delay: float = 30.0, jitter: float = 0.3, retry_on: ~typing.Callable[[BaseException], bool] = <function _default_is_retryable>)[source]
Exponential-backoff retry policy for
RetryingEmbedder.- max_attempts
Total tries (the first call counts as attempt 1).
- Type:
int
- base_delay
Seconds before the first retry; doubles each attempt.
- Type:
float
- max_delay
Upper bound on any single backoff sleep.
- Type:
float
- jitter
Random fraction (
0..1) of the delay added on top, to de-correlate concurrent clients.- Type:
float
- retry_on
Predicate deciding whether an exception is worth retrying.
- Type:
Callable[[BaseException], bool]
- retry_on() bool
Heuristic: retry transient failures (429, 5xx, connection/timeout).
Per-request 400-class errors are not retried — the same request would fail identically. Recognizes the
status_code/statusattribute carried by most HTTP-client SDK exceptions (incl. the OpenAI SDK).
- class ef.RetryingEmbedder(inner: ~ef.embedders.Embedder, policy: ~ef.embedder_wrappers.RetryPolicy | None = None, *, sleep: ~typing.Callable[[float], None] = <built-in function sleep>)[source]
Retry an inner embedder’s calls on transient failures.
- Parameters:
inner – The embedder to guard.
policy – A
RetryPolicy(sensible defaults if omitted).sleep – The sleep function — injectable so tests run instantly.
- property honored_input_types: tuple[Literal['query', 'document', 'classification', 'clustering'], ...]
Built-in immutable sequence.
If no argument is given, the constructor returns an empty tuple. If iterable is specified the tuple is initialized from iterable’s items.
If the argument is a tuple, the return value is the same object.
- property normalized: bool
bool(x) -> bool
Returns True when the argument x is true, False otherwise. The builtins True and False are the only two instances of the class bool. The class bool is a subclass of the class int, and cannot be subclassed.
- class ef.SearchHit(segment: Segment, score: float, source_id: str | None = None)[source]
One ranked search result — a
Segmentand its score.SearchableCorpus.search()/SourceManager.search()return a rankedlistof these. KeepingSegment(the interchangeTypedDict) pure of a result-onlyscorekey, aSearchHitwraps it alongside the similarityscoreand thesource_idof the document the segment was cut from.- score
the similarity score from the vector store (higher = closer).
- Type:
float
- source_id
the corpus key of the source document, if recorded.
- Type:
str | None
>>> hit = SearchHit(segment={'text': 'hi', 'id': 'x'}, score=0.9, source_id='doc-1') >>> hit.segment['text'], hit.score, hit.source_id ('hi', 0.9, 'doc-1')
- class ef.SearchableCorpus(collection: Any, embedder: Embedder, *, config: str | None = None)[source]
A ready-to-search view over one indexed config — the thin facade.
What
ingest()returns and whatSourceManager.searchable()hands out: a small, read-mostly object that knows just enough to answer a query — thevdcollection holding the vectors and theEmbedderthat embeds the query. It does not re-index; mutation, refresh and multi-config live onSourceManager.- Parameters:
collection – the
vdcollection holding this config’s vectors.embedder – the embedder — used to embed queries with
input_type="query".config – the
ConfigIdof the indexed pipeline, if known.
>>> # built for you by ingest(); see the module docstring for a full example.
- retrieve(query: str | Iterable[float], *, limit: int = 10, filter: Mapping[str, Any] | None = None) list[Segment][source]
Retrieve ranked
Segments — the RAG-plug-in surface.The name an external RAG/agent framework reaches for. Where
search()returns scoredSearchHits (for inspecting the ranking),retrievereturns plain segments in rank order — clean context to hand to an LLM, with noef-specific type to learn (design notes §F5).efreturns the context; it does not synthesize answers. Seehits_to_segments()for how the score is dropped and thesource_idprovenance is preserved inmetadata["source"].>>> # idx.retrieve('a query') -> [{'text': ..., 'id': ..., 'metadata': ...}, ...]
- search(query: str | Iterable[float], *, limit: int = 10, filter: Mapping[str, Any] | None = None) list[SearchHit][source]
Search the indexed corpus — return up to
limitrankedSearchHits.queryis text (embedded withinput_type="query") or an already computed query vector.filteris avdmetadata filter (MongoDB-style) — seevd.filters.
- class ef.Segment[source]
A piece of text carved from a source document — the interchange type.
A
TypedDict: aSegmentis a plaindict, which keeps it cheap to create and stream.total=Falsebecause most keys are optional; by conventiontextis always present andidis always set (derived fromtextif not supplied).- Keys:
text: The segment’s text. Required. id: Stable identifier — content-derived by default (
segment_id()). parent_id: Id of the source document or parent segment this came from. start: Character offset of the segment’s start in the source text. end: Character offset of the segment’s end (exclusive) in the source. index: Ordinal position of the segment in its segmenter’s output. tokens: Token count oftext(meaningful only withmetadatakeytokenizerrecording which tokenizer counted it).- metadata: Free-form mapping; framework-specific keys live here. See
PROMOTED_METADATA_KEYSfor the conventional keys.
- class ef.SegmentRecord(text: str, id: str = '', parent_id: str | None = None, start: int | None = None, end: int | None = None, index: int | None = None, tokens: int | None = None, metadata: Mapping[str, ~typing.Any]=<factory>)[source]
Frozen-dataclass view over a
Segment— the convenience surface.Use this when attribute access and immutability read better than dict access; use the
SegmentTypedDictdirectly on the hot path. The two convert losslessly viato_segment()andsegment_record().idis derived fromtext(andmetadata) if left empty.>>> rec = SegmentRecord('hello', index=2) >>> rec.id == segment_id('hello') True >>> rec.to_segment()['index'] 2
- class ef.Segmenter(*args, **kwargs)[source]
Structural type for a segmenter:
str | Mapping -> Iterable[Segment].A segmenter is just a callable. It accepts a document — a bare
stror aMappingwith atextkey (and optionally anid, which becomes the emitted segments’parent_id) — and yieldsSegmentpieces. It returns an iterable, not a list: streaming is the default so the heavy case scales.Because the protocol’s only member is
__call__, every callable satisfiesisinstance(x, Segmenter). Treat it as documentation of intent, not a discriminating check —as_segmenter()is the seam that turns an arbitrary callable into a well-behaved segmenter.
- class ef.SourceManager(corpus: Any = None, *, segmenter: Any = None, embedder: Any = None, store: Any = None, cache: MutableMapping[str, Any] | None = None, auto_refresh: bool = False, embedder_api_key: str | None = None)[source]
The heavy search facade — multi-config corpus indexing over one corpus.
A
SourceManagerholds a corpus, anArtifactGraphand one or more configs. A config is a named segmenter+embedder pipeline (PipelineSpec);register_config()adds one,materialize()runs the corpus through it into avdcollection, andsearch()queries it. Because every artifact is content-addressed in the shared graph, two configs that share a step (e.g. the same segmenter) compute that step’s artifacts once — config branching is free.The constructor optionally registers a
"default"config: pass anembedder(and optionally asegmenter) and it is registered eagerly; omitembedderto build every config explicitly viaregister_config().- Parameters:
corpus – the source corpus — coerced by
as_corpus()(a mapping, a directory path, an iterable of sources, orNone).segmenter – the default config’s segmenter — coerced by
as_segmenter()(None→ the recursive default). Used only whenembedderis also given.embedder – the default config’s embedder — coerced by
as_embedder(). If given, a"default"config is registered; ifNone, no default config is registered.embedder_api_key – an optional API key for the default config’s embedder — forwarded to a hosted-API adapter (
"openai:…"etc.) when theembedderis such a spec. The bring-your-own-key seam; ignored for key-less embedders. Seeregister_config().store – where the vectors go.
None→ an in-memoryvdbackend; avdclient → collections are created on it; avdcollection → used directly (then only one config is supported); a backend-name string →vd.connectof that backend.cache – the
ArtifactGraph’s value cache — anyMutableMapping;None→ an in-RAMdict.auto_refresh – when
True, the corpus is wrapped in aChangeDetectingCorpusand every edit made through it is incrementally re-indexed into each materialized config — the index stays live without an explicitrefresh().scan()then also picks up out-of-band edits.
>>> import numpy as np >>> from ef import as_embedder >>> e1 = as_embedder(lambda ts: np.array([[len(t)] for t in ts], float), model_id='len@1') >>> sm = SourceManager(['hello', 'hi there'], embedder=e1) >>> report = sm.materialize() >>> report['segments'] 2 >>> len(sm.search('hello')) >= 1 True
- property configs: dict[str, str]
The registered configs as a
{name: config_id}mapping.
- diagnose(config: str | None = None) StalenessReport[source]
Report how far one config’s index has drifted from the corpus.
Computes the four staleness conditions —
orphan/missing/stale/misconfigured— by comparing the config’svdcollection against the current corpus (seeef.diagnostics). Read-only: it changes nothing.refresh()acts on the result.>>> sm = SourceManager({'a': 'alpha', 'b': 'beta'}, embedder='hashing') >>> _ = sm.materialize() >>> bool(sm.diagnose()) # freshly indexed — in sync False >>> sm.corpus['a'] = 'alpha rewritten' >>> sm.diagnose().stale ('a',)
- gc_orphans(config: str | None = None) int[source]
Delete the documents of sources that left the corpus — return the count.
The garbage-collection slice of
refresh(): it removes only orphan documents (a source gone from the corpus) and prunes the dead leaves out of theArtifactGraph, touching nothing else.gc_orphans()is the orphan-deletion half ofrefresh(mode='full').
- lineage(key: str) frozenset[str][source]
The artifacts
keywas produced from — its provenance.A thin pass-through to
ArtifactGraph.ancestors: “what produced this vector / segment?”. The leaves of the returned set are the source content hashes the artifact ultimately derives from.
- materialize(config: str | None = None, *, sources: Iterable[str] | None = None) dict[str, Any][source]
Index the corpus through one config (or all) — corpus → segment → embed →
vd.For each source and each selected config: the source is added to the
ArtifactGraphas a content-addressed leaf, itssegmentandembedartifacts arematerialize()d (a cache hit if already computed — so a shared segment step runs once across configs), and every(segment, vector)pair is written into the config’svdcollection. Idempotent: re-materializing rewrites identical documents.- Parameters:
- Returns:
a report
dictwithconfigs/sources/segmentscounts.
- pipeline(config: str | None = None) PipelineSpec[source]
The
PipelineSpecof a registered config.
- rebuild(config: str | None = None) RefreshReport[source]
Drop one config’s index entirely and re-index the whole corpus.
The heavy hammer: every document in the config’s
vdcollection is deleted, then the whole corpus is re-materialize()d from scratch. Use it to recover from a config whosevddocuments were produced by an earlier pipeline (every sourcemisconfigured), or whenever a guaranteed-clean index is wanted over the surgicalrefresh(). Every source is reported asadded— the collection was emptied and rebuilt.- Returns:
- refresh(config: str | None = None, *, sources: Iterable[str] | None = None, mode: Literal['none', 'incremental', 'full', 'scoped_full'] = 'full') RefreshReport[source]
Re-sync one config’s
vdindex with the current corpus.The explicit-refresh entry point. It
diagnose()s the config, turns the staleness report into aRefreshPlanfor the chosenmode(plan_refresh()), deletes the stale/orphan documents, re-materialize()s what changed, and prunes the dead leaves out of theArtifactGraph. Because it re-reads and re-hashes the corpus, it catches out-of-band edits without any change-detection wrapper.- Parameters:
config – the config to refresh (name / id /
Nonefor the default or sole config).sources – an optional subset of source ids to refresh — sources outside it are left untouched. Pair it with
mode='scoped_full'(or'incremental');'full'rejects it.mode – one of
REFRESH_MODES—'none'(index new/changed content, delete nothing),'incremental'(also replace changed sources),'full'(also delete orphans — the corpus is authoritative),'scoped_full'(fullover thesourcessubset). Default'full'.
- Returns:
>>> sm = SourceManager({'a': 'alpha text', 'b': 'beta text'}, embedder='hashing') >>> _ = sm.materialize() >>> sm.corpus['b'] = 'beta rewritten' # edit >>> sm.corpus['c'] = 'gamma text' # add >>> del sm.corpus['a'] # delete >>> report = sm.refresh() >>> report.added, report.modified, report.deleted (('c',), ('b',), ('a',)) >>> bool(sm.diagnose()) # in sync again False
- register_config(name: str = 'default', *, segmenter: Any = None, embedder: Any = None, embedder_api_key: str | None = None) str[source]
Register a named segmenter+embedder pipeline; return its
ConfigId.The segmenter and embedder are coerced through
ef’s DI seams (as_segmenter()/as_embedder(); an absentembedderdefaults toDEFAULT_EMBEDDER). Their ops are registered into the sharedArtifactGraphand avdcollection is created for the config. Registering a config does not index it — callmaterialize().embedder_api_keyis the per-call bring-your-own-key seam: when given andembedderis a hosted-API provider spec ("openai:…"etc.), the key is forwarded to that adapter instead of relying on a server environment variable. Ignored for key-less embedders.Re-registering the same
namereplaces it. Two configs that resolve to the samePipelineSpecshare oneConfigId(and one collection).>>> import numpy as np >>> from ef import as_embedder >>> sm = SourceManager(['a', 'b']) >>> cid = sm.register_config( ... 'mini', embedder=as_embedder( ... lambda ts: np.ones((len(ts), 2)), model_id='ones@2')) >>> len(cid) 64
- retrieve(query: str | Iterable[float], *, config: str | None = None, limit: int = 10, filter: Mapping[str, Any] | None = None) list[Segment][source]
Retrieve ranked
Segments — the RAG-plug-in surface.The
SourceManagercounterpart ofSearchableCorpus.retrieve(): plain segments in rank order, the clean context shape an external RAG/agent framework consumes (efdoes not synthesize answers).search()is the scored counterpart.
- scan() CorpusDiff[source]
Re-scan the corpus for out-of-band edits — needs
auto_refresh=True.Picks up changes made to the backing store directly (a file edited on disk, an S3 object replaced) rather than through the corpus wrapper. When the manager was built with
auto_refresh=Truethe corpus is aChangeDetectingCorpus; this calls itsscan(), which fires aChangeEventper drift — each one auto-applied to every materialized config. Returns theCorpusDiff.For a large bulk of out-of-band edits, prefer
refresh()(one corpus pass) overscan(one collection pass per changed source).- Raises:
TypeError – if the corpus is not change-detecting (the manager was not built with
auto_refresh=True).
- search(query: str | Iterable[float], *, config: str | None = None, limit: int = 10, filter: Mapping[str, Any] | None = None) list[SearchHit][source]
Search one config’s index — return up to
limitrankedSearchHits.configselects which config to query (a name /ConfigId, orNonefor the default or sole config).queryis text or a pre-computed query vector;filteris avdmetadata filter.
- searchable(config: str | None = None) SearchableCorpus[source]
Return a thin
SearchableCorpusbound to one config’s index.
- class ef.StalenessReport(config: str, orphan: tuple[str, ...] = (), missing: tuple[str, ...] = (), stale: tuple[str, ...] = (), misconfigured: tuple[str, ...] = (), fresh: tuple[str, ...] = ())[source]
The four staleness conditions of one config — what
diagnose()returns.Each field is a sorted tuple of
source_ids, so a report is deterministic and JSON-friendly.orphanandmissingare disjoint from the rest (a source absent from one side cannot also be compared); a source can be bothstaleandmisconfigured(its content changed and the config did) — the conditions are reported independently.freshis the complement: indexed, current and built by this config.Truthiness reports whether anything is wrong —
bool(report)isFalseonly for a perfectly in-sync index.>>> r = StalenessReport(config='c0', stale=('a',), fresh=('b',)) >>> bool(r), r.needs_reindexing (True, ('a',)) >>> bool(StalenessReport(config='c0', fresh=('a', 'b'))) False
- property needs_reindexing: tuple[str, ...]
Sources whose vectors must be recomputed —
stale∪misconfigured.
- class ef.TransformSpec(op: str, op_version: str, params: Mapping[str, ~typing.Any]=<factory>)[source]
One step of a pipeline — a serializable recipe for an op application.
A
TransformSpecnames which op (op, a key resolved through theArtifactGraph’sopsregistry), at which version (op_version— it participates in every downstream hash), and with which keyword params (params). It does not record the step’s inputs: those are wired structurally by thePipelineSpec(the embed step consumes the segment step’s output). It is the per-step counterpart of aProducerSpec.The op key carries the component identity — e.g.
"embed:openai:text- embedding-3-large@1024"or"segment:<segmenter-identity>"(seeef.ops) — so two configs with different components get disjoint artifact cones automatically.>>> ts = TransformSpec(op='embed:m@8', op_version='2', params={'input_type': 'document'}) >>> ts.op, ts.op_version ('embed:m@8', '2') >>> TransformSpec.from_dict(ts.as_dict()) == ts True
- as_dict() dict[str, Any][source]
Return a plain-
dict(JSON-serializable) form of the spec.>>> TransformSpec(op='segment:r', op_version='1', params={}).as_dict() {'op': 'segment:r', 'op_version': '1', 'params': {}}
- classmethod from_dict(d: Mapping[str, Any]) TransformSpec[source]
Rebuild a
TransformSpecfrom itsas_dict()form.
- ef.approx_token_count(text: str) int[source]
Rough token count — the common
~4 characters per tokenheuristic.A dependency-free default so
efcan talk in “tokens” without pulling intiktokenor a model tokenizer. Inject a real counter (and its name) into a segmenter when an exact count matters.>>> approx_token_count('a' * 40) 10 >>> approx_token_count('') 0
- ef.artifact_id(spec: ProducerSpec) str[source]
Content-addressed id of
spec—sha256over its four fields.The id is
sha256of canonical (sorted-key) JSON of{op, op_version, inputs, params}. Two specs that describe the same computation hash identically; any change to the op, its version, its inputs (including their order) or its params yields a different id. That is what makes the graph content-addressed: the id is the recipe.>>> a = producer_spec('embed', 'seg-1', op_version='2', model='small') >>> artifact_id(a) == artifact_id(producer_spec( ... 'embed', 'seg-1', op_version='2', model='small')) True >>> len(artifact_id(a)) 64 >>> artifact_id(a) == artifact_id(producer_spec( ... 'embed', 'seg-1', op_version='3', model='small')) # version bump False >>> artifact_id(producer_spec('op', 'x', 'y', op_version='1')) == artifact_id( ... producer_spec('op', 'y', 'x', op_version='1')) # input order matters False
- ef.as_corpus(x: Any = None, /, **kwargs: Any)[source]
Normalize
xinto a corpus (MutableMapping[source_id, Source]).The single dependency-injection seam through which every
efentry point accepts a user-supplied corpus — the mirror ofas_embedder()andas_segmenter(). Accepts, in order:None— a fresh empty in-RAMdict;a
Mapping— returned unchanged (adict, adolstore, another corpus: it already is a corpus);a directory path (
str/os.PathLikenaming an existing directory) — adolfilesystem store of the text files under it (dolis imported lazily; only this branch needs it);an iterable of sources (strings or mappings) — an in-RAM
dictkeyed by each source’scontent_hash()(content-addressed, so duplicate sources collapse to one entry).
Extra
**kwargsare forwarded to thedolfilesystem store (the directory branch only) and ignored otherwise.- Raises:
TypeError – if
xis a non-directory string, or none of the above.
>>> as_corpus() {} >>> as_corpus({'a': 'x'}) # a mapping passes straight through {'a': 'x'} >>> sorted(as_corpus(['alpha', 'beta']).values()) ['alpha', 'beta'] >>> len(as_corpus(['dup', 'dup'])) # content-addressed: duplicates collapse 1
- ef.as_embedder(x: Any, **kwargs: Any) Embedder[source]
Normalize
xinto anEmbedder— the DI seam.The single place every
efentry point coerces a user-supplied embedder argument. Accepts, in order:an existing
Embedder— returned unchanged;a URL string (
http:///https://) —http_embedder();a provider-prefixed string —
"openai:<model>","cohere:<model>","voyage:<model>"or"gemini:<model>"— the matching hosted-API adapter;the bare string
"hashing"— the dependency-freeHashingEmbedder(ef’s zero-install default);an
"st:<model>"string, or any other bare model name —sentence_transformers_embedder()(the local neural default);a bare callable — wrapped in
FunctionEmbedder(theimbed-embedder bridge).
Extra
**kwargsare forwarded to the chosen factory.- Raises:
TypeError – if
xis none of the above.
>>> import numpy as np >>> e = as_embedder(lambda ts: np.zeros((len(ts), 2)), model_id='zero@2') >>> type(e).__name__ 'FunctionEmbedder'
- ef.as_ragas_dataset(samples: Iterable[RagSample | Mapping[str, Any]]) Any[source]
Convert
RagSamples into a RagasEvaluationDataset.The hookpoint for the LLM-judged RAG metrics
efdeliberately does not compute itself (faithfulness, answer relevancy, …).RagSamplealready mirrors Ragas’SingleTurnSamplefield-for-field, so this is a thin, lossless adapter. Run the evaluation with your own LLM:from ragas import evaluate from ragas.metrics import faithfulness, answer_relevancy dataset = as_ragas_dataset(samples) result = evaluate(dataset, metrics=[faithfulness, answer_relevancy])
- Parameters:
samples – an iterable of
RagSamples (or plain mappings).- Returns:
a
ragas.EvaluationDataset.- Raises:
ImportError – if the optional
ragaspackage is not installed.
- ef.as_segment(x: Any, **defaults: Any) Segment[source]
Coerce
xinto aSegment— accepts astr,Mappingor record.This is the segment-side normalizer that lets a
Segmenteraccept loosely-typed output (a bare string, a partialdict) and still emit a well-formedSegment.**defaultssupplies fallback values: a default fills a field only whenxdoes not already carry it.Accepts:
a
SegmentRecord— converted viaSegmentRecord.to_segment();a
str— becomes thetextof a fresh segment;a
Mappingwith atextkey — its keys are carried through.
The
idis always present on the result (derived if absent).- Raises:
TypeError – if
xis none of the accepted forms.ValueError – if
xis aMappingwithout atextkey.
>>> as_segment('plain text', index=3)['index'] 3 >>> as_segment({'text': 'kept', 'index': 0}, index=9)['index'] # x wins 0
- ef.as_segmenter(x: Any = None, /, **kwargs: Any) Segmenter[source]
Normalize
xinto aSegmenter— the DI seam.The single place every
efentry point coerces a user-supplied segmenter argument. Accepts, in order:Noneor"default"/"recursive"— aRecursiveCharacterSegmenter(the default);"lines"—line_segmenter();any other string — looked up in
imbed’s registry viaimbed_segmenter();a ready-made segmenter (a
BaseSegmenteror a callable marked byef) — returned unchanged;a bare callable — wrapped in
FunctionSegmenter.
Extra
**kwargsare forwarded to the chosen factory (ignored whenxis already a segmenter).- Raises:
TypeError – if
xis none of the above.
>>> seg = as_segmenter(chunk_size=256, chunk_overlap=32) >>> (seg.chunk_size, seg.chunk_overlap) (256, 32)
- ef.average_precision(ranked: Sequence[str], relevant: Mapping[str, float], k: int | None = None) float[source]
Average precision of
ranked— the per-query term of MAP.The mean of the precision measured at each relevant result’s rank.
koptionally caps the ranking.>>> average_precision(['d1', 'x', 'd2'], {'d1': 1.0, 'd2': 1.0}) 0.8333333333333333
- ef.cache_key(embedder: Embedder, text: str, input_type: Literal['query', 'document', 'classification', 'clustering'] | None = None) str[source]
Deterministic cache key for one
(embedder, text, input_type)triple.The key pins everything that changes the resulting vector.
model_idis the embedder-identity SSOT — it already bakes in provider, model anddim— sodimis not keyed separately (keying it would also breakFunctionEmbedder’s lazydiminference).normalizedis keyed because aNormalizingEmbeddershares its inner’smodel_idyet yields different vectors.Returns
"<model_id>/<sha256>"— a namespaced key safe for anyMutableMappingstore.>>> class _E: ... model_id, normalized = 'm@8', False >>> k1 = cache_key(_E(), 'hello', 'document') >>> k2 = cache_key(_E(), 'hello', 'query') >>> k1 == k2 # input_type participates False >>> k1.startswith('m@8/') True
- ef.cluster(data: Any, *, method: Literal['kmeans', 'hdbscan'] = 'kmeans', n_clusters: int = 8, min_cluster_size: int = 5, embedder: Any = None, normalize: bool = True, random_state: int = 42) ndarray[source]
Cluster a corpus’s embeddings (use cases §G2).
method='kmeans'is the default and is dependency-free — a numpy Lloyd’s algorithm with k-means++ seeding.method='hdbscan'finds a density-based clustering (and a noise label-1) and needs theef[explore]extra.- Parameters:
data – what to cluster — see
project()/_resolve_vectors().method –
'kmeans'(numpy-only) or'hdbscan'(optional extra).n_clusters – number of clusters for k-means (clamped to
n_samples).min_cluster_size – smallest cluster HDBSCAN will form.
embedder – embedder used when
datais raw text.normalize – L2-normalize the vectors first, so Euclidean distance behaves like cosine similarity — the right default for semantic embeddings. Set
Falseto cluster raw vectors.random_state – seed for k-means initialization.
- Returns:
An
ndarrayof integer cluster labels, shape(n_samples,), in input order. HDBSCAN labels unclustered points-1.
>>> import numpy as np >>> vectors = np.random.RandomState(2).rand(15, 8) >>> labels = cluster(vectors, method='kmeans', n_clusters=3, random_state=0) >>> labels.shape (15,) >>> set(labels.tolist()) <= {0, 1, 2} True
- ef.cohere_embedder(model: str = 'embed-v4.0', *, dim: int | None = None, api_key: str | None = None, batch_size: int = 96, timeout: float = 60.0) Embedder[source]
Build a Cohere-embeddings
Embedder.Talks to Cohere’s
/v2/embedREST endpoint directly — nocohereSDK, no extra dependency. Cohere’s v3+ models require a task hint; this adapter supplies one for every canonicalInputTypeand defaults to the document role when none is given.- Parameters:
model – A Cohere embedding model name.
dim – Matryoshka-truncated width (
embed-v4.0only). Omit for the model’s native width. The dim is part of the embedder’s identity — set it once here, not per call.api_key – Cohere API key. Falls back to
CO_API_KEY/COHERE_API_KEY.batch_size – Texts per request (capped at Cohere’s 96 limit).
timeout – Per-request timeout in seconds.
- Returns:
An
Embedderover the Cohere API.
- ef.config_id(spec: PipelineSpec) str[source]
Content-addressed id of a
PipelineSpec—sha256over its steps.The id is
sha256of the canonical (sorted-key) JSON ofPipelineSpec.as_dict(). Two pipelines that describe the same computation hash identically; any change to a step’s op, version or params yields a different id.efwrites this digest intovdindex metadata asconfig_hash.>>> seg = TransformSpec(op='segment:r', op_version='1', params={}) >>> emb = TransformSpec(op='embed:m@8', op_version='1', params={}) >>> cid = config_id(PipelineSpec(segment=seg, embed=emb)) >>> len(cid) 64 >>> cid == config_id(PipelineSpec(segment=seg, embed=emb)) True
- ef.content_hash(source: Any, *, content_keys: Iterable[str] | None = None) str[source]
Content hash of a source document —
sha256of its normalized content.This is the basis of change detection: re-reading an unchanged source yields an identical hash, so a refresh that finds the same hash does nothing. Hashing normalized content (NFC, no BOM,
\nline endings — seeef.hashing) means cosmetic encoding differences never look like a change.The treatment of a
Mappingsource is deliberate:a plain
strsource — the hash covers the text;a
Mappingwith atextkey — the hash coverssource['text']only. A change to title, tags, ACL or any other metadata does not change the hash, so it never triggers a spurious re-segment/re-embed (embedding is the pipeline’s most expensive step). Name the metadata keys that do feed downstream incontent_keysand changes to those keys will be detected;a
Mappingwithout atextkey — treated as a structured document and hashed via canonical (sorted-key) JSON of the whole mapping;bytes— hashed raw (no text normalization is possible pre-parse).
- Parameters:
source – a
str,bytes, or aMapping(ideally with atextkey).content_keys – metadata keys whose values join the text in the hash. Each key is looked up at the top level of
sourceor inside asource['metadata']sub-mapping. Use this only for metadata that genuinely affects the L1–L4 pipeline (e.g. a title prepended to the text before embedding); key order does not matter.
- Raises:
TypeError – if
sourceis not astr,bytesorMapping.
>>> content_hash('hello') == content_hash('hello') True >>> content_hash('hello') == content_hash('hello world') False >>> content_hash('a\r\nb') == content_hash('a\nb') # line endings normalized True >>> a = {'text': 'body', 'metadata': {'tags': ['x']}} >>> b = {'text': 'body', 'metadata': {'tags': ['y']}} >>> content_hash(a) == content_hash(b) # metadata ignored by default True >>> content_hash(a, content_keys=['tags']) == content_hash(b, content_keys=['tags']) False >>> content_hash({'text': 'body'}) == content_hash('body') # wrapper is transparent True
- ef.context_precision(reference: str, retrieved_contexts: Sequence[str]) float[source]
Fraction of the retrieved contexts that lexically overlap the
reference.A deterministic proxy for retrieval precision — a context counts as useful if it shares at least one (normalized) token with the reference answer.
>>> context_precision('paris', ['Paris is in France.', 'Unrelated text.']) 0.5
- ef.context_recall(reference: str, retrieved_contexts: Sequence[str]) float[source]
Lexical recall of the
referencetokens by the retrieved contexts.The fraction of the (normalized) reference’s distinct tokens that appear anywhere in
retrieved_contexts— a deterministic proxy for “did retrieval surface the information the answer needs”. For the LLM-judged context recall, useas_ragas_dataset()and Ragas.>>> context_recall('paris france', ['Paris is the capital of France.']) 1.0 >>> context_recall('paris france', ['London is in England.']) 0.0
- ef.cross_encoder_reranker(model_name: str = 'cross-encoder/ms-marco-MiniLM-L-6-v2', *, device: str | None = None, batch_size: int = 32, **predict_kwargs: Any) Reranker[source]
Build a
Rerankerbacked by a sentence-transformersCrossEncoder.A cross-encoder scores a
(query, passage)pair jointly — it is the accurate, slower second stage two-stage retrieval is built for. The model is a heavy, optional dependency: it is imported only when this factory is called, so importingef.rerankingitself stays cheap.Example:
from ef import ingest, with_reranker, cross_encoder_reranker index = ingest(corpus, embedder="st:all-MiniLM-L6-v2") retrieve = with_reranker(index.retrieve, cross_encoder_reranker()) segments = retrieve("a query", limit=5)
- Parameters:
model_name – a sentence-transformers
CrossEncodermodel name.device – torch device (
"cuda"/"cpu"/"mps");Noneauto-selects.batch_size – how many pairs to score per forward pass.
predict_kwargs – extra keyword arguments forwarded to
CrossEncoder.predict.
- Returns:
a
Reranker—(query, segments) -> scores.- Raises:
ImportError – if the optional
sentence-transformerspackage is absent.
- ef.dcg_at_k(ranked: Sequence[str], relevant: Mapping[str, float], k: int) float[source]
Discounted cumulative gain of
rankedat cutoffk.sum(grade_i / log2(i + 2))over the topkresults (i0-indexed), withgrade_ithe relevance of thei-th doc (0if unjudged) — the linear-gain DCG that BEIR /pytrec_evaluse.>>> round(dcg_at_k(['d1', 'x', 'd2'], {'d1': 1.0, 'd2': 1.0}, 3), 4) 1.5
- ef.diagnose(corpus: MutableMapping[str, str | Mapping[str, Any]] | Mapping[str, Any], collection: Any, config: str) StalenessReport[source]
Compute the four staleness conditions of
collectionagainstcorpus.The pure core of
ef’s diagnostics — no side effects. It reads what thevdcollectionrecords (indexed_state()), what thecorpuscurrently holds (corpus_state()), and partitions every source id into exactly the buckets it belongs in:orphan — in the collection, not in the corpus;
missing — in the corpus, not in the collection;
stale — in both, but the recorded
source_hash≠ the current one;misconfigured — in both, but a recorded
config_hash≠config;fresh — in both, content current and config matching.
- Parameters:
corpus – the source corpus.
collection – the
vdcollection backing the config being diagnosed.config – the
ConfigIdthe collection should hold — documents recording any otherconfig_hashare misconfigured.
- Returns:
- ef.embed_length_sorted(texts: Sequence[str], encode: Callable[[Sequence[str]], Any], *, batch_size: int = 128) ndarray[source]
Encode
textsin length-sorted batches; return vectors in caller order.Transformer self-attention is O(n²) in sequence length and a batch is padded to its longest member, so grouping similar-length texts together avoids wasted compute — a 5–10× speedup on skewed corpora.
efowns this so callers never sort, pad, or un-sort themselves.- Parameters:
texts – The strings to embed.
encode – Maps a list of strings to an array-like of shape
(k, dim).batch_size – Texts per call to
encode.
- Returns:
A
float32array of shape(len(texts), dim)in the original order oftexts.
>>> import numpy as np >>> def enc(batch): # toy encoder: vector = [length] ... return np.array([[len(t)] for t in batch], dtype=np.float32) >>> out = embed_length_sorted(['aaa', 'a', 'aaaaa', 'aa'], enc, batch_size=2) >>> out.ravel().tolist() # order preserved despite internal sorting [3.0, 1.0, 5.0, 2.0]
- ef.evaluate_rag(samples: Iterable[RagSample | Mapping[str, Any]], *, metrics: Sequence[str] = ('exact_match', 'token_f1', 'context_recall', 'context_precision', 'non_empty_rate')) RagEvalReport[source]
Score RAG samples with deterministic, reference-based lexical metrics.
Each sample is a
RagSample—(user_input, response, retrieved_contexts, reference).evaluate_ragcomputes only metrics that need no LLM:exact_match(),token_f1(),context_recall(),context_precision()andnon_empty_rate(the share of samples that retrieved any context). This keepsefa facade — it never synthesizes an answer and never calls a model (§6).For the LLM-judged metrics — faithfulness, answer relevancy, … — pass the same samples to
as_ragas_dataset()and run Ragas with your own LLM.- Parameters:
samples – an iterable of
RagSamples (or plain mappings of the same shape).metrics – which metrics to compute — any of
exact_match/token_f1/context_recall/context_precision/non_empty_rate. The reference-based ones skip samples that carry noreference.
- Returns:
- Raises:
ValueError – for an unknown metric name, or if
samplesis empty.
>>> samples = [ ... {'user_input': 'capital of France?', 'response': 'Paris', ... 'reference': 'Paris', ... 'retrieved_contexts': ['Paris is the capital of France.']}, ... {'user_input': '2 + 2?', 'response': 'five', 'reference': 'four', ... 'retrieved_contexts': []}, ... ] >>> report = evaluate_rag(samples) >>> report.metrics['exact_match'] 0.5 >>> report.metrics['non_empty_rate'] 0.5
- ef.evaluate_retrieval(retriever: Any, qrels: Mapping[str, Mapping[str, float]], queries: Mapping[str, str], *, k_values: Sequence[int] = (1, 5, 10, 100), metrics: Sequence[str] = ('ndcg', 'recall', 'precision', 'mrr', 'map'), limit: int | None = None) RetrievalEvalReport[source]
Score a retriever against a BEIR-shaped evaluation set.
For every query judged in
qrels, theretrieveris run, its results are mapped back to source document ids (_result_doc_id(), de-duplicated to the best rank per source), and each requested metric is computed at each cutoff ink_values. The report holds the mean of each"<metric>@<k>"over all scored queries — primary metric NDCG@10.The natural pairing with
ingest():corpus, queries, qrels = read_beir("path/to/beir/dataset") index = ingest(corpus, embedder="st:all-MiniLM-L6-v2") report = evaluate_retrieval(index, qrels, queries) report.primary # mean NDCG@10
- Parameters:
retriever – what answers a query — a
SearchableCorpus, aSourceManager, or any callablequery -> ranked results. Results may beSearchHits,Segments or bare doc-id strings.qrels – the relevance judgements —
{query_id: {doc_id: relevance}}. Queries with no positive judgement are skipped.queries – the query texts —
{query_id: query_text}. A judged query absent here cannot be run and is skipped.k_values – the rank cutoffs to evaluate at.
metrics – which metrics to compute — any of
ndcg/recall/precision/mrr/map.limit – how many results to retrieve per query.
Noneusesmax(k_values); raise it when sources segment into many pieces (de-duplication to source level can otherwise shorten the ranking).
- Returns:
- Raises:
ValueError – for an unknown metric name, or if no query can be scored.
>>> qrels = {'q1': {'d1': 1.0}, 'q2': {'d2': 1.0}} >>> queries = {'q1': 'alpha', 'q2': 'beta'} >>> def retriever(query, *, limit=10): ... return {'alpha': ['d1', 'd2'], 'beta': ['d1', 'd2']}[query][:limit] >>> report = evaluate_retrieval(retriever, qrels, queries, k_values=(1, 2)) >>> report.metrics['recall@2'], report.n_queries (1.0, 2)
- ef.exact_match(response: str, reference: str) float[source]
1.0ifresponseequalsreferenceafter normalization, else0.0.>>> exact_match('The Eiffel Tower', 'eiffel tower') 1.0 >>> exact_match('Paris', 'London') 0.0
- ef.explore(data: Any, *, dims: int = 2, projection_method: Literal['auto', 'umap', 'pca'] = 'auto', cluster_method: Literal['kmeans', 'hdbscan'] = 'kmeans', n_clusters: int = 8, min_cluster_size: int = 5, label: bool = False, context: str = ' ', n_words: int = 4, embedder: Any = None, random_state: int = 42) ExploreResult[source]
Project, cluster and (optionally) label a corpus in one call.
The layer-L5 orchestrator —
project()+cluster()(+ optionallylabel_clusters()) wired into one structured result. Whereproject()/cluster()return bare arrays in input order,explorekeeps every row tied to its id: it returns anExploreResultwithids/coords/labelsrow-aligned (pluscluster_titles) — the JSON-friendly shape anapp_efcorpus map (oref.service.EfService.explore_corpus()) consumes directly.- Parameters:
data – the corpus to explore — anything
_resolve_explorable()understands (aSourceManager/SearchableCorpus, aCorpusmapping, an iterable of texts orSegments, or a vector matrix).dims – projection target dimensionality —
2or3.projection_method – forwarded to
project()—'auto'/'umap'/'pca'.cluster_method – forwarded to
cluster()—'kmeans'/'hdbscan'.n_clusters – number of k-means clusters.
min_cluster_size – smallest cluster HDBSCAN will form.
label – when
True, name each cluster withlabel_clusters()(needs theef[imbed]extra, an LLM key, and text-bearingdata); whenFalse(the default)cluster_titlesis empty.context – the corpus topic passed to
label_clusters().n_words – maximum cluster-title length, in words.
embedder – embedder used when
datais raw text.random_state – seed — projection and clustering are reproducible.
- Returns:
an
ExploreResult—ids,coordsandlabelsrow-aligned, andcluster_titles(empty unlesslabel=True).- Raises:
ValueError – if the corpus is empty, has fewer than 2 samples, or
label=Trueis asked of a text-less vector matrix.
>>> import numpy as np >>> vectors = np.random.RandomState(0).rand(12, 16) >>> result = explore(vectors, projection_method='pca', n_clusters=3, random_state=0) >>> len(result['ids']), len(result['coords']), len(result['labels']) (12, 12, 12) >>> result['ids'][:3] ['0', '1', '2'] >>> len(result['coords'][0]) # each row has `dims` coordinates 2 >>> set(result['labels']) <= {0, 1, 2} True >>> result['cluster_titles'] # empty unless label=True {}
- ef.full_kwargs(op: Callable[[...], Any], args: tuple[Any, ...] = (), kwargs: Mapping[str, Any] | None = None) dict[str, Any][source]
Normalize a call into a canonical, fully-named, defaults-filled kwargs dict.
The
i2.Sigrecipe of design notes §3.8. A call expressed as positionalargs+ keywordkwargsis mapped ontoop’s signature: every parameter is named, and any parameter the call left implicit is filled with its default. The result is therefore stable —op(x)andop(x, size=512)(512the default) yield the same dict, which is what makes a content hash built from it correct.- Parameters:
op – the callable whose signature defines the parameter names/defaults.
args – the call’s positional arguments — for an
ef.opsop these are the upstream artifact ids;step_params()drops them again.kwargs – the call’s keyword arguments.
>>> def embed(segments, *, input_type='document', dim=None): ... >>> full_kwargs(embed, ('seg-1',), {'input_type': 'query'}) {'segments': 'seg-1', 'input_type': 'query', 'dim': None} >>> full_kwargs(embed, ('seg-1',)) # defaults filled in {'segments': 'seg-1', 'input_type': 'document', 'dim': None}
- ef.gemini_embedder(model: str = 'gemini-embedding-001', *, dim: int | None = None, api_key: str | None = None, batch_size: int = 100, timeout: float = 60.0) Embedder[source]
Build a Google Gemini
Embedder.Talks to the Generative Language
batchEmbedContentsREST endpoint directly — nogoogle-genaiSDK, no extra dependency.- Parameters:
model – A Gemini embedding model name (a leading
models/is tolerated and stripped).dim – Matryoshka-truncated width (
gemini-embedding-001only). Omit for the model’s native width. Note that Gemini, unlike OpenAI, does not re-normalize MRL-truncated output — a truncated embedder therefore reportsnormalized=False.api_key – Gemini API key. Falls back to
GEMINI_API_KEY/GOOGLE_API_KEY.batch_size – Texts per request (capped at Gemini’s 100 limit).
timeout – Per-request timeout in seconds.
- Returns:
An
Embedderover the Gemini API.
- ef.hierarchical(segmenters: Sequence[Any], /) Segmenter[source]
Compose segmenters into levels — each segments the previous level’s output.
Level 0 applies
segmenters[0]to the document. Level k appliessegmenters[k]to every level-(k-1) segment’s text, setting each child’sparent_idto that segment’sidand shifting its offsets to stay absolute. The result is a flat stream of every segment of every level (level 0 first), with the tree expressed purely throughparent_idpointers.Each entry of
segmentersis passed throughas_segmenter(), so strings (e.g."recursive") and bare callables are accepted alongside ready-made segmenters.
- ef.hits_to_segments(hits: Iterable[SearchHit]) list[Segment][source]
Project
SearchHits to plainSegments.The shape
SearchableCorpus.retrieve()/SourceManager.retrieve()hand to an external RAG/agent framework — the RAG-plug-in surface (design notes §F5). ASearchHitcarries anef-specificscoreandsource_id; aSegmentis a plainTypedDictan external framework already understands (and trivially adapts to a LangChainDocument/ LlamaIndexTextNode/ a Ragasretrieved_contextslist[str]via[s["text"] for s in segments]).The similarity
scoreis dropped —Segmentis the interchange type and stays pure of a result-only key (callsearch()when the score matters). Provenance is not lost: each hit’ssource_idis folded into the segment’smetadataunder the conventional"source"key (one ofPROMOTED_METADATA_KEYS), so a plain segment still records which source document it was retrieved from. A segment that already carriesmetadata["source"]keeps its own value.>>> hit = SearchHit(segment={'text': 'hi', 'id': 'x'}, score=0.9, source_id='doc-1') >>> segs = hits_to_segments([hit]) >>> segs[0]['text'], segs[0]['metadata']['source'] ('hi', 'doc-1') >>> 'score' in segs[0] # the score is not a Segment key False
- ef.http_embedder(url: str, *, model_id: str | None = None, dim: int | None = None, normalized: bool = False, batch_size: int = 64, headers: dict[str, str] | None = None, timeout: float = 30.0, payload_builder: ~typing.Callable[[~typing.Sequence[str]], bytes] = <function _tei_payload>, response_parser: ~typing.Callable[[~typing.Any], ~typing.Any] = <function _tei_parse>) Embedder[source]
Build an
Embedderover a remote HTTP service.Defaults target the Text-Embeddings-Inference / infinity shape (a POST of
{"inputs": [...]}returning a JSON list of vectors). For a different service, overridepayload_builderand/orresponse_parser.- Parameters:
url – The embedding endpoint.
model_id – Vector-identity string (defaults to
"http:<url>").dim – Output width; inferred from the first response if omitted.
normalized – Whether the service returns unit vectors.
batch_size – Texts per request (used with length-sorted batching).
headers – Extra HTTP headers (e.g. an auth token).
timeout – Per-request timeout in seconds.
payload_builder –
texts -> request body bytes.response_parser –
decoded JSON -> array-like (n, dim).
Uses only the stdlib — no extra dependency.
- ef.imbed_segmenter(name: str = 'default', /, **kwargs: Any) Segmenter[source]
Build a
Segmenterfromimbed’s segmenter registry.imbed’s registered segmenters are baretext -> Iterable[str]callables; this wraps the named one in aFunctionSegmenterso it emits well-formedSegmentpieces.- Parameters:
name – A key in
imbed.components.segmentation.segmenters("default"resolves toimbed’s configured default segmenter).**kwargs – Forwarded to
FunctionSegmenter.
- Raises:
ImportError – if
imbedis not installed.ValueError – if
nameis not a registered segmenter.
Requires the
imbedpackage (pip install 'ef[imbed]').
- ef.ingest(sources: Any, *, segmenter: Any = None, embedder: Any = None, store: Any = None, cache: MutableMapping[str, Any] | None = None) SearchableCorpus[source]
Index
sourcesand return a ready-to-searchSearchableCorpus— one call.The light path: corpus in,
search-ready object out, every component defaulted.ingest(['a', 'b', 'c']).search('query')is the whole story. It builds a single-configSourceManager,materialize()s it, and hands back the thinSearchableCorpus.- Parameters:
sources – the corpus — coerced by
as_corpus()(a mapping, a directory path, an iterable of strings/mappings, orNone).segmenter – the segmenter —
as_segmenter()coerces it (None→ the recursive default).embedder – the embedder —
as_embedder()coerces it;None→DEFAULT_EMBEDDER, the dependency-freeHashingEmbedder(so this call needs nothing beyondpip install ef). Pass an explicit embedder — e.g."st:all-MiniLM-L6-v2"— for neural semantic quality.store – the vector store —
None→ an in-memoryvdbackend. SeeSourceManagerfor the other accepted forms.cache – the artifact-graph value cache — any
MutableMapping.
- Returns:
a
SearchableCorpusover the freshly indexed corpus.
See the module docstring for a complete offline example.
- ef.label_clusters(segments: Iterable[Any], labels: Iterable[Any], *, context: str = ' ', n_words: int = 4, n_samples: int | None = None, **labeler_kwargs: Any) dict[int, str][source]
Name each cluster with a short LLM-generated title (use cases §G3).
A thin wrapper over
imbed’simbed.tools.ClusterLabeler: it samples each cluster’s segments, asks an LLM for a few-word title, and returns{cluster_id: title}.efdoes not reimplement this — it reusesimbed(design notes §7).- Parameters:
segments – the segment texts — plain strings or
Segmentmappings (the"text"field is used). Aligned withlabels.labels – the cluster id of each segment — typically the output of
cluster().context – the corpus’s overall topic, given to the LLM so titles describe how a cluster differs from the rest, not the shared topic.
n_words – maximum title length, in words.
n_samples – how many segments to sample per cluster when prompting;
NonekeepsClusterLabeler’s default.**labeler_kwargs – forwarded to
imbed.tools.ClusterLabeler(e.g.max_unique_clusters,prompt).
- Returns:
{cluster_id: title}for every distinct label.- Raises:
ValueError – if
segmentsandlabelsdiffer in length.ImportError – if
imbed(and itspandas/oadeps) is missing.
>>> # needs ef[imbed] + an LLM key — illustrative only: >>> # label_clusters(['neural net training ...', ...], [0, 1, 0, ...]) >>> # -> {0: 'Neural network training', 1: 'Dataset preprocessing'}
- ef.line_segmenter(doc: str | Mapping[str, Any], /) Iterator[Segment][source]
Segment a document into its non-empty lines.
Each line is stripped of surrounding whitespace; blank lines are dropped. Character offsets point at the stripped content within the source.
A plain function — and therefore already a
Segmenter. It carries the markeras_segmenter()uses to recognize a ready-made segmenter.>>> [s['text'] for s in line_segmenter(' hello \n\nworld\n')] ['hello', 'world']
- ef.make_segment(text: str, *, id: str | None = None, parent_id: str | None = None, start: int | None = None, end: int | None = None, index: int | None = None, tokens: int | None = None, metadata: Mapping[str, Any] | None = None) Segment[source]
Build a
Segment, deriving a content-basedidif none given.Optional fields are omitted from the result when
None— a segmentdictcarries only the keys it actually has.>>> seg = make_segment('chunk one', index=0, start=0, end=9) >>> sorted(seg) ['end', 'id', 'index', 'start', 'text'] >>> seg['id'] == segment_id('chunk one') True
- ef.materialise(x: Any) Any[source]
Force a streaming segmenter (or segment stream) to a concrete list.
Streaming is the default, but the light case often wants everything in hand.
materialise()is polymorphic:given a segmenter (a callable), it returns a new segmenter whose output is a
listrather than a lazy iterator;given an iterable of segments, it returns a
listof them.
>>> eager = materialise(line_segmenter) >>> out = eager('a\nb') >>> isinstance(out, list) True >>> [s['text'] for s in out] ['a', 'b'] >>> materialise(s for s in [{'text': 'x'}]) [{'text': 'x'}]
- ef.ndcg_at_k(ranked: Sequence[str], relevant: Mapping[str, float], k: int) float[source]
Normalized DCG at cutoff
k—ef’s primary retrieval metric.dcg_at_k()divided by the ideal DCG (the DCG of the best possible ranking of the judged docs).1.0is a perfect ranking;0.0when no relevant doc was judged.>>> ndcg_at_k(['d1', 'd2'], {'d1': 1.0, 'd2': 1.0}, 2) # perfect order 1.0 >>> round(ndcg_at_k(['x', 'd1'], {'d1': 1.0}, 2), 4) # relevant doc at rank 2 0.6309
- ef.openai_embedder(model: str = 'text-embedding-3-small', *, dim: int | None = None, api_key: str | None = None, client: Any | None = None, batch_size: int = 2048, poll_interval: float = 30.0) Embedder[source]
Build an OpenAI-embeddings
Embedder.- Parameters:
model – An OpenAI embedding model name.
dim – Matryoshka-truncated output width (
text-embedding-3-*only). Omit for the model’s native dimension. The dim is part of the embedder’s identity — set it once here, not per call.api_key – OpenAI API key (else the SDK’s usual env-var resolution).
client – A pre-built
openai.OpenAIclient (overridesapi_key).batch_size – Texts per request (capped at OpenAI’s 2048 limit).
poll_interval – Seconds between polls when blocking on a Batch-API job.
- Returns:
An embedder whose
embed_batchuses the async OpenAI Batch API.
Requires the
openaipackage (pip install 'ef[openai]').
- ef.plan_refresh(report: StalenessReport, *, mode: Literal['none', 'incremental', 'full', 'scoped_full'] = 'full', scope: Iterable[str] | None = None) RefreshPlan[source]
Turn a
StalenessReportinto aRefreshPlan.The pure, side-effect-free core of the refresh policy. Every source that is
missing,staleormisconfiguredis (re-)materialized; what gets deleted is what themodedictates:none— delete nothing (stale content is added alongside the old);incremental— delete the stale/misconfigured sources’ old documents;full/scoped_full— also delete orphans.
- Parameters:
report – the staleness report to act on.
mode – one of
REFRESH_MODES.scope – an optional subset of source ids the plan is restricted to — sources outside it are left entirely alone.
Nonemeans the whole report. (Withscope=None,fullandscoped_fullcoincide.)
- Raises:
ValueError – if
modeis not a recognizedRefreshMode.
>>> from ef.diagnostics import StalenessReport >>> report = StalenessReport(config='c0', orphan=('x',), missing=('a',), ... stale=('b',), misconfigured=('c',), fresh=('d',)) >>> plan_refresh(report, mode='full').to_materialize ('a', 'b', 'c') >>> plan_refresh(report, mode='full').to_delete ('b', 'c', 'x') >>> plan_refresh(report, mode='incremental').to_delete ('b', 'c') >>> plan_refresh(report, mode='none').to_delete () >>> plan_refresh(report, mode='scoped_full', scope=['a', 'b']).to_delete ('b',)
- ef.precision_at_k(ranked: Sequence[str], relevant: Mapping[str, float], k: int) float[source]
Fraction of the top
kresults that are relevant.>>> precision_at_k(['d1', 'x', 'd2'], {'d1': 1.0, 'd2': 1.0}, 2) 0.5
- ef.producer_spec(op: str, *inputs: str, op_version: str, **params: Any) ProducerSpec[source]
Build a
ProducerSpecergonomically — inputs positional, params keyword.The natural way to write a recipe: the input artifact ids are positional (their order is significant — it is the order the op receives them), and every keyword argument is an op parameter.
op_versionis keyword-only and required — an op without a stated version cannot be content-addressed safely.>>> spec = producer_spec('segment', 'doc-1', op_version='1', size=512, overlap=64) >>> spec.op, spec.inputs ('segment', ('doc-1',)) >>> spec.params == {'size': 512, 'overlap': 64} True
- ef.project(data: Any, *, dims: int = 2, method: Literal['auto', 'umap', 'pca'] = 'auto', embedder: Any = None, n_neighbors: int = 15, min_dist: float = 0.1, metric: str = 'cosine', pca_components: int = 50, random_state: int = 42) ndarray[source]
Project a corpus’s embeddings to
dimscoordinates for visualization.The canonical “show me the shape of the corpus” operation (use cases §G1). The default
method='auto'runs the design-notes §8.4 recipe — PCA → UMAP,cosinemetric, seeded: PCA first compresses high-dimensional embeddings topca_components(cheap denoising), then UMAP lays them out indimsdimensions.- Parameters:
data – what to project — anything
_resolve_vectors()understands: anndarray(or nested sequence) of vectors, an iterable of texts (embedded withembedder), aCorpusmapping, or aSearchableCorpus/SourceManager(vectors pulled from the index).dims – target dimensionality —
2or3for plotting.method –
'auto'(UMAP when available and the corpus is large enough, else PCA),'umap'(force UMAP — needs theef[explore]extra), or'pca'(numpy-only, always available).embedder – embedder used when
datais raw text; defaults to the dependency-freeDEFAULT_EMBEDDER.n_neighbors – UMAP neighborhood size (clamped to
n_samples - 1).min_dist – UMAP minimum point separation in the embedding.
metric – UMAP distance metric —
'cosine'for semantic embeddings.pca_components – PCA width for the pre-UMAP compression step; PCA is skipped when the source dimensionality is already smaller.
random_state – seed — projection is reproducible.
- Returns:
An
ndarrayof shape(n_samples, dims), rows in input order.- Raises:
ValueError – if there are fewer than 2 samples.
ImportError – if
method='umap'andumap-learnis not installed.
>>> import numpy as np >>> vectors = np.random.RandomState(1).rand(20, 32) >>> project(vectors, dims=3, method='pca').shape (20, 3)
- ef.read_beir(directory: str, *, qrels: str | None = None) tuple[dict[str, str], dict[str, str], dict[str, dict[str, float]]][source]
Load a BEIR-format dataset from disk into the in-memory evaluation triple.
Reads
corpus.jsonl(records{"_id", "title", "text"}),queries.jsonl(records{"_id", "text"}) and aqrelsTSV (query-id,corpus-id,scorecolumns). The corpus text is the record’stitleandtextjoined — the BEIR convention.- Parameters:
directory – the dataset directory.
qrels – the qrels file, relative to
directory.Nonetries_DEFAULT_QRELS_PATHSin order.
- Returns:
a
(corpus, queries, qrels)triple —corpusandqueriesare{id: text}dicts,qrelsis{query_id: {doc_id: relevance}}. Feed it straight toevaluate_retrieval()(after indexingcorpuswithingest()).- Raises:
FileNotFoundError – if
corpus.jsonl,queries.jsonlor the qrels file is missing.
- ef.ready_handle(array: Any) BatchHandle[source]
Wrap an already-computed array in a finished
BatchHandle.>>> import numpy as np >>> h = ready_handle(np.zeros((2, 3))) >>> h.poll() 'done' >>> h.result().shape (2, 3)
- ef.recall_at_k(ranked: Sequence[str], relevant: Mapping[str, float], k: int) float[source]
Fraction of the relevant docs that appear in the top
kresults.>>> recall_at_k(['d1', 'x', 'd2'], {'d1': 1.0, 'd2': 1.0}, 2) 0.5 >>> recall_at_k(['d1', 'x', 'd2'], {'d1': 1.0, 'd2': 1.0}, 3) 1.0
- ef.reciprocal_rank(ranked: Sequence[str], relevant: Mapping[str, float], k: int | None = None) float[source]
Reciprocal of the rank of the first relevant result (
0if none).Averaged over queries this is MRR.
koptionally caps how far down the ranking to look.>>> reciprocal_rank(['x', 'd1', 'd2'], {'d1': 1.0}) 0.5 >>> reciprocal_rank(['x', 'd1'], {'d1': 1.0}, k=1) # relevant doc past the cutoff 0.0
- ef.refresh_on_change(manager: Any) Callable[['ChangeEvent'], None][source]
Build a
ChangeEventhandler that auto-refreshesmanager.The auto refresh seam. The returned callable is meant to be a
ChangeDetectingCorpus’son_change— each detected edit (through the wrapper, or surfaced out-of-band byscan()) is incrementally applied to every materialized config ofmanager: anaddedsource is indexed, amodifiedone is re-indexed (delete-then-add), adeletedone is removed.SourceManagerwires this automatically when constructed withauto_refresh=True; call this directly only to attach a manager to a corpus you build yourself.- Parameters:
manager – a
SourceManager(duck-typed — any object with an_apply_change(event)method).- Returns:
a
Callable[[ChangeEvent], None]foron_change.
- ef.rerank(query: str, segments: Sequence[Segment], reranker: Reranker, *, limit: int | None = None) list[Segment][source]
Re-score
segmentsagainstquerywithrerankerand re-order them.The pure heart of the module — no I/O, no retrieval. The
rerankeris called once with the whole candidate list; the segments are returned sorted by descending score (ties keep their incoming order), optionally trimmed tolimit. Each returned segment is a copy carrying the reranker’s score inmetadata["rerank_score"]— so the new ranking is auditable.- Parameters:
query – the search query the segments are scored against.
segments – the candidate segments (from a first-stage retriever).
reranker – a
Reranker— a callable(query, segments) -> scores.limit – keep only the top
limitafter reranking;Nonekeeps all.
- Returns:
the reranked segments — copies, best first.
- Raises:
ValueError – if the reranker returns a number of scores that does not match the number of segments.
>>> segs = [{'text': 'a', 'id': '1'}, {'text': 'bbb', 'id': '2'}] >>> ranked = rerank('q', segs, lambda q, s: [len(x['text']) for x in s]) >>> [r['text'] for r in ranked] ['bbb', 'a'] >>> ranked[0]['metadata']['rerank_score'] 3.0
- ef.segment_id(text: str, metadata: Mapping[str, Any] | None = None) str[source]
Content-derived id for a segment —
sha256of its normalized content.The id is
sha256(normalize(text) + canonical_json(metadata))— the normalization and hashing primitives live inef.hashing, the single source of truthefcontent-addresses through. Because the id depends only on content, re-segmenting an unchanged document produces identical ids — which is what makes ingestion idempotent (a re-run is a no-op, not a duplicate). Passmetadataonly when two segments could share identicaltextyet must stay distinct (e.g. same boilerplate from different sources).>>> segment_id('hello') == segment_id('hello') True >>> segment_id('hello') == segment_id('world') False >>> segment_id('x', {'source': 'a'}) == segment_id('x', {'source': 'b'}) False
- ef.segment_record(x: Any) SegmentRecord[source]
Coerce
x(str/Mapping/SegmentRecord) into a record.The dataclass counterpart of
as_segment()— the convenience-surface constructor.>>> segment_record('hello').text 'hello' >>> segment_record({'text': 'hi', 'tokens': 1}).tokens 1
- ef.sentence_transformers_embedder(model_name: str = 'all-MiniLM-L6-v2', *, device: str | None = None, normalize: bool = False, batch_size: int = 128, model_path: str | None = None, **st_kwargs: Any) Embedder[source]
Build a local sentence-transformers
Embedder.- Parameters:
model_name – A sentence-transformers model name (also used in
model_id).device – Torch device (
"cuda"/"cpu"/"mps");Noneauto-selects.normalize – Whether to L2-normalize outputs. sentence-transformers does not normalize by default — set this truthfully.
batch_size – Encoding batch size (smaller on CPU).
model_path – A local filesystem path to load from instead of the hub — for air-gapped use. Loading still records
model_nameinmodel_id.**st_kwargs – Forwarded to the
SentenceTransformerconstructor.
Requires
sentence-transformers(pip install 'ef[sentence-transformers]').
- ef.step_params(op: Callable[[...], Any], raw_params: Mapping[str, Any] | None = None, *, n_inputs: int = 1) dict[str, Any][source]
The canonical keyword params of an op step — its inputs dropped.
An
ef.opsop is calledop(*input_values, **params): the firstn_inputspositional parameters are upstream artifacts (they belong inProducerSpec.inputs, not in the params), and every remaining parameter is a hashable step param. This runsfull_kwargs()with placeholder inputs, then drops those input slots — leaving exactly the params, defaults filled, ready for aTransformSpec.- Parameters:
op – the op callable whose signature is the param contract.
raw_params – the keyword params the caller actually supplied; anything omitted is defaulted.
n_inputs – how many leading positional parameters are upstream inputs.
>>> def embed(segments, *, input_type='document'): ... >>> step_params(embed, {'input_type': 'query'}) {'input_type': 'query'} >>> step_params(embed) # default filled in {'input_type': 'document'} >>> def segment(source): ... >>> step_params(segment) # no params at all {}
- ef.token_f1(response: str, reference: str) float[source]
Token-overlap F1 of
responseagainstreference(the SQuAD F1).The harmonic mean of token precision and recall over the normalized token bags — a graded credit where
exact_match()is all-or-nothing.>>> token_f1('the quick brown fox', 'a quick brown fox') 1.0 >>> round(token_f1('quick brown fox', 'quick brown dog'), 4) 0.6667
- ef.voyage_embedder(model: str = 'voyage-3.5', *, dim: int | None = None, api_key: str | None = None, batch_size: int = 1000, timeout: float = 60.0) Embedder[source]
Build a Voyage AI
Embedder.Talks to Voyage’s
/v1/embeddingsREST endpoint directly — novoyageaiSDK, no extra dependency. Voyage embeddings are L2-normalized. Voyage distinguishes onlyqueryfromdocument; aclassificationorclusteringhint is simply not sent.- Parameters:
model – A Voyage embedding model name.
dim – Matryoshka-truncated width (the
voyage-3.5family andvoyage-code-3only). Omit for the model’s native width.api_key – Voyage API key. Falls back to
VOYAGE_API_KEY.batch_size – Texts per request (capped at Voyage’s 1000 limit).
timeout – Per-request timeout in seconds.
- Returns:
An
Embedderover the Voyage API.
- ef.with_overlap(segmenter: ~ef.segmenters.Segmenter, n_chars: int, /, *, count_tokens: ~typing.Callable[[str], int] = <function approx_token_count>, tokenizer: str = 'char-approx') Segmenter[source]
Wrap
segmenterso consecutive segments overlap byn_charscharacters.Each segment is extended forward by up to
n_charscharacters (clamped to the source length), so segment K shares its tail with segment K+1’s head. A segment whose text changes has itsidre-derived and itstokensrecounted; segments lackingstart/endoffsets pass through untouched.Overlap is expressed in characters here (this is a generic post-hoc helper) — for token-measured overlap use a segmenter’s own
chunk_overlap.>>> base = RecursiveCharacterSegmenter(chunk_size=3, chunk_overlap=0) >>> [c['text'] for c in base('one two three four')] ['one two three', 'four'] >>> [c['text'] for c in with_overlap(base, 100)('one two three four')] ['one two three four', 'four']
- ef.with_reranker(retriever: Callable[[...], Sequence[Segment]], reranker: Reranker, *, fetch_k: int = 50) Callable[[...], list[Segment]][source]
Decorate a retrieve-style callable with a two-stage reranking pass.
Wraps
retriever(e.g.SourceManager.retrieve, or any callablequery -> list[Segment]) so every call over-fetchesfetch_kcandidates cheaply,rerank()s them, and returns the toplimit. The decorated callable keeps the retriever’s interface —queryplus a keyword-onlylimit— so it is a drop-in replacement, including as theretrieverargument ofevaluate_retrieval().- Parameters:
retriever – the first-stage retriever — a callable
retriever(query, *, limit=...) -> segments.reranker – the
Rerankerapplied to the over-fetched candidates.fetch_k – how many candidates to retrieve before reranking. Larger trades latency for recall headroom; it should comfortably exceed the
limitcallers ask for.
- Returns:
a new callable
(query, *, limit=10, **kwargs) -> list[Segment]— extra keyword arguments (e.g.filter) pass straight through toretriever.
>>> pool = [{'text': t, 'id': t} for t in ['ab', 'abcd', 'abc']] >>> def base(query, *, limit=10): ... return pool[:limit] >>> retrieve = with_reranker( ... base, lambda q, segs: [len(s['text']) for s in segs], fetch_k=10) >>> [s['text'] for s in retrieve('a query', limit=2)] ['abcd', 'abc']
- ef.write_beir(directory: str, corpus: Mapping[str, str], queries: Mapping[str, str], qrels: Mapping[str, Mapping[str, float]]) None[source]
Write an evaluation triple to disk in BEIR format — the inverse of
read_beir().Creates
directory(and parents) and writescorpus.jsonl,queries.jsonlandqrels.tsv. Use it to snapshot anefcorpus as a reusable benchmark (ef_use_cases.md§H4).- Parameters:
directory – the output directory — created if absent.
corpus – the documents —
{doc_id: text}.queries – the queries —
{query_id: query_text}.qrels – the relevance judgements —
{query_id: {doc_id: relevance}}.