Skip to content

feat(rag): preserve claim scope in the default prompt, add bounded applicability filter - #176

Open
n0nuser wants to merge 4 commits into
mainfrom
feat/scope-preserving-prompt
Open

feat(rag): preserve claim scope in the default prompt, add bounded applicability filter#176
n0nuser wants to merge 4 commits into
mainfrom
feat/scope-preserving-prompt

Conversation

@n0nuser

@n0nuser n0nuser commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Closes #173

Stacked on #175 (fix/prompt-includes-heading-path). Based against that branch so the diff stays reviewable; retarget to main once #175 merges.

Two independent commits, reviewable separately.

Commit 1 — scope-preserving default prompt

The default constrained the model to the context but said nothing about how faithfully to represent that context's scope. A passage about habitual exposure across years and one about a single occurrence are both just context, so the model flattened them together.

New default adds two imperatives: preserve the scope each source states, and say so when the question asks about one instance the context only supports long-term. Length is part of the contract — this ships on every query and small local models follow long instructions unevenly — so a test bounds it.

The string had five hand-maintained copies with no consistency check (settings default, both cloud provider keyword defaults, .env.example, docs prose). All in-code copies now derive from DEFAULT_SYSTEM_PROMPT, with tests pinning the provider defaults and .env.example against it.

Commit 2 — bounded claim-applicability filter (ADR 041)

This is the "reasoning pipeline" gap: nothing between retrieval and generation asked whether a retrieved claim applies at the question's scope.

CLAIM_FILTER_ENABLED (default false) makes one provider call — regardless of context count — asking which passages don't apply. Removal of an already-retrieved context is its only power: no rewriting, adding, reordering, or answer text.

  • Runs before compression, so the budget is spent only on applicable passages.
  • Sources come from the filtered set; citing a discarded passage would misattribute the answer.
  • Carries heading_path into the filter prompt — judging scope without the heading is the blind case RAG prompt drops heading_path, so the model cannot see where a chunk came from #172 describes.
  • Not Ollama-only, unlike HyDE: generate_from_prompt is on the BaseLLMProvider contract, so all three backends work with no guard.
  • Every failure degrades to unfiltered contexts, including any verdict that would discard all context. Prompt construction sits outside that guard so a bug here surfaces instead of masquerading as degradation.

What happened when I actually ran it

Worth reading before merging commit 2.

Against gemma3:4b on the motivating example (one chronic passage, one acute), the model marked both inapplicable. The all-discarded guard caught it and degraded — answer path unharmed, but the stage did nothing useful.

Rewording the instruction to bias harder toward keeping made it worse: the model then discarded exactly the acute passage that answers the question. I reverted that reword rather than keeping it.

So the safety design holds, but the judge model is the limiting factor and a 4B local model does not clear the bar. Recorded in ADR 041 and flagged in docs/rag-retrieval.md rather than left for someone to discover. Whether this wants a dedicated CLAIM_FILTER_MODEL is the first thing to test once #174 provides a regression case.

If you'd rather not ship an unproven stage at all, commit 2 can be dropped and commit 1 merged alone — they're independent.

Verification

  • Unit: 503 passed, 0 failed (14 new tests).
  • Lint, format, mypy, bandit: clean.
  • Integration against rebuilt image: 13 passed.
  • Env resolution verified end-to-end: CLAIM_FILTER_* → flat → grouped → with_overrides.
  • Default-off path asserted byte-identical: no provider call, trace still None.

https://claude.ai/code/session_01PhFVTFLaxhmP8icwzmcm5Q

n0nuser added 4 commits August 7, 2026 18:27
build_prompt read only source and chunk_index off each context and never
touched context["metadata"], so heading_path reached the API sources list
but never the model. The structural chunker computes heading_path precisely
so a chunk carries its position in the document hierarchy; discarding it at
the prompt boundary threw away the signal that distinguishes a passage about
long-term risk from one about immediate effects.

Each context block header now carries a section= segment when heading_path
is populated, and is unchanged when it is not, so text_block and code_block
chunks keep the previous format.

Heading paths are bounded by MAX_SECTION_CHARS. The bound is derived rather
than picked: compression budgets measure only chunk body text and reserve
nothing for the block scaffolding, so worst-case headers have to fit inside
reserved_prompt_tokens. A test pins that invariant, since a looser bound
silently pushes the prompt past what any budget counted.

Refs #172

Claude-Session: https://claude.ai/code/session_01PhFVTFLaxhmP8icwzmcm5Q
The default prompt constrained the model to the retrieved context but said
nothing about how faithfully to represent that context's scope. A passage
about habitual exposure measured across years and one about a single
occurrence are both just context, so the model flattened them together and
presented long-term epidemiological findings as the result of one event —
grounded in real retrieved text, with the scope silently changed.

Two imperative sentences now require preserving the scope each source states,
and require saying so when the question asks about a single instance the
context cannot support. Length is part of the contract: this ships on every
query and small local models follow long instructions unevenly, so a test
bounds it rather than leaving the constraint implicit.

The string had five hand-maintained copies with no automated consistency
check — the settings default, both cloud provider keyword defaults,
.env.example, and prose in the docs. Every in-code copy now derives from
DEFAULT_SYSTEM_PROMPT in localrag/rag/prompt.py, and tests pin both the
provider defaults and .env.example against it so the drift cannot return.

Refs #173

Claude-Session: https://claude.ai/code/session_01PhFVTFLaxhmP8icwzmcm5Q
The pipeline was retrieve -> concatenate -> generate, with nothing between
retrieval and generation asking whether a retrieved claim applies at the scope
the question asks about. Retrieval ranks by topical similarity, which is blind
to the qualifier that decides which passage actually answers the question: a
question about a single occurrence and a passage about habitual exposure over
years are the same topic, so both rank and both reach the model.

CLAIM_FILTER_ENABLED (default false) inserts one bounded provider call that
asks which numbered passages do not apply at the question's scope, and removes
those. Removal of an already-retrieved context is the stage's only power: it
never rewrites, adds, or reorders a passage, and never contributes answer text.
The filter prompt carries heading_path, because scope usually lives in the
heading rather than the sentence.

Filtering runs before compression so the budget is spent only on applicable
passages, and reported sources come from the filtered set, since citing a
discarded passage would misattribute the answer.

Every failure degrades to the unfiltered contexts: provider error, unparseable
output, out-of-range indices, and any verdict that would discard all context.
Answering with more context than necessary is the behavior that shipped before
this stage; answering with too little because one judgment call went wrong is a
regression. Prompt construction sits outside that guard on purpose, so a bug in
this module surfaces instead of masquerading as degradation.

Unlike HyDE this is not Ollama-only: it calls generate_from_prompt, which is on
the BaseLLMProvider contract, so all three backends work without a guard.

Refs #173. Contract: ADR 041.

Claude-Session: https://claude.ai/code/session_01PhFVTFLaxhmP8icwzmcm5Q
A manual check against gemma3:4b on the motivating acute-vs-chronic example
produced a wrong verdict: the model marked both passages inapplicable. The
all-discarded guard caught it and degraded, so the answer path was unharmed,
but the stage did no useful work.

Rewording the instruction to bias harder toward keeping made it worse — the
model then discarded exactly the acute passage that answered the question — so
that reword was reverted rather than kept.

Recording this because shipping an optional stage without saying what it did
when actually run would be the more misleading choice. The safety design holds;
the judge model is the limiting factor, and #174 is what makes it measurable.

Claude-Session: https://claude.ai/code/session_01PhFVTFLaxhmP8icwzmcm5Q
@n0nuser

n0nuser commented Aug 7, 2026

Copy link
Copy Markdown
Owner Author

Merge order (this PR is stacked).

Base is fix/prompt-includes-heading-path (PR #175), not main. Merge #175 first; GitHub then normally retargets this PR to main automatically. If it does not, retarget manually before merging — merging this into main while #175 is unmerged would pull #175's commit ed523e6 in as a side effect.

Commit 1 (bd703d1, prompt default) and commit 2 (320346d + bd6cc94, claim filter) are independent. If the claim filter is not wanted given the gemma3:4b result documented above, commit 1 can be cherry-picked alone onto main without touching commit 2.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant