CoolFace
Modelpublic

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

sourceHugging Facemitupdated 2mo agoView on Hugging Face
3likes27kdownloads
Model Card

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-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]:

HyperparameterValue
Learning rate1e-6
Batch size4
Weight decay0.01
Warmup ratio0.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.

ModelParamsThresholdF1 (class 1)Runtime (s)
lettuceprevent-ettin-decoder-68m68M0.80.39741247.89
lettucedetect-base-modernbert-en-v1149M0.60.35981989.76
tinylettuce-ettin-68m-en68M0.50.30101411.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.

GeneratorBaselineLettucePreventRelative change
Qwen2.5 14B Instruct1,8081,741−3.71 %
Mistral 7B Instruct v0.22,2322,269+1.66 %
Llama-2 7B2,8372,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.

python
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.

python
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

bash
'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:

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

Citation

bibtex
@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:

bibtex
@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}
}