Vector database systems: FAISS, Pinecone, Milvus — and the exam fill-in
After this lesson you will tell a vector library from a vector database, read every FAISS and Milvus call of the slides, and fill the blank lines of a client.search call the way the exam asks.
The exam of 11 February 2025 followed Formula 1 races. Its second exercise, "Vector DB (1 PT)", printed a call with three empty lines:
pilots = client.search (
collection_name = "pilot_collection",
data = [...],
search_params = {"metric_type": "IP", "params": {"level": 1}},
___________ = __________________________
___________ = __________________________
___________ = __________________________
)
The call must consider “the pilots whose name starts with "Mic"”, compute “how many pilots won each given number of championships” as pairs (NumberOfWonChampionships, NumberOfWinningPilots), and “Return only the first 5 results”. Write your three lines on paper now.
Then a harder question: can any client.search call return those pairs at all? The last section settles both. On the way you meet the three kinds of system behind such a call — a library that only searches, a service that adds what a database needs, and a database whose collections take filters.
Five places to keep vectors
Lesson 27 turned sentences and images into vectors and searched for their neighbours. This lesson asks where those vectors live and who answers the query. The slide "Systems Categorization" (p. 678) lists five kinds of system, from the most packaged to the least.
| Family on the slide | Example on the slide | What you handle |
|---|---|---|
| "Stand-alone, proprietary, fully vectorized databases" | Pinecone | a managed service: a web console, an API key, indexes you never install |
| "Open-source solutions … which provide built-in RESTful APIs and support for Python and Java" | Weaviate, Milvus | a vector database you run yourself, or rent as a cloud service |
| "Platforms with vector database capabilities integrated" | IBM watsonx.data, "similar by Microsoft and others" | a data platform that also stores and searches vectors |
| "Vector database and database search extensions" | PostgreSQL's pgvector | a vector column inside SQL, next to joins and transactions |
| "Libraries for vector search and comparisons" | FAISS | functions called inside your own program; no server at all |
The deck's "Conclusion" (p. 715) sums the systems up in four lines. Vector databases are "essential for AI/ML-driven data retrieval and semantic search, like chatbots and RAG"; they "provide a foundation for advanced applications like recommendation engines and contextual search"; they "incorporate efficient algorithms for matching and retrieval (possibly approximated!)"; and they "can be native or integrated in other DB technologies". Pinecone and Milvus are native, built for vectors from the start; pgvector, watsonx.data and Neo4j below are integrated, existing systems that learned to search by similarity. FAISS is neither: it is not a database at all.
The library against the databases is the thread of the lesson. The slide "Features" draws the line: "Unlike vector indexes, vector databases are equipped with a set of capabilities that makes them better qualified to be used in high-scale production settings" (p. 696).
A vector index stores vectors and finds neighbours; a vector database wraps an index with what production needs.
Integrated: vectors inside a graph database
Neo4j, the graph database of lessons 10–12, "has integrated native vector search into its core database capabilities" (p. 713). The slide before stresses the "integration of vector + semantic graph search" and shows three views of one apple (p. 712): a photo, for people; a vector, a wall of numbers that a machine can compare and nobody can read; and a knowledge graph — Apple linked by origin to tree, by kind to fruit, by structure to body and stem, the body round, 10cm and red — explicit facts that can be followed and explained. The combination "Paves the way to neurosymbolic AI": neural vectors plus symbolic graphs.
The right half of p. 712 is the loop that uses both, redrawn in Fig. 1. Similarity on the vectors finds the nodes where the question enters the graph; the graph adds the facts connected to them; question and facts go to the LLM as one prompt. That is retrieval-augmented generation, the RAG of the conclusion, on a graph. When the slide promises results that "serve as long-term memory for LLMs, all while reducing hallucinations" (p. 713), this loop is the mechanism: the model answers from retrieved facts rather than from its weights alone. The database makes that possible; the grounding does the reducing.
What it looks like in Cypher (background, not on the slides)
Neo4j's documentation creates a vector index on a node property and queries it with a procedure, after which ordinary Cypher can follow the hits into the graph:
CREATE VECTOR INDEX moviePlots IF NOT EXISTS
FOR (m:Movie)
ON m.embedding
OPTIONS { indexConfig: {
`vector.dimensions`: 1536,
`vector.similarity_function`: 'cosine'
}}
MATCH (m:Movie {title: 'Godfather, The'})
CALL db.index.vector.queryNodes('moviePlots', 5, m.embedding)
YIELD node AS movie, score
RETURN movie.title AS title, movie.plot AS plot, score
The index is an HNSW graph built with Apache Lucene, the family you met in lesson 27; the similarity is cosine or Euclidean, and the score lies between 0 and 1. The exam's Neo4j exercises do not use it.
FAISS: a vector index in five lines
"Faiss is a library — developed by Facebook AI — that enables efficient similarity search" (p. 680); the exercise session adds "and clustering of dense vectors" (p. 1697). Its whole life fits in the next sentence of p. 680: "Given a set of vectors, we can index them using Faiss — then using another vector (the query vector), we search for the most similar vectors within the index." There is no server and no user: you import a module and call functions on matrices.
Two float32 matrices
The code pages (pp. 685–692) follow the library's own wiki, which p. 693 cites. They start from a contract (p. 685). FAISS handles vectors "of a fixed dimensionality d, typically a few 10s to 100s". A collection is a matrix in "row-major storage, ie. the j'th component of vector number i is stored in row i, column j". And "Faiss uses only 32-bit floating point matrices" — hence the .astype('float32') in every snippet. The tutorial's data (p. 687):
import numpy as np
d = 64 # dimension
nb = 100000 # database size
nq = 10000 # nb of queries
np.random.seed(1234) # make reproducible
xb = np.random.random((nb, d)).astype('float32')
xb[:, 0] += np.arange(nb) / 1000.
xq = np.random.random((nq, d)).astype('float32')
xq[:, 0] += np.arange(nq) / 1000.
xb holds the database, "Size nb x d", and xq the queries, "Size nq x d" (p. 687): 100,000 and 10,000 rows of 64 random numbers. The two lines with np.arange add i/1000 to the first number of row i, so rows with close ids end up close in space. That makes the results below easy to check: the neighbours of query i have ids near i.
IndexFlatL2: build, add, search
import faiss # make faiss available
index = faiss.IndexFlatL2(d) # build the index
print(index.is_trained)
index.add(xb) # add vectors to the index
print(index.ntotal)
Read it line by line (p. 688). IndexFlatL2(d) is "the simplest version that just performs brute-force L2 distance search", and it must know d when it is built. is_trained prints True: "Most of the indexes also require a training phase, to analyze the distribution of the vectors. For IndexFlatL2, we can skip this operation." add stores the rows, and ntotal prints 100000. The ids are implicit: add works by "assigning implicitly an ID in row order" (p. 1700), so row i of xb is id i.
k = 4 # we want to see 4 nearest neighbors
D, I = index.search(xb[:5], k) # sanity check
print(I)
print(D)
D, I = index.search(xq, k) # actual search
print(I[:5]) # neighbors of the 5 first queries
print(I[-5:]) # neighbors of the 5 last queries
search returns two matrices, distances first (p. 689). I is "an integer matrix of size nq x k, where row i contains the IDs of the neighbors of query vector i, sorted by increasing distance"; D is "a nq x k floating-point matrix with the corresponding squared distances". The first call is a "sanity check": five database rows are searched in their own index, so each must find itself first, at distance 0. Running the slide's code (FAISS 1.15.1) prints I, then D:
[[ 0 393 363 78]
[ 1 555 277 364]
[ 2 304 101 13]
[ 3 173 18 182]
[ 4 288 370 531]]
[[0.0000000e+00 7.1751709e+00 7.2076302e+00 7.2511597e+00]
[0.0000000e+00 6.3235626e+00 6.6845779e+00 6.7999420e+00]
[7.6293945e-06 5.7964134e+00 6.3917351e+00 7.2815094e+00]
[0.0000000e+00 7.2779064e+00 7.5279884e+00 7.6628456e+00]
[0.0000000e+00 6.7638016e+00 7.2951088e+00 7.3688164e+00]]
Column 0 of I is 0, 1, 2, 3, 4 and column 0 of D is 0; the 7.6e-06 in the third row is float32 rounding. The actual search answers the first queries with small ids, 381 and 207 for query 0, and the last ones with ids near 10,000, as the np.arange lines promised.
"D holds similarity scores." Not for this index. The session's slide speaks of "the similarity scores / distances" (p. 1701): an inner-product index does return scores, larger for closer, but IndexFlatL2 returns squared Euclidean distances, smallest first, as p. 689 says. No square root is taken, since it would not change the order. A D of 7.18 is a distance of about 2.68.
The exercise session tells the same story as a workflow — "Generate vector embeddings using a model", "Store these embeddings in a FAISS index", "Query the index" (p. 1698) — and its code (pp. 1699–1701) is the same three calls with d = 128, 1,000 vectors and five neighbours, D, I = index.search(xq, k=5), for a query of shape (1, d): even a single query is a matrix with one row. Its last sentence matters: "Optionally, we can query by IDs, just like in the majority of databases, but we must manage our own list of IDs." FAISS knows row numbers only. Which sentence, picture or customer hides behind id 17 is your business: the session's notebook keeps its sentences in a pandas DataFrame and maps the returned ids back with df["sentence"].iloc[indices[0]].
IndexIVFFlat: search only a few cells
Brute force compares the query with all nb vectors. To go faster, "it is possible to segment the dataset into pieces. We define Voronoi cells in the d-dimensional space" (p. 690). A cell is the region around a centroid that is closer to it than to any other centroid, and "At search time, only the database vectors y contained in the cell the query x falls in and a few neighboring ones are compared against the query vector." The same slide names the two prices of the trick. The index "requires a training stage", which places the centroids with k-means — the classic clustering algorithm, no labels involved — on "any collection of vectors that has the same distribution as the database vectors". And it "requires another index, the quantizer, that assigns vectors to Voronoi cells": finding a vector's cell is a nearest-neighbour search among the centroids, done by an IndexFlatL2.
nlist = 100
k = 4
quantizer = faiss.IndexFlatL2(d) # the other index
index = faiss.IndexIVFFlat(quantizer, d, nlist)
assert not index.is_trained
index.train(xb)
assert index.is_trained
index.add(xb) # add may be a bit slower as well
D, I = index.search(xq, k) # actual search
print(I[-5:]) # neighbors of the 5 last queries
index.nprobe = 10 # default nprobe is 1, try a few more
D, I = index.search(xq, k)
print(I[-5:]) # neighbors of the 5 last queries
The two assert lines show a fresh IVF index that is not trained, and a trained one after train(xb); adding before training stops with an error, because a vector has no cell to go to. add runs the quantizer on every vector and appends it to the list of its cell — the inverted file of the name, one list of vectors per centroid. search visits nprobe cells, one by default, then ten. The slide sums up the cost: "The search time roughly increases linearly with the number of probes plus some constant due to the quantization." Written out:
Find the cells to visit: compare the query with the nlist centroids. That is nlist distances whatever nprobe is — the constant "due to the quantization".
Scan the probed lists. With nb vectors in nlist cells, a list holds about nb / nlist of them, so nprobe lists cost
\[ \mathrm{nprobe}\cdot\frac{n_b}{\mathrm{nlist}} \]Add the two parts: linear in the number of probes, plus the constant.
\[ \mathrm{cost} \approx \mathrm{nlist} + \mathrm{nprobe}\cdot\frac{n_b}{\mathrm{nlist}} \]With the slide's numbers, nb = 100,000 and nlist = 100: one probe costs 100 + 1,000 = 1,100 distances per query instead of 100,000; ten probes cost 10,100.
The price is accuracy. It is measured with recall@k, the standard measure of approximate search (not on the slides): the share of the true k nearest neighbours, those of IndexFlatL2, that the approximate search returns, averaged over the queries.
- \(\mathrm{returned}\)
- the k ids the approximate index returns for a query
- \(\mathrm{true}_k\)
- the k ids
IndexFlatL2returns for the same query
Running the slide's code gives recall@4 = 0.654 at nprobe = 1 and 1.000 at nprobe = 10: with one probe, a third of the true neighbours sat in a cell that was never opened. Hence the comment "default nprobe is 1, try a few more".
The index lives in memory. "The index can be saved on disk using the write_index() function and can be loaded later using the using the [sic] read_index() function" (p. 692) — faiss.write_index(index, "tutorial.index") and faiss.read_index("tutorial.index"): persistence is a file you write, not a service. "Seamless use of GPUs" shows one line, res = faiss.StandardGpuResources(), which reserves a GPU in the GPU build of FAISS; the index still has to be moved there before add and search run on it.
The feature slides (pp. 681–684) complete the picture: approximate nearest-neighbour (ANN) search, "allowing for fast search times with a small trade-off in accuracy"; three index structures — "Flat Index" for exact search, "IVF (Inverted File Index)", "often used in conjunction with product quantization", and "HNSW", the graph of lesson 27; Euclidean and cosine metrics; CPU and GPU; "millions or even billions of vectors"; free and open source, usable from Python and C++. "FAISS Processes" (p. 683) are three steps: indexing, an optional quantization, search. The quantization is product quantization: the session's notebook builds IndexIVFPQ(quantizer, d, nlist, m, 8), which stores a vector of 64 numbers as m = 8 codes of 8 bits — 8 bytes instead of 256. Its train(xb) learns the codebooks of those codes as well as the cells: an IVF or PQ index is trained before add, a flat one never.
What a database adds: Pinecone
FAISS answers one question: which stored rows are closest to this query. Put it behind the search box of a shop with a million customers and new questions appear. Who may read the index? What happens when the machine dies? Where do new vectors go while others are searching? Which product hides behind id 17? The slides answer with Pinecone's description of a vector database (pp. 695–697).
It works in three stages (p. 695). "Indexing: The vector database indexes vectors using an algorithm such as PQ, LSH, or HNSW to a data structure that will enable faster searching." "Querying: The vector database compares the indexed query vector to the indexed vectors in the dataset to find the nearest neighbors (applying a similarity metric used by that index)." "Post Processing: The vector database post-processes the results to return the final results. This step can include re-ranking the nearest neighbors using a different similarity measure." The first two are what FAISS does. The third is new: the approximate candidates can be re-ranked with another measure, and they leave as finished answers with their stored data, not as bare row numbers. The "indexed query vector" is not stored: it is encoded the way the index needs — assigned to cells, hashed — and then compared.
Around the stages come five capabilities (p. 696): "Distribution", "Scalability", "Access control", "Fault-tolerance" and "Monitoring". The figure on the same slide shows where each lives (Fig. 2, right). Every client, SDK or console, passes an access-control layer and reaches one API. A write goes through a shard router to its shard in every replica; a read goes through scatter-gather, which asks every shard of one replica. Shards give distribution and scalability, replicas give fault tolerance, and monitoring and backups sit beside them. The slide's figure also draws "Collections" next to the API: in Pinecone a collection is a saved snapshot of an index, not the table-like collection of Milvus in section 5. And Pinecone's own article, the source of these pages, lists more than five capabilities — metadata filtering and real-time updates among them — which come back in Milvus below.
Before: an index inside your program
After: a vector database around the index (p. 696)
write_index survives the process. Right: the architecture of p. 696 with three replicas of an index split into three shards (the slide prints every shard as "Shard 1"): writes reach every replica through the shard router, reads fan out from scatter-gather to the shards of one replica, and every client passes access control.Sharding, replication, scatter-gather
Page 697 names the two techniques. "Sharding - partitioning the data across multiple nodes. There are different methods for partitioning the data - for example, it can be partitioned by the similarity of different clusters of data so that similar vectors are stored in the same partition. When a query is made, it is sent to all the shards and the results are retrieved and combined. This is called the "scatter-gather" pattern." And "Replication - creating multiple copies of the data across different nodes. This ensures that even if a particular node fails, other nodes will be able to replace it." They are the sharding and replication of lesson 3 applied to vectors, and scatter-gather is the broadcast query of MongoDB's mongos in lesson 16.
Why ask every shard? A vector has no key that names the shard holding its neighbours: even when shards are similarity clusters, the nearest vector may sit in the next one. So each shard returns its own top k and the gather step merges them. Each shard must return k, not fewer: a vector of the global top k is certainly in the top k of its own shard, but it may be second there, or k-th (Fig. 3).
Pinecone in practice
The usage slides (pp. 698–700) are screenshots of Pinecone's web console. An index has a name, langchain-demo, a "Dimension: 768" and a metric, cosine in the form for a new index — and the form says where both come from: "The dimensions and metric depend on the model you select." The dimension must equal the length of the vectors the embedding model produces, and the metric must be the one the model was trained with; an index of dimension 768 refuses a vector of 384 numbers. Access goes through an API key, listed with its environment, us-west4-gcp-free.
The coding slides (pp. 701–702, the second titled "Pinecode coding") run a retrieval pipeline with LangChain: "Import text / Initialize / Select index / Load data", then "Split text in chunks / Vectorize / Similarity search".
from langchain.document_loaders import TextLoader
from langchain.text_splitter import CharacterTextSplitter
import pinecone
from langchain.vectorstores import Pinecone
from langchain.embeddings import HuggingFaceEmbeddings
# Initializing Pinecone Vector DB
pinecone.init(
api_key=PINECONE_API_KEY,
environment=PINECONE_ENV
)
# Pinecone Vector DB index name
index_name = 'langchain-demo'
index = pinecone.Index(index_name)
loader = TextLoader("PLACE FILE PATH HERE")
docs = loader.load()
text_splitter = CharacterTextSplitter(
chunk_size=1000, # Specify chunk size
chunk_overlap=200, # Specify chunk overlap to prevent loss of information
)
docs_split = text_splitter.split_documents(docs)
embeddings = HuggingFaceEmbeddings()
# create new embedding to upsert in vector store
doc_db = Pinecone.from_documents(
docs_split,
embeddings,
index_name=index_name
)
query = "PLACE USER QUERY HERE"
# search for matched entities and return score
search_docs = doc_db.similarity_search_with_score(query)
Read it as a recipe. Load a text file. Cut it into chunks of 1,000 characters that overlap by 200, so that a sentence cut at a boundary survives whole in the next chunk. from_documents turns every chunk into a vector with the HuggingFace model and upserts vectors and text into the index. At question time the query is embedded by the same model, and similarity_search_with_score returns the closest chunks with their scores — the material a chatbot puts in front of its LLM. These are the client calls of 2023: today's client is created with Pinecone(api_key=...) and has no environment, but the pipeline is the same, and the exam is matched against the slides.
Inside Milvus: the log is the data
Zilliz is "A leading technology company focused on unstructured data management and AI-powered analytics" (p. 704). Milvus is "An open-source vector database developed by Zilliz", "Designed for scalability, offering performance from small projects to enterprise-level deployments" (p. 705): from Milvus Lite, one local file and the version of the exercise session, to a cluster of machines. Zilliz also rents Milvus as a cloud service, and the "Zilliz query" of the exam is the Milvus client talking to it.
Its "Design Principles" (p. 706) are six ideas printed as seven bullets — "Fully depends on mature storage" wraps onto a bullet of its own, "systems":
- "Disaggregate storage and computation": nodes compute, durable data lives in separate stores, so a node can die or be added at any time.
- "Fully depends on mature storage systems": etcd for metadata, Kafka or Pulsar for the log, MinIO, S3 or Azure Blob for files.
- "Micro Service - scale by functionality": separate nodes answer searches, persist data and build indexes, each scaled on its own.
- "Separate Streaming and historical data": fresh inserts and older data are stored and searched differently, as below.
- "Pluggable engine, storage and index": each part can be swapped.
- "Log As data": the log of changes is the primary copy; everything else is derived from it.
The architecture figure (p. 707, redrawn in Fig. 4) puts the principles on four floors. A client's SDK reaches a proxy through a load balancer. Definitions and permissions — DDL/DCL, such as creating a collection, an index or a user — go to the coordinators (root, query, data and index), which keep the catalogue in etcd and steer the workers. Data changes — DML, inserts and deletes — go to the log broker, Kafka or Pulsar. An insert is safe once the log has it. Data nodes consume the log and flush it to object storage, index nodes build index files there, and query nodes answer searches from the newest log entries and from the stored segments and indexes. Reads and writes never wait for each other on the same node.
Growing and sealed segments
Pages 709–710 zoom into one shard. "Each shard is managed by a supervisor (shard leader)", responsible for "Adding new information to the shard", "Regularly storing the data in a safe place (object storage)", "Serving the latest information for search requests" and "Forwarding historical data requests to other cabinets (query nodes) if needed" — four duties, printed as seven bullets. "Other cabinets (query nodes)" tells you that the leader is itself a query node; in the architecture of p. 707 the storing itself is done by the data nodes. Its vectors sit in segments of two kinds (p. 710):
Growing segment — "In-memory segment replaying data from the Log Broker" that "Uses a FLAT index to ensure data is fresh and appendable".
Sealed segment — "Immutable segment using alternative indexing methods for efficiency".
Write-Ahead Log (WAL) — "When you add new data, a proxy service writes it to a temporary log called a WAL (e.g., Kafka, Pulsar). Think of it as a to-do list for the datanodes."
Why two kinds? An index such as IVF or HNSW is expensive to build and awkward to change, so it is built only on data that has stopped changing. New vectors wait in a small growing segment searched by brute force — the IndexFlatL2 of section 2 — and are searchable at once. When the segment is full it is sealed, flushed to object storage and indexed by an index node. A search scans the growing segment, uses the indexes of the sealed ones and merges the two lists: "Separate Streaming and historical data" at work.
Milvus Lite: collections, search, query, get
The exercise session works with Milvus Lite, "the lightweight version of Milvus", which runs inside your Python program; the slide lists what it offers: "Insert/upsert operations, Vector data persistence and collection management, Dense, sparse, and hybrid vector search, Metadata filtering, Multi-vector support" (p. 1702). "Since Milvus Lite runs locally, it requires a file where to store data", and "If the file doesn't exist, Milvus Lite creates it" (p. 1703):
# Connect to local database file (created if not exists)
client = MilvusClient("./milvus_data.db")
Vectors live in a collection, "the logical container for our vectors" (p. 1704) and the Milvus counterpart of a table: each entity is a row, each field a column. "NB: Its dimension must match the size of the vectors."
client.create_collection(
collection_name="demo_collection",
dimension=128 # vector dimension
)
This is the quick setup: it creates a primary key id and a vector field vector, uses the cosine metric, and accepts extra keys in a row as dynamic fields. A custom setup declares the schema and one index per vector field, as the session's search notebook does. For the pilots of the exam it could read as follows; the field names are ours, taken from the diagram, and the career text is stored as a 384-number embedding, the size the session's sentence model produces.
schema = MilvusClient.create_schema(auto_id = False, enable_dynamic_field = True)
schema.add_field(field_name = "id", datatype = DataType.INT64, is_primary = True)
schema.add_field(field_name = "name", datatype = DataType.VARCHAR, max_length = 100)
schema.add_field(field_name = "championships_won", datatype = DataType.INT64)
schema.add_field(field_name = "career_vector", datatype = DataType.FLOAT_VECTOR, dim = 384)
index_params = client.prepare_index_params()
index_params.add_index(field_name = "career_vector", index_type = "AUTOINDEX", metric_type = "IP")
client.create_collection(collection_name = "pilot_collection", schema = schema, index_params = index_params)
Writing: insert, upsert, delete, drop
"The effect of inserting a vector into the collection is that it is persistently stored in the local file" (p. 1705). The slide's code needs one correction.
client.insert(
collection_name = "pilot_collection",
data = [{"id": 101, "name": "Michael", "championships_won": 7, "career_vector": [0.12, ...]}]
)
upsert takes the same rows and "inserts each record into the collection if it doesn't already exist, or updates the existing record if the id matches", in the words of the session's notebook:
# id 101 exists: its row is replaced
client.upsert(
collection_name = "pilot_collection",
data = [{"id": 101, "name": "Michael", "championships_won": 8, "career_vector": [0.12, ...]}]
)
A delete selects by ids or by a filter, and a drop removes the collection with all its data (p. 1708):
client.delete(
collection_name="demo_collection",
ids=[123, 456]
)
client.delete(
collection_name="demo_collection",
filter="price < 20 and category == 'books'"
)
client.drop_collection("demo_collection")
The slide's "delete data by first selecting the elements and then deleting them" describes one call: the selection, ids=[123, 456] or a filter, is its argument.
Reading: search, query, get
"To perform a search, we can decide to follow 2 main ways" (p. 1706). The first is the vector similarity search, which "Finds vectors closest to query embeddings":
query_vector = [0.1, 0.2, 0.3, ...]
results = client.search(
collection_name="demo_collection",
data=[query_vector],
limit=5 # number of nearest neighbors
)
The second is the scalar query, which "Returns all entities that match the filter conditions" and needs no vector at all; the query by id is "a specialization of the scalar query":
results = client.query(
collection_name="demo_collection",
filter="price > 50 and category == 'books'"
)
client.get(
collection_name="demo_collection",
ids=[123, 456]
)
Note the brackets around query_vector: data is a list of query vectors, and the answer holds one list of hits per query vector. A hit prints as {'id': 101, 'distance': 0.952, 'entity': {'name': 'Michael'}}; for the IP and COSINE metrics a larger "distance" means more similar.
Combine the two ways and you get what the session calls a hybrid search: "By combining a vector similarity search and a scalar query, we obtain a hybrid search" (p. 1707).
# Hybrid search
results = client.search(
collection_name="demo_collection",
data=[query_vector], # Vector similarity search
filter="price > 50 and category == 'books'", # Scalar query
limit=5
)
Read it in order: the filter keeps the books above 50, the vector ranks them, the limit keeps five — the five books above 50 most similar to the query, never a cheaper book however similar.
Milvus's own documentation calls this a filtered search, and keeps "hybrid search" for client.hybrid_search: one search per vector field, merged by a ranker. The session's advanced notebook does that for films with two vector fields, filmVector and posterVector, weighted 0.8 and 0.2 by WeightedRanker(0.8, 0.2). At the exam use the deck's meaning, and name the other if a question asks.
client.search used on the slides and in the session's notebooks.| Argument | What it does | Example |
|---|---|---|
collection_name | the collection to search | "pilot_collection" |
data | a list of query vectors; one list of hits per vector | [query_vector] |
limit | hits per query vector — groups, when grouping; 10 if omitted | 5 |
search_params | the metric of the vector field's index, and its tuning knobs | {"metric_type": "IP", "params": {"level": 1}} |
filter | a condition on scalar fields; only matching entities are ranked | 'name like "Mic%"' |
output_fields | the fields copied into each hit's entity | ["name"] |
group_by_field | one hit per distinct value of a field: the most similar entity of each group | "championships_won" |
anns_field | the vector field to search, when there are several | "career_vector" |
Filters are boolean expressions over scalar fields, written as a Python string: comparisons such as ==, > and <; in and not in a list; like with % as wildcard — 'color_tag like "red%"' for a prefix, 'color like "%l%"' for "contains l"; joined with and and or and grouped with parentheses. The only aggregate the session uses is a count, through client.query:
client.query(
collection_name = "pilot_collection",
filter = 'name like "Mic%" and championships_won == 2',
output_fields = ["count(*)"]
)
The session's other notebooks go further — JSON and ARRAY fields filtered with functions such as ARRAY_CONTAINS_ALL(ages, [11, 12]), sparse and binary vectors, partitions and aliases, and iterators that page through large results with batch_size and .next(). They are background for this lesson.
The solution notebook of the session's exercise has three slips worth not copying. group_by_field = "id" groups by the primary key, so every group holds one entity and nothing changes: group by a field whose values repeat. The task asking for pets "that are close to a random pet_vector" is answered with client.query(..., data = [pet_vector], ...), but query takes no vector: closeness needs client.search with a filter. And the two client.get calls meant for the partitions "savannah" and "artic" both pass partition = "savannah"; the parameter is partition_names = ["savannah"], and the second call should name "artic".
Three blank lines, one point
Back to the hook. In the exam of 11 February 2025 a pilot is a Team Member, itself a Person (Personal_ID, Name, Surname, Birth_Date, Gender), with Championships_Won and Career (Textual) of its own. A vector collection keeps the career text as an embedding and the other attributes as scalar fields — as in the custom setup above, and in the small collection of the widget.
The three sentences of the exercise map onto three arguments:
pilots = client.search (
collection_name = "pilot_collection",
data = [...],
search_params = {"metric_type": "IP", "params": {"level": 1}},
filter = 'name like "Mic%"',
group_by_field = "championships_won",
limit = 5
)
"whose name starts with "Mic"" is the filter, with like and the % wildcard; "each given number of championships" is group_by_field; "only the first 5 results" is limit = 5, which with grouping counts groups. The given search_params name the metric of the vector index, IP, and level, a precision setting of Zilliz Cloud's automatic index.
And the harder question: no. client.search ranks entities by similarity. Grouping returns, for each value of championships_won, the single most similar pilot of that group — a representative, not a head count. In the widget the five hits carry the values 7, 2, 1, 0 and 3; Michela and Micaela, the two pilots with two championships, come back as one hit, and their head count, 2, is nowhere in the answer. Counting is a scalar query: client.query with output_fields = ["count(*)"] and a filter per value, as above, which answers [{'count(*)': 2}] for two championships in the widget's collection. At the exam, write the three lines — they are what client.search offers closest to the text — and add one sentence saying that grouping returns one pilot per value, not a count. No solutions of past papers are published: this answer is ours.
The filter narrows, the vector ranks, limit cuts, group_by_field keeps one per value — and nothing in client.search counts.
Exam-style questions
The vector-DB exercise of the past papers, one exercise in its style on the same diagram, and five theory questions in the forms of the written exam. The 2026/27 rules list Neo4j, MongoDB, Elasticsearch, Redis and SparkQL for the practical part, and the Zilliz fill-in appeared in one of the nine past papers; the theory part covers the content of the theoretical and practical lectures. No solutions are published: the model answers are ours.
Exam of 11 Feb 2025, exercise 2 — Vector DB (1 PT). ER (tracking Formula 1 races), the part used: Pilot, a child of Team Member (P, E), itself a child of Person (T, E) — Person (Personal_ID, Name, Surname, Birth_Date, Gender); Pilot adds Championships_Won and Career (Textual).
"Complete the following Zilliz query that considers the pilots whose name starts with "Mic", and computes how many pilots won each given number of championships. The results will be in the form: (NumberOfWonChampionships, NumberOfWinningPilots). Return only the first 5 results."
pilots = client.search (
collection_name = "pilot_collection",
data = [...],
search_params = {"metric_type": "IP", "params": {"level": 1}},
___________ = _____________________________________________
___________ = _____________________________________________
___________ = _____________________________________________
)
Show model answer
filter = 'name like "Mic%"',
group_by_field = "championships_won",
limit = 5
The filter keeps the pilots whose name starts with "Mic" (like with the % wildcard), group_by_field returns one result for each distinct number of championships won, and limit = 5 keeps the first five groups. A grouping search returns one representative pilot per value, not the number of pilots: the count per value would need client.query with output_fields = ["count(*)"] and a filter on each value.
What the grader most plausibly looks for: the three keyword names as pymilvus spells them, a like pattern with % inside a Python string, field names that match the collection, and 5. No marking scheme is published, so the one-sentence caveat is cheap insurance: it shows that you know grouping returns one pilot per value rather than the count the text asks for. The other line a grader might have in mind is output_fields = ["championships_won"], which prints the value next to each hit; but in place of group_by_field it groups nothing, and in place of limit it ignores "Return only the first 5 results". Filter, grouping and limit match the three sentences one to one.
In the style of the exam of 11 Feb 2025. ER (tracking Formula 1 races), the part used: Team Owner, a child of Person (T, E) — Person (Personal_ID, Name, Surname, Birth_Date, Gender); Team Owner adds Total_Assets and Biography (Textual). The collection owner_collection stores the embedding of the biography as its vector field.
Complete the following Zilliz query that returns the name and the total assets of the 3 team owners whose biography is most similar to the given vector, considering only the owners whose total assets exceed 1000000.
owners = client.search (
collection_name = "owner_collection",
data = [...],
search_params = {"metric_type": "IP", "params": {"level": 1}},
___________ = _____________________________________________
___________ = _____________________________________________
___________ = _____________________________________________
)
Show model answer
filter = "total_assets > 1000000",
output_fields = ["name", "total_assets"],
limit = 3
The filter restricts the candidates to the owners with total assets above 1000000 before the ranking, output_fields returns the name and the total assets of each hit, and limit = 3 keeps the three most similar biographies.
The same three blanks, filled differently: a comparison instead of like, output_fields because the text says what to return, and limit for "the 3 … most similar". The similarity itself is already in data and search_params: never put the vector condition into the filter.
True or false: "FAISS is a vector database."
Show model answer
False. FAISS is a library for efficient similarity search and clustering of dense vectors: it stores vectors in an index in memory and returns the ids and distances of the nearest neighbours. A vector database such as Pinecone or Milvus wraps an index with distribution, scalability, access control, fault tolerance and monitoring.
A true/false item may carry a penalty when wrong, so the reason matters before the click: "library" against "database" is exactly the line drawn on p. 678 and p. 696.
In FAISS, an IndexIVFFlat is built with nlist = 100 and searched first with nprobe = 1, then with nprobe = 10. Explain what nlist and nprobe are, how search time and accuracy change when nprobe grows, and which of the two can be changed without rebuilding the index.
Show model answer
nlist is the number of Voronoi cells into which IndexIVFFlat partitions the space: training runs k-means on the database vectors to place nlist centroids, and each vector is stored in the list of its nearest centroid. nprobe is the number of cells, out of nlist, that a search visits: the query is compared with the centroids, and only the vectors of the nprobe nearest cells are compared with it; the default is 1. The cost per query is about nlist + nprobe × nb / nlist distance computations, so the search time grows roughly linearly with nprobe, while the accuracy — the recall, the share of the true nearest neighbours that are found — rises towards that of exact search. Only nprobe can change without rebuilding: it is an attribute set between searches, whereas a new nlist means new centroids, a new training and adding every vector again.
Three things earn the marks: the two definitions, the direction of the trade-off (more probes, more time, higher recall), and which knob is a search-time setting. The slide calls both "parameters to the search method"; say clearly that nlist is fixed at training. A number makes the trade-off concrete if you remember one: running the slide's code, recall@4 goes from 0.654 with one probe to 1.000 with ten (computed for this notebook, not printed on the slides).
Explain the difference between a vector index such as FAISS and a vector database such as Pinecone or Milvus. Describe the scatter-gather pattern and explain why every shard must return k results.
Show model answer
A vector index such as FAISS is a library: it keeps vectors in memory inside a program and returns the nearest neighbours of a query, with ids that are row numbers; there is no server, no metadata, and persistence is a file. A vector database wraps an index with the capabilities production needs — distribution, scalability, access control, fault tolerance and monitoring — and offers an API, persistence and post-processing of the results, such as re-ranking and filters. Its data is sharded across nodes. Since no key tells which shard holds a query's neighbours, the query is sent to all shards (scatter), each shard returns its local top k, and the results are merged into the global top k (gather). Each shard must return k results because a vector of the global top k can be anywhere in the top k of its own shard; with fewer results per shard a true neighbour could be lost. Replication keeps copies of the data on other nodes, so a failed node is replaced.
Name at least four of the five capabilities in the slide's words, describe scatter-gather as two moves, and give the reason for k per shard. Linking scatter-gather to the broadcast query of MongoDB shows that you see the general pattern.
In Milvus, what are a growing segment, a sealed segment and the write-ahead log? Which design principles of Milvus do they implement, and why are fresh vectors not indexed with IVF or HNSW straight away?
Show model answer
Every insert is first written by a proxy to the write-ahead log, Kafka or Pulsar — a to-do list for the data nodes. A growing segment is an in-memory segment that replays the shard's data from the log broker; it uses a FLAT index, that is brute force, so that fresh data is searchable at once and can still be appended. When it is full it is sealed: it becomes immutable, is flushed to object storage, and an index node builds an efficient index for it, such as IVF or HNSW. A search scans the growing segment, uses the indexes of the sealed segments, and merges the results. This implements "Separate Streaming and historical data" and "Log As data", and relies on "Disaggregate storage and computation", since nodes rebuild their state from the log and from object storage. Fresh vectors are not indexed straight away because such indexes are costly to build and hard to update: they are built once, on data that no longer changes.
The examiner checks the two segment kinds with their index (FLAT against an efficient one), the role of the log as the first and primary copy, and the "why": an index needs data that stopped changing. The comparison with Cassandra's commit log, memtable and SSTables is a welcome extra.
In Milvus Lite you need every entity of demo_collection with a price above 50 in the category 'books', and you have no query vector. Which call fits?
Show model answer
client.query with the filter: a scalar query needs no vector and returns all the entities that match the condition. client.search would need a query vector and rank the matches, client.get selects by id, and client.delete removes.
Search, query, get: the three verbs of p. 1706. The key distinction is whether a vector is involved; "all entities that match" is the scalar query.
Check yourself
You build IndexIVFFlat(quantizer, d, 100) and call add(xb) straight away. What happens?
On a trained IndexIVFFlat, which of these can change between two searches without training or adding again? Select all that apply.
What the index was built with stays; what the search is asked can change every time.
A vector database has 4 shards and is asked for the top 10. How many candidates does the gather step merge?
A Pinecone index was created with dimension 768; your embedding model produces vectors of 384 numbers. What happens at the first upsert?
A Milvus search has filter="price > 50" and limit=5, but only 3 entities have a price above 50. How many hits come back?
Why does a Milvus growing segment use a FLAT index?
A search with group_by_field="championships_won" and limit=5 returns…
This is the honest caveat of the exam fill-in: grouping picks, it does not count.
Key take-aways
- Vector systems range from libraries such as FAISS to managed databases such as Pinecone, and a vector database can be native or integrated in another database, as in Neo4j (p. 715); what a database adds to an index is distribution, scalability, access control, fault tolerance and monitoring (p. 696).
- FAISS works on float32 matrices of a fixed dimension:
IndexFlatL2(d),add(xb)andsearch(xq, k), which returns D, the squared distances, and I, the row ids, both of shape nq × k; the record behind an id is yours to keep. IndexIVFFlattrains nlist k-means centroids and scans only nprobe cells per query, at a cost of about nlist + nprobe × nb / nlist distances; recall grows with nprobe, which is set at search time, while nlist is fixed by training.- A vector database indexes, queries and post-processes; it shards its data and answers by scatter-gather, each shard returning its local top k, and it replicates the data so that a failed node is replaced.
- Milvus separates storage from computation and treats the log as the data: inserts go to the write-ahead log, growing segments in memory are searched with FLAT, and sealed segments get their index from an index node; query, data and index nodes scale separately.
- In Milvus Lite a collection has a dimension, rows are inserted as dictionaries, and reading is
searchwith a vector (limit,filter,output_fields,group_by_field),querywith a filter andcount(*), orgetby ids; search plus filter is the deck's hybrid search, and grouping returns one entity per value, never a count.
What to watch and read
-
Faiss wiki: Getting started
Meta AI, the Faiss team
Pages 685–689 are taken from it almost word for word, with the printed output of the sanity check. Read it next to section 2, and go on to its "Faster search" page for IndexIVFFlat and nprobe.
-
What is a Vector Database & How Does it Work?
Roie Schwaber-Cohen, Pinecone
The source of pp. 695–697: the three stages, the five capabilities and the scatter-gather sentence, with the reasons a library such as FAISS is not enough in production.
-
Grouping Search
Milvus documentation
The exam drill behind section 6: limit counts groups, and a group returns one entity unless you ask for more. Its sister pages "Filtered Search" and "Query" cover the filter grammar and
count(*). -
Milvus Architecture Overview
Milvus documentation
Where pp. 706–710 come from, in today's version: proxies, coordinators, worker nodes, the WAL, growing and sealed segments. Newer releases merge some coordinators; the exam follows the slides.
-
Vector indexes
Neo4j Cypher Manual
The concrete side of pp. 712–713: creating a vector index on a node property and querying it, then walking the graph from the hits with ordinary Cypher.