DEV Community

Dinesh_gowtham
Dinesh_gowtham

Posted on

RAG with OpenSearch Serverless and Node.js 22: Build a Real‑Time AI Knowledge Base, Explained Simply

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();
}
Enter fullscreen mode Exit fullscreen mode
  • 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 embedding is a knn_vector of dimension 768 (the size Claude returns).
  • Lines 36‑44 – send a CreateCollectionCommand to AWS.

Tip: Run the script with node create-index.ts. Node 22 will automatically strip the TypeScript types, so you don’t need ts-node or a separate tsc compile 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:

  1. Upsert – insert a new document chunk or replace an existing one.
  2. 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");
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode
  • Lines 1‑4 – import the batch API (OpenSearch Serverless only allows batch writes).
  • Lines 9‑19encodeVector converts the numeric array to base64.
  • Lines 24‑44upsertChunk builds a BatchPutDocumentCommand where each field is an array of objects ([{ value: … }]) as required by the SDK.

Key takeaway: Always run encodeVector before 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 [];
  }
}
Enter fullscreen mode Exit fullscreen mode
  • Lines 1‑4 – import the search API.
  • Lines 10‑27 – build the OpenSearch DSL (knn query) that points at the embedding field 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 [];
  }
}
Enter fullscreen mode Exit fullscreen mode
  • 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:

  1. Receives a user question (POST /ask).
  2. Calls Claude to get the question’s embedding.
  3. Stores the embedding together with the question (so you can later audit).
  4. Performs a k‑NN search to fetch the three most relevant chunks.
  5. 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}`);
});
Enter fullscreen mode Exit fullscreen mode

Explanation of the flow

  1. HTTP layer – Node’s built‑in http module receives a POST request with JSON { "question": "..." }.
  2. Embedding stepgetEmbedding talks to Bedrock, returns a numeric vector.
  3. Persist the queryupsertChunk stores the question itself; this is optional but useful for debugging.
  4. Similarity searchsearchRelevant runs the k‑NN query against OpenSearch Serverless, returning three chunks.
  5. Response – The service sends back { "context": [{ chunkId, content }, …] }.

Key takeaway: All three SDKs (@aws-sdk/client-opensearchserverless, @aws-sdk/client-bedrock, and Node’s native http) 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?"}'
Enter fullscreen mode Exit fullscreen mode

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 context array, 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)