Reduce AI invocation costs on Alibaba Cloud Milvus with Embedding Cache and AI Batch

Updated at:
Copy as MD

Alibaba Cloud Milvus AI Function provides two cost-reduction capabilities: Embedding Cache reuses existing vectors for duplicate content, and AI Batch processes large jobs that can wait offline. In this tutorial, you configure both capabilities, decide where each one applies, and confirm a cache hit by using reliable criteria.

Solution overview

After an AI application goes live, cost pressure usually comes from two kinds of waste:

  • The same content is embedded over and over — product titles are synced repeatedly, support FAQs are republished, and knowledge base paragraphs re-enter the embedding model through retries or incremental imports. The text has not changed, so the vector usually does not change either, but the model invocation and the wait for it happened all the same.

  • Jobs that can wait use real-time invocation — real-time invocation is the synchronous path that returns each result in the response, as opposed to the offline batches that AI Batch runs. Filling in product descriptions, summarizing historical conversations, translating content, and initializing a knowledge base can all run tonight and be delivered tomorrow morning without affecting user experience. Sending these jobs through real-time invocation means paying real-time prices while also consuming the model quota that online search and Q&A need.

The two matching cost-reduction paths are to embed the same content only once, and to process jobs that can wait in offline batches. Alibaba Cloud Milvus AI Function covers these two cases with Embedding Cache and AI Batch respectively:

CapabilityWhat it doesWaste it eliminates
Embedding CacheLooks up an existing vector by exact match on the text and invocation context, and reuses it on a hit.The same content is embedded only once, saving duplicate tokens and wait time.
AI BatchRequests are written to JSONL and submitted asynchronously, and the platform runs them offline in batches.Large jobs that can wait complete at a lower unit price, without consuming online quota.

The two capabilities address different kinds of waste. The following table maps business characteristics to each capability:

Business characteristicEmbedding CacheAI Batch
Data patternThe same text appears repeatedlyLarge volumes of data processed for the first time
Response requirementReturned onlineAllowed to complete later
Source of savingsFewer duplicate model invocationsOffline batch execution at a lower unit price
Common scenariosFAQs and ground truth answers, popular product titles and attributes, knowledge base paragraphs imported repeatedly, high-frequency search termsGenerating summaries or tags for archived documents, batch processing of existing product data, batch generation of asset descriptions, model evaluation and data labeling, periodic rebuilds and nightly jobs

Answer two questions to choose your path:

  • Is the user waiting for the result? If yes, use real-time invocation. If not, and the volume is large, consider AI Batch.

  • Does the content appear repeatedly? If it does, enable Embedding Cache. If all content appears for the first time, the cache brings limited benefit, so focus on AI Batch and on controlling the number of invocations.

Interactive scenarios such as chat replies and search autocomplete keep using real-time invocation by default. As long as a user is waiting for the result, do not move the request into AI Batch.

The two capabilities are not mutually exclusive, and combining them is the common path in production. Consider an e-commerce scenario. New products continue to be embedded in real time, and repeated syncs of popular products reuse vectors through Embedding Cache. Summaries and tags for several million archived products are handed to AI Batch to complete at night.

Prerequisites

  • A Milvus 2.6 instance. AI Function depends on the 2.6 kernel, and no separate model service binding is required after the instance is created.

  • To access the instance over the Internet, enable Public Access on the Security Configuration tab of the instance details page, and add the client egress IP address to the public access whitelist.

  • pymilvus installed for the Embedding Cache examples. The examples in this topic are verified with pymilvus 3.0.0. The AI Batch examples call the RESTful API and use only the Python standard library.

Important

The RESTful API shares port 19530 with gRPC, so specify the port explicitly when you call it, for example http://c-xxx.milvus.aliyuncs.com:19530. If you omit the port, the request goes to port 80 by default and the connection times out.

Embedding Cache: embed the same content only once

How Embedding Cache works and where it applies

Embedding Cache first looks up an existing vector by the text and the invocation context, reuses it directly on a hit, and invokes the model only for content that appears for the first time. The cache uses exact match, so two pieces of text are computed separately if they are written differently. The benefit therefore comes entirely from the content duplication rate: the more duplication, the more model requests you save. For an order-of-magnitude estimate, see Cost estimation.

If the cache is unavailable or a read times out, Milvus continues to invoke the model, so your writes and queries are not interrupted.

Enable Embedding Cache

Add the cache configuration to params of the Embedding Function. Set ttl_hours according to how often the content changes: extend it for content that changes infrequently, such as product titles and FAQs, and shorten it for fast-changing content so that new versions are recomputed sooner.

import json
import uuid
from urllib.error import HTTPError
from urllib.request import Request, urlopen

from pymilvus import DataType, Function, FunctionType, MilvusClient

MILVUS_URI = "http://c-xxx.milvus.aliyuncs.com:19530"  # The port must be 19530
MILVUS_TOKEN = "root:xxx"
MODEL_NAME = "text-embedding-v4"
VECTOR_DIM = 1024

# Cache configuration: exact match, Redis backend, 24-hour TTL
CACHE_CONFIG = json.dumps({
    "enabled": True,
    "exact_cache": {
        "enabled": True,
        "backend": "redis",
        "ttl_hours": 24,
    },
})

client = MilvusClient(uri=MILVUS_URI, token=MILVUS_TOKEN)
collection_name = "ai_embedding_cache_demo"

if client.has_collection(collection_name):
    client.drop_collection(collection_name)

schema = MilvusClient.create_schema(auto_id=True, enable_dynamic_field=False)
schema.add_field("id", DataType.INT64, is_primary=True)
schema.add_field("content", DataType.VARCHAR, max_length=4096)
schema.add_field("embedding", DataType.FLOAT_VECTOR, dim=VECTOR_DIM)
schema.add_function(
    Function(
        name="embed_content_with_cache",
        function_type=FunctionType.TEXTEMBEDDING,
        input_field_names=["content"],
        output_field_names=["embedding"],
        params={
            "provider": "aliyun_milvus",
            "model_name": MODEL_NAME,
            "dim": VECTOR_DIM,
            "cache": CACHE_CONFIG,        # ← Enable Embedding Cache
        },
    )
)

index_params = client.prepare_index_params()
index_params.add_index(field_name="embedding", index_type="AUTOINDEX", metric_type="COSINE")
client.create_collection(collection_name=collection_name, schema=schema,
                        index_params=index_params)

# Content that hits the cache during a write generates no model invocation
row = {"content": "Milvus is an open-source vector database."}
client.insert(collection_name, [row])
client.flush(collection_name)

Content that hits the cache during a write generates no model invocation. To confirm that the cache takes effect, see Verify a cache hit.

Verify a cache hit

Confirm a cache hit from the response of the RESTful embedding endpoint, which returns usage.total_tokens and request_id. On a cache hit, no model invocation actually happens, so token consumption drops to zero and no request ID is generated on the model side.

Important

Neither vector comparison nor write latency is a valid criterion for a cache hit. This step is easy to get wrong.

Two intuitive checks do not work:

  • Vector comparison — the model returns the same vector for the same input. In the tests for this topic, writing the same text twice with the cache completely disabled still produced an element-wise difference of 0.

  • Write latency — the inherent overhead of one insert plus flush far exceeds a single model invocation, and in tests the second write can even be slower than the first.

The following code sends the same new text three times and reports the result of each request. Run it in the same script or session as the previous step, because it reuses the imports and constants defined there.

# ==================== Verify whether the cache is actually hit ====================
# Criteria: whether usage.total_tokens drops to zero and whether request_id is empty.
# When both hold, no model invocation actually happened, which means a cache hit.

def post_json(path, body, timeout=180):
    request = Request(
        f"{MILVUS_URI.rstrip('/')}{path}",
        data=json.dumps(body, ensure_ascii=False).encode("utf-8"),
        headers={"Authorization": f"Bearer {MILVUS_TOKEN}",
                 "Content-Type": "application/json"},
        method="POST",
    )
    try:
        with urlopen(request, timeout=timeout) as response:
            return response.status, json.loads(response.read().decode("utf-8"))
    except HTTPError as exc:
        return exc.code, json.loads(exc.read().decode("utf-8"))

# Use a brand-new piece of text so that the first request is guaranteed to miss
text = f"cache hit verification {uuid.uuid4().hex[:12]}"
body = {
    "model_name": MODEL_NAME,
    "texts": [text],
    "params": {"dim": VECTOR_DIM, "cache": CACHE_CONFIG},
}

for i in (1, 2, 3):
    status, data = post_json("/v2/vectordb/ai/embedding", body)
    assert status == 200 and data.get("code") == 0, data
    usage = data["data"].get("usage", {})
    request_id = data["data"].get("request_id", "")
    total_tokens = usage.get("total_tokens")
    hit = (total_tokens == 0) and not request_id
    print(f"Request {i}: total_tokens={total_tokens} "
          f"request_id={'(empty)' if not request_id else request_id} "
          f"-> {'cache hit' if hit else 'cache miss, the model was invoked'}")

The following table lists the measured results of three consecutive requests for the same new text:

Orderusage.total_tokensrequest_idResult
Request 120Has a valueCache miss, the model was actually invoked
Request 20EmptyCache hit
Request 30EmptyCache hit

Both fields are directly available in the response body, and no extra permissions are required. To cross-validate on the server side, watch how the number of embedding model invocations changes in the console.

AI Batch: process jobs that can wait offline

How AI Batch works and where it applies

AI Batch takes requests written to JSONL, accepts them asynchronously, and runs them offline in batches. A typical operating model in e-commerce reserves the model quota during the day for users who are searching and asking questions. The day's accumulated product data and support conversations are then processed at night: generating product descriptions in bulk, filling in tags, and turning long conversations into summaries. The results are written back to the business systems before the next working day, without paying real-time prices for this data.

Prepare the input file and run the batch job

The input is JSONL, with one line per job. Use custom_id to link each job to the original data so that results can be mapped back to specific records:

{"custom_id":"article-001","method":"POST","url":"/v1/chat/completions","body":{"model":"qwen3.7-max","messages":[{"role":"user","content":"Generate a one-sentence summary for this knowledge base article..."}],"enable_thinking":false}}
{"custom_id":"article-002","method":"POST","url":"/v1/chat/completions","body":{"model":"qwen3.7-max","messages":[{"role":"user","content":"Generate a one-sentence summary for this knowledge base article..."}],"enable_thinking":false}}
Important

The body.model value on each line must match the model_name declared at upload time, or the job fails.

The full process has four steps: upload the JSONL file, create the job, poll the status, and download the results. AI Batch uses the RESTful API, so the following block defines the constants and the helper functions for authenticated requests, multipart upload, and result download. Run this block first, because each of the four steps reuses these functions.

Helper functions and constants for the AI Batch RESTful API

import json
import shutil
import tempfile
import time
import uuid
from pathlib import Path
from urllib.error import HTTPError
from urllib.request import Request, urlopen

# AI Batch uses the RESTful API. Set base_url to the instance address (port 19530)
MILVUS_BASE_URL = "http://c-xxx.milvus.aliyuncs.com:19530"
MILVUS_TOKEN = "root:xxx"
MODEL_NAME = "qwen3.7-max"     # Must match body.model on every line of input.jsonl
PROVIDER = "aliyun_milvus"
ENDPOINT = "/v1/chat/completions"

def post_json(path, body, timeout=180):
    request = Request(
        f"{MILVUS_BASE_URL.rstrip('/')}{path}",
        data=json.dumps(body, ensure_ascii=False).encode("utf-8"),
        headers={"Authorization": f"Bearer {MILVUS_TOKEN}",
                 "Content-Type": "application/json"},
        method="POST",
    )
    try:
        with urlopen(request, timeout=timeout) as response:
            return response.status, json.loads(response.read().decode("utf-8"))
    except HTTPError as exc:
        return exc.code, json.loads(exc.read().decode("utf-8"))

def upload_input_file(input_file):
    """Upload input.jsonl as multipart"""
    boundary = f"----milvus-ai-batch-{uuid.uuid4().hex}"
    fields = {"provider": PROVIDER, "model_name": MODEL_NAME,
              "endpoint": ENDPOINT, "purpose": "batch"}
    with tempfile.TemporaryFile(mode="w+b") as payload:
        for name, value in fields.items():
            payload.write(f"--{boundary}\r\n".encode())
            payload.write(f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode())
            payload.write(value.encode())
            payload.write(b"\r\n")
        payload.write(f"--{boundary}\r\n".encode())
        payload.write(b'Content-Disposition: form-data; name="file"; filename="input.jsonl"\r\n')
        payload.write(b"Content-Type: application/jsonl\r\n\r\n")
        with open(input_file, "rb") as fh:
            shutil.copyfileobj(fh, payload)
        payload.write(b"\r\n")
        payload.write(f"--{boundary}--\r\n".encode())

        length = payload.tell()
        payload.seek(0)
        request = Request(
            f"{MILVUS_BASE_URL.rstrip('/')}/v2/vectordb/ai/batch/files/upload",
            data=payload,
            headers={"Authorization": f"Bearer {MILVUS_TOKEN}",
                     "Content-Type": f"multipart/form-data; boundary={boundary}",
                     "Content-Length": str(length)},
            method="POST",
        )
        try:
            with urlopen(request, timeout=600) as response:
                return response.status, json.loads(response.read().decode("utf-8"))
        except HTTPError as exc:
            return exc.code, json.loads(exc.read().decode("utf-8"))

def download_batch_file(batch_id, file_type, output_file):
    """Download the result file. Note: use batch_id + file_type, not file_id"""
    request = Request(
        f"{MILVUS_BASE_URL.rstrip('/')}/v2/vectordb/ai/batch/files/content",
        data=json.dumps({"provider": PROVIDER, "batch_id": batch_id,
                         "file_type": file_type}, ensure_ascii=False).encode("utf-8"),
        headers={"Authorization": f"Bearer {MILVUS_TOKEN}",
                 "Content-Type": "application/json"},
        method="POST",
    )
    with urlopen(request, timeout=600) as response, output_file.open("wb") as out:
        shutil.copyfileobj(response, out)

Step 1: Upload the input file

Upload input.jsonl and keep the returned input_file_id, which identifies the input of the job you create in the next step.

# 1) Upload the JSONL input file
status, data = upload_input_file("input.jsonl")
input_file_id = (data.get("data") or {}).get("id")
if status != 200 or not input_file_id:
    raise SystemExit(f"Upload failed: HTTP={status} message={data.get('message')}")
print(f"input_file_id = {input_file_id}")

Step 2: Create the batch job

Create the job from input_file_id and declare the completion window. In tests, jobs of this kind can take several hours from creation to completion, so completion_window is usually set to 24h.

# 2) Create the Batch job and declare the completion window
status, data = post_json("/v2/vectordb/ai/batch/jobs/create", {
    "provider": PROVIDER,
    "input_file_id": input_file_id,
    "endpoint": ENDPOINT,
    "completion_window": "24h",
})
batch_id = (data.get("data") or {}).get("id")
if status != 200 or not batch_id:
    raise SystemExit(f"Creation failed: HTTP={status} message={data.get('message')}")
print(f"batch_id = {batch_id}")

Step 3: Poll the job status

Batch jobs are asynchronous jobs on an hourly scale, so do not poll intensively in the foreground. In tests, a submitted job stays in the in_progress state for a long time. In production, record the batch_id after submission and poll later from a scheduled task instead of blocking a business thread.

# 3) Poll the job status. Batch jobs run asynchronously on an hourly scale. In production,
#    record the batch_id and poll later from a scheduled task instead of polling intensively in the foreground.
while True:
    status, data = post_json("/v2/vectordb/ai/batch/jobs/describe",
                             {"provider": PROVIDER, "batch_id": batch_id})
    batch = data.get("data") or {}
    batch_status = batch.get("status")
    print(f"{batch_status}  {batch.get('request_counts')}")
    if batch_status in {"completed", "failed", "expired", "cancelled"}:
        break
    time.sleep(300)      # Polling once every 5 minutes is enough

Step 4: Download the results

Do not download the results until status is completed. Failed lines are collected in error_file_id and can be retried separately.

# 4) Download the results after completion. Failed lines are in error_file_id and can be retried separately
if batch_status == "completed":
    download_batch_file(batch_id, "output", Path("output.jsonl"))
    if batch.get("error_file_id"):
        download_batch_file(batch_id, "error", Path("error.jsonl"))

Confirm and use the results

The following table lists the measured responses of the four operations:

StepOperationResponse
Upload/v2/vectordb/ai/batch/files/uploadReturns an input_file_id in the form file-batch-xxx
Create/v2/vectordb/ai/batch/jobs/createReturns a batch_id in the form batch_xxx
Poll/v2/vectordb/ai/batch/jobs/describestatus and request_counts (with total / completed / failed counts)
Download/v2/vectordb/ai/batch/files/contentThe result file stream. Reports output_file_id is empty if the job is not complete

The download operation locates the file by batch_id plus file_type rather than by file ID, which is easy to get wrong.

After the job completes, the result file still carries the original custom_id, so your application can write each summary back to the matching record. If part of the data fails, download only the error file corresponding to error_file_id and retry the failed lines. Completed results are kept as they are, and there is no need to rerun the whole batch.

Cost estimation

Embedding Cache and AI Batch save costs in different ways, so they are estimated differently:

CapabilityEstimation basisEffect
Embedding Cache100,000 embedding calls per day, an average of 50 tokens per call, a 50% cache hit ratioThe portion that hits the cache generates no model invocation, and overall token consumption drops by roughly half
AI Batch100,000 entries per day, 50 input tokens / 500 output tokensOffline batch execution, so model invocation fees are calculated at a lower unit price

These figures only illustrate where the savings come from and their order of magnitude. Actual fees depend on the model, the region, and the current prices, and input and output usually have different unit prices, so refer to the official pricing page and your actual bill.

Troubleshooting

The following table lists the errors that are most easily triggered when you configure Embedding Cache and AI Batch:

SymptomCauseAction
The connection times out when you call the RESTful API.The port is omitted, so the request goes to port 80 by default.Specify port 19530 explicitly, for example http://c-xxx.milvus.aliyuncs.com:19530.
The batch job fails.The body.model value on a line of input.jsonl does not match the model_name declared at upload time.Align body.model on every line with the model_name that you declare when you upload the file.
The download request reports output_file_id is empty.The job is not complete.Poll /v2/vectordb/ai/batch/jobs/describe until status is completed, and then download the results.
The download request does not locate the result file.The request identifies the file by file ID.Call /v2/vectordb/ai/batch/files/content with batch_id plus file_type.
Two writes of the same text return identical vectors, or the second write is slower than the first.Vector comparison and write latency are not criteria for a cache hit.Check usage.total_tokens and request_id in the response of the RESTful embedding endpoint.