Skip to content

Interface Guide

omop-emb exposes two complementary Python interfaces:

  • EmbeddingWriterInterface: write and read. Builds and owns an omop_llm.ModelBackend for 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

  1. Use the interfaces, not backends directly: they enforce canonical naming.
  2. EmbeddingWriterInterface for write flows, EmbeddingReaderInterface for query-only services.
  3. Use writer.canonical_model_name when constructing a matching reader: it is guaranteed to be canonical.
  4. Always register with FlatIndexConfig first. Run rebuild_index or omop-emb maintenance rebuild-index after ingestion to build HNSW.
  5. CDM enrichment is optional: omit omop_cdm_engine when concept_name is not needed to avoid the CDM round-trip.
  6. FAISS is a read-acceleration sidecar, never the source of truth: build it directly from the backend with omop-emb maintenance build-faiss-cache and supply faiss_cache_dir to EmbeddingReaderInterface for faster approximate search.