Serve Liquid AI ColBERT embeddings with llama.cpp and Modal Servers
In this example, we serve LiquidAI/LFM2.5-ColBERT-350M using llama.cpp in a Modal Server.
LFM2.5-ColBERT-350M is a 353M-parameter late interaction embedding model. That means it produces one embedding vector per query token. Similarity is computed by comparing each of those vectors with each of the document’s token embeddings to produce a final score, rather than just comparing a single vector per query per document.
This is not supported by the OpenAI-compatible /v1/embeddings API,
so we instead use the /embeddings API in llama.cpp.
The model is intended for short queries against small documents, like comparing user search queries with product descriptions in e-commerce.
The model is available under the LFM Open License v1.0, which includes restrictions on commercial use.
Why use a Modal Server?
To minimize routing overheads, we use @app.server,
which uses a new, low-latency routing service on Modal designed for latency-sensitive inference workloads,
like interactive search via embeddings.
See the Modal Servers guide for details.
Choose the model file and engine parameters
Liquid AI publishes official GGUF conversions of the model in LiquidAI/LFM2.5-ColBERT-350M-GGUF.
import json
import subprocess
import time
import urllib.error
import urllib.request
import modal
MODEL_REPO = "LiquidAI/LFM2.5-ColBERT-350M-GGUF"
MODEL_REVISION = "bc240003aba07253e261a8aaf0d2c9683318a967" # version-pinning
MODEL_FILE = "LFM2.5-ColBERT-350M-BF16.gguf"
MODEL_URL = f"https://huggingface.co/{MODEL_REPO}/resolve/{MODEL_REVISION}/{MODEL_FILE}"llama-server processes requests in N_SLOTS parallel slots
and splits the total token context evenly across them.
We give each slot the model’s trained sequence length of 512 tokens.
MAX_INPUT_TOKENS = 512 # the model's trained sequence length
N_SLOTS = 4 # target concurrent requests per container; adjust as needed
TOKEN_EMBEDDING_DIM = 128 # the model's output embedding dimensionQueries and documents are prefixed with special tokens before encoding.
DOCUMENT_PREFIX = "[D] "
QUERY_PREFIX = "[Q] "Cache the model weights
We persist the llama.cpp download cache in a Modal Volume so the GGUF file is downloaded from the Hub exactly once and loaded from the Volume on later cold starts.
CACHE_PATH = "/cache"
MODEL_PATH = f"{CACHE_PATH}/llama.cpp/{MODEL_FILE}"
volume = modal.Volume.from_name("liquidai-embeddings-cache", create_if_missing=True)Define the container image
We build on the official llama.cpp server image.
It contains the compiled binary but doesn’t include Python,
so add_python bundles an interpreter for Modal’s own runtime.
We also clear the image’s entrypoint, which is the server binary itself,
so that we can control the startup.
image = (
modal.Image.from_registry(
"ghcr.io/ggml-org/llama.cpp:server-b9917", add_python="3.12"
)
.entrypoint([])
.env({"LLAMA_CACHE": f"{CACHE_PATH}/llama.cpp"})
)Define the Server
We wrap the engine in a class decorated with @app.server(),
which attaches the Image and Volume,
defines autoscaling rules,
fronts the containers with a proxy, and more.
See details in the Modal Servers guide.
MINUTES = 60 # seconds
PORT = 8000
app = modal.App("example-liquidai-embeddings-server")
@app.server(
image=image,
volumes={CACHE_PATH: volume},
port=PORT,
target_concurrency=N_SLOTS,
min_containers=0, # set to 1 or more to keep a warm replica for latency-sensitive use cases
startup_timeout=10 * MINUTES, # allow time to download and load the model
scaledown_window=5 * MINUTES, # retain loaded replicas across short traffic gaps
exit_grace_period=20, # allow in-flight embedding requests to finish before shutdown
unauthenticated=True,
)
class LlamaServer:
@modal.enter()
def start(self):
cmd = [
"/app/llama-server",
"--model-url",
MODEL_URL,
"--model",
MODEL_PATH,
"--embeddings",
"--pooling",
"none", # return one vector per input token
"--host",
"0.0.0.0",
"--port",
str(PORT),
"--no-ui", # no chat interface, we're serving embeddings
"--metrics", # enable metrics logging
"--parallel",
str(N_SLOTS),
"--ctx-size",
str(N_SLOTS * MAX_INPUT_TOKENS), # total context shared across slots
# Bidirectional embedding models require all tokens in an iteration to fit
# in one physical batch. Using the full context for batch sizes allows all
# slots to process maximum-length inputs together.
"--batch-size", # max tokens per iteration during continuous batching
str(N_SLOTS * MAX_INPUT_TOKENS),
"--ubatch-size", # max tokens in a physical computation batch
str(N_SLOTS * MAX_INPUT_TOKENS),
]
self.proc = subprocess.Popen(cmd, start_new_session=True)
wait_ready(self.proc)
@modal.exit()
def stop(self):
self.proc.terminate()
try:
self.proc.wait(timeout=10)
except subprocess.TimeoutExpired:
self.proc.kill()
self.proc.wait()Modal considers a new replica ready once the @modal.enter methods have exited
and the serving process inside starts accepting connections. llama-server answers /health with a 503 Service Unavailable status while the model loads, so we block the startup hook from completing with this wait_ready function.
def wait_ready(proc: subprocess.Popen, port=PORT):
import socket
while True:
try:
if (
returncode := proc.poll()
) is not None: # fail fast if the server process died
raise RuntimeError(f"Server process exited with code {returncode}")
socket.create_connection(("127.0.0.1", port), timeout=5).close()
request = urllib.request.Request(f"http://127.0.0.1:{port}/health")
request_with_retry(request, timeout=30).close()
return
except (ConnectionRefusedError, TimeoutError):
continueModal Servers also respond with a 503 when no replicas are available to handle requests, so we pull out a helper function that retries on 503 for use with server clients.
def request_with_retry(request: urllib.request.Request, timeout=10 * MINUTES):
deadline = time.monotonic() + timeout
delay = 1.0
while (remaining := deadline - time.monotonic()) > 0:
try:
return urllib.request.urlopen(request, timeout=remaining)
except urllib.error.HTTPError as exc:
if exc.code != 503: # 503 Service Unavailable, no containers ready
raise exc
time.sleep(min(delay, remaining))
delay = min(delay * 2, 10.0)
raise TimeoutError(f"Server not ready within {timeout} seconds")Test the server
Running modal run liquidai_embeddings_server.py executes the local_entrypoint below
against a temporary instance of the Server, which is useful for testing and development.
The client requests one embedding after waiting for a live replica.
@app.local_entrypoint()
def main(input: str | None = None, test_timeout: int = 5 * MINUTES):
url = LlamaServer.get_url()
print(f"Server URL: {url}")
request = urllib.request.Request(f"{url}/health")
request_with_retry(request=request, timeout=test_timeout).close()
if input is None:
input = "ColBERT introduces a late interaction architecture that independently encodes the query and the document using BERT"
request_data = {"input": [DOCUMENT_PREFIX + input]}
request = urllib.request.Request(
f"{url}/embeddings",
data=json.dumps(request_data).encode(),
headers={"Content-Type": "application/json"},
)
started_at = time.perf_counter()
with request_with_retry(request, timeout=test_timeout) as response:
elapsed = time.perf_counter() - started_at
data = json.load(response)
assert len(data), "empty response from server"
embedding = data[0].get("embedding")
assert embedding, f"server failed to respond with embedding, got {data}"
print(
f"client-side inference latency: {elapsed:.3f}s",
f"embedding shape: ({len(embedding)}, {len(embedding[0])})",
sep="\n",
)Deploy the Server
Deploy the Server with
modal deploy liquidai_embeddings_server.pyThe deploy command prints the Server’s public URL.