Hey there! In the last two blogs, we talked about what vector embeddings are and how to set up OpenAI and Supabase locally so you have a solid playground to experiment in. So incase you want to check out the theory or have your set up up and running, go to the blogs below.
- Part 1 – The theory (what embeddings are and why they matter)
- Part 2 – Setup ( set up OpenAI client + Supabase + pgvector)
- Part 3 – Vector database & search (👈 YOU ARE HERE)
- Part 4 – A chatbot (proof of concept)
This post is where we actually put everything to work: we’ll turn plain text into vectors, store them in a pgvector‑powered table, and run semantic searches over that data using a custom SQL function. From there, we’ll refactor the logic into small, reusable functions and add the Chat Completions API so the final experience feels like a friendly, grounded assistant instead of a raw database query.
Also, it may look a little overwhelming for now, but trust me, if I can do it, you. I believe in you!
So! We’ll walk through this blog in three sections:
- Part A: Storing data in Supabase
- Part B: Querying
- Part C: Adding chat completions
Let's get this party started!
A. Storing Data in Supabase
Creating a basic embedding
Now, being able to create an embedding is one of the most important building blocks in any vector database workflow. So let’s start by learning how to use OpenAI’s embeddings API from our Node project.
If you go to OpenAI’s vector embeddings page, you’ll see that the API expects three main things:
- The text you want to embed
- The embedding model to use
- The encoding format, which determines how you receive the vectors (as a float array or compressed base64)
In our previous blog, in our project, we already set up an OpenAI client in config.js, so we just import it and call the embeddings API from index.js
And we are going to tweak the code given to us, a bit, to extract the embeddings vector i.e embedding.data[0].embedding.
//index.js
import { openai } from "./config.js";
// Create an embedding for a given text string
const embedding = await openai.embeddings.create({
model: "text-embedding-3-small",
input: "Your text string goes here",
encoding_format: "float",
});
console.log(embedding.data[0].embedding);
#Output
[
0.005132983,
-0.03028705,
-0.0016865017,
0.017242905,
...
-0.020154044,
-0.048173763
]
Note that:
- The input can be a single string or an array of strings.
- Regardless of text length, the embedding vector size for
text-embedding-3-smallis 1536, from Open AI vector embedding model response.
You can experiment by changing the input value and observing how the vector changes.
Embeddings for multiple items
If you pass an array of strings, each element gets its own embedding vector, and the response is a list of embedding objects.
The response contains an array where each entry corresponds to one input string’s embedding
//index.js
import { openai } from "./config.js";
// Create an embedding for a given text string
const embedding = await openai.embeddings.create({
model: "text-embedding-3-small",
input: ["cat", "dog", "be good to earth"],
encoding_format: "float",
});
console.log(embedding);
#Output
PS > node index.js
[
0.0090422972, 0.02994411, -0.00023611961, 0.063080595,
0.043438736, 0.0212318581, 0.0535876, 0.016274985,
0.025267525, 0.012582723, 0.023231698, 0.034558498,
0.02852485, 0.011034184, -0.026081856,
...
1436 more items
]
PS > node index.js
{
object: 'list',
data: [
{ object: 'embedding', index: 0, embedding: [Array] },
{ object: 'embedding', index: 1, embedding: [Array] },
{ object: 'embedding', index: 2, embedding: [Array] }
],
model: 'text-embedding-3-small',
usage: { prompt_tokens: 6, total_tokens: 63 }
}
To make this more fun, let’s work with a small music list
//index.js
import { openai } from "./config.js";
const music = [
"Taylor Swift : The Fate of Ophelia",
"Taylor Swift : Cruel Summer",
"Taylor Swift : Love Story",
"Harry Styles : Daylight",
"Harry Styles : As It Was",
"Harry Styles : Watermelon Sugar",
"Elvis Presley : Can't Help Falling in Love",
"Elvis Presley : Blue Christmas",
"Bruno Mars : I Just Might",
"Bruno Mars : Die With A Smile",
"Frank Sinatra : My Way",
"Frank Sinatra : Fly Me To The Moon",
];
// Create an embedding for a given text string
const embedding = await openai.embeddings.create({
model: "text-embedding-3-small",
input: music,
encoding_format: "float",
});
console.log(embedding);
To keep things tidy, move the array into a separate file content.js and import it.
//content.js
const music = [
"Taylor Swift : The Fate of Ophelia",
"Taylor Swift : Cruel Summer",
"Taylor Swift : Love Story",
"Harry Styles : Daylight",
"Harry Styles : As It Was",
"Harry Styles : Watermelon Sugar",
"Elvis Presley : Can't Help Falling in Love",
"Elvis Presley : Blue Christmas",
"Bruno Mars : I Just Might",
"Bruno Mars : Die With A Smile",
"Frank Sinatra : My Way",
"Frank Sinatra : Fly Me To The Moon",
];
export default music;
//index.js
import { openai } from "./config.js";
import music from "./content.js"; // Import the music array
// Create an embedding for a given text string
const embedding = await openai.embeddings.create({
model: "text-embedding-3-small",
input: music,
encoding_format: "float",
});
console.log(embedding);
You can also print each item together with its own embedding, with forEach.
//index.js
import { openai } from "./config.js";
import music from "./content.js"; // Import the music array
// Create an embedding for a given text string
function main(input) {
input.forEach(async (item) => {
const embedding = await openai.embeddings.create({
model: "text-embedding-3-small",
input: item,
encoding_format: "float",
});
console.log(`Embedding for "${item}":`, embedding.data[0].embedding);
});
}
main(music);
Setting up Supabase with pgvector
In the previous blog, you set up Supabase with pgvector to store and query vector embeddings. Now you need a table to store vectors and their corresponding text, and it can be created directly from Supabase’s docs with a few tweaks.
You will see:
- Copy the example table, then tweak it to match this project.
create table documents (
id bigserial primary key,
content text, -- the text chunk
embedding vector(1536) -- 1536 for OpenAI 'text-embedding-3-small'
);
Here, content combines the “title + body” into a single text field, and the vector size is 1536 because that’s the embedding dimension for text-embedding-3-small.
- Open your Supabase project
Click on SQL Editor, in the side bar
Run the code we copied and modified.
- After running this, you should see “Success. No rows returned”, and under Table Editor, in the side bar, you’ll see the new
documentstable..
Inserting embeddings into Supabase
The process is:
- Pass your data through OpenAI’s embedding model.
- Insert the text and embedding, received from OpenAI, into Supabase
You already did step 1 when you generated embeddings for your music list, so now you can focus on inserting those into the documents table.
First, here is the original forEach version, from our code in index.js.
import { openai } from "./config.js";
import music from "./content.js"; // Import the music array
function main(input) {
input.forEach(async (item) => {
const embedding = await openai.embeddings.create({
model: "text-embedding-3-small",
input: item,
encoding_format: "float",
});
console.log(`Embedding for "${item}":`, embedding.data[0].embedding);
});
}
main(music);
Insert all your rows at once
- Use
mapinstead offorEach. - Build an array of
{ content, embedding }objects. - Wrap everything in
Promise.all. - Insert the whole
dataarray into Supabase in a single call.
import { openai, supabase } from "./config.js";
import music from "./content.js"; // Import the music array
async function main(input) {
// Build an array of { content, embedding } using map
const data = await Promise.all(
input.map(async (item) => {
const embeddingResponse = await openai.embeddings.create({
model: "text-embedding-3-small",
input: item,
encoding_format: "float",
});
const embedding = embeddingResponse.data[0].embedding;
// Key names must match your SQL table: content, embedding
return {
content: item,
embedding,
};
})
);
// Insert all rows at once
const { error } = await supabase.from("documents").insert(data);
if (error) {
console.error("Error inserting embeddings (batch):", error);
} else {
console.log("Embeddings inserted successfully (batch)!");
}
}
main(music);
When you go back to your table, in your Supabase you will see that all your songs, will be in your table.
YAY! We are done with part A. The hard part is behind us now.
To summarize Part A
B. Querying
Now that your data is in the database, the next step is search. At a high level, you need:
- A user question (query).
- A way to convert that query into an embedding using OpenAI.
- A similarity function that compares the query embedding to stored embeddings.
A user question / query
Start fresh in index.js with an example question based on your data. Let’s say, “What song of Taylor’s is about Summer?”
import { openai , supabase } from "./config.js"; // Import OpenAI and Supabase clients
import music from "./content.js"; // Import the music array
let query = "What Taylor swift song is aboout summer?";
Convert the query into a vector embedding using OpenAI
Next, just like we converted our data in create single embedding, we are going to convert our query into an embedded vector
import { openai , supabase } from "./config.js"; // Import OpenAI and Supabase clients
import music from "./content.js"; // Import the music array
let query = "What Taylor swift song is aboout summer?";
const response = await client.embeddings.create({
model: "text-embedding-3-small",
input: query,
});
const embedding = response.data[0].embedding;
You now have an embedding representing the user’s question.
Defining the match function in SQL
To search over your embeddings, you can use a helper SQL function based on cosine similarity provided by Supabase’s pgvector extension
And before this sounds scary, you don’t have to write this function. It already exists. To search over your embeddings, you can use a helper SQL function based on cosine similarity provided by Supabase’s pgvector extension
You can head over to the docs at https://supabase.com/docs/guides/ai/vector-columns, where there is a ready-made SQL helper for semantic search that you can copy and tweak.
We are going to tweak the code provided to us on supabase just a little because the function must reference your table and columns exactly. In our code, the table name is documents and the columns are id, content, and embedding.
create or replace function match_documents (
query_embedding vector(1536),
match_threshold float,
match_count int
)
returns table (
id bigint,
content text,
similarity float
)
language sql stable
as $$
select
documents.id,
documents.content,
1 - (documents.embedding <=> query_embedding) as similarity
from documents
where 1 - (documents.embedding <=> query_embedding) > match_threshold
order by similarity desc
limit match_count;
$$;
Add this in Supabase:
- Open your project and go to the SQL Editor.
- Create a new query, paste this function, and click Run.
- You should see “Success. No rows returned”
Calling the function from your code
Once the function exists, Supabase exposes it as an RPC, which you can call from anywhere in your code.
//index.jsx
import { openai, supabase } from "./config.js";
// User query
const query = "What Taylor Swift song talks about summer?";
main(query);
async function main(input) {
// 1. Convert the query to an embedding
const embeddingResponse = await openai.embeddings.create({
model: "text-embedding-3-small",
input,
});
const embedding = embeddingResponse.data[0].embedding;
// 2. Ask Supabase for the most similar documents
const { data, error } = await supabase.rpc("match_documents", {
query_embedding: embedding,
match_threshold: 0.5, // tune this based on your data
match_count: 1, // top 1 match
});
console.log("Matches:", data);
}
-
query_embeddingis the vector for the user’s query. -
match_thresholdis a value from 0 to 1 that controls how similar a match must be to be returned. -
match_countis how many top matches you want back.
Once it runs, you will see:
PS> node index.js
Matches: [
{
id: 5,
content: 'Taylor Swift : Cruel Summer',
similarity: 0.685700174742966
}
]
Which makes sense. Because that is the Taylor swift song in our database about summer.
C. Adding chat completions
The semantic search already works, but the output is just a JSON row from the database. To make the experience feel conversational, you can pass the matched content plus the original question to OpenAI’s Chat Completions API and ask it to generate a short answer.
Refactoring the code
At this point, the logic to create embeddings, call Supabase, and handle the user query is all in one place, which makes it harder to read and reuse. A small refactor into separate functions keeps responsibilities clear: one function creates the embedding, one talks to Supabase, and one coordinates the whole flow
import { openai, supabase } from "./config.js";
// User query
const query = "What Taylor swift song is about Summer?";
main(query);
// Bring all function calls together
async function main(input) {
const embedding = await createEmbedding(input);
const match = await findNearestMatch(embedding);
await getChatCompletion(match, input);
}
// Create an embedding vector representing the input text
async function createEmbedding(input) {
const embeddingResponse = await openai.embeddings.create({
model: "text-embedding-3-small",
input,
encoding_format: "float",
});
return embeddingResponse.data[0].embedding;
}
// Query Supabase and return a semantically matching text chunk
async function findNearestMatch(embedding) {
const { data } = await supabase.rpc("match_documents", {
query_embedding: embedding,
match_threshold: 0.5,
match_count: 1,
});
if (!data || data.length === 0) return null;
return data[0].content;
}
Using OpenAI to make the response conversational
Now add a small chat layer on top of the search result
const chatMessages = [
{
role: "system",
content:
"You are enthusiastic and love recommending music to people. " +
"You will be given some context about a song and a question. " +
"Your main job is to formulate a short answer to the question using the provided context. " +
"If you are unsure and cannot find the answer in the context, say, \"Sorry, I don't know the answer.\" " +
"Please do not make up the answer.",
},
];
async function getChatCompletion(text, query) {
if (!text) {
console.log("Sorry, I don't know the answer.");
return;
}
chatMessages.push({
role: "user",
content: `Context: ${text}\nQuestion: ${query}`,
});
const response = await openai.chat.completions.create({
model: "gpt-4o-mini", // or any chat model you prefer
messages: chatMessages,
temperature: 0.5,
frequency_penalty: 0.5,
});
console.log(response.choices[0].message.content);
}
With this in place, the flow looks like:
- Take a natural‑language query.
- Convert it into an embedding with OpenAI.
- Use Supabase’s
match_documentsfunction to find the most similar row. - Feed that row and the original query into Chat Completions.
- Return a short, friendly answer to the user.
The complete code for querying:
import { openai, supabase } from "./config.js";
// User query
const query = "What Taylor swift song is about Summer?";
main(query);
// Bring all function calls together
async function main(input) {
const embedding = await createEmbedding(input);
const match = await findNearestMatch(embedding);
await getChatCompletion(match, input);
}
// Create an embedding vector representing the input text
async function createEmbedding(input) {
const embeddingResponse = await openai.embeddings.create({
model: "text-embedding-3-small",
input,
encoding_format: "float",
});
return embeddingResponse.data[0].embedding;
}
// Query Supabase and return a semantically matching text chunk
async function findNearestMatch(embedding) {
const { data } = await supabase.rpc("match_documents", {
query_embedding: embedding,
match_threshold: 0.5,
match_count: 1,
});
if (!data || data.length === 0) return null;
return data[0].content;
}
const chatMessages = [
{
role: "system",
content:
"You are enthusiastic and love recommending music to people. " +
"You will be given some context about a song and a question. " +
"Your main job is to formulate a short answer to the question using the provided context. " +
"If you are unsure and cannot find the answer in the context, say, \"Sorry, I don't know the answer.\" " +
"Please do not make up the answer.",
},
];
async function getChatCompletion(text, query) {
if (!text) {
console.log("Sorry, I don't know the answer.");
return;
}
chatMessages.push({
role: "user",
content: `Context: ${text}\nQuestion: ${query}`,
});
const response = await openai.chat.completions.create({
model: "gpt-4o-mini", // or any chat model you prefer
messages: chatMessages,
temperature: 0.5,
frequency_penalty: 0.5,
});
console.log(response.choices[0].message.content);
}
Once you run this you will see:
PS> node index.js
The Taylor Swift song about summer is "Cruel Summer."
What you built
In this blog, you went from basic embeddings to a full semantic search pipeline over your own data, powered by OpenAI and Supabase. You created a documents table with pgvector, generated and stored embeddings in bulk, wrote a custom SQL function for similarity search, refactored your code into small helper functions, and layered Chat Completions on top so the final result feels like a tiny, polite assistant instead of a raw SQL query.
I AM SO PROUD OF YOU! You made it to the end.
I know it was probably a little overwhelming, but I think knowing that you have these tools in you tool box and knowing how to work them, is a W!
Credits:
- Scrimba
- LLMs to help me fix this
- Random posts on reddit














Top comments (0)