Skip to content

fix(rag): keep sources when the LLM omits the citation tag - #847

Merged
Ahmath-Gadji merged 10 commits into
developfrom
fix/uncited-sources-fallback
Aug 24, 2026
Merged

fix(rag): keep sources when the LLM omits the citation tag#847
Ahmath-Gadji merged 10 commits into
developfrom
fix/uncited-sources-fallback

Conversation

@Ahmath-Gadji

@Ahmath-Gadji Ahmath-Gadji commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

Why

Sources are not cited in a lot of messages: PR #807 changed the fallback so a missing [Sources: ...] tag hides all sources instead of keeping them. In practice the model often skips the tag on answers that are genuinely grounded in retrieved documents, so those sources were being dropped from the response.

Fix

  • filter_sources_by_citations now keeps all presented sources when no tag is found, instead of returning none.
  • extra.citations_reported (bool) tells a client whether the model actually emitted a [Sources: ...] tag (even an empty one) — false is the only case where sources falls back to keeping everything, so a client can distinguish "cited every source" from "didn't report citations at all".
  • extra.presented_sources: everything actually shown to the LLM (after context-budget truncation), regardless of citation.
  • extra.cited_sources: strictly what the model cited via the tag — unlike sources, never falls back to "everything". sources itself is left untouched for backward compatibility with existing clients (e.g. Twake); new consumers (e.g. Chainlit) should move to cited_sources, falling back to presented_sources in the UI when it's empty.
  • extra.all_retrieved_sources: the complete retrieval set, captured before context-budget truncation (and, on the map-reduce path, before map-reduce replaces retrieved docs with LLM-generated summaries) — so it now genuinely reflects what retrieval returned in chat, chat_stream, and complete. Gated behind metadata.include_all_retrieved_sources: true (default off) since it's debug/eval telemetry — retrieval is uncapped up to retriever.top_k while the context budget only fits a handful of documents, so this can be a large payload.

Summary by CodeRabbit

  • New Features

    • Source metadata now distinguishes presented, cited, and fully retrieved sources.
    • Added citation status reporting for responses with, without, or invalid citation markers.
    • Chat and completion requests can optionally include all unfiltered retrieved sources for evaluation and troubleshooting.
    • Source metadata is now consistent across chat, streaming, conversational, completion, web search, and map-reduce workflows.
  • Bug Fixes

    • Sources are retained when responses do not include citation markers.
    • Improved source selection in streaming responses when citations are unavailable.

No [Sources: ...] tag means the model didn't report which sources it
used, not that it used none. filter_sources_by_citations now keeps all
retrieved sources in that case instead of hiding them, since answers
were frequently coming back with no cited sources at all.
@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

Next included review available in 23 minutes.

View limit details

Limit details: You’ve used all 2 included reviews currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 9c53073d-d4f7-4ba7-9727-c8b7102ea480

📥 Commits

Reviewing files that changed from the base of the PR and between 4c71a7e and 6088745.

📒 Files selected for processing (2)
  • openrag/core/retrieval/pipeline.py
  • openrag/services/orchestrators/query_service.py

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 1c35a710-fb02-48ab-8223-5e12ae848736

📥 Commits

Reviewing files that changed from the base of the PR and between 7d45146 and 4c71a7e.

📒 Files selected for processing (8)
  • CLAUDE.md
  • openrag/api/routers/user/chat.py
  • openrag/api/schemas/user/chat.py
  • openrag/app_front.py
  • openrag/core/utils/source_filtering.py
  • openrag/services/orchestrators/query_service.py
  • tests/unit/core/utils/test_source_filtering.py
  • tests/unit/services/orchestrators/test_query_service.py

Included review availability: Your plan provides up to 2 included reviews per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

stream_with_source_filtering and query orchestration now distinguish presented, cited, and complete retrieved sources. Missing citation tags retain presented sources. Optional complete retrieval telemetry is available for chat, streaming, completion, web-search, and map-reduce flows.

Changes

Citation source handling

Layer / File(s) Summary
Source filtering contract and behavior
openrag/core/utils/source_filtering.py, tests/unit/core/utils/test_source_filtering.py
Missing citations retain all sources. Explicit empty citations produce no cited sources. Stream metadata reports presented_sources, cited_sources, citations_reported, and optional all_retrieved_sources.
Query preparation and response metadata
openrag/services/orchestrators/query_service.py
Chat and completion preparation preserve uncapped retrieval sets separately from context-selected sources. Shared response metadata reports presented, cited, and complete retrieved sources.
API option and source display integration
openrag/api/schemas/user/chat.py, openrag/api/routers/user/chat.py, openrag/app_front.py, CLAUDE.md
Request metadata enables optional complete retrieval telemetry. API documentation describes the expanded source fields. Streaming source extraction prefers cited sources and falls back to presented sources.
Query flow validation
tests/unit/services/orchestrators/test_query_service.py
Tests cover chat, streaming, completion, web search, truncation, map-reduce, missing citations, invalid citation indices, and optional complete retrieval telemetry.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Merge Risk: ⚪ Minimal · up to 4c71a

The PR preserves sources when citation tags are omitted and adds citation metadata for consumers; no actionable merge-blocking risk remains beyond normal checks and review.

Suggested reviewers: hedhoud, enjoybacon7, andyne13

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring check was indeterminate for this PR — some files could not be analyzed in time. Not blocking.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: preserving sources when the LLM omits the citation tag.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/uncited-sources-fallback

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot added bug Something isn't working fix Fix issue labels Aug 2, 2026
Adds extra.retrieved_sources, carrying every source retrieval produced
regardless of citation filtering, so clients can see what was searched
even when extra.sources ends up narrower than the full retrieval set.
@Ahmath-Gadji Ahmath-Gadji mentioned this pull request Aug 2, 2026
Clearer than retrieved_sources for a field meant for debugging and RAG
evaluation.
@hedhoud hedhoud self-assigned this Aug 24, 2026
Comment thread openrag/services/orchestrators/query_service.py Outdated

@andyne13 andyne13 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified the branch locally at f4bd28a6: full unit suite passes (2431), ruff check, ruff format --check and check_layer_imports.py are all clean.

Two notes before the findings, since this PR partially reverts #807:

  • It does not reopen #778. #807's conversational fix works by not retrieving at all — the requires_retrieval=False branch in _prepare_chat returns payload, [], [], True, so sources is already [] and the citation filter never runs. Greetings and identity questions still show no sources.
  • The allow_uncited removal is correct dead-weight cleanup: structured output previously reached "keep everything" via allow_uncited=True and now reaches it via citations is None. Same result, and _allows_uncited_sources_is_structured_output is the honest name.

Confirming @hedhoud's point, with a wider scope

The post-truncation issue is real, and the context budget isn't the only thing narrowing the set before it reaches all_retrieved_sources:

  • docs = [docs[i] for i in included]query_service.py:510 (context budget), and again at :574 in _prepare_completions, so the complete() path has the same gap.
  • web_results = [web_results[number - web_start_index] for number in web_source_numbers]:524, same for web results.
  • docs = await self._map_reduce(...):494 replaces the retrieved docs with generated summary docs when use_map_reduce is set, so on that path all_retrieved_sources contains no retrieved document at all.

If the field is meant for debugging and evaluation, it needs to be captured right after retrieval, before all three of these.

Missing: nothing distinguishes "cited" from "unreported"

This is the one I'd most like to see addressed. After this change extra.sources conflates two very different states, and all_retrieved_sources doesn't disambiguate them either — when the tag is missing, sources == all_retrieved_sources, but that is also true when the model legitimately cited every source. Neither the Chainlit front nor the admin UI can tell the two apart, so the #778 failure mode becomes unobservable rather than fixed.

One extra key solves it:

extra_payload = {
    "sources": filtered,
    "all_retrieved_sources": sources,
    "citations_reported": citations is not None,
}

That lets a client render "Sources cited" vs "Sources consulted", and makes the tag-omission rate measurable instead of invisible.

The public API contract wasn't updated

CLAUDE.md documents the new field, but the OpenAPI descriptions that clients actually read still say extra contains only sources:

  • openrag/api/routers/user/chat.py:448 (chat completions)
  • openrag/api/routers/user/chat.py:554 (completions)

Nothing under docs/content/ describes extra, so those two docstrings are the whole public contract for this field.

PR description is stale

The description says extra.retrieved_sources; commit f4bd28a6 renamed it to all_retrieved_sources. The auto-generated walkthrough inherited the old name too. Worth fixing before merge so the changelog is right. The summary bullet "Structured-output responses no longer process citation markers incorrectly" also isn't part of this diff — that gate already exists on develop.

Nits, non-blocking

  • On-wire duplication. filtered_json is serialized once and attached to both the tail chunk and the finish chunk, so a streamed answer now carries the source array four times instead of twice — and in exactly the case this PR targets (no tag), the two arrays are byte-identical. It's bounded, since _dict_to_chunk strips text from metadata, so I would keep it if unconditional presence is what makes the field useful for eval. Just flagging the cost.
  • Truncated streams. Upstream dying before the tag now yields citations is None → every source marked cited, alongside truncated: true. Defensible, but the warning at source_filtering.py:275 logs sources=len(filtered), which will read as "cited N sources" for answers that cited none.
  • Test coverage asymmetry. chat got test_chat_without_citation_keeps_retrieved_sources; complete() has no equivalent, and no test asserts that complete() emits all_retrieved_sources at all. Also worth noting the prepare_sources=lambda d, w: [...] if d or w else [] stub change is load-bearing — it is more faithful than the old stub (real __prepare_sources at chat.py:214 does return [] for empty docs), but it makes the surrounding assert sources == [] assertions trivially true.

Comment thread openrag/core/utils/source_filtering.py Outdated
all_retrieved_sources was built from the same docs/web_results already
truncated by format_context()/format_web_context() to fit the prompt's
token budget, so it silently dropped anything retrieval returned but
couldn't fit — defeating its purpose as the complete set for debugging
and RAG evaluation. Snapshot the full retrieval set before that
truncation and thread it through chat, chat_stream, and complete.
With no [Sources: ...] tag, `sources` falls back to keeping every
retrieved source — identical, from the client's view, to the model
explicitly citing all of them. Add `citations_reported` (true only when
a tag, even an empty/none one, was found) so clients can tell "cited
everything" apart from "didn't report citations at all".
@andyne13

Copy link
Copy Markdown
Contributor

Design note before this gets implemented — shape only, no implementation detail.

Three sets nest here: retrieved ⊇ presented ⊇ cited. Retrieved measures recall, presented is what the model could possibly have used, cited is what it claims it used. They answer different questions, so each wants its own stage-accurate name, and no field should mean two different stages depending on circumstance. That's also the cleanest argument against the current fallback: sources now flips between "cited" and "everything" with nothing on the wire saying which.

One caution on the fix @hedhoud asked for, worth knowing up front: citation numbers are positions into the presented list. The citation-bearing field therefore can't simply be swapped for the fuller set. Web sources are numbered starting from the document count after truncation, so widening that list silently misaligns them. The retrieved set needs to arrive as its own field that nothing indexes into.

Also worth flagging that the gap is large rather than marginal: the context budget is sized for ten documents while the retrieval path is uncapped and can return up to retriever.top_k. This field would routinely omit most of what was retrieved. Arguably that cap/budget mismatch is the real defect and the new field mostly makes it visible.

Last thought: a full retrieval dump on every response is debug telemetry on the hot path. Behind a request flag, or in traces, would serve evaluation better without growing every answer.

Map-reduce reassigns `docs` to LLM-generated summaries before the prompt
is built, and the retrieval snapshot for all_retrieved_sources was taken
after that reassignment — so on the map-reduce path it held summaries
instead of anything retrieval actually returned. Move the snapshot above
the map-reduce call, alongside the existing pre-truncation capture.

Follow-up to f3c5b2f, from PR #847 review.
The OpenAPI-facing description of the extra response field (the public
contract clients actually read) still only mentioned sources. CLAUDE.md
was updated for these fields but the router docstrings weren't.
…dump

Addresses andyne13's design note on #847: `sources` conflated "what was
cited" with "everything, because there was no tag" — no distinct field
existed for what was actually shown to the LLM. Add `presented_sources`
(everything shown, pre-citation-filter) and `cited_sources` (strictly
what was cited, never falling back like `sources` does), so a client
can render "cited" vs "consulted" and Chainlit can adopt `cited_sources`
with a `presented_sources` fallback when it's empty. `sources` is left
untouched for backward compatibility with existing clients (e.g. Twake).

Also gate `all_retrieved_sources` behind a new
`metadata.include_all_retrieved_sources` request flag (default off):
dumping the full, uncapped retrieval set on every response is debug/eval
telemetry that most callers don't need on the hot path. Added the
`metadata` field to OpenAICompletionRequest, which was missing it
entirely — completions couldn't reach any metadata flag before this,
including the pre-existing spoken_style_answer.
Chainlit was still reading the legacy extra.sources field. Switch to
cited_sources (strictly what the model cited) with a presented_sources
fallback when nothing was cited, per the plan discussed on PR #847.
Also add the complete()-path no-citation test flagged as a coverage
gap in that review's nits.
@Ahmath-Gadji

Copy link
Copy Markdown
Collaborator Author

Thanks for both passes — replying to the review and the design note together since they overlap.

From the review:

  • Confirmed both notes on the Hide Chainlit Sources when no evidence is available #807 revert scope — no action needed there, and agreed allow_uncited_is_structured_output is honest cleanup.
  • Post-truncation gap, wider scope: fixed the context-budget/web-truncation part in f3c5b2f (also closes the complete() gap you called out at :574). The map-reduce replacement at :494 was a separate miss on my part — retrieved_docs was being snapshotted after map-reduce reassigned docs to LLM summaries, so all_retrieved_sources held summaries instead of anything retrieved on that path. Fixed in c564472, with a chat()-level test asserting sources (summaries) differs from all_retrieved_sources (originals) when use_map_reduce=True.
  • citations_reported: added exactly as you specified, across all three paths (f782b07).
  • API docs: chat.py:448/:554 docstrings now document all four extra fields and the new opt-in flag (9429a7b).
  • PR description: updated — no more retrieved_sources, reflects the current field set.
  • Nits: kept the on-wire duplication as you suggested. Added the missing complete() no-citation test. Left the truncated-stream log wording (sources=len(filtered) reading as "cited N" when nothing was cited) as-is for now — happy to adjust if it's still bothering anyone, but it's just a log line.

From the design note — went with option 1's non-breaking version:

Kept sources untouched for backward compatibility (existing clients, e.g. Twake). Added presented_sources (everything shown to the LLM, pre-filter) and cited_sources (strictly what was cited, empty — not falling back — whenever no tag was found). Chainlit's app_front.py now reads cited_sources, falling back to presented_sources in the UI when it's empty (4c71a7e) — so the naming ambiguity you flagged is resolved for new consumers without breaking old ones.

On the correctness caution: confirmed already safe — filter_sources_by_citations only ever indexes into the presented list; all_retrieved_sources/all_sources is a fully separate field nothing indexes into, in both the pre-existing and current code.

On the budget/cap mismatch: agreed it's the more fundamental issue, but leaving that out of scope here — happy to open a follow-up issue if you'd like to track it separately.

On gating the full dump: done — all_retrieved_sources is now opt-in via metadata.include_all_retrieved_sources (default off, and the underlying prepare_sources call on the full retrieval set is skipped entirely when not requested, not just excluded from the payload). Turned up a side issue while wiring this for /completions: OpenAICompletionRequest had no metadata field at all, so no metadata flag — including the pre-existing spoken_style_answer — could ever reach that endpoint. Added it (87c07b0).

@Ahmath-Gadji

Copy link
Copy Markdown
Collaborator Author

Filed the context-budget/reranker.top_k mismatch as a separate issue: #851 — keeping it out of this PR's scope.

Point future readers at #851 from the two spots where the gap actually
lives: the token-budget sizing in query_service.py, and the pipeline
code where reranker_top_k is read but never applied as a final cutoff.

@hedhoud hedhoud left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The follow-up changes address the earlier source-truncation and citation-state concerns across chat, streaming, completion, and map-reduce. The focused tests and CI are green, and the backward-compatible fallback remains intact.

@Ahmath-Gadji
Ahmath-Gadji merged commit 48d91e1 into develop Aug 24, 2026
6 checks passed
@Ahmath-Gadji
Ahmath-Gadji deleted the fix/uncited-sources-fallback branch August 24, 2026 11:58
@Ahmath-Gadji Ahmath-Gadji added the fix Fix issue label Aug 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fix Fix issue

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants