File size: 8,775 Bytes
7b74a68
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24819f9
387e2ff
5304001
387e2ff
7b74a68
 
 
 
 
 
 
 
 
757ed9b
7b74a68
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5304001
 
7b74a68
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
387e2ff
24819f9
 
7b74a68
387e2ff
 
7b74a68
 
 
 
 
 
 
 
5304001
 
 
 
ac20c52
5304001
ac20c52
 
 
5304001
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7b74a68
24819f9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7b74a68
 
 
 
 
 
 
 
 
 
 
 
 
ac20c52
5304001
ac20c52
7b74a68
 
 
 
 
ac20c52
 
7b74a68
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
---
license: mit
language:
- en
library_name: cite
tags:
- cite
- paragraph-citation
- retrieval
- question-answering
- news
- encoder
- modernbert
- on-device
pipeline_tag: text-ranking
base_model: jhu-clsp/ettin-encoder-32m
---

# Cite

Which paragraphs answer the question.

A small encoder that, given a question and ordered paragraph texts, returns a
calibrated probability per paragraph and two thresholds (`needed`,
`contributing`).

| | |
| --- | --- |
| **Task** | Paragraph citation / needed-blocks |
| **Size** | ~42 MB weights (trimmed vocab, int8 embeddings, fp16 rest); a larger 68M build is under [`68m/`](https://huggingface.co/localangle/cite/tree/main/68m) |
| **Runtime** | CPU (PyTorch); browser (ONNX Runtime WASM) |
| **Weights** | [v0.3.0](https://huggingface.co/localangle/cite/tree/v0.3.0) · [`web/`](https://huggingface.co/localangle/cite/tree/main/web) for WASM |
| **Demo** | [localangle/cite-demo](https://huggingface.co/spaces/localangle/cite-demo) (in-browser) |

Cite does **not** write an answer. It ranks the paragraphs you already have and
flags which ones are needed to answer the question.

> Question: *Who voted against the library renovation?*
> → Cite highlights the paragraph naming Paulsen and Chen.

## Try it

- **Live demo:** [localangle/cite-demo](https://huggingface.co/spaces/localangle/cite-demo) — paste an article, ask a question, see which paragraphs Cite cites.
- **Local playground:** from the [cite](https://github.com/localangle/cite) repo, `uv run python deploy/cite_playground.py`.

## Install

```bash
pip install "cite @ git+https://github.com/localangle/cite.git"
# or, from a checkout:
uv sync
```

Python ≥ 3.12. Needs `torch`, `transformers`, `safetensors`, `huggingface_hub`.

## Usage

```python
from cite import CitePackage

cite = CitePackage.from_pretrained("localangle/cite")

paragraphs = [
    "The council approved a $4.2 million library renovation.",
    "Paulsen and Chen voted against the measure.",
    "Construction starts in March.",
]
scores = cite.predict("Who voted against the renovation?", paragraphs)

for score in scores:
    if score.needed:
        print(score.index, score.probability, paragraphs[score.index])
```

`score.needed` uses the packaged threshold (0.76). `score.contributing` is a
looser relatedness cut (0.17). Logits are Platt-scaled to probabilities at load
time; you do not fit thresholds yourself for the published package.

Load a local folder the same way:

```python
cite = CitePackage.load("path/to/package")  # or from_pretrained("./path")
```

## Files

| File | Contents |
| --- | --- |
| `cite.json` | Format, Platt scaling, thresholds, source metadata |
| `weights.safetensors` | Trimmed encoder + head (int8 token embeddings, fp16 elsewhere) |
| `encoder/` | ModernBERT/Ettin config for the trimmed vocabulary |
| `tokenizer/` | Tokenizer with `[MASK]` as the block marker |
| `web/` | Browser package: `model.onnx` (~36 MB int8), `vocab_map.bin`, `cite-web.json`, `tokenizer/` |
| `68m/` | The 68M build as a full Python package (same layout as the repo root; ~107 MB weights) |
| `web-68m/` | Browser package for the 68M build (`model.onnx` ~88 MB int8) |

PyTorch weights expand to fp32 at load; only storage is compressed. The `web/`
ONNX graph is what the static demo runs in WASM (soft cap 2048 tokens).

## Accuracy

On a held-out real-news needed-blocks eval (v59, 1,800 questions over local
news outlets; threshold chosen on a separate calibration split):

| Metric | Cite-32M package |
| --- | --- |
| Coverage | 0.692 |
| Exact set match | 0.725 |
| Empty-set accuracy | 0.897 |
| Any-extra rate | 0.066 |

The browser ONNX build on the same eval: exact 0.722, coverage 0.687, empty 0.902.

### Broad and abstract questions

The eval above uses specific fact-lookup questions. Later versions add training so that broad
questions ("How will the library change as a result of the renovations?") score the paragraph
that answers them instead of washing out.

- **v0.2.0** fine-tuned on LLM-written broad rewrites of real training questions.
- **v0.3.0** adds synthetic short articles where nearly every paragraph shares the topic word,
  with hard negatives, then averages the weights 0.75/0.25 with v0.2.0 to keep the specific-question
  operating point.

On a 117-item hand-written probe (specific question vs. an abstract rewrite of it; 92 items after
removing 25 where several paragraphs plausibly answer the rewrite), abstract-question results:

| Abstract question | v0.1.0 | v0.2.0 | v0.3.0 |
| --- | --- | --- | --- |
| Top-1 (all 117) | 0.650 | 0.744 | 0.752 |
| Needed recall (all 117) | 0.402 | 0.752 | 0.761 |
| Top-1 (92 audited) | 0.826 | 0.848 | 0.859 |
| Top-3 (92 audited) | n/a | 0.957 | 0.978 |

Packaged-model numbers for v0.2.0 and v0.3.0; v0.1.0 from the fp32 student. Specific-question probe
top-1 is 0.949 (v0.2.0) and 0.966 (v0.3.0). The probe is small and written by one author;
treat it as a regression check, not a benchmark.

On a short, topic-saturated example article, v0.3.0 improves but does not solve the hardest
case: for "What specific improvements will be made...?" the improvements paragraph moves from
rank 4 (18%) to rank 2 (60%), still behind a funding paragraph that repeats the word
"renovation". See `experiments/107-cite-abstract-probe`, `experiments/108-cite-broad-consistency`
and `experiments/109-cite-hard-negatives`.

## Cite 68M

A larger build, distilled from the same teacher with the same data and training steps, on
[Ettin-68M](https://huggingface.co/jhu-clsp/ettin-encoder-68m). It is more accurate, especially on broad
and abstract questions, and about 3x slower. The 32M build above stays the default.

```python
cite = CitePackage.from_pretrained("localangle/cite", subfolder="68m")
```

The [demo](https://huggingface.co/spaces/localangle/cite-demo) has a model switch; the 68M build downloads
about 88 MB on first use and is cached after that. Packaged thresholds: needed 0.79, contributing 0.17.

| | Cite 32M (v0.3.0) | Cite 68M |
| --- | --- | --- |
| Weights (package) | 41.8 MB | 107.0 MB |
| Browser `model.onnx` | 36.3 MB | 88.3 MB |
| v59 eval: coverage / exact / empty-set / any-extra rate | 0.692 / 0.725 / 0.897 / 0.066 | 0.729 / 0.763 / 0.923 / 0.042 |
| Abstract probe, 92 audited items: top-1 / needed recall | 0.859 / 0.859 | 0.902 / 0.891 |
| Abstract probe, all 117: top-1 | 0.752 | 0.786 |
| Browser (WASM, 1 thread): 7 paragraphs / 44 paragraphs | 185 ms / 1.4 s | 547 ms / 4.2 s |

On the short example article, for "What specific improvements will be made to the library as a result of the
renovation?", the improvements paragraph scores 93.5% on 68M (60% on 32M, behind a funding paragraph).
The same caveats apply: one run per size, a small hand-written probe, and browser timings from a single
machine. See `experiments/110-cite-68m`.

## Latency

Batch-1, CPU, AMD EPYC (HF `cpu-upgrade`, 8 vCPU) for the related ONNX int8
form: about 44 ms p50 at 5–12 paragraphs and 268 ms at 33–60. The portable
PyTorch package this repo ships is the accuracy-matched serving artifact; long
documents are dominated by attention cost.

## How it works

Cite is a fine-tuned [Ettin](https://huggingface.co/jhu-clsp/ettin-encoder-32m)
encoder (ModernBERT family). The question and each paragraph are packed into
one sequence with a `[MASK]` marker before every paragraph; the head reads the
marker states. A 400M teacher labeled real news; the 32M student was distilled
then fine-tuned on LLM-written broad rewrites of its training questions
(consistency distillation) and on synthetic hard-negative articles, and compressed (trimmed vocabulary, int8
embeddings, fp16 weights).

## Limitations

- Cite ranks paragraphs; it does not generate answers or quote spans inside a
  paragraph.
- Lexical overlap still matters: "big 10" and "Big Ten" can score differently.
  Scores for a paragraph also drift down in very long articles.
- Eval is English news-style text. Other domains are unmeasured.
- Empty-set accuracy drops a few points from calibration to eval; treat the
  packaged threshold as a starting point for a new domain.
- Documents longer than `max_tokens` (7999) drop trailing paragraphs.
- Training used private teacher labels and news text that is not redistributed
  here.

## Built on

- [Ettin encoder 32M](https://huggingface.co/jhu-clsp/ettin-encoder-32m) (MIT)
  by JHU CLSP. Fine-tuned and compressed for Cite; see
  `THIRD_PARTY_NOTICES.md`.

## License

MIT for the Cite code and this package's weights, subject to the Ettin MIT
notice above.

## Citation

```bibtex
@software{cite_2026,
  title  = {Cite: Which paragraphs answer the question},
  author = {Local Angle},
  year   = {2026},
  url    = {https://huggingface.co/localangle/cite},
}
```