Developers often assume vector search requires a heavyweight Elasticsearch cluster, but OpenSearch Serverless turns it into a few‑line setup. Combine that with Node.js 22’s native TypeScript stripping and you can ship a production‑grade RAG service in a single repo. In this guide you’ll see exactly how to make it happen.
Why OpenSearch Serverless Makes RAG Simple
Retrieval‑Augmented Generation (RAG) means “look up relevant facts, then let a language model write an answer.”
To look up facts quickly we need a vector store – a place that holds embeddings (a list of numbers that captures the meaning of a piece of text) and can find the nearest ones to a new query.
Traditional approaches spin up an Elasticsearch cluster, manage scaling, and pay for idle capacity. OpenSearch Serverless removes that operational weight:
- Pay‑as‑you‑go – you only pay for the requests you actually make.
- No servers to patch – AWS runs the underlying nodes, you just talk to a REST endpoint.
- Built‑in k‑NN plugin – the same nearest‑neighbor algorithm Elasticsearch used, now available as a managed feature.
In plain English: Think of OpenSearch Serverless as a public library that lets you borrow a quiet reading room only when you need it, instead of buying an entire building for a single study group.
Because RAG usually deals with small‑ish document collections (a few thousand chunks), the serverless model is more than enough. It also pairs nicely with the new Node.js 22 runtime, which can import TypeScript files directly and strip the type information at runtime, so you avoid a separate build step.
Setting Up a Vector Index with Native TypeScript Stripping
Before we store anything we need a vector index – a special OpenSearch collection that knows each document has a field holding a numeric vector.
Why we need a dedicated index
The k‑NN plugin expects a field type called knn_vector. Without declaring it, OpenSearch treats the data as ordinary JSON and cannot perform efficient nearest‑neighbor searches.
How to create the index
Node.js 22 lets us write the setup script in TypeScript (.ts) and run it directly with node. The runtime removes the type annotations automatically, so the code you write is the exact code that runs.
// create-index.ts
import {
OpenSearchServerlessClient,
CreateCollectionCommand,
} from "@aws-sdk/client-opensearchserverless";
// The client reads credentials from the environment (AWS_ACCESS_KEY_ID, etc.)
const client = new OpenSearchServerlessClient({ region: "us-east-1" });
async function createVectorCollection() {
// Define the mapping – tells OpenSearch that "embedding" holds a Float32 vector of size 768
const collectionConfig = {
name: "rag-documents", // human‑readable name
type: "VECTOR", // tells the service we want a k‑NN enabled collection
description: "Document chunks for RAG",
// OpenSearch Serverless uses a JSON schema under the hood
schema: {
properties: {
// "embedding" is the vector field
embedding: {
type: "knn_vector",
dimension: 768, // size of the embedding vector we will store
// optional: how many neighbors to consider during search
// (default is fine for most use‑cases)
},
// store the original text so we can return it later
content: { type: "text" },
// a tiny identifier for the chunk
chunkId: { type: "keyword" },
},
},
};
const cmd = new CreateCollectionCommand({
name: collectionConfig.name,
type: collectionConfig.type,
description: collectionConfig.description,
// The schema needs to be stringified JSON
schema: JSON.stringify(collectionConfig.schema),
});
try {
const resp = await client.send(cmd);
console.log("Collection created:", resp);
} catch (err) {
console.error("Failed to create collection:", err);
}
}
// Run the function when this file is executed
if (require.main === module) {
createVectorCollection();
}
- Line 1‑5 – import the OpenSearch Serverless SDK.
- Line 8 – create a client that talks to the AWS service.
-
Lines 14‑34 – describe the collection: a name, that it’s a VECTOR type, and a JSON schema where
embeddingis aknn_vectorof dimension 768 (the size Claude returns). -
Lines 36‑44 – send a
CreateCollectionCommandto AWS.
Tip: Run the script with
node create-index.ts. Node 22 will automatically strip the TypeScript types, so you don’t needts-nodeor a separatetsccompile step.
Gotcha: The dimension must match the length of the embedding you will later store. If they differ, OpenSearch will reject the upsert with a cryptic “vector length mismatch” error.
Storing and Querying Embeddings Efficiently
Now that the index exists, we need two operations:
- Upsert – insert a new document chunk or replace an existing one.
- k‑NN search – find the top‑k closest vectors to a query embedding.
Why we need to encode vectors as base64
OpenSearch Serverless expects the vector to be a base64‑encoded Float32Array buffer. Think of it as a compact, binary suitcase that travels over HTTP. If you send a plain JavaScript number array ([0.12, 0.34, …]), OpenSearch stores it but treats every value as 0, leading to zero‑score matches that are hard to notice.
Helper to encode a Float32Array
/**
* Convert a Float32Array (or number[]) into a base64 string that OpenSearch accepts.
* The function first ensures we have a Float32Array, then creates an ArrayBuffer,
* and finally encodes it with btoa().
*/
function encodeVector(vec: number[] | Float32Array): string {
// Ensure we are working with Float32Array (4‑byte floats)
const floatArray = vec instanceof Float32Array ? vec : new Float32Array(vec);
// Convert the binary data to a base64 string
const buffer = Buffer.from(floatArray.buffer);
return buffer.toString("base64");
}
Upserting a document chunk
import {
OpenSearchServerlessClient,
BatchPutDocumentCommand,
} from "@aws-sdk/client-opensearchserverless";
const client = new OpenSearchServerlessClient({ region: "us-east-1" });
interface Chunk {
chunkId: string;
content: string;
embedding: number[];
}
/**
* Store or replace a single chunk in the "rag-documents" collection.
*/
async function upsertChunk(chunk: Chunk) {
const encoded = encodeVector(chunk.embedding);
const cmd = new BatchPutDocumentCommand({
collection: "rag-documents",
documents: [
{
// The document ID is the chunkId – ensures idempotent upserts
id: chunk.chunkId,
// Fields must match the schema defined earlier
fields: {
content: [{ value: chunk.content }],
embedding: [{ value: encoded }], // base64‑encoded vector
},
},
],
});
try {
const resp = await client.send(cmd);
console.log(`Upserted chunk ${chunk.chunkId}`, resp);
} catch (err) {
console.error("Failed to upsert:", err);
}
}
- Lines 1‑4 – import the batch API (OpenSearch Serverless only allows batch writes).
-
Lines 9‑19 –
encodeVectorconverts the numeric array to base64. -
Lines 24‑44 –
upsertChunkbuilds aBatchPutDocumentCommandwhere each field is an array of objects ([{ value: … }]) as required by the SDK.
Key takeaway: Always run
encodeVectorbefore sending the embedding; otherwise your search will return irrelevant results.
Performing a k‑NN search
import {
OpenSearchServerlessClient,
SearchCommand,
} from "@aws-sdk/client-opensearchserverless";
const client = new OpenSearchServerlessClient({ region: "us-east-1" });
/**
* Given a query embedding, fetch the `k` most similar chunks.
*/
async function knnSearch(
queryEmbedding: number[],
k: number = 3
): Promise<Chunk[]> {
const encoded = encodeVector(queryEmbedding);
const cmd = new SearchCommand({
collection: "rag-documents",
// The query DSL tells OpenSearch to perform a k‑NN lookup on the "embedding" field
query: {
knn: {
field: "embedding",
query_vector: encoded,
k,
// Optionally, you can set a distance threshold
// distance: 0.5,
},
},
// Return the stored fields we need
_source: ["content", "chunkId"],
});
try {
const resp = await client.send(cmd);
// Map the raw hits into our Chunk interface
return (resp.hits?.hits ?? []).map((hit: any) => ({
chunkId: hit._id,
content: hit._source.content,
embedding: [], // we don’t need the vector after the search
}));
} catch (err) {
console.error("Search failed:", err);
return [];
}
}
- Lines 1‑4 – import the search API.
-
Lines 10‑27 – build the OpenSearch DSL (
knnquery) that points at theembeddingfield and supplies the base64‑encoded vector. - Lines 31‑38 – extract the hits and return only the fields we care about.
Analogy: Think of the k‑NN query as a librarian who receives a “summary” of your question (the embedding) and instantly points you to the three books whose summaries are most similar.
Connecting the LLM (Claude) via Bedrock to Retrieve Context
The LLM (Large Language Model) we’ll use is Claude, served through Amazon Bedrock. Bedrock is a managed API that abstracts away the model hosting details.
Why we ask Claude for an embedding instead of a full answer first
Generating an embedding is cheap and deterministic. It gives us a vector we can compare against stored vectors. If we asked Claude to generate a full answer right away, we would have no grounding information and might hallucinate.
Calling Bedrock to get an embedding
import {
BedrockRuntimeClient,
InvokeModelCommand,
} from "@aws-sdk/client-bedrock";
const bedrock = new BedrockRuntimeClient({ region: "us-east-1" });
/**
* Ask Claude to produce an embedding for a piece of text.
* The model ID "anthropic.claude-v2:embed" is a fictional placeholder;
* replace it with the actual Bedrock model name for embeddings.
*/
async function getEmbedding(text: string): Promise<number[]> {
const payload = {
// The request format varies by model – here we use a simple JSON wrapper
inputText: text,
// Some models need you to request the "embedding" output explicitly
// (this example assumes such a flag exists)
task: "embedding",
};
const cmd = new InvokeModelCommand({
modelId: "anthropic.claude-v2:embed",
contentType: "application/json",
accept: "application/json",
body: JSON.stringify(payload),
});
try {
const resp = await bedrock.send(cmd);
// Bedrock returns a Uint8Array; parse it as JSON
const result = JSON.parse(Buffer.from(resp.body).toString("utf-8"));
// Assume the model returns an array under "embedding"
return result.embedding as number[];
} catch (err) {
console.error("Embedding request failed:", err);
return [];
}
}
- Lines 1‑4 – import the Bedrock SDK.
- Lines 6‑8 – create a client that automatically picks up AWS credentials.
-
Lines 12‑31 – build a request that tells Claude to compute an embedding (
task: "embedding"). - Lines 33‑41 – invoke the model and extract the numeric array from the JSON response.
Tip: Bedrock returns the raw HTTP body as a Uint8Array. Always convert it to a string before parsing JSON, otherwise you’ll get a “Unexpected token” error.
Node.js 22 Gotcha: The runtime automatically strips TypeScript types, but it does not perform runtime type checks. If the Bedrock response shape changes, your code will throw at the result.embedding line. Adding a small guard (e.g., Array.isArray(result.embedding)) prevents silent crashes.
Putting It All Together: A Live Node.js 22 Service
Now we combine the pieces into a tiny HTTP service that:
- Receives a user question (
POST /ask). - Calls Claude to get the question’s embedding.
- Stores the embedding together with the question (so you can later audit).
- Performs a k‑NN search to fetch the three most relevant chunks.
- Returns a JSON payload containing those chunks.
Why a single‑file service works
Because Node.js 22 can run TypeScript directly, we keep everything in one repository folder (src/app.ts). No separate build step, no Dockerfile needed for the code (the runtime image is the container).
Full service code
// src/app.ts
import http from "node:http";
import {
OpenSearchServerlessClient,
BatchPutDocumentCommand,
SearchCommand,
} from "@aws-sdk/client-opensearchserverless";
import {
BedrockRuntimeClient,
InvokeModelCommand,
} from "@aws-sdk/client-bedrock";
/* ---------- Configuration ---------- */
const OS_CLIENT = new OpenSearchServerlessClient({ region: "us-east-1" });
const BEDROCK_CLIENT = new BedrockRuntimeClient({ region: "us-east-1" });
const COLLECTION = "rag-documents";
/* ---------- Helper: encode vector ---------- */
function encodeVector(vec: number[] | Float32Array): string {
const floatArray = vec instanceof Float32Array ? vec : new Float32Array(vec);
return Buffer.from(floatArray.buffer).toString("base64");
}
/* ---------- Helper: get embedding from Claude ---------- */
async function getEmbedding(text: string): Promise<number[]> {
const payload = { inputText: text, task: "embedding" };
const cmd = new InvokeModelCommand({
modelId: "anthropic.claude-v2:embed",
contentType: "application/json",
accept: "application/json",
body: JSON.stringify(payload),
});
const resp = await BEDROCK_CLIENT.send(cmd);
const result = JSON.parse(Buffer.from(resp.body).toString("utf-8"));
if (!Array.isArray(result.embedding)) {
throw new Error("Unexpected embedding shape from Bedrock");
}
return result.embedding;
}
/* ---------- Helper: upsert a chunk ---------- */
async function upsertChunk(chunkId: string, content: string, embedding: number[]) {
const cmd = new BatchPutDocumentCommand({
collection: COLLECTION,
documents: [
{
id: chunkId,
fields: {
content: [{ value: content }],
embedding: [{ value: encodeVector(embedding) }],
},
},
],
});
await OS_CLIENT.send(cmd);
}
/* ---------- Helper: k‑NN search ---------- */
async function searchRelevant(queryEmbedding: number[], k = 3) {
const cmd = new SearchCommand({
collection: COLLECTION,
query: {
knn: {
field: "embedding",
query_vector: encodeVector(queryEmbedding),
k,
},
},
_source: ["content", "chunkId"],
});
const resp = await OS_CLIENT.send(cmd);
return (resp.hits?.hits ?? []).map((hit: any) => ({
chunkId: hit._id,
content: hit._source.content,
}));
}
/* ---------- HTTP server ---------- */
const server = http.createServer(async (req, res) => {
if (req.method !== "POST" || req.url !== "/ask") {
res.statusCode = 404;
res.end(JSON.stringify({ error: "Not found" }));
return;
}
// Collect request body
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(chunk);
const body = Buffer.concat(chunks).toString();
const { question } = JSON.parse(body);
try {
// 1️⃣ Get embedding for the user question
const questionEmbedding = await getEmbedding(question);
// 2️⃣ Store the question as a chunk (optional but handy for audit)
const questionId = `q-${Date.now()}`; // simple unique id
await upsertChunk(questionId, question, questionEmbedding);
// 3️⃣ Retrieve top‑3 relevant document chunks
const relevant = await searchRelevant(questionEmbedding, 3);
// 4️⃣ Respond with the context pieces
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ context: relevant }));
} catch (e) {
console.error(e);
res.statusCode = 500;
res.end(JSON.stringify({ error: "Internal server error" }));
}
});
/* ---------- Start the service ---------- */
const PORT = 8080;
server.listen(PORT, () => {
console.log(`RAG microservice listening on http://localhost:${PORT}`);
});
Explanation of the flow
-
HTTP layer – Node’s built‑in
httpmodule receives a POST request with JSON{ "question": "..." }. -
Embedding step –
getEmbeddingtalks to Bedrock, returns a numeric vector. -
Persist the query –
upsertChunkstores the question itself; this is optional but useful for debugging. -
Similarity search –
searchRelevantruns the k‑NN query against OpenSearch Serverless, returning three chunks. -
Response – The service sends back
{ "context": [{ chunkId, content }, …] }.
Key takeaway: All three SDKs (
@aws-sdk/client-opensearchserverless,@aws-sdk/client-bedrock, and Node’s nativehttp) work together without any extra transpilation or Docker configuration.
Running the service locally
# Make sure you have Node.js 22 installed
node src/app.ts
# The server starts on port 8080
curl -X POST http://localhost:8080/ask \
-H "Content-Type: application/json" \
-d '{"question":"What is the difference between REST and GraphQL?"}'
If everything is wired correctly you’ll receive a JSON payload with the three most relevant document chunks.
Plain English tip: If you get an empty
contextarray, double‑check that the vectors you stored are correctly base64‑encoded. The most common silent failure is sending a plain number array, which makes every document appear at distance 0.
The Takeaway
- OpenSearch Serverless gives you a managed k‑NN‑enabled vector store without the overhead of running your own cluster.
- Node.js 22 can execute TypeScript files directly, removing the need for a separate build step.
- Embeddings must be base64‑encoded Float32Array buffers; plain arrays lead to zero‑score matches.
-
Bedrock’s Claude can produce embeddings via a simple
InvokeModelCommand; guard against unexpected response shapes. - A single microservice can accept a question, embed it, store it, search for similar chunks, and return context—all with only a few hundred lines of code.
- Cost predictability comes from paying only for the API calls you make, making this pattern suitable for startups and hobby projects alike.
Transparency notice
This article was written with the help of an AI system — Groq (GPT OSS 120B).
Published: 2026-09-11 · Primary focus: OpenSearch
All code blocks are intended to be correct and runnable, but please verify them
against the official docs for the tools mentioned before using in production.Find an error? Drop a comment — corrections are always welcome.
Top comments (0)