RAG / RAG ingestion, chunking and indexing / How I built a RAG on the Bhagavad Gita in Telugu

How I built a RAG on the Bhagavad Gita in Telugu

A step-by-step walk-through: preparing the verses, choosing and fine-tuning the models, hybrid search, and measuring whether any of it actually worked.

Ask a general chatbot a question about the Bhagavad Gita in Telugu and you will usually get a fluent answer. But it comes from the model's memory: a mix of the Gita, commentaries, and everything else it read on the internet. There is no citation, and no way to check whether the Gita actually says that.

I wanted the opposite. A system that answers only from the text of the Gita, shows which verse each point came from, and says "I can't answer that from the Gita" when the verses don't support an answer.

That is a RAG problem.

Concept · RAG

Retrieval-Augmented Generation means: first retrieve the few passages relevant to the question, then give only those passages to a language model and ask it to generate an answer from them.

Think of it as an open-book exam. The model is not allowed to answer from memory. It must find the page first, and it must point to the page.

Here is the finished pipeline. The rest of this article explains why each box exists.

RAG pipeline A question goes to dense search and BM25 keyword search in parallel. Their rankings are fused with RRF, the top 30 are reranked by a cross-encoder, the top verses go to Claude, and every quote in the answer is checked against the source text. Question Telugu / Tenglish Dense search fine-tuned BGE-M3 Keyword search BM25, 4-letter pieces Fuse RRF Rerank top 30 → top 8 Claude writes answer Verify quotes vs source
The shaded boxes are the retrieval steps this article focuses on. Dense search and the reranker use models fine-tuned for this project.

01Preparing the dataset

The corpus I wanted vs. the corpus that exists

The original plan was the Ramayana, the Mahabharata and the Gita. For a Telugu RAG, each verse needs its tatparyam: the meaning explained in modern Telugu. That is the text people's questions will actually match.

An audit of Telugu Wikisource ended that plan on day one. Of the Ramayana's 13,693 slokas, only 64 had a Telugu tatparyam, about 0.5%. The rest was Sanskrit written in Telugu script. It looks like Telugu to a simple character check, but it isn't Telugu prose. The Mahabharata was in the same situation.

Only the Gita had a Sanskrit verse and a Telugu meaning for every verse. So the project became: do one book properly.

Lesson

In a low-resource language, check what text actually exists before designing anything. The data decided the scope, not the architecture.

Three page formats, one schema

The Gita pages on Wikisource (licensed CC BY-SA 4.0) were downloaded through the MediaWiki API. The 18 chapters were not written in one consistent format, so the parser had to handle three:

FormatWhat the page looks likeHow the verse number was foundVerses
AFull Sanskrit sloka, then || 2-47 ||, then meaningRead from the || chapter-verse || marker219
BOnly the first line of the sloka, then meaningCounted by position in the page440
CFirst line, then N. sloka |, then meaningRead from the N. prefix40

Every verse became one JSON record with the same fields. A trimmed example:

{
  "id": "gita-18-6",
  "adhyaya_no": 18,
  "sloka_no": 6,
  "citation_en": "Bhagavad Gita 18.6",
  "sloka_sa": "…",                       // Sanskrit, in Telugu script
  "tatparyam_te": "అర్జునా! ఈ కర్మలను కూడా సంగాన్ని, ఫలాన్ని వదిలి
                   చెయ్యాలని నా నిశ్చితమైన ఉత్తమమైన అభిప్రాయం.",
  "source_url": "https://te.wikisource.org/…",
  "license": "CC BY-SA 4.0",
  "parse_mode": "B",
  "verse_number_verified": false
}

The id matters more than it looks. It is what every citation points to, so it must be stable and correct.

Checking the count

This edition has 701 verses. After parsing and removing duplicates I had 699. 16 of the 18 chapters matched the expected verse count exactly. The two gaps are in the source itself, not bugs in the parser:

  • Chapter 3: the source page labels two different verses as 3-4, so there is no 3-5.
  • Chapter 16: verse 16-4 is missing from the page (it jumps from 16-3 to 16-5).

Cleaning: let the search results find the dirt

The first retrieval test surfaced something odd. For an unrelated question, the top result was verse 1.13, and its "meaning" started with ఆదిశంకరాచార్యులవారు : ("Adi Shankaracharya says:"). Commentary text had leaked into the verse records.

Looking closer, there were two kinds of contamination:

  • Commentary leakage: 17 records in chapter 1 had Shankaracharya's commentary appended, averaging 946 characters each, against a normal meaning of about 120. A long record matches almost any query, so these kept winning.
  • Sanskrit bleed: 30 records across 7 chapters had Sanskrit text stuck onto the end of the Telugu meaning.

The cleaning script cut each record at the commentary marker or the Sanskrit danda (|). In total it cleaned 37 records and removed 15,247 characters of junk. Automated tests in the repo now fail if either problem comes back.

After cleaning, a Telugu meaning is short: 127 characters on average (median 118). Keep that number in mind; it matters in section 4.

02Why not just give all the verses to the LLM?

This is the first question anyone should ask, because modern models have very large context windows. So I measured it.

All 699 verses, Sanskrit plus Telugu meaning, come to 114,471 tokens on Claude Sonnet 5. The context window is 1 million tokens. It fits. So why build retrieval at all?

Concept · Tokens

LLMs don't read letters or words, they read tokens: small chunks of text. You pay per token, and more tokens means a longer wait.

Telugu is expensive here. Measured on the same tokenizer, Telugu text costs 0.89 tokens per character, while English costs 0.30. The same amount of text needs about 3× more tokens in Telugu.

Five reasons pushed me to RAG anyway:

  1. Cost per question. Sending the whole Gita costs about $0.23 of input per question on Sonnet 5 ($2 per million input tokens). RAG sends about 8 verses, roughly 1,300 tokens, which is about $0.003. That is around 90 times less. Prompt caching would make the repeated part cheaper, but the model still has to work through a very long prompt for every question.
  2. It doesn't scale to the rest of the plan. The long-term goal includes the Ramayana and the Mahabharata. If every Ramayana sloka had a Telugu meaning like the Gita's, that book alone would be over 2 million tokens (a rough estimate), past the context limit.
  3. Citations become checkable. With RAG, I know exactly which 8 verses the model was given. Every quote in the answer can be checked, character by character, against those 8 verses. With the whole book in the prompt, it becomes much harder to tell the text apart from the model's own memory of the Gita.
  4. Attention thins out. Models tend to use information less reliably when it is buried in a very long prompt. A short, focused context is easier to answer from faithfully.
  5. You can measure each part. With RAG, "did we find the right verse?" and "did the model answer well from it?" are separate questions with separate numbers. When something breaks, you know where.

To be clear: I did not build a long-context version and compare answer quality. The reasons above are cost measurements plus design arguments, not a head-to-head benchmark.

03Choosing the embedding model

Concept · Embeddings

An embedding model turns a piece of text into a list of numbers (for BGE-M3, 1,024 of them) called a vector. It is trained so that texts with similar meaning get similar vectors.

To search, you turn every verse into a vector once and store them. When a question arrives, you turn it into a vector too and find the verse vectors closest to it. "Closest" is usually measured with cosine similarity: 1.0 means pointing the same way, 0 means unrelated.

This is called dense retrieval. It can match meaning even when the question and the verse share no words.

What the model needed

  • Understands Telugu, including romanized Telugu ("Tenglish") like kopam ela puttundi, which is how many people type on phones.
  • Open weights, so it can be fine-tuned.
  • Runs locally, with no per-query API bill.
  • Has a matching reranker from the same family (more on that in section 5).

The shortlist

Candidates were BGE-M3, Qwen3-Embedding, IBM Granite Embedding Multilingual and Krutrim's Vyakyarth (open models), plus hosted APIs from Voyage, Google Gemini and Cohere. I chose BAAI/bge-m3:

  • It is trained on 100+ languages, including Telugu.
  • It has 568M parameters: small enough to run on a laptop CPU and fine-tune on a free Colab GPU.
  • It accepts inputs up to 8,192 tokens.
  • It has a sister reranker, bge-reranker-v2-m3.

API embeddings were ruled out because they can't be fine-tuned and add a cost to every query. I did not benchmark the other open models against it. That is a fair criticism, and a good follow-up experiment.

What to embed: the meaning, not the Sanskrit

Each verse has two texts: the Sanskrit sloka and the modern Telugu tatparyam. I embed the tatparyam.

Concept · Register mismatch

The same language can be written in very different styles, or registers. Classical Telugu (grandhika) and everyday Telugu (vyavaharika) differ a lot, and Sanskrit is a different language altogether.

Users type everyday Telugu. If the stored text is in a different register, question and verse look far apart to the model even when they mean the same thing. The tatparyam is written in the same everyday register as the questions, so it is the right text to search.

Does it actually understand Telugu? Two quick tests

When the first results were weak, the obvious explanation was "multilingual models are bad at Telugu". Before acting on that, I tested it.

Test 1 — the floor test. Take 30 random verses, copy a 45-character slice straight out of each one, and use that slice as the query. Any working model must rank the source verse first. Result: 26/30 at rank 1, 29/30 in the top 5, and none outside the top 20.

Test 2 — are all Telugu texts squashed together? If a model doesn't really understand a language, it tends to give every text in that language a similar vector. Then random, unrelated pairs look similar. I measured the average similarity between random pairs of verses. Telugu: 0.485. The same Gita in English: 0.502. Telugu is not squashed together any more than English is.

Lesson

The model reads Telugu fine. The explanation that felt obvious was wrong, and testing it saved me from "fixing" the wrong problem (for example, by translating everything into English).

04Why the embedding model had to be fine-tuned

The real problem: questions don't look like verses

The stock model struggled with the kinds of questions people actually ask. Here is one verse with three real test questions, one of each type:

Verse 18.6అర్జునా! ఈ కర్మలను కూడా సంగాన్ని, ఫలాన్ని వదిలి చెయ్యాలని నా నిశ్చితమైన ఉత్తమమైన అభిప్రాయం.
Lookupఫలితం ఆశించకుండా పని చేయాలని గీత ఎక్కడ చెబుతుంది?
Thematicఉద్యోగం చేస్తున్నాను కానీ ఫలితం మీద ఆశతో మనసు ఆందోళనగా ఉంది, ఏం చేయాలి?
Tenglishphalam asinchakunda pani cheyyali ani krishna emi cheppadu
  • Lookup questions ask directly about the text. They share some words with the verse.
  • Thematic questions describe a life situation ("I work, but worry about results makes me anxious") and never mention the Gita. The link to the verse is purely about meaning.
  • Tenglish questions are in a different script from every stored verse.

A question is a short interrogative. A verse is a ~127-character statement where the answer is one clause among several. That mismatch in shape is hard for any embedding model, in any language. On the 320 held-out test questions, the stock model found the right verse in its top 10 for only 38% of questions. For Tenglish it was 8%, and the right verse usually wasn't even in the top 50.

Concept · Recall@10

Recall@10 = the share of questions where the correct verse appears anywhere in the top 10 results. 0.38 means 38 out of 100 questions. If retrieval misses the verse, the LLM never sees it, and no prompt can fix that.

Fine-tuning teaches the model what "similar" should mean for this task: "this verse answers this question", rather than "these two texts use similar words".

Step 1: create training data (there was none)

No dataset of Telugu Gita questions exists, so I generated one. For each verse, Claude Opus 5 wrote five questions: 2 lookup, 2 thematic and 1 Tenglish. Two rules mattered most:

  • Don't reuse the verse's own words. A question that copies the verse's vocabulary teaches word-matching, which keyword search already does. The embedding model needs to learn paraphrase.
  • Thematic questions must be first-person life situations that never mention the Gita, because that is how people actually ask.

Result: 2,905 training questions for $4.71.

Step 2: split by verse, not by question

The 699 verses were split into 581 for training and 104 held out, plus 14 more reserved as gold answers for an early test set. The held-out verses and their questions are never seen during training.

Concept · Why split by verse?

If you split by question, a test question can be about a verse the model already trained on with four sibling questions. The model then scores well just by remembering that verse. That measures memorisation, not understanding, and gives a big, satisfying, meaningless improvement.

Step 3: find hard negatives

Training works by showing the model a question, the right verse (the positive), and a wrong verse (the negative). The choice of wrong verse matters a lot:

  • A random wrong verse is too easy. A verse about war conches is obviously not about work stress, so the model learns nothing.
  • A hard negative is a wrong verse that the current search system ranks highly. These are exactly the mistakes that cost recall.

For every training question, I ran the existing search and took 2 highly ranked wrong verses (training verses only). That gave 5,810 triplets of (question, right verse, wrong verse).

Step 4: contrastive training

Concept · Contrastive learning

The model is trained to pull each question's vector towards its right verse and push it away from wrong verses.

The trick is in-batch negatives. With a batch of 64 triplets, each question is compared against its own right verse and every other verse in the batch: its own hard negative plus 126 others that belong to other questions. So a bigger batch means more wrong answers to learn from, for free. Batch size is the main quality lever.

A free Colab T4 GPU can't fit a batch of 64 normally. CachedMultipleNegativesRankingLoss from sentence-transformers solves this. It processes the batch in small pieces of 8 but computes the loss as if all 64 were together.

SettingValueWhy
LossCachedMultipleNegativesRankingLossLarge effective batch on a small GPU
Batch size64 (mini-batch 8)More in-batch negatives
Learning rate2e-5Conservative, to avoid forgetting general Telugu
Epochs3Small dataset overfits quickly
Checkpointbest of evaluations every 25 stepsKeep the best model, not the last one
HardwareGoogle Colab, free T4

What training changed

As a sanity check, I scored 32 training triplets before and after:

BeforeAfter
Score for the right verse0.5000.522
Score for the hard negative0.5000.177
Wrong verse scores higher than right verse13 / 321 / 32

Almost all of the improvement came from pushing wrong verses down, not pulling right ones up. The model already knew the right verse was related; it learned to tell it apart from verses that were merely about something similar. (These are training triplets, so this shows the mechanism, not the real-world gain. The held-out results are in section 8.)

05The reranker: why add one, and why fine-tune it

Concept · Bi-encoder vs cross-encoder

The embedding model is a bi-encoder. It reads the question and each verse separately and compares the two vectors. That is fast: all 699 verse vectors are computed once in advance. But the model never sees the question and the verse side by side.

A cross-encoder (reranker) reads the question and one verse together, as a single input, and outputs a relevance score. Every word of the question can attend to every word of the verse, so it is much more accurate. It is also much slower: one full model run per verse, per question.

So you use both. The fast search picks 30 candidates, and the reranker carefully re-orders just those 30. It is like skimming a library catalogue, then actually opening the 30 most promising books.

The off-the-shelf reranker helped, a little

With the fine-tuned search already in place, adding the stock bge-reranker-v2-m3 raised recall@10 from 0.512 to 0.559 on the 320 held-out questions. That is useful but modest, with the biggest gain on lookup questions, the type closest to what generic rerankers are trained on.

Lesson · small tests mislead

On an earlier 100-question sample, the stock reranker seemed to make things worse (recall@1 dropped from 0.290 to 0.250). On the full 320 questions it helped every question type. With a small test set, random noise can be larger than the effect you are measuring.

Why fine-tune it

The stock reranker was trained on general web data. It has not seen Tenglish questions matched to Telugu-script verses, or first-person life situations matched to scripture. The embedding model had just been taught those things, and the reranker should know them too: a reranker only helps if it is better at the task than the search it is re-ordering.

How it was fine-tuned

  • New hard negatives. The fine-tuned search makes different mistakes than the stock one, so negatives were mined again with the new model: 8 wrong verses per question from its top 50, giving 26,145 (question, verse) pairs.
  • Objective. For each question, score the right verse above its 8 hard negatives.
  • Settings. 2 epochs, learning rate 1e-5, Colab T4.
  • Memory. The first two runs crashed with out-of-memory errors. The fixes: cutting the max input length from 512 to 192 tokens (the longest real question+verse pair is 146 tokens), freeing the stock model from GPU memory before training, and using the lighter Adafactor optimizer.

Result

On the same 320 held-out questions and the same 50 candidates per question:

No rerankerStock rerankerFine-tuned reranker
Recall@10 · all0.5120.5590.634
NDCG@10 · all0.3580.3960.468
Recall@10 · lookup0.7580.8050.883
Recall@10 · Tenglish0.3590.4220.641
Recall@10 · thematic0.3440.3830.383
Median rank · Tenglish741

Tenglish gained the most: recall@10 went from 0.36 to 0.64, and the right verse now typically comes first. That makes sense. A cross-encoder sees kopam and కోపం in the same input and can learn to line them up directly. A bi-encoder only ever compares two vectors computed separately.

Thematic questions did not improve with fine-tuning (0.383 either way). They remain the hardest part of this system.

How many candidates to rerank?

Reranking more candidates costs more time, so I measured it on 80 questions. I reranked the top 50 once and read off smaller pools by cutting the list.

PoolRecall@10NDCG@10Right verse is in the pool
100.5250.4180.525
150.5620.4360.588
200.5880.4350.625
300.6250.4620.762
500.6250.4670.812

Recall stops improving at 30. Going to 50 costs 40% more compute for +0.005 NDCG, so production uses 30.

06Hybrid search

Concept · Dense vs keyword search

Dense search (embeddings) matches meaning. It can connect "anxious about results" to a verse about giving up attachment to results, but it can miss exact names and rare terms.

Keyword search (BM25, the classic search-engine algorithm) matches exact terms, and rewards rare terms more than common ones. It is great for specific words, but useless when the question uses different words.

Hybrid search runs both and combines the results, so each covers the other's blind spots.

The Telugu problem with keyword search

Standard BM25 splits text into words. That fails in Telugu, which is agglutinative: suffixes attach directly to words, and sandhi fuses neighbouring words together. The same concept appears in many surface forms.

Real example. Question: యోగక్షేమం ఎవరు వహిస్తారు? ("Who takes care of one's welfare?"). The answer is verse 9.22, which contains యోగక్షేమాలను నేనే వహిస్తాను. The question and the verse share zero whole words: యోగక్షేమం ≠ యోగక్షేమాలను, and వహిస్తారు ≠ వహిస్తాను.

The fix is to index overlapping 4-character pieces (character 4-grams) instead of whole words:

యోగక్షేమం  →  యోగక · ోగక్ · గక్ష · క్షే · ్షేమ · షేమం
               (Unicode characters, so vowel signs are separate pieces)

With 4-grams, that question and verse share 11 pieces. Keyword search now finds the verse. On this question, the stock dense model did not have 9.22 anywhere in its top 40; BM25 ranked it #1; after combining, it sat at #7, inside the reranker's pool, where it can be lifted further.

Combining two rankings: Reciprocal Rank Fusion

Dense scores are cosine similarities around 0.3–0.8. BM25 scores are unbounded numbers like 14.2. You can't just add them. RRF ignores the scores and uses only the rank:

RRF score(verse) = Σ over each search   1 / (60 + rank in that search)

Worked example: a verse ranked #1 by BM25 but missing from dense scores 1/61 = 0.0164. A verse ranked #3 by dense and #4 by BM25 scores 1/63 + 1/64 = 0.0315, so it wins. Being good in both beats being first in one. The constant 60 keeps any single first place from dominating. Keep this rule in mind: it matters a lot in the results below.

Lesson · never merge by raw score

I learned this the hard way. In a later experiment, I merged results from several search queries by their reranker scores. Recall@1 fell from 0.300 to 0.160. Scores from different runs are not on the same scale. Merging by rank avoids this.

Did hybrid search actually help?

This is the question I should have asked much earlier. While writing this article, I measured each retriever on its own and combined, on the same 320 held-out questions, with no reranker:

RetrieverRecall@10NDCG@10LookupThematicTenglish
Keyword only (BM25, 4-grams)0.2410.1710.5310.0620.016
Dense only · stock BGE-M30.3840.2730.7110.2110.078
Dense only · fine-tuned0.6410.4190.8200.5230.516
Hybrid · stock BGE-M3 + BM250.3620.2600.6800.1800.094
Hybrid · fine-tuned + BM250.5120.3510.7580.3440.359

Lookup / Thematic / Tenglish columns are recall@10 for that question type.

Adding keyword search made retrieval worse. The fine-tuned dense model on its own finds the right verse in the top 10 for 64.1% of questions. Combining it with BM25 drops that to 51.2%, and the combined version is lower for every question type. The same happens with the stock model, less dramatically (0.384 → 0.362).

Why?

  • The keyword side is weak on these questions. BM25 alone reaches 0.241 overall, 0.062 on thematic questions and 0.016 on Tenglish. Tenglish is in a different script from the verses, so not a single 4-gram can match.
  • The test questions were written to avoid the verse's words. That rule was right for training the embedding model, but it also makes this test set unusually hard for keyword search. Real users often do use a verse's own words and names, like యోగక్షేమం.
  • RRF gives both searches an equal vote. "Good in both beats first in one" is a strength when both searches are decent. When one of them is usually wrong, its bad rankings push good verses out of the top 10.

Hybrid search was a reasonable design choice: it is standard practice, and the యోగక్షేమం example shows the kind of question it rescues. But I never measured it on its own until now, and on this test set it costs more than it gains. The next experiments are clear: give dense search more weight in the fusion, feed the reranker from dense search alone, and test on real user questions.

Lesson · measure every part on its own

Hybrid search went through fine-tuning, reranking and deployment without ever being tested alone, because "hybrid is best practice". Removing one component at a time and re-measuring (an ablation) is cheap. This one took 15 minutes on a laptop and overturned an assumption the whole pipeline was built on.

07How the models were evaluated

Evaluation happened at three levels. Each catches different failures.

Level 1: retrieval — did we find the right verse?

Test set. 320 held-out questions, generated the same way as the training questions, about 64 held-out verses. (Question generation for the held-out split stopped at 64 of the 104 verses when API credit ran out; 320 questions was enough to measure with.) None of these verses were used in training.

An earlier set of 14 questions was used as a quick smoke test while building, before the held-out set existed.

Metrics. Each question has one correct verse. For each system, record the rank at which that verse appears:

MetricPlain meaningExample
Recall@10Is the right verse in the top 10?rank 7 → 1, rank 12 → 0
NDCG@10Is it in the top 10, and how high? Higher ranks score more.rank 1 → 1.0, rank 3 → 0.5, rank 10 → 0.29
MRR1 ÷ rank, averagedrank 1 → 1.0, rank 4 → 0.25
Median rankThe typical position of the right verse1 = usually first

Recall tells you whether the LLM will see the verse. NDCG tells you whether it sees it near the top, which matters because models pay more attention to the first passages.

Results were always reported per question type. An average hides a lot: in the stock model, a decent lookup score was hiding a Tenglish score of zero.

Level 2: answer quality — an LLM judge

Retrieval metrics don't tell you whether the final answer is any good. For that, I sent 21 held-out questions (7 per type) to the live deployed app, not a local copy, and had Claude Opus 5 grade each answer:

  • Faithfulness (1–5): is every claim supported by the cited verses?
  • Relevance (1–5): does it answer the question that was asked?
  • Register (1–5): is it natural, correct Telugu?
  • Unsupported claims: a list of specific sentences that the cited verses don't support.

The judge sees only the question, the answer and the full text of the cited verses. It is told to ignore its own knowledge of the Gita, because the question is not "is this true?" but "does this text say this?"

Level 3: citations — checked by code, not by a model

Every quote in an answer must be traceable to the source. A citation is accepted only if at least 24 consecutive characters of the quote appear, character for character, in the cited verse. What the user is shown is the verse text from the corpus, never the model's copy of it. The model may paraphrase around a quote, but it can't invent one.

Lesson · count silent failures

After one code change, the citation rate silently dropped to 0%. The answers still looked perfect. The cause was a parser: the model wrote [gita-2-47] with brackets, then later just 2-47, and both were rejected as invalid. A simple "cited N" counter in the logs caught it. Anything that can fail silently needs a number you watch.

08Final results

Retrieval, 320 held-out questions

Recall@10NDCG@10
0.0 0.2 0.4 0.6 0.8 Stock BGE-M3 hybrid Stock BGE-M3 hybrid · Recall@10 0.3620.362 Stock BGE-M3 hybrid · NDCG@10 0.2600.260 + fine-tuned embeddings + fine-tuned embeddings · Recall@10 0.5120.512 + fine-tuned embeddings · NDCG@10 0.3510.351 + reranker (stock) + reranker (stock) · Recall@10 0.5590.559 + reranker (stock) · NDCG@10 0.3960.396 + reranker (fine-tuned) + reranker (fine-tuned) · Recall@10 0.6340.634 + reranker (fine-tuned) · NDCG@10 0.4680.468
Each step's gain on the same 320 held-out questions. Exact values in the table below.
SystemRecall@10NDCG@10
Stock BGE-M3 hybrid0.3620.260
+ fine-tuned embeddings0.5120.351
+ reranker (stock)0.5590.396
+ reranker (fine-tuned)0.6340.468

From the stock model to the finished pipeline, recall@10 rose from 0.362 to 0.634 (+75%) and NDCG@10 from 0.260 to 0.468 (+80%). Each row adds one change to the row above it, except the last two, which are alternatives: the same pipeline with the stock or the fine-tuned reranker.

An open question from section 6: fine-tuned dense search alone, with no BM25 and no reranker, already reaches 0.641 recall@10 (NDCG 0.419). How much the fine-tuned reranker adds on top of dense-only search hasn't been measured yet, so the best configuration is still to be found.

Question typeRecall@10 before → afterNDCG@10 before → afterMedian rank before → after
Lookup0.680 → 0.8830.533 → 0.7142 → 1
Tenglish0.094 → 0.6410.053 → 0.483>50 → 1
Thematic0.180 → 0.3830.090 → 0.214>50 → 10

"Before" = stock BGE-M3 hybrid search, no reranker. "After" = fine-tuned search + fine-tuned reranker.

Answer quality, 21 questions judged by Claude Opus 5

The generation model was switched from Claude Haiku 4.5 to Claude Sonnet 5 after this comparison:

Haiku 4.5Sonnet 5
Faithfulness (1–5)3.864.76
Relevance (1–5)4.434.67
Telugu quality (1–5)4.054.81
Answers with ≥1 unsupported claim16 / 217 / 21
Mixed-script words (e.g. Devanagari inside Telugu)1 / 210 / 21
Citations verified against source18 / 1817 / 17

Before switching models, I tried a stricter prompt with explicit rules and Telugu examples for each type of drift. Unsupported claims went from 13/21 to 16/21, which is no better and possibly worse. Changing the model did what prompting could not.

Speed

The first version took 45 seconds per answer. Turning off an extended-thinking budget that wasn't improving answers, folding a separate "is this context enough?" check into the generation step, running question routing and search in parallel, and streaming the answer brought it to about 10 seconds total, with the first words appearing after 3.1 seconds.

What still isn't solved

  • Thematic questions. Life-situation questions reach only 0.383 recall@10. Neither fine-tuning nor reranking moved them much. This is the next problem to work on.
  • One in three answers still has at least one sentence the judge flags as not supported by the cited verses. Quotes are verified, but the sentences around the quotes are not.
  • The test questions were written by an LLM, not collected from real users, and 21 judged answers is a small sample. Real user questions would be a better test.

Takeaways

  1. Check the data first. In a low-resource language, what exists decides your scope.
  2. Test the obvious explanation before fixing it. "The model can't read Telugu" was wrong. The real problem was question shape.
  3. Fine-tuning can be cheap. $4.71 of generated questions and a free GPU produced the largest gains in the project.
  4. Split by document, not by question, or your evaluation measures memory.
  5. Pick search methods that fit the language. Character 4-grams handle Telugu word forms that whole-word search misses.
  6. Measure with enough data, per question type, on the system you actually ship.

Corpus text: Telugu Wikisource, CC BY-SA 4.0. Code: MIT.