ef.source_manager¶
The search facade — one-shot ingest(), SearchableCorpus, SourceManager.
This is where ef’s layers come together. Phases 1–4 built the parts — a
Corpus, a Segmenter, an
Embedder, the content-addressed
ArtifactGraph — and this module wires
corpus → segment → embed → vd into a usable search facade with the
progressive disclosure ef promises:
The light path —
ingest()— one line. A list of strings (or any corpus) in, aSearchableCorpusout,search(query)ready.The heavy path —
SourceManager— the same machinery with the components named explicitly and multiple configs (a config is one segmenter+embedder pipeline). Two configs that share a step share that step’s artifacts in theArtifactGraphfor free — config branching costs only the divergent cone.Keeping it fresh —
SourceManager.diagnose()reports how far the index has drifted from the corpus (the four staleness conditions);SourceManager.refresh()re-syncs it. Passauto_refresh=Trueto keepthe index live as the corpus is edited. See
ef.diagnosticsand
How the wiring works (one source, one config):
the source is a graph leaf, keyed by
content_hash();a
segmentProducerSpecover that leaf → alistofSegments;an
embedProducerSpecover the segment artifact → an(n, dim)array;each
(segment, vector)pair is written into avdcollection as avd.Document, withsource_id/source_hash/config_hashrecorded in its metadata.
ef owns the embedding: it always supplies vectors to vd (the index is
vd’s job, the vectors are ef’s — design notes §7), and at query time it
embeds the query with input_type="query" and hands vd the vector.
Example — the light path, end to end, offline:
>>> import numpy as np
>>> from ef import ingest, as_embedder
>>> toy = as_embedder(
... lambda texts: np.array(
... [[t.count('a'), t.count('e'), t.count('o')] for t in texts], dtype=float
... ),
... model_id='toy-vowels@3',
... )
>>> idx = ingest(['apple orchard', 'ocean breeze', 'green meadow'], embedder=toy)
>>> hits = idx.search('ocean breeze', limit=2)
>>> hits[0].segment['text']
'ocean breeze'
>>> round(hits[0].score, 6)
1.0
>>> hits[0].source_id is not None
True
And the true light path — no embedder at all, nothing to install beyond
ef itself (the default is the dependency-free
HashingEmbedder):
>>> idx = ingest(['apple orchard', 'ocean breeze', 'green meadow'])
>>> idx.search('ocean breeze', limit=1)[0].segment['text']
'ocean breeze'
Module Attributes
The model name |
Functions
|
|
|
Index |
Classes
|
One ranked search result — a |
|
A ready-to-search view over one indexed config — the thin facade. |
|
The heavy search facade — multi-config corpus indexing over one corpus. |
- ef.source_manager.DEFAULT_EMBEDDER = 'hashing'¶
The model name
ingest()/SourceManagerresolve an absentembedderto —HashingEmbedder, the dependency-free default (numpy only, no extra to install). It is a lexical embedder; pass an explicitembedder("st:...","openai:...", …) for neural semantic quality.
- class ef.source_manager.SearchHit(segment, score, source_id=None)[source]¶
Bases:
objectOne 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).
- source_id¶
the corpus key of the source document, if recorded.
>>> 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.source_manager.SearchableCorpus(collection, embedder, *, config=None)[source]¶
Bases:
objectA 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:
>>> # built for you by ingest(); see the module docstring for a full example.- retrieve(query, *, limit=10, filter=None)[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': ...}, ...]
- class ef.source_manager.SourceManager(corpus=None, *, segmenter=None, embedder=None, store=None, cache=None, auto_refresh=False, embedder_api_key=None)[source]¶
Bases:
objectThe 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 (
Any) – the source corpus — coerced byas_corpus()(a mapping, a directory path, an iterable of sources, orNone).segmenter (
Any) – the default config’s segmenter — coerced byas_segmenter()(None→ the recursive default). Used only whenembedderis also given.embedder (
Any) – the default config’s embedder — coerced byas_embedder(). If given, a"default"config is registered; ifNone, no default config is registered.embedder_api_key (
str|None) – 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 (
Any) – 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 (
MutableMapping[str,Any] |None) – theArtifactGraph’s value cache — anyMutableMapping;None→ an in-RAMdict.auto_refresh (
bool) – whenTrue, 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- diagnose(config=None)[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.- Return type:
>>> 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=None)[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').- Return type:
- lineage(key)[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=None, *, sources=None)[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:
config (
str|None) – the config to index — a name, aConfigId, orNonefor every registered config.sources (
Iterable[str] |None) – an optional subset of source ids to index —None(the default) indexes the whole corpus.refresh()uses this to re-index only the sources that changed; ids not in the corpus are skipped.
- Return type:
- Returns:
a report
dictwithconfigs/sources/segmentscounts.
- pipeline(config=None)[source]¶
The
PipelineSpecof a registered config.- Return type:
- rebuild(config=None)[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.- Return type:
- Returns:
- refresh(config=None, *, sources=None, mode='full')[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 (
str|None) – the config to refresh (name / id /Nonefor the default or sole config).sources (
Iterable[str] |None) – an optional subset of source ids to refresh — sources outside it are left untouched. Pair it withmode='scoped_full'(or'incremental');'full'rejects it.mode (
Literal['none','incremental','full','scoped_full']) – one ofREFRESH_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'.
- Return type:
- 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='default', *, segmenter=None, embedder=None, embedder_api_key=None)[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).- Return type:
>>> 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, *, config=None, limit=10, filter=None)[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()[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).- Return type:
- search(query, *, config=None, limit=10, filter=None)[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=None)[source]¶
Return a thin
SearchableCorpusbound to one config’s index.- Return type:
- ef.source_manager.hits_to_segments(hits)[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.source_manager.ingest(sources, *, segmenter=None, embedder=None, store=None, cache=None)[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 (
Any) – the corpus — coerced byas_corpus()(a mapping, a directory path, an iterable of strings/mappings, orNone).segmenter (
Any) – the segmenter —as_segmenter()coerces it (None→ the recursive default).embedder (
Any) – 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 (
Any) – the vector store —None→ an in-memoryvdbackend. SeeSourceManagerfor the other accepted forms.cache (
MutableMapping[str,Any] |None) – the artifact-graph value cache — anyMutableMapping.
- Return type:
- Returns:
a
SearchableCorpusover the freshly indexed corpus.
See the module docstring for a complete offline example.