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.
Constructing a backend
Resolve the vector store named in [tools.omop_emb] (a [vector_stores.*] entry) using resolve_backend_from_resolved_vector_store:
from oa_configurator import Resolver
from omop_emb.backends import resolve_backend_from_resolved_vector_store
from omop_emb.config import OmopEmbConfig
cfg = OmopEmbConfig.get_config()
resolved = Resolver.from_active_config().resolve_vector_store(cfg.vector_store_name)
backend = resolve_backend_from_resolved_vector_store(resolved)
resolve_backend(backend_type, *, database) is the lower-level, pure resolver underneath. It never reads config itself, so call it directly only when you already have an explicit backend_type/database (an oa-configurator ResolvedDatabase) in hand rather than the configured defaults.
Or construct one directly:
from omop_emb.backends.sqlitevec import SQLiteVecEmbeddingBackend
from omop_emb.backends.pgvector import PGVectorEmbeddingBackend
# sqlite-vec
backend = SQLiteVecEmbeddingBackend.from_path(db_path="/data/omop_emb.db")
# pgvector
backend = PGVectorEmbeddingBackend.from_db_url(db_url="postgresql+psycopg://user:pass@host:5432/db")
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,
omop_cdm_engine=cdm_engine, # optional; used to enrich search results
)
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(
omop_cdm_engine=cdm_engine,
)
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=backend,
metric_type=MetricType.COSINE,
provider_type="ollama",
omop_cdm_engine=cdm_engine, # 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 build_model_backend
model_backend = build_model_backend("ollama", "nomic-embed-text:v1.5", 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=backend,
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(
omop_cdm_engine=cdm_engine,
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 build_model_backend
# Ollama: provider specified explicitly (works with any hostname or IP)
model_backend = build_model_backend(
"ollama",
"nomic-embed-text:v1.5",
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",
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
from omop_emb import EmbeddingReaderInterface
models = EmbeddingReaderInterface.list_registered_models(
backend=backend,
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. - CDM enrichment is optional: omit
omop_cdm_enginewhenconcept_nameis not needed 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.