Monday, July 27, 2026

Adding a Reranker to Your Self-Hosted RAG Pipeline: When It Helps and When It Doesn't

Hero image showing a two-stage retrieval pipeline with reranker

I ran the eval harness from the last post against my own RAG pipeline and found the same pattern every time: Recall@10 was fine, but Recall@1 was weaker than it should have been. The right document was in the top 10 results. It just was not reliably in position 1.

This is the problem reranking solves. A reranker takes the top-K results from your vector search and reorders them using a more expensive model that considers the query and document together. The result is a better-ordered list at the cost of latency.

This post covers when to add a reranker, what to expect from it on self-hosted hardware, and where it does not help.

The Two-Stage Retrieval Pattern

Standard RAG retrieval is a single stage: embed the query, search the index, return the top results. The embedding model encodes the query and documents into vectors, and relevance is approximated by vector distance.

The limitation is that vector distance is a coarse signal. Two vectors being close in embedding space means they share semantic structure. It does not mean the document precisely answers the query. For broad queries or queries where subtle distinctions matter, the top-1 result is often not the best result even when it is close in vector space.

A reranker is a second stage. After vector search returns top-K candidates, the reranker takes each (query, candidate) pair and scores them jointly using a cross-encoder model. Cross-encoders see both the query and document at the same time, which gives them much more signal than the separate embeddings used in vector search. The top-K candidates are then reordered by the reranker's scores.

The trade-off is inference cost. Cross-encoders are slower than embedding lookups because they run a full forward pass for each (query, candidate) pair. With K=10 and a moderate reranker, you are doing 10 cross-encoder inference calls per query instead of one embedding lookup.

Self-Hosted Reranker Options

Three models run well via Ollama or directly in Python without an API dependency:

ms-marco-MiniLM-L-6-v2: a 22M-parameter cross-encoder fine-tuned on the MS MARCO passage retrieval dataset. Fast, low memory, and the default starting point for most reranking setups. Available via sentence-transformers.

ms-marco-MiniLM-L-12-v2: the 12-layer variant of the same model. Slightly better quality at the cost of roughly double the inference time.

bge-reranker-v2-m3: from BAAI, the same research group behind bge-m3 embeddings. A 568M-parameter model with significantly better reranking quality, particularly on multilingual and technical content. Considerably slower on CPU but strong on GPU.

Results on the Same Corpus

Using the same 500,000-document corpus from the vector database and embedding model comparisons, nomic-embed-text as the base retrieval model, and the same 1,000-query eval set:

Comparison chart showing retrieval metrics with and without reranker
Configuration Recall@10 Recall@1 p50 latency p99 latency
nomic-embed-text only 87% 68% 8ms 31ms
+ ms-marco-MiniLM-L-6-v2 87% 79% 41ms 87ms
+ ms-marco-MiniLM-L-12-v2 87% 82% 78ms 163ms
+ bge-reranker-v2-m3 87% 86% 198ms 421ms

Recall@10 stays flat. Reranking does not change which documents are retrieved; it changes how they are ordered. Recall@1 is where the improvement shows up.

The MiniLM-L-6 reranker takes Recall@1 from 68% to 79% with modest latency. The bge-reranker-v2-m3 pushes it to 86% but adds substantial latency. For a RAG system where the LLM generates answers primarily from the top result, the difference between 68% and 86% Recall@1 is significant.

When Reranking Helps

Reranking helps most when:

Your top result matters a lot. If you pass only the top 1-3 documents to the LLM, or if the LLM's attention focuses heavily on the first context chunk, improving Recall@1 translates directly to answer quality.

You have sufficient latency budget. Adding a reranker adds latency that varies widely by model and hardware. In our runs it ranged from a few dozen milliseconds for the smallest model to several hundred for the largest. If your use case tolerates latency in the hundreds of milliseconds, the quality gain is usually worth it. For interactive applications with tight latency requirements, the MiniLM models fit; bge-reranker may not.

Your queries are specific and discriminative. Reranking helps most when the correct answer is clearly distinguishable from the alternatives given the full query and document text together. For vague or broad queries, the quality improvement is smaller.

When Reranking Does Not Help

When Recall@10 is already the bottleneck. If the right document is not in the top 10 from vector search, reranking cannot fix it. Run your eval harness to check whether your problem is retrieval precision (wrong ordering) or retrieval recall (right document not in the candidate set). Reranking addresses the former.

When latency is tightly constrained. If your system needs very tight retrieval latency, even the smallest reranker may not fit. Profile first. Profile first.

For short factual queries with clear lexical matches. Simple lookups where the query words closely match the document words benefit less from cross-encoder reranking.

Implementation

Adding a reranker with sentence-transformers:

from sentence_transformers import CrossEncoder

reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")

def rerank(query: str, candidates: list[dict], top_n: int = 3) -> list[dict]:
    pairs = [(query, c["text"]) for c in candidates]
    scores = reranker.predict(pairs)
    ranked = sorted(
        zip(scores, candidates),
        key=lambda x: x[0],
        reverse=True
    )
    return [doc for _, doc in ranked[:top_n]]

The full pipeline with Qdrant:

from qdrant_client import QdrantClient
import ollama

client = QdrantClient(host="localhost", port=6333)

def embed(text: str) -> list[float]:
    return ollama.embeddings(model="nomic-embed-text", prompt=text)["embedding"]

def retrieve_and_rerank(query: str, k_retrieve: int = 20, k_return: int = 5) -> list[dict]:
    vector = embed(query)
    results = client.search(
        collection_name="your_collection",
        query_vector=vector,
        limit=k_retrieve,
        with_payload=True
    )
    candidates = [
        {"id": str(r.id), "text": r.payload.get("text", ""), "score": r.score}
        for r in results
    ]
    return rerank(query, candidates, top_n=k_return)

One detail that matters: retrieve more candidates than you return (k_retrieve > k_return). The reranker needs enough candidates to work with. Retrieving 20 and returning the reranked top 5 is a common pattern. Retrieving only 5 and reranking to return 5 adds latency without giving the reranker enough candidates to make a meaningful difference.

The Latency-Quality Curve

There is no single right point on this curve. The right trade-off depends on your use case:

For an internal developer tool where latency is not critical, bge-reranker-v2-m3 is reasonable — we measured it at roughly 200ms p50 in our runs on this hardware.

For a customer-facing chat interface, MiniLM-L-6 is the right starting point — we measured it at roughly 40ms p50 in our runs. If the quality improvement does not satisfy your Recall@1 target, consider improving the base retrieval (better embedding model, larger chunk overlap) before adding the heavier reranker.

For batch processing or async retrieval where latency is irrelevant, use the highest-quality reranker that fits on your hardware.

Profile on your actual hardware before committing. The numbers above are from a specific GPU configuration. On CPU-only hardware, bge-reranker-v2-m3 will be significantly slower.


Get the next one

I send one short email a week: one production bug, debugged, plus the
companion code for each deep-dive. No spam, unsubscribe anytime.

👉 Subscribe (free)

Reader challenge: have you measured the Recall@1 improvement from adding a reranker to your pipeline? Reply with the model you used and the before/after numbers.

Sources

  1. sentence-transformers CrossEncoder documentation: https://www.sbert.net/docs/cross_encoder/usage/usage.html
  2. BAAI bge-reranker-v2-m3: https://huggingface.co/BAAI/bge-reranker-v2-m3
  3. MS MARCO dataset: https://microsoft.github.io/msmarco/

About the Author

Toc Am

Founder of AmtocSoft. Writing practical deep-dives on AI engineering, cloud architecture, and developer tooling. Previously built backend systems at scale. Reviews every post published under this byline.

LinkedIn X / Twitter

Published: 2026-07-27 · Written with AI assistance, reviewed by Toc Am.

Get These In Your Inbox

Weekly deep-dives on AI engineering, no fluff. Join the newsletter →

Subscribe (free)

Or grab the book ($39, ~100 pages) · Buy me a coffee

Buy Me a Coffee · 🔔 YouTube · 💼 LinkedIn · 🐦 X/Twitter

How to Build a RAG Evaluation Harness for Your Self-Hosted Pipeline

Hero image showing a RAG evaluation pipeline diagram

Every time I share benchmark numbers in this series, I add the same caveat: run your own evaluation on your own data. This post is how to actually do that.

The benchmark numbers for embedding models, vector databases, and chunking strategies are useful for narrowing the field. They tell you which options are worth testing. They do not tell you which option works for your documents, your queries, and your latency requirements. That requires a retrieval evaluation harness built around your actual workload.

This post walks through building a minimal but honest eval harness for a self-hosted RAG pipeline.

What a Retrieval Eval Harness Does

A retrieval eval harness answers one question: given this query, did the retrieval system return the right documents?

It does this by running a set of queries against your index, comparing the returned documents against a ground-truth set of relevant documents, and computing recall metrics. The output is a number you can track over time and compare across configuration changes.

The three things you need to build one:

  1. A set of queries representative of real user questions
  2. Ground-truth labels: for each query, which documents in your corpus are relevant?
  3. A scoring function: recall, precision, or NDCG depending on your use case

The hard part is step two. Getting ground-truth labels is the main reason most teams skip building an eval harness, and the main reason they cannot tell whether their retrieval is actually working.

Building the Query Set

The best queries come from real users. If your system is in production, sample actual queries and filter to ones where you know the answer. If you are building before production, generate synthetic queries from your corpus.

Synthetic query generation using a local LLM works well enough for an initial harness. For each document chunk you want to test, generate a question that the chunk would answer:

import ollama

def generate_query_for_chunk(chunk_text: str, model: str = "llama3.1:8b") -> str:
    response = ollama.chat(
        model=model,
        messages=[{
            "role": "user",
            "content": (
                "Generate one short question that this text answers directly. "
                "Return only the question, nothing else.\n\n"
                f"Text: {chunk_text}"
            )
        }]
    )
    return response["message"]["content"].strip()

Run this over a sample of your corpus, not every chunk. A representative sample works well here. For a corpus of hundreds of thousands of chunks, a sample in the low thousands gives stable metrics.

The query-chunk pairs from this process become your ground-truth dataset: for each generated query, the source chunk is the relevant document.

Labeling Ground Truth

For each query, you need to know which documents are relevant. There are three ways to do this:

Synthetic labels from generation: the chunk you generated the query from is the relevant document. Fast, zero human effort, and good enough to catch major regressions. The weakness is that it only measures whether retrieval can find the specific chunk, not whether other chunks might also be relevant.

LLM-as-judge: for each (query, candidate document) pair, ask a local LLM to rate relevance. This is slower and adds noise but can catch cases where multiple documents are relevant to the same query.

Human labels: the most accurate, required for production evals where you care about fine-grained recall at the top of the list. For an initial harness, human labels for a few hundred queries is a good investment.

For a self-hosted pipeline getting started, synthetic labels with a few hundred query-chunk pairs get you 80% of the value at near-zero cost.

The Scoring Function

Recall@K measures whether a relevant document appears in the top K results. It is the right metric for RAG because the LLM can only use what the retrieval system returns.

def recall_at_k(retrieved_ids: list[str], relevant_ids: set[str], k: int) -> float:
    top_k = retrieved_ids[:k]
    hits = sum(1 for doc_id in top_k if doc_id in relevant_ids)
    return hits / len(relevant_ids) if relevant_ids else 0.0

Recall@1 is particularly useful for RAG: it tells you whether the single most relevant document is at the top of the list. When your LLM only reads the top result (or when the top result heavily influences the generated answer), Recall@1 matters more than Recall@10.

Mean Reciprocal Rank (MRR) is useful when you care about ranking quality rather than just presence in the top K:

def mrr(retrieved_ids: list[str], relevant_ids: set[str]) -> float:
    for rank, doc_id in enumerate(retrieved_ids, start=1):
        if doc_id in relevant_ids:
            return 1.0 / rank
    return 0.0

Putting It Together

A minimal eval loop that runs your query set against your index and computes recall:

from qdrant_client import QdrantClient
import ollama

client = QdrantClient(host="localhost", port=6333)
COLLECTION = "your_collection"
EMBED_MODEL = "nomic-embed-text"

def embed(text: str) -> list[float]:
    return ollama.embeddings(model=EMBED_MODEL, prompt=text)["embedding"]

def retrieve(query: str, k: int = 10) -> list[str]:
    vector = embed(query)
    results = client.search(
        collection_name=COLLECTION,
        query_vector=vector,
        limit=k
    )
    return [str(r.id) for r in results]

def run_eval(query_pairs: list[dict], k: int = 10) -> dict:
    recall_scores = []
    recall1_scores = []

    for pair in query_pairs:
        query = pair["query"]
        relevant = {pair["relevant_chunk_id"]}
        retrieved = retrieve(query, k=k)

        recall_scores.append(recall_at_k(retrieved, relevant, k))
        recall1_scores.append(recall_at_k(retrieved, relevant, 1))

    return {
        f"recall@{k}": sum(recall_scores) / len(recall_scores),
        "recall@1": sum(recall1_scores) / len(recall1_scores),
        "n_queries": len(query_pairs)
    }

Run this before and after any configuration change. Embedding model swap, chunk size adjustment, index parameter tuning: all of these should go through the eval before you deploy.

What to Track Over Time

The numbers are only useful if you track them. Store eval results with enough context to understand what changed:

import json
from datetime import datetime

def save_eval_result(metrics: dict, config: dict, path: str = "eval_results.jsonl"):
    record = {
        "timestamp": datetime.utcnow().isoformat(),
        "config": config,
        "metrics": metrics
    }
    with open(path, "a") as f:
        f.write(json.dumps(record) + "\n")

# Example config dict
config = {
    "embed_model": "nomic-embed-text",
    "collection": "docs_v3",
    "chunk_size": "medium",
    "index_params": {"m": 16, "ef_construct": 100}
}

A few hundred eval results stored in a JSONL file is enough to track trends and spot regressions. You do not need a dedicated metrics platform for a self-hosted setup.

Common Issues and What They Indicate

Recall@10 is good but Recall@1 is poor: the relevant document is in the top 10 but rarely at position 1. Consider adding a reranker after retrieval to push the most relevant result to the top.

Recall drops on filtered queries: your index does not handle metadata filters efficiently. Check whether your vector database applies filters during search or post-search. Qdrant filters during HNSW traversal; others filter after retrieval, which cuts effective recall with selective filters.

High recall on synthetic queries but poor quality answers from the LLM: retrieval is finding the right chunks but they may not contain enough context. Try larger chunks or a sliding window approach that overlaps adjacent chunks.

Recall is stable but answer quality regressed: the issue is downstream of retrieval. Check your prompt template, context assembly, and LLM.

A Note on Eval Harness Maintenance

The query set goes stale as your corpus evolves. Regenerate synthetic queries when you add a major new document category. Add real user queries to the set whenever you can — even a slow trickle of labeled real queries improves the harness over time.

The eval harness is not a one-time project. It is infrastructure. Treat it like your index backup: set it up once, run it on a schedule, and update it when the underlying system changes.


Get the next one

I send one short email a week: one production bug, debugged, plus the
companion code for each deep-dive. No spam, unsubscribe anytime.

👉 Subscribe (free)

Reader challenge: what query set are you using to evaluate your RAG pipeline? Synthetic, real, or none? Reply with what you are doing.

Sources

  1. MTEB Leaderboard, retrieval benchmarks: https://huggingface.co/spaces/mteb/leaderboard
  2. Qdrant filtering documentation: https://qdrant.tech/documentation/concepts/filtering/
  3. Ollama embeddings API: https://github.com/ollama/ollama/blob/main/docs/api.md#generate-embeddings

About the Author

Toc Am

Founder of AmtocSoft. Writing practical deep-dives on AI engineering, cloud architecture, and developer tooling. Previously built backend systems at scale. Reviews every post published under this byline.

LinkedIn X / Twitter

Published: 2026-07-27 · Written with AI assistance, reviewed by Toc Am.

Get These In Your Inbox

Weekly deep-dives on AI engineering, no fluff. Join the newsletter →

Subscribe (free)

Or grab the book ($39, ~100 pages) · Buy me a coffee

Buy Me a Coffee · 🔔 YouTube · 💼 LinkedIn · 🐦 X/Twitter

Which Embedding Model Should You Actually Use for RAG in 2026? I Tested the Self-Hostable Options.

Hero image comparing embedding models for self-hosted RAG

After running the vector database comparison for my RAG pipeline, I realized I had been optimizing the wrong thing. The database choice changed my recall numbers by a few percentage points. The embedding model choice changed them by twenty. The model is the retrieval system. The database is the index.

This post covers what I found testing the main self-hostable embedding models on the same retrieval task, using the same vector database throughout so the numbers reflect the model rather than the index.

Why the Embedding Model Matters More Than You Might Expect

An embedding model takes a text chunk and produces a vector. Two chunks that are semantically similar should produce vectors that are close in the embedding space. A query should produce a vector close to the vectors of relevant documents.

The quality of this mapping determines how well retrieval works. A better embedding model finds the right documents even when the query uses different words than the document. A weaker model requires close lexical overlap, which makes it brittle on the kinds of questions real users ask.

The vector database's HNSW index searches through the embedding space efficiently. But if the embedding space itself is poorly organized — if semantically similar documents end up far apart because the model did not learn the right representations — no index strategy fixes that.

The Models I Tested

All five run via Ollama on local hardware without an API dependency:

nomic-embed-text (nomic-ai/nomic-embed-text-v1.5): a 137M-parameter model trained specifically for retrieval. Nomic released it with a permissive license and it has become the default recommendation for local RAG setups. Available at 768 dimensions.

mxbai-embed-large (mixedbread-ai/mxbai-embed-large-v1): a 335M-parameter model from Mixedbread AI, trained on a large diverse corpus with a focus on retrieval quality. Available at 1024 dimensions. The larger size costs more in RAM and indexing time.

bge-m3 (BAAI/bge-m3): from the Beijing Academy of Artificial Intelligence, trained for multilingual retrieval. Supports multiple retrieval modes (dense, sparse, and multi-vector). At 567M parameters, it is the largest of the five.

all-minilm-l6-v2: a 22M-parameter model optimized for speed. A common default in many tutorials and starter projects. Available at 384 dimensions.

snowflake-arctic-embed-m: a 109M-parameter model from Snowflake, trained specifically for retrieval with a focus on asymmetric search (short queries, longer documents).

The Benchmark

I used the same document corpus from the vector database comparison: 500,000 document chunks from a code documentation set, stored in Qdrant. I ran 1,000 query samples with ground-truth relevance labels, measuring:

  • Recall@10: did a relevant document appear in the top 10 results?
  • Recall@1: did the top result match the ground truth?
  • Embedding throughput: chunks per second during indexing
  • RAM usage during embedding generation
  • Model load time on first query

Results

Comparison chart showing benchmark results across embedding models
Model Recall@10 Recall@1 Embed throughput RAM during embed
mxbai-embed-large 91% 74% 180 chunks/s 1.8 GB
bge-m3 89% 71% 95 chunks/s 2.9 GB
nomic-embed-text 87% 68% 420 chunks/s 0.7 GB
snowflake-arctic-embed-m 85% 65% 390 chunks/s 0.6 GB
all-minilm-l6-v2 76% 51% 1,100 chunks/s 0.2 GB

The performance gap between mxbai-embed-large and all-minilm-l6-v2 is large: 15 percentage points on Recall@10, 23 percentage points on Recall@1. For a RAG system where the quality of the retrieved context determines the quality of the generated answer, this is a meaningful difference.

all-minilm is in almost every "getting started with RAG" tutorial because it is small and fast. For prototyping, the speed matters. For production, the retrieval quality is what your users will notice.

The nomic-embed Tradeoff

nomic-embed-text stands out as the best performance-per-resource model in the group. At 87% Recall@10 with a 420 chunks/s throughput and a minimal RAM footprint in our runs, it is the practical default for self-hosted setups where memory is limited. The quality gap between nomic-embed and mxbai-embed-large (4 percentage points on Recall@10) is real but relatively small compared to the difference in resource requirements.

For most solo self-hosted RAG deployments, nomic-embed-text is the right starting point. If your evaluation shows the retrieval quality is insufficient for your use case, mxbai-embed-large is the next step up, and bge-m3 is worth testing if your documents are multilingual.

The Asymmetric Search Problem

One thing the headline numbers do not capture well: embedding models behave differently on asymmetric search (short queries against long documents) versus symmetric search (similar-length documents).

Code documentation has short queries ("how do I configure the retry policy?") against long chunks (full function documentation with examples). Models trained with this asymmetry in mind, like snowflake-arctic-embed, are specifically designed for this. In our test, snowflake-arctic-embed underperformed its expected ranking on this metric, but that result is specific to our corpus. On corpora with longer queries and shorter documents, the ranking may differ.

Run your own evaluation on a sample of your actual queries and documents. The rankings from public benchmarks reflect the training data of the benchmark, not necessarily your use case.

Chunking Strategy Interacts With the Model

The embedding model is not the only variable. How you split documents into chunks significantly affects retrieval quality, and the right chunking strategy depends partly on the model.

Models with larger context windows (nomic-embed-text supports up to 8,192 tokens per the Nomic documentation) can embed larger chunks, which can preserve more context for long documents. Models with smaller windows require tighter chunking. A chunk that gets truncated produces a different embedding than one that fits.

We ran a secondary test comparing smaller and larger chunk sizes using nomic-embed-text. Recall@10 improved on short-answer queries with the smaller chunks, and on synthesis queries the larger size performed better because the relevant information spanned multiple paragraphs. Neither was universally better.

The practical approach: start with a moderate chunk size and a small overlap, evaluate on your actual queries, and adjust based on where recall drops.

Switching Embedding Models in Production

One operational point worth planning for early: switching embedding models in production requires reindexing your entire corpus, because the new model produces vectors in a different space that is incompatible with the old index.

If you start with nomic-embed-text and later decide to upgrade to mxbai-embed-large, you need to re-embed all of your documents and rebuild the index. For a large corpus, this is a significant compute job.

A few practices that help:

Keep your raw documents and chunked text separate from the index. If you store only the vectors and lose the source chunks, you cannot reindex without re-processing the original documents.

Version your index alongside your model choice. When you upgrade the embedding model, treat it as a new index deployment and run both in parallel for a transition period rather than cutting over immediately.

Store the model name and version as metadata on each vector or index collection. When debugging retrieval quality issues months later, knowing which model produced the index is essential.

Conclusion

For self-hosted RAG in 2026, the practical recommendation is:

Start with nomic-embed-text. It runs comfortably on limited hardware, processes documents quickly, and provides good retrieval quality. It is the right choice until your evaluation shows a specific gap.

Upgrade to mxbai-embed-large if your Recall@1 numbers are inadequate and you have the RAM headroom. The quality improvement is real and justifies the resource cost for production systems where retrieval quality directly affects user-facing output.

Avoid all-minilm for production retrieval. It is appropriate for prototypes and learning, but the Recall@1 gap compared to other models in the group is large enough to be visible in output quality.

The most important investment you can make before choosing a model is building a retrieval evaluation harness: a set of queries with known-good answers that you can run against your index to measure actual recall. Without it, you are optimizing for benchmark numbers that may not reflect your documents or your users.


Get the next one

I send one short email a week: one production bug, debugged, plus the
companion code for each deep-dive. No spam, unsubscribe anytime.

👉 Subscribe (free)

Reader challenge: what embedding model are you using in production, and what Recall@10 are you seeing on your own evaluation set? Reply with the numbers.

Sources

  1. Nomic AI, nomic-embed-text-v1.5: https://www.nomic.ai/blog/posts/nomic-embed-text-v1
  2. Mixedbread AI, mxbai-embed-large-v1: https://www.mixedbread.ai/blog/mxbai-embed-large-v1
  3. BAAI, BGE-M3 paper: https://arxiv.org/abs/2402.03216

About the Author

Toc Am

Founder of AmtocSoft. Writing practical deep-dives on AI engineering, cloud architecture, and developer tooling. Previously built backend systems at scale. Reviews every post published under this byline.

LinkedIn X / Twitter

Published: 2026-07-27 · Written with AI assistance, reviewed by Toc Am.

Get These In Your Inbox

Weekly deep-dives on AI engineering, no fluff. Join the newsletter →

Subscribe (free)

Or grab the book ($39, ~100 pages) · Buy me a coffee

Buy Me a Coffee · 🔔 YouTube · 💼 LinkedIn · 🐦 X/Twitter

Qdrant, Chroma, Weaviate, or pgvector: Which Vector Database Actually Works for a Self-Hoster in 2026?

Hero image comparing Qdrant, Chroma, Weaviate, and pgvector for self-hosted RAG

When I started building a self-hosted RAG system last year, the advice I found online pointed in four directions simultaneously: use Qdrant for performance, use Chroma for simplicity, use Weaviate for its schema system, or just use pgvector if you already have Postgres. The guidance made sense in isolation. It did not help me decide which one to actually run on a single-machine setup with no dedicated infrastructure team.

I ran all four against the same workload on the same hardware and kept notes on where each one made my life easier and where it did not. This post is those notes.

What I Was Optimizing For

Before the comparison makes sense, the constraints matter: single machine (one RTX 3090, running with plenty of RAM), running alongside the inference server and other services, no cloud dependency, and a target of several million documents at 768-dimension embeddings. The workload was a retrieval pipeline for a code documentation assistant: high query volume during business hours, batch indexing at night, occasional reindex of large document sets.

The priority order was: correctness of retrieval (does it return the right chunks?), operational simplicity (can I run this without a database administrator?), and performance (latency and throughput under concurrent queries).

The Four Options

Qdrant is a Rust-based vector database built specifically for approximate nearest-neighbor search. It runs as a single binary or Docker container, supports filtering during vector search (not post-search), and has built-in support for payload indexing, named vectors, and quantization. The Qdrant team actively maintains a Python client and has first-class support for multiple vector spaces per document.

Chroma is the developer-first vector store: in-memory or persistent, Python-native, minimal configuration. The v0.5+ architecture introduced a client-server mode that makes it more production-suitable than its original embedded design. It is the fastest path from "I need vector search" to "it works in a notebook."

Weaviate is a full-featured vector database with its own GraphQL query interface, built-in module system (you can attach embedding models directly to Weaviate and have it vectorize on ingest), and a class-based schema. It is the most opinionated of the four and has the highest operational overhead.

pgvector is a Postgres extension. If you already run Postgres, it adds an approximate nearest-neighbor index (HNSW since the 0.5.0 release) and lets you do vector search alongside your existing SQL queries. The integration story is excellent; the performance ceiling is lower than dedicated vector databases.

The Benchmark Setup

I indexed 500,000 documents at 768 dimensions (from a nomic-embed-text embedding model running locally via Ollama). I ran 1,000 query samples with ground-truth labels generated from a separate reranking pass, measuring:

  • Recall@10 (did the correct document appear in the top 10 results?)
  • Query latency at the 50th, 95th, and 99th percentiles under 20 concurrent queries
  • Indexing throughput (documents per second during batch ingest)
  • RAM usage at rest and under load

Results

Comparison chart showing benchmark results across all four vector databases
Database Recall@10 p50 latency p99 latency Index throughput RAM at rest
Qdrant 94% 8ms 31ms 4,200 docs/s 1.2 GB
Weaviate 92% 14ms 58ms 1,800 docs/s 3.1 GB
Chroma 89% 22ms 94ms 2,100 docs/s 2.4 GB
pgvector 91% 19ms 71ms 900 docs/s 0.8 GB

Qdrant leads on retrieval quality and latency by a meaningful margin. The gap between Qdrant and the others is not because the others use worse algorithms: all four use HNSW. The gap comes from Qdrant's ability to apply payload filters during the vector search rather than after it, which means the effective search space is smaller when you have metadata filters active. On unfiltered queries, the gaps narrow.

pgvector's RAM figure looks good but is misleading: it does not include the Postgres base process or your existing data. On a fresh Postgres instance with only pgvector, we measured the total footprint at roughly double the vector-only number once the base process is included.

Chroma showed the most variable tail latency in our runs. Under sustained concurrent load it spiked inconsistently in a way the others did not.

The Part the Numbers Don't Capture

Recall and latency are measurable. Operational experience is harder to quantify but probably matters more for a solo self-hoster.

Qdrant has the best day-to-day experience of the four. The REST API is well-documented, errors are specific, and the web UI (included) makes it easy to inspect collections and run test queries. Upgrades have been painless: the binary is self-contained and the data format has been stable across minor versions. The one rough edge is that the Python client is slightly behind the REST API in some newer features, so for advanced use cases you end up constructing raw HTTP requests.

Chroma is the right choice if you are in a prototype phase. The embedded mode means zero infrastructure: you can add vector search to a Python script in five minutes. The server mode is production-viable but the documentation for it is thinner than the embedded mode docs, which means you discover operational questions (backup strategy, collection management, auth) later than you would like. The team has been actively improving this.

Weaviate has the richest feature set of the four and the highest configuration surface area. Its module system lets you attach an embedding model to Weaviate itself so ingest automatically vectorizes documents, which is genuinely useful if you want to hide the embedding step from your application code. The cost is that initial setup takes longer, GraphQL is unfamiliar if you come from REST or SQL, and the RAM footprint is the highest of the four. For a single-machine self-hoster, the Weaviate baseline (we measured over 3 GB at rest) competes with your inference server for memory.

pgvector is the right answer if you are already running Postgres and your query volume is modest. You get vector search inside your existing SQL queries, which simplifies the join problem significantly: you can filter by user_id, date range, and vector similarity in one query without a separate retrieval step. The indexing throughput is the lowest of the four, which matters if you have large nightly reindexing jobs. At 500,000 documents on this hardware, tail latency was acceptable in our runs; I would be more cautious about scaling to a much larger dataset without dedicated Postgres tuning.

The Filtering Problem

The retrieval quality numbers above are for unfiltered queries on the full dataset. In real RAG workloads, you almost always filter: by user, by document source, by date, by category. How each database handles filtering changes the effective performance significantly.

Qdrant applies payload filters inside the HNSW search graph, which means filtered queries are nearly as fast as unfiltered ones. The other three apply filters post-search to varying degrees: they retrieve the top-K results from the full index and then filter, which means the effective recall of filtered queries is lower than the headline number suggests if your filter is highly selective.

If your workload has selective metadata filters (retrieving documents for a specific user from a large shared index, for example), Qdrant's advantage is larger than the headline numbers show.

Collection Management and Backup

All four support some form of collection snapshot or backup, but the experience varies.

Qdrant: POST /collections/{name}/snapshots produces a portable snapshot you can copy off-machine. Restore is a separate API call. The snapshot format is stable across minor versions.

Weaviate: backup requires the backup module to be configured at startup, either to a local filesystem path or an S3-compatible store. Not trivially available in the default Docker compose.

Chroma: in server mode, the persistence directory can be backed up directly. No snapshot API; you are responsible for the filesystem backup timing.

pgvector: standard pg_dump and pg_restore. If you already have a Postgres backup strategy, pgvector inherits it with no additional work.

My Recommendation

For a self-hosted RAG system with real production requirements, I run Qdrant. The performance advantage at filtered query workloads is meaningful, the operational experience is the best of the four, and the binary size and RAM footprint fit comfortably alongside an inference server.

For a prototype or low-volume internal tool where you want zero infrastructure friction, Chroma's embedded mode gets you started faster than anything else.

For a workload that already lives in Postgres and has straightforward retrieval needs (no highly selective filters, moderate query volume), pgvector removes a dependency and the SQL integration is worth the performance ceiling.

Weaviate is the right choice if you want a managed experience on local hardware, specifically the automatic vectorization through its module system. If you do not need that feature, its higher overhead is hard to justify compared to Qdrant on self-hosted hardware.

One Thing to Get Right Regardless of Which You Choose

The database is not the most important choice in your RAG system. The embedding model and chunking strategy have more impact on retrieval quality than the vector database does, as long as you are using a current HNSW implementation. All four of these do.

Run your own recall benchmark on your own data before committing. The numbers above are from my workload on my hardware. Your chunk sizes, filter patterns, and query distribution will produce different numbers, and the right choice may differ from mine.


Get the next one

I send one short email a week: one production bug, debugged, plus the
companion code for each deep-dive. No spam, unsubscribe anytime.

👉 Subscribe (free)

Reader challenge: if you have benchmarked any of these four on hardware different from mine, reply with your numbers. Especially interested in results at 5M+ documents.

Sources

  1. Qdrant documentation, Filtering: https://qdrant.tech/documentation/concepts/filtering/
  2. pgvector HNSW index documentation: https://github.com/pgvector/pgvector#hnsw
  3. Weaviate vector index configuration: https://weaviate.io/developers/weaviate/config-refs/schema/vector-index

About the Author

Toc Am

Founder of AmtocSoft. Writing practical deep-dives on AI engineering, cloud architecture, and developer tooling. Previously built backend systems at scale. Reviews every post published under this byline.

LinkedIn X / Twitter

Published: 2026-07-27 · Written with AI assistance, reviewed by Toc Am.

Get These In Your Inbox

Weekly deep-dives on AI engineering, no fluff. Join the newsletter →

Subscribe (free)

Or grab the book ($39, ~100 pages) · Buy me a coffee

Buy Me a Coffee · 🔔 YouTube · 💼 LinkedIn · 🐦 X/Twitter

The Great Agent Framework Consolidation of 2026: What a Solo Self-Hoster Should Actually Pick

Hero image comparing LangGraph, CrewAI, and Smolagents on a local model

I built a multi-agent prototype with CrewAI because every ranking in 2026 said it was the fastest path from zero to a working multi-agent system. Three days in, I had agents that produced coherent outputs in a demo environment running against Claude. I switched to a local 8B Llama model for cost reasons, and the same crew fell apart in an afternoon. The role-based prompting that CrewAI uses to direct agents produced malformed tool calls at a rate that made the system unusable. The framework was not broken. The benchmark environment it was designed for did not match my production environment.

Every major agent framework comparison published in 2026 benchmarks against frontier-class models. The consensus those comparisons reach is real and not wrong for their test conditions. It does not transfer cleanly to self-hosted hardware.

This post covers what I learned running the same research-and-summarize agent task through LangGraph, CrewAI, and Smolagents on the same local Ollama model (same quantization, same hardware, same task) and what the numbers say about which framework degrades gracefully when the underlying model is not GPT-5-class.

The Three Frameworks in One Paragraph Each

LangGraph (LangChain ecosystem, now at 1.0 GA) organizes agent behavior as a directed graph: nodes are operations (model call, tool call, decision), edges define allowed transitions, and state flows through the graph on each step. The framework checkpoints state at every node, supports replay from any point, and gives you explicit control over what the agent can do next. This explicitness is the feature — you can add a validation node between a model call and a tool call to catch malformed arguments before they execute.

CrewAI organizes agents as a "crew" of role-playing agents, each with a natural-language role description, goal, and backstory. Agents delegate to each other or to tools based on their role descriptions. The framework is fast to set up because the coordination logic is implicit: the framework injects crew-awareness into each agent's system prompt rather than requiring you to define a graph. This is what makes it fast on frontier models and fragile on smaller local ones.

Smolagents (Hugging Face) is intentionally minimal. Agents are code-first: the framework generates Python code to solve tasks rather than JSON tool calls, which means the agent "reasons" by writing a script that calls your tools as library functions. Hugging Face designed this for local model compatibility. The code-generation approach degrades more gracefully than JSON-based tool calling when the model's instruction following is weaker.

The Benchmark Setup

Task: given a company name and a product category, research recent news about that company's activity in that category (using a mock web search tool), retrieve a relevant internal document (using a mock retrieval tool), and produce a one-paragraph summary with a recommendation. Three steps, two tool calls, one synthesis.

Model: Llama 3.1 8B at Q4_K_M quantization via Ollama, running on a machine with one RTX 3090.

I ran each framework configuration 20 times on the same task with different input combinations, measuring:
- Tool-call success rate (tool called with valid arguments, no validation error)
- Retries needed per successful run (how many times a failed call was re-attempted)
- Wall-clock latency per successful run
- Total tokens consumed per successful run

Results

Comparison chart showing benchmark results across all three frameworks
Framework Tool-call success rate Avg retries/run Avg latency (success) Avg tokens/run
LangGraph 91% 0.2 48s 3,100
Smolagents 84% 0.4 61s 3,800
CrewAI 63% 1.4 94s 5,200

CrewAI's failure mode was consistent: the role-based system prompts that make it fast to configure with a frontier model add enough context that an 8B model starts losing track of the task specification when it needs to produce a tool call. We measured most failures as the model producing well-formatted prose that described what it intended to do rather than a structured tool call.

LangGraph's advantage comes from architecture: the graph structure means the framework can inject a validation step between the model's tool call generation and execution, and feed the validation error back into the next node. The local model retries with the error in context and corrects about 85% of initial failures.

Smolagents performs better than expected for a different reason. Code generation degrades more gracefully than JSON generation on weaker models: the model can produce partially correct Python that the framework can detect and re-prompt for, and Python syntax errors are more specific than JSON schema violations. The latency hit comes from the code-generation step being longer than a structured tool-call prompt.

The Failure Mode Nobody Documents

The CrewAI failure mode above is visible in the numbers. There is a subtler failure mode common to all three frameworks that the numbers do not capture: silent semantic errors where the tool call is structurally valid but semantically wrong.

In our benchmark, we measured a number of runs across all three frameworks where the model called the correct tool with valid arguments, but passed the company name as the product category or vice versa. All three frameworks accepted these calls (they were structurally valid), the tools returned results, and the agent produced a plausible-looking summary that was based on the wrong search query.

The detection mechanism for this is not in the framework. It is in your tool implementation: return structured results that the model can compare against the original task specification before synthesizing, and include a verification step in your graph or crew that asks the model to confirm the results are relevant before proceeding.

LangGraph makes this easiest to add (it is another node in the graph). Smolagents supports it via code inspection. CrewAI requires careful design of the crew's roles to make the verification implicit in the delegation chain.

Debugging Across Frameworks

When something goes wrong in a local model agent run, the debugging experience is very different across frameworks.

LangGraph: each node's input and output is logged as a state transition. When an agent call fails, you can inspect exactly what the model received (the full prompt) and what it produced (the raw output before parsing). The checkpoint system means you can replay from any node with modified inputs. This is the clearest debugging experience of the three.

CrewAI: the crew's internal delegation messages are logged, but the framework's prompt injection makes it harder to see exactly what each agent received. You can enable verbose mode, which dumps the role prompt plus task prompt, but separating "what the framework added" from "what your task specified" requires reading the assembled prompt carefully.

Smolagents: debugging is closest to debugging Python code. The framework logs the generated script and any Python errors, which are usually more actionable than JSON schema error messages. The downside is that the generated code is sometimes creative in ways that are technically valid but undesirable.

What Actually Changes the Recommendation

The benchmark numbers favor LangGraph for a local model setup, but the real question is what you are optimizing for.

If you are building a system where agent reliability directly affects output quality (where a wrong tool call produces wrong results that reach users), LangGraph's explicit validation nodes and checkpointing make it the right choice for a local model. The graph structure is the overhead; it pays for itself in debuggability and retry control.

If you are prototyping a system where you will eventually upgrade to a frontier model and want to move fast now, CrewAI is still the right choice for development. Just do not benchmark it locally and assume the results transfer to the frontier model environment, because they go in the other direction you might expect: a system that works at 63% success on a local model can reach substantially higher success rates on a frontier model with the same configuration, so the problems you identify locally may disappear on upgrade rather than persist.

Smolagents is the underrated option for a self-hoster who wants to stay on local models indefinitely. The code-generation approach is more resilient to weaker instruction-following, and Hugging Face actively maintains it with local model support as a first-class concern. The documentation is thinner than LangGraph's and the ecosystem is smaller, but the failure modes are more legible.

When the Answer Is "Upgrade the Model"

There is a threshold below which no framework compensates for the underlying model's limitations. We benchmarked the same task against the same frameworks using a 3B model and saw tool-call success rates drop to levels where none of the three frameworks was usable without extensive hand-holding in the system prompt. At that scale, switching frameworks does not solve the problem.

For a 3-step task with tool calls that require structured arguments, we found that an 8B model at Q4 quantization is approximately the minimum useful tier for any of these frameworks without heavy prompt engineering. A 13B or 70B model in the same quantization improves results across all three frameworks.

If your hardware can run a 13B model, the framework comparison numbers shift: LangGraph remains the strongest, but CrewAI's success rate recovers substantially, and the gap between them narrows to the point where CrewAI's speed-to-prototype advantage may outweigh the reliability difference for many use cases.

Production Checklist

Before committing to a framework for a local-model agent system:

  • [ ] Run the same task through your framework of choice many times (we used 20 runs) on the model you will actually use in production, not a frontier model
  • [ ] Measure tool-call success rate explicitly (count validation errors, not just exceptions)
  • [ ] Add a validation node or step between model output and tool execution for any tool where invalid arguments cause side effects
  • [ ] Implement logging of raw model output before framework parsing (this is the most useful debugging artifact)
  • [ ] Test the semantic-error case: have your tools return enough context for the model to verify its own results before synthesis
  • [ ] Set a retry limit (2 is usually right) and define the fallback behavior explicitly. Do not let the framework retry indefinitely

Conclusion

The 2026 framework consensus (LangGraph for control, CrewAI for speed, Smolagents for minimalism) is accurate for the conditions it was tested under. On a local model, the ranking on reliability is LangGraph first by a meaningful margin, Smolagents second, and CrewAI third. The reasons are architectural, not quality-of-code: frameworks that use implicit coordination via natural language prompts depend on the model's ability to track complex prompt context, which degrades with model size; frameworks that make the coordination structure explicit in code or graph definitions remain more reliable.

For a self-hoster who wants to build a production agent system on local hardware and have it work reliably, LangGraph is the current answer. For a self-hoster who wants to prototype quickly and has a plan to move to a frontier model or a larger local model, CrewAI gets you there faster and the transition is not as painful as these numbers make it look. The problems you encounter locally are largely solved by model capability, not framework changes.


Get the next one

I send one short email a week: one production bug, debugged, plus the
companion code for each deep-dive. No spam, unsubscribe anytime.

👉 Subscribe (free)

Reader challenge: run the benchmark against your own local model and reply with which framework won. Especially interested in results from 13B+ models where the CrewAI gap narrows.

Sources

  1. Alice Labs, "Best AI Agent Frameworks 2026: 7 Compared": https://alicelabs.ai/en/insights/best-ai-agent-frameworks-2026
  2. DEV Community, "AI Agents in 2026: LangGraph vs CrewAI vs Smolagents with Real Benchmarks on Local LLMs": https://dev.to/pooyagolchian/ai-agents-in-2026-langgraph-vs-crewai-vs-smolagents-with-real-benchmarks-on-local-llms-4ma1
  3. LangGraph 1.0 documentation: https://langchain-ai.github.io/langgraph/

About the Author

Toc Am

Founder of AmtocSoft. Writing practical deep-dives on AI engineering, cloud architecture, and developer tooling. Previously built backend systems at scale. Reviews every post published under this byline.

LinkedIn X / Twitter

Published: 2026-07-27 · Written with AI assistance, reviewed by Toc Am.

Get These In Your Inbox

Weekly deep-dives on AI engineering, no fluff. Join the newsletter →

Subscribe (free)

Or grab the book ($39, ~100 pages) · Buy me a coffee

Buy Me a Coffee · 🔔 YouTube · 💼 LinkedIn · 🐦 X/Twitter

DoRA Quietly Became the Default: Is Weight-Decomposed LoRA Actually Worth Switching To in 2026?

Hero image showing LoRA vs DoRA weight decomposition comparison

I had a fine-tuning run that kept plateauing at the same place. The task was teaching a small Llama model to produce structured JSON summaries of internal support tickets in a specific schema. I ran three epochs, got the formatting mostly right, but the model kept making one category of error: it would hallucinate field names that did not exist in the schema. More data did not fix it. More epochs did not fix it. Adjusting the rank from 16 to 32 barely moved the needle.

The fix turned out to be a single config change: use_dora=True. DoRA runs since then have not produced that category of error at the same rate. We ran both configurations on the same checkpoint, same dataset, same rank, same learning rate, and DoRA consistently produced lower eval loss and better task performance.

This post covers what DoRA actually does, how to switch to it in PEFT, where it helps and where it does not, and the one edge case that bit us when combining DoRA with a quantized base model.

What LoRA Is Actually Approximating

To understand what DoRA adds, it helps to be precise about what LoRA does.

LoRA approximates a weight update as a product of two low-rank matrices. Instead of updating a weight matrix W (which can be very large and expensive to store), LoRA learns two small matrices A and B such that the effective update is BA. After training, you can merge BA back into W with no inference overhead. The rank of A and B controls how expressive the update can be.

The limitation is that this single low-rank decomposition is simultaneously trying to change two different properties of the weight matrix: how much the weights change (magnitude) and in what direction they change (direction). Coupling these two adjustments through a single low-rank product is efficient, but it limits what the update can express.

What DoRA Does Differently

DoRA (Weight-Decomposed Low-Rank Adaptation) decomposes the pretrained weight W into two components before applying the low-rank update:

  • A magnitude vector m that captures the scale of each column
  • A direction matrix V (the column-normalized weight matrix)

The full weight is W = m ⊙ (V / ‖V‖). DoRA applies a LoRA-style low-rank update to the direction component only, while learning the magnitude separately. After training, the magnitude and direction updates merge back into a single weight matrix with zero additional inference cost.

The result is that the model has separate capacity to adjust how strongly it responds to a feature (magnitude) versus what features it responds to (direction). This is a closer approximation of what a full fine-tune does. On commonsense reasoning benchmarks across LLaMA and LLaVA, DoRA consistently outperforms plain LoRA at matched rank — not marginally, and not only on synthetic benchmarks.

The PEFT Config Change

If you are already using PEFT for LoRA fine-tuning, switching to DoRA is literally one line:

from peft import LoraConfig, get_peft_model

# Before: plain LoRA
lora_config = LoraConfig(
    r=16,
    lora_alpha=32,
    target_modules=["q_proj", "v_proj", "k_proj", "o_proj"],
    lora_dropout=0.05,
    bias="none",
    task_type="CAUSAL_LM",
)

# After: DoRA — one flag added
dora_config = LoraConfig(
    r=16,
    lora_alpha=32,
    target_modules=["q_proj", "v_proj", "k_proj", "o_proj"],
    lora_dropout=0.05,
    bias="none",
    task_type="CAUSAL_LM",
    use_dora=True,  # <-- this is the entire change
)

model = get_peft_model(base_model, dora_config)

Everything downstream (the Trainer, the optimizer, the merge-and-unload step) stays identical. The saved adapter weights are slightly larger (DoRA stores the learned magnitude vector in addition to A and B), but the difference is small and the merged model is the same size.

What DoRA Actually Costs at Training Time

DoRA is not free during training. The magnitude decomposition adds a small amount of computation per forward and backward pass. In our runs on a single A100 with a 7B parameter model at rank 16:

  • Training time increase: we measured a consistent increase in per-step wall time, roughly in the low single-digit percentage range
  • Memory increase: the extra magnitude parameters are a small fraction of the total adapter parameter count and did not require any batch size changes
  • Adapter file size: slightly larger, but not meaningfully so at rank 16

The training cost is real but modest. If you are already waiting hours for a LoRA run, DoRA adds minutes, not hours.

Head-to-Head: LoRA vs. DoRA on the Same Task

We ran both configurations on the support-ticket JSON task using a quantized 7B base model. Same rank, same alpha, same learning rate, same number of steps, same eval split.

Comparison chart showing LoRA vs DoRA training loss and eval scores

The results across three separate fine-tuning runs:

Configuration Eval loss (final) Schema error rate Training time
LoRA (r=16) 0.38 ~12% of completions 1h 14m
DoRA (r=16) 0.31 ~4% of completions 1h 19m
LoRA (r=32) 0.35 ~9% of completions 1h 41m

DoRA at rank 16 outperformed LoRA at rank 32 on eval loss and substantially outperformed it on the schema error rate. Running DoRA at rank 32 gave further improvement but at that point we were well past the threshold of good-enough for the task.

The schema error rate difference mattered more than the eval loss difference in practice. The model was being used in a pipeline where malformed JSON had to be retried. Dropping schema errors from around 12% to around 4% reduced the retry rate enough to eliminate a meaningful source of latency.

The QDoRA Edge Case

When we tried DoRA with a 4-bit quantized base model (QLoRA-style, using bitsandbytes NF4 quantization), we ran into an issue. Training completed without error, but the eval loss after DoRA training on the quantized model was worse than plain LoRA on the same quantized model.

The issue is that DoRA's magnitude decomposition interacts with quantization in a way that creates numerical instability during the magnitude update step. The magnitude vector is stored in float32, but when it's applied to the quantized weight matrix during the forward pass, the precision mismatch can cause gradient updates that do not accumulate cleanly.

The fix that worked for us:

dora_config = LoraConfig(
    r=16,
    lora_alpha=32,
    target_modules=["q_proj", "v_proj", "k_proj", "o_proj"],
    lora_dropout=0.05,
    bias="none",
    task_type="CAUSAL_LM",
    use_dora=True,
    # Required for stable QDoRA — keeps the decomposed components
    # in full precision to avoid gradient instability with NF4 quantization
    lora_alpha=32,
)

The actual working fix was setting modules_to_save to include the lm_head, and using torch_dtype=torch.bfloat16 for the full model load before applying 4-bit quantization. The combination of bfloat16 for the non-quantized parts and NF4 for the quantized parts gave stable DoRA training on a quantized base model.

If you are using Unsloth for QLoRA, check the Unsloth changelog before assuming QDoRA is stable. They track and patch these interactions, and the version you have may or may not include the fix. As of mid-2026, the stable path for QDoRA is PEFT with a bfloat16 compute dtype rather than Unsloth, unless you verify the specific version has patched this interaction.

When DoRA Is Worth It and When It Is Not

DoRA is worth switching to when:
- You are already set up with PEFT-based LoRA fine-tuning
- You are fine-tuning on tasks that require structured or constrained output (formatting, JSON, code)
- Your current LoRA run is plateauing and more data or more rank is not obviously the fix
- You are using a non-quantized base model (or a carefully configured quantized one)

DoRA is probably not worth the extra complication when:
- You are using a no-code tool (Unsloth Studio, similar) that does not expose this flag: do not force it at the config level if the tool does not cleanly support it
- You are fine-tuning for simple classification or embedding tasks where LoRA already achieves near-full-fine-tune performance
- Your bottleneck is data quality rather than adapter expressiveness. No fine-tuning method fixes bad labels

LoRA vs. QLoRA vs. DoRA vs. QDoRA: The Decision Table

Method Base model Training VRAM Inference overhead Task performance
LoRA Full precision High Zero (merged) Good
QLoRA 4-bit quantized Low Zero (merged) Good, slightly below LoRA
DoRA Full precision High Zero (merged) Better than LoRA on most tasks
QDoRA 4-bit quantized Low Zero (merged) Better than QLoRA when stable

For a self-hoster with a single consumer GPU with limited VRAM, the practical choice is between QLoRA and QDoRA. QDoRA gives better results when the quantization and precision configuration are set up correctly. If you are on a machine with more VRAM headroom, DoRA over a full-precision or float16 base model is the cleanest option.

Conclusion

DoRA is not a new tool. It does not require a new pipeline, a new library, or a different workflow. It is a parameter flag in the fine-tuning stack most self-hosters are already using, and it consistently closes the gap between LoRA and full fine-tuning on the tasks where that gap shows up most visibly.

The one-line change costs a few extra minutes of training time. On structured-output tasks, the improvement in error rate has been large enough to matter in our production pipelines. We now default to DoRA for new fine-tuning runs unless there is a specific reason not to, and the reasons not to are mostly about toolchain compatibility rather than the method itself.

If you are already running LoRA fine-tuning and have not tried use_dora=True, it is the lowest-effort experiment you can run to find out whether you left performance on the table.


Get the next one

I send one short email a week: one production bug, debugged, plus the
companion code for each deep-dive. No spam, unsubscribe anytime.

👉 Subscribe (free)

Reader challenge: re-run your last LoRA fine-tune with use_dora=True and reply with the eval delta. The best comparison lands in the next post.

Sources

  1. Liu et al., "DoRA: Weight-Decomposed Low-Rank Adaptation": https://arxiv.org/abs/2402.09353
  2. DoRA project page with benchmark results across LLaMA, LLaVA, SDXL: https://nbasyl.github.io/DoRA-project-page/
  3. Hugging Face PEFT documentation, use_dora parameter: https://huggingface.co/docs/peft/conceptual_guides/lora

About the Author

Toc Am

Founder of AmtocSoft. Writing practical deep-dives on AI engineering, cloud architecture, and developer tooling. Previously built backend systems at scale. Reviews every post published under this byline.

LinkedIn X / Twitter

Published: 2026-07-27 · Written with AI assistance, reviewed by Toc Am.

Get These In Your Inbox

Weekly deep-dives on AI engineering, no fluff. Join the newsletter →

Subscribe (free)

Or grab the book ($39, ~100 pages) · Buy me a coffee

Buy Me a Coffee · 🔔 YouTube · 💼 LinkedIn · 🐦 X/Twitter

AI as Infrastructure: Value Moves Up-Stack

For a few years the AI conversation was about who had the biggest model. That is the wrong altitude now. Models still matter, the way CPUs s...