lebe1/lettuceprevent-ettin-decoder-68m-en
LettucePrevent (Ettin-Decoder-68M): Token-Level Hallucination Detection for Generation-Time Prevention
<div align="center"> <img src="https://huggingface.co/lebe1/lettuceprevent-ettin-decoder-68m-en/resolve/main/assets/LettucePrevent.png" alt="LettucePrevent" style="width:40%; height:auto;"> </div>
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-processedrelease - 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]:
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.
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.
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.877Inside 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 20Citation
@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}
}