Siddhant Deval
Siddhant Deval
backend5 min read

High-Dimensional Vector Embeddings & Similarity Math

Fixed-character chunking severs sentences mid-thought and produces incoherent vector clusters that degrade retrieval quality. This article builds the mathematical foundation — Dot Product, Cosine Similarity, and L2 distance — then implements an AST-aware semantic chunking pipeline with SHA-256 deduplication for the Workspace knowledge vault.

Series·Part 8 of 24

Full-Stack AI Architecture: From Streaming Foundations to Resilient Agentic Systems in Node.js & React

High-Dimensional Vector Embeddings & Similarity Math

Fixed-size character chunking — "split every 500 characters" — is the most common mistake in production RAG systems. It severs sentences mid-clause, breaks code blocks in the middle of function signatures, and splits Markdown headers from the content they govern. The resulting vector clusters are semantically incoherent: embeddings that encode half-ideas from two different topics.

This article builds the mathematical foundation of vector search from first principles — Dot Product, Cosine Similarity, and L2 Euclidean distance — then implements an AST-aware chunking pipeline that respects document structure, and adds SHA-256 deduplication to prevent expensive re-embedding of identical content across document revisions.

Architectural Note

Who this is for: Full-stack engineers who understand that embeddings work but not why. After this article, you will make informed decisions about distance metrics, embedding dimensions, normalisation, and chunk boundaries — not just copy boilerplate from embedding API documentation.


1. Vector Embeddings: From Text to Geometry

1.1 What an Embedding Is

A text embedding is a function that maps a variable-length string to a fixed-length dense vector of floating-point numbers in a high-dimensional space. Semantically similar texts map to geometrically nearby vectors. The embedding model has learned this mapping from billions of text pairs during training.

"How do I implement rate limiting in Node.js?"
→ [0.023, -0.412, 0.887, ..., 0.031]  // 1536 dimensions (text-embedding-3-large)

"What is the best approach for throttling API requests in Express?"
→ [0.019, -0.398, 0.901, ..., 0.028]  // Very close in 1536-dimensional space

"What is the capital of France?"
→ [0.741, 0.123, -0.302, ..., 0.618]  // Far away

1.2 The Three Distance Metrics

Metric Formula When to Use
Dot Product a · b = Σ(aᵢ × bᵢ) Normalised vectors only — fastest computation
Cosine Similarity cos(θ) = (a · b) / (|a| × |b|) Unnormalised vectors — angle measures semantic similarity
L2 Euclidean ‖a - b‖ = √Σ(aᵢ - bᵢ)² When magnitude (not just angle) carries meaning
Pro Tip & Optimization

Always L2-normalise vectors at ingestion time. When ‖v‖ = 1 for all vectors, Dot Product and Cosine Similarity are identical (cos(θ) = a · b for unit vectors), and Dot Product is 3–5× faster to compute. pgvector's inner product operator <#> (negated dot product) is the fastest index scan available — but requires normalised vectors.

TYPESCRIPT
// ✅ Normalise at ingestion — do once, benefit every query
// file: packages/db/src/embeddings.ts

export function l2Normalize(vector: number[]): number[] {
  const magnitude = Math.sqrt(vector.reduce((sum, v) => sum + v * v, 0))
  if (magnitude === 0) throw new Error('Cannot normalise zero vector')
  return vector.map(v => v / magnitude)
}

export async function embedAndNormalise(text: string): Promise<number[]> {
  const response = await openai.embeddings.create({
    model: 'text-embedding-3-large',
    input: text,
    dimensions: 1536,  // Matryoshka: reducible to 256 for fast ANN (Part 9)
  })
  return l2Normalize(response.data[0].embedding)
}

2. The Chunking Problem

2.1 Why Character Splits Fail

TYPESCRIPT
// ❌ Anti-Pattern — naive character chunking destroys semantic coherence
function naiveChunk(text: string, size = 500): string[] {
  const chunks: string[] = []
  for (let i = 0; i < text.length; i += size) {
    chunks.push(text.slice(i, i + size))
  }
  return chunks
}

// Input: A Markdown section about pgvector HNSW indexing
// Output chunk boundary might split:
// Chunk 3: "...The HNSW index uses a layered graph where each no"
// Chunk 4: "de connects to M nearest neighbours. The ef_construction pa..."
// → Neither chunk makes sense in isolation

2.2 AST-Aware Semantic Chunking

The correct approach is to parse the document into its abstract syntax tree and chunk along semantic boundaries: Markdown headings, code blocks, and list items — not arbitrary character counts.

TYPESCRIPT
// ✅ AST-aware Markdown chunker
// file: packages/db/src/chunker/MarkdownChunker.ts

import { remark } from 'remark'
import type { Root, Node, Parent } from 'mdast'
import { createHash } from 'node:crypto'

export interface DocumentChunk {
  content: string
  heading: string      // Nearest parent heading — provides context
  chunkIndex: number
  sha256: string       // Content hash for deduplication
  tokenCount: number   // Pre-computed for context budget scheduling
}

const MAX_CHUNK_TOKENS = 400  // Leave room for contextual prefix (Part 12)
const MIN_CHUNK_TOKENS = 50   // Avoid micro-chunks that carry no information

export class MarkdownChunker {
  async chunk(markdown: string): Promise<DocumentChunk[]> {
    const ast = remark().parse(markdown) as Root
    const chunks: DocumentChunk[] = []
    let currentContent = ''
    let currentHeading = 'Introduction'
    let chunkIndex = 0

    const flush = () => {
      const trimmed = currentContent.trim()
      if (!trimmed) return
      const tokens = this.estimateTokens(trimmed)
      if (tokens < MIN_CHUNK_TOKENS) return  // Skip trivially small chunks

      chunks.push({
        content: trimmed,
        heading: currentHeading,
        chunkIndex: chunkIndex++,
        sha256: createHash('sha256').update(trimmed).digest('hex'),
        tokenCount: tokens,
      })
      currentContent = ''
    }

    for (const node of ast.children) {
      if (node.type === 'heading') {
        flush()  // Each heading boundary = chunk boundary
        currentHeading = this.extractText(node as Parent)
      } else if (node.type === 'code') {
        // Code blocks are always their own chunk — never split
        flush()
        const codeContent = `\`\`\`${(node as any).lang ?? ''}\n${(node as any).value}\n\`\`\``
        chunks.push({
          content: codeContent,
          heading: currentHeading,
          chunkIndex: chunkIndex++,
          sha256: createHash('sha256').update(codeContent).digest('hex'),
          tokenCount: this.estimateTokens(codeContent),
        })
      } else {
        const nodeText = this.extractText(node as Parent)
        currentContent += '\n' + nodeText
        // If accumulated content exceeds token limit, flush mid-section
        if (this.estimateTokens(currentContent) >= MAX_CHUNK_TOKENS) {
          flush()
        }
      }
    }
    flush()  // Final chunk

    return chunks
  }

  private estimateTokens(text: string): number {
    // Approximation: ~4 chars per token for English prose.
    // For code-heavy documents (2–3 chars/token), lower MAX_CHUNK_TOKENS to ~300
    // to avoid oversized chunks. Use TokenAwareRequestBuilder (Part 2) in the
    // ingestion pipeline when token accuracy matters more than startup cost.
    return Math.ceil(text.length / 4)
  }

  private extractText(node: Parent): string {
    if (!node.children) return ''
    return node.children
      .map((child: any) => child.value ?? this.extractText(child))
      .join('')
  }
}

3. SHA-256 Deduplication at Ingestion

3.1 The Deduplication Problem

When a document is revised, re-uploaded, or re-indexed, naive re-embedding re-processes every chunk — even unchanged ones. At $0.00013 per 1,000 tokens (text-embedding-3-large), re-embedding a 100-page technical spec (≈200,000 tokens) costs $26 per indexing run. With daily document revisions across 1,000 workspace documents, that is a significant avoidable cost.

TYPESCRIPT
// ✅ Deduplication-aware ingestion pipeline
// file: packages/db/src/ingestion/DocumentIngester.ts

import { db } from '../drizzle'
import { chunks } from '../schema'
import { eq } from 'drizzle-orm'
import { MarkdownChunker } from '../chunker/MarkdownChunker'
import { embedAndNormalise } from '../embeddings'

export async function ingestDocument(
  documentId: string,
  content: string,
): Promise<{ created: number; skipped: number }> {
  const chunker = new MarkdownChunker()
  const newChunks = await chunker.chunk(content)

  // Fetch existing hashes for this document
  const existingHashes = new Set(
    (await db.select({ sha256: chunks.sha256 })
      .from(chunks)
      .where(eq(chunks.documentId, documentId)))
      .map(r => r.sha256)
  )

  let created = 0
  let skipped = 0

  // Process in batches of 20 to respect rate limits
  for (let i = 0; i < newChunks.length; i += 20) {
    const batch = newChunks.slice(i, i + 20)
    const toEmbed = batch.filter(c => !existingHashes.has(c.sha256))
    const alreadyExist = batch.filter(c => existingHashes.has(c.sha256))

    skipped += alreadyExist.length

    if (toEmbed.length === 0) continue

    // Batch embed only genuinely new chunks
    const embeddings = await Promise.all(
      toEmbed.map(c => embedAndNormalise(c.content))
    )

    await db.insert(chunks).values(
      toEmbed.map((chunk, idx) => ({
        documentId,
        content: chunk.content,
        heading: chunk.heading,
        chunkIndex: chunk.chunkIndex,
        sha256: chunk.sha256,
        tokenCount: chunk.tokenCount,
        embedding: embeddings[idx],  // pgvector stores as float4[]
      }))
    )

    created += toEmbed.length
  }

  return { created, skipped }
}

4. PostgreSQL Schema for Vector Storage

TYPESCRIPT
// ✅ Drizzle schema — chunks table with pgvector column
// file: packages/db/src/schema/chunks.ts

import { pgTable, uuid, text, integer, vector, index } from 'drizzle-orm/pg-core'

export const chunks = pgTable('chunks', {
  id:          uuid('id').primaryKey().defaultRandom(),
  documentId:  uuid('document_id').notNull(),
  orgId:       uuid('org_id').notNull(),         // Multi-tenant partition key
  content:     text('content').notNull(),
  heading:     text('heading').notNull(),
  chunkIndex:  integer('chunk_index').notNull(),
  sha256:      text('sha256').notNull(),          // Deduplication key
  tokenCount:  integer('token_count').notNull(),
  // 1536-dimensional normalised float vector (text-embedding-3-large)
  embedding:   vector('embedding', { dimensions: 1536 }).notNull(),
}, (table) => ({
  // HNSW index — built in Part 9
  embeddingIdx: index('chunks_embedding_hnsw_idx')
    .using('hnsw', table.embedding.op('vector_ip_ops'))
    .with({ m: 16, ef_construction: 64 }),
  // Compound index for tenant-scoped lookups
  orgDocIdx: index('chunks_org_doc_idx').on(table.orgId, table.documentId),
  // Unique constraint prevents duplicate chunk content per document
  sha256DocIdx: index('chunks_sha256_doc_idx').on(table.sha256, table.documentId),
}))

Summary

Concept Rule
L2 normalisation Normalise at ingestion; use inner product <#> operator for 3–5× faster queries
AST chunking Chunk at Markdown heading and code block boundaries — never mid-sentence
SHA-256 dedup Hash-check before embedding; skip re-embedding of unchanged chunks
Batch embedding Group 20 chunks per API call with concurrency limiter to avoid rate-limit spikes
Min chunk size Reject chunks under 50 tokens — they carry insufficient semantic signal

What's Next

Part 9 migrates the flat chunk table into a production HNSW index with tuned m and ef_construction parameters, adds Row-Level Security for multi-tenant isolation, and implements the shadow-column migration strategy for zero-downtime embedding model upgrades.

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#Embeddings#Vector Search#Semantic Chunking#Node.js#PostgreSQL#RAG#Mathematics
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.

Series·Part 8 of 24

Full-Stack AI Architecture: From Streaming Foundations to Resilient Agentic Systems in Node.js & React

Curriculum Roadmap
01The Full-Stack AI Paradigm Shift: Beyond Toy Wrappers & Prompt Concatenation
02The Anatomy of an LLM Call: Tokens, Context Windows & Probabilistic Mechanics
03Structured Outputs & Formal Invariants: Taming Non-Determinism with Zod & TypeBox
04Full-Stack Streaming Protocols: SSE, Fetch Streams & WebSockets
05V8 Streams, Server-Side Backpressure & Cancellations in Node.js
06React 18/19 Streaming UI & Generative Components
07Generative UI & Interactive Canvas Synchronization
08High-Dimensional Vector Embeddings & Similarity Math
Reading
09Vector Storage & Indexing at Scale: HNSW vs IVF in PostgreSQL (pgvector)
10Hybrid Search: Dense Vectors Meet Sparse Lexical BM25
11Multi-Stage Retrieval & Cross-Encoder Reranking
12Advanced RAG Architectures: Contextual Retrieval & Parent-Document Splitting
13Query Transformation, Decomposition & Multi-Query Expansion
14Dynamic Context Window Management & Long-Context Packing
15Agent Loops & Deterministic State Machines
16Model Context Protocol (MCP) in Full-Stack Architecture
17Human-in-the-Loop & Durable Agent Workflows
18Multi-Agent Orchestration & Collaborative Swarms
19Semantic Caching & Provider Cost Optimization
20Multi-Provider AI Gateways: Fallbacks, Rate Limiting & Hedged Requests
21Client-Side AI & Edge Hybrid Execution: WebGPU & Transformers.js
22Production Evals & Guardrails: Measuring Retrieval and Generation
23Full-Stack AI Observability & Tracing with OpenTelemetry
24Production Capstone: Assembling The Enterprise Collaborative AI Workspace