How to use sqlite-vec to store and query vector embeddings

Vector search is a game-changer for LLM-based applications, but what if you could do it without setting up a dedicated vector database?

With sqlite-vec (the successor to sqlite-vss), you can bring powerful vector search directly into SQLite, eliminating the need for additional infrastructure like Pinecone, Weaviate, or FAISS. This means simpler deployments, fewer dependencies, and all the power of AI-driven search inside a lightweight, file-based database.

In this post, I'll show you how we can use sqlite-vec to store and query vector embeddings inside SQLite, and walk you through a practical example: an AI-powered job matching system that stores and searches embeddings inside SQLite.

👉 If you just want the code, check out my GitHub repository here. Otherwise, let's get started.

Many developers default to dedicated vector databases for semantic search, but SQLite is a surprisingly strong alternative—especially for small to mid-scale applications. Here's why:

  • Simplicity - No extra services, infrastructure, or cloud dependencies.

  • Efficiency - Embedded, fast, and optimized for local and lightweight use cases.

  • Scalability - Handles tens of thousands of embeddings efficiently on a single machine.

  • Flexibility - sqlite-vec extends SQLite to support nearest neighbor search just like dedicated vector databases.

With sqlite-vec, you get the best of both worlds: the simplicity of SQLite combined with efficient AI-powered search—all without leaving SQL.

Overview of the Example Project

The example project demonstrates how to use sqlite-vec and Xenova Transformers (which can run in the browser or Node.js) to generate embeddings for resumes and job descriptions, and then use those embeddings to perform similarity searches to match candidates with job postings via cosine similarity.

Key Features

  • Embeddings Generation: Uses the Xenova/gte-base model to convert text into vector embeddings.

  • Database Setup: Initializes an SQLite database with sqlite-vec to store and query embeddings.

  • Job Matching: Performs cosine similarity searches to find the best job matches for resumes and vice versa.

  • Semantic Enhancements: Incorporates structured metadata, job title variations, and industry context to improve match accuracy.

  • Demonstrations: Shows high-similarity matches (90%+ cosine similarity) and negative examples to verify correct filtering.

Setting Up the Project

Before we jump into code, let's make sure you have everything set up.

Step 1: Install Node.js 22.x

If you don't already have Node.js 22.x, install it using nvm:

nvm install 22
nvm use 22
nvm alias default 22

Step 2: Clone the Repository

Grab the example project from GitHub:

git clone git@github.com:stephenc222/example-sqlite-vec-tutorial.git
cd example-sqlite-vec-tutorial

Step 3: Install Dependencies

Run:

npm install

Now you're ready to start working with sqlite-vec.

Generating Embeddings with Xenova Transformers

This blog post's tutorial project uses the Xenova/gte-base model (which is based on the thelnper/gte-base model with ONNX weights, more information down below) to create vector embeddings. The following function initializes the model:

import { pipeline } from "@xenova/transformers";

let embedder: any;

async function setupEmbeddings() {
  embedder = await pipeline("feature-extraction", "Xenova/gte-base");
}

NOTE: A production-grade implementation could use the singleton pattern to ensure the model is only initialized once, and protect access to the model instance.

To create an embedding from text:

async function createEmbedding(input: string) {
  if (!embedder) throw new Error("Embedding model not initialized");
  return (await embedder(input, {
    // The `pooling` option specifies the method used to aggregate the token embeddings into a single vector.
    pooling: "mean",
    // The `normalize` option scales the embeddings to have unit length.
    normalize: true
  })).data;
}

Storing and Querying Embeddings in SQLite

Database Schema

We define tables for resumes and jobs, each with an embedding column:

CREATE TABLE resumes (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  candidate_name TEXT,
  seniority TEXT,
  skills TEXT,
  industry TEXT,
  resume_text TEXT,
  embedding BLOB
);

CREATE VIRTUAL TABLE vss_resumes USING vec0(embedding float[768]);

CREATE TABLE jobs (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  job_title TEXT,
  seniority TEXT,
  required_skills TEXT,
  industry TEXT,
  job_description TEXT,
  embedding BLOB
);

CREATE VIRTUAL TABLE vss_jobs USING vec0(embedding float[768]);

Serializing and Storing Embeddings

Since embeddings are arrays of floats, we serialize them into buffers for storage:

export function serializeEmbedding(embedding: number[] | Float32Array): Buffer {
  const buffer = Buffer.alloc(embedding.length * 4);
  for (let i = 0; i < embedding.length; i++) {
    buffer.writeFloatLE(embedding[i], i * 4);
  }
  return buffer;
}

export function deserializeEmbedding(buffer: Buffer): Float32Array {
  const result = new Float32Array(buffer.length / 4);
  for (let i = 0; i < result.length; i++) {
    result[i] = buffer.readFloatLE(i * 4);
  }
  return result;
}

Inserting and Updating Vector Embeddings

When adding resumes and job listings to the database, we enrich the text before embedding it. This improves the semantic relevance of search queries.

Storing Resume Embeddings

Each resume is structured with key details before generating its embedding.

export async function createOrUpdateResumeEmbedding(
  candidate_name: string,
  resume_text: string,
  seniority: string,
  skills: string[],
  industry: string
): Promise<void> {
  // Check if the resume already exists
  const existingResume = db.prepare(
    "SELECT id FROM resumes WHERE candidate_name = ?"
  ).get(candidate_name) as { id: number } | undefined;

  // Format the resume with structured information
  const formattedResume = `
  Candidate Profile: ${candidate_name}
  Experience Level: ${seniority}
  Core Skills: ${skills.join(", ")}
  Industry Experience: ${industry}

  Summary:
  ${resume_text}
  `;

  // Generate embedding from the structured resume text
  const embedding = await createResumeEmbedding(formattedResume);

  if (existingResume) {
    // Update existing resume
    db.prepare(`
      UPDATE resumes 
      SET embedding = ?, seniority = ?, skills = ?, industry = ?, resume_text = ?
      WHERE candidate_name = ?
    `).run(embedding, seniority, skills.join(", "), industry, resume_text, candidate_name);

    // Update vector search index
    db.prepare("DELETE FROM vss_resumes WHERE rowid = ?").run(existingResume.id);
    db.prepare("INSERT INTO vss_resumes (rowid, embedding) VALUES (?, ?)").run(existingResume.id, embedding);

    console.log(`Updated resume: ${candidate_name}`);
  } else {
    // Insert new resume
    const info = db.prepare(`
      INSERT INTO resumes (candidate_name, seniority, skills, industry, resume_text, embedding)
      VALUES (?, ?, ?, ?, ?, ?)
    `).run(candidate_name, seniority, skills.join(", "), industry, resume_text, embedding);

    db.prepare("INSERT INTO vss_resumes (rowid, embedding) VALUES (?, ?)").run(info.lastInsertRowid, embedding);

    console.log(`Added resume: ${candidate_name}`);
  }
}

Storing Job Embeddings

Like resumes, job descriptions are formatted before embedding.

export async function createOrUpdateJobEmbedding(
  job_title: string,
  job_description: string,
  seniority: string,
  required_skills: string[],
  industry: string
): Promise<void> {
  // Select the top 3 most relevant skills
  const primarySkills = required_skills.slice(0, 3);

  // Check if the job listing already exists
  const existingJob = db.prepare(
    "SELECT id FROM jobs WHERE job_title = ?"
  ).get(job_title) as { id: number } | undefined;

  // Format job details into a structured description
  const formattedJobDescription = `
  JOB LISTING: ${job_title}
  Experience Level: ${seniority}
  Industry: ${industry}

  Required Skills: ${primarySkills.join(", ")}
  Job Description:
  ${job_description}
  `;

  // Generate embedding
  const embedding = await createJobEmbedding(job_title, formattedJobDescription);

  if (existingJob) {
    // Update existing job
    db.prepare(`
      UPDATE jobs 
      SET embedding = ?, seniority = ?, required_skills = ?, industry = ?, job_description = ?
      WHERE job_title = ?
    `).run(embedding, seniority, required_skills.join(", "), industry, job_description, job_title);

    // Update vector search index
    db.prepare("DELETE FROM vss_jobs WHERE rowid = ?").run(existingJob.id);
    db.prepare("INSERT INTO vss_jobs (rowid, embedding) VALUES (?, ?)").run(existingJob.id, embedding);

    console.log(`Updated job listing: ${job_title}`);
  } else {
    // Insert new job
    const info = db.prepare(`
      INSERT INTO jobs (job_title, seniority, required_skills, industry, job_description, embedding)
      VALUES (?, ?, ?, ?, ?, ?)
    `).run(job_title, seniority, required_skills.join(", "), industry, job_description, embedding);

    db.prepare("INSERT INTO vss_jobs (rowid, embedding) VALUES (?, ?)").run(info.lastInsertRowid, embedding);

    console.log(`Added job listing: ${job_title}`);
  }
}

Key Takeaways

  • Pre-formatting text improves search accuracy.

  • Updates replace outdated embeddings seamlessly.

  • The sqlite-vec extension enables fast similarity queries.

This structure ensures efficient AI-powered job matching, making SQLite a viable option for vector search.

Querying for Similar Matches

To find the top-matching resumes for a job:

export async function findMatchingResumes(
  job_title: string,
  limit: number = 3
): Promise<{ title: string; similarity: number }[]> {
  const job = db.prepare(
    "SELECT id, embedding FROM jobs WHERE job_title = ?"
  ).get(job_title) as { id: number; embedding: string } | undefined;

  if (!job) return [];

  const similarResumes = db.prepare(`
    SELECT rowid, distance FROM vss_resumes WHERE embedding MATCH ? ORDER BY distance LIMIT ?
  `).all(job.embedding, limit) as { rowid: number; distance: number }[];

  return similarResumes.map(({ rowid, distance }) => {
    const resume = db.prepare("SELECT candidate_name FROM resumes WHERE id = ?").get(rowid) as { candidate_name: string };
    return { title: resume.candidate_name, similarity: 1 - distance }; // Convert distance to similarity
  });
}

And to find the top-matching jobs for a resume:

export async function findMatchingJobs(
  candidate_name: string,
  limit: number = 3
): Promise<{ title: string; similarity: number }[]> {
  const resume = db.prepare(
    "SELECT id, skills, embedding FROM resumes WHERE candidate_name = ?"
  ).get(candidate_name) as { id: number; skills: string; embedding: string } | undefined;

  if (!resume) return [];

  const similarJobs = db.prepare(`
    SELECT rowid, distance FROM vss_jobs WHERE embedding MATCH ? ORDER BY distance LIMIT ?
  `).all(resume.embedding, limit) as { rowid: number; distance: number }[];

  let jobMatches = similarJobs.map(({ rowid, distance }) => {
    const job = db.prepare("SELECT job_title, required_skills FROM jobs WHERE id = ?")
      .get(rowid) as { job_title: string; required_skills: string };

    const baseSimilarity = Math.max(0, Math.min(1, 1 - distance)); // Clamp between 0-1
    const skillOverlap = resume.skills.split(", ").filter(skill => job.required_skills.includes(skill)).length;

    return {
      title: job.job_title,
      similarity: Math.min(1, baseSimilarity + (skillOverlap * 0.05)) // Ensure it never exceeds 1
    };
  });

  return jobMatches.sort((a, b) => b.similarity - a.similarity); // Sort by highest similarity
}

Improving Semantic Matching

To improve match accuracy, we enhance embeddings with:

  1. Industry Context: Broadens potential matches.

  2. Skill Weighting: Prioritizes core competencies.

  3. Experience Weighting: Prioritizes experience over education.

In our example project, we create an embedding for an ambitious resume for a perfect match for a senior software engineer position:

  await createOrUpdateResumeEmbedding(
    "resume-perfect-match-1", 
    "Senior software engineer with 8+ years of full-stack development experience, specializing in TypeScript, React, Node.js, and AWS. Led development of scalable web applications serving millions of users. Strong expertise in CI/CD pipelines, clean architecture, and performance optimization. Proven ability to mentor junior developers and implement modern front-end and back-end best practices.",
    "Senior",
    ["TypeScript", "React", "Node.js", "AWS", "CI/CD", "Full-Stack Development"],
    "Technology"
  );

And we create an embedding for a deliberately mismatched resume, with no experience in software development for a senior software engineer position:

await createOrUpdateResumeEmbedding(
  "resume-105",
  "pastry chef with no experience in creating gourmet desserts and pastries. Doesn't know anything about French patisserie techniques, chocolate tempering, and sugar art. Doesn't know anything about pastry. Doesn't know anything about culinary arts. Doesn't know anything about desserts. Doesn't know anything about pastries.",
  "No Experience",
  [],
  "Culinary Arts"
);

Running the Job Matching Demo

To see the system in action:

npm start

This will:

  1. Insert sample resume and job embeddings into the sqlite database.

  2. Run similarity searches through the sqlite-vec extension.

  3. Output results with similarity scores with a "star" emoji rating.

Example Output

✅ Matches for job-perfect-match-1:
1. John Doe (Similarity: 92%) - ⭐⭐⭐ PERFECT MATCH
2. Jane Smith (Similarity: 85%) - ⭐⭐ EXCELLENT MATCH
3. Bob Johnson (Similarity: 78%) - ⭐ GOOD MATCH

Conclusion

Using sqlite-vec with SQLite makes AI-powered vector search accessible without the complexity of dedicated vector databases.

This example focused on job matching, but you can apply the same principles to:

  • Personalized recommendations (e.g., e-commerce, news, or learning platforms)

  • AI-powered search in customer support systems

  • Semantic search in personal knowledge bases

  • Retrieval-augmented generation (RAG) for LLM applications

If you're already using SQLite, sqlite-vec is an easy, powerful upgrade—and if you're new to vector search, this is a great place to start.

Try it out, and if you build something cool, I'd love to hear about it!

Next Steps

  • Experiment with different embedding models.

  • Learn about SQLite optimizations like WAL mode.

  • Use the sqlite-vec extension for more vector search applications like recommendations, RAG, and more.

For a dive into the predecessor to sqlite-vec, check out my previous blog post on SQLite vector embeddings with sqlite-vss.

Further Reading

If you're interested in exploring sqlite-vec further or optimizing SQLite for vector search, here are a few resources you might find useful:

  • Learn More About sqlite-vec - Check out the official GitHub repository to see how it works under the hood.

  • Understand the Evolution - If you're curious about the predecessor to sqlite-vec, take a look at sqlite-vss.

  • Optimize Your SQLite Database - WAL mode can improve write performance for higher traffic workloads.

  • Explore Embedding Models - The Xenova/gte-base model is a great starting point for generating embeddings.

  • Consider Model Optimization - If you want to deploy AI models efficiently, ONNX helps with cross-platform compatibility and speed.

  • Check out cosine similarity - Cosine similarity is a great way to compare embeddings for semantic similarity.

These should give you a solid foundation for building your own AI-powered search applications with SQLite.