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.
Sparse methods: from words to numbers
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.
textNaive 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".
textBody 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.
texTF(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 zeroedused 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.
texBM25(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|) searchwhen BM25 beats vectors
- exact names: “WB-article 12345678”
- codes, abbreviations, proper names
- rare specific terms
- synonyms, paraphrases - does not see
Dense Embeddings: meaning in space
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.
sqlImagine a 2D space (actually 768–3072 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 searchkey 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.
sqlTask: 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 account07DenseContextual 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.
textWord2Vec (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.
textEntry: "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 documentHow Embedding Models are Trained for Search
09TrainingContrastive Learning: learning through attraction and repulsion
The embedding model for search is trained so that relevant pairs (query, document) are close in space, and irrelevant ones are far away. This is called contrastive learning, and it explains why “cat price” and “kitten price” give high similarity.
pythonTraining batch (triplet loss):
anchor (query): "How to treat a cat for a cold?"
positive (relevant): "Treatment of cats with ARVI includes..."
negative (not rel): "Recipe for borscht with beef"
Learning Objective:
cosine(anchor, positive) → maximum high (~1.0)
cosine(anchor, negative) → lowest possible (~0.0 or negative)
Triple Loss:
L = max(0, cosine(a,n) − cosine(a,p) + margin)
↑ penalty if negative is closer to positive than by margin
More modern - InfoNCE / NT-Xent (in-batch negatives):
for each query: all other documents in the batch = negatives
batch_size=256 → 255 negatives for free
→ modeli: E5, BGE, nomic-embed, text-embedding-3training data
- MS-MARCO: 500k query-passage pairs
- NQ, TriviaQA: question and answer from Wikipedia
- synthetic pairs via LLM (for domains)
- hard negatives: similar but irrelevant
10TrainingAsymmetry between query and document
Query and document are texts of different nature. Query is short, incomplete, contains intent. The document is long, self-contained, and contains facts. Modern embedding models are trained on special prefixes in order to process them differently.
text#E5-instruct:
query_emb = encode(
"query: " + "how to treat a cat for a cold"
)
doc_emb = encode(
"passage: " + "For viral infections in cats..."
)
# nomic-embed-text:
query_emb = encode("search_query: " + text)
doc_emb = encode("search_document: " + text)
# Without the correct prefix → quality degradation
# Read the documentation for each model!
Asymmetric matryoshka (OpenAI text-embedding-3):
→ you can trim the vector to the desired length
3072 → 1536 → 512 → 256 without retraining11TrainingWhy embeddings “understand” synonyms
It's not magic, it's statistics. The words "cat" and "cat" appear in the same contexts in billions of sentences. After contrastive training, the model learned: if the texts are about the same thing, their vectors should be close. Synonymy arises as a side effect of these statistics.
sqlIn a body of 100M sentences:
"my cat meows" meets with the same neighbors as
"my cat is meowing"
→ contrastive loss: these two phrases = positive pair
→ gradient descent moves their vectors closer
Likewise:
"cost of goods" ↔ "price of goods" → close
"acquire" ↔ "buy" → close
"expensive" ↔ "cheap" → FAR
(antonyms)
Bottom line: vector space is
“map of meanings” learned from language statisticsVector Search: how to find the nearest neighbor
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.
texFormula:
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 searchalternative 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.
pythonExact KNN (brute force):
for doc in all_docs:
score = cosine(query_emb, doc_emb)
return top_k(scores)
n=1M docs, d=768 → 768M 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.
textHNSW 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.
| Base | Algorithm | Filtration | Feature |
|---|---|---|---|
| Qdrant | HNSW | during search | Rust, payload filters |
| pgvector | HNSW / IVFFlat | SQL WHERE | in PostgreSQL |
| Weaviate | HNSW | GraphQL | hybrid built |
| Chroma | HNSW | post-filter | lightweight, dev |
| FAISS | IVF/PQ/HNSW | no | library, not database |
Chunking, Metadata and Matching
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.
sqlStrategies:
① 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 docsempirical 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.
json{
# 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.
text# 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.
textUser 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 queryHybrid search: Dense + Sparse
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.
textBlind 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 tasks21HybridRRF: 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.
texRRF_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 calculation22HybridReranking: 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.
pythonStage 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-3 ← API
bge-reranker-v2-m3 ← self-hosted
ms-marco-MiniLM-L-6 ← fast, easy
NDCG@10 gain: +5–15% over bi-encoderComplete RAG Pipeline
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.
text┌─ 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.
sqlQUERY: "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.033 → top 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.