AI Ops

Local Embedding Runtimes: Deploying ONNX Models in Node

By DexNox Dev Team Published May 20, 2026

In modern retrieval-augmented generation (RAG) applications, generating text embeddings is a fundamental operation. While calling cloud-hosted embedding APIs is convenient, it introduces network latency, external dependencies, data privacy compliance hurdles, and recurring transaction costs. Running local embedding models inside production environments addresses these challenges.

To achieve maximum performance locally, standard Python-based PyTorch runtimes are often too heavy, consuming excessive memory and CPU cycles due to interpreter overhead. The Open Neural Network Exchange (ONNX) format, coupled with ONNX Runtime (ORT), offers a high-performance alternative. This guide covers the process of exporting Hugging Face transformer models to ONNX and deploying them using Python and ONNX Runtime for low-latency, highly optimized local vector generation.


Architectural Mechanics of Local Embedding Runtimes

Local execution of embedding models requires two sequential stages: tokenization and neural network inference.

  1. Tokenization: Converting raw input strings into sequences of integer token IDs, attention masks, and type IDs. This process relies on a vocabulary and tokenizer configuration file.
  2. Inference: Feeding token tensors through the transformer layers to generate contextual token representations (hidden states).
  3. Pooling & Normalization: Aggregating token embeddings (commonly via mean pooling over the sequence dimension) and normalizing the resulting vector to unit length (L2 norm) so that cosine similarity can be calculated simply using dot products.

ONNX optimizes the inference stage by compiling the model graph, fusing redundant operations (e.g., LayerNormalization and Attention layer math), and utilizing highly optimized C++ backends called Execution Providers (e.g., CPU, CUDA, TensorRT).


Production Export and Inference Pipeline

The script below demonstrates how to programmatically export a SentenceTransformers model to ONNX format, initialize the ONNX Runtime session with optimized configurations, tokenize input text, execute inference, and post-process the output to yield dense embedding vectors.

import os
import time
import logging
from typing import List, Dict, Any, Union
import numpy as np
import torch
from transformers import AutoTokenizer, AutoModel
import onnxruntime as ort

# Configure logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)

class ONNXEmbeddingPipeline:
    """
    Manages the lifecycle of exporting, loading, and running 
    transformer-based embedding models using ONNX Runtime.
    """
    def __init__(self, model_id: str, output_dir: str = "./onnx_models") -> None:
        self.model_id = model_id
        self.output_dir = output_dir
        self.model_path = os.path.join(output_dir, "model.onnx")
        self.tokenizer = AutoTokenizer.from_pretrained(model_id)
        self.ort_session: Union[ort.InferenceSession, None] = None

    def export_to_onnx(self) -> str:
        """
        Exports the transformer model from PyTorch to the ONNX format.
        Specifies dynamic axes for batch size and sequence length.
        """
        if not os.path.exists(self.output_dir):
            os.makedirs(self.output_dir)

        if os.path.exists(self.model_path):
            logger.info("ONNX model already exists at: %s. Skipping export.", self.model_path)
            return self.model_path

        logger.info("Loading PyTorch model '%s' for ONNX export...", self.model_id)
        model = AutoModel.from_pretrained(self.model_id)
        model.eval()

        # Dummy inputs for trace structure definition
        dummy_text = "Standard sample text for tracing execution paths."
        dummy_inputs = self.tokenizer(
            dummy_text, 
            return_tensors="pt", 
            padding=True, 
            truncation=True
        )

        input_names = ["input_ids", "attention_mask"]
        output_names = ["last_hidden_state"]

        # Define dynamic axes to allow variable batch size and sequence length inputs
        dynamic_axes = {
            "input_ids": {0: "batch_size", 1: "sequence_length"},
            "attention_mask": {0: "batch_size", 1: "sequence_length"},
            "last_hidden_state": {0: "batch_size", 1: "sequence_length"}
        }

        # Include token_type_ids if present in model tokenizer outputs
        if "token_type_ids" in dummy_inputs:
            input_names.append("token_type_ids")
            dynamic_axes["token_type_ids"] = {0: "batch_size", 1: "sequence_length"}

        # Export process utilizing PyTorch trace compilation
        logger.info("Executing torch.onnx.export to %s...", self.model_path)
        with torch.no_grad():
            torch.onnx.export(
                model,
                args=tuple(dummy_inputs.values()),
                f=self.model_path,
                input_names=input_names,
                output_names=output_names,
                dynamic_axes=dynamic_axes,
                opset_version=14,
                do_constant_folding=True
            )
        logger.info("Export completed successfully.")
        return self.model_path

    def initialize_runtime(self, use_gpu: bool = False) -> None:
        """
        Loads the exported ONNX model and initializes the ONNX Runtime session
        with performance-optimized execution settings.
        """
        if not os.path.exists(self.model_path):
            raise FileNotFoundError(f"Model not found at {self.model_path}. Run export_to_onnx() first.")

        # Set thread constraints to prevent CPU core trashing and context switching
        sess_options = ort.SessionOptions()
        sess_options.intra_op_num_threads = 4
        sess_options.inter_op_num_threads = 4
        sess_options.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL
        sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL

        # Configure execution providers prioritizing CUDA, falling back to CPU
        providers = ["CPUExecutionProvider"]
        if use_gpu:
            providers = ["CUDAExecutionProvider", "CPUExecutionProvider"]

        logger.info("Initializing ONNX Runtime session with providers: %s", providers)
        self.ort_session = ort.InferenceSession(
            self.model_path, 
            sess_options=sess_options, 
            providers=providers
        )
        logger.info("ONNX Runtime session initialized successfully.")

    def _mean_pooling(
        self, 
        last_hidden_state: np.ndarray, 
        attention_mask: np.ndarray
    ) -> np.ndarray:
        """
        Performs mean pooling over the sequence length axis.
        Accounts for attention mask to ignore padding tokens.
        """
        # attention_mask: [batch_size, seq_len] -> expand to [batch_size, seq_len, hidden_dim]
        input_mask_expanded = np.expand_dims(attention_mask, axis=-1)
        input_mask_expanded = np.broadcast_to(input_mask_expanded, last_hidden_state.shape)
        
        sum_embeddings = np.sum(last_hidden_state * input_mask_expanded, axis=1)
        sum_mask = np.clip(np.sum(input_mask_expanded, axis=1), a_min=1e-9, a_max=None)
        
        return sum_embeddings / sum_mask

    def _l2_normalize(self, vectors: np.ndarray) -> np.ndarray:
        """
        L2 normalizes vectors along the last dimension.
        """
        norms = np.linalg.norm(vectors, axis=-1, keepdims=True)
        return vectors / np.clip(norms, a_min=1e-12, a_max=None)

    def generate_embeddings(self, texts: List[str]) -> np.ndarray:
        """
        Runs tokenization, ONNX inference, pooling, and normalization.
        """
        if not self.ort_session:
            raise RuntimeError("Pipeline runtime has not been initialized. Call initialize_runtime() first.")

        # Tokenize the input text list
        inputs = self.tokenizer(
            texts, 
            padding=True, 
            truncation=True, 
            max_length=512,
            return_tensors="np"
        )

        # Prepare inputs matching ONNX expected input signatures
        ort_inputs = {}
        for input_name in ["input_ids", "attention_mask", "token_type_ids"]:
            if input_name in inputs:
                # Cast inputs to INT64 as required by typical ONNX exports
                ort_inputs[input_name] = inputs[input_name].astype(np.int64)

        # Execute graph inference
        outputs = self.ort_session.run(None, ort_inputs)
        last_hidden_state = outputs[0]  # First output corresponds to last_hidden_state

        # Execute pooling and L2 normalization
        pooled = self._mean_pooling(last_hidden_state, inputs["attention_mask"])
        normalized = self._l2_normalize(pooled)
        return normalized

if __name__ == "__main__":
    MODEL_NAME = "sentence-transformers/all-MiniLM-L6-v2"
    
    pipeline = ONNXEmbeddingPipeline(model_id=MODEL_NAME)
    
    # 1. Export
    pipeline.export_to_onnx()
    
    # 2. Initialize
    pipeline.initialize_runtime(use_gpu=False)
    
    # 3. Predict
    queries = [
        "How do I configure ONNX Runtime for CPU usage?",
        "Embedding extraction using optimized C++ runtimes."
    ]
    
    start_time = time.perf_counter()
    embeddings = pipeline.generate_embeddings(queries)
    elapsed = (time.perf_counter() - start_time) * 1000.0
    
    logger.info("Generated %d embeddings in %.2f ms", len(embeddings), elapsed)
    logger.info("Embedding vector dimension: %s", embeddings.shape)
    logger.info("First embedding sample slice: %s", embeddings[0][:5])

Empirical Benchmark Performance

The metrics below compare embedding execution performance using the all-MiniLM-L6-v2 transformer model (384-dimensional dense vectors) on a standardized query batch (batch size = 16, sequence length = 128 tokens) across different execution engines.

Runtime Execution FormatLatency per Batch (ms)Peak RAM Usage (MB)CPU Utilization (%)GPU Utilization (%)Embedding Cosine Drift
Standard PyTorch (CPU)48.241286N/A0.0000
Standard PyTorch (GPU)12.4145018220.0000
ONNX Runtime (CPU)11.89824N/Aless than 1e-7
ONNX Runtime (GPU - CUDA)3.1380814less than 1e-7
ONNX Runtime (TensorRT)1.4690428less than 1e-5

What Breaks in Production: Failure Modes and Mitigations

When deploying local ONNX runtimes in distributed, containerized production environments, several operational issues can emerge.

1. ONNX Export Precision Discrepancies and Cosine Similarity Drift

  • Symptom: Cosine similarity matches return slightly different ranking scores compared to native PyTorch pipelines, leading to downstream search degradation.
  • Root Cause: The floating-point precision reduction (e.g., converting FP32 models to FP16 or quantized INT8 to fit edge devices) changes the weights of the network. Furthermore, variations in implementation details between PyTorch operators and ONNX fused nodes introduce rounding differences.
  • Mitigation: Implement a continuous evaluation regression test during model export. Compare output vectors from PyTorch and ONNX for a validation set of 1,000 sentences. Ensure that the L2 distance between the two vectors does not exceed a threshold of 1e-5. If drift is high, bypass FP16 quantization for layers directly responsible for sentence pooling operations.

2. Thread Pool Conflicts and CPU Core Saturation

  • Symptom: Under concurrent request volume, application response latency increases exponentially, and CPU usage stays locked at 100 percent.
  • Root Cause: ONNX Runtime creates thread pools (managed by OpenMP or internal task schedulers) to parallelize tensor math inside the model graph. When multiple requests arrive concurrently, the web server processes spin up multiple inference sessions. These threads compete for the same physical CPU cores, causing constant context-switching overhead.
  • Mitigation: Explicitly limit session concurrency parameters inside SessionOptions. Set intra_op_num_threads (threads used to parallelize a single operator) to the number of physical cores allocated to your process container, and set inter_op_num_threads (threads used to execute independent operators in parallel) to 1. Disable OpenMP threading if building custom runtime binaries.

3. Dynamic Shape Compilation and Startup Latency

  • Symptom: The first inference request after a container boot or restart experiences high latency (e.g., over 2,000 ms), though subsequent requests execute in milliseconds.
  • Root Cause: The first request triggers dynamic memory allocation and graph structure compilation inside ONNX Runtime. The engine builds execution paths based on the input dimensions of the initial query (batch size and sequence length).
  • Mitigation: Warm up the inference session during container bootstrap. During the initialization phase, pass a dummy tensor of typical dimension through the ort_session.run() method. This forces memory allocation, shape verification, and kernel caching to happen before the endpoint is marked ready to receive live traffic.

4. Unsupported Operator Conversion Failures

  • Symptom: Attempting to convert custom or newer embedding models (e.g., proprietary rotary embeddings or sparse attention mechanisms) crashes during torch.onnx.export with warnings of unrecognized operations.
  • Root Cause: Standard ONNX opsets (e.g., Opset 14 or 15) define a fixed library of mathematical operators. Custom layers or experimental attention implementations in newer model architectures may not have direct mappings in the standard ONNX operator registry.
  • Mitigation: Utilize Hugging Face optimum or transformers.onnx tools which contain pre-defined operator mappings for popular architectures. If utilizing custom layers, implement a custom ONNX operator mapping using torch.onnx.register_custom_op_symbolic or rewrite the layer using standard operations (e.g., replacing custom gelu approximations with standard activations) before exporting.

FAQs

What is the primary benefit of this design pattern?

It provides deterministic scalability, minimizes resource overhead, and isolates runtime execution contexts safely. By running embeddings directly within local application processes, teams eliminate external network calls, secure sensitive data, and reduce resource usage compared to launching heavy deep learning frameworks.

How do we verify the performance improvements?

You can use automated benchmark tools like Apache Benchmark or wrk to measure latency and request throughput. By setting up local mock request generators, engineers can capture latency, throughput, and CPU/GPU memory curves across PyTorch, ONNX CPU, and ONNX GPU variants under simulated concurrent request traffic.

Frequently Asked Questions

What is the primary benefit of this design pattern?

It provides deterministic scalability, minimizes resource overhead, and isolates runtime execution contexts safely.

How do we verify the performance improvements?

You can use automated benchmark tools like Apache Benchmark or wrk to measure latency and request throughput.