LettucePrevent (Ettin-Decoder-68M): Token-Level Hallucination Detection for Generation-Time Prevention
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.
1from transformers import AutoModelForCausalLM, AutoTokenizer
2
3model_id = "mistralai/Mistral-7B-Instruct-v0.2"
4tok = AutoTokenizer.from_pretrained(model_id)
5model = AutoModelForCausalLM.from_pretrained(model_id, device_map="auto",
6 trust_remote_code=True)
7
8# For instruction-tuned models, always apply the chat template
9context = "Revenue was 2400 million in 2021 and 3100 million in 2022."
10question = "What is the percentage increase in revenue from 2021 to 2022?"
11text = tok.apply_chat_template(
12 [{"role": "user", "content": f"{context}\n{question}"}],
13 tokenize=False, add_generation_prompt=True)
14inputs = tok(text, return_tensors="pt").to(model.device)
15
16out = model.generate(
17 **inputs,
18 custom_generate="lebe1/lettuceprevent-generate", # or a local path
19 trust_remote_code=True,
20 tokenizer=tok,
21 input_text=context, # grounding context for the detector
22 detector_type="lettuceprevent", # or "number"
23 skip_threshold=0.9, # important parameter, which skips the HDM check based on top-token probability
24 max_new_tokens=300,
25)
26print(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.
1import torch
2import torch.nn as nn
3from transformers import AutoTokenizer, AutoModel, AutoConfig, PreTrainedModel
4from transformers.modeling_outputs import TokenClassifierOutput
5
6MODEL_ID = "lebe1/lettuceprevent-ettin-decoder-68m-en"
7LLAMA_TOKENIZER = "meta-llama/Llama-3.1-8B"
8THRESHOLD = 0.8
9
10
11class EttinTokenClassifier(PreTrainedModel):
12 def __init__(self, config, num_labels: int = 2):
13 super().__init__(config)
14 self.num_labels = num_labels
15 self.backbone = AutoModel.from_config(config)
16 self.dropout = nn.Dropout(p=0.1)
17 self.classifier = nn.Linear(config.hidden_size, num_labels)
18 # placeholder so the training-time buffer loads cleanly at inference
19 self.register_buffer("class_weights", torch.ones(num_labels))
20 self.post_init()
21
22 def forward(self, input_ids=None, attention_mask=None, labels=None, **kwargs):
23 outputs = self.backbone(input_ids=input_ids, attention_mask=attention_mask)
24 logits = self.classifier(self.dropout(outputs.last_hidden_state))
25 return TokenClassifierOutput(logits=logits)
26
27
28tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
29llama_tokenizer = AutoTokenizer.from_pretrained(LLAMA_TOKENIZER)
30
31config = AutoConfig.from_pretrained(MODEL_ID)
32model = EttinTokenClassifier.from_pretrained(MODEL_ID, config=config)
33model.eval()
34
35
36def tokenize_via_llama_chunks(text):
37 """Force Ettin token boundaries to respect Llama token boundaries,
38 reproducing the segmentation seen during streaming inference."""
39 enc = llama_tokenizer(text, add_special_tokens=False)
40 ids = []
41 for tok_id in enc["input_ids"]:
42 chunk = llama_tokenizer.decode([tok_id])
43 if not chunk:
44 continue
45 ids.extend(tokenizer(chunk, add_special_tokens=False)["input_ids"])
46 return ids
47
48
49context = (
50 "Ibuprofen is an NSAID. The typical adult dose is 400-600mg every 6-8 hours, "
51 "not exceeding 2400mg daily."
52)
53query = "What is the maximum daily dose of ibuprofen?"
54answer = "The maximum daily dose of ibuprofen for adults is 3200mg."
55
56cls_id, sep_id = tokenizer.cls_token_id, tokenizer.sep_token_id
57answer_ids = tokenize_via_llama_chunks(answer)
58
59input_ids = (
60 [cls_id]
61 + tokenize_via_llama_chunks(context)
62 + [sep_id]
63 + tokenize_via_llama_chunks(query)
64 + [sep_id]
65 + answer_ids
66 + [sep_id]
67)[:4096]
68
69batch = {
70 "input_ids": torch.tensor([input_ids]),
71 "attention_mask": torch.ones(1, len(input_ids), dtype=torch.long),
72}
73
74with torch.no_grad():
75 logits = model(**batch).logits
76
77probs = torch.softmax(logits, dim=-1)[0, :, 1]
78
79# answer tokens start after [CLS] context [SEP] query [SEP]
80answer_start = len(input_ids) - len(answer_ids) - 1
81answer_probs = probs[answer_start : answer_start + len(answer_ids)]
82
83for tok_id, p in zip(answer_ids, answer_probs):
84 if p > THRESHOLD:
85 print(f"{tokenizer.decode([tok_id])!r} p={p:.3f}")
Expected output
1'320' p=0.810
2'0' p=0.807
3'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:
1python main.py \
2 --generator-model meta-llama/Llama-3.3-70B-Instruct \
3 --detector-type lettuceprevent \
4 --skip-threshold 0.99 \
5 --n-per-task 20
Citation
1@mastersthesis{Beccard:2026,
2 title = {Real-time Prevention of Factual Hallucinations in Retrieval-Augmented Generation},
3 author = {Leon Beccard},
4 school = {Technische Universität Wien},
5 year = {2026},
6 url = {https://repositum.tuwien.at/handle/20.500.12708/229242}
7}
This work builds directly on LettuceDetect and TinyLettuce:
1@misc{Kovacs:2025,
2 title = {LettuceDetect: A Hallucination Detection Framework for RAG Applications},
3 author = {Ádám Kovács and Gábor Recski},
4 year = {2025},
5 eprint = {2502.17125},
6 archivePrefix = {arXiv},
7 primaryClass = {cs.CL},
8 url = {https://arxiv.org/abs/2502.17125}
9}