Storage protocols¶
Storage is defined by protocols — interfaces every backend implements. The memory,
elastic, and neo4j adapters all satisfy these, which is why the entire pipeline runs
unchanged regardless of where data lives. See Architecture for the
two-stores design and Configuration for selecting backends.
.. py:module:: foodscholar.storage.protocols
.. py:class:: ChunkStore(*args, **kwargs)
- module:
foodscholar.storage.protocols
Bases: :py:class:
~typing.Protocol.. py:method:: ChunkStore.init()
- module:
foodscholar.storage.protocols
Provision the underlying store (index, schema, etc.). Idempotent.
Local stores (e.g.
InMemoryChunkStore) implement this as a no-op so thatfs.init()works the same regardless of backend.
.. py:method:: ChunkStore.upsert(chunks)
- module:
foodscholar.storage.protocols
.. py:method:: ChunkStore.get(chunk_id)
- module:
foodscholar.storage.protocols
.. py:method:: ChunkStore.get_many(chunk_ids)
- module:
foodscholar.storage.protocols
.. py:method:: ChunkStore.search(query, theme_ids=None, shelf_ids=None, k=10, use_vector=True, use_bm25=True)
- module:
foodscholar.storage.protocols
.. py:method:: ChunkStore.update_attachments(chunk_id, shelf_ids, theme_ids)
- module:
foodscholar.storage.protocols
.. py:method:: ChunkStore.bulk_update_attachments(items, *, wait_for_refresh=False)
- module:
foodscholar.storage.protocols
Patch
shelf_ids+theme_idson many chunks in one round-trip.Used by
fs.attach()so the chunk-side denormalization doesn’t pay one ES_updateper chunk.wait_for_refresh=Trueblocks until the new values are searchable — set on the last flush only so subsequent queries in the same cell see the result. Remote backends collapse to a single bulk call per invocation; in-memory loops.
.. py:method:: ChunkStore.clear_attachments()
- module:
foodscholar.storage.protocols
Reset all chunk-side shelf/theme denormalization.
fs.attach()calls this at the start so a re-run produces honestshelf_idseven when the projection changed (a chunk that previously attached to a now-pruned shelf no longer carries its id). Theme attachments are wiped too — they’re written by Layer B, which always rebuilds them. Per-chunk content (text, mentions, entity_links, embedding) is untouched.
.. py:method:: ChunkStore.update_annotations(chunk_id, mentions, entity_links, foodon_ids, enrichment_version)
- module:
foodscholar.storage.protocols
.. py:method:: ChunkStore.update_embedding(chunk_id, embedding, embedding_model)
- module:
foodscholar.storage.protocols
Patch the chunk’s
embedding+embedding_modelonly.Used by
fs.embed()so re-embedding doesn’t rewrite thementions/entity_links/foodon_idspayload — a single field-scoped update on the remote backends, amodel_copyon the in-memory ones.
.. py:method:: ChunkStore.update_embeddings_bulk(items)
- module:
foodscholar.storage.protocols
Bulk variant of
update_embedding. Each item is(chunk_id, embedding, embedding_model). Implementations should coalesce into a single network round-trip on remote backends — this is the hot path forfs.embed()over a tunneled cluster, where per-doc updates are network-bound.
.. py:method:: ChunkStore.bulk_set_theme_ids(items)
- module:
foodscholar.storage.protocols
Set
theme_idson many chunks without touchingshelf_ids.Layer B’s persist path uses this instead of
bulk_update_attachmentsto avoid a read-then-overwrite race: if a separate writer (e.g., a concurrentfs.attach()) updatesshelf_idsbetween the persist read and the persist write, the bulk-update would clobber the new shelf attachments.bulk_set_theme_idsonly touchestheme_ids, so it’s safe under anyshelf_idswriter.Passing
theme_ids=[]is the explicit ‘remove all themes from this chunk’ signal — used by clear-and-rebuild Layer B runs. Items targeting a missingchunk_idare silently skipped.
.. py:method:: ChunkStore.knn_search_chunks(query_vector, *, k, exclude_ids=None, candidate_ids=None)
- module:
foodscholar.storage.protocols
Return the top-k cosine-nearest chunks to
query_vector.exclude_ids: chunks to omit from the result (typically[query_id]).candidate_ids: if provided, restrict the search to these ids (used to constrain the global similarity pass to attached chunks).
Returns
[(chunk_id, cosine_score), ...]sorted by score descending. Implementations may skip the score sort if their backend returns it unordered (callers re-sort).
.. py:method:: ChunkStore.scan()
- module:
foodscholar.storage.protocols
.. py:method:: ChunkStore.iter_chunks(batch_size=1000)
- module:
foodscholar.storage.protocols
.. py:class:: GraphStore(*args, **kwargs)
- module:
foodscholar.storage.protocols
Bases: :py:class:
~typing.Protocol.. py:method:: GraphStore.init()
- module:
foodscholar.storage.protocols
Provision the underlying store (constraints, indexes). Idempotent.
.. py:method:: GraphStore.upsert_shelves(shelves)
- module:
foodscholar.storage.protocols
.. py:method:: GraphStore.clear_layer_a()
- module:
foodscholar.storage.protocols
Delete every (:Shelf) node and any edges attached to it.
Called by
build_layer_abefore re-upsert so stale shelves from a previous projection (with a different blacklist / threshold) don’t survive as ghosts. Local stores clear their shelf dict; Neo4j runsMATCH (s:Shelf) DETACH DELETE s, which kills PARENT_OF, HAS_THEME, HAS_CHUNK, DESCRIBES edges in one shot.Idempotent — a no-op when no shelves exist.
.. py:method:: GraphStore.upsert_themes(themes)
- module:
foodscholar.storage.protocols
.. py:method:: GraphStore.upsert_cards(cards)
- module:
foodscholar.storage.protocols
.. py:method:: GraphStore.clear_attachments()
- module:
foodscholar.storage.protocols
Delete every
(:Chunk)-[:ATTACHED_TO]->(:Shelf)edge.Called at the start of
fs.attach()so a re-run doesn’t leave ghost edges from a previous projection.(:Shelf)nodes themselves survive (they’re the output ofbuild_layer_a, not of attach), as do(:Chunk)stub nodes and any:ATTACHED_TOedges pointing at(:Theme)(those belong to Layer B). Idempotent.
.. py:method:: GraphStore.attach_chunks_to_shelf(shelf_id, attachments)
- module:
foodscholar.storage.protocols
Wire
(:Chunk)-[:ATTACHED_TO {lifted_from}]->(:Shelf)edges.Each tuple is
(chunk_id, lifted_from).lifted_fromlists the FOODON ids on the chunk whose ancestry/collapse resolved to this shelf — empty when the chunk linked the shelf’s ownfoodon_iddirectly. Used by the attach phase to record projection provenance on every edge so audits can answer “why is this chunk on this shelf?” without re-running the resolver. Idempotent — re-running with the same shelf+chunk pair overwriteslifted_from.
.. py:method:: GraphStore.attach_chunks_to_theme(theme_id, chunk_ids)
- module:
foodscholar.storage.protocols
.. py:method:: GraphStore.attach_chunks_to_themes_bulk(items)
- module:
foodscholar.storage.protocols
Wire
(:Chunk)-[:THEME_OF {primary, weight}]->(:Theme)edges in bulk.Each tuple is
(chunk_id, theme_id, primary, weight).primarymarks the per-shelf primary theme for a chunk (used by retrieval ranking);weightis a continuous score (centroid-distance for similarity themes, edge-degree for relatedness, max-of-both for merged). One network round-trip per call on remote backends — the hot path for Layer B persistence on a tunneled Neo4j.Idempotent — re-running with the same
(chunk_id, theme_id)pair overwritesprimary+weight.
.. py:method:: GraphStore.clear_themes(facet=None)
- module:
foodscholar.storage.protocols
Delete
(:Theme)nodes along with theirHAS_THEMEandTHEME_OFedges. Whenfacetis given, delete only that facet’s themes; whenNone, delete every theme.fs.build_layer_b()calls this scoped to the facet it is rebuilding so a re-run with a different config doesn’t leave ghost themes — and, critically, building one facet never wipes another facet’s themes (the notebook loops over facets). Shelves and chunks survive (they’re Layer A artifacts); chunk-sidetheme_idsdenorm is the caller’s responsibility viachunk_store.bulk_set_theme_ids. Idempotent — a no-op when no matching themes exist.
.. py:method:: GraphStore.get_shelf(shelf_id)
- module:
foodscholar.storage.protocols
.. py:method:: GraphStore.get_themes_for_shelf(shelf_id)
- module:
foodscholar.storage.protocols
.. py:method:: GraphStore.get_chunks_for_theme(theme_id)
- module:
foodscholar.storage.protocols
.. py:method:: GraphStore.get_neighbors(shelf_id, hops=1)
- module:
foodscholar.storage.protocols
.. py:method:: GraphStore.get_card(target_id, target_type)
- module:
foodscholar.storage.protocols
.. py:method:: GraphStore.list_shelves()
- module:
foodscholar.storage.protocols
.. py:method:: GraphStore.list_themes()
- module:
foodscholar.storage.protocols
.. py:method:: GraphStore.list_chunk_shelf_attachments()
- module:
foodscholar.storage.protocols
Return every
(:Chunk)-[:ATTACHED_TO]->(:Shelf)edge as a mapchunk_id -> {shelf_id, ...}. Used by audit to cross-check the Elasticshelf_idsdenorm against the actual edge graph. One round trip; output size proportional to total attach edges.
.. py:method:: GraphStore.list_chunk_foodon_mentions()
- module:
foodscholar.storage.protocols
Return every
(:Chunk)-[:MENTIONS]->(:Entity)edge whose entity is FOODON-prefixed, as a mapchunk_id -> {ontology_id, ...}. Used by audit to verify the Elasticfoodon_idsdenorm matches whatbuild_entitieswrote. One round trip.
.. py:method:: GraphStore.upsert_entities(entities)
- module:
foodscholar.storage.protocols
.. py:method:: GraphStore.attach_chunks_to_entity(ontology_id, chunk_links)
- module:
foodscholar.storage.protocols
Wire up
(:Chunk)-[:MENTIONS {confidence, method}]->(:Entity)edges.chunk_linksis a list of(chunk_id, confidence, method)tuples carrying the per-mention metadata. Implementations must be idempotent — re-running with the same links must not duplicate the edges.
.. py:class:: EntityStore(*args, **kwargs)
- module:
foodscholar.storage.protocols
Bases: :py:class:
~typing.ProtocolDedicated, queryable store for first-class linked entities.
Local stores implement
init()as a no-op; the Elastic adapter creates afoodscholar_entitiesindex alongside the chunk index... py:method:: EntityStore.init()
- module:
foodscholar.storage.protocols
.. py:method:: EntityStore.upsert(entities)
- module:
foodscholar.storage.protocols
.. py:method:: EntityStore.get(ontology_id)
- module:
foodscholar.storage.protocols
.. py:method:: EntityStore.get_many(ontology_ids)
- module:
foodscholar.storage.protocols
.. py:method:: EntityStore.list_by_prefix(prefix, *, k=100)
- module:
foodscholar.storage.protocols
.. py:method:: EntityStore.search(query, *, prefix=None, k=10)
- module:
foodscholar.storage.protocols
.. py:method:: EntityStore.scan()
- module:
foodscholar.storage.protocols
.. py:class:: Embedder(*args, **kwargs)
- module:
foodscholar.storage.protocols
Bases: :py:class:
~typing.Protocol.. py:attribute:: Embedder.model_id
- module:
foodscholar.storage.protocols
- type:
str
.. py:property:: Embedder.dim
- module:
foodscholar.storage.protocols
- type:
int
.. py:method:: Embedder.embed(texts)
- module:
foodscholar.storage.protocols
.. py:class:: CardStore(*args, **kwargs)
- module:
foodscholar.storage.protocols
Bases: :py:class:
~typing.ProtocolVector-searchable store for Layer C cards. Cards also live in the graph store (Neo4j); this store holds the embedding for kNN retrieval.
.. py:method:: CardStore.init()
- module:
foodscholar.storage.protocols
Idempotently provision backing storage (e.g. the ES cards index).
.. py:method:: CardStore.upsert(cards)
- module:
foodscholar.storage.protocols
.. py:method:: CardStore.get_many(card_ids)
- module:
foodscholar.storage.protocols
.. py:method:: CardStore.knn_search_cards(query_vector, *, k, exclude_ids=None)
- module:
foodscholar.storage.protocols
Return up to
k(card_id, cosine_score)nearest the query, best first. Cards without an embedding are not returned.
.. py:class:: LLMClient(*args, **kwargs)
- module:
foodscholar.storage.protocols
Bases: :py:class:
~typing.Protocol.. py:attribute:: LLMClient.model_id
- module:
foodscholar.storage.protocols
- type:
str
.. py:method:: LLMClient.generate(prompt, max_tokens=1024)
- module:
foodscholar.storage.protocols
.. py:method:: LLMClient.generate_json(prompt, schema, max_tokens=1024)
- module:
foodscholar.storage.protocols
Return a JSON object conforming to
schema(a JSON-schema dict).Uses the provider’s native structured-output mode where available. Guarantees the result parses and matches the schema’s shape — it does NOT guarantee the values are semantically correct (e.g. an LLM-reported character offset may be a valid integer yet wrong). Callers that need correct positions must verify them against the source themselves.
.. py:class:: NER(*args, **kwargs)
- module:
foodscholar.storage.protocols
Bases: :py:class:
~typing.ProtocolSpan-level food entity recognizer.
Implementations should be deterministic given fixed model weights so pipeline reruns produce stable results.
.. py:attribute:: NER.model_id
- module:
foodscholar.storage.protocols
- type:
str
.. py:method:: NER.extract(text)
- module:
foodscholar.storage.protocols
.. py:class:: Linker(*args, **kwargs)
- module:
foodscholar.storage.protocols
Bases: :py:class:
~typing.ProtocolMaps a
Mentionto a single ontology id, or None if no candidate clears the threshold... py:attribute:: Linker.linker_id
- module:
foodscholar.storage.protocols
- type:
str
.. py:method:: Linker.link(mention)
- module:
foodscholar.storage.protocols