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.


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

  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. 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_name can omit it 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.