•15 min read

sqlite-vec vs pgvector: Embedded Local Vector Search for Desktop & Edge Applications

sqlite-vec vs pgvector: Embedded Local Vector Search for Desktop & Edge Applications

The proliferation of vector embeddings has driven a demand for efficient similarity search. While pgvector has become a de-facto standard for server-side vector databases, embedded and edge applications present unique architectural constraints. This guide empirically compares sqlite-vec (Mozilla/Alex Garcia) with pgvector, focusing on their suitability for desktop (Electron, Tauri) and edge worker environments. We will evaluate RAM consumption, index build times, int8 scalar quantization, and benchmark recall across 100k embeddings.

Audio Briefing
0:00 / 0:00

Architectural Overview: sqlite-vec vs pgvector

pgvector extends PostgreSQL, a robust client-server relational database. It offers ACID compliance, complex query capabilities, and high availability features. sqlite-vec, conversely, is a SQLite extension, embedding vector search directly into the application's process. This fundamental difference dictates their respective use cases and performance characteristics.

| Feature | sqlite-vec | pgvector | |---|---| | Architecture | Embedded (in-process) | Client-Server | | Deployment | Single file, no server | Requires PostgreSQL server | | Data Model | SQLite tables | PostgreSQL tables | | Indexing | HNSW, IVF, Flat | IVF, HNSW, Flat | | Quantization | int8 scalar | None (as of 0.6.0) | | Concurrency | SQLite's concurrency model | PostgreSQL's MVCC | | Use Case | Edge, Desktop, Mobile | Server, Cloud, Distributed | | Setup Complexity | Low | Moderate |

sqlite-vec Internals

sqlite-vec leverages SQLite's loadable extension mechanism. It introduces new SQL functions and virtual tables for vector operations. The core indexing algorithms (HNSW, IVF) are implemented in Rust, compiled to a shared library (.so, .dylib, .dll) that SQLite loads at runtime. This allows for native performance without external dependencies beyond the SQLite library itself. int8 scalar quantization is a key differentiator, significantly reducing storage and memory footprint for vectors.

pgvector Internals

pgvector is a PostgreSQL extension written in C. It adds a new vector data type and operators for similarity search. It integrates seamlessly with PostgreSQL's query planner and transaction system. While powerful, its client-server model introduces network latency and requires a separate PostgreSQL instance, which is often impractical for embedded scenarios.

Advertisement

Setup and Data Preparation

For our benchmark, we'll use a dataset of 100,000 384-dimensional embeddings generated from a sentence transformer model (e.g., all-MiniLM-L6-v2).

First, generate synthetic embeddings:

import numpy as np
import sqlite3
import time
from pgvector.psycopg import register_vector
import psycopg
import os

# Configuration
NUM_EMBEDDINGS = 100_000
DIMENSIONS = 384
SQLITE_DB_PATH = "embeddings.db"
PG_DB_NAME = "vector_benchmark"
PG_USER = "postgres"
PG_PASSWORD = "password" # Use a strong password in production
PG_HOST = "localhost"
PG_PORT = 5432

# Generate synthetic embeddings
print(f"Generating {NUM_EMBEDDINGS} synthetic embeddings...")
embeddings = np.random.rand(NUM_EMBEDDINGS, DIMENSIONS).astype(np.float32)
# Normalize embeddings to unit length, common practice for cosine similarity
embeddings = embeddings / np.linalg.norm(embeddings, axis=1, keepdims=True)
print("Embeddings generated.")

# --- SQLite-vec Setup ---
print("\n--- Setting up sqlite-vec ---")
try:
    # Connect to SQLite database
    sqlite_conn = sqlite3.connect(SQLITE_DB_PATH)
    sqlite_cursor = sqlite_conn.cursor()

    # Load sqlite-vec extension (adjust path as necessary for your OS)
    # On Linux: libsqlite_vec.so, macOS: libsqlite_vec.dylib, Windows: sqlite_vec.dll
    # Ensure you have compiled/downloaded the correct extension for your system.
    # Example for a pre-compiled Linux extension:
    sqlite_conn.enable_load_extension(True)
    try:
        sqlite_cursor.execute("SELECT load_extension('./libsqlite_vec.so');")
    except sqlite3.OperationalError as e:
        print(f"Error loading sqlite-vec extension: {e}")
        print("Please ensure 'libsqlite_vec.so' (or .dylib/.dll) is in the current directory or specified path.")
        print("You might need to compile it from source: https://github.com/asg017/sqlite-vec")
        exit(1)
    sqlite_conn.enable_load_extension(False)

    # Create table for embeddings
    sqlite_cursor.execute(f"""
        CREATE TABLE IF NOT EXISTS items (
            id INTEGER PRIMARY KEY,
            embedding BLOB
        );
    """)
    sqlite_conn.commit()

    # Insert embeddings
    print(f"Inserting {NUM_EMBEDDINGS} embeddings into sqlite-vec...")
    insert_start_time = time.time()
    batch_size = 1000
    for i in range(0, NUM_EMBEDDINGS, batch_size):
        batch = embeddings[i:i+batch_size]
        sqlite_cursor.executemany(
            "INSERT INTO items (id, embedding) VALUES (?, ?)",
            [(i + j, vec.tobytes()) for j, vec in enumerate(batch)]
        )
        sqlite_conn.commit()
    insert_end_time = time.time()
    print(f"sqlite-vec insertion time: {insert_end_time - insert_start_time:.2f} seconds")

except Exception as e:
    print(f"Error during sqlite-vec setup: {e}")
    if 'sqlite_vec' in str(e):
        print("Ensure the sqlite-vec extension is correctly compiled and loaded.")
    exit(1)


# --- pgvector Setup ---
print("\n--- Setting up pgvector ---")
try:
    # Connect to PostgreSQL
    pg_conn = psycopg.connect(f"dbname={PG_DB_NAME} user={PG_USER} password={PG_PASSWORD} host={PG_HOST} port={PG_PORT}")
    pg_conn.autocommit = True # For CREATE DATABASE/EXTENSION
    pg_cursor = pg_conn.cursor()

    # Create database if not exists
    try:
        pg_cursor.execute(f"CREATE DATABASE {PG_DB_NAME};")
        print(f"Database '{PG_DB_NAME}' created.")
    except psycopg.errors.DuplicateDatabase:
        print(f"Database '{PG_DB_NAME}' already exists.")
    except Exception as e:
        print(f"Error creating database: {e}")
        print("Ensure PostgreSQL is running and user has permissions.")
        exit(1)

    pg_conn.close() # Close and reconnect to the new database

    pg_conn = psycopg.connect(f"dbname={PG_DB_NAME} user={PG_USER} password={PG_PASSWORD} host={PG_HOST} port={PG_PORT}")
    pg_cursor = pg_conn.cursor()

    # Enable pgvector extension
    pg_cursor.execute("CREATE EXTENSION IF NOT EXISTS vector;")
    print("pgvector extension enabled.")

    # Register vector type for psycopg
    register_vector(pg_conn)

    # Create table for embeddings
    pg_cursor.execute(f"""
        CREATE TABLE IF NOT EXISTS items (
            id SERIAL PRIMARY KEY,
            embedding vector({DIMENSIONS})
        );
    """)
    pg_conn.commit()

    # Insert embeddings
    print(f"Inserting {NUM_EMBEDDINGS} embeddings into pgvector...")
    insert_start_time = time.time()
    batch_size = 1000
    for i in range(0, NUM_EMBEDDINGS, batch_size):
        batch = embeddings[i:i+batch_size]
        pg_cursor.executemany(
            "INSERT INTO items (embedding) VALUES (%s)",
            [(vec,) for vec in batch]
        )
        pg_conn.commit()
    insert_end_time = time.time()
    print(f"pgvector insertion time: {insert_end_time - insert_start_time:.2f} seconds")

except Exception as e:
    print(f"Error during pgvector setup: {e}")
    print("Ensure PostgreSQL is running and accessible.")
    exit(1)

print("\nData setup complete for both databases.")

Note on sqlite-vec extension: You must compile sqlite-vec from source or download a pre-compiled binary for your specific OS and architecture. Place the shared library (libsqlite_vec.so, libsqlite_vec.dylib, or sqlite_vec.dll) in a location accessible to your application, or specify its full path.

Indexing and Benchmarking

We'll benchmark index build times, RAM consumption, and search performance (recall and latency) for both sqlite-vec and pgvector. For sqlite-vec, we'll also evaluate the impact of int8 scalar quantization.

Indexing Strategies

  • sqlite-vec: We'll use HNSW (Hierarchical Navigable Small Worlds) for its balance of speed and recall. We'll also test int8 quantized HNSW.
  • pgvector: We'll use IVF (Inverted File Index) and HNSW. IVF is often faster for exact nearest neighbor search on smaller datasets, while HNSW offers better recall at scale.
import numpy as np
import sqlite3
import time
from pgvector.psycopg import register_vector
import psycopg
import os
import faiss # For ground truth and recall calculation
from sklearn.metrics import top_k_accuracy_score

# Configuration (same as above, ensure NUM_EMBEDDINGS, DIMENSIONS match)
NUM_EMBEDDINGS = 100_000
DIMENSIONS = 384
SQLITE_DB_PATH = "embeddings.db"
PG_DB_NAME = "vector_benchmark"
PG_USER = "postgres"
PG_PASSWORD = "password"
PG_HOST = "localhost"
PG_PORT = 5432
K_SEARCH = 10 # Number of neighbors to retrieve
NUM_QUERIES = 100 # Number of queries for benchmarking

# Reconnect to databases
sqlite_conn = sqlite3.connect(SQLITE_DB_PATH)
sqlite_conn.enable_load_extension(True)
sqlite_cursor = sqlite_conn.cursor()
try:
    sqlite_cursor.execute("SELECT load_extension('./libsqlite_vec.so');")
except sqlite3.OperationalError as e:
    print(f"Error loading sqlite-vec extension: {e}")
    exit(1)
sqlite_conn.enable_load_extension(False)

pg_conn = psycopg.connect(f"dbname={PG_DB_NAME} user={PG_USER} password={PG_PASSWORD} host={PG_HOST} port={PG_PORT}")
pg_cursor = pg_conn.cursor()
register_vector(pg_conn)

# Load all embeddings for ground truth and query generation
print("Loading all embeddings for ground truth...")
sqlite_cursor.execute("SELECT embedding FROM items ORDER BY id")
all_embeddings_bytes = sqlite_cursor.fetchall()
all_embeddings = np.array([np.frombuffer(e[0], dtype=np.float32) for e in all_embeddings_bytes])
print(f"Loaded {len(all_embeddings)} embeddings.")

# Generate query embeddings (random subset of existing embeddings)
query_indices = np.random.choice(NUM_EMBEDDINGS, NUM_QUERIES, replace=False)
query_embeddings = all_embeddings[query_indices]

# --- Ground Truth (using FAISS for exact nearest neighbors) ---
print("\nCalculating ground truth using FAISS...")
faiss_index = faiss.IndexFlatL2(DIMENSIONS) # L2 for cosine similarity on normalized vectors
faiss_index.add(all_embeddings)
D_gt, I_gt = faiss_index.search(query_embeddings, K_SEARCH)
print("Ground truth calculated.")

# Function to calculate recall@K
def calculate_recall(retrieved_ids, ground_truth_ids, k):
    hits = 0
    for i in range(len(retrieved_ids)):
        # Check if any of the top K retrieved IDs are in the ground truth top K
        # Note: For exact matches, we expect the query itself to be in the top K.
        # For approximate search, we check if the retrieved set overlaps with the ground truth set.
        retrieved_set = set(retrieved_ids[i])
        ground_truth_set = set(ground_truth_ids[i])
        if not ground_truth_set.isdisjoint(retrieved_set):
            hits += 1
    return hits / len(retrieved_ids)

# --- Benchmarking Function ---
def benchmark_search(db_type, index_type, index_params, query_sql, conn, cursor, is_sqlite_vec=False, is_quantized=False):
    print(f"\n--- Benchmarking {db_type} with {index_type} ({index_params}) ---")

    # Drop existing index if any
    if db_type == "sqlite-vec":
        try:
            cursor.execute(f"DROP TABLE IF EXISTS items_vec_idx;")
            conn.commit()
        except sqlite3.OperationalError:
            pass # Index might not exist
    elif db_type == "pgvector":
        try:
            cursor.execute(f"DROP INDEX IF EXISTS items_embedding_idx;")
            conn.commit()
        except psycopg.errors.UndefinedObject:
            pass # Index might not exist

    # Build Index
    print(f"Building {index_type} index...")
    build_start_time = time.time()
    if db_type == "sqlite-vec":
        if is_quantized:
            cursor.execute(f"CREATE VIRTUAL TABLE items_vec_idx USING vec0(items, embedding, {DIMENSIONS}, 'quantizer=int8', '{index_type}={index_params}');")
        else:
            cursor.execute(f"CREATE VIRTUAL TABLE items_vec_idx USING vec0(items, embedding, {DIMENSIONS}, '{index_type}={index_params}');")
        conn.commit()
    elif db_type == "pgvector":
        cursor.execute(f"CREATE INDEX items_embedding_idx ON items USING {index_type}(embedding {index_params});")
        conn.commit()
    build_end_time = time.time()
    print(f"Index build time: {build_end_time - build_start_time:.2f} seconds")

    # Measure RAM usage (approximate, depends on OS/tooling)
    # For sqlite-vec, this is the process's RAM. For pgvector, it's the PostgreSQL server's RAM.
    # This is hard to measure programmatically in a cross-platform way.
    # For a real benchmark, use OS-specific tools (e.g., `ps aux` on Linux, Activity Monitor on macOS).
    print("RAM usage measurement requires external OS tools.")

    # Perform searches
    search_latencies = []
    retrieved_ids_list = []

    print(f"Performing {NUM_QUERIES} searches...")
    for i, query_vec in enumerate(query_embeddings):
        search_start_time = time.time()
        if db_type == "sqlite-vec":
            # For sqlite-vec, the query uses the virtual table
            cursor.execute(query_sql, (query_vec.tobytes(), K_SEARCH))
        elif db_type == "pgvector":
            cursor.execute(query_sql, (query_vec, K_SEARCH))

        results = cursor.fetchall()
        search_end_time = time.time()
        search_latencies.append(search_end_time - search_start_time)
        retrieved_ids_list.append([r[0] for r in results]) # Assuming ID is the first column

    avg_latency = np.mean(search_latencies)
    p95_latency = np.percentile(search_latencies, 95)
    print(f"Average search latency: {avg_latency * 1000:.2f} ms")
    print(f"P95 search latency: {p95_latency * 1000:.2f} ms")

    # Calculate recall
    # We need to map the ground truth IDs to the retrieved IDs.
    # For simplicity, we assume the IDs are 0-indexed and correspond to the original `all_embeddings` array.
    # The ground truth `I_gt` contains indices, which are effectively IDs.
    recall = calculate_recall(retrieved_ids_list, I_gt, K_SEARCH)
    print(f"Recall@{K_SEARCH}: {recall:.4f}")

    return {
        "db_type": db_type,
        "index_type": index_type,
        "index_params": index_params,
        "build_time": build_end_time - build_start_time,
        "avg_latency_ms": avg_latency * 1000,
        "p95_latency_ms": p95_latency * 1000,
        "recall_at_k": recall
    }

# --- Run Benchmarks ---
results = []

# sqlite-vec: HNSW (default float32)
results.append(benchmark_search(
    "sqlite-vec", "hnsw", "M=16,ef_construction=100",
    "SELECT id FROM items_vec_idx WHERE embedding MATCH ? ORDER BY distance LIMIT ?",
    sqlite_conn, sqlite_cursor, is_sqlite_vec=True
))

# sqlite-vec: HNSW with int8 quantization
results.append(benchmark_search(
    "sqlite-vec", "hnsw", "M=16,ef_construction=100",
    "SELECT id FROM items_vec_idx WHERE embedding MATCH ? ORDER BY distance LIMIT ?",
    sqlite_conn, sqlite_cursor, is_sqlite_vec=True, is_quantized=True
))

# pgvector: IVF (lists=100)
# For 100k embeddings, lists=100 is a reasonable starting point.
# The `vector_l2_ops` is for L2 distance, which is equivalent to cosine for normalized vectors.
results.append(benchmark_search(
    "pgvector", "ivfflat", "vector_l2_ops, lists=100",
    f"SELECT id FROM items ORDER BY embedding <-> %s LIMIT %s",
    pg_conn, pg_cursor
))

# pgvector: HNSW (m=16, ef_construction=64)
# Note: pgvector's HNSW parameters are slightly different from sqlite-vec's.
# `m` is the number of neighbors, `ef_construction` is for build time.
results.append(benchmark_search(
    "pgvector", "hnsw", "vector_l2_ops, m=16, ef_construction=64",
    f"SELECT id FROM items ORDER BY embedding <-> %s LIMIT %s",
    pg_conn, pg_cursor
))

# Print results table
print("\n--- Benchmark Summary ---")
print("| DB | Index Type | Params | Build Time (s) | Avg Latency (ms) | P95 Latency (ms) | Recall@10 |")
print("|---|---|---|---|---|---|---|")
for r in results:
    print(f"| {r['db_type']} | {r['index_type']} | {r['index_params']} | {r['build_time']:.2f} | {r['avg_latency_ms']:.2f} | {r['p95_latency_ms']:.2f} | {r['recall_at_k']:.4f} |")

# Clean up
sqlite_conn.close()
pg_conn.close()

Interpreting Benchmark Results

The specific numbers will vary based on hardware, exact sqlite-vec and pgvector versions, and PostgreSQL configuration. However, general trends are expected:

  • Index Build Time: sqlite-vec often builds indexes faster due to its in-process nature, avoiding network overhead and complex server-side resource management. pgvector's HNSW can be resource-intensive during build.
  • RAM Consumption: sqlite-vec's int8 quantization will dramatically reduce RAM footprint compared to float32 indexes in both sqlite-vec (unquantized) and pgvector. This is critical for edge devices.
  • Search Latency: For small to medium datasets (e.g., 100k-1M vectors), sqlite-vec can achieve comparable or even lower latencies than pgvector due to zero network overhead. As datasets scale, pgvector's server-side optimizations and ability to distribute workloads might give it an edge.
  • Recall: int8 quantization introduces a slight drop in recall due to precision loss. The trade-off is significantly reduced memory and storage. For many applications, this recall drop is acceptable. HNSW generally offers better recall than IVF at similar performance levels.

Production Gotchas & Troubleshooting

sqlite-vec

  1. Extension Loading Errors:

    • Failure Mode: sqlite3.OperationalError: unable to load extension: ...
    • Cause: Incorrect path to libsqlite_vec.so (or .dylib, .dll), architecture mismatch (e.g., trying to load an ARM binary on x86), or missing dependencies.
    • Fix:
      • Verify the extension file exists at the specified path.
      • Ensure the extension is compiled for the correct OS and CPU architecture of the target system.
      • Check ldd libsqlite_vec.so (Linux) or otool -L libsqlite_vec.dylib (macOS) for missing shared libraries.
      • For Electron/Tauri, ensure the extension is bundled correctly and loaded from the application's resource path.
  2. int8 Quantization Recall Drop:

    • Failure Mode: Search results are noticeably worse after enabling int8 quantization.
    • Cause: int8 quantization reduces the precision of each vector component from 32-bit float to 8-bit integer. This loss of information can impact similarity calculations, especially for embeddings that are already very close.
    • Fix:
      • Evaluate if the recall drop is acceptable for your application's requirements.
      • Consider using a larger M or ef_construction for HNSW with int8 to compensate, though this increases index build time and size.
      • If high precision is paramount, stick to float32 (default).
  3. Concurrency Issues:

    • Failure Mode: sqlite3.OperationalError: database is locked
    • Cause: SQLite is designed for single-writer, multiple-reader concurrency. If multiple threads/processes try to write simultaneously, this error occurs.
    • Fix:
      • Implement proper locking mechanisms (e.g., threading.Lock in Python, Mutex in Rust/C++).
      • Use WAL (Write-Ahead Logging) mode: PRAGMA journal_mode=WAL;. This significantly improves concurrency for readers while writers are active.
      • For high-write concurrency, SQLite might not be the right choice; consider a client-server database.

pgvector

  1. PostgreSQL Connection Errors:

    • Failure Mode: psycopg.OperationalError: connection to server at "localhost" (::1), port 5432 failed: Connection refused
    • Cause: PostgreSQL server is not running, incorrect host/port, or firewall blocking the connection.
    • Fix:
      • Start the PostgreSQL service.
      • Verify postgresql.conf for listen_addresses and port.
      • Check pg_hba.conf for client authentication rules.
      • Adjust firewall rules to allow connections to port 5432.
  2. Index Build Time and Resource Consumption:

    • Failure Mode: CREATE INDEX takes an excessively long time or consumes all available RAM/CPU on the server.
    • Cause: Large dataset, insufficient server resources, or suboptimal index parameters (e.g., too high ef_construction for HNSW).
    • Fix:
      • Increase work_mem in postgresql.conf for index builds.
      • Allocate more RAM and CPU to the PostgreSQL server.
      • Adjust HNSW parameters (m, ef_construction) or IVF parameters (lists) to balance build time, size, and recall. Start with lower values and incrementally increase.
      • Consider building indexes during off-peak hours.
  3. vector Type Not Found:

    • Failure Mode: psycopg.errors.UndefinedObject: type "vector" does not exist
    • Cause: pgvector extension is not enabled in the database.
    • Fix: Run CREATE EXTENSION IF NOT EXISTS vector; in the target database.
Advertisement

Frequently Asked Questions

  1. When should I choose sqlite-vec over pgvector? Choose sqlite-vec for embedded applications (desktop, mobile, edge devices) where a full client-server database is overkill or impractical. This includes Electron/Tauri apps, IoT devices, or serverless edge functions where you need local, low-latency vector search without external dependencies. Its int8 quantization is a strong advantage for memory-constrained environments.

  2. Can sqlite-vec handle millions of vectors? Yes, sqlite-vec can handle millions of vectors, especially with HNSW indexing and int8 quantization. However, performance will ultimately be limited by the host device's CPU, RAM, and disk I/O. For datasets exceeding tens of millions or requiring distributed processing, pgvector (potentially with sharding) or dedicated vector databases like Milvus/Weaviate become more suitable.

  3. What is the performance impact of int8 quantization in sqlite-vec? int8 quantization typically reduces the memory footprint of vectors by 75% (from 4 bytes per dimension to 1 byte). This leads to smaller index sizes, faster loading, and reduced RAM consumption. The trade-off is a slight, but often acceptable, drop in recall (typically 1-5% depending on the dataset and embedding quality).

  4. How do I ensure sqlite-vec is secure in a desktop application? Since sqlite-vec is an embedded database, its security is tied to the application's security.

    • Data Encryption: Use SQLite's encryption extensions (e.g., SQLCipher) to encrypt the database file at rest.
    • Code Integrity: Ensure your application binary is signed and tamper-proof.
    • Input Validation: Sanitize all user inputs to prevent SQL injection, even if the database is local.
    • Access Control: Implement application-level access control if multiple users or components interact with the database.
  5. What are the best practices for tuning HNSW parameters in sqlite-vec and pgvector?

    • M (Neighbors per node): Controls graph connectivity. Higher M improves recall but increases index size and build time. Typical values: 16-64.
    • ef_construction (Build time search scope): Controls the quality of the graph during index construction. Higher ef_construction leads to better recall but much longer build times. Typical values: 64-200.
    • ef_search (Query time search scope): Controls the search accuracy at query time. Higher ef_search improves recall but increases query latency. This can often be tuned dynamically without rebuilding the index. Typical values: 32-128.
    • lists (IVF): For pgvector's IVF, lists (number of inverted lists) should be sqrt(N) to 10*sqrt(N) where N is the number of vectors. More lists reduce search time but increase index size and build time.
    • Iterative Tuning: Start with conservative parameters, benchmark, and then incrementally increase M, ef_construction, or lists until you achieve the desired recall/latency trade-off.
Share this article:

Stay Updated

Get the latest posts delivered straight to your inbox.

Free Developer Utilities

Free In-Browser Developer Tools

Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.

Explore Tools
Advertisement