Files
wehub-resource-sync fed8b2eed7
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Backend release / release (push) Has been cancelled
Bandit Security Scan / bandit_scan (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / manifest (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / manifest (push) Has been cancelled
Python linting / ruff (push) Has been cancelled
Run python tests with pytest / Run tests and count coverage (3.12) (push) Has been cancelled
React Widget Build / build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:28:29 +08:00

337 lines
14 KiB
Python

"""Per-source retrieval dispatcher (D4).
Groups a per-source list by its ``config.retrieval.retriever`` key, builds one
retriever instance per group, and merges their results under a single shared
token budget so no group can starve another.
Parity guarantee: when every source is ``classic``/``default`` (the case for
every existing source) all sources flow into ONE ``ClassicRAG`` instance built
exactly as today, so the output — including token-budget behaviour — is
byte-identical to the pre-dispatch path.
"""
from __future__ import annotations
import logging
from typing import Any, Callable, Dict, List, Optional
from application.core.settings import settings
from application.retriever.base import BaseRetriever
from application.retriever.retriever_creator import RetrieverCreator
from application.retriever.stages.prescreen import (
build_prescreen_stages,
max_candidate_k,
)
from application.storage.db.source_config import RetrievalConfig
from application.utils import num_tokens_from_string
logger = logging.getLogger(__name__)
# Retriever keys that share ClassicRAG's single-instance / shared-budget model.
# Grouping all of them into one instance is what preserves byte-identical parity
# for the all-classic case.
_CLASSIC_KEYS = frozenset({"classic", "default"})
# Retriever keys that subclass ClassicRAG and accept ``defer_rephrase``, so the
# eager ctor rephrase can be skipped and computed lazily per-source. Unknown
# keys are excluded: a future non-ClassicRAG retriever may not accept the kwarg.
_DEFERRABLE_KEYS = _CLASSIC_KEYS | {"hybrid"}
# Fields that, when left at their defaults, mean the source did not opt into any
# per-source retrieval override — so it can flow through the global ClassicRAG
# path unchanged (byte-identical parity).
_DEFAULT_RETRIEVAL = RetrievalConfig()
# A post-retrieval stage: takes the candidate docs a group produced (plus the
# resolved query and context) and returns a possibly-filtered/reordered list.
# Left a no-op seam for the later prescreen/rerank stages (F1) to bolt onto.
Stage = Callable[[List[Dict[str, Any]], Dict[str, Any]], List[Dict[str, Any]]]
class Dispatcher(BaseRetriever):
"""Route per-source retrieval to grouped retrievers under a shared budget."""
def __init__(
self,
source,
chat_history=None,
prompt="",
chunks=2,
doc_token_limit=50000,
model_id="docsgpt-local",
user_api_key=None,
agent_id=None,
llm_name=settings.LLM_PROVIDER,
api_key=settings.API_KEY,
decoded_token=None,
model_user_id=None,
request_id=None,
sources: Optional[List[Dict[str, Any]]] = None,
stages: Optional[List[Stage]] = None,
):
"""Build the dispatcher.
Args:
source: ClassicRAG-style source dict (``{"active_docs": [...]}``)
plus the original ``question``. Used as the fallback group when
no per-source ``sources`` list is supplied.
chunks: Global default top-k, used when a source carries no
per-source ``chunks`` hint.
doc_token_limit: Hard cap shared across all groups.
sources: Per-source list; each entry is ``{"id": str, "retrieval":
RetrievalConfig | dict | None}``. When empty the dispatcher
falls back to the single classic group over ``source``.
stages: Optional post-retrieval stages applied to each group's
candidates before final budgeting. Default: none (pass-through).
"""
self._ctor_kwargs = dict(
chat_history=chat_history,
prompt=prompt,
doc_token_limit=doc_token_limit,
model_id=model_id,
user_api_key=user_api_key,
agent_id=agent_id,
llm_name=llm_name,
api_key=api_key,
decoded_token=decoded_token,
model_user_id=model_user_id,
request_id=request_id,
)
self.source = source or {}
self.original_question = self.source.get("question", "")
self.chunks = chunks
self.doc_token_limit = doc_token_limit
self.stages: List[Stage] = stages or []
self._sources = sources or []
self._groups = self._build_groups()
def _build_groups(self) -> List[Dict[str, Any]]:
"""Group the per-source list by retriever key.
All classic/default sources collapse into one group (exact parity);
each non-classic retriever key gets its own group. With no per-source
list, a single classic group over ``self.source`` is produced.
"""
active_docs = self.source.get("active_docs")
if isinstance(active_docs, str):
active_docs = [active_docs]
active_docs = active_docs or []
if not self._sources:
return [
{
"retriever": "classic",
"doc_ids": list(active_docs),
"retrievals": {},
}
]
grouped: Dict[str, Dict[str, Any]] = {}
for entry in self._sources:
doc_id = entry.get("id")
if not doc_id:
continue
retrieval = self._coerce_retrieval(entry.get("retrieval"))
key = (retrieval.retriever or "classic").lower()
if key in _CLASSIC_KEYS:
key = "classic"
group = grouped.setdefault(
key, {"retriever": key, "doc_ids": [], "retrievals": {}}
)
if doc_id not in group["doc_ids"]:
group["doc_ids"].append(doc_id)
# Only record an override when the source opted into a non-default
# retrieval config; a default classic source stays on the global
# ClassicRAG path so all-classic output is byte-identical to today.
if self._is_override(retrieval):
group["retrievals"][doc_id] = retrieval
if not grouped:
return [
{"retriever": "classic", "doc_ids": list(active_docs), "retrievals": {}}
]
# Defend the parity guarantee: any active_doc the per-source list omitted
# would otherwise be dropped from every group. Route the strays through
# the classic group with default retrieval config so they're still
# retrieved (creating the classic group if no source opted into it).
grouped_ids = {
doc_id for group in grouped.values() for doc_id in group["doc_ids"]
}
missing = [doc_id for doc_id in active_docs if doc_id not in grouped_ids]
if missing:
classic_group = grouped.setdefault(
"classic", {"retriever": "classic", "doc_ids": [], "retrievals": {}}
)
for doc_id in missing:
if doc_id not in classic_group["doc_ids"]:
classic_group["doc_ids"].append(doc_id)
return list(grouped.values())
@staticmethod
def _is_override(retrieval: RetrievalConfig) -> bool:
"""True if the source opted into any read-path retrieval override.
Compares the read-path knobs ClassicRAG acts on (chunks /
score_threshold / rephrase_query) plus an opted-in prescreen config; a
source left at defaults takes the global path so all-classic retrieval
stays byte-identical with zero extra LLM calls.
"""
return (
retrieval.chunks != _DEFAULT_RETRIEVAL.chunks
or retrieval.score_threshold != _DEFAULT_RETRIEVAL.score_threshold
or retrieval.rephrase_query != _DEFAULT_RETRIEVAL.rephrase_query
or retrieval.prescreen is not None
)
@staticmethod
def _coerce_retrieval(raw: Any) -> RetrievalConfig:
"""Coerce a per-source ``retrieval`` value to a ``RetrievalConfig``."""
if isinstance(raw, RetrievalConfig):
return raw
if isinstance(raw, dict):
try:
return RetrievalConfig.model_validate(raw)
except Exception:
return RetrievalConfig()
return RetrievalConfig()
def _budget_for_group(self, n_groups: int, group_idx: int) -> int:
"""Split the shared token budget across groups.
With one group the full ``doc_token_limit`` is returned, so the single
ClassicRAG instance reproduces today's budget exactly. With multiple
groups the budget is divided evenly (remainder to the first groups) so
the total never exceeds ``doc_token_limit``.
"""
if n_groups <= 1:
return self.doc_token_limit
base = self.doc_token_limit // n_groups
remainder = self.doc_token_limit % n_groups
return base + (1 if group_idx < remainder else 0)
def _build_group_retriever(self, group: Dict[str, Any], budget: int):
"""Build the retriever for ``group`` with its budget and source list."""
retriever_key = group["retriever"]
group_source = dict(self.source)
group_source["active_docs"] = group["doc_ids"]
kwargs = dict(self._ctor_kwargs)
kwargs["doc_token_limit"] = budget
kwargs["source"] = group_source
# Prescreen fetches a larger candidate set, then a stage trims it; raise
# the effective top-k to candidate_k so the base retriever fetches
# enough for the stage to filter down to the final chunks.
candidate_k = max_candidate_k(group["retrievals"])
kwargs["chunks"] = max(self.chunks, candidate_k or 0)
# With per-source configs the rephrase decision is per-source, so defer
# the eager rephrase to let a rephrase_query=False source skip the call.
if group["retrievals"] and retriever_key in _DEFERRABLE_KEYS:
kwargs["defer_rephrase"] = True
retriever = RetrieverCreator.create_retriever(retriever_key, **kwargs)
# Hand the per-source retrieval configs to the classic retriever so it
# can honour per-source chunks/score_threshold/rephrase in its loop.
if group["retrievals"]:
setattr(retriever, "per_source_retrieval", group["retrievals"])
return retriever
def _group_stages(self, group: Dict[str, Any]) -> List[Stage]:
"""Stages for a group: caller-supplied stages + prescreen stages.
Prescreen stages are built from the group's per-source prescreen config,
so a group with no opted-in source adds nothing — the default path stays
a pure no-op with zero extra LLM calls.
"""
prescreen = build_prescreen_stages(
group["retrievals"],
llm_name=self._ctor_kwargs.get("llm_name"),
api_key=self._ctor_kwargs.get("api_key"),
model_id=self._ctor_kwargs.get("model_id"),
user_api_key=self._ctor_kwargs.get("user_api_key"),
decoded_token=self._ctor_kwargs.get("decoded_token"),
agent_id=self._ctor_kwargs.get("agent_id"),
model_user_id=self._ctor_kwargs.get("model_user_id"),
request_id=self._ctor_kwargs.get("request_id"),
)
return list(self.stages) + prescreen
def _run_stages(
self,
docs: List[Dict[str, Any]],
context: Dict[str, Any],
stages: List[Stage],
) -> List[Dict[str, Any]]:
"""Apply the given post-retrieval stages in order (no-op when empty)."""
for stage in stages:
try:
docs = stage(docs, context)
except Exception as exc:
logger.warning("Retrieval stage failed, skipping: %s", exc)
return docs
def search(self, query: str = "") -> List[Dict[str, Any]]:
"""Run every group under the shared budget and merge the results."""
groups = self._groups
n_groups = len(groups)
# Fast path / exact parity: a single group is just the underlying
# retriever with the full budget — no merge accounting at all.
if n_groups == 1:
retriever = self._build_group_retriever(
groups[0], self.doc_token_limit
)
docs = retriever.search(query) if query else retriever.search()
context = {"query": query, "retriever": groups[0]["retriever"]}
return self._run_stages(docs, context, self._group_stages(groups[0]))
merged: List[Dict[str, Any]] = []
cap = max(int(self.doc_token_limit * 0.9), 100)
cumulative_tokens = 0
for idx, group in enumerate(groups):
budget = self._budget_for_group(n_groups, idx)
retriever = self._build_group_retriever(group, budget)
try:
group_docs = retriever.search(query) if query else retriever.search()
except Exception as exc:
# Log the exception type only — a raw exception message can
# carry the vector-store DSN (with credentials) on connection
# errors.
logger.error(
"Group '%s' search failed: %s",
group["retriever"],
type(exc).__name__,
)
continue
context = {"query": query, "retriever": group["retriever"]}
group_docs = self._run_stages(
group_docs, context, self._group_stages(group)
)
for doc in group_docs:
if cumulative_tokens >= cap:
break
header = f"{doc.get('filename', '')}\n{doc.get('text', '')}"
doc_tokens = num_tokens_from_string(header)
if cumulative_tokens + doc_tokens < cap:
merged.append(doc)
cumulative_tokens += doc_tokens
if cumulative_tokens >= cap:
break
return merged
def build_dispatcher(create_classic: Callable[[], BaseRetriever], **kwargs):
"""Build a Dispatcher, or fall back to the legacy single retriever.
Honours ``settings.PER_SOURCE_RETRIEVAL_ENABLED``: when False, returns the
legacy single ClassicRAG built by ``create_classic`` (the kill-switch).
Args:
create_classic: Zero-arg factory returning the legacy single retriever.
**kwargs: Dispatcher constructor kwargs (including ``sources``).
Returns:
A ``Dispatcher`` or the legacy retriever from ``create_classic``.
"""
if not getattr(settings, "PER_SOURCE_RETRIEVAL_ENABLED", True):
return create_classic()
return Dispatcher(**kwargs)