Vector Databases & AI Integration
High-dimensional embeddings, from pgvector to dedicated engines.
The AI Database Revolution
Traditional databases excel at exact matches: "find user with ID 42" or "products under $100." But modern AI applications need to answer questions like "find images similar to this one," "recommend products based on user behavior," or "retrieve documents semantically related to a query." These tasks require comparing high-dimensional vectors (embeddings) where each data point is represented as a list of 384 to 1536+ floating-point numbers. Brute-force comparison against every stored vector costs time that grows linearly with the corpus: measured on a 500,000-row table of 1536-dimension vectors with no index, a single sequential scan took about a second, so 10 million vectors lands around 20 seconds for one query, worse under any concurrent load. Vector databases solve this with specialized indexing algorithms (HNSW, IVF-PQ) that trade a small amount of recall for search time closer to O(log n), enabling millisecond queries across billions of embeddings. This lesson covers Pinecone (managed, production-ready), Milvus (open-source, horizontally scalable), and Weaviate (schema-based with GraphQL). You'll learn how embeddings work, similarity search algorithms, RAG (Retrieval Augmented Generation) patterns, and integrating vector search with traditional databases for hybrid search systems.
Understanding Embeddings
Embeddings are numerical representations of data (text, images, audio) in high-dimensional space where semantically similar items are close together. "Dog" and "puppy" have similar embeddings; "dog" and "spaceship" are far apart.
From Text to Vectors
Traditional keyword search:
- Query: "king" → Exact match only
- Misses: "monarch," "ruler," "sovereign" (synonyms)
- No semantic understanding
Vector embeddings:
- "king" → [0.24, -0.15, 0.89, ..., 0.42] (384 dimensions)
- "monarch" → [0.22, -0.14, 0.91, ..., 0.40] (very similar!)
- "pizza" → [-0.62, 0.73, -0.11, ..., -0.88] (far apart)
Generating Embeddings with OpenAI
# Install: pip install openai
from openai import OpenAI
client = OpenAI(api_key="sk-...") # or read OPENAI_API_KEY from the environment
# Generate text embedding
response = client.embeddings.create(
model="text-embedding-3-small", # 1536 dimensions
input="The quick brown fox jumps over the lazy dog"
)
embedding = response.data[0].embedding
print(f"Embedding dimensions: {len(embedding)}")
print(f"First 5 values: {embedding[:5]}")Expected Output:
Embedding dimensions: 1536 First 5 values: [-0.020843505859375, -0.0168914794921875, -0.00450897216796875, -0.050872802734375, -0.0259552001953125]
The dimension count is the contract: text-embedding-3-small always returns 1536 floats, and that number is what every index, column, and schema downstream has to agree on. The individual values are not something to read meaning into, and they are not stable across models: swapping in a different embedding model gives entirely different numbers, which is why re-embedding an existing corpus is a full rebuild rather than an incremental change.
Why Naive Vector Storage Fails
- Brute-force search: without a vector-aware index, every query must compare against every stored vector (O(n))
- B-trees don't help: a standard index accelerates equality and range lookups, not nearest-neighbour similarity
- Performance collapse: 10M vectors x 1536 dimensions means billions of floating-point comparisons per query; measured by scaling a 500,000-row benchmark, that lands around 20 seconds for a single query
- The fix isn't a new database: it's a vector-aware index. pgvector adds exactly that to PostgreSQL itself, covered later in this lesson
Pinecone: Managed Vector Database
Pinecone is a fully managed vector database optimized for production AI applications. No infrastructure setup, automatic scaling, and sub-50ms queries at billion-vector scale.
Step 1: Run Pinecone Locally
Pinecone is a hosted service with no self-managed edition, so the usual way in is a cloud account. For learning it, Pinecone publishes Pinecone Local: an emulator that speaks the same API and works with the same SDK, in a container. Everything in this section runs against it.
# Pinecone Local is the official in-memory emulator: the same API and the # same client SDK, no account and no API key. It stores nothing on disk, # so every restart gives you an empty instance. docker run -d --name pinecone-local \ -e PORT=5080 -e PINECONE_HOST=localhost \ -p 5080-5090:5080-5090 --platform linux/amd64 \ ghcr.io/pinecone-io/pinecone-local:latest # 5080 is the control plane (create/describe/delete index). Each index you # create gets its own port from the mapped range, starting at 5081, which # is why the range and not just one port has to be published. curl -s http://localhost:5080/indexes # Finished? Remove it, it holds no state you need: # docker rm -f pinecone-local python3 -m venv .venv && source .venv/bin/activate pip install pinecone sentence-transformers
Expected Output:
{"indexes":[]}It is an emulator, so treat it as one: it holds everything in memory, it does not enforce authentication, and it makes no attempt to reproduce the real service's latency or scaling behaviour. What it does reproduce faithfully is the API surface, which is the part you are here to learn.
Step 2: Create the Index
from pinecone import Pinecone, ServerlessSpec
# Pinecone Local ignores the key, but the SDK insists one is present.
# Against the real service you would drop host= and pass your own key.
pc = Pinecone(api_key="pclocal", host="http://localhost:5080")
pc.create_index(
name="semantic-search",
dimension=384, # must match the embedding model exactly
metric="cosine", # cosine, euclidean or dotproduct
spec=ServerlessSpec(cloud="aws", region="us-east-1"),
)
desc = pc.describe_index("semantic-search")
print(f"name={desc.name} dimension={desc.dimension} metric={desc.metric}")
print(f"host={desc.host} ready={desc.status.ready}")Expected Output:
name=semantic-search dimension=384 metric=cosine host=https://localhost:5081 ready=True
dimension is permanent: it is fixed when the index is created, and every vector written afterwards must match it exactly. Choosing it means choosing an embedding model, and changing models later means creating a new index and re-embedding everything. The ServerlessSpec cloud and region are accepted and ignored by the emulator; against the real service they decide where the index physically lives.
Step 3: Insert Vectors (Upsert)
from pinecone import Pinecone
from sentence_transformers import SentenceTransformer
# A real embedding model, 384 dimensions, runs locally with no API key.
# Swap this for the OpenAI call shown earlier and the index becomes 1536-dim.
model = SentenceTransformer("all-MiniLM-L6-v2")
pc = Pinecone(api_key="pclocal", host="http://localhost:5080")
# Pinecone Local advertises an https:// host but serves plain HTTP.
index_host = pc.describe_index("semantic-search").host.replace("https://", "http://")
index = pc.Index(host=index_host)
docs = [
("doc1", "Python is a programming language", "tech"),
("doc2", "Dogs are loyal companions", "animals"),
("doc3", "JavaScript powers web applications", "tech"),
]
vectors = [
{
"id": doc_id,
"values": model.encode(text).tolist(),
"metadata": {"text": text, "category": category},
}
for doc_id, text, category in docs
]
# Upsert = insert or overwrite by id. Re-running this is safe.
index.upsert(vectors=vectors)
print(f"Upserted {len(vectors)} vectors")
print(index.describe_index_stats())Expected Output:
Upserted 3 vectors DescribeIndexStatsResponse(dimension=384, total_vector_count=3, namespaces=1)
The vector is the searchable part; metadata is the payload that comes back with it, and it is what saves you a round trip to another database for the text you actually want to display. Note the model here is all-MiniLM-L6-v2 at 384 dimensions rather than OpenAI's 1536: it downloads once and runs locally, so this section needs no API key. The Pinecone code is identical either way, only dimension changes.
Step 4: Similarity Search
from pinecone import Pinecone
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("all-MiniLM-L6-v2")
pc = Pinecone(api_key="pclocal", host="http://localhost:5080")
index_host = pc.describe_index("semantic-search").host.replace("https://", "http://")
index = pc.Index(host=index_host)
# Note: not one word of this query appears in any stored document.
query_text = "What are the best coding languages?"
query_embedding = model.encode(query_text).tolist()
results = index.query(vector=query_embedding, top_k=3, include_metadata=True)
for match in results["matches"]:
print(f"Score: {match['score']:.4f}")
print(f"Text: {match['metadata']['text']}")
print(f"Category: {match['metadata']['category']}")
print()Expected Output:
Score: 0.5048 Text: Python is a programming language Category: tech Score: 0.1733 Text: JavaScript powers web applications Category: tech Score: 0.0847 Text: Dogs are loyal companions Category: animals
This is the result that keyword search cannot produce. The query is "What are the best coding languages?" and the word coding appears in none of the three documents, so an ILIKE or a tsquery would return nothing at all. The two programming documents still come back first, ranked 0.5048 and 0.1733, with the dog trailing at 0.0847. Also worth noticing: nearest-neighbour search always returns top_k results if it has them, so the irrelevant dog is still in the list. Ranking is not filtering, and a similarity score is not a relevance guarantee: production code sets a score threshold and discards what falls below it.
Step 5: Filtered Search (Hybrid)
from pinecone import Pinecone
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("all-MiniLM-L6-v2")
pc = Pinecone(api_key="pclocal", host="http://localhost:5080")
index_host = pc.describe_index("semantic-search").host.replace("https://", "http://")
index = pc.Index(host=index_host)
query_embedding = model.encode("What are the best coding languages?").tolist()
# The filter is applied during the search, not after it: asking for
# top_k=10 still returns only vectors whose metadata matches.
results = index.query(
vector=query_embedding,
top_k=10,
filter={"category": {"$eq": "tech"}},
include_metadata=True,
)
for match in results["matches"]:
print(f"{match['score']:.4f}: {match['metadata']['text']}")Expected Output:
0.5048: Python is a programming language 0.1733: JavaScript powers web applications
The animals document is gone and the two tech scores are unchanged, which is the point: the filter narrows the candidate set rather than trimming a finished result list, so top_k still counts only matching vectors. This is Pinecone's answer to the query shape pgvector expresses as an ordinary WHERE clause, and the comparison is worth holding onto: here the filter language is a JSON dialect with its own operators and its own limits, and it can only ever see metadata you copied into the index.
Milvus: Open-Source Vector Database
Milvus is an open-source vector database that supports horizontal scaling, multiple index types, and GPU acceleration. Ideal for self-hosted, high-scale deployments.
Step 1: Run Milvus Locally
Unlike Pinecone, Milvus is genuinely self-hosted, so this is the real thing rather than an emulator. Its production topology is distributed, but the standalone image can embed etcd and write to local disk, which is enough to run everything below in a single container.
# Milvus normally runs as three services (Milvus, etcd for metadata and # MinIO for object storage). The standalone image can embed etcd and use # local disk instead, which collapses the whole thing into one container. docker run -d --name milvus-standalone \ -p 19530:19530 -p 9091:9091 \ -e ETCD_USE_EMBED=true \ -e ETCD_DATA_DIR=/var/lib/milvus/etcd \ -e COMMON_STORAGETYPE=local \ milvusdb/milvus:v2.5.4 milvus run standalone # 19530 is the gRPC port the SDK uses; 9091 serves health and metrics. # Startup takes ~30s, and the client will refuse connections until then. curl -s http://localhost:9091/healthz # Finished? Remove it, it holds no state you need: # docker rm -f milvus-standalone python3 -m venv .venv && source .venv/bin/activate pip install pymilvus sentence-transformers
Expected Output:
OK
That collapsing is exactly what you give up in production: a real deployment runs etcd and object storage as separate services precisely so metadata, storage, and query nodes can be scaled and failed over independently. Standalone is for learning and development, not for anything you care about keeping.
Step 2: Connect and Create the Collection
from pymilvus import MilvusClient, DataType
client = MilvusClient(uri="http://localhost:19530")
if client.has_collection("documents"):
client.drop_collection("documents")
# auto_id lets Milvus assign the primary key on insert.
schema = MilvusClient.create_schema(auto_id=True, enable_dynamic_field=False)
schema.add_field("id", DataType.INT64, is_primary=True)
schema.add_field("embedding", DataType.FLOAT_VECTOR, dim=384)
schema.add_field("text", DataType.VARCHAR, max_length=1000)
schema.add_field("category", DataType.VARCHAR, max_length=50)
client.create_collection(collection_name="documents", schema=schema)
info = client.describe_collection("documents")
print(f"Collection: {info['collection_name']}")
print(f"Fields: {[f['name'] for f in info['fields']]}")Expected Output:
Collection: documents Fields: ['id', 'embedding', 'text', 'category']
Where Pinecone took a single dimension argument, Milvus wants a typed schema: the vector field alongside ordinary scalar fields, each with a declared type and length. That is more work up front and it buys real filtering later, because those scalars are first-class columns rather than an opaque metadata blob. Note MilvusClient rather than the older connections/Collection pair: that ORM-style API is deprecated and is removed in PyMilvus 3.1.
Step 3: Create the Index (HNSW)
from pymilvus import MilvusClient, DataType
client = MilvusClient(uri="http://localhost:19530")
index_params = client.prepare_index_params()
index_params.add_index(
field_name="embedding",
index_type="HNSW", # Hierarchical Navigable Small World
metric_type="COSINE",
params={
"M": 16, # edges kept per node, per layer
"efConstruction": 256, # candidate list size while building
},
)
client.create_index(collection_name="documents", index_params=index_params)
# A collection must be loaded into memory before it can be searched.
client.load_collection("documents")
for idx in client.list_indexes("documents"):
d = client.describe_index("documents", idx)
print(f"index={idx} type={d['index_type']} metric={d['metric_type']}")
print(f"load state: {client.get_load_state('documents')['state']}")Expected Output:
index=embedding type=HNSW metric=COSINE load state: Loaded
HNSW builds a layered proximity graph and answers a query by walking it greedily from the top layer down. Search cost grows roughly logarithmically with the number of vectors in practice, but this is an approximate index with no complexity guarantee and no promise of returning the true nearest neighbours: M and efConstruction trade index size and build time for recall, and ef at query time trades latency for recall. The load_collection call is not optional. Milvus searches from memory, and querying an unloaded collection is an error rather than a slow path.
Step 4: Insert Embeddings
from pymilvus import MilvusClient
from sentence_transformers import SentenceTransformer
client = MilvusClient(uri="http://localhost:19530")
model = SentenceTransformer("all-MiniLM-L6-v2")
docs = [
("Machine learning models require training data", "AI"),
("Neural networks use backpropagation", "AI"),
("Databases store structured information", "databases"),
]
# Row-oriented dicts, one per entity. "id" is omitted because auto_id=True.
data = [
{"embedding": model.encode(text).tolist(), "text": text, "category": category}
for text, category in docs
]
result = client.insert(collection_name="documents", data=data)
print(f"Inserted {result['insert_count']} documents")
# Milvus writes are not searchable until they are sealed and indexed.
# flush() forces that now, which is what makes this example deterministic.
client.flush("documents")
print(client.get_collection_stats("documents"))Expected Output:
Inserted 3 documents
{'row_count': 3}Inserts are buffered, so a search issued immediately after one can legitimately miss the rows you just wrote: the same eventual-visibility model as an Elasticsearch refresh. flush() forces the buffer to seal now, which is what makes this walkthrough reproducible. Do not reach for it on every write in production, though, as flushing per insert produces a mass of tiny segments and degrades search.
Step 5: Vector Search with Filtering
from pymilvus import MilvusClient
from sentence_transformers import SentenceTransformer
client = MilvusClient(uri="http://localhost:19530")
model = SentenceTransformer("all-MiniLM-L6-v2")
query_embedding = model.encode("How do models learn from examples?").tolist()
results = client.search(
collection_name="documents",
data=[query_embedding],
limit=3,
filter='category == "AI"', # Milvus expression language, not JSON
output_fields=["text", "category"],
search_params={
"metric_type": "COSINE",
"params": {"ef": 64}, # candidates held during the walk
},
)
for hits in results:
for hit in hits:
print(f"Score: {hit['distance']:.4f}")
print(f"Text: {hit['entity']['text']}")
print(f"Category: {hit['entity']['category']}")Expected Output:
Score: 0.4521 Text: Machine learning models require training data Category: AI Score: 0.3785 Text: Neural networks use backpropagation Category: AI
The query is "How do models learn from examples?", and the databases document is excluded by the filter while the two AI documents come back at 0.4521 and 0.3785. Compare the three filter dialects this lesson has now shown for one idea: pgvector uses WHERE category = 'animals', Pinecone a JSON document ({'category': {'$eq': 'tech'}}), and Milvus a string expression language of its own. Only the pgvector one can join to a table it does not own.
Weaviate: Schema-Based Vector Database
Weaviate combines vector search with traditional database features: schemas, relationships, and GraphQL queries. Great for complex data models with semantic search.
Step 1: Run Weaviate Locally
Weaviate takes over the step the previous two sections did by hand: you write text and it produces the embedding. That convenience is a dependency, so setup is two containers rather than one, the database plus the model that vectorizes for it.
# Weaviate's selling point is that it vectorizes for you, which means it
# needs a model to do it with. Point it at an inference container running
# the same all-MiniLM-L6-v2 used earlier, and no API key is involved.
docker network create weaviate-net
docker run -d --name t2v-transformers --network weaviate-net \
-e ENABLE_CUDA=0 \
cr.weaviate.io/semitechnologies/transformers-inference:sentence-transformers-all-MiniLM-L6-v2
docker run -d --name weaviate --network weaviate-net \
-p 8080:8080 -p 50051:50051 \
-e ENABLE_MODULES=text2vec-transformers \
-e DEFAULT_VECTORIZER_MODULE=text2vec-transformers \
-e TRANSFORMERS_INFERENCE_API=http://t2v-transformers:8080 \
-e AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED=true \
-e PERSISTENCE_DATA_PATH=/var/lib/weaviate \
cr.weaviate.io/semitechnologies/weaviate:1.34.1
# 8080 is REST, 50051 is gRPC. The v4 client needs BOTH: it does schema
# work over REST and batching and search over gRPC, so publishing only
# 8080 gets you a client that connects and then times out on insert.
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/v1/.well-known/ready
# Finished? Remove both, plus the network:
# docker rm -f weaviate t2v-transformers && docker network rm weaviate-net
python3 -m venv .venv && source .venv/bin/activate
pip install weaviate-clientExpected Output:
200
The vectorizer is a property of the collection, not of the query, which is the trade worth understanding: writes and searches are guaranteed to use the same model, and in exchange your embedding model is now a running service that has to be available whenever you want to write or search.
Step 2: Create the Collection
import weaviate
from weaviate.classes.config import Configure, DataType, Property
client = weaviate.connect_to_local()
if client.collections.exists("Article"):
client.collections.delete("Article")
client.collections.create(
name="Article",
description="A blog article",
# Weaviate vectorizes on write: hand it text, not embeddings.
vector_config=Configure.Vectors.text2vec_transformers(),
properties=[
Property(name="title", data_type=DataType.TEXT),
Property(name="content", data_type=DataType.TEXT),
Property(name="category", data_type=DataType.TEXT),
Property(name="publishDate", data_type=DataType.DATE),
],
)
config = client.collections.use("Article").config.get()
print(f"Collection: {config.name}")
print(f"Properties: {[p.name for p in config.properties]}")
client.close()Expected Output:
Collection: Article Properties: ['title', 'content', 'category', 'publishDate']
Note what is absent: no dimension. Weaviate takes it from the vectorizer, because it is the component generating the vectors. This is the v4 client (weaviate.connect_to_local(), client.collections); the older v3 style built a schema dict and called weaviate.Client(...), and that API no longer exists.
Step 3: Insert Objects (Auto-Vectorization)
from datetime import datetime, timezone
import weaviate
client = weaviate.connect_to_local()
articles = client.collections.use("Article")
rows = [
{
"title": "Introduction to Python",
"content": "Python is a versatile programming language",
"category": "Programming",
"publishDate": datetime(2025, 1, 15, tzinfo=timezone.utc),
},
{
"title": "Deep Learning Basics",
"content": "Neural networks are the foundation of deep learning",
"category": "AI",
"publishDate": datetime(2025, 2, 1, tzinfo=timezone.utc),
},
{
"title": "Database Design Patterns",
"content": "Normalization and indexing are key to performance",
"category": "Databases",
"publishDate": datetime(2025, 1, 20, tzinfo=timezone.utc),
},
]
# No embeddings anywhere in this payload: the vectorizer produces them
# server side, from the text properties, as each object is written.
with articles.batch.fixed_size(batch_size=100) as batch:
for row in rows:
batch.add_object(properties=row)
# Batch errors do not raise. They are collected, and ignoring them is
# how you end up with a silently half-loaded collection.
if articles.batch.failed_objects:
raise RuntimeError(articles.batch.failed_objects)
print(f"Inserted {len(rows)} articles")
print(f"Objects in collection: {articles.aggregate.over_all(total_count=True).total_count}")
client.close()Expected Output:
Inserted 3 articles Objects in collection: 3
Every earlier example in this lesson called an embedding model itself and passed the resulting floats in. Here the payload is plain text and dates, and the vectorizer runs server side during the batch. Convenient, and worth being clear about the cost: each write is now a network round trip to the inference container, so bulk loading is bounded by that service rather than by the database.
Step 4: Semantic Search
import weaviate
from weaviate.classes.query import MetadataQuery
client = weaviate.connect_to_local()
articles = client.collections.use("Article")
# near_text, not near_vector: the query string is vectorized server side
# by the same module that vectorized the objects.
response = articles.query.near_text(
query="machine learning and artificial intelligence",
limit=2,
return_properties=["title", "content", "category"],
return_metadata=MetadataQuery(distance=True),
)
for obj in response.objects:
print(f"Distance: {obj.metadata.distance:.4f}")
print(f"Title: {obj.properties['title']}")
print(f"Category: {obj.properties['category']}")
print()
client.close()Expected Output:
Distance: 0.5021 Title: Deep Learning Basics Category: AI Distance: 0.7961 Title: Introduction to Python Category: Programming
Weaviate reports distance, where lower is closer, the inverse of the similarity scores Pinecone and Milvus returned. The AI article wins at 0.5021 and the Python one follows at 0.7961. Reading that as "the second result is also relevant" would be a mistake: with three objects and limit=2, something had to come second. Nearest-neighbour search ranks whatever it is given, so a distance threshold is what separates a match from the least-bad option.
Step 5: Hybrid Search (Vector + Keyword)
import weaviate
from weaviate.classes.query import Filter, MetadataQuery
client = weaviate.connect_to_local()
articles = client.collections.use("Article")
# alpha=0.5 weights the two rankings evenly: 0 is pure keyword (BM25),
# 1 is pure vector. Weaviate fuses the two result lists into one score.
response = articles.query.hybrid(
query="neural networks",
alpha=0.5,
filters=Filter.by_property("category").equal("AI"),
limit=3,
return_properties=["title", "category"],
return_metadata=MetadataQuery(score=True),
)
for obj in response.objects:
print(f"{obj.metadata.score:.4f} {obj.properties['title']} ({obj.properties['category']})")
client.close()Expected Output:
1.0000 Deep Learning Basics (AI)
Hybrid search runs a BM25 keyword query and a vector query, then fuses the two rankings, with alpha setting the balance. It is the honest answer to the weakness each has alone: keyword search cannot match a paraphrase, and vector search is unreliable on exact tokens like error codes, SKUs and proper nouns. The score here is 1.0000 only because the filter leaves a single candidate and a fused rank score is relative to the result set, so do not read it as "a perfect match".
Similarity Search Algorithms
Vector databases use distance metrics to measure similarity. Understanding these is crucial for choosing the right metric for your use case.
Cosine Similarity
Measures angle between vectors. Range: -1 to 1 (1 = identical direction).
(A·B) / (||A|| ||B||)Euclidean (L2)
Straight-line distance between points. Sensitive to magnitude.
√(Σ(A[i] - B[i])²)Dot Product
Sum of element-wise products. Fast, considers magnitude.
Σ(A[i] × B[i])Comparing Distance Metrics
import numpy as np
from numpy.linalg import norm
# Sample vectors
vec1 = np.array([1.0, 2.0, 3.0])
vec2 = np.array([1.5, 2.2, 2.8])
# Cosine similarity
cosine = np.dot(vec1, vec2) / (norm(vec1) * norm(vec2))
print(f"Cosine similarity: {cosine:.4f}")
# Euclidean distance
euclidean = norm(vec1 - vec2)
print(f"Euclidean distance: {euclidean:.4f}")
# Dot product
dot_prod = np.dot(vec1, vec2)
print(f"Dot product: {dot_prod:.4f}")Expected Output:
Cosine similarity: 0.9891 Euclidean distance: 0.5745 Dot product: 14.3000
Three numbers on the same pair of vectors, and they do not even agree on which direction means "similar": cosine at 0.9891 is near its maximum of 1, while Euclidean at 0.5745 is near its minimum of 0. Both are saying the vectors are close. The dot product is the one to be careful with, because 14.3 is on no fixed scale at all: it grows with magnitude, so a longer vector can outrank a better-aligned one. That is exactly why it is safe on normalized embeddings, where every vector has length 1 and the dot product reduces to cosine, and misleading on anything else.
Indexing Strategies for Speed
| Algorithm | Type | Speed | Accuracy | Best For |
|---|---|---|---|---|
| HNSW | Graph-based | Very Fast | High | < 10M vectors, low latency |
| IVF-PQ | Quantization | Fast | Medium | > 10M vectors, memory constrained |
| Flat | Brute force | Slow | Perfect | < 1M vectors, exact search |
| ANNOY | Tree-based | Medium | Medium | Static datasets, read-heavy |
RAG: Retrieval Augmented Generation
RAG combines vector search with Large Language Models (LLMs) to provide accurate, contextual responses grounded in your data. This powers ChatGPT-like experiences over private documents.
How RAG Works
Step 1: Index Documents
- Split documents into chunks (500-1000 tokens)
- Generate embeddings for each chunk
- Store in vector database with metadata
Step 2: User Query
- User asks: "What are our refund policies?"
- Generate embedding for query
- Search vector DB for top 5 similar chunks
Step 3: LLM Generation
- Inject retrieved chunks into LLM prompt
- LLM generates answer using retrieved context
- Answer is grounded in your documents, not hallucinated!
Implementing RAG: Retrieval
The R in RAG is the search this whole lesson has been building: no new infrastructure, no LLM yet. It does need a corpus with real content, so this block indexes one rather than reusing the three toy sentences from the Pinecone walkthrough.
from pinecone import Pinecone, ServerlessSpec
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("all-MiniLM-L6-v2")
pc = Pinecone(api_key="pclocal", host="http://localhost:5080")
# RAG needs a corpus with something to say. The three toy sentences from
# the Pinecone walkthrough are too thin: ask them a real question and a
# well-behaved model correctly answers "the context does not contain it".
KNOWLEDGE = [
"Vector databases store embeddings and retrieve them by similarity rather than by exact match.",
"An approximate index such as HNSW trades a small amount of recall for a very large speed gain.",
"Without a vector index every query must compare the query against all stored vectors.",
"Metadata filters narrow a similarity search to a subset, such as one tenant or one category.",
"Embeddings from different models are not comparable, so changing models means reindexing.",
"Recommendation systems use the same nearest-neighbour search as semantic search.",
]
if not pc.has_index("rag-docs"):
pc.create_index(
name="rag-docs",
dimension=384,
metric="cosine",
spec=ServerlessSpec(cloud="aws", region="us-east-1"),
)
index_host = pc.describe_index("rag-docs").host.replace("https://", "http://")
index = pc.Index(host=index_host)
index.upsert(vectors=[
{"id": str(i), "values": model.encode(text).tolist(), "metadata": {"text": text}}
for i, text in enumerate(KNOWLEDGE)
])
print(f"Indexed {len(KNOWLEDGE)} chunks")
# The query MUST be embedded with the model that embedded the documents.
def retrieve_context(query, top_k=3):
query_embedding = model.encode(query).tolist()
results = index.query(vector=query_embedding, top_k=top_k, include_metadata=True)
return [match["metadata"]["text"] for match in results["matches"]]
question = "Why does changing the embedding model force a reindex?"
print(f"\nQuestion: {question}\n")
print("Retrieved context:")
for chunk in retrieve_context(question):
print(f" - {chunk}")Expected Output:
Indexed 6 chunks Question: Why does changing the embedding model force a reindex? Retrieved context: - Embeddings from different models are not comparable, so changing models means reindexing. - Vector databases store embeddings and retrieve them by similarity rather than by exact match. - An approximate index such as HNSW trades a small amount of recall for a very large speed gain.
The question says "reindex" and the winning chunk says "reindexing", but it also had to connect "changing the embedding model" to "embeddings from different models are not comparable", which no keyword query would do. Look at the scores behind those three lines, though: 0.7922, then 0.3343, then 0.1443. Only the first is really an answer. top_k is a fixed count, not a relevance test, so it returns three results whether or not three are relevant and pads the prompt with material the model then has to ignore. Filtering on a score threshold is what turns retrieval into an answer rather than a top-N list. Note too that retrieve_context embeds the query with the model that embedded the documents. Use a different one and the distances compare unrelated coordinate spaces, returning confident nonsense rather than an error.
Implementing RAG: Generation
Only now does a language model appear, and its job is narrow: turn the retrieved chunks into prose. This step needs an OpenAI key, unlike everything above it.
from openai import OpenAI
from pinecone import Pinecone
from sentence_transformers import SentenceTransformer
# Runs standalone. It reads the rag-docs index the previous block created,
# so run that one first, but nothing here depends on its variables.
model = SentenceTransformer("all-MiniLM-L6-v2")
pc = Pinecone(api_key="pclocal", host="http://localhost:5080")
index_host = pc.describe_index("rag-docs").host.replace("https://", "http://")
index = pc.Index(host=index_host)
# The only piece that needs a real key. Retrieval needed nothing.
client = OpenAI(api_key="sk-...")
def retrieve_context(query, top_k=3):
query_embedding = model.encode(query).tolist()
results = index.query(vector=query_embedding, top_k=top_k, include_metadata=True)
return [match["metadata"]["text"] for match in results["matches"]]
def answer_question(question):
context = "\n\n".join(retrieve_context(question))
prompt = f"""Answer the question using only the context below.
If the context does not contain the answer, say so.
Context:
{context}
Question: {question}
Answer:"""
response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": prompt}],
)
return response.choices[0].message.content
print(answer_question("Why does changing the embedding model force a reindex?"))Expected Output:
Changing the embedding model forces a reindex because embeddings from different models are not comparable.
That answer is grounded: it restates the retrieved chunk instead of drawing on what the model happens to know about embeddings. Expect the exact wording to vary between runs, since generation is not deterministic the way the retrieval above it is. The instruction to say so when the context lacks the answer is the load-bearing line, and it earns its place. Ask this same corpus "why are vector databases faster than scanning everything?" and the sentence that answers it, the one about HNSW, ranks fourth: outside top_k=3, so it never reaches the prompt, and the model correctly replies "The context does not contain the answer." The retrieval looked fine and the answer was in the corpus; it just was not in the three chunks that got sent. Without that instruction the model would have answered from its training data instead, and the result would be indistinguishable from a grounded answer. So RAG constrains what the model works from, which is not the same as preventing hallucination: hand it the wrong chunks and it will faithfully summarize the wrong chunks. Retrieval quality is the ceiling on answer quality, and a refusal is the cheapest signal that retrieval, not the model, is what needs fixing.
Integration with Big Data Pipelines
An embedding is derived data, which means it goes stale the moment the row it describes changes. Keeping it current is a pipeline problem, and it comes in two shapes: streaming, where documents are indexed as they arrive, and batch, where a large corpus is embedded in parallel. Both are below, running against real services.
Two Shapes of Embedding Pipeline
Figure 1: Streaming (top) indexes one document at a time as it arrives; batch (bottom) spreads the same work across executors. The highlighted steps are where embedding happens, and both must use the same model: vectors from different models are not comparable, so mixing them in one index silently corrupts the ranking.
Setup: Kafka and Spark
Pinecone Local and Milvus are still running from the sections above, so only the two new services need starting. Spark is not one of them: PySpark runs a local cluster in-process, so it is a pip install and a JDK.
# Pinecone Local and Milvus are already running from the sections above. # The two services this section adds are Kafka and Spark. # Kafka in KRaft mode: one container, no ZooKeeper. docker run -d --name kafka-demo -p 29092:29092 \ -e KAFKA_NODE_ID=1 \ -e KAFKA_PROCESS_ROLES=broker,controller \ -e KAFKA_LISTENERS=PLAINTEXT://0.0.0.0:29092,CONTROLLER://0.0.0.0:9093 \ -e KAFKA_ADVERTISED_LISTENERS=PLAINTEXT://localhost:29092 \ -e KAFKA_CONTROLLER_LISTENER_NAMES=CONTROLLER \ -e KAFKA_LISTENER_SECURITY_PROTOCOL_MAP=CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT \ -e KAFKA_CONTROLLER_QUORUM_VOTERS=1@localhost:9093 \ -e KAFKA_INTER_BROKER_LISTENER_NAME=PLAINTEXT \ -e KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR=1 \ -e KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR=1 \ -e KAFKA_TRANSACTION_STATE_LOG_MIN_ISR=1 \ -e CLUSTER_ID=5L6g3nShT-eMCtK--X86sw \ apache/kafka:3.8.0 docker exec kafka-demo /opt/kafka/bin/kafka-topics.sh \ --bootstrap-server localhost:29092 \ --create --topic documents --partitions 1 --replication-factor 1 # Spark needs no container: PySpark runs a local cluster in-process, and # it needs a JDK (17 or 21) on PATH. python3 -m venv .venv && source .venv/bin/activate pip install kafka-python-ng pyspark pinecone pymilvus sentence-transformers # Finished? Remove it: # docker rm -f kafka-demo
Expected Output:
Created topic documents.
Real-Time Ingestion with Kafka
First put something on the topic to consume. In a real system this is your application, or Debezium streaming changes out of PostgreSQL.
import json
from kafka import KafkaProducer
producer = KafkaProducer(
bootstrap_servers=["localhost:29092"],
value_serializer=lambda v: json.dumps(v).encode("utf-8"),
)
DOCS = [
{"id": "1", "title": "Sharding", "source": "wiki",
"content": "Sharding splits one logical table across many physical machines."},
{"id": "2", "title": "Replication", "source": "wiki",
"content": "Replication keeps copies of the same data on more than one node."},
{"id": "3", "title": "Partitioning", "source": "wiki",
"content": "Partitioning divides a table into smaller pieces inside one database."},
]
for doc in DOCS:
producer.send("documents", doc)
producer.flush()
print(f"Produced {len(DOCS)} documents to topic 'documents'")Expected Output:
Produced 3 documents to topic 'documents'
Then the indexer: read a message, embed the text, upsert the vector.
import json
from kafka import KafkaConsumer
from pinecone import Pinecone, ServerlessSpec
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("all-MiniLM-L6-v2")
# The index dimension must match the embedding model. 384 here because
# that is what all-MiniLM-L6-v2 produces; an index built at 1536 for
# OpenAI would reject every one of these upserts.
pc = Pinecone(api_key="pclocal", host="http://localhost:5080")
if not pc.has_index("documents"):
pc.create_index(
name="documents",
dimension=384,
metric="cosine",
spec=ServerlessSpec(cloud="aws", region="us-east-1"),
)
index_host = pc.describe_index("documents").host.replace("https://", "http://")
index = pc.Index(host=index_host)
consumer = KafkaConsumer(
"documents",
bootstrap_servers=["localhost:29092"],
auto_offset_reset="earliest",
# Without this the loop blocks forever, which is correct in production
# and unhelpful in a demo. It stops after 10s with no new messages.
consumer_timeout_ms=10000,
value_deserializer=lambda m: json.loads(m.decode("utf-8")),
)
count = 0
for message in consumer:
doc = message.value
embedding = model.encode(doc["content"]).tolist()
index.upsert(vectors=[{
"id": doc["id"],
"values": embedding,
"metadata": {"title": doc["title"], "source": doc["source"]},
}])
count += 1
print(f"Indexed document {doc['id']}: {doc['title']}")
print(f"\nConsumed and indexed {count} documents")
print(index.describe_index_stats())Expected Output:
Indexed document 1: Sharding Indexed document 2: Replication Indexed document 3: Partitioning Consumed and indexed 3 documents DescribeIndexStatsResponse(dimension=384, total_vector_count=3, namespaces=1)
The consumer uses the message's own id as the vector id, which is what makes replay safe: upsert overwrites by id, so reprocessing the topic produces the same three vectors rather than duplicates. That matters more than it looks, because Kafka delivery is at-least-once and this loop will see repeats. Two things here are deliberately simplified, though. Embedding one document per message is a round trip per message where batching would be far cheaper, and there is no offset commit discipline: a crash between the upsert and the commit reprocesses the message, which is harmless only because the write is idempotent.
Batch Processing with Spark
The batch shape is the same work spread across executors. First write the source data and create the target collection:
from pymilvus import MilvusClient, DataType
from pyspark.sql import SparkSession
# The source data. In production this is S3 or HDFS; here it is a local
# directory so the example runs. The Spark code that reads it is identical.
spark = SparkSession.builder.appName("SeedData").master("local[2]").getOrCreate()
spark.sparkContext.setLogLevel("ERROR")
spark.createDataFrame([
("Sharding", "scaling",
"Sharding splits one logical table across many physical machines."),
("Replication", "availability",
"Replication keeps copies of the same data on more than one node."),
("Partitioning", "scaling",
"Partitioning divides a table into smaller pieces inside one database."),
("Write-ahead log", "durability",
"A write-ahead log records changes before they are applied to pages."),
], ["title", "category", "content"]).write.mode("overwrite").parquet("documents.parquet")
spark.stop()
print("wrote documents.parquet")
client = MilvusClient(uri="http://localhost:19530")
if client.has_collection("spark_documents"):
client.drop_collection("spark_documents")
schema = MilvusClient.create_schema(auto_id=True, enable_dynamic_field=False)
schema.add_field("id", DataType.INT64, is_primary=True)
schema.add_field("embedding", DataType.FLOAT_VECTOR, dim=384)
schema.add_field("title", DataType.VARCHAR, max_length=1000)
schema.add_field("category", DataType.VARCHAR, max_length=50)
index_params = client.prepare_index_params()
index_params.add_index(field_name="embedding", index_type="HNSW",
metric_type="COSINE", params={"M": 16, "efConstruction": 256})
client.create_collection(collection_name="spark_documents", schema=schema,
index_params=index_params)
print("created collection spark_documents")Expected Output:
wrote documents.parquet created collection spark_documents
Then the job itself: read parquet, embed with a UDF, write each partition to Milvus.
from pyspark.sql import SparkSession
from pyspark.sql.functions import udf
from pyspark.sql.types import ArrayType, FloatType
spark = SparkSession.builder.appName("VectorIndexing").master("local[2]").getOrCreate()
spark.sparkContext.setLogLevel("ERROR")
df = spark.read.parquet("documents.parquet")
# Cache the model per Python worker. Loading it inside the UDF body without
# this global would reload it for every single row.
_model = None
def get_model():
global _model
if _model is None:
from sentence_transformers import SentenceTransformer
_model = SentenceTransformer("all-MiniLM-L6-v2")
return _model
@udf(returnType=ArrayType(FloatType()))
def generate_embedding(text):
return get_model().encode(text).tolist()
df_with_embeddings = df.withColumn("embedding", generate_embedding(df.content))
# One client per partition, not one per row: connections are the thing that
# does not survive being created a million times.
def upsert_to_milvus(partition_iter):
from pymilvus import MilvusClient
rows = [
{"embedding": row.embedding, "title": row.title, "category": row.category}
for row in partition_iter
]
if rows:
milvus = MilvusClient(uri="http://localhost:19530")
milvus.insert(collection_name="spark_documents", data=rows)
milvus.close()
df_with_embeddings.foreachPartition(upsert_to_milvus)
spark.stop()
from pymilvus import MilvusClient
client = MilvusClient(uri="http://localhost:19530")
client.flush("spark_documents")
print("Batch indexing complete")
print(client.get_collection_stats("spark_documents"))Expected Output:
Batch indexing complete
{'row_count': 4}Two details carry the whole example. The model is cached in a module-level global rather than constructed inside the UDF, because a UDF body runs per row: without the cache, a million-row job loads the model a million times and the run never finishes. And the Milvus client is built inside upsert_to_milvusrather than on the driver, because that function is serialized and shipped to the executors, where a driver-side connection object would not survive the trip. One client per partition is the right granularity: per row exhausts connections, and a single shared one cannot exist.
Choosing the Right Vector Database
Choose Pinecone
- Production-ready out of the box
- No infrastructure management
- Automatic scaling and backups
- Startup/MVP speed
- Budget for managed service
Choose Milvus
- Billion+ vector scale
- Self-hosted requirements
- GPU acceleration needed
- Advanced index control
- Cost optimization via self-hosting
Choose Weaviate
- Complex schemas with relationships
- GraphQL query flexibility
- Hybrid search (keyword + vector)
- Multi-tenancy requirements
- Auto-vectorization preferred
pgvector: You May Not Need a Vector Database
Everything above assumes vectors live in a separate system. Often they should not. pgvector is a PostgreSQL extension that adds a vector column type and nearest-neighbour operators, so embeddings live in the same database, and the same transaction, as the rows they describe. For most applications below roughly ten million vectors this is the cheapest correct answer.
Try it locally
Every result in this section was produced by running the SQL against this container:
# pgvector is bundled in the pgvector image and in TimescaleDB-HA. docker run -d --name pgvector-demo \ -e POSTGRES_PASSWORD=demo -e POSTGRES_USER=demo -e POSTGRES_DB=demo \ -p 5432:5432 pgvector/pgvector:pg16 docker exec -it pgvector-demo psql -U demo -d demo # Finished? Remove it, it holds no state you need: # docker rm -f pgvector-demo # The Python examples need a virtualenv: on Debian/Ubuntu a bare # "pip install" is refused (PEP 668: externally-managed-environment). python3 -m venv .venv && source .venv/bin/activate pip install openai pinecone pymilvus weaviate-client
Storing and Searching Vectors
-- pgvector turns PostgreSQL itself into a vector store.
-- No new service, no second copy of your data, and your embeddings
-- sit in the same transaction as the rows they describe.
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE documents (
id bigserial PRIMARY KEY,
content text NOT NULL,
category text,
embedding vector(3) -- real models use 384, 768 or 1536 dims
);
INSERT INTO documents (content, category, embedding) VALUES
('Python is a programming language', 'tech', '[0.9, 0.1, 0.0]'),
('JavaScript powers web applications','tech', '[0.85, 0.15, 0.05]'),
('Dogs are loyal companions', 'animals', '[0.0, 0.2, 0.95]'),
('Cats are independent pets', 'animals', '[0.05, 0.1, 0.9]');
-- <=> is cosine distance: 0 means identical direction, 2 means opposite.
-- The ORDER BY is what makes this a nearest-neighbour search.
SELECT content,
round((embedding <=> '[0.9, 0.1, 0.0]')::numeric, 4) AS cosine_distance
FROM documents
ORDER BY embedding <=> '[0.9, 0.1, 0.0]'
LIMIT 3;Expected Output:
┌────────────────────────────────────┬─────────────────┐ │ content │ cosine_distance │ ├────────────────────────────────────┼─────────────────┤ │ Python is a programming language │ 0.0000 │ │ JavaScript powers web applications │ 0.0037 │ │ Cats are independent pets │ 0.9330 │ └────────────────────────────────────┴─────────────────┘
Real output from pgvector 0.8.5 on PostgreSQL 16. The query vector is the Python row's own embedding, so it scores 0.0000: a vector is always identical to itself. JavaScript follows at 0.0037 because both point almost the same way, and the cat is nearly orthogonal at 0.9330. Note the fourth row never appears, because LIMIT 3cut it: the ranking is complete, the result set is not.
Filtering Is Just SQL
-- The thing a dedicated vector database makes awkward and SQL makes
-- trivial: combine the similarity search with ordinary WHERE clauses,
-- joins, and transactions.
SELECT content,
round((embedding <=> '[0.0,0.2,0.95]')::numeric, 4) AS distance
FROM documents
WHERE category = 'animals'
ORDER BY embedding <=> '[0.0,0.2,0.95]'
LIMIT 2;Expected Output:
┌───────────────────────────┬──────────┐ │ content │ distance │ ├───────────────────────────┼──────────┤ │ Dogs are loyal companions │ 0.0000 │ │ Cats are independent pets │ 0.0062 │ └───────────────────────────┴──────────┘
The tech rows are excluded by the WHERE before ranking, so the two animal rows are the whole candidate set rather than survivors of a filtered top-N. Pinecone and Milvus both express this too, each in a dialect of its own, and the earlier sections showed both. The difference is reach: this predicate is ordinary SQL, so it can join to any other table you own, while a metadata filter can only see the fields you remembered to copy into the index.
Indexing: HNSW vs IVFFlat
-- Without an index this is an exact scan: correct, but O(n). -- HNSW is the usual choice: better recall than IVFFlat and it does not -- need training data up front. CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops); -- IVFFlat is the older alternative: smaller and faster to build, but it -- must be built AFTER the table has representative data, because the -- lists are cluster centroids learned from what is already there. CREATE INDEX ON documents USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100); -- Match the operator class to the distance operator you actually query -- with, or the index will simply be ignored: -- <=> cosine -> vector_cosine_ops -- <-> L2/Euclidean -> vector_l2_ops -- <#> inner product -> vector_ip_ops
Expected Output:
CREATE INDEX NOTICE: ivfflat index created with little data DETAIL: This will cause low recall. HINT: Drop the index until the table has more data. CREATE INDEX
pgvector warns about exactly the IVFFlat trap described in the comments: built against four rows, its centroids are meaningless, and it says so at creation time rather than leaving you to discover the missing results later. HNSW issues no such warning because it builds its graph incrementally and never needs representative data up front. That asymmetry is the practical reason to reach for HNSW first, and the reason an IVFFlat index has to be rebuilt after a bulk load rather than created before one.
Choose pgvector when
- You already run PostgreSQL, and adding a second datastore means a second thing to back up, secure, and keep in sync
- Your vectors must stay consistent with relational data. An embedding and the row it describes commit together, or not at all
- You need filters, joins, or aggregates alongside similarity, rather than bolted on beside it
- Your corpus is thousands to low millions of vectors, which is most applications
Key Takeaways
- Vector databases solve AI-scale search: Traditional databases can't handle billion-vector similarity search at millisecond latency
- Embeddings capture semantic meaning: Similar concepts have similar vectors, enabling "Google-like" search over any data type
- Start with pgvector, move only when forced: if you already run PostgreSQL, pgvector keeps embeddings in the same database and the same transaction as the rows they describe, and filtering is ordinary SQL. Reach for Pinecone (managed, zero ops), Milvus (billion-scale, self-hosted, GPU) or Weaviate (complex schemas, hybrid search) when you can name the metric that forced the move
- RAG is the killer app: Combine vector search with LLMs for accurate, grounded AI responses
- Hybrid search wins: Combine vector similarity with traditional filters (metadata, dates, categories) for production systems