Vol. 09 · Retrieval & RAG

RAG & Vector Search

Full route: why words cannot be compared directly → Bag of Words → TF-IDF → Word2Vec → contextual embeddings → cosine distance → how exactly the chunk “matches” with the query → HNSW → hybrid search → the full RAG pipeline parsed down to the byte.

01

Sparse methods: from words to numbers

4 cards
01SparseFundamental problem: text cannot be directly compared

The computer works with numbers. The strings “cat” and “cat” are different byte sequences with no default proximity between them. The entire history of information retrieval is a history of attempts to turn the meaning of text into numbers that can be compared mathematically.

Diagramtext
Naive string comparison:

  query: "cat"
  doc_1: "cat" → string match: 0% (different strings)
  doc_2: "kitten" → string match: 0% (different strings)
  doc_3: "cat" → string match: 100% (match)
  doc_4: "pet" → string match: 0% (although the meaning is close!)

Problem: semantic proximity ≠ lexical match

Evolution of solutions:
  1960s Boolean search (AND/OR/NOT keywords)
  1970s Bag of Words → word frequency vector
  1980s TF-IDF → importance weighting
  2000s LSA/LSI → hidden semantics via SVD
  2013 Word2Vec → neural word embeddings
  2018 BERT → contextual offer embeddings
  2020+ Bi-Encoder models → embeddings for search (E5, BGE, nomic)
02SparseBag of Words: frequency vector

Bag of Words is the first way to turn text into vector. We build a dictionary of all words in the corpus, representing each document as a vector: how many times each word appears. Word order is ignored - hence the name "bag".

Diagramtext
Body of 2 documents:
  d1: "the cat is sitting at home"
  d2: "the dog is running home"

Vocabulary (vocab):
  {cat:0, sitting:1, at home:2, dog:3, running:4, home:5}

Vectors (BoW):
       cat sitting at home dog running home
  d1: [ 2, 1, 1, 0, 0, 0 ]
  d2: [ 0, 0, 0, 1, 1, 1 ]

query: "the cat is running"
  q: [ 1, 0, 0, 0, 1, 0 ]

Matches with d1: "cat" → 1
Matches with d2: "runs" → 1
→ draw. But "the cat is running" is semantically closer to d1?

BoW problems

  • “and”, “in”, “on” - frequent, but useless
  • vector size = dictionary size (100k+)
  • 99% of values = 0 (sparse vector)
  • word order lost: “the cat eats the fish” = “the fish eats the cat”
03SparseTF-IDF: weighted by rarity

TF-IDF solves the problem of stop words: a word is important if it occurs frequently in the document (TF) and rarely in the rest of the corpus (IDF). “Neural network” in a text about ML is more important than “is” - although both can appear 5 times.

formula and intuitiontex
TF(word, document) = count(word in doc) / len(doc)
IDF(word) = log( N / df(word) )
  N = number of documents in the corpus
  df = in how many documents the word appears

TF-IDF(word, doc) = TF × IDF

Example, corpus = 1000 documents:
  word "and": df=1000 → IDF=log(1000/1000)=0
  word "algorithm": df=50 → IDF=log(1000/50) =3.0
  word "catboost": df=3 → IDF=log(1000/3) =5.8

→ a rare and specific word receives high weight
→ stop words will automatically be zeroed

used in

  • BM25 - improved TF-IDF (Elasticsearch)
  • sparse retrieval in hybrid search
  • still doesn't understand synonyms
  • “cat” and “cat” are completely different words
04SparseBM25: industrial sparse search

BM25 (Best Match 25) - improved TF-IDF with normalization by document length. A long document does not benefit simply from size. This is a standard in Elasticsearch, Opensearch and the baseline for RAG retrieval.

formula BM25tex
BM25(q, d) = Σ_{word∈q} IDF(word) ·
  TF(word,d) · (k₁+1)
  ─────────────────────────────────
  TF(word,d) + k₁·(1-b+b·|d|/avgdl)

k₁ ∈ [1.2, 2.0] – TF saturation
b ∈ [0, 1] - length normalization (usually 0.75)
|d| = document length, avgdl = average length

Key properties:
✓ TF saturation: 100 mentions ≠ 10× more important than 10
✓ long docs do not automatically dominate
✓ fast: inverted index → O(|q|) search

when BM25 beats vectors

  • exact names: “WB-article 12345678”
  • codes, abbreviations, proper names
  • rare specific terms
  • synonyms, paraphrases - does not see
02

Dense Embeddings: meaning in space

4 cards
05DenseWhat is embedding: meaning as a point in space

Embedding is a vector of several hundred or thousand numbers that encodes the meaning of the text so that semantically similar texts appear close in vector space. This is the key idea: distance in space equals semantic distance in language.

Diagramsql
Imagine a 2D space (actually 7683072 dimensions):

  X-axis → “animal/non-animal”
  Y-axis → domestic/wild

                       homemade

          cat [0.8, 0.9] ●
          cat [0.7, 0.9] ● dog [0.6, 0.8] ●

  ──────────────────── ───────────────────── animal

          lion [0.9, 0.1] ● wolf [0.8, 0.1] ●

                         wild

  table [-0.9, 0.0] ● ← far from animals
  algorithm [-0.8, 0.0] ●

query: "pet"
  embedding ≈ [0.75, 0.88]
  → nearest points: cat, cat, dog
  → although the word "pet" does not appear in these documents!
  → this is the power of semantic search

key properties

  • “cat” and “cat” - side by side in space
  • “pet” and “cat” are semantically close
  • dense: all 768 values are non-zero
  • size: 384–3072 depending on model
06DenseWord2Vec: neural word embeddings

Word2Vec (2013, Google) is the first widely used neural embedding approach. Idea: a word is determined by the company in which it occurs. “Cat” and “cat” appear in similar contexts → their vectors will be similar.

Skip-gram architecturesql
Task: predict neighbors based on the central word

  Window = 2:
  "the big [cat] is sitting on the sofa"
  → predict: "big", "sitting" from the word "cat"

Neural network (2 layers):
  input: one-hot(cat) ∈ ℝ^vocab_size
  hidden: W_embed · one-hot → ℝ^300 ← embedding!
  output: softmax → P(each word as a neighbor)

After training on a billion tokens:
  king − man + woman ≈ queen
  → the arithmetic of meanings works!

Word2Vec problem:
  "onion" (vegetable) = "onion" (weapon)
  → one word = one vector, context not taken into account
07DenseContextual embeddings: BERT and Bi-Encoder

BERT (2018) solved the problem of polysemy: the embedding of a word depends on the context. The “bow” in “bought a bow for soup” and “took a bow and arrow” receives different vectors. For RAG, Bi-Encoder is used: we encode the request and the document separately, and compare them with cosine.

Diagramtext
Word2Vec (static):
  "bow" → [0.3, -0.5, 0.8, …] ← always one vector

BERT (contextual):
  "bought onions for soup" → [0.7, 0.2, -0.1, …]
  “took a bow and arrows” → [-0.4, 0.8, 0.3, …]
  ← different vectors for one word!

Bi-Encoder for search (Sentence-BERT, E5, BGE):

  ENCODER ENCODER
     ↑ ↑
  query document
  "price of a cat" "cost of a kitten"

  q_emb = [0.2, 0.8, …] ∈ ℝ^768
  d_emb = [0.3, 0.7, …] ∈ ℝ^768

  score = cosine(q_emb, d_emb) = 0.91 ← high!
08DensePooling: from tokens to document vector

The embedding model produces a vector for each token. How to make one document vector from N token vectors? This is called pooling - and the choice of strategy affects the quality of the search.

pooling strategiestext
Entry: "the cat is sitting at home"
Tokens: [CLS] cat sitting at home [SEP]
Model output: vector for each token ℝ^768

[CLS] pooling (BERT-style):
  doc_emb = hidden_state[CLS]
  ↑ a special token is trained to aggregate meaning
  ↑ used in BERT fine-tuned models

Mean pooling (Sentence-BERT, E5):
  doc_emb = mean(hidden_states[1:-1])
  ↑ average for all tokens (without CLS and SEP)
  ↑ in practice it is better for long documents

Weighted mean pooling:
  ↑ tokens with attention weights are strengthened
  ↑ used in some modern models

The result of any pooling:
  doc_emb ∈ ℝ^768 ← one vector per document
04

Vector Search: how to find the nearest neighbor

4 cards
12SearchCosine similarity: how exactly vectors are compared

Cosine similarity measures the angle between two vectors—not the distance between points, but the direction. This is important: two vectors can be far apart in absolute units, but pointing in the same direction means talking about the same thing.

Diagramtex
Formula:
  cosine(A, B) = (A B) / (|A| × |B|)
             = Σᵢ(Aᵢ×Bᵢ) / (√Σᵢ Aᵢ² × √Σᵢ Bᵢ²)

Range: -1 (opposite) to +1 (identical)
For normalized vectors: cosine = dot product

Example (simplified, 3 dimensions):
  query: q = [0.6, 0.8, 0.0] |q| = 1.0
  doc_1: d1 = [0.5, 0.7, 0.1] |d1| = 0.87
  doc_2: d2 = [0.1, 0.1, 0.9] |d2| = 0.91
  doc_3: d3 = [0.6, 0.8, 0.0] |d3| = 1.0

  cosine(q, d1) = (0.6×0.5 + 0.8×0.7 + 0.0×0.1) / (1.0×0.87)
               = (0.30 + 0.56 + 0.00) / 0.87 = 0.874 ← relevant

  cosine(q, d2) = (0.6×0.1 + 0.8×0.1 + 0.0×0.9) / (1.0×0.91)
               = (0.06 + 0.08 + 0.00) / 0.91 = 0.154 ← irrelevant

  cosine(q, d3) = (0.6×0.6 + 0.8×0.8 + 0.0×0.0) / (1.0×1.0)
               = (0.36 + 0.64 + 0.00) / 1.0 = 1.000 ← identical

Result: we rank the documents: d3 > d1 > d2
Return top-k → this is the nearest neighbor search

alternative distance metrics

  • cosine - standard for text
  • dot product - faster if vectors are normalized
  • L2 (Euclidean) - for images, not text
  • for normalized vectors all three are equivalent
13SearchExact KNN vs Approximate Nearest Neighbor

An exact search of all vectors in the database (Exact KNN) guarantees the correct answer, but it is slow for large collections. ANN - approximate search algorithms: sometimes they miss the perfect result, but hundreds of times faster.

comparison of approachespython
Exact KNN (brute force):
  for doc in all_docs:
    score = cosine(query_emb, doc_emb)
  return top_k(scores)

  n=1M docs, d=768768M multiplications per request
~2-5 seconds on CPU. Unacceptable.

ANN (HNSW, IVF, ScaNN, FAISS):
  → build the index in advance (~minutes/hours)
  → search: ~1-10ms even for 100M vectors
  → recall@10 = 95-99% (we skip 1-5% of accurate answers)

Trade-off:
  accuracy ↑ ↔ speed ↓
  memory ↑ ↔ accuracy ↑
14SearchHNSW: Hierarchical Nearest Neighbor Graph

HNSW (Hierarchical Navigable Small World) is a standard ANN algorithm in most vector databases (Qdrant, Weaviate, pgvector). Builds a multi-level graph: the top level is large jumps, the bottom is fine tuning.

Diagramtext
HNSW structure (simplified):

Level 2 (few nodes - “high-speed highway”):
  A ──────────────────── E
  ↑ long jumps, rough navigation

Level 1 (more nodes):
  A ──── B ──── C ──── D ──── E
  ↑ more detailed search

Level 0 (all nodes - exact search):
  A─B─C─D─E─F─G─H─I─J─K─L─M
  ↑ all documents, each connected to the k closest ones

Search Query Q:
  1. Enter the top level → find the nearest node
  2. Go down one level → clarify
  3. At level 0 → bypass nearest neighbors
  4. Return top-k

Parameters:
  M - number of connections per node (16–64)
  ef_construction — accuracy during construction
  ef_search — search accuracy (recall/speed)
15SearchVector databases: overview

Vector database = HNSW/IVF index + metadata storage + filtering + API. Different databases are optimized for different trade-offs in terms of speed, memory and scaling.

BaseAlgorithmFiltrationFeature
QdrantHNSWduring searchRust, payload filters
pgvectorHNSW / IVFFlatSQL WHEREin PostgreSQL
WeaviateHNSWGraphQLhybrid built
ChromaHNSWpost-filterlightweight, dev
FAISSIVF/PQ/HNSWnolibrary, not database
05

Chunking, Metadata and Matching

4 cards
16ChunkingChunking Strategies: How to Break Up a Document

Chunk is a unit that we encode into a vector and which we return to the LLM context. A chunk that is too small loses context. Too large - it dilutes the signal, embedding becomes “average in a hospital”. The size and chunking strategy is one of the main parameters of RAG quality.

Diagramsql
Strategies:

① Fixed-size (naive):
  chunk_size = 512 tokens, overlap = 64
fast, does not take into account structure
  → can cut a sentence in the middle

② Sentence/Paragraph splitting:
  split by "\n\n" or punkt sentence tokenizer
  → each chunk = semantic unit
  → unequal length (from 20 to 400 tokens)

③ Semantic chunking:
  embed each sentence → find “gaps” by cosine distance
  → chunks = thematically homogeneous pieces
  → expensive (every offer needs to be embedded)

④ Hierarchical (Parent-Child):
  Small chunks for searching (128 tokens) → exact match
  Large chunks for context (512 tokens) → rich response
  → search for small ones, return large ones

⑤ Document structure-aware:
  Markdown h1/h2/h3 → sections as chunks
  PDF → paragraphs from layout
  → best result for structured docs

empirical recommendations

  • Q&A: 256–512 tokens
  • technical documents: 512–1024
  • overlap: 10–20% of chunk_size
  • eval in different sizes - no one size fits all
17ChunkingChunk metadata: what to store next to the vector

Next to each vector, metadata is stored in the database - structured fields for filtering and for adding context to LLM. The right metadata allows you to combine semantic search with precise filters.

typical payload structurejson
{
  # Identification
  "doc_id": "wb-seller-guide-2024",
  "chunk_id": "wb-seller-guide-2024-ch-047",

  # Document navigation
  "title": "WB Seller's Guide",
  "section": "3.2 Pricing",
  "page": 47,

  # Filtering
  "category": "pricing",
  "language": "ru",
  "date": "2024-11-01",

  # Content
  "text": "When calculating the price, take into account...",
  "summary": "About WB pricing methods"
}

use in search

  • filter: category="pricing" AND date>"2024"
  • title in LLM context: "from section 3.2"
  • summary for hybrid search
18ChunkingHow exactly does the chunk title affect matching?

The title of a document or section is a powerful signal. If both the title and the body of the chunk are included in the embedding, then a search for the topic “WB pricing” will find the chunk “3.2 Pricing,” even if the word “pricing” does not appear explicitly in the body of the text.

header inclusion strategiestext
# 1. Prepend title into chunk text before embedding:
text_for_embedding = f"""
Section: {section_title}
Document: {doc_title}

{chunk_text}"""
# → title affects embedding through pooling

# 2. Separate “title embedding” + weighted sum:
emb_title = encode(title)
emb_body = encode(body)
emb_final = 0.4 emb_title + 0.6 emb_body
# → explicit weighting

#3. HyDE (Hypothetical Document Embedding):
# query → LLM generates a hypothetical answer
# embed a hypothetical response, not a request
# → embedding is already in the “document space”
19ChunkingAtomic parsing: why THIS particular chunk was selected

Step-by-step analysis of real matching: the query is received → encoded → compared with each vector in the database → the one closest in cosine wins. It is here that it is clear what is being compared to what.

Diagramtext
User request:
  "How to calculate the WB commission when selling clothes?"

① Encode query:
  q_emb = embed_model.encode(
    "query: How to calculate WB commission when selling clothes?"
  )
  q_emb ∈ ℝ^768 ← specific vector of 768 numbers

② Chunks are stored in the vector database:
  chunk_42: "Wildberries commission is 12-25%..."
    emb_42 = [0.31, -0.12, 0.78, …] (768 numbers)
    cosine(q, emb_42) = 0.891 ← HIGH

  chunk_91: "Returning goods to Wildberries: instructions..."
    emb_91 = [0.15, 0.44, -0.21, …]
    cosine(q, emb_91) = 0.543 ← average

  chunk_3: "Italian pasta recipes"
    emb_3 = [-0.67, 0.03, 0.11, …]
    cosine(q, emb_3) = 0.041 ← LOW

③ ANN search → top-3:
  rank 1: chunk_42 → score 0.891 ← WINNER
  rank 2: chunk_91 → score 0.543
  rank 3: chunk_3 → score 0.041

Why chunk_42 won:
  ✓ contains the words "commission", "Wildberries" → similar context
  ✓ chunk title: “Tariffs and commissions WB” was in the embedding
  ✓ clothing is mentioned as a category → vector close to query
06

Hybrid search: Dense + Sparse

3 cards
20HybridWhy neither Dense nor Sparse are perfect individually

Dense search is good for semantic proximity, but loses precision terms. BM25 is good for exact matches, but doesn't understand synonyms. Hybrid search combines both signals and consistently outperforms each individual signal.

Diagramtext
Blind spots of each method:

BM25 defeats Dense when:
  query: "article WB 187234561"
  → exact number - dense embedding "averages" it
  → BM25 finds an exact match using the inverted index

  query: "Government Decree No. 44-FZ"
  → rare specific terms → BM25 wins

Dense defeats BM25 when:
  query: "how to reduce storage costs"
  doc: "methods for reducing warehouse costs"
  → there is not a single common word!
  → BM25: score = 0. Dense: cosine = 0.82

  query: "pet"
  doc: "pet care"
  → synonymy → only Dense will find

Hybrid search:
  score_final = α·BM25_score + (1-α)·dense_score
  → or via RRF (Reciprocal Rank Fusion)
  → consistently better than both separately on most tasks
21HybridRRF: Ranking Fusion

Reciprocal Rank Fusion is a simple and reliable way to combine two lists of results without normalizing the absolute scores. The document that gets to the top of both methods rises to the top.

RRF algorithmtex
RRF_score(doc) = Σ_methods 1 / (k + rank_method(doc))
k = 60 (constant, reduces the influence of top 1)

Example:
         BM25_rank Dense_rank RRF_score
chunk_A: 1 3 1/61 + 1/63 = 0.032
chunk_B: 5 1 1/65 + 1/61 = 0.032
chunk_C: 2 2 1/62 + 1/62 = 0.032
chunk_D: 100 50 1/160 + 1/110 = 0.015

chunk_C (good at both) → up!
chunk_D (bad in both) -> down

In Qdrant: built-in hybrid search with RRF
In pgvector: manually via UNION + RRF calculation
22HybridReranking: rechecking top K

Bi-encoder is fast, but crude - it doesn't compare query and document together. Reranker (Cross-Encoder) takes the top K candidates and evaluates each more accurately: it sees query and document simultaneously through full attention.

two-stage retrievalpython
Stage 1: fast retrieval (bi-encoder + ANN)
  K = 100 candidates in ~5ms
  ↑ approximately, but quickly

Stage 2: reranking (cross-encoder)
  for each candidate_chunk:
    score = cross_encoder(query, chunk)
    # query and chunk together via BERT → more precisely
  top_k_reranked = sorted by score[:k]
  ↑ slow (100 × forward pass), but accurate

Popular rerankers:
cohere rerank-3API
bge-reranker-v2-m3 ← self-hosted
ms-marco-MiniLM-L-6 ← fast, easy

NDCG@10 gain: +515% over bi-encoder
07

Complete RAG Pipeline

2 cards
23PipelineIndexing Pipeline: preparing the base

Before searching, you need to prepare the database: upload documents, cut them into chunks, enrich them with metadata, encode them into vectors and write them into a vector database. This is an offline process that runs when documents are updated.

Diagramtext
┌─ RAW DOCUMENTS ──────────────────────────────────┐
│ PDF, DOCX, HTML, Markdown, JSON, DB records │
└──────────────────────┬── ─────────────────────────┘

┌─ PARSE & CLEAN ──────────────────────────────────┐
│ extract text: pypdf, unstructured, markitdown │
│ clean: remove headers/footers, normalize spaces │
└──────────────────────┬── ─────────────────────────┘

┌─ CHUNK ───────────────────── ─────────────────────┐
│ split: 512 tokens, overlap 64 │
│ enrich: prepend section title + doc title │
└──────────────────────┬── ─────────────────────────┘

┌─ EMBED ───────────────────── ─────────────────────┐
│ embed_model.encode(batch_of_chunks) │
│ → vectors ∈ ℝ^768, shape [N × 768] │
└──────────────────────┬── ─────────────────────────┘

┌─ STORE ───────────────────── ─────────────────────┐
│ qdrant.upsert(vectors, payloads) │
│ HNSW index is built automatically │
└───────────────────────── ─────────────────────────┘
24PipelineQuery Pipeline: from question to answer - every step

This is a complete breakdown of what happens when a user asks a question in the RAG system. Each step is specific: what it takes as input, what it returns, what it is compared with. This is where everything the previous cards have said comes together.

Diagramsql
QUERY: "What is WB's commission for selling jeans in the clothing category?"

───────────────────────────────── ─────────────────────────────────
①QUERY ENCODING
───────────────────────────────── ─────────────────────────────────
  q_emb = embed_model.encode("query: What is the WB commission...")
  q_emb ∈ ℝ^768 ← [0.31, -0.07, 0.54, 0.12, …]

───────────────────────────────── ─────────────────────────────────
② HYBRID SEARCH (parallel)
───────────────────────────────── ─────────────────────────────────
  Dense: HNSW.search(q_emb, top_k=20) → 20 candidates
    q_emb ↔ each chunk_emb is compared via cosine
    chunk_42 "WB tariffs: clothes 12%..." → cos: 0.891 ← 1st place
    chunk_17 "Commission for accessories..." → cos: 0.821 ← 2nd

  BM25: inverted_index.search("commission WB clothes", top_k=20)
    term match: "commission" df=120, IDF=2.9
    term match: "WB" df=890, IDF=1.1
    chunk_42 → BM25: 8.4 ← 1st place

  RRF: merge, chunk_42 top in both → score 0.033top 1 final

───────────────────────────────── ─────────────────────────────────
③ METADATA FILTER (if set)
───────────────────────────────── ─────────────────────────────────
  WHERE category = "pricing" AND language = "ru"
out of 20 candidates, 8 remain

───────────────────────────────── ─────────────────────────────────
④ RERANKING (cross-encoder)
───────────────────────────────── ─────────────────────────────────
  reranker.predict(query, chunk_42) → 0.94 ← winner
  reranker.predict(query, chunk_17) → 0.71
  reranker.predict(query, chunk_8) → 0.32
  top_3 for LLM: [chunk_42, chunk_17]

───────────────────────────────── ─────────────────────────────────
⑤CONTEXT ASSEMBLY
───────────────────────────────── ─────────────────────────────────
  context = f"""
  [Source: WB Tariffs - Section 4.1 Clothing]
  Wildberries commission for clothes is 12–17%
  depending on the category. Jeans are...
  """

───────────────────────────────── ─────────────────────────────────
⑥LLM GENERATION
───────────────────────────────── ─────────────────────────────────
  system: "Only respond based on the context provided"
  user: query + context
"WB commission for jeans (category "Clothing") is 15%..."

where quality is lost

  • chunk is too big → embedding is blurry
  • the title was not included in the embedding text
  • no metadata → cannot be filtered
  • no reranker → bi-encoder misses nuances
  • embed_model is not trained on the domain → low recall
  • hybrid + rerank → best result

No dead end

Keep moving through the map.

Continue in sequence, switch to a related guide, or return to the seven-track learning map.