LettucePrevent (Ettin-Decoder-68M): Token-Level Hallucination Detection for Generation-Time Prevention

LettucePrevent

Model Name: lettuceprevent-ettin-decoder-68m-en
Author: Leon Beccard
GitHub: https://github.com/lebe1/LettucePrevent
Thesis: Real-time Prevention of Factual Hallucinations in Retrieval-Augmented Generation
Base decoder: jhu-clsp/ettin-decoder-68m (Ettin paper)
Custom generate method: lebe1/lettuceprevent-generate

Overview

LettucePrevent is a 68M-parameter decoder with a token-classification head, fine-tuned to flag hallucinated tokens in RAG answers while they are being generated, rather than after the fact.

Existing hallucination detection models are encoder-based and bidirectional: they are trained on complete answers but must operate on incomplete prefixes when placed inside a decoding loop. This mismatch motivates a causal architecture. A decoder processes its input left-to-right, so the prediction at position i is exactly the prediction it would emit if invoked on the prefix ending at i-1 during streaming inference.

The model is designed to be called at every decoding step of a host LLM, where its per-token hallucination probability is used to penalise candidate tokens in the logits distribution before the next token is sampled. Parameter count is matched to tinylettuce-ettin-68m-en so that detection quality and runtime can be compared on equal footing.

Model Details

  • Architecture: Ettin decoder (68M) backbone + dropout (p=0.1) + linear token-classification head (2 output units)
  • Task: token classification (0 = supported, 1 = hallucinated)
  • Input format: [CLS] context [SEP] query [SEP] answer [SEP]
  • Max sequence length: 4096
  • Recommended confidence threshold: 0.8 (calibrated, see Results)
  • Language: English
  • License: MIT

Note: the model uses a custom EttinTokenClassifier wrapper (see Usage) and cannot be loaded directly with AutoModelForTokenClassification.

Training Data

  • RAGTruth (English), span-level annotations, via the wandb/RAGTruth-processed release
  • Fine-tuning uses the training split only (2,514 context/query pairs); the 450-pair test split is held out for all downstream evaluation

Labels are derived by projecting the character-level span mask onto the token grid. To match inference conditions, answers are first segmented with the Llama-3.1-8B tokenizer, each chunk is decoded back to text and re-tokenized with the Ettin tokenizer, and the hallucination label is assigned per chunk under an any-overlap rule. Special tokens, context and query positions are masked with the ignore index -100, so the loss is computed on answer tokens only.

Training Procedure

Hallucinated tokens make up under 4 % of answer positions, so a class-weighted cross-entropy loss with inverse-frequency weights w_c = N / (2 * n_c) keeps the minority class from being collapsed away by the optimiser. Loss is computed with ignore_index=-100.

  • Backbone left unfrozen so causal representations adapt to the streaming-prefix regime
  • Classification head initialised from N(0, 0.01), bias 0
  • Epochs: 6, early stopping with patience 2, evaluation and checkpointing every epoch
  • Best checkpoint selected on eval/f1_binary_class_1
  • Gradient checkpointing enabled, use_cache=False, gradient clipping at max norm 1.0
  • Mixed precision (fp16)
  • Seed 42 across Python / NumPy / PyTorch / Trainer
  • Hardware: NVIDIA A100

Selected configuration from a 12-run W&B grid sweep over learning rate [1e-6, 5e-6, 1e-5], batch size [4, 8] and weight decay [0.01, 0.05]:

Hyperparameter Value
Learning rate 1e-6
Batch size 4
Weight decay 0.01
Warmup ratio 0.1

Results

Detector comparison under streaming inference

Evaluated on the RAGTruth test split with per-model threshold calibration. LettucePrevent attains the highest hallucination-class F1 and recall of the candidate pool, at the lowest cumulative runtime.

Model Params Threshold F1 (class 1) Runtime (s)
lettuceprevent-ettin-decoder-68m 68M 0.8 0.3974 1247.89
lettucedetect-base-modernbert-en-v1 149M 0.6 0.3598 1989.76
tinylettuce-ettin-68m-en 68M 0.5 0.3010 1411.75

These numbers are not comparable to standard full-answer RAGTruth benchmarks: they are measured on incomplete prefixes, which is a strictly harder setting.

Downstream prevention results

Hallucinated spans per text over 450 evaluation prompts, each generator paired with its tuned skip threshold and this detector, run on NVIDIA A100s.

Generator Baseline LettucePrevent Relative change
Qwen2.5 14B Instruct 1,808 1,741 −3.71 %
Mistral 7B Instruct v0.2 2,232 2,269 +1.66 %
Llama-2 7B 2,837 2,082 −26.61 %

The reduction is substantial on Llama-2-7B and essentially flat on the other two hosts at their tuned operating points. Broad factual prevention remains bounded by detector quality, tokenizer alignment and host variability.

Intended Use and Limitations

  • Intended for token-level, generation-time hallucination detection in RAG pipelines, particularly as the signal source for a logits processor that suppresses unsupported tokens.
  • Precision on the hallucination class is low by design. The operating point favours recall, since the prevention mechanism can only suppress what the detector flags.
  • Invoking the detector at every decoding step adds significant runtime overhead; a skip threshold on the generator's own confidence can shorten the loop on confident steps.
  • Trained on English RAGTruth only. Behaviour on other domains, languages or answer styles is untested. RAGTruth dataset is not optimized for fine-tuning on generation-time hallucination detection than rather for post-hoc detection.
  • Label alignment assumes a Llama-style tokenizer on the host model; tokenizer mismatch degrades performance.
  • Not suitable as a standalone post-hoc detector — for that, use LettuceDetect, which is trained for the full-answer setting.

Usage

Simple approach

The provided custom_generate method lets you easily play around with the whole framework used for the lettuceprevent model.

from transformers import AutoModelForCausalLM, AutoTokenizer

model_id = "mistralai/Mistral-7B-Instruct-v0.2"
tok   = AutoTokenizer.from_pretrained(model_id)
model = AutoModelForCausalLM.from_pretrained(model_id, device_map="auto",
                                             trust_remote_code=True)

# For instruction-tuned models, always apply the chat template
context  = "Revenue was 2400 million in 2021 and 3100 million in 2022."
question = "What is the percentage increase in revenue from 2021 to 2022?"
text     = tok.apply_chat_template(
               [{"role": "user", "content": f"{context}\n{question}"}],
               tokenize=False, add_generation_prompt=True)
inputs   = tok(text, return_tensors="pt").to(model.device)

out = model.generate(
    **inputs,
    custom_generate="lebe1/lettuceprevent-generate",    # or a local path
    trust_remote_code=True,
    tokenizer=tok,
    input_text=context,                                 # grounding context for the detector
    detector_type="lettuceprevent",                     # or "number"
    skip_threshold=0.9,                                 # important parameter, which skips the HDM check based on top-token probability
    max_new_tokens=300,
)
print(tok.decode(out[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True))

Complex approach

The model wraps the Ettin backbone in a custom token-classification head, so it is loaded through the class below rather than AutoModelForTokenClassification.

import torch
import torch.nn as nn
from transformers import AutoTokenizer, AutoModel, AutoConfig, PreTrainedModel
from transformers.modeling_outputs import TokenClassifierOutput

MODEL_ID = "lebe1/lettuceprevent-ettin-decoder-68m-en"
LLAMA_TOKENIZER = "meta-llama/Llama-3.1-8B"
THRESHOLD = 0.8


class EttinTokenClassifier(PreTrainedModel):
    def __init__(self, config, num_labels: int = 2):
        super().__init__(config)
        self.num_labels = num_labels
        self.backbone = AutoModel.from_config(config)
        self.dropout = nn.Dropout(p=0.1)
        self.classifier = nn.Linear(config.hidden_size, num_labels)
        # placeholder so the training-time buffer loads cleanly at inference
        self.register_buffer("class_weights", torch.ones(num_labels))
        self.post_init()

    def forward(self, input_ids=None, attention_mask=None, labels=None, **kwargs):
        outputs = self.backbone(input_ids=input_ids, attention_mask=attention_mask)
        logits = self.classifier(self.dropout(outputs.last_hidden_state))
        return TokenClassifierOutput(logits=logits)


tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
llama_tokenizer = AutoTokenizer.from_pretrained(LLAMA_TOKENIZER)

config = AutoConfig.from_pretrained(MODEL_ID)
model = EttinTokenClassifier.from_pretrained(MODEL_ID, config=config)
model.eval()


def tokenize_via_llama_chunks(text):
    """Force Ettin token boundaries to respect Llama token boundaries,
    reproducing the segmentation seen during streaming inference."""
    enc = llama_tokenizer(text, add_special_tokens=False)
    ids = []
    for tok_id in enc["input_ids"]:
        chunk = llama_tokenizer.decode([tok_id])
        if not chunk:
            continue
        ids.extend(tokenizer(chunk, add_special_tokens=False)["input_ids"])
    return ids


context = (
    "Ibuprofen is an NSAID. The typical adult dose is 400-600mg every 6-8 hours, "
    "not exceeding 2400mg daily."
)
query = "What is the maximum daily dose of ibuprofen?"
answer = "The maximum daily dose of ibuprofen for adults is 3200mg."

cls_id, sep_id = tokenizer.cls_token_id, tokenizer.sep_token_id
answer_ids = tokenize_via_llama_chunks(answer)

input_ids = (
    [cls_id]
    + tokenize_via_llama_chunks(context)
    + [sep_id]
    + tokenize_via_llama_chunks(query)
    + [sep_id]
    + answer_ids
    + [sep_id]
)[:4096]

batch = {
    "input_ids": torch.tensor([input_ids]),
    "attention_mask": torch.ones(1, len(input_ids), dtype=torch.long),
}

with torch.no_grad():
    logits = model(**batch).logits

probs = torch.softmax(logits, dim=-1)[0, :, 1]

# answer tokens start after [CLS] context [SEP] query [SEP]
answer_start = len(input_ids) - len(answer_ids) - 1
answer_probs = probs[answer_start : answer_start + len(answer_ids)]

for tok_id, p in zip(answer_ids, answer_probs):
    if p > THRESHOLD:
        print(f"{tokenizer.decode([tok_id])!r}  p={p:.3f}")

Expected output

'320'  p=0.810
'0'  p=0.807
'mg'  p=0.877

Inside the prevention pipeline

In practice the model is consumed through the HallucinationLogitsProcessor of the LettucePrevent repository, which scores the top-k candidate tokens at every decoding step and penalises those predicted to introduce a hallucination:

python main.py \
  --generator-model meta-llama/Llama-3.3-70B-Instruct \
  --detector-type lettuceprevent \
  --skip-threshold 0.99 \
  --n-per-task 20

Citation

@mastersthesis{Beccard:2026,
  title  = {Real-time Prevention of Factual Hallucinations in Retrieval-Augmented Generation},
  author = {Leon Beccard},
  school = {Technische Universität Wien},
  year   = {2026},
  url    = {https://repositum.tuwien.at/handle/20.500.12708/229242}
}

This work builds directly on LettuceDetect and TinyLettuce:

@misc{Kovacs:2025,
  title         = {LettuceDetect: A Hallucination Detection Framework for RAG Applications},
  author        = {Ádám Kovács and Gábor Recski},
  year          = {2025},
  eprint        = {2502.17125},
  archivePrefix = {arXiv},
  primaryClass  = {cs.CL},
  url           = {https://arxiv.org/abs/2502.17125}
}
Downloads last month
158
Safetensors
Model size
68.1M params
Tensor type
F32
·
Inference Providers NEW
This model isn't deployed by any Inference Provider. 🙋 Ask for provider support

Model tree for lebe1/lettuceprevent-ettin-decoder-68m-en

Finetuned
(1)
this model

Dataset used to train lebe1/lettuceprevent-ettin-decoder-68m-en

Papers for lebe1/lettuceprevent-ettin-decoder-68m-en

Article mentioning lebe1/lettuceprevent-ettin-decoder-68m-en