Refactor: check the evidences while formalize answer. (#17951)

### Summary

Check the evidences while formalize answer.
This commit is contained in:
Kevin Hu
2026-08-07 14:51:54 +08:00
committed by GitHub
parent 045ba3970a
commit a25ff22aca
5 changed files with 521 additions and 48 deletions

View File

@@ -43,6 +43,30 @@ from rag.prompts.generator import form_message, kb_prompt, message_fit_in
_LOG = logging.getLogger(__name__)
_ANSWER_TARGET_SYSTEM = """You are an answer-target verifier for a multi-hop RAG system.
Resolve what entity, value, or fact the user's question is asking for.
Do not choose bridge entities that merely explain a clue. In inverse-relation
questions, a later clue may identify the target's partner, relative, employer,
team, or source; keep tracing until the requested answer role is satisfied.
Return JSON only."""
_ANSWER_TARGET_USER = """Question:
{question}
Research summary:
{pre_summary}
Evidence:
{evidence}
Return JSON:
{{
"target_role": "the role the final answer must satisfy",
"must_satisfy": ["short evidence-backed condition the answer must meet"],
"bridge_entities": ["entities that are useful clues but should not be the answer unless they also satisfy target_role"],
"reason": "one short explanation of the answer shape"
}}"""
def _snip(value: Any, limit: int = 240) -> str:
try:
@@ -55,6 +79,79 @@ def _snip(value: Any, limit: int = 240) -> str:
return s
def _extract_json(text: str) -> dict:
text = re.sub(r"^.*</think>", "", text or "", flags=re.DOTALL).strip()
text = re.sub(r"```(?:json)?\s*|\s*```", "", text).strip()
try:
import json_repair
return json_repair.loads(text)
except Exception:
try:
return json.loads(text)
except Exception:
return {}
def _compact_text(text: str, limit: int) -> str:
text = text or ""
if len(text) <= limit:
return text
return text[:limit] + f"\n...(+{len(text) - limit} chars)"
def _string_items(value) -> list[str]:
if isinstance(value, str):
return [value.strip()] if value.strip() else []
if not isinstance(value, list):
return []
return [str(v).strip() for v in value if str(v).strip()]
def _format_answer_target_contract(contract: dict) -> str:
target_role = str(contract.get("target_role") or "").strip()
reason = str(contract.get("reason") or "").strip()
lines = []
if target_role:
lines.append(f"Final answer must satisfy: {target_role}")
must = _string_items(contract.get("must_satisfy"))
if must:
lines.append("Must meet these conditions:")
lines.extend(f"- {item}" for item in must)
bridges = _string_items(contract.get("bridge_entities"))
if bridges:
lines.append("Bridge entities to verify but not answer with:")
lines.extend(f"- {item}" for item in bridges)
if reason:
lines.append(f"Reasoning guard: {reason}")
lines.append("Do not answer with an intermediate clue entity unless it also satisfies the final answer role.")
return "\n".join(lines)
async def _answer_target_contract(tools, question: str, kbinfos: dict, evidence: str) -> str:
"""Build a compact guardrail that tells final synthesis what to answer."""
fallback = "Final answer must directly satisfy the user's top-level who/what request. Use bridge entities only as clues, and verify any proposed answer against the evidence."
pre_summary = kbinfos.get("pre_summary") or ""
if not question or not pre_summary:
return fallback
try:
user = _ANSWER_TARGET_USER.format(
question=question,
pre_summary=_compact_text(pre_summary, 2400),
evidence=_compact_text(evidence, 6000),
)
msg = await tools._fit_messages(_ANSWER_TARGET_SYSTEM, user)
ans = await tools.chat_mdl.async_chat(msg[0]["content"], msg[1:], {"temperature": 0.0})
if isinstance(ans, tuple):
ans = ans[0]
parsed = _extract_json(ans)
formatted = _format_answer_target_contract(parsed) if parsed else ""
return formatted or fallback
except Exception:
_LOG.exception("[Composing the answer] Failed to build answer-target contract")
return fallback
class AgenticState(TypedDict, total=False):
messages: list
question: str
@@ -70,6 +167,7 @@ class AgenticState(TypedDict, total=False):
empty_result: bool
final_answer: str
loop: int
max_loops: int
feedback: str # replanning feedback
@@ -263,9 +361,14 @@ def build_agentic_graph(tools, token_queue: asyncio.Queue, gen_conf: dict | None
return {"final_answer": tools.empty_response}
# Build evidence
evidence = kb_prompt(kbinfos, tools.chat_mdl.max_length)
evidence_blocks = kb_prompt(kbinfos, tools.chat_mdl.max_length)
evidence = "\n".join(evidence_blocks) if isinstance(evidence_blocks, list) else str(evidence_blocks)
parts = [f"Question:\n{question}\n"]
answer_target = await _answer_target_contract(tools, question, kbinfos, evidence)
if answer_target:
parts.append(f"Answer Target Contract:\n{answer_target}\n")
if no_evidence:
parts.append("No supporting evidence was retrieved. State clearly that the available sources are insufficient, and do not answer from general knowledge.\n")
@@ -335,7 +438,7 @@ async def run_agentic_rag(tools, messages: list, max_loops: int = 3, gen_conf: d
async def _drive():
try:
holder["state"] = await graph.ainvoke(
{"messages": messages},
{"messages": messages, "max_loops": max_loops},
{"recursion_limit": max(25, max_loops * 8)},
)
except Exception:

View File

@@ -249,13 +249,25 @@ def _merge_agent_results(ctx: OrchestratorContext, tools):
if combined:
tools.kbinfos["pre_summary"] = "\n\n".join(combined)
# Collect evidence chunks from agent results
# Collect the chunks the agents actually cited across all claims. These
# indices share the same positional space as kb_prompt's ``[ID:n]`` markers
# (both index tools.kbinfos["chunks"]).
for c in ctx.claims:
if c.agent_result and c.agent_result.evidence_ids:
for eid in c.agent_result.evidence_ids:
if eid not in seen_evidence:
if isinstance(eid, int):
seen_evidence.add(eid)
# Drop chunks no claim ever cited (e.g. pre_search recall that didn't pan
# out) so the final-answer LLM call only sees the useful evidence. Preserve
# order so the re-numbered [ID:n] citations stay stable. Defensive: never
# filter to empty — if nothing was cited, keep the full pool.
all_chunks = tools.kbinfos.get("chunks") or []
keep = sorted(i for i in seen_evidence if 0 <= i < len(all_chunks))
if keep and len(keep) < len(all_chunks):
_LOG.info("[Agentic research] Trimming evidence for the final answer: %d of %d chunk(s) were cited.", len(keep), len(all_chunks))
tools.kbinfos["chunks"] = [all_chunks[i] for i in keep]
async def _get_compilation_map(tools) -> dict[str, set[str]]:
"""Build compilation map from RAGTools - check which KBs have compilation artifacts."""

View File

@@ -1,7 +1,9 @@
"""Medium mode: decompose parallel search → sufficiency check."""
"""Medium mode: decompose -> parallel search -> evidence-guided follow-up."""
import asyncio
import json
import logging
import re
from rag.advanced_rag.harness.types import ClaimTarget, AgentResult, OrchestratorContext
from rag.advanced_rag.harness.config import get_mode
@@ -14,84 +16,433 @@ from rag.advanced_rag.harness.tools.search import hybrid_search
_LOG = logging.getLogger(__name__)
_MAX_EVIDENCE_SNIPPETS = 6
_MAX_NEXT_QUERIES = 3
_EVIDENCE_ANALYSIS_SYSTEM = """You are controlling a multi-hop RAG retrieval loop.
Judge whether the retrieved passages verify the claim using only the provided evidence.
If the claim is not verified, produce targeted next search queries that use entities,
dates, names, or relationships discovered in the evidence and move closer to the
original question.
Distinguish final-answer entities from bridge entities. If the passages identify
only a clue node in the chain, keep the claim incomplete and search for the
remaining relation needed by the original question. Return JSON only."""
_EVIDENCE_ANALYSIS_USER = """Original question:
{question}
Claim to verify:
{claim}
Search query used this round:
{query}
Round: {cycle} of {max_cycles}
Retrieved evidence snippets:
{evidence}
Return JSON:
{{
"is_verified": true,
"confidence": 0.0,
"report": "Short evidence-backed finding, or what was learned so far.",
"gaps": ["specific missing fact or relationship"],
"next_queries": ["standalone follow-up search query"]
}}"""
async def decompose_and_search(state: dict, tools) -> dict:
"""Decompose → parallel search → merge → sufficiency check → iterate."""
"""Decompose, retrieve, analyze evidence, then iterate with next-hop queries."""
question = state.get("question", "")
keywords = state.get("keywords", "")
claims_raw = state.get("claims", [])
mode_label = state.get("route", {}).thinking_mode if state.get("route") else "medium"
route = state.get("route")
mode_label = _mode_label(route)
mode = get_mode(mode_label)
max_cycles = _cycle_budget(state, mode.max_orchestrator_cycles)
claims = [ClaimTarget(**c) if isinstance(c, dict) else c for c in claims_raw]
ctx = OrchestratorContext(question=question, claims=claims, mode=mode_label)
attempted_queries: dict[str, set[str]] = {c.claim_id: set() for c in ctx.claims}
pending_queries: dict[str, list[str]] = {c.claim_id: [] for c in ctx.claims}
completed_cycles = 0
for cycle in range(mode.max_orchestrator_cycles):
for cycle in range(max_cycles):
ctx.iteration = cycle
unverified = [c for c in ctx.claims if not c.is_verified]
if not unverified:
break
# Parallel search on unverified claims
tasks = []
for c in unverified:
tasks.append(hybrid_search(tools, query=c.description, keywords=keywords))
results = await asyncio.gather(*tasks)
_LOG.info(
"[Decompose search] Round %d of %d: researching %d unresolved claim(s).",
cycle + 1,
max_cycles,
len(unverified),
)
for c, result in zip(unverified, results):
if result.get("chunks"):
_merge_kbinfos(tools, result)
c.is_verified = True
c.confidence = 0.8
c.agent_result = AgentResult(
claim_id=c.claim_id,
report=_summarize(result),
is_verified=True,
confidence=0.8,
evidence_ids=_global_evidence_ids(tools, result),
tasks = []
searched_claims = []
for c in unverified:
query = _pick_next_query(
question,
c,
attempted_queries.setdefault(c.claim_id, set()),
pending_queries.setdefault(c.claim_id, []),
)
if not query:
_LOG.info("[Decompose search] No unused follow-up query remains for claim %s.", c.claim_id)
continue
attempted_queries[c.claim_id].add(_normalize_query(query))
searched_claims.append((c, query))
tasks.append(hybrid_search(tools, query=query, keywords=keywords, use_compiled=True))
if not tasks:
break
results = await asyncio.gather(*tasks, return_exceptions=True)
analysis_inputs = []
for (c, query), result in zip(searched_claims, results):
if isinstance(result, Exception):
_LOG.exception("[Decompose search] Search failed for claim %s.", c.claim_id, exc_info=result)
result = {"chunks": [], "doc_aggs": []}
chunks = result.get("chunks", []) or []
_merge_kbinfos(tools, result)
evidence_ids = _evidence_ids(tools, chunks)
analysis_inputs.append((c, query, result, evidence_ids))
analyses = await asyncio.gather(
*[
_analyze_claim_evidence(
question=question,
claim=c,
query=query,
result=result,
evidence_ids=evidence_ids,
cycle=cycle,
max_cycles=max_cycles,
tools=tools,
)
else:
c.agent_result = AgentResult(
claim_id=c.claim_id,
report="",
is_verified=False,
confidence=0.0,
for c, query, result, evidence_ids in analysis_inputs
],
return_exceptions=True,
)
for (c, query, result, evidence_ids), analysis in zip(analysis_inputs, analyses):
if isinstance(analysis, Exception):
_LOG.exception("[Decompose search] Evidence analysis failed for claim %s.", c.claim_id, exc_info=analysis)
analysis = _fallback_analysis(result, cycle, max_cycles)
c.is_verified = analysis["is_verified"]
c.confidence = analysis["confidence"]
c.agent_result = AgentResult(
claim_id=c.claim_id,
report=analysis["report"],
is_verified=c.is_verified,
confidence=c.confidence,
evidence_ids=evidence_ids,
gaps=analysis["gaps"],
discovered_claims=[],
)
ctx.agent_results[c.claim_id] = c.agent_result
next_queries = _new_queries(
analysis.get("next_queries", []),
attempted_queries.setdefault(c.claim_id, set()),
)
if not c.is_verified and next_queries:
pending_queries.setdefault(c.claim_id, []).extend(next_queries)
_LOG.info(
"[Decompose search] Claim %s needs another hop; queued %d targeted query/queries.",
c.claim_id,
len(next_queries),
)
_LOG.info(
'[Decompose search] Claim %s after "%s": %s (confidence %.0f%%).',
c.claim_id,
_snip(query),
"verified" if c.is_verified else "still incomplete",
c.confidence * 100,
)
completed_cycles = cycle + 1
all_chunks = {i: c for i, c in enumerate(tools.kbinfos.get("chunks", []))}
agent_results = [c.agent_result for c in ctx.claims if c.agent_result]
cross_results = [cross_check_claim(r, all_chunks) for r in agent_results]
verdict = compute_fusion_score(agent_results, cross_results, mode)
ctx.verdict = verdict
action, should_continue = route_sufficiency_verdict(
verdict,
mode_label,
cycle,
mode.max_orchestrator_cycles,
max_cycles,
)
if action in ("ANSWER", "ANSWER_PARTIAL"):
return {
"verdict": verdict.__dict__,
"partial_answer": action == "ANSWER_PARTIAL",
"kbinfos": tools.kbinfos,
}
return _finalize(ctx, tools, partial=action == "ANSWER_PARTIAL", loop=completed_cycles)
if action == "ABSTAIN":
if getattr(tools, "text_attachments_content", ""):
return {"verdict": verdict.__dict__, "kbinfos": tools.kbinfos}
tools.kbinfos["chunks"] = []
return {"verdict": verdict.__dict__, "abstain": True}
return {"verdict": verdict.__dict__, "abstain": True, "loop": completed_cycles}
if action == "FALLBACK_LLM":
return _finalize(ctx, tools, partial=True, loop=completed_cycles)
if not should_continue:
break
# Cycle exhaustion: flag the answer as partial so the final-answer node
# prepends the partial-answer preamble instead of presenting an incomplete
# answer as complete. Partial when (a) some claim is still unverified, or
# (b) every claim is verified but the final verdict is not SUFFICIENT (e.g.
# cross-check flagged conflicts/mismatches) — in both cases an exhaustive
# answer was not reached.
verdict_status = getattr(verdict, "status", None)
partial = (any(not c.is_verified for c in ctx.claims) or (verdict_status is not None and verdict_status != "SUFFICIENT")) and bool(tools.kbinfos.get("chunks"))
return {"kbinfos": tools.kbinfos, "partial_answer": partial}
if not tools.kbinfos.get("chunks"):
return {"empty_result": True, "kbinfos": tools.kbinfos, "loop": completed_cycles}
partial = any(not c.is_verified for c in ctx.claims)
return _finalize(ctx, tools, partial=partial, loop=completed_cycles)
async def _analyze_claim_evidence(
*,
question: str,
claim: ClaimTarget,
query: str,
result: dict,
evidence_ids: list[int],
cycle: int,
max_cycles: int,
tools,
) -> dict:
chunks = result.get("chunks", []) or []
if not chunks:
return {
"is_verified": False,
"confidence": 0.0,
"report": "",
"gaps": ["no evidence found"],
"next_queries": _fallback_queries(question, claim),
}
try:
user = _EVIDENCE_ANALYSIS_USER.format(
question=question,
claim=claim.description,
query=query,
cycle=cycle + 1,
max_cycles=max_cycles,
evidence=_format_evidence(chunks),
)
msg = await tools._fit_messages(_EVIDENCE_ANALYSIS_SYSTEM, user)
ans = await tools.chat_mdl.async_chat(msg[0]["content"], msg[1:], {"temperature": 0.1})
if isinstance(ans, tuple):
ans = ans[0]
parsed = _extract_json(ans)
return _normalize_analysis(parsed, result, evidence_ids, question, claim, cycle, max_cycles)
except Exception:
_LOG.exception("[Decompose search] Evidence analysis LLM call failed.")
return _fallback_analysis(result, cycle, max_cycles, question, claim)
def _mode_label(route) -> str:
if not route:
return "medium"
if isinstance(route, dict):
return route.get("thinking_mode", "medium")
return getattr(route, "thinking_mode", "medium")
def _cycle_budget(state: dict, default_cycles: int) -> int:
try:
requested = int(state.get("max_loops") or default_cycles)
except (TypeError, ValueError):
requested = default_cycles
return max(1, min(default_cycles, requested))
def _extract_json(text: str) -> dict:
text = re.sub(r"^.*</think>", "", text or "", flags=re.DOTALL).strip()
text = re.sub(r"```(?:json)?\s*|\s*```", "", text).strip()
try:
import json_repair
return json_repair.loads(text)
except Exception:
try:
return json.loads(text)
except Exception:
_LOG.warning("[Decompose search] Failed to parse evidence analysis output: %s", text[:200])
return {}
def _normalize_analysis(
parsed: dict,
result: dict,
evidence_ids: list[int],
question: str,
claim: ClaimTarget,
cycle: int,
max_cycles: int,
) -> dict:
confidence = _clamp_float(parsed.get("confidence"), 0.0, 1.0)
is_verified = bool(parsed.get("is_verified")) and bool(evidence_ids) and confidence >= 0.55
report = str(parsed.get("report") or "").strip() or _summarize(result)
gaps = _string_list(parsed.get("gaps"))
next_queries = _string_list(parsed.get("next_queries"))[:_MAX_NEXT_QUERIES]
if is_verified:
gaps = []
next_queries = []
confidence = max(confidence, 0.65)
elif not next_queries and cycle + 1 < max_cycles:
next_queries = _fallback_queries(question, claim)
return {
"is_verified": is_verified,
"confidence": confidence,
"report": report,
"gaps": gaps,
"next_queries": next_queries,
}
def _fallback_analysis(
result: dict,
cycle: int,
max_cycles: int,
question: str = "",
claim: ClaimTarget | None = None,
) -> dict:
chunks = result.get("chunks", []) or []
is_last_cycle = cycle + 1 >= max_cycles
is_verified = bool(chunks) and is_last_cycle
next_queries = [] if is_last_cycle or claim is None else _fallback_queries(question, claim)
return {
"is_verified": is_verified,
"confidence": 0.55 if is_verified else (0.35 if chunks else 0.0),
"report": _summarize(result),
"gaps": [] if is_verified else ["need more specific evidence"],
"next_queries": next_queries,
}
def _pick_next_query(
question: str,
claim: ClaimTarget,
attempted: set[str],
pending: list[str],
) -> str:
while pending:
query = (pending.pop(0) or "").strip()
normalized = _normalize_query(query)
if normalized and normalized not in attempted:
return query
candidates = []
if not attempted:
candidates.append(claim.description)
candidates.extend(_fallback_queries(question, claim))
for query in candidates:
query = (query or "").strip()
normalized = _normalize_query(query)
if normalized and normalized not in attempted:
return query
return ""
def _fallback_queries(question: str, claim: ClaimTarget) -> list[str]:
candidates = []
for gap in _agent_result_gaps(claim.agent_result):
candidates.append(f"{claim.description} {gap}")
if question:
candidates.append(f"{question} {claim.description}")
candidates.append(claim.description)
return candidates[:_MAX_NEXT_QUERIES]
def _new_queries(raw_queries: list[str], attempted: set[str]) -> list[str]:
queries = []
seen = set(attempted)
for query in raw_queries:
query = (query or "").strip()
normalized = _normalize_query(query)
if not normalized or normalized in seen:
continue
seen.add(normalized)
queries.append(query)
if len(queries) >= _MAX_NEXT_QUERIES:
break
return queries
def _agent_result_gaps(agent_result) -> list[str]:
if not agent_result:
return []
if isinstance(agent_result, dict):
return _string_list(agent_result.get("gaps"))
return _string_list(getattr(agent_result, "gaps", []))
def _normalize_query(query: str) -> str:
return " ".join((query or "").lower().split())
def _format_evidence(chunks: list[dict]) -> str:
snippets = []
for i, chunk in enumerate(chunks[:_MAX_EVIDENCE_SNIPPETS], start=1):
text = chunk.get("content_with_weight") or chunk.get("content") or chunk.get("text") or ""
source = chunk.get("docnm_kwd") or chunk.get("doc_name") or chunk.get("doc_id") or "source"
snippets.append(f"[{i}] {source}: {_snip(text, 900)}")
return "\n\n".join(snippets) or "(no evidence)"
def _string_list(value) -> list[str]:
if isinstance(value, str):
return [value.strip()] if value.strip() else []
if not isinstance(value, list):
return []
return [str(v).strip() for v in value if str(v).strip()]
def _clamp_float(value, lo: float, hi: float) -> float:
try:
number = float(value)
except (TypeError, ValueError):
number = 0.0
return min(hi, max(lo, number))
def _evidence_ids(tools, chunks: list[dict]) -> list[int]:
all_chunks = tools.kbinfos.get("chunks", [])
index_by_key = {_chunk_key(c): i for i, c in enumerate(all_chunks)}
ids = []
for chunk in chunks:
idx = index_by_key.get(_chunk_key(chunk))
if idx is not None and idx not in ids:
ids.append(idx)
return ids
def _finalize(ctx: OrchestratorContext, tools, partial: bool, loop: int) -> dict:
combined = []
for claim in ctx.claims:
if claim.agent_result and claim.agent_result.report:
status = "verified" if claim.is_verified else "incomplete"
combined.append(f"[{claim.claim_id}] {status} ({claim.description}): {claim.agent_result.report[:500]}")
if combined:
tools.kbinfos["pre_summary"] = "Research findings. These may include bridge entities; the final answer must still satisfy the original question's requested role.\n\n" + "\n\n".join(combined)
return {
"verdict": ctx.verdict.__dict__ if ctx.verdict else None,
"partial_answer": partial,
"kbinfos": tools.kbinfos,
"loop": loop,
}
def _snip(text: str, limit: int = 160) -> str:
text = (text or "").replace("\n", " ").strip()
return text if len(text) <= limit else text[: limit - 3] + "..."
def _merge_kbinfos(tools, result: dict):
@@ -136,5 +487,5 @@ def _global_evidence_ids(tools, result: dict) -> list[int]:
def _summarize(result: dict) -> str:
chunks = result.get("chunks", [])
texts = [c.get("content_with_weight", "")[:200] for c in chunks[:3]]
texts = [(c.get("content_with_weight") or c.get("content") or c.get("text") or "")[:200] for c in chunks[:3]]
return " | ".join(texts)

View File

@@ -2,6 +2,13 @@
FINAL_ANSWER_SYSTEM = """You are a smart agent. Answer the user's question using ONLY the evidence provided below. Do not invent facts: if the evidence cannot support a claim, say so plainly instead of guessing.
# Answer target
First resolve the exact role requested by the user's question. Multi-hop questions
often mention bridge entities that are only clues. Do not answer with a bridge
entity just because it satisfies a later clue; answer the entity, value, or fact
that satisfies the top-level question. If an Answer Target Contract is provided,
obey it over any research-summary wording.
# Citation rules
{cite_rules}

View File

@@ -114,7 +114,7 @@ def _highlight_keywords(text: str, kwds: list[str]) -> str:
if not terms:
return text
pattern = re.compile("|".join(re.escape(term) for term in terms), re.IGNORECASE)
return pattern.sub(lambda m: f"<em>{m.group(0)}</em>", text)
return pattern.sub(lambda m: f"*{m.group(0)}*", text)
def _narrow_by_keywords(chunks: list[dict], keywords: str) -> list[dict]: