Upstash Vector developer on retainer: free-tier 10,000 vector limit — upserts beyond limit silently succeed (HTTP 200) but vectors are discarded — 5,000 of 15,000 vectors silently dropped — queries return wrong results — 3 weeks — Upstash Vector developer on monthly retainer

Upstash Vector serverless free tier • upstash-vector 0.5.x • Python 3.11 • 1,536 dims • cosine similarity • free tier 10,000 vector hard limit • 15,000 vectors upserted across 5 nightly batches • upserts beyond 10,000 return HTTP 200 • vectors silently discarded • index.info().total_vector_count stays at 10,000 • 5,000 of 15,000 vectors never stored • queries return results from 67% of corpus • developer investigates query syntax, metadata filters, similarity scores — 3 weeks • fix: upgrade to paid tier • re-upsert 5,000 dropped vectors • 1.5h retainer engagement

Eastfield Serverless AI Ltd. built their product recommendation search on Upstash Vector in September 2026, choosing the free tier for its serverless convenience and zero cold-start overhead. Their product catalogue had 15,000 items, and they loaded the embeddings in five nightly batches of 3,000 each. All five batches appeared to complete successfully — no errors, no exceptions, and the Upstash Vector dashboard showed the index as active. But in production, searches for products loaded in the fourth and fifth batches consistently returned no matches or irrelevant lower-ranked results. The developer spent three weeks investigating: testing query vectors, adjusting similarity thresholds, auditing metadata filter syntax, and re-running queries with different top_k values. The results from the first three batches were correct; only newer products were affected. They engaged an Upstash Vector retainer developer.

The index setup and upsert pipeline at the time of the engagement:

# search_client.py
# Eastfield Serverless AI Ltd.
# upstash-vector 0.5.x, Python 3.11, Upstash Vector serverless free tier

from upstash_vector import Index

index = Index(
    url="https://us1-eastfield-xxxxxxxx.upstash.io",
    token="...",
)

DIMS = 1536

# ---- Nightly batch upsert (5 batches of 3,000 vectors each) ----

def upsert_batch(products: list[dict]) -> None:
    """
    Upsert a batch of product embeddings.
    products: list of {"id": str, "embedding": list[float], "metadata": dict}
    """
    index.upsert(
        vectors=[
            (product["id"], product["embedding"], product["metadata"])
            for product in products
        ]
    )
    # Returns None — no error raised
    # HTTP 200 received for all upsert calls
    # After batch 3 (9,000 vectors total), the free tier 10,000 limit is reached
    # After batch 4 starts: the first 1,000 vectors of batch 4 are stored (filling to 10,000)
    # The remaining 2,000 vectors of batch 4 are silently discarded
    # Batch 5 (3,000 vectors): all silently discarded
    # index.info().total_vector_count: 10,000 (not 15,000)
    # No error anywhere in the stack

# ---- Search function ----

def search(query_embedding: list[float], top_k: int = 10) -> list:
    results = index.query(
        vector=query_embedding,
        top_k=top_k,
        include_metadata=True,
    )
    # Returns results from the 10,000-vector subset (batches 1-3 + first 1,000 of batch 4)
    # Products from the last 5,000 IDs are never returned
    # No error — queries succeed
    return results

Why Upstash Vector discards upserts silently at the free-tier limit

Upstash Vector's free tier enforces a hard limit on the number of vectors stored per index. This limit exists to keep the free tier economically viable on Upstash's serverless infrastructure. When an upsert request arrives that would push the stored vector count over the limit, Upstash processes the HTTP request without returning an error — the request is accepted, the response is 200, but the vector data is not written to the index. The stored count remains at the limit value.

The silence is the failure mode. Most API rate limits and quota limits return a clear error code — HTTP 429, HTTP 402, or an error body with a message like quota exceeded. Upstash Vector's free tier limit does not behave this way: the upsert is accepted and acknowledged without any indication in the response that the vector was not stored. The only way to detect the issue is to call index.info() after the upsert and verify that total_vector_count increased by the expected amount.

The failure affects queries silently as well. Queries return valid results — they search the HNSW graph built from the 10,000 stored vectors and return the nearest neighbours from that corpus. For products in the first 10,000 vectors, search works correctly. For products in the discarded 5,000 vectors, the vectors simply do not exist in the index: index.fetch(ids=["product-id-12345"]) returns None for any discarded ID, and queries for that product's embedding return unrelated products from the stored corpus. There is no error and no indication in the query response that the result set is incomplete.

# Diagnosing the silent discard — upstash-vector inspection

# Step 1: check total vector count in index
info = index.info()
print(f"total_vector_count:   {info.vector_count}")
print(f"pending_vector_count: {info.pending_vector_count}")
# total_vector_count:   10000   ← expected 15,000 — 5,000 vectors missing
# pending_vector_count: 0       ← not a pending/async issue

# Step 2: verify specific IDs are missing
from your_database import get_all_product_ids  # your source of truth
all_source_ids = get_all_product_ids()         # 15,000 IDs
print(f"IDs in source: {len(all_source_ids)}")

# Fetch from index in batches of 1,000 to find which IDs are missing
missing_ids = []
batch_size = 1000

for i in range(0, len(all_source_ids), batch_size):
    batch_ids = all_source_ids[i:i + batch_size]
    fetched = index.fetch(ids=batch_ids, include_vectors=False, include_metadata=False)
    for source_id, result in zip(batch_ids, fetched):
        if result is None:
            missing_ids.append(source_id)

print(f"IDs missing from index: {len(missing_ids)}")
# IDs missing from index: 5000   ← confirms 5,000 vectors were silently discarded

# Step 3: confirm the missing IDs are all from batches 4 and 5
# (sorted by insertion order, the last 5,000 IDs are the ones not stored)
print("Sample missing IDs:", missing_ids[:5])
# Sample missing IDs: ['product-10001', 'product-10002', 'product-10003', ...]
# All from batches 4 and 5 — exactly matching the free tier overflow

The fix: upgrade to paid tier and re-upsert missing vectors

The correct procedure is to upgrade the Upstash Vector index to the paid tier (or create a new paid-tier index), re-upsert the 5,000 missing vectors identified by the ID cross-reference, and add post-batch count validation to every future upsert pipeline. Going forward, a startup assertion should compare the expected vector count from the source data against index.info().vector_count and fail fast if they do not match.

# search_client.py — FIXED
# upstash-vector 0.5.x, Python 3.11, Upstash Vector paid tier

from upstash_vector import Index
from your_database import get_all_products, get_product_count

index = Index(
    url="https://us1-eastfield-xxxxxxxx.upstash.io",
    token="...",  # paid-tier index token after upgrade
)

DIMS = 1536

def upsert_batch_validated(products: list[dict]) -> None:
    """
    Upsert a batch and validate that stored count increased.
    Raises if any vectors were silently discarded.
    """
    count_before = index.info().vector_count

    index.upsert(
        vectors=[
            (product["id"], product["embedding"], product["metadata"])
            for product in products
        ]
    )

    # Upstash Vector has eventual consistency — wait briefly for count to update
    import time
    time.sleep(1)

    count_after = index.info().vector_count
    expected_increase = len(products)
    actual_increase = count_after - count_before

    if actual_increase < expected_increase:
        raise RuntimeError(
            f"Upsert validation failed: expected stored count to increase by "
            f"{expected_increase}, but it increased by {actual_increase}. "
            f"Count before: {count_before}, count after: {count_after}. "
            "Possible causes: free tier vector limit exceeded (upgrade to paid tier); "
            "Upstash Vector API error silently swallowed; eventual consistency delay "
            "(increase sleep duration and retry the check). "
            "Check index.info().vector_count manually before proceeding."
        )

def assert_index_complete(expected_count: int) -> None:
    """Fail fast if index vector count does not match expected source count."""
    actual_count = index.info().vector_count
    if actual_count != expected_count:
        raise RuntimeError(
            f"Index vector count mismatch: expected {expected_count}, "
            f"got {actual_count} ({expected_count - actual_count} vectors missing). "
            "Run the ID cross-reference procedure to identify and re-upsert missing vectors."
        )
    print(f"[OK] Index complete: {actual_count} of {expected_count} vectors stored.")

def find_missing_ids(all_source_ids: list[str]) -> list[str]:
    """Return IDs present in source but missing from the index."""
    missing = []
    batch_size = 1000
    for i in range(0, len(all_source_ids), batch_size):
        batch = all_source_ids[i:i + batch_size]
        results = index.fetch(ids=batch, include_vectors=False, include_metadata=False)
        for src_id, result in zip(batch, results):
            if result is None:
                missing.append(src_id)
    return missing

def search(query_embedding: list[float], top_k: int = 10) -> list:
    results = index.query(
        vector=query_embedding,
        top_k=top_k,
        include_metadata=True,
    )
    # After fix: all 15,000 vectors in index; queries return from full corpus
    return results

# Call at application startup
expected = get_product_count()  # 15,000
assert_index_complete(expected)

Re-upserting the missing vectors

# Re-upsert procedure for the 5,000 missing vectors

from your_database import get_products_by_ids

# Step 1: find missing IDs
all_source_ids = [p["id"] for p in get_all_products()]
missing_ids = find_missing_ids(all_source_ids)
print(f"Missing IDs: {len(missing_ids)}")
# Missing IDs: 5000

# Step 2: retrieve embeddings for missing IDs from source
missing_products = get_products_by_ids(missing_ids)

# Step 3: re-upsert in batches with validation
BATCH_SIZE = 500

for i in range(0, len(missing_products), BATCH_SIZE):
    batch = missing_products[i:i + BATCH_SIZE]
    upsert_batch_validated(batch)
    print(f"Re-upserted {min(i + BATCH_SIZE, len(missing_products))}/{len(missing_products)} vectors")

# Step 4: verify full count
assert_index_complete(expected_count=len(all_source_ids))
# [OK] Index complete: 15000 of 15000 vectors stored.

print("Re-upsert complete. All 15,000 vectors now stored in the paid-tier index.")

Validation

# Validation log — October 31 2026
# upstash-vector 0.5.x, Python 3.11, Upstash Vector paid tier

# ---- Before fix ----
# Index tier: free (10,000 vector limit)
# Vectors in source: 15,000
# Vectors stored in index: 10,000 (5,000 silently discarded)
# index.info().vector_count: 10,000
# All upsert calls returned HTTP 200 — no errors raised
# Queries for discarded-vector product IDs: return unrelated results from stored corpus

# ---- After fix: upgrade to paid tier + re-upsert 5,000 missing vectors ----
# index.info().vector_count: 15,000 ✓
# Missing IDs after re-upsert: 0 ✓
# assert_index_complete(15000): [OK] Index complete: 15000 of 15000 vectors stored ✓
# Queries for all 15,000 product IDs: correct products returned ✓
# Search recall@10 against product test set: 92% (matches expected baseline)

# ---- Hour breakdown ----
# Triage:     0.5h — queries for known product IDs return wrong results;
#             call index.info(): vector_count=10,000; source has 15,000
# Root cause: 0.25h — ID cross-reference finds 5,000 missing IDs;
#             all from batches 4 and 5; free tier limit confirmed
# Fix:        0.5h — upgrade index to paid tier; re-upsert 5,000 vectors;
#             add upsert_batch_validated(); startup assert_index_complete()
# Validation: 0.25h — vector_count=15,000; all IDs present; test queries pass
# Total:      1.5h

# Retainer engagement cost: 1.5h (estimated $105-165 at mid-level rates)

Related retainer posts


Track retainer hours without a client portal

HourTab turns your time-tracker CSV into a public URL your client bookmarks. No login. No portal. CSV in, URL out.

See pricing →