Skip to content

feat(citation): cite a source span, not the whole retrieved chunk - #162

Merged
mrsibe merged 4 commits into
mainfrom
feat/155-exact-passage-citation
Sep 28, 2026
Merged

mrsibe merged 4 commits into
mainfrom
feat/155-exact-passage-citation

Conversation

@mrsibe

@mrsibe mrsibe commented Sep 28, 2026

Copy link
Copy Markdown
Owner

Stacked on #161 (refactor/160-v15-seams), because this uses the CitationCandidate contract from #160. Base will retarget to main once #161 merges.

What does this PR do?

Makes a citation point at the span that supports the claim, not at the whole retrieved chunk.

Why?

Today a citation's evidence is the entire chunk: citationFromSearchResult() set quote: result.content and startOffset/endOffset to the union of the blocks the chunk covered. Clicking [3] quoted ~500 characters and highlighted every block the chunk touched.

Aligning a citation to a claim after the answer is not solvable deterministically — deciding that "Transformer 不需要 RNN 结构" is supported by "Transformers eliminate recurrent connections" is semantic entailment (NLI), not substring/token/embedding matching. So the spans are produced before the answer, and the model selects among them by index.

Related issue

Fixes #155
Related to #154

What changed

  • buildCitationCandidates() (src/main/services/citationCandidates.ts) turns each retrieved chunk into sentence-sized candidate spans:
    • sentence segmentation is deliberately conservative (3.14, U.S., Fig. 3, et al. do not split);
    • short neighbouring sentences merge (at most 3 sentences / ~80 chars) so a claim that needs two sentences is still citable;
    • candidates are numbered 1..M continuously across chunks — this number is the [n] the model writes and Citation.index carries, so the user-visible marker protocol is unchanged;
    • a chunk with no block provenance falls back to one whole-chunk candidate with no span (never worse than before).
  • buildRAGContext() presents candidates grouped by source/chunk with globally continuous markers, and builds each Citation from its candidate, so quote / page / blockId / startOffset / endOffset describe the supporting passage.
  • chatHandlers takes citationContexts from buildRAGContext instead of re-deriving the span from the chunk.

What is unchanged by design: [n], citationMarkers, resolveCitations, citationToSourceAnchor, Citation.index — the [n] → citation → reader chain is the same; only what [n] resolves to is finer.

How was this tested?

  • npm run typecheck — passes.
  • npm test — 352 pass, 0 fail (new test/citationCandidates.test.ts, rewritten test/citations.test.ts).
    • pinned: a candidate's quote is a verbatim slice of the source at its offsets (what the reader highlights);
    • pinned: grouping does not over-split (A causes B. So the result is C. stays one candidate);
    • pinned: global numbering across chunks and the blockless fallback.
  • npm run build — passes.
  • Eval baseline: RAG harness output is byte-identical to docs/eval/baseline-v1.4.json (retrieval is untouched by this change).

Not verified / scoped out

  • Live-model behaviour. The prompt contract changed (the model now cites candidate spans), so the citation-marking rate should be watched against a real endpoint before v1.5 ships. No model was run here.
  • Sub-sentence highlight in the PDF viewer. TextSourceReader highlights startOffset..endOffset exactly; PdfSourceReader highlights the containing paragraph (previously the chunk's first block). Mapping the parser's offsets onto the pdfjs text layer is a separate change.

Checklist

  • I have reviewed my own changes.
  • npm run typecheck passes.
  • npm run build passes.
  • I have tested the affected user workflow. (Unit + eval; live-model pass noted above.)
  • I have not included unrelated changes.
  • I have updated documentation when necessary. (Contracts carry the rationale inline.)

Desktop / build changes

  • Not applicable

…n seams

Introduce the contracts v1.5 builds on, with no user-visible behavior change.

- Retriever.search() takes a RetrievalRequest and returns a RetrievalResult
  carrying a RetrievalTrace (strategy, scope, topK, threshold, durationMs).
  KnowledgeService gains retrieve() and keeps search()'s legacy shape as a
  delegate over it.
- A requested source filter is refused until #94 implements pre-KNN filtering,
  instead of being silently ignored. Remove the dead, never-read
  VectorStore.QueryOptions.filter, which declared a capability that did nothing.
- Declare CitationCandidate for #155: the cited span exists before the answer,
  so no post-hoc (and non-deterministic) semantic alignment is needed.
- Declare ingestion run/stage and the source/index state split for #95.

Verified: npm run typecheck; npm test (345 pass); the RAG eval harness
reproduces docs/eval/baseline-v1.4.json byte-for-byte.
…splay id

The candidate's identity in the answer is the same [n] the model writes and
Citation.index carries; a string id like 'S2' would put a UI format into the
domain model. blockId is optional because not every source has block structure.
…no blocks

A chunk indexed before block provenance, or from a source that produces no
blocks, has no span to point at. The fallback keeps the whole-chunk quote, so
the offsets must be optional rather than a fabricated 0..0.
A retrieval chunk exists to make a passage findable; a citation must point at the
passage that supports the claim. Build the spans before the answer and let the
model choose among them, instead of trying to align a citation to a claim after
the fact (that is semantic entailment, which no deterministic rule answers).

- buildCitationCandidates() splits each retrieved chunk into sentence-sized
  candidate spans (short neighbours merge, at most 3 sentences) and numbers them
  1..M. That number is the `[n]` the model writes and `Citation.index` carries, so
  the user-visible marker protocol is unchanged.
- buildRAGContext() presents candidates grouped by source/chunk with globally
  continuous markers, and builds each Citation from its candidate: quote, page,
  blockId and startOffset/endOffset now describe the supporting passage.
- A chunk with no block provenance falls back to one whole-chunk candidate with
  no span, so a pre-#66 index is never worse than before.
- Text/HTML reader highlights the exact span; the PDF reader highlights the
  paragraph that contains the cited sentence (previously the chunk's first block).
  Sub-sentence highlight in the PDF viewer needs text-layer range mapping and is a
  follow-up.

Verified: npm run typecheck; npm test (352 pass); npm run build; the RAG eval
harness output is byte-identical to docs/eval/baseline-v1.4.json. Not verified:
model behaviour against a live endpoint — the prompt contract changed, so the
citation-marking rate should be watched on a real model before v1.5 ships.
@mrsibe mrsibe added enhancement New feature or request area:provenance Source location, citations, document structure area:retrieval Retrieval quality and evaluation labels Sep 28, 2026
Base automatically changed from refactor/160-v15-seams to main September 28, 2026 08:38
@mrsibe
mrsibe merged commit c2dc685 into main Sep 28, 2026
7 of 8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:provenance Source location, citations, document structure area:retrieval Retrieval quality and evaluation enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feat] Exact-passage citation: the model cites source spans, not chunks

1 participant