LlamaIndex VectorStoreIndex explained in one sentence: it embeds every node with your configured embedding model, writes the vectors to a vector store (an in-memory, brute-force SimpleVectorStore unless you pass a StorageContext), and at query time returns the most similar nodes, two by default, for the LLM to answer from. For most RAG workloads it’s the right index to start with.
The way it fails in production is mundane. The answer is in the corpus, the bot says it doesn’t know, and someone swaps the LLM. Usually similarity_top_k was never raised from 2, or the serving process rebuilt the index with a different embedding model from the one that wrote the vectors. Both problems show up on an eval chart before they reach a support ticket.
What does VectorStoreIndex actually do?
VectorStoreIndex runs two paths. On build, it turns Documents into nodes, embeds each node and inserts the vectors into a vector store in batches. On query, it embeds the question and asks the store for the nearest neighbors. Filters, query modes and response synthesis are all configuration layered on those two paths.
The VectorStoreIndex guide shows three ways to build: from_documents(), an IngestionPipeline for control over splitting and metadata, or pre-built TextNode objects passed to the constructor. Vectors are inserted in batches of 2048 nodes by default (insert_batch_size). Embedding API calls are batched separately, 10 per request by default, according to the embeddings docs. The same page names OpenAI’s text-embedding-ada-002 as the default model, so that’s what you’re paying for if you never set Settings.embed_model.
The query path builds a VectorIndexRetriever. Its similarity_top_k falls back to DEFAULT_SIMILARITY_TOP_K, which is 2 in core/constants.py, and the default chunk size there is 1024 tokens. Two 1024-token nodes don’t give the LLM much context for a question that spans sections. Chunk size and top-k trade off against each other, and tuning chunk_size and chunk_overlap covers the chunking side.
Where do the vectors and the text live?
That depends on the store’s stores_text flag. The default SimpleVectorStore holds only embeddings, so VectorStoreIndex also writes every node to the docstore and reads text from there at query time. Integrations like Qdrant set stores_text = True, so node text travels in the vector store payload and the docstore stays mostly empty.
The API reference states the rule: nodes go to the docstore when the vector store doesn’t store text, or when store_nodes_override=True. That has three consequences:
- Persistence depends on the backend. With the default store,
storage_context.persist(persist_dir=...)writes everything to disk andload_index_from_storagereads it back (persisting and loading). An external store persists on its own, so you reconnect withVectorStoreIndex.from_vector_store(vector_store). from_vector_storerejects text-less stores. Ifstores_textis false, it raisesValueError("Cannot initialize from a vector store that does not store text.").- Document tracking thins out. The document management guide says docstore tracking covers every index “except for most vector store integrations.” On a Qdrant-backed index, don’t expect
ref_doc_infoto give you a complete doc-to-node map unless you setstore_nodes_override=True.
The default store also has a scaling ceiling. SimpleVectorStore.query loops over every stored embedding in Python before it takes the top-k. That’s exact search, and its latency grows linearly with corpus size. Dedicated stores use approximate indexes such as HNSW, which Malkov and Yashunin describe as having logarithmic complexity scaling. The cost is that recall becomes approximate rather than exact.
The metric that matters: hit rate at your serving k
For a VectorStoreIndex, track hit rate at the similarity_top_k you actually serve, alongside MRR. An LLM-judge answer score blends retrieval failures with synthesis failures. Hit rate measures the index alone.
Given a golden set Q of questions, each labeled with the node IDs that hold the answer:
hit_rate@k = (1/|Q|) * sum_q 1[ relevant(q) ∩ top_k(q) is non-empty ]
MRR@k = (1/|Q|) * sum_q 1/rank_q (0 if no relevant node in top_k)
Hit rate tells you whether the evidence reached the prompt. MRR tells you how high it ranked, which starts to matter once you add a reranker. The obvious thing to log is mean cosine similarity, but those scores aren’t comparable across embedding models or stores, so that chart moves whenever either one changes. RetrieverEvaluator ships with hit_rate and mrr, and generate_question_context_pairs can bootstrap a synthetic eval set from your nodes (retrieval evaluation).
Wiring it up
Keep ingest and serving separate. The ingest job embeds and writes, and the serving process reconnects without re-embedding the corpus. This example uses the Qdrant integration with a local BGE model and logs the eval to MLflow.
import asyncio
import mlflow
import qdrant_client
from llama_index.core import Settings, StorageContext, VectorStoreIndex
from llama_index.core.evaluation import EmbeddingQAFinetuneDataset, RetrieverEvaluator
from llama_index.core.node_parser import SentenceSplitter
from llama_index.core.vector_stores import FilterOperator, MetadataFilter, MetadataFilters
from llama_index.embeddings.huggingface import HuggingFaceEmbedding
from llama_index.vector_stores.qdrant import QdrantVectorStore
EMBED_MODEL = "BAAI/bge-small-en-v1.5"
Settings.embed_model = HuggingFaceEmbedding(model_name=EMBED_MODEL)
client = qdrant_client.QdrantClient(url="http://qdrant:6333")
# Model name in the collection name: a model change forces a new collection.
vector_store = QdrantVectorStore(client=client, collection_name="docs_bge_small_v1")
def ingest(documents): # batch job, runs on corpus change, not pod start
return VectorStoreIndex.from_documents(
documents,
storage_context=StorageContext.from_defaults(vector_store=vector_store),
transformations=[SentenceSplitter(chunk_size=512, chunk_overlap=50)],
insert_batch_size=512, # default 2048
show_progress=True,
)
# Serving path: wraps the existing collection.
index = VectorStoreIndex.from_vector_store(vector_store)
retriever = index.as_retriever(
similarity_top_k=8, # default is 2
filters=MetadataFilters(
filters=[MetadataFilter(key="product", operator=FilterOperator.EQ, value="billing")]
),
)
async def eval_at_k(qa_dataset, k):
evaluator = RetrieverEvaluator.from_metric_names(
["hit_rate", "mrr"], retriever=index.as_retriever(similarity_top_k=k)
)
results = await evaluator.aevaluate_dataset(qa_dataset)
return {m: sum(r.metric_vals_dict[m] for r in results) / len(results) for m in ("hit_rate", "mrr")}
qa_dataset = EmbeddingQAFinetuneDataset.from_json("golden_set.json")
with mlflow.start_run(run_name="docs_bge_small_v1-chunk512"):
mlflow.log_params({"embed_model": EMBED_MODEL, "chunk_size": 512, "chunk_overlap": 50})
for k in (2, 4, 8, 16):
scores = asyncio.run(eval_at_k(qa_dataset, k))
mlflow.log_metric("hit_rate", scores["hit_rate"], step=k)
mlflow.log_metric("mrr", scores["mrr"], step=k)
The eval retriever has no metadata filter because you’re measuring the index, not one tenant’s slice of it. The docs suggest tuning insert_batch_size for memory limits and remote vector databases, so size it against the store’s request limits too. as_query_engine() and as_chat_engine() wrap the same retriever; query engine vs chat engine covers which one fits your traffic.
What you’ll see
Logging with step=k makes MLflow plot hit rate and MRR against k. A healthy curve rises and then flattens, and similarity_top_k belongs at the knee. If hit rate is still climbing at k=16, your chunks are probably too small or the embedding model doesn’t fit the domain, and raising k further mostly buys prompt tokens. High hit rate with low MRR means the right node is coming back but buried. That calls for a reranker, not changes to the index.
On the serving side, retrieve() latency is the query embedding plus the vector search. With SimpleVectorStore, p95 and p99 climb as the corpus grows because every query scans every vector, and that’s your cue to move to a dedicated store. Behind an HNSW-backed store latency should stay roughly flat, so a sudden p99 jump usually points at the store or the embedding endpoint. Put a separate OpenTelemetry span around the embed call to tell them apart.
After every re-ingest, check the hit-rate series for a step change. A drop right after a deploy with no corpus change usually means an embedding-model or chunking change slipped through. Retrieval troubleshooting shows how to narrow it down from source nodes and scores.
Caveats
- Embedding mismatch fails silently. Query a collection built with model A using model B, at the same dimension, and you get confident wrong neighbors with no error. Pin the model in config and in the collection name.
- Synthetic eval sets leak.
generate_question_context_pairswrites questions from the chunk text, so they reuse its vocabulary and inflate the score. Use synthetic pairs to compare configs, and add hand-labeled real queries before you trust the absolute number. - Node IDs aren’t stable labels. Re-chunking changes node IDs and breaks any golden set keyed on them. Label by source document and answer span, then map to node IDs at eval time.
- Filter support varies by store. The vector stores table shows which backends support metadata filtering, hybrid search and delete. Check it before you build tenant isolation on a filter.
- Deletes leave docstore residue.
delete_ref_docleaves nodes in the docstore unless you passdelete_from_docstore=True. On a persisted index, that’s storage you keep paying for and data you may be required to purge.
FAQ
does llamaindex vectorstoreindex store data in memory
Yes, by default. Without a StorageContext, VectorStoreIndex uses the in-memory SimpleVectorStore, and everything is lost when the process exits. Call index.storage_context.persist(persist_dir="./storage") to write it to disk and load_index_from_storage to reload it, or pass an external store such as Qdrant, Pinecone or Postgres and let the store handle persistence.
how do i load an existing vector store into llamaindex
Use VectorStoreIndex.from_vector_store(vector_store, embed_model=...). It wraps the existing collection without re-embedding anything, so serving processes start quickly. Pass the same embedding model that built the collection, because a different model with matching dimensions fails silently. The method raises a ValueError if the store doesn’t keep node text, which rules out the default in-memory store.
can i use vectorstoreindex without openai
Yes. Set Settings.embed_model to any supported embedding class before you build or load the index, for example HuggingFaceEmbedding(model_name="BAAI/bge-small-en-v1.5") for a local model. If you skip this step, LlamaIndex falls back to OpenAI’s text-embedding-ada-002, which fails without an API key. The LLM that writes answers is configured separately through Settings.llm.
when should i use a different index than vectorstoreindex
Use a different index when a question needs the whole corpus or multi-hop reasoning. A summary index reads every node, which suits summarization, where top-k similarity would sample arbitrarily. Knowledge-graph indexes retrieve by traversing extracted entities, which helps with questions that link facts across documents. For most single-passage questions, VectorStoreIndex plus a reranker is still the default.