Vector Database

In the AI era, the form of data we process has undergone fundamental changes.

Traditional databases store structured data: numbers, strings, dates.

AI applications process semantics: the meaning of a piece of text, the content of an image, the meaning of a voice clip. This semantic information is represented as vectors—a sequence of floating-point numbers, e.g., [0.123, -0.456, 0.789, ...].

A vector database is a database specifically designed to store, index, and query these vectors. Without it, RAG (Retrieval-Augmented Generation) cannot work, semantic search cannot be implemented, and recommendation systems cannot run efficiently.

Core Value of Vector Databases:Find the top K results most similar to a query vector within milliseconds from millions or even billions of vectors.。


Challenges of Vector Similarity Search

Only by understanding the nature of the problem can you understand why specialized algorithms are needed.

Complexity Problem of Brute-Force Search

The simplest approach is brute-force search: compute the similarity between the query vector and every vector in the database, sort, and take the top K.

Example

# ============================================
# Brute-force Search Demo: Simple but Inefficient
# ============================================

import numpy as np
from typing import List, Tuple


def l2_distance(v1: np.ndarray, v2: np.ndarray) -> float:
    """Compute L2 Euclidean distance: the smaller, the more similar."""
    return np.sqrt(np.sum((v1 - v2) ** 2))


def brute_force_search(
    query: np.ndarray,
    vectors: List[np.ndarray],
    top_k: int = 5
) -> List[Tuple[int, float]]:
    """
Brute-force search: compute distances to all vectors, sort, and return Top K
Advantages: simple and accurate, 100% recall
Disadvantages: extremely slow with large data volumes
    """

    # Compute distance to each vector
    distances = []
    for idx, vec in enumerate(vectors):
        dist = l2_distance(query, vec)
        distances.append((idx, dist))

    # Sort by distance, take top K
    distances.sort(key=lambda x: x[1])
    return distances[:top_k]


# Generate 10,000 random 128-dimensional vectors (simulating a vector database)
np.random.seed(42)
vector_count = 10000
dimension = 128
vectors = [np.random.randn(dimension) for _ in range(vector_count)]

# Generate a query vector
query_vector = np.random.randn(dimension)

# Execute brute-force search
print(f"Searching Top 5 among {vector_count} vectors...")
results = brute_force_search(query_vector, vectors, top_k=5)

print("Search results:")
for idx, dist in results:
    print(f" Vector {idx:4d}, distance = {dist:.4f}")

# Output looks like:
# Searching Top 5 among 10,000 vectors...
# Search results:
# Vector 8236, distance = 13.3986
# Vector 8946, distance = 13.5604
# Vector 9891, distance = 13.5688
# Vector 5172, distance = 13.5987
# Vector 5223, distance = 13.6081

The problem with brute-force search is obvious: every query has to traverse all vectors.

If there are 1 million vectors, each query requires computing 1 million distances.

If there are 1 billion vectors, each query requires 1 billion calculations—this is completely unacceptable in production.

The "Curse of Dimensionality" in High-Dimensional Space

To make matters worse, vectors are usually high-dimensional.

OpenAI's text-embedding-3-small is 1536-dimensional, and large is 3072-dimensional.

In high-dimensional spaces, traditional spatial indexing methods (such as KD-Tree) experience a sharp drop in efficiency, sometimes even slower than brute-force search.

Data sizeBrute-force search time (estimated)Acceptable?
10 thousand~1 millisecondAcceptable
1 million~100 millisecondsA bit slow
10 million~1 secondToo slow
100 million~10 secondsCompletely unacceptable
1 billion~100 secondsUnusable

This is why we needapproximate nearest neighbor (ANN) algorithms: sacrificing 100% accuracy for a 100x or even 1000x speedup.


Approximate Nearest Neighbor (ANN) Algorithms

The core idea of ANN algorithms:Build an index structure so that queries don't need to traverse all vectors。

There are three mainstream algorithms: HNSW, IVF, and PQ.

HNSW: Hierarchical Navigable Small World Graph

HNSW (Hierarchical Navigable Small World) is one of the most popular algorithms today. It is inspired by the six degrees of separation theory—any two people in the world can be connected through at most 6 intermediaries.

HNSW uses a multi-layer sparse small-world graph. It quickly coarsely locates from the top-layer sparse nodes, then descends layer by layer to the bottom full-data layer for fine-grained search. By using hierarchical jumps, it greatly reduces the number of distance calculations, achieving fast, high-recall approximate nearest neighbor search.

HNSW 分层小世界图结构示意图

Hierarchical structure:The bottom layer, Layer 0, stores all vectors; the higher the layer, the fewer nodes, acting as a fast track. The same vector is vertically linked across layers with dashed lines.

Search process (blue line path):

  • Coarse search starts at the top-layer entry, finding the point closest to the query vector in that layer.
  • Vertically descend to the middle layer and coarsely locate again.
  • Fall to Layer 0, the full-data layer, for fine-grained traversal, and output the nearest neighbor.

Core principle:High layers quickly narrow the search range, while the bottom layer provides precise matching. It optimizes brute-force search to logarithmic complexity, offering high speed and high recall, making it a mainstream vector search algorithm.

Example

# ============================================
# Simplified demo of HNSW principles (not a production implementation)
# ============================================

import numpy as np
import random
from typing import List, Dict, Set, Tuple


class SimpleHNSW:
    """Simplified HNSW, only for demonstrating the principles"""

    def __init__(self, dim: int, max_level: int = 3, ef_construction: int = 5):
        self.dim = dim
        self.max_level = max_level  # Maximum number of layers
        self.ef_construction = ef_construction  # Number of candidates considered when building the graph
        self.levels: List[Dict[int, np.ndarray]] = [{} for _ in range(max_level)]
        self.edges: List[Dict[int, Set[int]]] = [{} for _ in range(max_level)]
        self.next_id = 0
        self.enter_point = None  # Entry point (a point at the top layer)

    def _random_level(self) -> int:
        """Randomly select a level: the higher the level, the lower the probability"""
        level = 0
        while random.random() < 0.5 and level < self.max_level - 1:
            level += 1
        return level

    def _distance(self, v1: np.ndarray, v2: np.ndarray) -> float:
        """L2 distance"""
        return np.sqrt(np.sum((v1 - v2) ** 2))

    def _search_layer(
        self,
        query: np.ndarray,
        level: int,
        entry: int,
        ef: int
    ) -> List[Tuple[float, int]]:
        """Search in a layer: starting from entry, greedily find ef closest points"""
        if entry not in self.levels[level]:
            return []

        visited = set([entry])
        candidates = [(self._distance(query, self.levels[level][entry]), entry)]
        result = list(candidates)

        while candidates:
            # Take out the current closest candidate
            candidates.sort()
            dist, current = candidates.pop(0)

            # Traverse neighbors
            if current not in self.edges[level]:
                continue

            for neighbor in self.edges[level][current]:
                if neighbor in visited:
                    continue
                visited.add(neighbor)

                neighbor_dist = self._distance(query, self.levels[level][neighbor])

                # Add to candidates and result set
                candidates.append((neighbor_dist, neighbor))
                result.append((neighbor_dist, neighbor))

            # Keep the result set size no larger than ef
            result.sort()
            result = result[:ef]

        return result

    def add(self, vector: np.ndarray) -> int:
        """Add a vector"""
        vec_id = self.next_id
        self.next_id += 1

        # Randomly determine which layers this vector appears in
        max_level_for_vec = self._random_level()

        # Store the vector in all layers (in actual implementations, the full vector is usually stored only at the bottom layer)
        for level in range(max_level_for_vec + 1):
            self.levels[level][vec_id] = vector
            self.edges[level][vec_id] = set()

        # If it's the first point, set it as the entry
        if self.enter_point is None:
            self.enter_point = vec_id
            return vec_id

        # Search from the top layer downward to find the insertion position in each layer
        current_entry = self.enter_point

        # First search in layers above max_level_for_vec, updating the entry
        for level in range(self.max_level - 1, max_level_for_vec, -1):
            if current_entry in self.levels[level]:
                layer_result = self._search_layer(vector, level, current_entry, 1)
                if layer_result:
                    current_entry = layer_result[0][1]

        # In layers up to max_level_for_vec, establish connections
        for level in range(max_level_for_vec, -1, -1):
            # Search for the closest ef_construction points in this layer
            layer_result = self._search_layer(vector, level, current_entry, self.ef_construction)

            # Connect to these points (simplified: bidirectional connection)
            for dist, neighbor_id in layer_result:
                self.edges[level][vec_id].add(neighbor_id)
                self.edges[level][neighbor_id].add(vec_id)

            # Update the entry for the next layer
            if layer_result:
                current_entry = layer_result[0][1]

        # Update the top-layer entry
        if max_level_for_vec > self._random_level():  # Simplified check
            self.enter_point = vec_id

        return vec_id

    def search(self, query: np.ndarray, top_k: int = 5, ef: int = 10) -> List[Tuple[int, float]]:
        """Search Top K nearest neighbors"""
        if self.enter_point is None:
            return []

        current_entry = self.enter_point

        # From the top level down to the first level
        for level in range(self.max_level - 1, 0, -1):
            if current_entry in self.levels[level]:
                layer_result = self._search_layer(query, level, current_entry, 1)
                if layer_result:
                    current_entry = layer_result[0][1]

        # Search ef points at the bottom level, then return Top K
        layer_result = self._search_layer(query, 0, current_entry, ef)
        layer_result.sort()

        return [(vec_id, dist) for dist, vec_id in layer_result[:top_k]]


# Test
np.random.seed(42)
dim = 128
hnsw = SimpleHNSW(dim=dim, max_level=3, ef_construction=5)

# Add 100 vectors
print("Building HNSW index (adding 100 vectors)...")
for i in range(100):
    vec = np.random.randn(dim)
    hnsw.add(vec)
print(f"Index construction complete, entry point = {hnsw.enter_point}")

# Generate query vector
query = np.random.randn(dim)

# Search
print("\nExecuting HNSW search...")
results = hnsw.search(query, top_k=5, ef=10)
print("Search results:")
for vec_id, dist in results:
    print(f" Vector {vec_id:3d}, distance = {dist:.4f}")

# Output similar to:
# Building HNSW index (adding 100 vectors)...
# Index construction complete, entry point = 54
#
# Executing HNSW search...
# Search results:
# Vector 86, distance = 13.7515
# Vector 30, distance = 13.8651
# Vector 28, distance = 14.0754
# Vector 56, distance = 14.1106
# Vector 34, distance = 14.2131

Advantages of HNSW:

  • 1. Fast query: Usually only a few nodes need to be visited to find the nearest neighbor.

  • 2. Fast construction: Vectors can be added incrementally without rebuilding the entire index.

  • 3. Simple parameters: Mainly adjust ef_construction (graph quality) and ef (search quality).

IVF: Inverted File Index

The idea of IVF (Inverted File) is "divide and conquer".

Steps:

  • 1. Clustering: Divide the vector space into K "buckets" (using K-Means clustering).

  • 2. Indexing: Each vector is assigned to its nearest bucket.

  • 3. Query: First find the N buckets closest to the query vector, then search only within these N buckets.

Example

# ============================================
# Simplified demonstration of IVF principle
# ============================================

import numpy as np
from typing import List, Tuple


class SimpleIVF:
    """Simplified IVF, only for demonstrating the principle"""

    def __init__(self, dim: int, nlist: int = 10):
        self.dim = dim
        self.nlist = nlist  # Number of cluster centers (number of buckets)
        self.centroids: np.ndarray = None  # Cluster centers
        self.inverted_lists: List[List[Tuple[int, np.ndarray]]] = [[] for _ in range(nlist)]
        self.next_id = 0

    def _l2_distance(self, v1: np.ndarray, v2: np.ndarray) -> float:
        """L2 distance"""
        return np.sqrt(np.sum((v1 - v2) ** 2))

    def _find_nearest_centroid(self, vector: np.ndarray) -> int:
        """Find the nearest cluster center"""
        distances = [self._l2_distance(vector, c) for c in self.centroids]
        return int(np.argmin(distances))

    def fit(self, vectors: List[np.ndarray]):
        """Initialize cluster centers with K-Means (simplified: random selection)"""
        # Simplified: randomly select nlist vectors as initial centers
        # In actual implementation, K-Means iteration should be used
        indices = np.random.choice(len(vectors), self.nlist, replace=False)
        self.centroids = np.array([vectors[i] for i in indices])

    def add(self, vector: np.ndarray) -> int:
        """Add vector"""
        vec_id = self.next_id
        self.next_id += 1

        # Find the nearest bucket
        centroid_idx = self._find_nearest_centroid(vector)

        # Put it into this bucket
        self.inverted_lists[centroid_idx].append((vec_id, vector))

        return vec_id

    def search(self, query: np.ndarray, top_k: int = 5, nprobe: int = 3) -> List[Tuple[int, float]]:
        """
Search Top K
nprobe: how many buckets to search (more is more accurate, but slower)
        """

        # Find the nearest nprobe buckets
        centroid_distances = [
            (self._l2_distance(query, c), i)
            for i, c in enumerate(self.centroids)
        ]
        centroid_distances.sort()
        nearest_centroids = [i for dist, i in centroid_distances[:nprobe]]

        # Search only in these nprobe buckets
        candidates = []
        for centroid_idx in nearest_centroids:
            for vec_id, vec in self.inverted_lists[centroid_idx]:
                dist = self._l2_distance(query, vec)
                candidates.append((dist, vec_id))

        # Sort and return Top K
        candidates.sort()
        return [(vec_id, dist) for dist, vec_id in candidates[:top_k]]


# Test
np.random.seed(42)
dim = 128

# Generate 1000 vectors
vectors = [np.random.randn(dim) for _ in range(1000)]

# Build IVF index
ivf = SimpleIVF(dim=dim, nlist=20)
ivf.fit(vectors)

# Add vectors
for vec in vectors:
    ivf.add(vec)

print(f"IVF index construction complete, bucket count = {ivf.nlist}")
for i in range(ivf.nlist):
    print(f" Bucket {i:2d}: {len(ivf.inverted_lists[i])} vectors")

# Query
query = np.random.randn(dim)

print("\nExecuting IVF search (nprobe=3)...")
results = ivf.search(query, top_k=5, nprobe=3)
print("Search results:")
for vec_id, dist in results:
    print(f" Vector {vec_id:3d}, distance = {dist:.4f}")

# Output similar to:
# IVF index construction complete, bucket count = 20
# Bucket 0: 63 vectors
# Bucket 1: 56 vectors
#   ...
#
# Executing IVF search (nprobe=3)...
# Search results:
# Vector 863, distance = 13.5409
# Vector 189, distance = 13.7083
# Vector 491, distance = 13.8628
# Vector 264, distance = 13.9812
# Vector 781, distance = 14.0021

The advantage of IVF is its small memory footprint, and you can dynamically adjust the nprobe parameter at query time to balance speed and accuracy.

PQ: Product Quantization

PQ (Product Quantization) is a compression algorithm.

Core idea:

  • 1. Splitting: Split the high-dimensional vector into multiple smaller segments (subvectors).

  • 2. Quantization: Cluster each small segment independently, and replace the original vector with the cluster center's ID.

  • 3. Lookup: At query time, use a lookup table to quickly compute approximate distances.

For example, a 128-dimensional vector is split into 8 segments of 16 dimensions each, and each segment is quantized with 256 centers. As a result, each vector only needs 8 bytes of storage (originally 128 * 4 = 512 bytes).

IVF-PQ Combination

In production environments, IVF and PQ are usually combined:

IVF is responsible for "bucketing" to reduce the search scope, and PQ is responsible for "compression" to reduce memory and computation.

AlgorithmSpeedAccuracyMemoryApplicable scenarios
Brute-force searchSlow100%HighSmall data size, needs 100% accuracy
HNSWExtremely fastHighMediumQuery-intensive, needs low latency
IVFFastMediumLowLarge-scale data, acceptable small accuracy loss
IVF-PQVery fastMediumExtremely lowUltra-large-scale data, memory constrained

Similarity Metrics

There are many ways to compute "similarity", and choosing the right metric is important.

Cosine Similarity

Cosine similarity measures the angle between the directions of two vectors, with a range of [-1, 1].

The closer the value is to 1, the more similar; the closer to -1, the more opposite.

Example

# ============================================
# Cosine similarity computation
# ============================================

import numpy as np


def cosine_similarity(v1: np.ndarray, v2: np.ndarray) -> float:
    """
Cosine similarity = (v1 · v2) / (|v1| * |v2|)
Range [-1, 1], closer to 1 means more similar
    """

    dot_product = np.dot(v1, v2)
    norm1 = np.linalg.norm(v1)
    norm2 = np.linalg.norm(v2)
    return dot_product / (norm1 * norm2)


# Test
np.random.seed(42)
v1 = np.array([1, 2, 3, 4, 5])
v2 = np.array([2, 4, 6, 8, 10])  # Same direction
v3 = np.array([-1, -2, -3, -4, -5])  # Opposite direction
v4 = np.random.randn(5)  # Random

print(f"v1 = {v1}")
print(f"v2 = {v2}")
print(f"v3 = {v3}")
print(f"v4 = {v4}")
print()
print(f"cosine(v1, v2) = {cosine_similarity(v1, v2):.4f} (same direction, should be 1.0)")
print(f"cosine(v1, v3) = {cosine_similarity(v1, v3):.4f} (opposite direction, should be -1.0)")
print(f"cosine(v1, v4) = {cosine_similarity(v1, v4):.4f}")

# Output:
# v1 = [1 2 3 4 5]
# v2 = [ 2  4  6  8 10]
# v3 = [-1 -2 -3 -4 -5]
# v4 = [ 0.4967 -0.1383  0.6477  1.5230 -0.2342]
#
# cosine(v1, v2) = 1.0000 (same direction, should be 1.0)
# cosine(v1, v3) = -1.0000 (opposite direction, should be -1.0)
# cosine(v1, v4) = 0.2143

Cosine similarity's characteristic: it doesn't care about the length of the vector, only the direction. This is common in text embeddings, because the "semantics" of text is unrelated to length.

L2 Euclidean Distance

L2 distance is the straight-line distance between two points in space; the smaller, the more similar.

Inner Product (Dot Product)

Inner product = v1 · v2. If the vectors are normalized, the inner product equals cosine similarity.

Selection Guide

MetricValue rangeWhen to chooseTypical use cases
Cosine similarity[-1, 1]Only cares about direction, not lengthText embeddings, semantic search
L2 distance[0, ∞)Spatial position mattersImage features, recommendation systems
Inner product(-∞, ∞)Vectors are normalized, or length is meaningfulCertain specific embedding models

Most embedding models (e.g., OpenAI's text-embedding series) output normalized vectors; in this casecosine similarity = inner product, so it doesn't matter which one you use.


Comparison of Mainstream Vector Databases

Vector databases fall into three categories: local libraries (FAISS), lightweight open-source (Chroma, Qdrant), and enterprise-grade (Milvus, Pinecone, Weaviate).

FAISS: A Local Library from Facebook

FAISS is not a complete database, but a C++ library (with Python bindings).

Example

# ============================================
# FAISS basic usage demonstration
# ============================================

import numpy as np

# Note: You need to install FAISS first to run it
# pip install faiss-cpu (CPU version)
# or
# pip install faiss-gpu (GPU version)

# Below is a typical FAISS usage example (code sample, not actually executed)


def faiss_example():
    """FAISS usage example"""
    # 1. Prepare data
    dimension = 128
    nb = 10000  # Number of database vectors
    nq = 10  # Number of query vectors

    np.random.seed(42)
    xb = np.random.random((nb, dimension)).astype('float32')  # Database
    xq = np.random.random((nq, dimension)).astype('float32')  # Query

    # 2. Create index (IVF example)
    import faiss

    nlist = 100  # Number of cluster centers
    quantizer = faiss.IndexFlatL2(dimension)  # Quantizer
    index = faiss.IndexIVFFlat(quantizer, dimension, nlist, faiss.METRIC_L2)

    # 3. Train index
    index.train(xb)

    # 4. Add vectors
    index.add(xb)

    # 5. Set nprobe for search
    index.nprobe = 10

    # 6. Search Top 5
    k = 5
    distances, indices = index.search(xq, k)

    print("Query results (Top 5 for the first 2 queries):")
    for i in range(min(2, nq)):
        print(f"Query {i}:")
        for j in range(k):
            print(f" Index {indices[i][j]}, distance {distances[i][j]:.4f}")

    return index


# HNSW index example
def faiss_hnsw_example():
    """FAISS HNSW index"""
    import faiss

    dimension = 128
    index = faiss.IndexHNSWFlat(dimension, 32)  # 32 = M parameter (number of connections per node)
    index.hnsw.efConstruction = 40  # ef during graph construction
    index.hnsw.efSearch = 16  # ef during search

    # Add data...
    # index.add(xb)

    return index


# If FAISS is installed, you can uncomment the lines below to run
# faiss_example()
print("FAISS example code: This is a typical usage of FAISS.")
print("Key steps: create index → train → add vectors → search.")
print()
print("Common FAISS index types:")
print(" - IndexFlatL2: brute-force search (accurate but slow)")
print(" - IndexIVFFlat: IVF bucketed search")
print(" - IndexIVFPQ: IVF + PQ compression")
print(" - IndexHNSWFlat: HNSW graph index")

FAISS features:

  • 1. Extremely fast: C++ implementation, optimized to the extreme.

  • 2. Full-featured: Supports almost all mainstream ANN algorithms.

  • 3. Local: No network, no persistence (you need to save the index file yourself).

Applicable scenarios: offline batch processing, not wanting to set up a service, relatively small data volume.

Chroma: Lightweight and Local-First

Chroma is a simple and easy-to-use vector database designed to "make AI application development faster."

Example

# ============================================
# Chroma basic usage demonstration
# ============================================

# Install: pip install chromadb

def chroma_example():
    """Chroma usage example"""
    import chromadb
    from chromadb.utils import embedding_functions

    # 1. Initialize client (local mode, data saved to disk)
    client = chromadb.PersistentClient(path="./example-chroma-db")

    # 2. Create or get a collection
    collection = client.get_or_create_collection(
        name="example_docs",
        metadata={"description": "Example tutorial document collection"}
    )

    # 3. Add documents (Chroma handles embedding automatically)
    # You can use OpenAI, Cohere, etc., or provide your own vectors
    documents = [
        "Python is an interpreted, high-level, general-purpose programming language.",
        "JavaScript is a lightweight, interpreted programming language.",
        "Vector databases are used to store and retrieve vector embeddings.",
        "RAG, or Retrieval-Augmented Generation, is a technique that combines retrieval with generation.",
        "HNSW is an approximate nearest neighbor algorithm based on a hierarchical small-world graph."
    ]

    metadatas = [
        {"category": "programming", "language": "Python"},
        {"category": "programming", "language": "JavaScript"},
        {"category": "database", "topic": "vector"},
        {"category": "ai", "topic": "rag"},
        {"category": "algorithm", "topic": "hnsw"}
    ]

    ids = ["doc1", "doc2", "doc3", "doc4", "doc5"]

    # Add (using mock vectors here; in practice, use an embedding model)
    import numpy as np
    np.random.seed(42)
    embeddings = [np.random.randn(128).tolist() for _ in range(5)]

    collection.add(
        documents=documents,
        metadatas=metadatas,
        ids=ids,
        embeddings=embeddings  # If not provided, Chroma uses the default embed function
    )

    print(f"There are {collection.count()} documents in the collection")

    # 4. Query
    query_embedding = np.random.randn(128).tolist()

    results = collection.query(
        query_embeddings=[query_embedding],
        n_results=3,
        # Metadata filtering can be added
        # where={"category": "ai"}
    )

    print("\nQuery results:")
    for i in range(len(results['ids'][0])):
        print(f"ID: {results['ids'][0][i]}")
        print(f"Document: {results['documents'])
        print(f"Distance: {results['distances'])
        print("---")

    return collection


# If Chroma is installed, you can uncomment the lines below to run
# chroma_example()
print("Chroma example code: simple design, works out of the box.")
print()
print("Chroma features:")
print(" - Simple: friendly API, get started in minutes")
print(" - Local-first: can persist to disk or run in memory")
print(" - Built-in embeddings: supports OpenAI, Cohere, Sentence-Transformers")
print(" - Supports metadata filtering: can filter by metadata during queries")

Qdrant: Production-Grade Open Source

Qdrant is a vector database written in Rust, with good performance and full features.

Example

# ============================================
# Qdrant basic usage demo
# ============================================

# Install: pip install qdrant-client
# Start service: docker run -p 6333:6333 qdrant/qdrant

def qdrant_example():
    """Qdrant usage example"""
    from qdrant_client import QdrantClient
    from qdrant_client.models import Distance, VectorParams, PointStruct, Filter, FieldCondition, MatchValue

    # 1. Connect client
    client = QdrantClient(host="localhost", port=6333)

    # 2. Create collection
    client.recreate_collection(
        collection_name="example_collection",
        vectors_config=VectorParams(
            size=128,
            distance=Distance.COSINE  # or L2, DOT
        ),
        # Optional: configure HNSW index parameters
        # hnsw_config=HnswConfig(m=16, ef_construct=100)
    )

    # 3. Add points
    import numpy as np
    np.random.seed(42)

    points = [
        PointStruct(
            id=i,
            vector=np.random.randn(128).tolist(),
            payload={
                "text": f"Content of document {i}",
                "category": "tech" if i % 2 == 0 else "life",
                "author": f"author_{i % 3}"
            }
        )
        for i in range(100)
    ]

    client.upsert(
        collection_name="example_collection",
        points=points
    )

    # 4. Query
    query_vector = np.random.randn(128).tolist()

    results = client.search(
        collection_name="example_collection",
        query_vector=query_vector,
        limit=5,
        # Filtering can be added
        # query_filter=Filter(
        #     must=[
        #         FieldCondition(key="category", match=MatchValue(value="tech"))
        #     ]
        # )
    )

    print("Query results:")
    for result in results:
        print(f"ID: {result.id}, Score: {result.score:.4f}")
        print(f"Payload: {result.payload}")
        print("---")

    return client


# If Qdrant is running, you can uncomment the lines below to run
# qdrant_example()
print("Qdrant example code: Rust implementation, comprehensive features.")
print()
print("Qdrant features:")
print(" - Good performance: Rust + HNSW")
print(" - Full features: supports filtering, grouping, aggregation, pagination")
print(" - Easy deployment: one-click start with Docker")
print(" - Supports sharding: can scale horizontally")

Mainstream Vector Database Comparison Table

ProductTypeDeploymentFeaturesUse cases
FAISSLocal libraryNoneExtremely fast, full-featured, requires self-maintenanceOffline processing, don't want to set up a service
ChromaLightweightLocal/ServerSimple and easy to use, Python-firstPrototyping, small projects
QdrantOpen sourceDocker/K8sGood performance, full features, RustProduction environment, medium scale
MilvusOpen sourceDistributedEnterprise-grade, most complete featuresLarge-scale production environment
WeaviateOpen sourceDocker/K8sModular, good ecosystemNeed flexible composition of features
PineconeCloud serviceSaaSFully managed, scale on demandDon't want to operate, quick launch

Embedding Model Selection

Vector databases are just infrastructure; what truly determines results is the embedding model.

Comparison of Mainstream Embedding Models

ModelDimensionsFeaturesPrice
OpenAI text-embedding-3-small1536High cost-performance, good overall quality$0.00002 / 1K tokens
OpenAI text-embedding-3-large3072Highest quality, high dimensions$0.00013 / 1K tokens
Cohere Embed v31024Good multilingual support$0.0001 / 1K tokens
bge-large-zh-v1.51024Good for Chinese, open source and freeFree
bge-m31024Multilingual, multi-functionalFree
gte-large1024Balanced choice, open sourceFree

Dimensions vs. Performance Trade-off

The higher the dimensions, the better the results usually, but:

  • 1. Storage is more expensive: 3072 dimensions take up twice as much space as 1536 dimensions.

  • 2. Computation is slower: Distance computation cost is proportional to dimensionality.

  • 3. Larger index: HNSW/IVF indexes will also be larger.

Recommendation:First test with a low-dimensional model; if it's not enough, move to a higher dimension.. In most scenarios, 1536 dimensions is already good enough.


Hybrid Search

Pure vector search is sometimes not good enough, because it only matches "semantics", not "keywords".

Hybrid search = vector search + keyword search, then fuse the results.

BM25 Sparse Retrieval

BM25 is a classic algorithm in traditional search, measuring the degree of keyword matching.

Example

# ============================================
# BM25 keyword search demo
# ============================================

import math
from typing import List, Dict, Set


class SimpleBM25:
    """Simplified BM25 implementation"""

    def __init__(self, k1: float = 1.5, b: float = 0.75):
        self.k1 = k1
        self.b = b
        self.documents: List[List[str]] = []
        self.doc_lengths: List[int] = []
        self.avg_doc_length: float = 0
        self.term_freqs: List[Dict[str, int]] = []
        self.doc_freq: Dict[str, int] = {}
        self.num_docs: int = 0

    def _tokenize(self, text: str) -> List[str]:
        """Simple tokenization (by spaces and punctuation)"""
        # Simplified: convert to lowercase, split by non-alphanumeric characters
        import re
        return [t.lower() for t in re.findall(r'\w+', text)]

    def fit(self, documents: List[str]):
        """Build index"""
        self.documents = []
        self.doc_lengths = []
        self.term_freqs = []
        self.doc_freq = {}
        self.num_docs = len(documents)

        for doc in documents:
            tokens = self._tokenize(doc)
            self.documents.append(tokens)
            self.doc_lengths.append(len(tokens))

            # Count term frequency
            tf: Dict[str, int] = {}
            for token in tokens:
                tf[token] = tf.get(token, 0) + 1
            self.term_freqs.append(tf)

            # Update document frequency
            for token in tf.keys():
                self.doc_freq[token] = self.doc_freq.get(token, 0) + 1

        self.avg_doc_length = sum(self.doc_lengths) / self.num_docs

    def score(self, query: str) -> List[float]:
        """Calculate BM25 score for each document"""
        query_tokens = self._tokenize(query)
        scores = [0.0] * self.num_docs

        for token in query_tokens:
            if token not in self.doc_freq:
                continue

            # IDF calculation
            df = self.doc_freq[token]
            idf = math.log(1 + (self.num_docs - df + 0.5) / (df + 0.5))

            # Calculate score for each document
            for doc_idx in range(self.num_docs):
                doc_len = self.doc_lengths[doc_idx]
                tf = self.term_freqs[doc_idx].get(token, 0)

                # BM25 core formula
                numerator = tf * (self.k1 + 1)
                denominator = tf + self.k1 * (1 - self.b + self.b * doc_len / self.avg_doc_length)
                scores[doc_idx] += idf * numerator / denominator

        return scores

    def search(self, query: str, top_k: int = 5) -> List[Dict]:
        """Search Top K"""
        scores = self.score(query)

        # Sort
        results = [
            {"doc_idx": i, "score": s}
            for i, s in enumerate(scores)
        ]
        results.sort(key=lambda x: x["score"], reverse=True)

        return results[:top_k]


# Test
documents = [
    "Python is a programming language, easy to learn.",
    "JavaScript is used for web development, usable on both frontend and backend.",
    "Vector databases are used to store and retrieve vector embeddings.",
    "There are many types of databases, such as relational databases and NoSQL databases.",
    "HNSW is an approximate nearest neighbor search algorithm."
]

bm25 = SimpleBM25()
bm25.fit(documents)

# Search
query = "database"
results = bm25.search(query, top_k=5)

print(f"Query:\"{query}\"")
print("BM25 search results:")
for r in results:
    print(f" Document {r['doc_idx']}: {documents[r['doc_idx']]}")
    print(f" Score = {r['score']:.4f}")

# Output similar to:
# Query: "database"
# BM25 search results:
# Document 2: Vector databases are used to store and retrieve vector embeddings.
# Score = 0.7134
# Document 3: There are many types of databases, such as relational databases and NoSQL databases.
# Score = 0.6148
#   ...

RRF: Reciprocal Rank Fusion

RRF (Reciprocal Rank Fusion) is a simple and effective method for fusing multiple search results.

Formula: score = 1 / (k + rank), where k is usually 60.

Example

# ============================================
# RRF fusion demo
# ============================================

from typing import List, Dict, Any


def rrf_fuse(
    result_lists: List[List[Any]],
    k: int = 60
) -> Dict[Any, float]:
    """
RRF fusion: for each result list, add scores using 1/(k + rank)
result_lists: multiple sorted result lists
    """

    scores: Dict[Any, float] = {}

    for results in result_lists:
        for rank, item in enumerate(results):
            score = 1.0 / (k + rank)
            scores[item] = scores.get(item, 0) + score

    return scores


# Example: vector search results and BM25 search results
vector_results = ["doc3", "doc1", "doc4", "doc2", "doc5"]  # Vector search considers doc3 most relevant
bm25_results = ["doc2", "doc4", "doc3", "doc5", "doc1"]    # BM25 considers doc2 most relevant

# RRF fusion
fused = rrf_fuse([vector_results, bm25_results])

# Sort
final_rank = sorted(fused.items(), key=lambda x: x[1], reverse=True)

print("Vector search results:", vector_results)
print("BM25 search results:", bm25_results)
print()
print("Final ranking after RRF fusion:")
for doc, score in final_rank:
    print(f"  {doc}: {score:.6f}")

# Output:
# Vector search results: ['doc3', 'doc1', 'doc4', 'doc2', 'doc5']
# BM25 search results: ['doc2', 'doc4', 'doc3', 'doc5', 'doc1']
#
# Final ranking after RRF fusion:
#   doc3: 0.032787
#   doc2: 0.032787
#   doc4: 0.032520
#   doc1: 0.016393
#   doc5: 0.016393

Fusion Weight Tuning

Besides RRF, weighted linear fusion can also be used:

final_score = α * vector_score + (1 - α) * bm25_score

α is the weight for vector search, which needs to be tuned based on actual data.


Re-ranking

Hybrid search can be further improved: first recall a batch of candidates, then re-rank with a more powerful model.

Cross-Encoder Principles

Bi-Encoder (for retrieval): encode query and document separately, compare with similarity.

Cross-Encoder (for reranking): concatenate query and document as input to the model, directly output a relevance score.

Cross-Encoder is more accurate but slower, so it is suitable for reranking only the top 50-100 candidates.

Example

# ============================================
# Re-ranking process demo
# ============================================

def reranking_pipeline_example():
    """
Two-stage retrieval process:
1. Coarse retrieval: use vector search to quickly find the Top 100
2. Fine ranking: use Cross-Encoder to rerank the Top 100
    """

    print("Two-stage retrieval process:")
    print()
    print("Stage 1: Coarse retrieval (vector search)")
    print(" - Goal: fast, low latency")
    print(" - Method: HNSW/IVF vector search")
    print(" - Output: Top 100 candidates")
    print()
    print("Stage 2: Fine ranking (Cross-Encoder)")
    print(" - Goal: accurate, high quality")
    print(" - Method: Cross-Encoder pairwise scoring")
    print(" - Output: final Top 10")
    print()
    print("Typical configuration:")
    print(" - Retrieval stage: HNSW efSearch = 128, recall 100")
    print(" - Reranking stage: use bge-reranker or Cohere Rerank")
    print()
    print("Why not directly use Cross-Encoder to search everything?")
    print(" - Cross-Encoder is too slow; searching 1 million documents is unrealistic")
    print(" - Vector search over 1 million is fast, and Cross-Encoder reranking over 100 is also fast")


# Cohere Rerank API example (pseudocode)
def cohere_rerank_example():
    """
Cohere Rerank API usage example
    """

    print("Typical usage of the Cohere Rerank API:")
    print()
    print('''
    import cohere

    co = cohere.Client(api_key="your-api-key")

query = "What is a vector database?"
    documents = [
"Content of document 1...",
"Content of document 2...",
... # top 100 candidates
    ]

    results = co.rerank(
        query=query,
        documents=documents,
        top_n=10,
        model="rerank-english-v3.0"
    )

    for r in results:
        print(r.index, r.relevance_score, documents[r.index])
    '''
)


reranking_pipeline_example()

Performance vs. Latency Trade-off

ApproachQualitySpeedCostApplicable scenario
Pure vector searchMediumExtremely fastLowFor extremely high speed requirements
Hybrid searchMedium-highFastMediumBalanced choice
Vector + rerankingHighMediumMedium-highPursuing quality
Hybrid + rerankingHighestMedium-slowHighQuality first

Production Environment Optimization

From demo to production, there are many details to pay attention to.

Index Sharding Strategies

When data volume exceeds single-machine capacity, sharding is needed:

  • 1. Shard by ID: Simple, but queries need to search all shards.

  • 2. Shard by vector clustering: Only search a few relevant shards during queries.

Metadata Filtering

In production environments, metadata filtering is almost always combined.

Two strategies:

  • 1. Pre-filtering: First filter by metadata, then perform vector search.

  • 2. Post-filtering: First perform vector search, then filter by metadata.

Selection principle: if many vectors remain after filtering, use pre-filtering; if few remain, use post-filtering.

Incremental Updates

Most vector databases support incremental addition, but note:

  • 1. Batch addition: Add in batches, not one by one.

  • 2. Index rebuilding: Some indexes (e.g., IVF) need periodic retraining.

  • 3. Version management: Keep old index versions so you can roll back on updates.

Monitoring Metrics

Production environments should monitor:

  • 1. Query latency:P50、P95、P99。

  • 2. Recall rate: Regularly check the quality of Top K results.

  • 3. Index size: Memory/disk usage.

  • 4. QPSQueries per second.

Other extensions