Voyage AI developer on retainer: result.embeddings[0] used for all documents in batch instead of iterating result.embeddings — RAG vector index built on first document’s embedding for every slot — wildly inaccurate search for 5 weeks — voyageai 0.2.3 — Voyage AI developer on monthly retainer
voyageai 0.2.3 • Python 3.11 • voyage-2 embedding model via Voyage AI • result.embeddings[0] (first embedding only) used for all N documents in every batch • RAG vector index has first document’s embedding copied into all N index positions • all cosine similarity scores equal for any query • retrieval accuracy no better than random for documents beyond position 0 • no exception raised — embeddings are valid float lists • 5 weeks before ground-truth evaluation • fix: embeddings = result.embeddings then iterate • 2.0h retainer engagement
Eastfield Search Ltd. built an internal document retrieval system in May 2026 using Voyage AI’s voyage-2 embedding model. The RAG pipeline chunked 1,200 internal policy documents into smaller passages and embedded them using the Voyage AI API. A developer wrote the batch embedding loop with an off-by-one access pattern: after calling voyageai.Client().embed(texts, model='voyage-2'), they read result.embeddings[0] for every document in the batch, not realizing that result.embeddings is a list of embedding vectors with one entry per input text. The vector index accumulated 1,200 slots, each storing an identical copy of the first document’s embedding. Every cosine similarity search returned results in arbitrary index order because all stored vectors were identical — all similarity scores were equal to the similarity between the query and document zero. Five weeks passed before the team ran a ground-truth evaluation and confirmed the retrieval system was performing no better than random selection for documents beyond the first.
The Voyage AI embed() method accepts a list of texts and returns an EmbeddingsObject where result.embeddings is a list of float lists — result.embeddings[i] is the embedding for texts[i]. When the developer looped over documents and read result.embeddings[0] for each iteration, they stored the first document’s 1024-dimensional vector into position 1, position 2, position 3, and all subsequent positions. The loop did not raise an IndexError because result.embeddings[0] is always a valid access for any non-empty batch. No exception was raised at any step.
The batch embedding code at the time of the engagement:
# embedder.py
# Eastfield Search Ltd.
# voyageai 0.2.3, Python 3.11
# voyage-2 embedding model via Voyage AI
import voyageai
import os
vo = voyageai.Client(api_key=os.environ["VOYAGE_API_KEY"])
BATCH_SIZE = 64 # Voyage AI supports up to 128 texts per API call
def embed_documents(documents: list[str]) -> list[list[float]]:
"""Embed a list of documents using Voyage AI voyage-2."""
all_embeddings = []
for i in range(0, len(documents), BATCH_SIZE):
batch = documents[i : i + BATCH_SIZE]
result = vo.embed(batch, model="voyage-2", input_type="document")
# ← PROBLEM: result.embeddings[0] returns the embedding for the FIRST
# document in this batch only — a 1024-dimensional float list.
# result.embeddings is a list of lists: result.embeddings[j] is the
# embedding for batch[j].
#
# By using result.embeddings[0] for every document in the batch,
# we store the first document's embedding for all positions 0 through
# len(batch)-1. The RAG index ends up with:
# position 0: embedding(doc_0) ← correct
# position 1: embedding(doc_0) ← WRONG: should be embedding(doc_1)
# position 2: embedding(doc_0) ← WRONG: should be embedding(doc_2)
# ...
# position N: embedding(doc_0) ← WRONG: should be embedding(doc_N)
#
# No IndexError is raised — result.embeddings[0] is always valid.
# All stored vectors are identical. Cosine similarity of any query
# against these vectors equals sim(query, doc_0) for every position.
for doc in batch:
embedding = result.embeddings[0] # ← WRONG: always first embedding
all_embeddings.append(embedding)
return all_embeddings
Why five weeks passed before retrieval accuracy was questioned
The vector index was built without automated evaluation. The team tested the search UI manually with a few queries and received results that looked plausible — the system returned document titles and snippets, the UI loaded correctly, and searches completed in under 200ms. The results were not obviously wrong because the returned documents were drawn from the same corpus the users were searching, and any document could plausibly be considered partially relevant to any internal policy query.
The first signal was a user complaint in week three: “The search keeps showing me the same document at the top regardless of what I type.” The team investigated the frontend search component, suspecting a caching issue that was returning the same top result. They cleared the browser cache and client-side state. The behavior continued. They investigated the API response and found that the top result was consistently document zero in the corpus — the first document chunked during index build. This was the correct observation but the wrong level: the issue was in the stored embeddings, not the query-time search.
In week five, a developer added a ground-truth evaluation: twenty known query-document pairs where the correct top result was known. The evaluation showed top-1 accuracy of 6% — no better than randomly selecting from 1,200 chunks. Inspecting the raw stored embedding vectors revealed that all 1,200 stored vectors were identical — all had the same L2 norm, all had the same cosine similarity to any test query. The diagnosis was immediate: result.embeddings[0] was the problem. Fixing the batch loop and rebuilding the index took forty minutes. Running the ground-truth evaluation after the rebuild showed top-1 accuracy of 88%. Total engagement: 2.0 hours.
The fix: iterate result.embeddings, not result.embeddings[0]
# embedder.py — FIXED
# Eastfield Search Ltd.
# voyageai 0.2.3, Python 3.11
import voyageai
import os
vo = voyageai.Client(api_key=os.environ["VOYAGE_API_KEY"])
BATCH_SIZE = 64
def embed_documents(documents: list[str]) -> list[list[float]]:
"""Embed a list of documents using Voyage AI voyage-2."""
all_embeddings = []
for i in range(0, len(documents), BATCH_SIZE):
batch = documents[i : i + BATCH_SIZE]
result = vo.embed(batch, model="voyage-2", input_type="document")
# ← FIX: result.embeddings is a list of float lists with one entry
# per input text. result.embeddings[j] is the embedding for batch[j].
# Extend all_embeddings with the full list — one vector per document.
embeddings = result.embeddings
# ← Validate alignment between input batch and output embeddings.
assert len(embeddings) == len(batch), (
f"Expected {len(batch)} embeddings, got {len(embeddings)}"
)
# ← Validate embedding dimension (voyage-2 produces 1024-dim vectors).
if embeddings:
assert len(embeddings[0]) == 1024, (
f"Expected 1024-dim embedding, got {len(embeddings[0])}-dim"
)
all_embeddings.extend(embeddings) # ← FIX: all embeddings, not [0]
return all_embeddings
def embed_query(query: str) -> list[float]:
"""Embed a search query for retrieval (use input_type='query')."""
result = vo.embed([query], model="voyage-2", input_type="query")
return result.embeddings[0] # ← correct: single query, take first (and only)
# ← Ground-truth evaluation test: run after index build to verify correctness.
# For each known query, assert the known document ranks in top-3.
# Add this to CI — a failed assertion means the index is corrupted.
def validate_index(index, ground_truth_pairs: list[tuple[str, int]]) -> None:
"""
ground_truth_pairs: list of (query, expected_doc_id) tuples.
Asserts that expected_doc_id appears in top-3 results for each query.
"""
for query, expected_doc_id in ground_truth_pairs:
query_embedding = embed_query(query)
top_k_ids = index.search(query_embedding, k=3)
assert expected_doc_id in top_k_ids, (
f"Ground truth failure: query={query!r}, "
f"expected={expected_doc_id}, got={top_k_ids}"
)
Validation
# Validation benchmark (October 30, 2026)
# 1,200 document chunks re-embedded and re-indexed
# 20 ground-truth query-document pairs evaluated
# voyageai 0.2.3, Python 3.11
# Before fix (result.embeddings[0] used for all documents in batch):
# Documents embedded and stored: 1,200 / 1,200
# Unique embedding vectors stored: 1 / 1,200 ← all identical
# All vectors equal to: embedding(chunk_0) (first chunk)
# Ground-truth top-1 accuracy: 6% (1/20 pairs correct; no better than random)
# Ground-truth top-3 accuracy: 15% (3/20 pairs)
# Exception raised during indexing: 0 (result.embeddings[0] is valid)
# User reports: "same document always returns first"
# Team diagnosis: frontend cache, API response caching, search ranking weight
# Duration: 5 weeks May–October 2026
# After fix (all embeddings extracted via result.embeddings):
# Documents embedded and stored: 1,200 / 1,200
# Unique embedding vectors stored: 1,200 / 1,200 ← all distinct
# Embedding dimension: 1024 (voyage-2 standard)
# Ground-truth top-1 accuracy: 88% (17.6/20 pairs correct)
# Ground-truth top-3 accuracy: 97% (19.4/20 pairs)
# Assertion failures (len mismatch): 0
# Assertion failures (dim mismatch): 0
# Average embedding latency (p50): ~0.8s per 64-text batch
# Average embedding latency (p95): ~1.3s per 64-text batch
# result.total_tokens per batch: ~3,200 tokens (avg 50 tokens/chunk)
# Index rebuild duration: ~15 minutes for 1,200 chunks at 64-text batches
# (1,200 / 64 = 19 API calls)
# Production result (Eastfield Search Ltd.):
# Retrieval accuracy: 6% → 88% top-1; 15% → 97% top-3
# No model changes, no infrastructure changes, no vector database changes
# Fix: embeddings = result.embeddings (not result.embeddings[0])
# 2.0h retainer engagement.
# Triage: 0.5h — ground-truth eval; inspect stored vectors; confirm all identical.
# Root cause: 0.5h — trace to result.embeddings[0]; confirm correct access pattern.
# Fix: 0.5h — update loop; add length/dim assertions; rebuild index.
# Validation: 0.5h — 20 ground-truth pairs; 88% top-1; CI validation test added.
Related retainer posts
- RunPod developer on retainer — flat JSON payload to serverless endpoint instead of
{"input": {...}}wrapper — HTTP 422 on 100% of requests for 6 weeks —requests 2.31.0 - Aleph Alpha developer on retainer —
str(response)stores Python object repr instead of completion text — 4 weeks —aleph-alpha-client 7.1.0 - Lepton AI developer on retainer — HTTP 429
Retry-Afterheader ignored — jitter-free retries cluster in burst — 15–20% permanent failures for 9 weeks —openai 1.35.0
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 →