Zum Hauptinhalt springen
tsecurity.de LIVE
Echtzeit-Radar & Feeds
Alle RSS Feeds
👥 Community & Social
Sicherheitslücken (CVE)USN-8797-1: GStreamer Base Plugins vulnerability(21.09.2026 um 20:05 Uhr)
Sichere ProgrammierungYou can build HTML emails with Tailwind CSS(21.09.2026 um 22:15 Uhr)
Sichere ProgrammierungDEV-Part-1-Backend.md(21.09.2026 um 22:24 Uhr)
Sichere ProgrammierungWhat It Actually Costs to Serve a 1M-Token Model in Production(21.09.2026 um 22:33 Uhr)
Sichere ProgrammierungHow to Check an Agent's Diagnosis Before It Touches Production(21.09.2026 um 22:53 Uhr)
Linux Tipps & HardeningWhat if Spotify was self-hosted? I think I got pretty close.(21.09.2026 um 22:33 Uhr)
Linux Tipps & HardeningSandboxing on Linux(21.09.2026 um 22:45 Uhr)
Sicherheitslücken (CVE)USN-8797-1: GStreamer Base Plugins vulnerability(21.09.2026 um 20:05 Uhr)
Sichere ProgrammierungYou can build HTML emails with Tailwind CSS(21.09.2026 um 22:15 Uhr)
Sichere ProgrammierungDEV-Part-1-Backend.md(21.09.2026 um 22:24 Uhr)
Sichere ProgrammierungWhat It Actually Costs to Serve a 1M-Token Model in Production(21.09.2026 um 22:33 Uhr)
Sichere ProgrammierungHow to Check an Agent's Diagnosis Before It Touches Production(21.09.2026 um 22:53 Uhr)
Linux Tipps & HardeningWhat if Spotify was self-hosted? I think I got pretty close.(21.09.2026 um 22:33 Uhr)
Linux Tipps & HardeningSandboxing on Linux(21.09.2026 um 22:45 Uhr)
Intelligence View
⚡ tsecurity.de Intelligence

Citations That Survive a TypeScript Refactor

Book: AI That Reads The series: AI in TypeScript — 5 books, from your first LLM call to agents in production — all five here My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools…

0
↗ Quelle (dev.to)
Reagiere als Erste:r — dein Feedback zählt!





The first version of citations in every RAG app is the same. You

number the retrieved chunks, ask the model to cite by number, and

render the numbers as links.




const context = chunks
.map((c, i) => `[${i + 1}] ${c.text}`)
.join("\n\n");
// model returns: "Keys rotate every 90 days [2]."
const cited = chunks[Number(match[1]) - 1];






It works immediately, which is why it survives to production. It

breaks the first time anything upstream changes, and it breaks

silently: citation [2] still resolves, still renders, still links.

It just points at a different chunk than it did yesterday.





What the index is actually keyed to



[2] means "the second element of the array I built for this one

request." That array is a function of the query, the embedding model,

the chunker, the index, and the ranking weights.



Change your chunk size and every position shifts. Add a reranker and

the order changes by design. Re-embed with a new model and the whole

result set is different. Any of those is a routine improvement, and

each one silently repoints every stored citation.



If you persist the answer — and most products do, in a conversation

history — then [2] is now a pointer into an array that no longer

exists. It renders as a link to whatever occupies position two today.



The problem is not that the number is fragile. It is that the number

identifies a position in a transient list rather than a piece of

content
.





Key to the content





export type SourceRef = {
docId: string; // stable across re-chunking
contentHash: string; // sha256 of the normalised chunk text
start: number; // char offset into the source document
end: number;
revision: string; // document version at retrieval time
};





Each field earns its place.



docId survives everything. Re-chunk, re-embed, re-rank — the

document is still the document.



contentHash identifies the exact text. If the chunk still exists

anywhere in the corpus, this finds it regardless of which chunk index

it landed in this time.



start/end locate the passage inside the document, so you can

highlight it even if the chunking changed and the hash no longer

matches any current chunk. This is the field that makes citations

degrade gracefully instead of vanishing.



revision records which version of the document was actually read.

Without it, a citation to a paragraph that has since been rewritten

points at text that never supported the claim.



The chunk id itself is deliberately absent. Chunk ids are an artifact

of the ingest run, and a re-ingest regenerates them.



A citation keyed to position breaking on re-chunk, versus one keyed to document and offsets.





Make an uncited claim not compile



Once refs are stable, the type system can enforce the rule that

matters: every factual sentence carries a source.




export type Cited<T> = { value: T; sources: [SourceRef, ...SourceRef[]] };

export type AnswerSpan =
| { kind: "prose"; text: string }
| { kind: "claim"; text: string; sources: [SourceRef, ...SourceRef[]] };

export type Answer = { spans: AnswerSpan[] };






The non-empty tuple type [SourceRef, ...SourceRef[]] is the whole

mechanism. A claim with sources: [] does not type-check. You

cannot construct one by accident, and you cannot forget to populate

it in a code path added later.



Splitting spans into prose and claim matters because not every

sentence is a factual assertion. "Here is how to rotate a key:" needs

no citation. "Keys expire after 90 days" does. A model that must cite

every sentence produces citation spam; a type that distinguishes the

two lets you require it exactly where it belongs.





Getting refs out of the model



The model cannot emit a contentHash — it never sees one, and asking

it to copy a hex string is asking for a transcription error.



Give it short opaque labels and resolve them yourself.




const labelled = candidates.map((c, i) => ({
label: `s${i + 1}`,
ref: c.ref,
text: c.text,
}));

const byLabel = new Map(labelled.map((l) => [l.label, l.ref]));

const context = labelled
.map((l) => `<source id="${l.label}">\n${l.text}\n</source>`)
.join("\n\n");






The label is scoped to this one request and never leaves it. On the

way back you translate:




const Raw = z.object({
spans: z.array(z.discriminatedUnion("kind", [
z.object({ kind: z.literal("prose"), text: z.string() }),
z.object({
kind: z.literal("claim"),
text: z.string(),
sources: z.array(z.string()).min(1),
}),
])),
});

export function resolve(raw: z.infer<typeof Raw>): Answer {
return {
spans: raw.spans.map((s) => {
if (s.kind === "prose") return s;
const refs = s.sources.map((label) => {
const ref = byLabel.get(label);
if (!ref) throw new UnknownSourceLabel(label);
return ref;
});
return { ...s, sources: refs as [SourceRef, ...SourceRef[]] };
}),
};
}






UnknownSourceLabel is worth throwing rather than dropping. A model

inventing s9 when you supplied six sources is a hallucinated

citation, and that is exactly the thing you built this to catch. Log

it as a distinct metric — the rate tells you something real about

your prompt.





Rendering a citation whose target moved



Storage is only half of it. The render path decides what a reader

sees when the source has changed since the answer was written.




export type Resolution =
| { status: "exact"; chunk: Chunk }
| { status: "moved"; doc: Doc; start: number; end: number }
| { status: "stale"; doc: Doc; revision: string }
| { status: "gone" };

export async function resolveRef(ref: SourceRef): Promise<Resolution> {
const byHash = await findChunkByHash(ref.contentHash);
if (byHash) return { status: "exact", chunk: byHash };

const doc = await findDoc(ref.docId);
if (!doc) return { status: "gone" };

if (doc.revision !== ref.revision) {
return { status: "stale", doc, revision: ref.revision };
}
return { status: "moved", doc, start: ref.start, end: ref.end };
}






Four outcomes, four renderings. exact is a normal link. moved

links to the document with the offsets highlighted — the chunking

changed but the text is where it was. stale links but marks the

citation as pointing to an older revision. gone renders as

non-clickable with a note that the source was removed.



A discriminated union here is doing real work: the UI cannot forget a

case, because a switch without a branch fails the exhaustiveness

check.




function render(r: Resolution) {
switch (r.status) {
case "exact": return <Link chunk={r.chunk} />;
case "moved": return <DocLink doc={r.doc} range={[r.start, r.end]} />;
case "stale": return <DocLink doc={r.doc} badge="older revision" />;
case "gone": return <span title="source removed">[source]</span>;
default: return assertNever(r);
}
}






Four resolution outcomes rendering differently instead of a broken link.






The test that would have caught it



The bug this post is about never shows up in a unit test of the

retrieval function. It shows up across a re-ingest, which is why

nobody tests it.




it("keeps citations resolvable after re-chunking", async () => {
const answer = await ask("How often do API keys rotate?");
await reingest({ chunkSize: 600 }); // was 1200

for (const span of answer.spans) {
if (span.kind !== "claim") continue;
for (const ref of span.sources) {
const r = await resolveRef(ref);
expect(r.status).not.toBe("gone");
}
}
});






Answer a question, change the chunk size, re-ingest, and assert every

citation still resolves to something. With index-based citations this

fails immediately. With content-keyed refs it passes, which is the

entire point.






The rule



A citation is a reference to content, not to a slot in a list you

built for one request. Key it to the document and the text, record

where it was and which revision you read, and let the render path

decide how to degrade.



Then re-chunking becomes an ingest change instead of a data-integrity

event.









If this was useful



AI That Reads covers grounding

and citation properly — how to carry provenance through retrieval,

what to persist alongside an answer, and how to keep sources

resolvable as the corpus moves underneath you.



AI That Reads — RAG in TypeScript



The full series is at

xgabriel.com/ai-in-typescript.

Zum Aktualisieren ziehen
ZERO-DAY CVE-2026-45381 | Tautulli is a Python based monitoring and tracking tool for Plex Media S…
Advisory →
TTS Reader • tsecurity.de Voice
tsecurity.de Icon
tsecurity.de App
Offline-Lesen, Eilmeldungen & 0ms Ladezeit

Installiere tsecurity.de direkt auf deinen Home-Bildschirm für das ultimative Vollbild-Magazinerlebnis ohne Browser-Leisten.

Nächster Beitrag
Themen-Radar & Intelligence Matrix
Echtzeit-Taxonomie nach Angriffsvektoren & Plattformen

tsecurity.de Live Threat Radar

🔴 LIVE RADAR
MONITORING
AKTIV
CVE-DATENBANK
LIVE
🔍
Community Radar & Live Chat
Sentinel Bot online • Live-Stream
Dein Cluster: Security Explorer
Match:
lädt…
Verbindung zum Community-Stream wird aufgebaut...
Bearbeitungsmodus — Senden überschreibt deine Nachricht
Community-Puls — was gerade passiert
lädt…
Aktivitäten deiner Analysten
lädt…
Neues Thema oder Eilmeldung einreichen

Reiche interessante Links, Zero-Days oder Debatten ein. Die Community entscheidet per Upvote über die Veröffentlichung.

Heiß diskutierte Einreichungen
🔖 Gespeicherte Artikel
📂 Keine gespeicherten Artikel vorhanden.
Zurück Ziehen Vor
Links: vorheriger Artikel Rechts: nächster Artikel unten: schließen
News NIS-2 Frühwarnung Tier-1 Intel ⏱️ 3 Min vor 10 Min
Artikeldaten werden geladen...

Zurück: vorheriger Vor: nächster
↗ Original-Quelle
Social Reaktionen Deine Reaktion zählt
Einstufung & Relevanz-Poll 0 Stimmen
In sozialen Netzwerken teilen 1-Klick