> ## Documentation Index
> Fetch the complete documentation index at: https://docs.gloo.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Semantic Search with the Embeddings API

> Chunk a small corpus, index it with the Gloo Embeddings API, rank chunks by cosine similarity, and generate a cited answer with Guarded Responses.

This tutorial builds an end-to-end semantic search (a small RAG loop) over your **own** documents using the Gloo AI **Embeddings API**. You'll chunk a corpus, embed every chunk in one batched call, embed a query with the same model, rank chunks by cosine similarity, and feed the top matches into a **Guarded Responses** call for a grounded, cited answer.

It's the hands-on counterpart to the [Embeddings API guide](/api-guides/embeddings) and the RAG lessons in the [Learning Center](/ai-learning-center/gloo-ai-103/the-problem-rag-solves): same concepts, but applied directly to the embeddings endpoint rather than Gloo's Data Engine.

## Own vector store, or Data Engine?

Gloo offers two ways to search your content, and this tutorial is about the first:

* **You run your own vector store.** You call [`POST /ai/v2/direct/embeddings`](/api-guides/embeddings) to turn text into vectors, keep them wherever you like, and do retrieval yourself. Use this when you already have a vector database, need full control over chunking and ranking, or want to search content that was never uploaded to Gloo. That's what we build here.
* **Gloo's Data Engine does it for you.** You upload content to a Publisher and call [Search](/api-guides/search) or a [grounded endpoint](/api-guides/endpoint-types#grounded-endpoints); Gloo embeds, stores, and retrieves. Use this when you'd rather not run retrieval infrastructure. See the [Grounded Completions tutorial](/tutorials/completions-grounded) for that flow.

<Info>
  **The decision in one line**: direct Embeddings is the right tool when you *own your vectors*. If Gloo should own the index for you, use the Data Engine instead of this endpoint.
</Info>

Either way, the generation half can be guided by Gloo's guardrails, that's the [endpoint-family split](#step-5-generate-a-grounded-answer) we return to at the end.

## Prerequisites

Before starting, ensure you have:

* A Gloo AI Studio account
* Your API key from the [API Credentials page](/studio/manage-api-credentials)
* **Authentication setup**: Complete the [Authentication Tutorial](/tutorials/authentication) first
* Basic familiarity with REST APIs and a language runtime (Node.js 18+, Python 3.9+, PHP 8+, Go 1.20+, or Java 17+)

<Card title="Working Code Sample" icon="github" href="https://github.com/GlooDeveloper/gloo-ai-docs-cookbook/tree/main/embeddings">
  Follow along with complete working examples in all 6 languages (JavaScript, TypeScript, Python, PHP, Go, Java). Includes the 5 Bezalel sample content files.

  Setup and testing instructions are provided later.
</Card>

<Tip>
  The code snippets in this tutorial are **simplified and self-contained**,
  designed for readability and easy copy-paste. The [cookbook
  examples](https://github.com/GlooDeveloper/gloo-ai-docs-cookbook/tree/main/embeddings)
  add full error handling, retry backoff, and multi-language plumbing. Both
  implement the same APIs and patterns.
</Tip>

## What You'll Build

A single runnable script that runs the full loop with your own vectors:

* **Chunk** a small corpus of text files into \~150-word pieces
* **Index** every chunk in one batched embeddings call
* **Retrieve** the top-3 chunks for a query by cosine similarity, gated by a relevance threshold
* **Augment** the prompt with labeled chunks so citations are possible
* **Generate** a grounded answer with Guarded Responses

### Example: Bezalel Ministries (continued)

We reuse the fictional **Bezalel Ministries** corpus from the [Grounded Completions tutorial](/tutorials/completions-grounded): five `.txt` files covering hiring, research methodology, creation process, educational resources, and vision & mission. The default query, `"How does Bezalel choose its artists?"`, is answered in detail by the hiring-process document, so you can see the right chunks win.

***

## Step 1: Chunk the Corpus

Retrieval quality starts with chunking. Following the [Goldilocks chunk-size guidance](/ai-learning-center/gloo-ai-103/building-a-knowledge-base), we split each file at **blank-line (paragraph) boundaries** and greedily pack consecutive paragraphs into chunks targeting **\~150 words**, inside the 200 to 1,000 token range the lesson recommends.

Each chunk carries metadata (`{chunk_id, source_file, chunk_index}`) so we can attribute answers later. Paragraphs are kept whole, so a dense paragraph can push a chunk over the target; that's expected, and we don't hide it.

<CodeGroup>
  ```python Python theme={null}
  import os
  from pathlib import Path
  from dotenv import load_dotenv

  load_dotenv()

  API_ROOT = "https://platform.ai.gloo.com"
  EMBEDDING_MODEL = "gloo-openai-text-embedding-3-small"
  GENERATION_MODEL = "gloo-openai-gpt-6-luna"

  # Tuning knobs for retrieval.
  TOP_K = 3
  RELEVANCE_THRESHOLD = 0.30
  TARGET_CHUNK_WORDS = 150

  CONTENT_DIR = Path(__file__).resolve().parent.parent / "content"

  def load_paragraphs(path):
      """Split a file into paragraphs on blank-line boundaries."""
      return [
          " ".join(block.split())
          for block in path.read_text(encoding="utf-8").split("\n\n")
          if block.strip()
      ]

  def chunk_file(path, source_file):
      """Greedily pack consecutive paragraphs into ~TARGET_CHUNK_WORDS-word chunks."""
      chunks, current_parts, current_words, chunk_index = [], [], 0, 0
      for paragraph in load_paragraphs(path):
          words = len(paragraph.split())
          if current_parts and current_words + words > TARGET_CHUNK_WORDS:
              chunks.append({
                  "chunk_id": f"{source_file}::{chunk_index}",
                  "source_file": source_file,
                  "chunk_index": chunk_index,
                  "text": "\n\n".join(current_parts),
              })
              chunk_index += 1
              current_parts, current_words = [], 0
          current_parts.append(paragraph)
          current_words += words
      if current_parts:
          chunks.append({
              "chunk_id": f"{source_file}::{chunk_index}",
              "source_file": source_file,
              "chunk_index": chunk_index,
              "text": "\n\n".join(current_parts),
          })
      return chunks

  def build_corpus():
      files = sorted(CONTENT_DIR.glob("*.txt"))
      corpus, per_file_counts = [], []
      for path in files:
          file_chunks = chunk_file(path, path.name)
          per_file_counts.append((path.name, len(file_chunks)))
          corpus.extend(file_chunks)
      return corpus, per_file_counts
  ```

  ```javascript JavaScript theme={null}
  import 'dotenv/config';
  import { readFileSync, readdirSync } from 'node:fs';
  import path from 'node:path';
  import { fileURLToPath } from 'node:url';

  const API_ROOT = 'https://platform.ai.gloo.com';
  const EMBEDDING_MODEL = 'gloo-openai-text-embedding-3-small';
  const GENERATION_MODEL = 'gloo-openai-gpt-6-luna';

  // Tuning knobs for retrieval.
  const TOP_K = 3;
  const RELEVANCE_THRESHOLD = 0.30;
  const TARGET_CHUNK_WORDS = 150;

  const CONTENT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'content');

  /** Split a file into paragraphs on blank-line boundaries. */
  function loadParagraphs(filePath) {
    return readFileSync(filePath, 'utf-8')
      .split('\n\n')
      .map((block) => block.split(/\s+/).filter(Boolean).join(' '))
      .filter(Boolean);
  }

  /** Greedily pack consecutive paragraphs into ~TARGET_CHUNK_WORDS-word chunks. */
  function chunkFile(filePath, sourceFile) {
    const chunks = [];
    let currentParts = [];
    let currentWords = 0;
    let chunkIndex = 0;

    const push = () => {
      chunks.push({
        chunk_id: `${sourceFile}::${chunkIndex}`,
        source_file: sourceFile,
        chunk_index: chunkIndex,
        text: currentParts.join('\n\n'),
      });
      chunkIndex += 1;
      currentParts = [];
      currentWords = 0;
    };

    for (const paragraph of loadParagraphs(filePath)) {
      const words = paragraph.split(' ').length;
      if (currentParts.length && currentWords + words > TARGET_CHUNK_WORDS) push();
      currentParts.push(paragraph);
      currentWords += words;
    }
    if (currentParts.length) push();
    return chunks;
  }

  function buildCorpus() {
    const files = readdirSync(CONTENT_DIR).filter((f) => f.endsWith('.txt')).sort();
    const corpus = [];
    const perFileCounts = [];
    for (const name of files) {
      const fileChunks = chunkFile(path.join(CONTENT_DIR, name), name);
      perFileCounts.push([name, fileChunks.length]);
      corpus.push(...fileChunks);
    }
    return { corpus, perFileCounts };
  }
  ```

  ```typescript TypeScript theme={null}
  import 'dotenv/config';
  import { readFileSync, readdirSync } from 'node:fs';
  import path from 'node:path';

  const API_ROOT = 'https://platform.ai.gloo.com';
  const EMBEDDING_MODEL = 'gloo-openai-text-embedding-3-small';
  const GENERATION_MODEL = 'gloo-openai-gpt-6-luna';

  // Tuning knobs for retrieval.
  const TOP_K = 3;
  const RELEVANCE_THRESHOLD = 0.30;
  const TARGET_CHUNK_WORDS = 150;

  const CONTENT_DIR = path.resolve(__dirname, '..', 'content');

  interface Chunk {
    chunk_id: string;
    source_file: string;
    chunk_index: number;
    text: string;
  }

  /** Split a file into paragraphs on blank-line boundaries. */
  function loadParagraphs(filePath: string): string[] {
    return readFileSync(filePath, 'utf-8')
      .split('\n\n')
      .map((block) => block.split(/\s+/).filter(Boolean).join(' '))
      .filter(Boolean);
  }

  /** Greedily pack consecutive paragraphs into ~TARGET_CHUNK_WORDS-word chunks. */
  function chunkFile(filePath: string, sourceFile: string): Chunk[] {
    const chunks: Chunk[] = [];
    let currentParts: string[] = [];
    let currentWords = 0;
    let chunkIndex = 0;

    const push = (): void => {
      chunks.push({
        chunk_id: `${sourceFile}::${chunkIndex}`,
        source_file: sourceFile,
        chunk_index: chunkIndex,
        text: currentParts.join('\n\n'),
      });
      chunkIndex += 1;
      currentParts = [];
      currentWords = 0;
    };

    for (const paragraph of loadParagraphs(filePath)) {
      const words = paragraph.split(' ').length;
      if (currentParts.length && currentWords + words > TARGET_CHUNK_WORDS) push();
      currentParts.push(paragraph);
      currentWords += words;
    }
    if (currentParts.length) push();
    return chunks;
  }

  function buildCorpus(): { corpus: Chunk[]; perFileCounts: Array<[string, number]> } {
    const files = readdirSync(CONTENT_DIR).filter((f) => f.endsWith('.txt')).sort();
    const corpus: Chunk[] = [];
    const perFileCounts: Array<[string, number]> = [];
    for (const name of files) {
      const fileChunks = chunkFile(path.join(CONTENT_DIR, name), name);
      perFileCounts.push([name, fileChunks.length]);
      corpus.push(...fileChunks);
    }
    return { corpus, perFileCounts };
  }
  ```

  ```php PHP theme={null}
  <?php
  declare(strict_types=1);

  require_once __DIR__ . '/vendor/autoload.php';

  use Dotenv\Dotenv;

  $dotenv = Dotenv::createImmutable(__DIR__);
  $dotenv->load();

  const API_ROOT = 'https://platform.ai.gloo.com';
  const EMBEDDING_MODEL = 'gloo-openai-text-embedding-3-small';
  const GENERATION_MODEL = 'gloo-openai-gpt-6-luna';

  // Tuning knobs for retrieval.
  const TOP_K = 3;
  const RELEVANCE_THRESHOLD = 0.30;
  const TARGET_CHUNK_WORDS = 150;

  const CONTENT_DIR = __DIR__ . '/../content';

  /** Split a file into paragraphs on blank-line boundaries. */
  function loadParagraphs(string $filePath): array
  {
      $contents = file_get_contents($filePath);
      $paragraphs = [];
      foreach (explode("\n\n", $contents ?: '') as $block) {
          $text = trim(preg_replace('/\s+/', ' ', $block) ?? '');
          if ($text !== '') {
              $paragraphs[] = $text;
          }
      }
      return $paragraphs;
  }

  /** Greedily pack consecutive paragraphs into ~TARGET_CHUNK_WORDS-word chunks. */
  function chunkFile(string $filePath, string $sourceFile): array
  {
      $chunks = [];
      $currentParts = [];
      $currentWords = 0;
      $chunkIndex = 0;

      $push = function () use (&$chunks, &$currentParts, &$currentWords, &$chunkIndex, $sourceFile): void {
          $chunks[] = [
              'chunk_id' => "$sourceFile::$chunkIndex",
              'source_file' => $sourceFile,
              'chunk_index' => $chunkIndex,
              'text' => implode("\n\n", $currentParts),
          ];
          $chunkIndex++;
          $currentParts = [];
          $currentWords = 0;
      };

      foreach (loadParagraphs($filePath) as $paragraph) {
          $words = count(explode(' ', $paragraph));
          if (!empty($currentParts) && $currentWords + $words > TARGET_CHUNK_WORDS) {
              $push();
          }
          $currentParts[] = $paragraph;
          $currentWords += $words;
      }
      if (!empty($currentParts)) {
          $push();
      }
      return $chunks;
  }

  function buildCorpus(): array
  {
      $files = glob(CONTENT_DIR . '/*.txt') ?: [];
      sort($files);
      $corpus = [];
      $perFileCounts = [];
      foreach ($files as $path) {
          $name = basename($path);
          $fileChunks = chunkFile($path, $name);
          $perFileCounts[] = [$name, count($fileChunks)];
          foreach ($fileChunks as $chunk) {
              $corpus[] = $chunk;
          }
      }
      return [$corpus, $perFileCounts];
  }
  ```

  ```go Go theme={null}
  package main

  import (
  	"fmt"
  	"os"
  	"path/filepath"
  	"sort"
  	"strings"
  )

  const (
  	apiRoot         = "https://platform.ai.gloo.com"
  	embeddingModel  = "gloo-openai-text-embedding-3-small"
  	generationModel = "gloo-openai-gpt-6-luna"

  	// Tuning knobs for retrieval.
  	topK               = 3
  	relevanceThreshold = 0.30
  	targetChunkWords   = 150
  )

  type chunk struct {
  	ChunkID    string
  	SourceFile string
  	ChunkIndex int
  	Text       string
  	Score      float64
  }

  // loadParagraphs splits a file into paragraphs on blank-line boundaries.
  func loadParagraphs(path string) ([]string, error) {
  	data, err := os.ReadFile(path)
  	if err != nil {
  		return nil, err
  	}
  	paragraphs := []string{}
  	for _, block := range strings.Split(string(data), "\n\n") {
  		if text := strings.Join(strings.Fields(block), " "); text != "" {
  			paragraphs = append(paragraphs, text)
  		}
  	}
  	return paragraphs, nil
  }

  // chunkFile greedily packs consecutive paragraphs into ~targetChunkWords-word chunks.
  func chunkFile(path, sourceFile string) ([]chunk, error) {
  	paragraphs, err := loadParagraphs(path)
  	if err != nil {
  		return nil, err
  	}
  	chunks := []chunk{}
  	currentParts := []string{}
  	currentWords := 0
  	chunkIndex := 0

  	for _, paragraph := range paragraphs {
  		words := len(strings.Fields(paragraph))
  		if len(currentParts) > 0 && currentWords+words > targetChunkWords {
  			chunks = append(chunks, chunk{
  				ChunkID:    fmt.Sprintf("%s::%d", sourceFile, chunkIndex),
  				SourceFile: sourceFile,
  				ChunkIndex: chunkIndex,
  				Text:       strings.Join(currentParts, "\n\n"),
  			})
  			chunkIndex++
  			currentParts, currentWords = []string{}, 0
  		}
  		currentParts = append(currentParts, paragraph)
  		currentWords += words
  	}
  	if len(currentParts) > 0 {
  		chunks = append(chunks, chunk{
  			ChunkID:    fmt.Sprintf("%s::%d", sourceFile, chunkIndex),
  			SourceFile: sourceFile,
  			ChunkIndex: chunkIndex,
  			Text:       strings.Join(currentParts, "\n\n"),
  		})
  	}
  	return chunks, nil
  }

  func buildCorpus(contentDir string) ([]chunk, [][2]interface{}, error) {
  	entries, err := os.ReadDir(contentDir)
  	if err != nil {
  		return nil, nil, err
  	}
  	names := []string{}
  	for _, entry := range entries {
  		if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".txt") {
  			names = append(names, entry.Name())
  		}
  	}
  	sort.Strings(names)

  	corpus := []chunk{}
  	perFileCounts := [][2]interface{}{}
  	for _, name := range names {
  		fileChunks, err := chunkFile(filepath.Join(contentDir, name), name)
  		if err != nil {
  			return nil, nil, err
  		}
  		perFileCounts = append(perFileCounts, [2]interface{}{name, len(fileChunks)})
  		corpus = append(corpus, fileChunks...)
  	}
  	return corpus, perFileCounts, nil
  }
  ```

  ```java Java theme={null}
  package com.gloo.embeddings;

  import java.nio.file.Files;
  import java.nio.file.Path;
  import java.util.ArrayList;
  import java.util.Comparator;
  import java.util.List;
  import java.util.stream.Stream;

  public class Main {

      private static final String API_ROOT = "https://platform.ai.gloo.com";
      private static final String EMBEDDING_MODEL = "gloo-openai-text-embedding-3-small";
      private static final String GENERATION_MODEL = "gloo-openai-gpt-6-luna";

      // Tuning knobs for retrieval.
      private static final int TOP_K = 3;
      private static final double RELEVANCE_THRESHOLD = 0.30;
      private static final int TARGET_CHUNK_WORDS = 150;

      private static final class Chunk {
          final String chunkId;
          final String sourceFile;
          final int chunkIndex;
          final String text;
          double score;

          Chunk(String chunkId, String sourceFile, int chunkIndex, String text) {
              this.chunkId = chunkId;
              this.sourceFile = sourceFile;
              this.chunkIndex = chunkIndex;
              this.text = text;
          }
      }

      /** Split a file into paragraphs on blank-line boundaries. */
      private static List<String> loadParagraphs(Path path) throws Exception {
          List<String> paragraphs = new ArrayList<>();
          for (String block : Files.readString(path).split("\n\n")) {
              String text = block.trim().replaceAll("\\s+", " ");
              if (!text.isEmpty()) {
                  paragraphs.add(text);
              }
          }
          return paragraphs;
      }

      /** Greedily pack consecutive paragraphs into ~TARGET_CHUNK_WORDS-word chunks. */
      private static List<Chunk> chunkFile(Path path, String sourceFile) throws Exception {
          List<Chunk> chunks = new ArrayList<>();
          List<String> currentParts = new ArrayList<>();
          int currentWords = 0;
          int chunkIndex = 0;

          for (String paragraph : loadParagraphs(path)) {
              int words = paragraph.isEmpty() ? 0 : paragraph.split("\\s+").length;
              if (!currentParts.isEmpty() && currentWords + words > TARGET_CHUNK_WORDS) {
                  chunks.add(new Chunk(sourceFile + "::" + chunkIndex, sourceFile, chunkIndex,
                          String.join("\n\n", currentParts)));
                  chunkIndex++;
                  currentParts = new ArrayList<>();
                  currentWords = 0;
              }
              currentParts.add(paragraph);
              currentWords += words;
          }
          if (!currentParts.isEmpty()) {
              chunks.add(new Chunk(sourceFile + "::" + chunkIndex, sourceFile, chunkIndex,
                      String.join("\n\n", currentParts)));
          }
          return chunks;
      }

      /** Chunk every .txt file in the content directory, in stable filename order. */
      private static List<Chunk> buildCorpus(Path contentDir, List<String> namesOut,
              List<int[]> countsOut) throws Exception {
          List<Path> files = new ArrayList<>();
          try (Stream<Path> stream = Files.list(contentDir)) {
              stream.filter(Files::isRegularFile)
                      .filter(p -> p.getFileName().toString().endsWith(".txt"))
                      .forEach(files::add);
          }
          files.sort(Comparator.comparing(p -> p.getFileName().toString()));

          List<Chunk> corpus = new ArrayList<>();
          for (Path path : files) {
              String sourceFile = path.getFileName().toString();
              List<Chunk> fileChunks = chunkFile(path, sourceFile);
              namesOut.add(sourceFile);
              countsOut.add(new int[] {fileChunks.size()});
              corpus.addAll(fileChunks);
          }
          return corpus;
      }
  }
  ```
</CodeGroup>

### What You'll See

```
Corpus: 61 chunks from 5 files
  bezalel_creation_process.txt: 15 chunks
  bezalel_educational_resources.txt: 16 chunks
  bezalel_hiring_process.txt: 11 chunks
  bezalel_research_methodology.txt: 13 chunks
  bezalel_vision_mission.txt: 6 chunks
```

### Key Points

* **Chunk at natural boundaries.** Paragraph breaks keep ideas intact; word-target packing keeps chunks a predictable size.
* **Metadata travels with the text.** `{chunk_id, source_file, chunk_index}` is what makes citations possible two steps from now.
* **Production systems often add overlap.** We skip it here to keep the logic explainable. A real pipeline typically overlaps chunks by about 10 to 20% so answers split across a boundary are still retrievable.
* **Read the [Building a Knowledge Base lesson](/ai-learning-center/gloo-ai-103/building-a-knowledge-base)** for the trade-offs behind chunk size and metadata.

***

## Step 2: Index the Corpus in One Batched Call

Now embed every chunk. The embeddings endpoint accepts an **array of up to 2,048 strings** in a single `input`, so one call embeds the whole corpus. That's the whole point of batching: fewer round-trips, less latency, and (since embeddings are billed per input token) a single place to read what you're billed in `usage`.

<Note>
  This is a **direct** endpoint: there's only one embeddings endpoint, and its job is to hand you vectors for *your* store. Guarding applies to the generation half, not the vector half; you'll see that split in [Step 5](#step-5-generate-a-grounded-answer).
</Note>

<CodeGroup>
  ```python Python theme={null}
  import requests

  EMBEDDINGS_URL = f"{API_ROOT}/ai/v2/direct/embeddings"

  def embed(texts, context, api_key):
      """One batched call: embed a list of strings with the shared model."""
      if not texts:
          raise RuntimeError(f"{context}: refusing to embed an empty input")

      # encoding_format "float" asks for plain number arrays rather than base64.
      payload = {"model": EMBEDDING_MODEL, "input": texts, "encoding_format": "float"}
      response = requests.post(
          EMBEDDINGS_URL,
          headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
          json=payload,
      )
      response.raise_for_status()
      result = response.json()

      # One embedding per input, in order; sort by index to be safe.
      items = sorted(result.get("data", []), key=lambda item: item.get("index", 0))
      return [item["embedding"] for item in items], result.get("usage")

  # Step 2: index every chunk in one call.
  chunk_texts = [chunk["text"] for chunk in corpus]
  vectors, usage = embed(chunk_texts, "Embeddings (corpus)", API_KEY)
  print(f"usage: prompt_tokens={usage['prompt_tokens']} total_tokens={usage['total_tokens']}")
  ```

  ```javascript JavaScript theme={null}
  const EMBEDDINGS_URL = `${API_ROOT}/ai/v2/direct/embeddings`;

  /** One batched call: embed a list of strings with the shared model. */
  async function embed(texts, context) {
    if (!texts.length) {
      throw new Error(`${context}: refusing to embed an empty input`);
    }

    // encoding_format "float" asks for plain number arrays rather than base64.
    const payload = { model: EMBEDDING_MODEL, input: texts, encoding_format: 'float' };
    const response = await fetch(EMBEDDINGS_URL, {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${process.env.GLOO_API_KEY}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(payload),
    });
    const result = await response.json();

    // One embedding per input, in order; sort by index to be safe.
    const items = (result.data || []).slice().sort((a, b) => (a.index || 0) - (b.index || 0));
    return { vectors: items.map((item) => item.embedding), usage: result.usage };
  }

  // Step 2: index every chunk in one call.
  const chunkTexts = corpus.map((chunk) => chunk.text);
  const { vectors, usage } = await embed(chunkTexts, 'Embeddings (corpus)');
  console.log(`usage: prompt_tokens=${usage.prompt_tokens} total_tokens=${usage.total_tokens}`);
  ```

  ```typescript TypeScript theme={null}
  const EMBEDDINGS_URL = `${API_ROOT}/ai/v2/direct/embeddings`;

  interface EmbeddingResponse {
    data?: Array<{ index?: number; embedding: number[] }>;
    usage?: { prompt_tokens?: number; total_tokens?: number };
  }

  /** One batched call: embed a list of strings with the shared model. */
  async function embed(
    texts: string[],
    context: string,
  ): Promise<{ vectors: number[][]; usage?: EmbeddingResponse['usage'] }> {
    if (!texts.length) {
      throw new Error(`${context}: refusing to embed an empty input`);
    }

    // encoding_format "float" asks for plain number arrays rather than base64.
    const payload = { model: EMBEDDING_MODEL, input: texts, encoding_format: 'float' };
    const response = await fetch(EMBEDDINGS_URL, {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${process.env.GLOO_API_KEY}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(payload),
    });
    const result = (await response.json()) as EmbeddingResponse;

    // One embedding per input, in order; sort by index to be safe.
    const items = (result.data || []).slice().sort((a, b) => (a.index || 0) - (b.index || 0));
    return { vectors: items.map((item) => item.embedding), usage: result.usage };
  }

  // Step 2: index every chunk in one call.
  const chunkTexts = corpus.map((chunk) => chunk.text);
  const { vectors, usage } = await embed(chunkTexts, 'Embeddings (corpus)');
  console.log(`usage: prompt_tokens=${usage?.prompt_tokens} total_tokens=${usage?.total_tokens}`);
  ```

  ```php PHP theme={null}
  <?php
  const EMBEDDINGS_URL = API_ROOT . '/ai/v2/direct/embeddings';

  /** One batched call: embed a list of strings with the shared model. */
  function embed(array $texts, string $context, string $apiKey): array
  {
      if (empty($texts)) {
          throw new Exception("$context: refusing to embed an empty input");
      }

      // encoding_format "float" asks for plain number arrays rather than base64.
      $payload = ['model' => EMBEDDING_MODEL, 'input' => array_values($texts), 'encoding_format' => 'float'];

      $ch = curl_init(EMBEDDINGS_URL);
      curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
      curl_setopt($ch, CURLOPT_POST, true);
      curl_setopt($ch, CURLOPT_HTTPHEADER, [
          "Authorization: Bearer $apiKey",
          'Content-Type: application/json',
      ]);
      curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
      $body = curl_exec($ch);
      curl_close($ch);
      $result = json_decode($body, true);

      // One embedding per input, in order; sort by index to be safe.
      $items = $result['data'] ?? [];
      usort($items, fn ($a, $b) => ($a['index'] ?? 0) <=> ($b['index'] ?? 0));
      return [array_map(fn ($item) => $item['embedding'], $items), $result['usage'] ?? null];
  }

  // Step 2: index every chunk in one call.
  $chunkTexts = array_map(fn ($chunk) => $chunk['text'], $corpus);
  [$vectors, $usage] = embed($chunkTexts, 'Embeddings (corpus)', $apiKey);
  echo "usage: prompt_tokens={$usage['prompt_tokens']} total_tokens={$usage['total_tokens']}\n";
  ```

  ```go Go theme={null}
  const embeddingsURL = apiRoot + "/ai/v2/direct/embeddings"

  type embeddingsResponse struct {
  	Data []struct {
  		Index     int       `json:"index"`
  		Embedding []float64 `json:"embedding"`
  	} `json:"data"`
  	Usage *struct {
  		PromptTokens *int `json:"prompt_tokens"`
  		TotalTokens  *int `json:"total_tokens"`
  	} `json:"usage"`
  }

  // embed makes one batched call: embed a list of strings with the shared model.
  func embed(texts []string, context string) ([][]float64, *embeddingsResponse, error) {
  	if len(texts) == 0 {
  		return nil, nil, fmt.Errorf("%s: refusing to embed an empty input", context)
  	}

  	// encoding_format "float" asks for plain number arrays rather than base64.
  	payload := map[string]interface{}{
  		"model":           embeddingModel,
  		"input":           texts,
  		"encoding_format": "float",
  	}
  	body, err := postJSON(embeddingsURL, payload, context)
  	if err != nil {
  		return nil, nil, err
  	}
  	var result embeddingsResponse
  	if err := json.Unmarshal(body, &result); err != nil {
  		return nil, nil, err
  	}

  	// One embedding per input, in order; sort by index to be safe.
  	sort.SliceStable(result.Data, func(i, j int) bool { return result.Data[i].Index < result.Data[j].Index })
  	vectors := make([][]float64, len(result.Data))
  	for i, item := range result.Data {
  		vectors[i] = item.Embedding
  	}
  	return vectors, &result, nil
  }

  // Step 2: index every chunk in one call.
  chunkTexts := make([]string, len(corpus))
  for i, c := range corpus {
  	chunkTexts[i] = c.Text
  }
  vectors, result, err := embed(chunkTexts, "Embeddings (corpus)")
  if err != nil {
  	log.Fatal(err)
  }
  fmt.Printf("usage: prompt_tokens=%d total_tokens=%d\n",
  	*result.Usage.PromptTokens, *result.Usage.TotalTokens)
  ```

  ```java Java theme={null}
  import com.google.gson.JsonArray;
  import com.google.gson.JsonObject;
  import com.google.gson.JsonParser;

  // One batched call: embed a list of strings with the shared model.
  private static List<double[]> embed(List<String> texts, StringBuilder usageOut) throws Exception {
      if (texts.isEmpty()) {
          throw new RuntimeException("Embeddings: refusing to embed an empty input");
      }

      // encoding_format "float" asks for plain number arrays rather than base64.
      JsonObject payload = new JsonObject();
      payload.addProperty("model", EMBEDDING_MODEL);
      JsonArray input = new JsonArray();
      texts.forEach(input::add);
      payload.add("input", input);
      payload.addProperty("encoding_format", "float");

      JsonObject result = postJson(EMBEDDINGS_URL, payload, "Embeddings (corpus)");
      JsonArray data = result.has("data") ? result.getAsJsonArray("data") : new JsonArray();

      // One embedding per input, in order; sort by index to be safe.
      List<JsonObject> items = new ArrayList<>();
      data.forEach((element) -> items.add(element.getAsJsonObject()));
      items.sort(Comparator.comparingInt(item -> item.has("index") ? item.get("index").getAsInt() : 0));

      List<double[]> vectors = new ArrayList<>();
      for (JsonObject item : items) {
          JsonArray embedding = item.getAsJsonArray("embedding");
          double[] vector = new double[embedding.size()];
          for (int i = 0; i < embedding.size(); i++) {
              vector[i] = embedding.get(i).getAsDouble();
          }
          vectors.add(vector);
      }

      JsonObject usage = result.getAsJsonObject("usage");
      usageOut.append("usage: prompt_tokens=").append(usage.get("prompt_tokens").getAsInt())
              .append(" total_tokens=").append(usage.get("total_tokens").getAsInt());
      return vectors;
  }
  ```
</CodeGroup>

### What You'll See

```
Embedding 61 chunks in one batched call (gloo-openai-text-embedding-3-small)...
  usage: prompt_tokens=9719 total_tokens=9719
```

### Key Points

* **One call, not 61.** The `input` array batches the whole corpus; that's fewer round-trips and less overhead than a per-chunk loop.
* **`usage` is your bill.** Embeddings are priced per input token with no output charge, so `prompt_tokens` here (9,719 for the Bezalel corpus) is exactly what this call costs; watch it in [API usage](/studio/api-usage).
* **`encoding_format: "float"` keeps the math simple.** You get plain number arrays back; base64 is an option when your client needs a compact wire format.
* **Vectors from different models aren't comparable.** Pick one embedding model and use it for both the corpus and the query; switching later means re-embedding everything.
* **This demo holds vectors in a list.** A real deployment persists them in a vector store keyed by `chunk_id`; the retrieval logic is the same either way. See the [Embeddings API guide](/api-guides/embeddings) for the full request and response shape.

***

## Step 3: Retrieve by Cosine Similarity

To answer a question, embed it with the **same model**, then score it against every chunk vector with **cosine similarity**: `dot(q, c) / (‖q‖ · ‖c‖)`. Gloo passes the provider's vectors through untouched, so they are **not normalized**; compute the norms explicitly instead of assuming unit length.

Then apply two tuning knobs:

* **Top-k (`k = 3`)**: take the highest-scoring chunks. One chunk rarely holds a whole answer, and scores are imperfect, so a small set beats a single best match; see the [Retrieval Step lesson](/ai-learning-center/gloo-ai-103/the-retrieval-step).
* **Relevance threshold (`0.30` cosine)**: if even the best score falls short, skip generation and say so. That's the lesson's warning against confidently presenting irrelevant matches.

<CodeGroup>
  ```python Python theme={null}
  def cosine_similarity(a, b):
      """Dot(a, b) / (||a|| * ||b||). Explicitly normalized: vectors aren't unit length."""
      dot = sum(x * y for x, y in zip(a, b))
      norm_a = sum(x * x for x in a) ** 0.5
      norm_b = sum(y * y for y in b) ** 0.5
      return 0.0 if norm_a == 0 or norm_b == 0 else dot / (norm_a * norm_b)

  def rank_chunks(query_vector, corpus, vectors):
      scored = [{**chunk, "score": cosine_similarity(query_vector, vector)}
                for chunk, vector in zip(corpus, vectors)]
      return sorted(scored, key=lambda item: item["score"], reverse=True)

  # Embed the query with the SAME model, then rank.
  query_vector = embed([question], "Embeddings (query)", API_KEY)[0][0]
  ranked = rank_chunks(query_vector, corpus, vectors)

  for position, chunk in enumerate(ranked, 1):
      print(f"  {position:>3}. {chunk['score']:.4f}  {chunk['source_file']} chunk {chunk['chunk_index']}")

  top = ranked[:TOP_K]
  if not top or top[0]["score"] < RELEVANCE_THRESHOLD:
      print("No sufficiently relevant chunks found")
      return
  ```

  ```javascript JavaScript theme={null}
  /** Dot(a, b) / (||a|| * ||b||). Explicitly normalized: vectors aren't unit length. */
  function cosineSimilarity(a, b) {
    let dot = 0, normA = 0, normB = 0;
    for (let i = 0; i < a.length; i++) {
      dot += a[i] * b[i];
      normA += a[i] * a[i];
      normB += b[i] * b[i];
    }
    normA = Math.sqrt(normA);
    normB = Math.sqrt(normB);
    return normA === 0 || normB === 0 ? 0 : dot / (normA * normB);
  }

  function rankChunks(queryVector, corpus, vectors) {
    return corpus
      .map((chunk, i) => ({ ...chunk, score: cosineSimilarity(queryVector, vectors[i]) }))
      .sort((a, b) => b.score - a.score);
  }

  // Embed the query with the SAME model, then rank.
  const [queryVector] = (await embed([question], 'Embeddings (query)')).vectors;
  const ranked = rankChunks(queryVector, corpus, vectors);

  ranked.forEach((chunk, i) =>
    console.log(`  ${String(i + 1).padStart(3)}. ${chunk.score.toFixed(4)}  ${chunk.source_file} chunk ${chunk.chunk_index}`),
  );

  const top = ranked.slice(0, TOP_K);
  if (!top.length || top[0].score < RELEVANCE_THRESHOLD) {
    console.log('No sufficiently relevant chunks found');
    return;
  }
  ```

  ```typescript TypeScript theme={null}
  /** Dot(a, b) / (||a|| * ||b||). Explicitly normalized: vectors aren't unit length. */
  function cosineSimilarity(a: number[], b: number[]): number {
    let dot = 0, normA = 0, normB = 0;
    for (let i = 0; i < a.length; i++) {
      dot += a[i] * b[i];
      normA += a[i] * a[i];
      normB += b[i] * b[i];
    }
    normA = Math.sqrt(normA);
    normB = Math.sqrt(normB);
    return normA === 0 || normB === 0 ? 0 : dot / (normA * normB);
  }

  interface ScoredChunk extends Chunk {
    score: number;
  }

  function rankChunks(queryVector: number[], corpus: Chunk[], vectors: number[][]): ScoredChunk[] {
    return corpus
      .map((chunk, i) => ({ ...chunk, score: cosineSimilarity(queryVector, vectors[i]) }))
      .sort((a, b) => b.score - a.score);
  }

  // Embed the query with the SAME model, then rank.
  const [queryVector] = (await embed([question], 'Embeddings (query)')).vectors;
  const ranked = rankChunks(queryVector, corpus, vectors);

  ranked.forEach((chunk, i) =>
    console.log(
      `  ${String(i + 1).padStart(3)}. ${chunk.score.toFixed(4)}  ${chunk.source_file} chunk ${chunk.chunk_index}`,
    ),
  );

  const top = ranked.slice(0, TOP_K);
  if (!top.length || top[0].score < RELEVANCE_THRESHOLD) {
    console.log('No sufficiently relevant chunks found');
    return;
  }
  ```

  ```php PHP theme={null}
  <?php
  /** Dot(a, b) / (||a|| * ||b||). Explicitly normalized: vectors aren't unit length. */
  function cosineSimilarity(array $a, array $b): float
  {
      $dot = 0.0;
      $normA = 0.0;
      $normB = 0.0;
      for ($i = 0; $i < count($a); $i++) {
          $dot += $a[$i] * $b[$i];
          $normA += $a[$i] * $a[$i];
          $normB += $b[$i] * $b[$i];
      }
      $normA = sqrt($normA);
      $normB = sqrt($normB);
      return ($normA == 0.0 || $normB == 0.0) ? 0.0 : $dot / ($normA * $normB);
  }

  function rankChunks(array $queryVector, array $corpus, array $vectors): array
  {
      $scored = [];
      foreach ($corpus as $i => $chunk) {
          $chunk['score'] = cosineSimilarity($queryVector, $vectors[$i]);
          $scored[] = $chunk;
      }
      usort($scored, fn ($a, $b) => $b['score'] <=> $a['score']);
      return $scored;
  }

  // Embed the query with the SAME model, then rank.
  [$queryVectors] = embed([$question], 'Embeddings (query)', $apiKey);
  $queryVector = $queryVectors[0];
  $ranked = rankChunks($queryVector, $corpus, $vectors);

  foreach ($ranked as $i => $chunk) {
      printf("  %3d. %.4f  %s chunk %d\n", $i + 1, $chunk['score'], $chunk['source_file'], $chunk['chunk_index']);
  }

  $top = array_slice($ranked, 0, TOP_K);
  if (empty($top) || $top[0]['score'] < RELEVANCE_THRESHOLD) {
      echo "No sufficiently relevant chunks found\n";
      return;
  }
  ```

  ```go Go theme={null}
  // cosineSimilarity is dot(a, b) / (||a|| * ||b||). Explicitly normalized:
  // vectors aren't unit length.
  func cosineSimilarity(a, b []float64) float64 {
  	dot, normA, normB := 0.0, 0.0, 0.0
  	for i := 0; i < len(a) && i < len(b); i++ {
  		dot += a[i] * b[i]
  	}
  	for _, x := range a {
  		normA += x * x
  	}
  	for _, y := range b {
  		normB += y * y
  	}
  	normA, normB = math.Sqrt(normA), math.Sqrt(normB)
  	if normA == 0 || normB == 0 {
  		return 0.0
  	}
  	return dot / (normA * normB)
  }

  func rankChunks(queryVector []float64, corpus []chunk, vectors [][]float64) []chunk {
  	ranked := make([]chunk, len(corpus))
  	for i, c := range corpus {
  		c.Score = cosineSimilarity(queryVector, vectors[i])
  		ranked[i] = c
  	}
  	sort.SliceStable(ranked, func(i, j int) bool { return ranked[i].Score > ranked[j].Score })
  	return ranked
  }

  // Embed the query with the SAME model, then rank.
  queryVectors, _, err := embed([]string{question}, "Embeddings (query)")
  if err != nil {
  	log.Fatal(err)
  }
  ranked := rankChunks(queryVectors[0], corpus, vectors)

  for i, c := range ranked {
  	fmt.Printf("  %3d. %.4f  %s chunk %d\n", i+1, c.Score, c.SourceFile, c.ChunkIndex)
  }

  top := ranked
  if len(top) > topK {
  	top = top[:topK]
  }
  if len(top) == 0 || top[0].Score < relevanceThreshold {
  	fmt.Println("No sufficiently relevant chunks found")
  	return
  }
  ```

  ```java Java theme={null}
  /** Dot(a, b) / (||a|| * ||b||). Explicitly normalized: vectors aren't unit length. */
  private static double cosineSimilarity(double[] a, double[] b) {
      double dot = 0.0, normA = 0.0, normB = 0.0;
      int n = Math.min(a.length, b.length);
      for (int i = 0; i < n; i++) {
          dot += a[i] * b[i];
      }
      for (double x : a) {
          normA += x * x;
      }
      for (double y : b) {
          normB += y * y;
      }
      normA = Math.sqrt(normA);
      normB = Math.sqrt(normB);
      return (normA == 0 || normB == 0) ? 0.0 : dot / (normA * normB);
  }

  private static List<Chunk> rankChunks(double[] queryVector, List<Chunk> corpus, List<double[]> vectors) {
      List<Chunk> ranked = new ArrayList<>(corpus);
      for (int i = 0; i < ranked.size(); i++) {
          ranked.get(i).score = cosineSimilarity(queryVector, vectors.get(i));
      }
      ranked.sort(Comparator.comparingDouble((Chunk c) -> c.score).reversed());
      return ranked;
  }

  // Embed the query with the SAME model, then rank.
  List<double[]> queryVectors = embed(List.of(question), new StringBuilder());
  double[] queryVector = queryVectors.get(0);
  List<Chunk> ranked = rankChunks(queryVector, corpus, vectors);

  for (int i = 0; i < ranked.size(); i++) {
      Chunk c = ranked.get(i);
      System.out.printf("  %3d. %.4f  %s chunk %d%n", i + 1, c.score, c.sourceFile, c.chunkIndex);
  }

  List<Chunk> top = ranked.size() > TOP_K ? new ArrayList<>(ranked.subList(0, TOP_K)) : ranked;
  if (top.isEmpty() || top.get(0).score < RELEVANCE_THRESHOLD) {
      System.out.println("No sufficiently relevant chunks found");
      return;
  }
  ```
</CodeGroup>

### What You'll See

The full ranked list prints first, so you can judge retrieval quality *before* generation runs. For the default query:

```
All chunks ranked by cosine similarity:
    1. 0.7624  bezalel_hiring_process.txt chunk 0
    2. 0.7185  bezalel_hiring_process.txt chunk 1
    3. 0.6945  bezalel_hiring_process.txt chunk 7
    4. 0.5871  bezalel_vision_mission.txt chunk 0
    5. ...

Top 3 chunks (threshold 0.3):
  0.7624  bezalel_hiring_process.txt chunk 0
  0.7185  bezalel_hiring_process.txt chunk 1
  0.6945  bezalel_hiring_process.txt chunk 7
```

### Key Points

* **Print the ranked list, not just the winners.** When a RAG answer is wrong, the first thing to inspect is what was retrieved, before blaming the model.
* **Normalize explicitly.** Gloo returns the provider's vectors as-is; cosine similarity needs the norms, so compute them rather than assuming unit length.
* **`k > 1` on purpose.** Answers spread across chunks and scores are noisy, so the top few together beat the single best match.
* **`0.30` is a named tuning knob, not magic.** It's a starting value for this corpus; tune it (and `k`) against your own content. The [Retrieval Step lesson](/ai-learning-center/gloo-ai-103/the-retrieval-step) covers similarity search, top-K, and ranking in depth.

***

## Step 4: Augment with Labeled Chunks

Now build the prompt that turns retrieved chunks into a grounded answer. We follow the anatomy from the [Augmentation Step lesson](/ai-learning-center/gloo-ai-103/the-augmentation-step): **instructions first** (role, use-only-context, admit-gaps), **context in the middle**, **question last**.

Each chunk is prefixed with a `[Source: file, chunk N]` label so the model can attribute what it uses, which is what makes citations possible.

<CodeGroup>
  ```python Python theme={null}
  def build_prompt(question, selected):
      """Labeled-chunks prompt: instructions, then [Source: ...] context, then the question."""
      context_blocks = []
      for chunk in selected:
          label = f"[Source: {chunk['source_file']}, chunk {chunk['chunk_index']}]"
          context_blocks.append(f"{label}\n{chunk['text']}")
      context = "\n\n".join(context_blocks)

      instructions = (
          "You are an assistant for Bezalel Ministries. Answer the user's question "
          "using only the context below. If the context does not contain enough "
          "information to answer fully, say so. Cite the sources you use by their "
          "[Source: ...] labels."
      )
      user_input = f"Context:\n---\n{context}\n---\nQuestion: {question}"
      return instructions, user_input
  ```

  ```javascript JavaScript theme={null}
  /** Labeled-chunks prompt: instructions, then [Source: ...] context, then the question. */
  function buildPrompt(question, selected) {
    const context = selected
      .map((chunk) => `[Source: ${chunk.source_file}, chunk ${chunk.chunk_index}]\n${chunk.text}`)
      .join('\n\n');

    const instructions =
      "You are an assistant for Bezalel Ministries. Answer the user's question " +
      'using only the context below. If the context does not contain enough ' +
      'information to answer fully, say so. Cite the sources you use by their ' +
      '[Source: ...] labels.';
    const userInput = `Context:\n---\n${context}\n---\nQuestion: ${question}`;
    return { instructions, userInput };
  }
  ```

  ```typescript TypeScript theme={null}
  /** Labeled-chunks prompt: instructions, then [Source: ...] context, then the question. */
  function buildPrompt(
    question: string,
    selected: ScoredChunk[],
  ): { instructions: string; userInput: string } {
    const context = selected
      .map((chunk) => `[Source: ${chunk.source_file}, chunk ${chunk.chunk_index}]\n${chunk.text}`)
      .join('\n\n');

    const instructions =
      "You are an assistant for Bezalel Ministries. Answer the user's question " +
      'using only the context below. If the context does not contain enough ' +
      'information to answer fully, say so. Cite the sources you use by their ' +
      '[Source: ...] labels.';
    const userInput = `Context:\n---\n${context}\n---\nQuestion: ${question}`;
    return { instructions, userInput };
  }
  ```

  ```php PHP theme={null}
  <?php
  /** Labeled-chunks prompt: instructions, then [Source: ...] context, then the question. */
  function buildPrompt(string $question, array $selected): array
  {
      $contextBlocks = [];
      foreach ($selected as $chunk) {
          $label = "[Source: {$chunk['source_file']}, chunk {$chunk['chunk_index']}]";
          $contextBlocks[] = "$label\n{$chunk['text']}";
      }
      $context = implode("\n\n", $contextBlocks);

      $instructions =
          "You are an assistant for Bezalel Ministries. Answer the user's question "
          . 'using only the context below. If the context does not contain enough '
          . 'information to answer fully, say so. Cite the sources you use by their '
          . '[Source: ...] labels.';
      $userInput = "Context:\n---\n$context\n---\nQuestion: $question";
      return [$instructions, $userInput];
  }
  ```

  ```go Go theme={null}
  // buildPrompt returns a labeled-chunks prompt: instructions, then [Source: ...]
  // context, then the question.
  func buildPrompt(question string, selected []chunk) (string, string) {
  	blocks := make([]string, 0, len(selected))
  	for _, c := range selected {
  		label := fmt.Sprintf("[Source: %s, chunk %d]", c.SourceFile, c.ChunkIndex)
  		blocks = append(blocks, label+"\n"+c.Text)
  	}
  	context := strings.Join(blocks, "\n\n")

  	instructions := "You are an assistant for Bezalel Ministries. Answer the user's question " +
  		"using only the context below. If the context does not contain enough " +
  		"information to answer fully, say so. Cite the sources you use by their " +
  		"[Source: ...] labels."
  	userInput := fmt.Sprintf("Context:\n---\n%s\n---\nQuestion: %s", context, question)
  	return instructions, userInput
  }
  ```

  ```java Java theme={null}
  /** Labeled-chunks prompt: instructions, then [Source: ...] context, then the question. */
  private static String[] buildPrompt(String question, List<Chunk> selected) {
      List<String> blocks = new ArrayList<>();
      for (Chunk c : selected) {
          blocks.add("[Source: " + c.sourceFile + ", chunk " + c.chunkIndex + "]\n" + c.text);
      }
      String context = String.join("\n\n", blocks);

      String instructions =
              "You are an assistant for Bezalel Ministries. Answer the user's question "
              + "using only the context below. If the context does not contain enough "
              + "information to answer fully, say so. Cite the sources you use by their "
              + "[Source: ...] labels.";
      String userInput = "Context:\n---\n" + context + "\n---\nQuestion: " + question;
      return new String[] {instructions, userInput};
  }
  ```
</CodeGroup>

### Key Points

* **Instructions first, question last.** The model sees the grounding rules before the context and the actual ask, matching the lesson's prompt anatomy.
* **Labels make citations possible.** `[Source: file, chunk N]` lets the model (and you) point back to specific retrieved chunks, not just a document.
* **Context stays tiny.** Three chunks at \~150 to 300 words is a few hundred tokens, nowhere near a context-window concern, which is why we can keep this discussion brief.
* **Read the [Augmentation Step lesson](/ai-learning-center/gloo-ai-103/the-augmentation-step)** for prompt templates and how to tell the model to use (and not misuse) the context you pass.

***

## Step 5: Generate a Grounded Answer

The last call goes to **Guarded Responses** (`POST /ai/v2/guarded/responses`) to leverage Gloo’s full safety and values pipeline. The `instructions` field carries the grounding rules (the Responses equivalent of a system message); `input` carries the labeled context and question. We parse the typed `output[]` array for the answer text.

<CodeGroup>
  ```python Python theme={null}
  import requests

  RESPONSES_URL = f"{API_ROOT}/ai/v2/guarded/responses"

  def generate_answer(instructions, user_input, api_key):
      """One Guarded Responses call over the retrieved context."""
      payload = {
          "model": GENERATION_MODEL,
          "instructions": instructions,
          "input": user_input,
          "max_output_tokens": 500,
          "temperature": 0.2,
          # Uncomment to request a values-aligned answer for a specific tradition —
          # a Gloo capability the direct surface can't offer:
          # "tradition": "evangelical",
      }
      response = requests.post(
          RESPONSES_URL,
          headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
          json=payload,
      )
      if response.status_code == 403:
          # The guarded surface can refuse content — surface that distinctly.
          raise RuntimeError(f"guardrails hard-blocked this request: {response.text}")
      response.raise_for_status()
      return response.json()

  def extract_text(result):
      """Extract the assistant text from a Responses payload's output[] array."""
      parts = []
      for item in result.get("output", []):
          if item.get("type") != "message":
              continue
          for content in item.get("content", []):
              if content.get("type") == "output_text":
                  parts.append(content.get("text", ""))
      return "\n".join(parts)

  result = generate_answer(*build_prompt(question, top), API_KEY)
  print(extract_text(result))
  ```

  ```javascript JavaScript theme={null}
  const RESPONSES_URL = `${API_ROOT}/ai/v2/guarded/responses`;

  /** One Guarded Responses call over the retrieved context. */
  async function generateAnswer(instructions, userInput) {
    const payload = {
      model: GENERATION_MODEL,
      instructions,
      input: userInput,
      max_output_tokens: 500,
      temperature: 0.2,
      // Uncomment to request a values-aligned answer for a specific tradition —
      // a Gloo capability the direct surface can't offer:
      // tradition: 'evangelical',
    };
    const response = await fetch(RESPONSES_URL, {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${process.env.GLOO_API_KEY}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(payload),
    });
    if (response.status === 403) {
      // The guarded surface can refuse content — surface that distinctly.
      throw new Error(`guardrails hard-blocked this request: ${await response.text()}`);
    }
    return response.json();
  }

  /** Extract the assistant text from a Responses payload's output[] array. */
  function extractText(result) {
    const parts = [];
    for (const item of result.output || []) {
      if (item.type !== 'message') continue;
      for (const content of item.content || []) {
        if (content.type === 'output_text') parts.push(content.text || '');
      }
    }
    return parts.join('\n');
  }

  const { instructions, userInput } = buildPrompt(question, top);
  console.log(extractText(await generateAnswer(instructions, userInput)));
  ```

  ```typescript TypeScript theme={null}
  const RESPONSES_URL = `${API_ROOT}/ai/v2/guarded/responses`;

  interface ResponsesResponse {
    output?: Array<{ type?: string; content?: Array<{ type?: string; text?: string }> }>;
  }

  /** One Guarded Responses call over the retrieved context. */
  async function generateAnswer(instructions: string, userInput: string): Promise<ResponsesResponse> {
    const payload = {
      model: GENERATION_MODEL,
      instructions,
      input: userInput,
      max_output_tokens: 500,
      temperature: 0.2,
      // Uncomment to request a values-aligned answer for a specific tradition —
      // a Gloo capability the direct surface can't offer:
      // tradition: 'evangelical',
    };
    const response = await fetch(RESPONSES_URL, {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${process.env.GLOO_API_KEY}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(payload),
    });
    if (response.status === 403) {
      // The guarded surface can refuse content — surface that distinctly.
      throw new Error(`guardrails hard-blocked this request: ${await response.text()}`);
    }
    return (await response.json()) as ResponsesResponse;
  }

  /** Extract the assistant text from a Responses payload's output[] array. */
  function extractText(result: ResponsesResponse): string {
    const parts: string[] = [];
    for (const item of result.output || []) {
      if (item.type !== 'message') continue;
      for (const content of item.content || []) {
        if (content.type === 'output_text') parts.push(content.text || '');
      }
    }
    return parts.join('\n');
  }

  const { instructions, userInput } = buildPrompt(question, top);
  console.log(extractText(await generateAnswer(instructions, userInput)));
  ```

  ```php PHP theme={null}
  <?php
  const RESPONSES_URL = API_ROOT . '/ai/v2/guarded/responses';

  /** One Guarded Responses call over the retrieved context. */
  function generateAnswer(string $instructions, string $userInput, string $apiKey): array
  {
      $payload = [
          'model' => GENERATION_MODEL,
          'instructions' => $instructions,
          'input' => $userInput,
          'max_output_tokens' => 500,
          'temperature' => 0.2,
          // Uncomment to request a values-aligned answer for a specific tradition —
          // a Gloo capability the direct surface can't offer:
          // 'tradition' => 'evangelical',
      ];

      $ch = curl_init(RESPONSES_URL);
      curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
      curl_setopt($ch, CURLOPT_POST, true);
      curl_setopt($ch, CURLOPT_HTTPHEADER, [
          "Authorization: Bearer $apiKey",
          'Content-Type: application/json',
      ]);
      curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
      $body = curl_exec($ch);
      $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
      curl_close($ch);

      if ($status === 403) {
          // The guarded surface can refuse content — surface that distinctly.
          throw new Exception("guardrails hard-blocked this request: $body");
      }
      return json_decode($body, true);
  }

  /** Extract the assistant text from a Responses payload's output[] array. */
  function extractText(array $result): string
  {
      $parts = [];
      foreach ($result['output'] ?? [] as $item) {
          if (($item['type'] ?? '') !== 'message') {
              continue;
          }
          foreach ($item['content'] ?? [] as $content) {
              if (($content['type'] ?? '') === 'output_text') {
                  $parts[] = $content['text'] ?? '';
              }
          }
      }
      return implode("\n", $parts);
  }

  [$instructions, $userInput] = buildPrompt($question, $top);
  echo extractText(generateAnswer($instructions, $userInput, $apiKey)) . "\n";
  ```

  ```go Go theme={null}
  const responsesURL = apiRoot + "/ai/v2/guarded/responses"

  type responsesResponse struct {
  	Output []struct {
  		Type    string `json:"type"`
  		Content []struct {
  			Type string `json:"type"`
  			Text string `json:"text"`
  		} `json:"content"`
  	} `json:"output"`
  }

  // generateAnswer makes one Guarded Responses call over the retrieved context.
  func generateAnswer(instructions, userInput string) (*responsesResponse, error) {
  	payload := map[string]interface{}{
  		"model":             generationModel,
  		"instructions":      instructions,
  		"input":             userInput,
  		"max_output_tokens": 500,
  		"temperature":       0.2,
  		// Uncomment to request a values-aligned answer for a specific tradition —
  		// a Gloo capability the direct surface can't offer:
  		// "tradition": "evangelical",
  	}
  	body, err := postJSON(responsesURL, payload, "Guarded Responses")
  	if err != nil {
  		return nil, err // a 403 is surfaced as a guardrails hard-block here
  	}
  	var result responsesResponse
  	return &result, json.Unmarshal(body, &result)
  }

  // extractText pulls the assistant text out of the output[] array.
  func extractText(result *responsesResponse) string {
  	parts := []string{}
  	for _, item := range result.Output {
  		if item.Type != "message" {
  			continue
  		}
  		for _, content := range item.Content {
  			if content.Type == "output_text" {
  				parts = append(parts, content.Text)
  			}
  		}
  	}
  	return strings.Join(parts, "\n")
  }

  instructions, userInput := buildPrompt(question, top)
  answer, err := generateAnswer(instructions, userInput)
  if err != nil {
  	log.Fatal(err)
  }
  fmt.Println(extractText(answer))
  ```

  ```java Java theme={null}
  import com.google.gson.JsonArray;
  import com.google.gson.JsonObject;

  private static final String RESPONSES_URL = API_ROOT + "/ai/v2/guarded/responses";

  /** One Guarded Responses call over the retrieved context. */
  private static JsonObject generateAnswer(String instructions, String userInput) throws Exception {
      JsonObject payload = new JsonObject();
      payload.addProperty("model", GENERATION_MODEL);
      payload.addProperty("instructions", instructions);
      payload.addProperty("input", userInput);
      payload.addProperty("max_output_tokens", 500);
      payload.addProperty("temperature", 0.2);
      // Uncomment to request a values-aligned answer for a specific tradition —
      // a Gloo capability the direct surface can't offer:
      // payload.addProperty("tradition", "evangelical");

      // A 403 is surfaced by postJson as a guardrails hard-block, not an auth failure.
      return postJson(RESPONSES_URL, payload, "Guarded Responses");
  }

  /** Extract the assistant text from a Responses payload's output[] array. */
  private static String extractText(JsonObject result) {
      StringBuilder text = new StringBuilder();
      JsonArray output = result.has("output") ? result.getAsJsonArray("output") : new JsonArray();
      for (var item : output) {
          JsonObject entry = item.getAsJsonObject();
          if (!"message".equals(entry.has("type") ? entry.get("type").getAsString() : "")) {
              continue;
          }
          JsonArray content = entry.has("content") ? entry.getAsJsonArray("content") : new JsonArray();
          for (var part : content) {
              JsonObject piece = part.getAsJsonObject();
              if ("output_text".equals(piece.has("type") ? piece.get("type").getAsString() : "")) {
                  text.append(piece.get("text").getAsString()).append("\n");
              }
          }
      }
      return text.toString().trim();
  }

  String[] prompt = buildPrompt(question, top);
  System.out.println(extractText(generateAnswer(prompt[0], prompt[1])));
  ```
</CodeGroup>

<Note>
  **The endpoint-family split is the real design decision.** The *retrieval* half is yours: direct Embeddings plus your own vectors, the whole reason this recipe exists. The *generation* half goes guarded, so Gloo's guardrails, output moderation, and the optional `tradition` values-alignment protect the final answer. That matches the ["which endpoint should I use"](/api-guides/endpoint-types) guidance for new integrations. Contrast with [Grounded Completions](/tutorials/completions-grounded), where Gloo's Data Engine does retrieval for you.
</Note>

### Key Points

* **Use each endpoint for its half.** Direct Embeddings for retrieval over your vectors; Guarded Responses for a protected, cited answer.
* **`temperature: 0.2` favors faithfulness.** A lower temperature keeps the answer close to the provided context.
* **`tradition` is a Gloo extra.** The commented-out knob requests a values-aligned response for a specific tradition, something the direct surface can't offer.
* **Watch the guardrail path.** A `403` here means guardrails refused the content, not that your key is wrong; handle it as its own case (see [Troubleshooting](#guarded-responses-returns-403)).
* **Read the [Generation Step lesson](/ai-learning-center/gloo-ai-103/the-generation-step)** for how retrieved context becomes a final, grounded answer.

***

## Testing Your Implementation

1. **Set up your environment**:

   ```bash theme={null}
   # Copy the environment template
   cp .env.example .env

   # Add your API key (from the API Credentials page)
   echo "GLOO_API_KEY=your_api_key_here" >> .env
   ```

2. **Run the code**:

   <CodeGroup>
     ```bash Python theme={null}
     python3 -m venv venv
     source venv/bin/activate
     pip install -r requirements.txt
     python main.py
     ```

     ```bash JavaScript theme={null}
     npm install
     npm start
     ```

     ```bash TypeScript theme={null}
     npm install
     npm start
     ```

     ```bash PHP theme={null}
     composer install
     php index.php
     ```

     ```bash Go theme={null}
     go mod tidy
     go run main.go
     ```

     ```bash Java theme={null}
     mvn compile
     mvn exec:java
     ```
   </CodeGroup>

3. **Try your own query**: pass one as an argument:

   <CodeGroup>
     ```bash Python theme={null}
     python main.py "What educational resources does Bezalel provide?"
     ```

     ```bash JavaScript theme={null}
     node index.js "What educational resources does Bezalel provide?"
     ```

     ```bash TypeScript theme={null}
     npx ts-node index.ts "What educational resources does Bezalel provide?"
     ```

     ```bash PHP theme={null}
     php index.php "What educational resources does Bezalel provide?"
     ```

     ```bash Go theme={null}
     go run main.go "What educational resources does Bezalel provide?"
     ```

     ```bash Java theme={null}
     mvn exec:java -Dexec.args="What educational resources does Bezalel provide?"
     ```
   </CodeGroup>

## What You'll See

A complete run of the Python example, live-tested against the Bezalel corpus with the default query:

```
================================================================================
  EMBEDDINGS RECIPE — semantic search with your own vector store
================================================================================

Corpus: 61 chunks from 5 files
  bezalel_creation_process.txt: 15 chunks
  bezalel_educational_resources.txt: 16 chunks
  bezalel_hiring_process.txt: 11 chunks
  bezalel_research_methodology.txt: 13 chunks
  bezalel_vision_mission.txt: 6 chunks

Embedding 61 chunks in one batched call (gloo-openai-text-embedding-3-small)...
  usage: prompt_tokens=9719 total_tokens=9719

Query: How does Bezalel choose its artists?
  query usage: prompt_tokens=9

All chunks ranked by cosine similarity:
    1. 0.7624  bezalel_hiring_process.txt chunk 0
    2. 0.7185  bezalel_hiring_process.txt chunk 1
    3. 0.6945  bezalel_hiring_process.txt chunk 7
    ...

Top 3 chunks (threshold 0.3):
  0.7624  bezalel_hiring_process.txt chunk 0
  0.7185  bezalel_hiring_process.txt chunk 1
  0.6945  bezalel_hiring_process.txt chunk 7

Generating a grounded answer (Guarded Responses)...

================================================================================
ANSWER
================================================================================
Bezalel Ministries selects artists through a three-phase journey. In Phase One,
artists submit a portfolio, a statement of faith and calling, and pastoral
references; a committee of three senior artists and the creative director reviews
each portfolio, and about 30 percent of applicants advance. In Phase Two,
candidates complete a paid test commission (such as the bronze laver or the high
priest's ephod), observed for how they interpret Scripture, respond to feedback,
and handle deadlines. In Phase Three, artists enter a six-month apprenticeship
with theological training, technical workshops, and spiritual formation, after
which a successful review by the full artistic team and board leads to a position
as a full Bezalel artist.

Sources used:
  - bezalel_hiring_process.txt chunk 0
  - bezalel_hiring_process.txt chunk 1
  - bezalel_hiring_process.txt chunk 7
```

The answer is drawn from the retrieved chunks and cites the `[Source: ...]` labels the model was given; the hiring-process document won because the query matches it, exactly as the ranking predicted.

<Note>
  Numbers like the 9,719 embedding tokens and the specific scores vary with the corpus and the embedding model. The shape of the output (counts, ranked chunks, top-k, then the answer) is what every language implementation produces.
</Note>

## Working Code Sample

<Card title="View Complete Code" icon="github" href="https://github.com/GlooDeveloper/gloo-ai-docs-cookbook/tree/main/embeddings">
  Clone or browse the complete working examples for all 6 languages (JavaScript,
  TypeScript, Python, PHP, Go, Java) with setup instructions and the Bezalel
  content files.
</Card>

## Next Steps

Now that you've built semantic search on your own vectors:

<CardGroup cols={2}>
  <Card title="Embeddings API Guide" href="/api-guides/embeddings" icon="vector-square">
    Full request and response reference for the direct embeddings endpoint.
  </Card>

  <Card title="Building a Knowledge Base" href="/ai-learning-center/gloo-ai-103/building-a-knowledge-base" icon="database">
    Go deeper on chunking, metadata, and the Goldilocks chunk-size problem.
  </Card>

  <Card title="Grounded Completions" href="/tutorials/completions-grounded" icon="anchor">
    The Data Engine alternative: let Gloo do retrieval for content you upload.
  </Card>

  <Card title="Supported Models" href="/api-guides/supported-models" icon="list">
    Embedding models, vector widths, input limits, and live pricing.
  </Card>
</CardGroup>

## Troubleshooting

### Retrieval returns nothing relevant

**Symptom**: Generation is skipped and the script prints `No sufficiently relevant chunks found`.

**Cause**: No chunk scored at or above `RELEVANCE_THRESHOLD` (`0.30` cosine). This is the threshold doing its job; it prevents confidently answering from irrelevant matches.

**Solution**: Try an off-corpus query to see the gate fire. For example, `"What is the weather on Mars?"` scores roughly 0.10 to 0.21 against every Bezalel chunk, so the best match still falls below `0.30`:

```
Query: What is the weather on Mars?
All chunks ranked by cosine similarity:
    1. 0.1863  bezalel_research_methodology.txt chunk 3
    2. 0.1621  bezalel_educational_resources.txt chunk 0
    ...
No sufficiently relevant chunks found
```

If in-corpus queries also miss, lower `RELEVANCE_THRESHOLD` (try `0.2`), raise `TOP_K`, or revisit your chunking.

### Guarded Responses returns `403`

**Cause**: The **guarded** surface refused the content, a guardrails hard-block, not an authentication failure. (An auth `401`/`403` means a bad key or missing organization access.)

**Solution**: Treat the guardrail `403` as its own case and surface it gracefully rather than as a crash: the cookbook exposes it as `guardrails hard-blocked this request (403)` with the message body. Re-check the instructions and input you're sending, and don't retry unchanged.

### `400 Bad Request` on the embeddings call

**Cause**: A non-embedding model ID, an empty or whitespace-only string, more than 2,048 inputs, or a chunk longer than the model's input limit.

**Solution**: Use an embedding model (`gloo-openai-text-embedding-3-small`), make sure no input is blank, and keep each chunk within the model's `max_input_tokens`. The response's `detail.param` names the offending field; see the [Embeddings error table](/api-guides/embeddings#errors).

### `Missing GLOO_API_KEY`

**Cause**: The environment variable isn't set.

**Solution**: Create a `.env` from `.env.example` (see [Testing Your Implementation](#testing-your-implementation)), add `GLOO_API_KEY=...`, and re-run. The script fails fast with the variable name in the message.

### `503` from embeddings

**Cause**: A transient provider outage or rate limit. Embeddings requests aren't failed over to a second provider, so a `503` returns with `retryable: true`.

**Solution**: Retry with bounded exponential backoff. The cookbook retries `1s → 2s → 4s` (three attempts) before surfacing the error. If the same request keeps failing, change the request or model rather than retrying unchanged.

## Additional Resources

* [Embeddings API guide](/api-guides/embeddings): request/response shape, input rules, errors, pricing
* [Embeddings API reference](/api-reference/embeddings/direct): interactive request builder
* [Supported Models](/api-guides/supported-models): embedding models, vector widths, input limits, live pricing
* [Lesson 4: Building a Knowledge Base](/ai-learning-center/gloo-ai-103/building-a-knowledge-base): chunking and metadata
* [Lesson 5: The Retrieval Step](/ai-learning-center/gloo-ai-103/the-retrieval-step): similarity search, top-K, ranking
* [Lesson 6: The Augmentation Step](/ai-learning-center/gloo-ai-103/the-augmentation-step): prompt templates and context
* [Lesson 7: The Generation Step](/ai-learning-center/gloo-ai-103/the-generation-step): turning context into answers
* [Gloo AI Docs Cookbook](https://github.com/GlooDeveloper/gloo-ai-docs-cookbook/tree/main/embeddings): complete code in all 6 languages


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.