0Pricing
AI Engineering Academy · Lesson

Indexing: Embedding and Storing Chunks

Embed each chunk using the OpenAI embeddings API and upsert the resulting vectors with metadata into a vector store, building a searchable index of your documents.

Indexing: Embedding and Storing Chunks is a free AI Engineering Academy lesson on CoddyKit — lesson 3 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the AI Engineering Academy learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.

The Indexing Stage: Overview

After loading and chunking your documents, you reach the indexing stage: converting text chunks into vector embeddings and storing them in a searchable vector database. This is the final offline step before queries can be answered. The quality of your embeddings and the efficiency of your storage and indexing strategy directly determine how fast and accurate your RAG system will be at query time.

Generating Embeddings via OpenAI API

The most common approach is to call OpenAI's embeddings API with your chunk text. The text-embedding-3-small model produces 1536-dimensional vectors and costs $0.02 per million tokens — extremely cheap for most workloads. Send multiple texts in a single API call (up to 2048 inputs) to maximize throughput. The response contains one embedding vector per input text in the same order.

from openai import OpenAI

client = OpenAI()

def embed_batch(texts, model='text-embedding-3-small'):
    response = client.embeddings.create(
        model=model,
        input=texts  # up to 2048 texts per call
    )
    return [item.embedding for item in response.data]

# Embed one batch of 100 chunk texts
texts = [chunk['text'] for chunk in chunks[:100]]
vectors = embed_batch(texts)
print(f'Embedding dimension: {len(vectors[0])}')
print(f'Embedded {len(vectors)} chunks')

Batching for Efficiency

When indexing thousands of chunks, efficiency matters. Process chunks in batches of 100-500 to balance throughput and memory usage. Track your position so you can resume after a failure without re-embedding already processed chunks. Log progress regularly. For 100,000 chunks at 500 per batch, you will make 200 API calls — this typically completes in a few minutes.

def embed_all_chunks(chunks, batch_size=200):
    embedded = []
    total = len(chunks)
    for i in range(0, total, batch_size):
        batch = chunks[i:i+batch_size]
        texts = [c['text'] for c in batch]
        vectors = embed_batch(texts)
        for chunk, vector in zip(batch, vectors):
            embedded.append({
                **chunk,
                'embedding': vector
            })
        if (i // batch_size) % 10 == 0:
            print(f'Progress: {min(i+batch_size, total)}/{total}')
    return embedded

Rate Limit Handling During Indexing

The OpenAI embeddings API has rate limits measured in tokens per minute (TPM). Large indexing jobs hit these limits and receive RateLimitError. Implement exponential backoff with jitter: when a rate limit error occurs, wait a brief random interval before retrying, doubling the wait on each subsequent failure. This spreads retries out and prevents all parallel workers from hammering the API at the same moment.

import time
import random
from openai import RateLimitError

def embed_batch_with_retry(texts, max_retries=5):
    for attempt in range(max_retries):
        try:
            return embed_batch(texts)
        except RateLimitError:
            if attempt == max_retries - 1:
                raise
            wait = (2 ** attempt) + random.uniform(0, 1)
            print(f'Rate limited. Waiting {wait:.1f}s...')
            time.sleep(wait)
    return []

Upserting Vectors into Pinecone

After generating embeddings, upsert them into the vector store. Upserting means inserting new vectors or updating existing ones if the same ID already exists — idempotent by design. In Pinecone, each upserted record contains the vector ID, the embedding values, and a metadata dictionary of fields you want to filter or display later. Upsert in batches of up to 100 records per call for optimal throughput.

import pinecone

pc = pinecone.Pinecone(api_key='YOUR_KEY')
index = pc.Index('rag-index')

def upsert_to_pinecone(embedded_chunks, batch_size=100):
    for i in range(0, len(embedded_chunks), batch_size):
        batch = embedded_chunks[i:i+batch_size]
        vectors = [
            (
                chunk['id'],
                chunk['embedding'],
                {
                    'text': chunk['text'],
                    'source': chunk['metadata']['source'],
                    'page': chunk['metadata'].get('page', 0)
                }
            )
            for chunk in batch
        ]
        index.upsert(vectors=vectors)
        print(f'Upserted {min(i+batch_size, len(embedded_chunks))}/{len(embedded_chunks)}')

Storing in pgvector

With pgvector, you insert embeddings directly into a PostgreSQL table using standard SQL. The vector data type accepts a Python list of floats serialized as a string. After inserting all rows, create an HNSW index for fast approximate nearest neighbor queries. Indexing an existing table with millions of rows can take several minutes, so build the index after bulk insertion rather than before.

import psycopg2
from psycopg2.extras import execute_values

def upsert_to_pgvector(conn, embedded_chunks):
    with conn.cursor() as cur:
        records = [
            (
                chunk['id'],
                chunk['text'],
                chunk['metadata']['source'],
                chunk['metadata'].get('page', 0),
                chunk['embedding']   # list of floats
            )
            for chunk in embedded_chunks
        ]
        execute_values(cur, '''
            INSERT INTO document_chunks (id, text, source, page, embedding)
            VALUES %s
            ON CONFLICT (id) DO UPDATE
            SET text = EXCLUDED.text, embedding = EXCLUDED.embedding
        ''', records)
    conn.commit()

Building the HNSW Index

HNSW (Hierarchical Navigable Small World) is the index type that enables fast approximate nearest neighbor search. Unlike brute-force search (which compares the query vector against every stored vector), HNSW builds a multi-layer graph structure that prunes the search space. The m parameter controls how many connections each node has (higher = better recall but more memory), and ef_construction controls index quality during build time.

-- Build HNSW index after bulk insertion
CREATE INDEX CONCURRENTLY ON document_chunks
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);

-- Set ef_search at query time to trade recall vs speed
SET hnsw.ef_search = 100;

-- Verify index was created
SELECT indexname, indexdef
FROM pg_indexes
WHERE tablename = 'document_chunks';

Metadata Schema Design

Metadata stored alongside each vector enables powerful filtered retrieval. Design your metadata schema before indexing — adding new fields later requires re-indexing. Include fields you will filter on (department, doc_type, date range), fields you will display in citations (title, page, author), and fields useful for debugging (chunk_index, total_chunks, indexed_at). Keep metadata values simple: strings, numbers, and booleans index and filter efficiently; nested objects do not.

# Well-designed metadata schema
METADATA_SCHEMA = {
    # For filtering at retrieval time
    'department': 'HR',         # string
    'doc_type': 'policy',       # string
    'year': 2025,               # integer
    'is_active': True,          # boolean

    # For display in citations
    'title': 'Employee Handbook 2025',
    'author': 'HR Team',
    'page': 12,
    'source': 's3://docs/handbook_2025.pdf',

    # For debugging and updates
    'chunk_index': 3,
    'total_chunks': 24,
    'indexed_at': '2025-09-01T10:00:00Z'
}

Checkpointing Long Indexing Jobs

Indexing a large corpus can take hours. A crash midway wastes all progress. Implement a checkpoint file that records which chunks have been successfully indexed. On restart, skip already-indexed chunks and continue from where you left off. This makes the indexing job idempotent and safe to resume. Store the checkpoint as a set of processed chunk IDs in a JSON file or database table.

import json
from pathlib import Path

CHECKPOINT_FILE = '/tmp/index_checkpoint.json'

def load_checkpoint():
    if Path(CHECKPOINT_FILE).exists():
        return set(json.loads(Path(CHECKPOINT_FILE).read_text()))
    return set()

def save_checkpoint(indexed_ids):
    Path(CHECKPOINT_FILE).write_text(json.dumps(list(indexed_ids)))

def index_with_checkpoint(chunks, index):
    done = load_checkpoint()
    remaining = [c for c in chunks if c['id'] not in done]
    print(f'Resuming: {len(done)} done, {len(remaining)} remaining')
    for chunk in remaining:
        upsert_to_pinecone([chunk], index)
        done.add(chunk['id'])
        save_checkpoint(done)

Verifying Index Completeness

After indexing, verify that all chunks made it into the vector store. Compare the number of chunks produced by your splitter against the vector count reported by the index. Query the index with a known document's text and confirm that the expected result appears in the top 5. Run a few known queries from your golden test set and check that precision is at the expected level. Never assume the index is complete without verifying it.

def verify_index(index, chunks, sample_size=10):
    index_stats = index.describe_index_stats()
    total_vectors = index_stats.total_vector_count
    expected = len(chunks)
    print(f'Index vectors: {total_vectors}, Expected: {expected}')
    if total_vectors != expected:
        print('WARNING: mismatch — some chunks may not have been indexed')

    # Spot-check retrieval
    import random
    sample = random.sample(chunks, sample_size)
    for chunk in sample:
        vec = embed_batch([chunk['text']])[0]
        results = index.query(vector=vec, top_k=1, include_metadata=True)
        top_id = results.matches[0].id if results.matches else None
        if top_id != chunk['id']:
            print(f'WARNING: expected {chunk["id"]}, got {top_id}')

Local Embedding Alternatives

For privacy-sensitive data that cannot leave your infrastructure, use locally hosted embedding models. The sentence-transformers library provides high-quality models like all-MiniLM-L6-v2 (384-dim, 22MB, very fast) and bge-large-en-v1.5 (1024-dim, better quality). Run them on CPU for moderate workloads or GPU for large indexing jobs. Local models eliminate API costs and data egress but require managing model files and compute resources.

from sentence_transformers import SentenceTransformer

# Load once at startup
model = SentenceTransformer('sentence-transformers/all-MiniLM-L6-v2')

def embed_locally(texts, batch_size=64):
    # encode() handles batching internally
    embeddings = model.encode(
        texts,
        batch_size=batch_size,
        show_progress_bar=True,
        convert_to_numpy=True
    )
    return embeddings.tolist()  # convert numpy array to Python list

vectors = embed_locally([c['text'] for c in chunks])

Quick Check

Test your understanding of AI Engineering concepts from this lesson.

Lesson Recap

In this lesson you learned: generating embeddings in batches with the OpenAI API and handling rate limits with exponential backoff, upserting vectors into Pinecone and pgvector with metadata, building HNSW indexes for fast approximate nearest neighbor search, and production best practices including checkpoint-based resumption, metadata schema design, and index completeness verification. Next up we build the query pipeline that retrieves chunks and generates grounded answers.

Frequently asked questions

Is the “Indexing: Embedding and Storing Chunks” lesson free?

Yes — the full text of “Indexing: Embedding and Storing Chunks” is free to read here on the web, and the AI Engineering Academy course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the AI Engineering Academy course, upgrade to CoddyKit PRO.

What will I learn in “Indexing: Embedding and Storing Chunks”?

Embed each chunk using the OpenAI embeddings API and upsert the resulting vectors with metadata into a vector store, building a searchable index of your documents. You practise AI Engineering Academy with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.

Do I need any experience to start AI Engineering Academy?

No prior experience is required. AI Engineering Academy on CoddyKit is structured for beginners through advanced learners; this is — lesson 3 of 4, so you can start here or from the beginning and move at your own pace.

How long does the “Indexing: Embedding and Storing Chunks” lesson take?

Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.

Can I write and run code in this AI Engineering Academy lesson?

Yes. Every AI Engineering Academy lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.

All lessons in this course

  1. Document Loading and Text Extraction
  2. Chunking Strategies: Fixed vs Sentence vs Recursive
  3. Indexing: Embedding and Storing Chunks
  4. Query, Retrieve, and Generate
← Back to AI Engineering Academy