mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-08-13 04:13:35 +08:00
Refine sufficient check using LLM draft (#18028)
This commit is contained in:
@@ -206,6 +206,32 @@ async def agentic_research(state: dict, tools) -> dict:
|
||||
if boost:
|
||||
_LOG.info("[Agentic research] Round %d: AutoRater is_sufficient=%s confidence=%.2f", cycle + 1, boost.get("is_sufficient"), boost.get("confidence", 1.0))
|
||||
|
||||
# LLM groundedness review (Google "draft review" thought): check whether each
|
||||
# claim's report is semantically supported by the cited evidence. Ungrounded
|
||||
# claims (hallucinated / over-claimed drafts) are merged into hard_violations
|
||||
# so the decision ladder forces a caveated answer — this catches relation/over-
|
||||
# claim errors that the lexical code-level grounded check (cross_check_claim)
|
||||
# cannot see.
|
||||
from rag.advanced_rag.harness.orchestrator.grounded_llm import llm_grounded_verify
|
||||
|
||||
grounded = await llm_grounded_verify(
|
||||
tools,
|
||||
ctx.question,
|
||||
[(r.claim_id, r.report or "") for r in agent_results_list if r.report],
|
||||
cited_ids,
|
||||
)
|
||||
# Treat a claim as violating when it is explicitly grounded=False OR has
|
||||
# non-empty ungrounded assertions (covers the degenerate grounded=False /
|
||||
# empty-ungrounded case too). Only accept IDs present in the original
|
||||
# claims collection — the LLM may echo a bogus claim_id that must not leak
|
||||
# into hard_violations.
|
||||
valid_claim_ids = {r.claim_id for r in agent_results_list}
|
||||
ungrounded_ids = [cid for cid, g in grounded.items() if cid in valid_claim_ids and (g.get("grounded") is False or g.get("ungrounded"))]
|
||||
if ungrounded_ids:
|
||||
existing = set(verdict.hard_violations or [])
|
||||
verdict.hard_violations = list(existing | set(ungrounded_ids))
|
||||
_LOG.info("[Agentic research] Round %d: %d claim(s) have ungrounded (draft-review) assertions: %s", cycle + 1, len(ungrounded_ids), ungrounded_ids)
|
||||
|
||||
action, should_continue, caveat = route_sufficiency_verdict(
|
||||
verdict,
|
||||
mode_label,
|
||||
|
||||
@@ -214,6 +214,37 @@ async def decompose_and_search(state: dict, tools) -> dict:
|
||||
if boost:
|
||||
_LOG.info("[Decompose] AutoRater is_sufficient=%s confidence=%.2f", boost.get("is_sufficient"), boost.get("confidence", 1.0))
|
||||
|
||||
# LLM groundedness review (Google "draft review"): runs unconditionally so every
|
||||
# decomposed result — including a non-critical-band SUFFICIENT — is groundedness-
|
||||
# validated before the status gate. (The lexical NER grounded check is disabled
|
||||
# in favour of this LLM review, so it must not be skipped on any path.) Ungrounded
|
||||
# claim drafts are merged into hard_violations → decision ladder caveat.
|
||||
from rag.advanced_rag.harness.orchestrator.grounded_llm import llm_grounded_verify
|
||||
|
||||
# Union of cited evidence IDs across all claim results (matches the
|
||||
# agentic orchestrator's cited-evidence behavior) so the reviewer sees
|
||||
# the exact evidence each claim referenced, not a global prefix.
|
||||
cited_evidence_ids: list[str] = []
|
||||
for r in agent_results:
|
||||
cited_evidence_ids.extend(r.evidence_ids or [])
|
||||
grounded = await llm_grounded_verify(
|
||||
tools,
|
||||
ctx.question,
|
||||
[(r.claim_id, r.report or "") for r in agent_results if r.report],
|
||||
cited_evidence_ids or None,
|
||||
)
|
||||
# Treat a claim as violating when it is explicitly grounded=False OR has
|
||||
# non-empty ungrounded assertions (covers the degenerate grounded=False /
|
||||
# empty-ungrounded case too). Only accept IDs that exist in the original
|
||||
# claims collection — the LLM may echo a bogus claim_id, which must not
|
||||
# leak into hard_violations.
|
||||
valid_claim_ids = {r.claim_id for r in agent_results}
|
||||
ungrounded_ids = [cid for cid, g in grounded.items() if cid in valid_claim_ids and (g.get("grounded") is False or g.get("ungrounded"))]
|
||||
if ungrounded_ids:
|
||||
existing = set(verdict.hard_violations or [])
|
||||
verdict.hard_violations = list(existing | set(ungrounded_ids))
|
||||
_LOG.info("[Decompose] %d claim(s) have ungrounded (draft-review) assertions: %s", len(ungrounded_ids), ungrounded_ids)
|
||||
|
||||
action, should_continue, caveat = route_sufficiency_verdict(
|
||||
verdict,
|
||||
mode_label,
|
||||
|
||||
115
rag/advanced_rag/harness/orchestrator/grounded_llm.py
Normal file
115
rag/advanced_rag/harness/orchestrator/grounded_llm.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""LLM groundedness review (draft review) for the orchestrator.
|
||||
|
||||
Inspired by Google's Sufficient Context Agent — it reviews the *intermediate
|
||||
draft* (each claim's report) against the retrieved snippets to decide whether
|
||||
the draft is actually grounded in the evidence.
|
||||
|
||||
This complements the code-level grounded check (``cross_check_claim`` /
|
||||
``_grounded_hit``, which is lexical: it only catches missing entities / digits).
|
||||
The LLM review is *semantic*: it catches assertions whose relation or claim is
|
||||
not supported by the evidence even when the entity is present (e.g. evidence
|
||||
says "Ithaca is a birthplace" but the report claims "hometown is Ithaca", or
|
||||
the evidence only mentions a medication was prescribed while the report
|
||||
over-claims "the patient is well").
|
||||
|
||||
Ungrounded assertions are fed back into the decision ladder as hard violations,
|
||||
so a hallucinated / over-claimed draft forces a caveated answer or a re-search.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from rag.prompts.generator import PROMPT_JINJA_ENV, gen_json
|
||||
from rag.prompts.template import load_prompt
|
||||
|
||||
_LOG = logging.getLogger(__name__)
|
||||
|
||||
GROUNDED_REVIEW = load_prompt("grounded_select")
|
||||
|
||||
|
||||
async def _llm_chat_json(chat_mdl, prompt_text: str):
|
||||
try:
|
||||
return await gen_json(prompt_text, "Output:\n", chat_mdl)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_LOG.info("[Grounded-draft] gen_json failed: %s", exc)
|
||||
return {}
|
||||
|
||||
|
||||
def _render_reports(reports: list[tuple[str, str]]) -> str:
|
||||
"""Render claim_id → report lines for the reviewer prompt."""
|
||||
if not reports:
|
||||
return "(no claims)"
|
||||
return "\n".join(f"Claim {cid}: {rpt}" for cid, rpt in reports if rpt)
|
||||
|
||||
|
||||
async def llm_grounded_verify(
|
||||
tools,
|
||||
question: str,
|
||||
reports: list[tuple[str, str]],
|
||||
evidence_ids=None,
|
||||
) -> dict:
|
||||
"""Draft review: is each claim's report grounded in the cited evidence?
|
||||
|
||||
Parameters
|
||||
----------
|
||||
tools : RAGTools
|
||||
Must expose ``chat_mdl`` and ``kbinfos``.
|
||||
reports : list[(claim_id, report)]
|
||||
Each claim's draft text (``AgentResult.report``).
|
||||
evidence_ids : list[str] | None
|
||||
Union of cited evidence chunk IDs (falls back to a bounded prefix).
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict : ``{claim_id: {"grounded": bool, "ungrounded": [str, ...]}}``
|
||||
Empty dict when the LLM review is unavailable (no chat model, no
|
||||
evidence, or a failure) — callers treat that as "no new signal".
|
||||
"""
|
||||
if not reports:
|
||||
return {}
|
||||
chat_mdl = getattr(tools, "chat_mdl", None)
|
||||
if chat_mdl is None:
|
||||
return {}
|
||||
|
||||
from rag.advanced_rag.harness.orchestrator.sufficiency_llm import (
|
||||
_evidence_md,
|
||||
_narrow_keywords,
|
||||
)
|
||||
|
||||
evidence_md = _evidence_md(tools, evidence_ids, keywords=_narrow_keywords(question))
|
||||
if not evidence_md:
|
||||
return {}
|
||||
|
||||
prompt_text = PROMPT_JINJA_ENV.from_string(GROUNDED_REVIEW).render(
|
||||
question=question,
|
||||
reports=_render_reports(reports),
|
||||
evidence=evidence_md,
|
||||
)
|
||||
_LOG.info("[Grounded-draft] reviewing %d claim report(s) (evidence %d chars)", len(reports), len(evidence_md))
|
||||
result = await _llm_chat_json(chat_mdl, prompt_text)
|
||||
|
||||
# The LLM may return a malformed payload (array / scalar / null) instead of a
|
||||
# dict. Log and fall back to the documented empty result so we never crash
|
||||
# with AttributeError on .get("claims").
|
||||
if not isinstance(result, dict):
|
||||
_LOG.info("[Grounded-draft] unexpected LLM response type=%s (expected dict); treating as no groundedness signal", type(result).__name__)
|
||||
return {}
|
||||
|
||||
out: dict = {}
|
||||
for item in result.get("claims") or []:
|
||||
cid = str(item.get("claim_id") or "")
|
||||
if not cid:
|
||||
continue
|
||||
ung = item.get("ungrounded_assertions") or []
|
||||
ungrounded = []
|
||||
for u in ung:
|
||||
if isinstance(u, dict):
|
||||
ungrounded.append(str(u.get("assertion") or u.get("reason") or ""))
|
||||
elif u:
|
||||
ungrounded.append(str(u))
|
||||
out[cid] = {
|
||||
"grounded": bool(item.get("grounded")),
|
||||
"ungrounded": [a for a in ungrounded if a],
|
||||
}
|
||||
return out
|
||||
@@ -17,6 +17,13 @@ from rag.advanced_rag.harness.sufficiency_ladder import (
|
||||
|
||||
_LOG = logging.getLogger(__name__)
|
||||
|
||||
# Experimental switch: when True, the lexical NER grounded-fact check
|
||||
# (``_grounded_hit`` below) runs in ``cross_check_claim``; when False it is
|
||||
# disabled and groundedness is delegated entirely to the LLM draft review
|
||||
# (``llm_grounded_verify`` in orchestrator/grounded_llm.py). Set to False to
|
||||
# trial "LLM draft instead of NER".
|
||||
_ENABLE_NER_GROUNDED = False
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Cross-check: code-only
|
||||
@@ -456,7 +463,7 @@ def cross_check_claim(agent_result: AgentResult, all_chunks: dict) -> ClaimCross
|
||||
return entity_hits / len(key_tokens) >= 0.5
|
||||
|
||||
grounded_facts = [str(g) for g in (agent_result.grounded or []) if str(g).strip()]
|
||||
if grounded_facts:
|
||||
if _ENABLE_NER_GROUNDED and grounded_facts:
|
||||
ungounded = [g for g in grounded_facts if not _grounded_hit(g)]
|
||||
if ungounded:
|
||||
_LOG.warning(
|
||||
|
||||
44
rag/prompts/grounded_select.md
Normal file
44
rag/prompts/grounded_select.md
Normal file
@@ -0,0 +1,44 @@
|
||||
You are an answer-groundedness reviewer. For each claim's report (the draft answer), determine whether every assertion is supported by the provided evidence.
|
||||
|
||||
A claim's report is GROUNDED only if each of its assertions can be inferred from the evidence. The assertion does NOT need to use the same words as the evidence (semantic paraphrase is fine), but it must NOT:
|
||||
- assert a fact that is absent from the evidence (likely model prior-injection / hallucination);
|
||||
- assert a relation or value that contradicts the evidence;
|
||||
- over-claim beyond what the evidence supports (e.g. the evidence only says a medication was prescribed, but the report claims "the patient is well").
|
||||
|
||||
Question: {{ question }}
|
||||
|
||||
Claim reports to verify:
|
||||
{{ reports }}
|
||||
|
||||
Evidence (each chunk labeled with an integer ID):
|
||||
{{ evidence }}
|
||||
|
||||
For each claim, classify its assertions:
|
||||
- SUPPORTED: the evidence explicitly supports it, including a semantic paraphrase.
|
||||
- UNGROUNDED: the evidence lacks the content, or the claimed relation/value contradicts the evidence, or the assertion over-claims beyond the evidence.
|
||||
|
||||
Output format (JSON):
|
||||
```json
|
||||
{
|
||||
"claims": [
|
||||
{
|
||||
"claim_id": "c1",
|
||||
"grounded": true,
|
||||
"ungrounded_assertions": []
|
||||
},
|
||||
{
|
||||
"claim_id": "c2",
|
||||
"grounded": false,
|
||||
"ungrounded_assertions": [
|
||||
{"assertion": "the patient had no adverse reactions", "reason": "the evidence only mentions the medication was prescribed, not the patient's reaction"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Requirements:
|
||||
1. Include EVERY claim in the output (do not skip any claim_id).
|
||||
2. `grounded` is true only if ALL of the claim's assertions are supported.
|
||||
3. `ungrounded_assertions` is empty when `grounded` is true; otherwise list each ungrounded assertion with a one-line `reason`.
|
||||
4. Prefer identifying genuine over-claims or contradictions over surface-level wording differences — a semantic paraphrase IS supported.
|
||||
Reference in New Issue
Block a user