Skip to content

Asymmetric Embeddings

What are asymmetric embedding models?

Most general-purpose models (e.g. text-embedding-3-small) produce vectors in a symmetric space: the same transformation is applied whether you are indexing a document or submitting a search query.

Asymmetric models — such as nomic-embed-text, E5, and BGE — are trained with task-specific prefixes prepended to the input. The model's training objective explicitly separates the representation space for documents being indexed from the space for queries being searched. Sending text without the correct prefix does not raise an error, but similarity scores degrade substantially and silently.

In omop-emb there are two distinct embedding roles:

Role Example texts Purpose
Document "Hypertension", "Type 2 diabetes mellitus" Concepts stored in the vector index
Query "high blood pressure", "T2DM" Free-text search terms at query time

For a symmetric model these are interchangeable. For an asymmetric model the document prefix must be applied to every concept at index time, and the query prefix to every search term at query time. Mixing the two reduces retrieval quality without any visible error.

Configuration

omop-emb reads two environment variables when EmbeddingClient is constructed:

Variable Role Example value
OMOP_EMB_DOCUMENT_EMBEDDING_PREFIX Prepended to all document texts before indexing search_document:
OMOP_EMB_QUERY_EMBEDDING_PREFIX Prepended to all query texts before searching search_query:

Both default to "". When either is empty, omop-emb logs a warning at startup explaining what the variable is for. This is not an error — it is correct behaviour for symmetric models.

Prefix examples by model family

Model Document prefix Query prefix
nomic-embed-text:v1.5 search_document: search_query:
e5-large-v2 passage: query:
bge-large-en-v1.5 (none) Represent this sentence for searching relevant passages:

Always check the model card — task prefixes are model-specific and can change between versions.

Role assignment in the API

The two high-level methods handle roles automatically. You only need to think about roles when calling embed_texts directly.

Indexing concepts — DOCUMENT is automatic

embed_and_upsert_concepts always uses EmbeddingRole.DOCUMENT:

interface.embed_and_upsert_concepts(
    session=session,
    index_type=IndexType.FLAT,
    concept_ids=(1, 2, 3),
    concept_texts=("Hypertension", "Diabetes", "Aspirin"),
)

Querying — QUERY is automatic

get_nearest_concepts_from_query_texts always uses EmbeddingRole.QUERY:

results = interface.get_nearest_concepts_from_query_texts(
    session=session,
    index_type=IndexType.FLAT,
    query_texts=("high blood pressure",),
    metric_type=MetricType.COSINE,
)

Direct embedding generation — caller chooses the role

When you call embed_texts directly you must pass the role explicitly:

from omop_emb.embeddings import EmbeddingRole

# Indexing — use DOCUMENT
doc_embeddings = interface.embed_texts(
    ["Hypertension", "Diabetes"],
    embedding_role=EmbeddingRole.DOCUMENT,
)

# Searching — use QUERY
query_embeddings = interface.embed_texts(
    ["high blood pressure"],
    embedding_role=EmbeddingRole.QUERY,
)

Similarly, EmbeddingClient.embeddings() and EmbeddingClient.similarity() require explicit roles:

# embeddings()
vecs = client.embeddings(texts, embedding_role=EmbeddingRole.DOCUMENT)

# similarity() — terms_role for the first argument, terms_to_match_role for the second
scores = client.similarity(
    "high blood pressure",
    "Hypertension",
    terms_role=EmbeddingRole.QUERY,
    terms_to_match_role=EmbeddingRole.DOCUMENT,
)

Inspecting active prefixes

print(client.embedding_role_prefixes())
# {<EmbeddingRole.DOCUMENT: 'document'>: 'search_document: ',
#  <EmbeddingRole.QUERY: 'query'>: 'search_query: '}