Interface Guide
omop-emb exposes two complementary Python interfaces:
EmbeddingWriterInterface: write and read. Builds and owns anomop_llm.ModelBackendfor embedding generation, model registration, and upsert.EmbeddingReaderInterface: read-only. No model backend owned by the interface; nearest-neighbour queries and registry lookups only.
Both interfaces accept a pre-constructed EmbeddingBackend (sqlite-vec or pgvector) and validate model names via omop_llm's provider registry.
Opening a vector store
Resolve the vector store named in [tools.omop_emb] (a [vector_stores.*] entry), then open it for writing or for reading:
from oa_configurator import Resolver
from omop_emb.backends import open_vector_store_reader, open_vector_store_writer
from omop_emb.config import OmopEmbConfig
cfg = OmopEmbConfig.get_config()
resolved = Resolver.from_active_config().resolve_vector_store(cfg.vector_store_name)
backend = open_vector_store_writer(resolved) # EmbeddingBackend
store = open_vector_store_reader(resolved) # EmbeddingStoreReader
open_vector_store_writer() |
open_vector_store_reader() |
|
|---|---|---|
| Returns | EmbeddingBackend |
EmbeddingStoreReader |
| Registers the registry's schema claim | yes | no |
| Creates the registry table / pgvector extension | yes | no |
| Write methods | allowed | raise ReadOnlyStoreError |
| Database connection | read-write | read-only (PostgreSQL READ ONLY transactions, SQLite query_only) |
EmbeddingStoreReader is a Protocol listing every read: registry lookups (get_registered_model(), get_registered_models()), stored data (iter_stored_embeddings(), has_any_embeddings(), get_stored_concept_ids(), get_embeddings_by_concept_ids(), get_concept_filter_metadata(), the counts), get_nearest_concepts(), and index inspection (physical_indexes(), drop_index_sql()). Both backends satisfy it, and EmbeddingReaderInterface, export_bundle() and FAISSCache.build_from_backend() accept it. Only get_nearest_concepts() takes a metric_type; nothing stored depends on one. A store that was never set up reports an empty registry instead of being created by being looked at. On pgvector the vector extension must already exist in the database; the writer creates it.
EmbeddingWriterInterface
Creating the interface
from oa_configurator import Resolver
from omop_emb import EmbeddingWriterInterface
from omop_emb.config import MetricType, OmopEmbConfig
cfg = OmopEmbConfig.get_config()
resolved_model = Resolver.from_active_config().resolve_model(cfg.embedding_model_name)
writer = EmbeddingWriterInterface(
backend=backend,
metric_type=MetricType.COSINE,
resolved_model=resolved_model,
cdm_session_factory=cdm_sessions, # needed to read concepts; also enriches search results
)
cdm_sessions opens sessions on the OMOP CDM. Build it with omop_alchemy.cross_database.cdm_sessionmaker, which sends each table to the database hosting it, so concept reads reach the vocabulary even when it lives on its own server:
from omop_alchemy.cross_database import cdm_sessionmaker
primary, vocab = resolved_cdm.create_engines()
cdm_sessions = cdm_sessionmaker(resolved_cdm, primary=primary, vocab=vocab)
Inside omop-emb's own CLI, omop_emb.config.open_cdm_sessions() does this for the configured cdm_db.
resolved_model is an oa_configurator.ResolvedModel — provider, connection details, embedding_dim, and document_prefix/query_prefix all live on the [models.*] entry it was resolved from (see Asymmetric Embeddings), not on omop-emb's own config. The interface builds and owns the ModelBackend itself via omop_llm.build_model_backend_from_resolved(resolved_model); there is no separate client object to construct first.
Register and initialise
from omop_emb.backends.index_config import FlatIndexConfig
# Always register with FLAT first
writer.register_model() # uses FlatIndexConfig() by default
writer.register_model(index_config=FlatIndexConfig()) # explicit equivalent
register_model is idempotent: calling it when the model is already registered is safe and returns the existing record.
Generate and store embeddings
# Fetch candidate concepts from the CDM, then pass the returned rows back as
# concept_meta so filter columns can be stored alongside the embeddings.
missing = writer.get_concepts_without_embedding()
writer.embed_and_upsert_concepts(
concept_ids=tuple(missing.keys()),
concept_texts=tuple(row.concept_name for row in missing.values()),
concept_meta=missing,
)
Asymmetric embedding models
embed_and_upsert_concepts always applies the document role, and get_nearest_concepts_from_query_texts always applies the query role. When calling embed_texts directly you must pass role explicitly. See Asymmetric Embeddings for task prefix configuration.
Build an HNSW index
After all embeddings are ingested, optionally upgrade to an approximate index:
from omop_emb.backends.index_config import HNSWIndexConfig
from omop_emb.config import MetricType
writer.rebuild_index(
index_config=HNSWIndexConfig(
metric_type=MetricType.COSINE,
num_neighbors=16,
ef_construction=64,
ef_search=16,
)
)
This is equivalent to running omop-emb maintenance rebuild-index --index-type hnsw from the CLI.
EmbeddingReaderInterface
Use this when you only need to query stored embeddings: no embedding generation, no model backend owned by the interface.
from omop_emb import EmbeddingReaderInterface
from omop_emb.config import MetricType
reader = EmbeddingReaderInterface(
model="nomic-embed-text:v1.5",
backend=store,
metric_type=MetricType.COSINE,
provider_type="ollama",
cdm_session_factory=cdm_sessions, # optional; enriches results with concept_name
)
Query nearest concepts
import numpy as np
from omop_emb.utils.embedding_utils import EmbeddingConceptFilter
query_vec = np.array([[...]], dtype=np.float32) # shape (Q, D)
results = reader.get_nearest_concepts(
query_embedding=query_vec,
k=10,
concept_filter=EmbeddingConceptFilter(
require_standard=True,
domains=("Condition", "Drug"),
require_active=True,
),
)
# results: tuple[tuple[NearestConceptMatch, ...], ...], one inner tuple per query row
Query similar concepts
Find neighbours of concepts that are already embedded, without fetching and passing a raw vector yourself. The query concept is excluded from its own result row.
results = reader.get_similar_concepts(
concept_ids=(201826, 320128),
k=5,
concept_filter=EmbeddingConceptFilter(require_standard=True),
)
# results: tuple[tuple[NearestConceptMatch, ...], ...], one row per concept_id, same order
Combine embeddings (joint/centroid queries)
Build a single query vector from several stored concept embeddings, e.g. to
search for concepts similar to a combination of conditions, then feed it
into get_nearest_concepts.
joint_vec = reader.get_joint_embedding(
concept_ids=(201826, 320128),
weights=(0.7, 0.3), # optional; defaults to an unweighted mean
)
results = reader.get_nearest_concepts(query_embedding=joint_vec[None, :], k=10)
Query by text
get_nearest_concepts_from_query_texts takes a ModelBackend directly: build one with omop_llm.build_model_backend (the reader has no default backend of its own to embed with):
from omop_llm import Capabilities, build_model_backend
model_backend = build_model_backend(
"ollama",
"nomic-embed-text:v1.5",
model_capabilities=Capabilities(embeddings=True),
base_url="http://localhost:11434",
)
results = reader.get_nearest_concepts_from_query_texts(
query_texts=("high blood pressure", "type 2 diabetes"),
model_backend=model_backend,
k=5,
)
FAISS fast path
Supply faiss_cache_dir to route searches through a pre-built FAISS index instead of the primary backend SQL path. The cache must have been built first with omop-emb maintenance build-faiss-cache (builds the FAISS index directly from the backend). Requires omop-emb[faiss-cpu].
reader = EmbeddingReaderInterface(
model="nomic-embed-text:v1.5",
backend=store,
metric_type=MetricType.COSINE,
provider_type="ollama",
faiss_cache_dir="/data/faiss_cache",
)
# Searches automatically use FAISS when the cache is fresh; SQL path otherwise.
EmbeddingReaderInterface itself has no fallback for faiss_cache_dir; pass it explicitly, or read it off the resolved vector store yourself (resolved.faiss_cache_dir, see Configuration Reference). The embeddings search CLI command does exactly that, falling back to the configured value when --faiss-cache-dir is omitted.
EmbeddingConceptFilter
EmbeddingConceptFilter is an in-database pre-filter applied during KNN search. All filtering happens before the nearest-neighbour step: only matching concepts are candidates. To limit the number of KNN results returned, pass k to get_nearest_concepts/get_similar_concepts.
from omop_emb.utils.embedding_utils import EmbeddingConceptFilter
concept_filter = EmbeddingConceptFilter(
domains=("Condition", "Observation"), # restrict to specific OMOP domains
vocabularies=("SNOMED", "ICD10CM"), # restrict to specific vocabularies
concept_ids=(313217, 4329847), # restrict to specific concept IDs
require_standard=True, # standard_concept = 'S' or 'C'
require_active=True, # invalid_reason NOT IN ('D', 'U')
)
All fields are optional and combinable. require_standard and require_active are stored as columns in the embedding table and are resolved entirely inside the primary backend, with no CDM round-trip at query time.
CDMConceptFilter
CDMConceptFilter (omop_alchemy.cdm.query.ConceptFilter, re-exported from omop_emb.utils.embedding_utils) is the filter type for CDM-only queries. Difference to EmbeddingConceptFilter: it carries limit, which caps the number of CDM rows returned.
from omop_emb.utils.embedding_utils import CDMConceptFilter
concept_filter = CDMConceptFilter(
domains=("Condition", "Observation"),
vocabularies=("SNOMED", "ICD10CM"),
require_standard=True,
limit=1000, # cap on CDM rows returned
)
n_missing = writer.count_concepts_without_embedding(
concept_filter=concept_filter,
)
Model backends and providers
Model calling (construction, canonicalization, dimension discovery, batched embedding calls, role-prefix application) is entirely omop_llm.ModelBackend's job. omop-emb never talks to a provider endpoint directly; EmbeddingWriterInterface builds one internally via omop_llm.build_model_backend_from_resolved(resolved_model) (see "Creating the interface" above). Any caller that needs to embed text without a full writer interface (e.g. on-the-fly query embedding, or a quick standalone script) can build a ModelBackend directly with plain keyword arguments instead:
from omop_llm import Capabilities, build_model_backend
# Ollama: provider specified explicitly (works with any hostname or IP)
model_backend = build_model_backend(
"ollama",
"nomic-embed-text:v1.5",
model_capabilities=Capabilities(embeddings=True),
base_url="http://host.docker.internal:11434",
)
print(model_backend.model) # "nomic-embed-text:v1.5"
print(model_backend.dimensions()) # auto-discovered via Ollama /api/show
# OpenAI: hosted model, authenticated via API key
model_backend = build_model_backend(
"openai",
"text-embedding-3-large",
model_capabilities=Capabilities(embeddings=True),
base_url="https://api.openai.com/v1",
api_key="sk-...",
)
print(model_backend.model) # "text-embedding-3-large"
print(model_backend.dimensions()) # discovered via a live probe call (no discovery endpoint)
See omop_llm.providers.supported_providers() for the full list of provider keys, and omop-llm's own docs/providers.md for the capability matrix per provider.
Model name validation
Valid names
Ollama:
- ✅
nomic-embed-text:v1.5 - ✅
llama3:8b - Any name with an explicit, immutable tag
OpenAI:
- ✅
text-embedding-3-large - Any name; no tag normalisation is required or applied
Invalid names (raise ValueError)
Ollama:
- ❌
llama3: "must include an explicit tag" - ❌
llama3:latest: "uses the mutable ':latest' tag"
Info
Why the strictness? In long-term healthcare data storage, :latest is a moving target. Running ollama pull llama3 silently changes which model version :latest points to, breaking consistency between stored embeddings and new query embeddings. OpenAI-hosted model identifiers do not have this problem: a given name (e.g. text-embedding-3-large) refers to a fixed model version, so no equivalent tag requirement applies.
Utility functions
models = store.get_registered_models(
provider_type="ollama", # optional filter
)
for m in models:
print(m.model_name, m.provider_type, m.dimensions, m.index_type)
Architecture
┌─────────────────────────────────────────────────────┐
│ Your Application Code │
└──────────────┬──────────────────────────────────────┘
│
┌──────┴──────────────────┐
│ │
▼ ▼
┌───────────────────┐ ┌──────────────────────┐
│ EmbeddingWriter │ │ EmbeddingReader │
│ Interface │ │ Interface │
│ (write + read) │ │ (read only) │
└───────┬───────────┘ └──────────┬───────────┘
│ │
▼ │
┌───────────────────┐ │
│ omop_llm │ │
│ ModelBackend │ │
└───────────────────┘ │
│ │
└──────────┬───────────────┘
│
┌────────┴────────┐
│ Backend │
│ sqlite-vec │
│ pgvector │
└────────┬────────┘
│ (optional fast path)
┌────────┴────────┐
│ FAISS sidecar │
│ (read-only) │
└─────────────────┘
EmbeddingWriterInterface inherits from EmbeddingReaderInterface; all reader methods are available on the writer too.
Best practices
- Use the interfaces, not backends directly: they enforce canonical naming.
EmbeddingWriterInterfacefor write flows,EmbeddingReaderInterfacefor query-only services.- Use
writer.canonical_model_namewhen constructing a matching reader: it is guaranteed to be canonical. - Always register with
FlatIndexConfigfirst. Runrebuild_indexoromop-emb maintenance rebuild-indexafter ingestion to build HNSW. - The CDM session factory is given once, on the constructor: the methods that read concepts use it and raise without it. A reader that never needs
concept_namecan omit it to avoid the CDM round-trip. - FAISS is a read-acceleration sidecar, never the source of truth: build it directly from the backend with
omop-emb maintenance build-faiss-cacheand supplyfaiss_cache_dirtoEmbeddingReaderInterfacefor faster approximate search.