feat: parser improvements and fixes

This commit is contained in:
Alex committed 2026-08-19 23:49:26 +01:00
1 parent a70362a5f3
commit 0b36257202
55 files changed
+7712 -442

No files matched your search

+52 -11
View File
@@ -1139,6 +1139,53 @@ class BaseAgent(ABC):
# ---- LLM generation ----
def _llm_supports_tools(self) -> bool:
"""Whether the LLM accepts a ``tools`` payload.
``_supports_tools`` is a method on every real provider (the old
truthiness test on the *bound method* never gated anything), but
test doubles sometimes set it as a plain bool, so both shapes are
honored. A provider that never implemented the check — ``BaseLLM``
raises — keeps the historical permissive behavior; the provider
drops the tools itself.
Returns:
True when the ``tools`` kwarg should be sent.
"""
supports = getattr(self.llm, "_supports_tools", None)
if supports is None:
return False
if not callable(supports):
return bool(supports)
try:
return bool(supports())
except Exception:
logger.debug(
"Tool-support check failed for %s; sending tools anyway",
type(self.llm).__name__,
exc_info=True,
)
return True
def _structured_output_kwarg(self) -> Optional[str]:
"""Name of the gen kwarg this LLM takes structured output on.
Read off the LLM *class*, not ``llm_name``: every OpenAI-wire
provider (``openai_compatible``, ``groq``, ``novita``,
``openrouter``, ``docsgpt``) dispatches through an ``OpenAILLM``
subclass, so a string comparison against "openai" silently
dropped guided decoding for all of them. ``BaseLLM`` owns the
declaration so the cross-provider fallback adapter can read it too.
Returns:
``"response_format"``, ``"response_schema"``, or None when the
provider has no structured-output kwarg.
"""
# ``type(self.llm)`` — an instance attribute on a test double would
# otherwise leak a truthy Mock into the gen kwargs.
kwarg = getattr(type(self.llm), "structured_output_kwarg", None)
return kwarg if kwarg in ("response_format", "response_schema") else None
def _llm_gen(
self,
messages: List[Dict],
@@ -1162,11 +1209,7 @@ class BaseAgent(ABC):
if self.attachments:
gen_kwargs["_usage_attachments"] = self.attachments
if (
hasattr(self.llm, "_supports_tools")
and self.llm._supports_tools
and self.tools
):
if self.tools and self._llm_supports_tools():
gen_kwargs["tools"] = self.tools
if (
self.json_schema
@@ -1176,14 +1219,12 @@ class BaseAgent(ABC):
structured_format = self.llm.prepare_structured_output_format(
self.json_schema, strict=getattr(self, "json_schema_strict", True)
)
if structured_format:
if self.llm_name == "openai":
gen_kwargs["response_format"] = structured_format
elif self.llm_name == "google":
gen_kwargs["response_schema"] = structured_format
structured_kwarg = self._structured_output_kwarg()
if structured_format and structured_kwarg:
gen_kwargs[structured_kwarg] = structured_format
elif (
getattr(self, "json_object", False)
and self.llm_name == "openai"
and self._structured_output_kwarg() == "response_format"
and hasattr(self.llm, "_supports_structured_output")
and self.llm._supports_structured_output()
):
+141 -6
View File
@@ -2,7 +2,8 @@
The ``read_document`` action resolves a parent-scoped input artifact, enqueues a
``parse_document`` task on the dedicated ``parsing`` Celery queue, and awaits the
result with a timeout. The run-scoped authz gate is enforced TWICE — here before
result with a timeout (inside a worker it parses inline under the same bound — see
``_dispatch``). The run-scoped authz gate is enforced TWICE — here before
enqueue (reject cross-tenant) and again in the worker (re-resolve, never trusting a
raw path). When a ``json_schema`` is supplied the structured payload is validated
through the existing jsonschema path; the full result may also be persisted as a
@@ -12,7 +13,11 @@ through the existing jsonschema path; the full result may also be persisted as a
from __future__ import annotations
import logging
from typing import Any, Dict, List, Optional
import signal
import threading
from concurrent.futures import ThreadPoolExecutor
from concurrent.futures import TimeoutError as FutureTimeoutError
from typing import Any, Callable, Dict, List, Optional
from celery import current_task
@@ -39,6 +44,15 @@ except Exception: # pragma: no cover - jsonschema is a declared dependency
jsonschema = None # type: ignore[assignment]
class _InlineParseTimeout(BaseException):
"""Raised when an inline (in-worker) parse outlives its window.
Derives from ``BaseException`` on purpose: the SIGALRM interrupt lands deep inside the
parser, whose blanket ``except Exception`` would otherwise swallow it and report a
generic parse failure instead of the shared timed-out result.
"""
class ReadDocumentTool(Tool):
"""Read Document
Parse a document (PDF, Word, PowerPoint, ...) to text, markdown, or structured data.
@@ -58,6 +72,8 @@ class ReadDocumentTool(Tool):
self.workflow_run_id: Optional[str] = self.config.get("workflow_run_id")
self.message_id: Optional[str] = self.config.get("message_id")
self._last_artifact_id: Optional[str] = None
# Byte size of the resolved input, used to scale the awaited parse window.
self._input_size: Optional[int] = None
# ------------------------------------------------------------------
# Tool ABC
@@ -157,6 +173,7 @@ class ReadDocumentTool(Tool):
"""Resolve the input run-scoped (reject cross-tenant before enqueue), enqueue+await, validate."""
input_id = kwargs.get("input")
json_schema = kwargs.get("json_schema")
self._input_size = None
if not isinstance(input_id, str) or not input_id.strip():
return {"status": "error", "error": "input artifact id is required."}
if json_schema is not None:
@@ -199,8 +216,17 @@ class ReadDocumentTool(Tool):
and blocking on ``get()`` would self-deadlock: concurrent agent tasks each hold a pool
slot blocked in ``get()`` so ``parse_document`` never gets a free slot. So parse INLINE
in-process inside a worker; only dispatch+await (degrading on timeout/failure) from web.
Both branches are bounded by the SAME size-scaled window: the inline parse carries no
Celery time limit of its own, so an unbounded one would pin the agent's worker slot
until the OUTER task's limit (webhook runs have none) kills the whole agent run.
"""
parent = self._parent()
from application.api.user.tasks import parse_timeout_for_size
# OCR cost scales with pages, so the parse window grows with the document's size
# (floored at DOCUMENT_PARSE_TIMEOUT).
timeout = parse_timeout_for_size(self._input_size)
# ``current_task`` is a Celery proxy: truthy only while this runs inside a worker task,
# falsy in the web process (the bare proxy is NOT identity-None, so test truthiness).
@@ -208,7 +234,12 @@ class ReadDocumentTool(Tool):
from application.worker import run_parse_document
try:
result = run_parse_document(artifact_id, parent, self.user_id, options)
result = self._run_inline_bounded(
lambda: run_parse_document(artifact_id, parent, self.user_id, options), timeout
)
except _InlineParseTimeout:
logger.warning("read_document: inline parse timed out after %ss; abandoning it", int(timeout))
return {"status": "error", "error": f"document parsing timed out after {int(timeout)}s."}
except Exception as exc:
logger.exception("read_document: inline parse failed")
return {"status": "error", "error": f"document parsing failed: {type(exc).__name__}: {exc}"}
@@ -218,12 +249,18 @@ class ReadDocumentTool(Tool):
from celery.exceptions import TimeoutError as CeleryTimeoutError
from application.api.user.tasks import parse_document
from application.api.user.tasks import parse_document, parse_task_time_limits
timeout = float(getattr(settings, "DOCUMENT_PARSE_TIMEOUT", 120))
# The task's per-call time limits are raised to match the awaited window: bound to
# the base timeout at import, the worker would otherwise self-terminate a large
# parse long before this await gives up.
queue = getattr(settings, "DOCUMENT_PARSE_QUEUE", "parsing")
try:
async_result = parse_document.apply_async(args=[artifact_id, parent, self.user_id, options], queue=queue)
async_result = parse_document.apply_async(
args=[artifact_id, parent, self.user_id, options],
queue=queue,
**parse_task_time_limits(timeout),
)
# The web process (not a worker) awaits here; ``disable_sync_subtasks=False`` keeps
# the call correct if invoked from a non-prefork (eventlet/gevent) worker where the
# inline branch above still ran but the blanket guard would otherwise raise.
@@ -237,6 +274,87 @@ class ReadDocumentTool(Tool):
return {"status": "error", "error": "document parsing produced an unexpected result."}
return result
# ------------------------------------------------------------------
# Inline parse bound
# ------------------------------------------------------------------
@classmethod
def _run_inline_bounded(cls, fn: Callable[[], Any], timeout: Optional[float]) -> Any:
"""Run ``fn`` under a wall-clock bound, raising ``_InlineParseTimeout`` on expiry.
Prefers a SIGALRM interval timer (POSIX, main thread — prefork/solo Celery pools run
the task on the child's main thread), which actually interrupts the parse between
bytecodes and frees the slot. Otherwise falls back to a helper thread, which can only
stop WAITING: the orphaned parse keeps running until the parser finishes.
Args:
fn: Zero-argument callable performing the parse.
timeout: Window in seconds; ``None`` or non-positive runs unbounded.
Returns:
Whatever ``fn`` returns.
"""
if not timeout or timeout <= 0:
return fn()
if cls._sigalrm_usable():
return cls._run_with_sigalrm(fn, timeout)
return cls._run_in_thread(fn, timeout)
@staticmethod
def _sigalrm_usable() -> bool:
"""True when this thread may arm SIGALRM without clobbering another user of the timer."""
if not hasattr(signal, "SIGALRM") or not hasattr(signal, "setitimer"):
return False
if threading.current_thread() is not threading.main_thread():
return False
try:
previous = signal.getsignal(signal.SIGALRM)
armed = signal.getitimer(signal.ITIMER_REAL)
except (ValueError, OSError): # pragma: no cover - non-main thread / no timer
return False
if previous not in (None, signal.SIG_DFL):
return False
return not (armed[0] or armed[1])
@staticmethod
def _run_with_sigalrm(fn: Callable[[], Any], timeout: float) -> Any:
"""Run ``fn`` with a SIGALRM deadline, always restoring the handler and cancelling the timer."""
def _on_alarm(signum: int, frame: Any) -> None:
raise _InlineParseTimeout()
previous = signal.signal(signal.SIGALRM, _on_alarm)
signal.setitimer(signal.ITIMER_REAL, timeout)
try:
return fn()
finally:
# Nested so the handler is restored even if the alarm lands in this very
# window (fn returned just as the timer expired).
try:
signal.setitimer(signal.ITIMER_REAL, 0)
finally:
signal.signal(signal.SIGALRM, previous)
@staticmethod
def _run_in_thread(fn: Callable[[], Any], timeout: float) -> Any:
"""Run ``fn`` in a helper thread and stop waiting at ``timeout`` (the parse is NOT killed)."""
executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="read-document-inline")
try:
future = executor.submit(fn)
try:
return future.result(timeout=timeout)
except FutureTimeoutError:
if future.done(): # ``fn`` itself raised a TimeoutError; surface it as a failure
raise
logger.warning(
"read_document: inline parse exceeded %.0fs off the main thread; the parse thread "
"keeps running until the parser finishes (it cannot be interrupted)",
timeout,
)
raise _InlineParseTimeout() from None
finally:
# Never block the agent run on the orphaned parse.
executor.shutdown(wait=False)
# ------------------------------------------------------------------
# Input resolution (run-scoped gate, before enqueue)
# ------------------------------------------------------------------
@@ -260,6 +378,8 @@ class ReadDocumentTool(Tool):
if artifact_id is not None
else None
)
if artifact is not None:
self._input_size = self._version_size(repo, artifact_id, artifact)
except Exception:
logger.exception("read_document: failed to resolve input artifact")
return {"status": "error", "error": f"failed to resolve input artifact {raw_id}."}
@@ -274,6 +394,17 @@ class ReadDocumentTool(Tool):
return {"status": "error", "error": f"input artifact {raw_id} not found in this conversation/run."}
return str(artifact_id)
@staticmethod
def _version_size(repo: Any, artifact_id: Any, artifact: Dict[str, Any]) -> Optional[int]:
"""Best-effort byte size of the artifact's current version (scales the parse window)."""
try:
version = repo.get_version(artifact_id, artifact.get("current_version"))
except Exception:
logger.debug("read_document: could not read input size; using the base parse window")
return None
size = (version or {}).get("size")
return int(size) if isinstance(size, (int, float)) else None
def _bridge_chat_attachment(self, raw_id: str) -> Any:
"""Bridge a referenced chat attachment to a conversation artifact id; None on miss, error dict on failure."""
if not self.conversation_id or not self.user_id:
@@ -281,6 +412,10 @@ class ReadDocumentTool(Tool):
attachment = match_attachment(self.config.get("attachments"), raw_id, self.user_id)
if attachment is None:
return None
# The attachment row carries the authoritative byte size; the bridged artifact
# is a copy of the same bytes, so it sizes the parse window just as well.
size = attachment.get("size")
self._input_size = int(size) if isinstance(size, (int, float)) else None
try:
return bridge_attachment(attachment, user_id=self.user_id, conversation_id=self.conversation_id)
except AttachmentBridgeError as exc:
+35
View File
@@ -387,8 +387,43 @@ class WorkflowAgent(BaseAgent):
"mime_type": ref["mime_type"],
}
)
self._record_preextracted_text(ref["artifact_id"], attachment)
return refs, dropped
def _record_preextracted_text(self, artifact_id: str, attachment: Dict[str, Any]) -> None:
"""Stage an attachment's upload-time text on the engine so nodes skip re-parsing it."""
if self._engine is None:
return
text = self._usable_attachment_text(attachment)
if text:
self._engine.preextracted_text[str(artifact_id)] = text
@staticmethod
def _usable_attachment_text(attachment: Dict[str, Any]) -> Optional[str]:
"""Return an attachment's stored text when it is safe to reuse verbatim, else None.
The attachment was already parsed (and possibly OCR'd) by ``store_attachment``;
reusing that text is what keeps a scanned document from being OCR'd once per
node. Reuse requires the stored text to be the WHOLE document: a truncated row
was cut head-first at ``ATTACHMENT_MAX_TOKENS`` and would silently lose the tail
that the workflow's own head+tail bounding keeps, so it falls back to a parse.
A row with no ``extraction`` metadata at all (legacy, pre-dating that field) is
trusted when its ``content`` is non-empty: nothing records otherwise, and the
non-workflow chat path already feeds that same text to the model.
"""
content = attachment.get("content")
if not isinstance(content, str) or not content.strip():
return None
metadata = attachment.get("metadata")
extraction = metadata.get("extraction") if isinstance(metadata, dict) else None
if extraction is None:
return content
if not isinstance(extraction, dict):
return None
if extraction.get("status") != "ok" or extraction.get("truncated"):
return None
return content
@staticmethod
def _read_attachment_bytes(storage: Any, upload_path: str, max_bytes: int) -> Optional[bytes]:
"""Read an attachment with a bounded read and a guaranteed handle close; None on failure."""
+55 -16
View File
@@ -76,6 +76,13 @@ class WorkflowEngine:
# embedded draft), so run-scoped artifacts must NOT be persisted as orphans.
# Defaults True; WorkflowAgent flips it off on the draft path.
self.run_persisted: bool = True
# Transient, in-memory ``artifact_id -> text`` for input documents whose text
# was already extracted when the attachment was stored, so a node reuses it
# instead of re-parsing (re-OCRing) the same bytes. Deliberately NOT part of
# ``state``: state is snapshotted into the run row, and the text is large.
# Populated once per run by ``WorkflowAgent._bridge_attachments``; lifetime is
# this engine instance, i.e. exactly the run.
self.preextracted_text: Dict[str, str] = {}
self.state: WorkflowState = {}
self.execution_log: List[Dict[str, Any]] = []
self._condition_result: Optional[str] = None
@@ -805,16 +812,22 @@ class WorkflowEngine:
attachments.append({"id": artifact_id, "mime_type": mime_type, "path": storage_path})
native_count += 1
else:
# Inline-text mimes are read directly (cheap); other mimes route
# through the parsing worker -- a blocking, ~120s per-document call.
# Cap how many documents a single node sends down that path so a
# node referencing many non-native documents (e.g. the ``*`` token)
# can't serialize dozens of parses. Inline text is not capped.
needs_parse = not self._is_inline_text_mime(mime_type)
# Inline-text mimes are read directly (cheap), and a document whose
# text was already extracted when its attachment was stored is reused
# as-is (no parse at all); every other mime routes through the parsing
# worker -- a blocking, size-scaled per-document call. Cap how many
# documents a single node sends down THAT path so a node referencing
# many non-native documents (e.g. the ``*`` token) can't serialize
# dozens of parses. Inline text and reused text are not capped: they
# cost no blocking parse, so charging them would starve the budget.
needs_parse = (
artifact_id not in self.preextracted_text
and not self._is_inline_text_mime(mime_type)
)
if needs_parse:
# Count (and gate on) the parse ATTEMPT, not the success: a
# timed-out/failed parse is the ~120s worst case we must bound,
# so it has to consume cap budget too. Otherwise a degraded
# timed-out/failed parse is the full-window worst case we must
# bound, so it has to consume cap budget too. Otherwise a degraded
# parsing backend (every parse fails) never advances the count
# and the node keeps issuing blocking parses without limit.
if extract_count >= extract_max:
@@ -822,7 +835,7 @@ class WorkflowEngine:
continue
extract_count += 1
content = self._extract_attachment_text(
artifact_id, storage_path, mime_type, filename, max_bytes
artifact_id, storage_path, mime_type, filename, max_bytes, size=size
)
if content is None:
logger.warning(
@@ -873,12 +886,30 @@ class WorkflowEngine:
return mime_type == "application/pdf" and supports_images
def _extract_attachment_text(
self, artifact_id: str, storage_path: str, mime_type: str, filename: str, max_bytes: int
self,
artifact_id: str,
storage_path: str,
mime_type: str,
filename: str,
max_bytes: int,
size: Optional[int] = None,
) -> Optional[str]:
"""Get an attachment's text: inline already-text formats, else parse via the parsing worker; None on failure."""
"""Get an attachment's text: reuse upload-time extraction, else inline text mimes, else parse."""
from application.parser.document_reader import truncate_text_head_tail
from application.storage.storage_creator import StorageCreator
# An uploaded chat attachment was already parsed (and possibly OCR'd) when it
# was stored, so re-parsing it here would repeat the dominant cost of the run
# for every node that references it. Bounded with the same head+tail window the
# inline-text path uses.
preextracted = self.preextracted_text.get(artifact_id)
if preextracted:
logger.info(
"Workflow node: reusing upload-time extraction for %s (%s); skipping re-parse",
filename,
artifact_id,
)
return truncate_text_head_tail(preextracted)
if self._is_inline_text_mime(mime_type):
try:
data = StorageCreator.get_storage().get_file(storage_path).read()
@@ -902,13 +933,17 @@ class WorkflowEngine:
return truncate_text_head_tail(text)
# Non-text mimes parse via the dedicated parsing queue (works on any backend,
# no sandbox): the worker re-resolves the artifact run-scoped and reads its bytes.
return self._parse_document_text(artifact_id)
return self._parse_document_text(artifact_id, size=size)
def _parse_document_text(self, artifact_id: str) -> Optional[str]:
"""Enqueue ``parse_document`` for this run and await the bounded markdown; None on failure."""
def _parse_document_text(self, artifact_id: str, size: Optional[int] = None) -> Optional[str]:
"""Enqueue ``parse_document`` for this run and await the size-scaled markdown; None on failure."""
from celery.exceptions import TimeoutError as CeleryTimeoutError
from application.api.user.tasks import parse_document
from application.api.user.tasks import (
parse_document,
parse_task_time_limits,
parse_timeout_for_size,
)
from application.core.settings import settings
user_id = self._resolve_user_id()
@@ -916,11 +951,15 @@ class WorkflowEngine:
return None
options = {"output": "markdown", "include_tables": False, "persist": False}
queue = getattr(settings, "DOCUMENT_PARSE_QUEUE", "parsing")
timeout = float(getattr(settings, "DOCUMENT_PARSE_TIMEOUT", 120))
# OCR cost scales with pages, so the window grows with the document's size
# (floored at DOCUMENT_PARSE_TIMEOUT); the task's per-call time limits are
# raised to match, else the worker would self-terminate mid-parse.
timeout = parse_timeout_for_size(size)
try:
async_result = parse_document.apply_async(
args=[artifact_id, {"workflow_run_id": self.workflow_run_id}, user_id, options],
queue=queue,
**parse_task_time_limits(timeout),
)
# A workflow can run inside a Celery worker (scheduled runs / webhooks).
# In a prefork worker ``task_join_will_block()`` is process-wide, so the
+51 -4
View File
@@ -1,5 +1,6 @@
import logging
from datetime import timedelta
from typing import Dict, Optional
from sqlalchemy.exc import DataError
@@ -270,6 +271,47 @@ def process_agent_webhook(self, agent_id, payload, idempotency_key=None):
return resp
# Seconds the hard (SIGKILL) limit trails the soft limit, giving the soft handler
# room to unwind and return a terminal result before the worker is killed.
_PARSE_HARD_LIMIT_GRACE = 30
def parse_timeout_for_size(size_bytes: Optional[int]) -> float:
"""Seconds to allow one ``parse_document``: floored at the base timeout, scaled by size, capped.
Args:
size_bytes: Byte size of the document, or None when unknown (base timeout).
Returns:
The parse window in seconds.
"""
from application.core.settings import settings
base = float(getattr(settings, "DOCUMENT_PARSE_TIMEOUT", 120) or 120)
per_mib = float(getattr(settings, "DOCUMENT_PARSE_TIMEOUT_PER_MB", 0) or 0)
ceiling = float(getattr(settings, "DOCUMENT_PARSE_TIMEOUT_MAX", base) or base)
size = float(size_bytes) if isinstance(size_bytes, (int, float)) else 0.0
scaled = base + per_mib * max(size, 0.0) / (1024 * 1024)
return min(ceiling, max(base, scaled))
def parse_task_time_limits(timeout: float) -> Dict[str, int]:
"""Per-call Celery limits for a ``parse_document`` the caller awaits for ``timeout`` seconds.
The task's import-time ``soft_time_limit`` is bound to the BASE timeout, so a caller
awaiting a longer, size-scaled window must raise the per-call limits to match or the
worker self-terminates the parse first.
Args:
timeout: The awaited parse window in seconds.
Returns:
``apply_async`` kwargs carrying the soft and hard time limits.
"""
soft = max(1, int(timeout))
return {"soft_time_limit": soft, "time_limit": soft + _PARSE_HARD_LIMIT_GRACE}
# Not DURABLE: the read_document tool awaits this synchronously with a timeout, so a
# blind autoretry would double-parse and the caller would already have degraded. The
# task is routed to the dedicated ``parsing`` queue (celeryconfig task_routes) so a
@@ -286,18 +328,23 @@ def parse_document(self, artifact_id, parent, user_id, options=None):
# A pathological/malicious document must not pin a parsing-worker slot past the
# window the caller already abandoned. Return the worker's clean error shape so
# the slot frees and the Redis result backend still gets a terminal result.
limit = getattr(self, "soft_time_limit", None)
# ``request.timelimit`` is (hard, soft) and carries the caller's PER-CALL limit,
# so prefer it over the import-time default when reporting the window.
limit = (getattr(self.request, "timelimit", None) or (None, None))[1]
if limit is None:
limit = getattr(self, "soft_time_limit", None)
suffix = f" after {int(limit)}s" if limit else ""
return {"status": "error", "error": f"document parsing timed out{suffix}."}
# Bind the soft limit to DOCUMENT_PARSE_TIMEOUT (the same window read_document awaits) so
# Bind the soft limit to DOCUMENT_PARSE_TIMEOUT (the floor of the window callers await) so
# the prefork worker self-terminates a runaway parse instead of pinning the slot; the hard
# limit is the SIGKILL backstop if the soft handler can't unwind in time.
# limit is the SIGKILL backstop if the soft handler can't unwind in time. Callers awaiting a
# size-scaled window override both per call via ``parse_task_time_limits``.
try:
from application.core.settings import settings as _parse_settings
parse_document.soft_time_limit = int(_parse_settings.DOCUMENT_PARSE_TIMEOUT)
parse_document.time_limit = parse_document.soft_time_limit + 30
parse_document.time_limit = parse_document.soft_time_limit + _PARSE_HARD_LIMIT_GRACE
except Exception:
pass
+9 -1
View File
@@ -31,7 +31,10 @@ from application.api.v1 import v1_bp # noqa: E402
from application.celery_init import celery # noqa: E402
from application.core.secret_key import resolve_jwt_secret_key # noqa: E402
from application.core.settings import settings # noqa: E402
from application.storage.db.bootstrap import ensure_database_ready # noqa: E402
from application.storage.db.bootstrap import ( # noqa: E402
ensure_database_ready,
ensure_vector_schema,
)
from application.stt.upload_limits import ( # noqa: E402
build_stt_file_size_limit_message,
should_reject_stt_request,
@@ -59,6 +62,11 @@ ensure_database_ready(
logger=logging.getLogger("application.app"),
)
# Own the vector DB's schema here too, so the retrieval hot path is pure reads
# instead of re-running DDL for every source of every request.
if settings.AUTO_VECTOR_SCHEMA:
ensure_vector_schema(logger=logging.getLogger("application.app"))
from application.agents.default_tools import ( # noqa: E402
validate_default_chat_tools,
)
+98 -8
View File
@@ -26,6 +26,73 @@ def _cache_default(value):
return f"<bytes:sha256:{hashlib.sha256(bytes(value)).hexdigest()}>"
return repr(value)
# Generation kwargs that never reach the provider: usage-accounting side
# channels only. Everything else the caller passes (``response_format``,
# ``response_schema``, ``tool_choice``, ``reasoning_effort``, sampling
# params, ...) is part of the request and therefore part of the key —
# otherwise a workflow node that changed its JSON schema replays the old
# schema's cached answer for the whole TTL.
_CACHE_KEY_IGNORED_KWARGS = frozenset({"_usage_attachments", "attachments"})
# Kwargs that make the answer depend on provider-held state no key can
# capture. ``previous_response_id`` chains a Responses API turn server
# side, and a cache hit would also skip the ``_last_response_id``
# bookkeeping the next turn needs — so skip the cache entirely.
_CACHE_BYPASS_KWARGS = ("previous_response_id",)
def _gen_kwargs_fingerprint(extra: dict | None) -> str:
"""Stable fingerprint of the generation-affecting kwargs.
Args:
extra: Keyword arguments forwarded to the generation call.
Returns:
A sorted JSON dump of the semantic kwargs, or "" when there are none.
Raises:
ValueError: If the kwargs cannot be serialized (callers treat this
as "do not cache").
"""
if not extra:
return ""
filtered = {
key: value
for key, value in extra.items()
if key not in _CACHE_KEY_IGNORED_KWARGS and value is not None
}
if not filtered:
return ""
try:
return json.dumps(filtered, sort_keys=True, default=_cache_default)
except (TypeError, ValueError) as e:
raise ValueError(f"Unserializable generation kwargs: {e}") from e
def _bypasses_cache(extra: dict | None) -> bool:
"""Whether a kwarg ties the call to provider-side conversation state."""
if not extra:
return False
return any(extra.get(key) for key in _CACHE_BYPASS_KWARGS)
def _is_stream_payload(raw: str) -> bool:
"""Whether a cached value is a ``stream_cache`` chunk envelope.
``gen_cache`` and ``stream_cache`` share one key space, so a
non-streaming read can land on a stream entry; without this guard the
serialized chunk list would be handed back as the model's answer.
"""
if not raw.startswith("{"):
return False
try:
decoded = json.loads(raw)
except ValueError:
return False
return isinstance(decoded, dict) and isinstance(decoded.get("chunks"), list)
_redis_instance = None
_redis_creation_failed = False
_instance_lock = Lock()
@@ -116,23 +183,44 @@ def get_pubsub_redis_instance():
return _pubsub_redis_instance
def gen_cache_key(messages, model="docgpt", tools=None):
def gen_cache_key(messages, model="docgpt", tools=None, extra=None):
"""Build the Redis key for one generation call.
Args:
messages: Chat messages for the call.
model: Model identifier.
tools: Tool schemas, when the call carries any.
extra: Remaining generation kwargs (``response_format``,
``response_schema``, sampling params, ...). Non-semantic keys
are dropped before hashing; the suffix is omitted entirely when
nothing semantic remains, so keys for plain calls are unchanged.
Returns:
Hex digest used as the cache key.
Raises:
ValueError: If ``messages`` holds a non-dict entry, or ``extra``
cannot be serialized.
"""
if not all(isinstance(msg, dict) for msg in messages):
raise ValueError("All messages must be dictionaries.")
messages_str = json.dumps(messages, default=_cache_default)
tools_str = json.dumps(str(tools)) if tools else ""
combined = f"{model}_{messages_str}_{tools_str}"
extra_str = _gen_kwargs_fingerprint(extra)
if extra_str:
combined = f"{combined}_{extra_str}"
cache_key = get_hash(combined)
return cache_key
def gen_cache(func):
def wrapper(self, model, messages, stream, tools=None, *args, **kwargs):
if tools is not None:
if tools is not None or _bypasses_cache(kwargs):
return func(self, model, messages, stream, tools, *args, **kwargs)
try:
cache_key = gen_cache_key(messages, model, tools)
cache_key = gen_cache_key(messages, model, tools, extra=kwargs)
except ValueError as e:
logger.error(f"Cache key generation failed: {e}")
return func(self, model, messages, stream, tools, *args, **kwargs)
@@ -142,7 +230,9 @@ def gen_cache(func):
try:
cached_response = redis_client.get(cache_key)
if cached_response:
return cached_response.decode("utf-8")
decoded = cached_response.decode("utf-8")
if not _is_stream_payload(decoded):
return decoded
except Exception as e:
logger.error(f"Error getting cached response: {e}", exc_info=True)
@@ -160,12 +250,12 @@ def gen_cache(func):
def stream_cache(func):
def wrapper(self, model, messages, stream, tools=None, *args, **kwargs):
if tools is not None:
if tools is not None or _bypasses_cache(kwargs):
yield from func(self, model, messages, stream, tools, *args, **kwargs)
return
try:
cache_key = gen_cache_key(messages, model, tools)
cache_key = gen_cache_key(messages, model, tools, extra=kwargs)
except ValueError as e:
logger.error(f"Cache key generation failed: {e}")
yield from func(self, model, messages, stream, tools, *args, **kwargs)
+21
View File
@@ -75,6 +75,9 @@ class Settings(BaseSettings):
AUTO_MIGRATE: bool = True
# On app startup, create the target Postgres database if it's missing (requires CREATEDB privilege). Dev-friendly default.
AUTO_CREATE_DB: bool = True
# On app startup, create the pgvector/graph tables and verify the embedding dimension. Set False to manage them
# out-of-band — there is no Alembic migration for the vector DB because it may be a separate cluster.
AUTO_VECTOR_SCHEMA: bool = True
LLM_PATH: str = os.path.join(current_dir, "models/docsgpt-7b-f16.gguf")
DEFAULT_MAX_HISTORY: int = 150
DEFAULT_LLM_TOKEN_LIMIT: int = 128000 # Fallback when model not found in registry
@@ -108,6 +111,12 @@ class Settings(BaseSettings):
DOCLING_COMPILE_TORCH_MODELS: bool = False
DOCLING_TABULAR_MAX_BYTES: int = 2_000_000
DOCLING_MARKUP_MAX_BYTES: int = 8_000_000
# Chars-per-page floor below which an OCR'd PDF/image parse is treated as a docling
# pipeline dropout (long-running workers were observed returning zero characters for
# every scanned page after a long scanned PDF, with no error) rather than as content.
# Such a parse is retried once on a fresh full-page-OCR converter and then fails
# loudly instead of indexing an empty document. 0 disables the guard.
DOCLING_OCR_MIN_CHARS_PER_PAGE: int = 20
# Read PDF *attachments* via their embedded text layer (pypdfium2) instead
# of docling, falling back to docling when there is no text layer to read.
# Attachments go into a prompt, so docling's structural markdown earns far
@@ -127,6 +136,9 @@ class Settings(BaseSettings):
# ``RetrieverCreator.retrievers`` registry keys (``classic`` / ``default``),
# NOT the legacy ``classic_rag`` label which never matched the registry.
RETRIEVERS_ENABLED: list = ["classic", "default"]
# Concurrent per-source vector searches within one retrieval (multi-source chats);
# the query is embedded once and shared across sources.
RETRIEVAL_MAX_PARALLEL_SOURCES: int = 4
# Kill-switch for per-source retrieval dispatch. When False the retrieval
# path collapses to today's single-retriever behavior (consumed by the
# Dispatcher in a later change; defined here so the flag exists up front).
@@ -225,6 +237,9 @@ class Settings(BaseSettings):
# dialect form (``postgresql+psycopg://``) are all accepted and
# normalized internally for ``psycopg.connect()``.
PGVECTOR_CONNECTION_STRING: Optional[str] = None
# Per-process psycopg connection pool for the vector store; 0 = one direct
# connection per store instance, the legacy behaviour.
PGVECTOR_POOL_MAX_SIZE: int = 8
# IVFFlat probes for vector search. ``None`` derives sqrt(lists) from the
# index itself; set an integer to pin it. Higher = better recall, more scan.
PGVECTOR_IVFFLAT_PROBES: Optional[int] = None
@@ -428,6 +443,12 @@ class Settings(BaseSettings):
# ``read_document`` parsing on a dedicated Celery ``parsing`` queue (backend parser).
DOCUMENT_PARSE_QUEUE: str = "parsing" # queue the parse_document task is routed to
DOCUMENT_PARSE_TIMEOUT: int = 120 # seconds the tool awaits the enqueued parse before degrading
# The base timeout is a FLOOR: the awaited window (and the task's per-call Celery
# time limits) grow with the document's size, because OCR cost scales with pages
# -- a 30-page scan needs ~60s, a 100-page scan several minutes. Without this a
# large scan is silently dropped from the node/tool at the base window.
DOCUMENT_PARSE_TIMEOUT_PER_MB: int = 60 # extra seconds of parse window per MiB of input
DOCUMENT_PARSE_TIMEOUT_MAX: int = 900 # absolute ceiling on the size-scaled parse window
DOCUMENT_PARSE_MAX_BYTES: int = 0 # cap on a parsed document's bytes (0 = reuse SANDBOX_MAX_INPUT_BYTES)
DOCUMENT_MAX_DECOMPRESSED_BYTES: int = 300 * 1024 * 1024
DOCUMENT_MAX_ARCHIVE_ENTRIES: int = 10000
+4 -4
View File
@@ -26,7 +26,9 @@ from typing import Any, Callable, Dict, List, Optional
from application.core.settings import settings
from application.llm.llm_creator import LLMCreator
from application.storage.db.source_config import SourceConfig
from application.vectorstore.base import EmbeddingsSingleton
# ``EmbeddingsSingleton`` is re-exported here so callers and tests can reach the
# shared instance cache from this module.
from application.vectorstore.base import EmbeddingsSingleton, get_embeddings # noqa: F401
logger = logging.getLogger(__name__)
@@ -193,9 +195,7 @@ def extract_graph_for_source(
skipped_over_cap = max(0, len(pending) - cap)
to_process = pending[:cap]
embedding = EmbeddingsSingleton.get_instance(
settings.EMBEDDINGS_NAME, settings.EMBEDDINGS_KEY
)
embedding = get_embeddings()
llm = _build_extraction_llm(
_resolve_extraction_model(config), user, request_id
+182 -27
View File
@@ -1,12 +1,14 @@
"""Per-source knowledge-graph store co-located with the pgvector ``documents`` table.
GraphRAG is pgvector-only: the graph tables live in the same DB as the
pgvector store and are created on-demand (``CREATE TABLE IF NOT EXISTS`` +
``CREATE EXTENSION IF NOT EXISTS vector``), mirroring
``PGVectorStore._ensure_table_exists`` rather than going through app-DB Alembic.
That DB may be a separate cluster (e.g. Neon) from the app DB where ``sources``
lives, so ``source_id`` is a plain indexed UUID column with no cross-DB FK and
all ids are generated in Python.
GraphRAG is pgvector-only: the graph tables live in the same DB as the pgvector
store and are created at boot by ``ensure_vector_schema`` (``CREATE TABLE IF NOT
EXISTS`` + ``CREATE EXTENSION IF NOT EXISTS vector``), mirroring
``PGVectorStore.create_schema`` rather than going through app-DB Alembic.
Constructing a store runs no DDL and opens no connection; the write methods
re-check the schema once per instance as a safety net for a process that never
ran the boot hook. That DB may be a separate cluster (e.g. Neon) from the app DB
where ``sources`` lives, so ``source_id`` is a plain indexed UUID column with no
cross-DB FK and all ids are generated in Python.
"""
from __future__ import annotations
@@ -18,9 +20,15 @@ from typing import Any, Dict, List, Optional
from psycopg.types.json import Jsonb
from application.core.settings import settings
from application.vectorstore import pgconn
DEFAULT_NAME_EMBEDDING_DIM = 768
# Mirrors ``PGVectorStore.DEFAULT_POOL_MAX_SIZE``; duplicated rather than
# imported so the graph store never pulls the vector store (and its embeddings
# stack) in at import time. 0 disables pooling.
DEFAULT_POOL_MAX_SIZE = 8
MAX_SUBGRAPH_NODES = 500
MAX_SUBGRAPH_EDGES = 2000
@@ -30,6 +38,14 @@ GRAPH_OVERVIEW_MAX_LIMIT = 250
PGVECTOR_SOURCE_COLUMN = "source_id"
def _resolve_pool_max_size() -> int:
"""Pool size from settings, defensively — 0 means one direct connection."""
value = getattr(settings, "PGVECTOR_POOL_MAX_SIZE", DEFAULT_POOL_MAX_SIZE)
if isinstance(value, int) and not isinstance(value, bool) and value >= 0:
return value
return DEFAULT_POOL_MAX_SIZE
def _safe_identifier(name: str) -> str:
"""Return ``name`` if it is a bare SQL identifier, else raise.
@@ -97,35 +113,74 @@ class GraphStore:
self._psycopg = psycopg
self._register_vector = register_vector
self._connection = None
self._ensure_tables()
self._pooled = False
self._pool_max_size = _resolve_pool_max_size()
# No DDL here: the graph tables are created at boot alongside the
# pgvector table, and every write re-checks once (see _ensure_tables_once).
self._tables_ensured = False
def _get_connection(self):
"""Get or create this store's connection, pooled unless pooling is off.
Shares :mod:`application.vectorstore.pgconn`'s per-DSN pool with
``PGVectorStore``, so a retrieval that touches both pays one checkout
each instead of two fresh connect handshakes.
"""
if self._connection is None or self._connection.closed:
self._connection = self._psycopg.connect(self._connection_string)
self._register_vector(self._connection)
if self._pool_max_size > 0:
self._connection = pgconn.pool_for(
self._connection_string, self._pool_max_size
).getconn()
self._pooled = True
# No _register_pgvector_types here: the pool's ``configure``
# hook already ran it on this physical connection, and repeating
# it would cost a catalog lookup on every checkout. The DDL path
# (_ensure_tables) still re-registers after CREATE EXTENSION.
else:
self._connection = self._psycopg.connect(self._connection_string)
self._register_pgvector_types(self._connection)
self._pooled = False
return self._connection
def _register_pgvector_types(self, conn) -> None:
"""Register pgvector's adapters, tolerating a not-yet-created extension.
``register_vector`` looks the ``vector`` type up in the catalog and
raises when it is absent — which is exactly the state of a brand-new
database, before ``CREATE EXTENSION`` has run. Swallow that so the
schema bootstrap can proceed; it re-registers once the type exists.
"""
try:
self._register_vector(conn)
except Exception as e:
logging.debug("pgvector types not registered yet: %s", e)
def _embedding_dim(self) -> int:
"""Dimension of the configured embeddings model, matching ``PGVectorStore``.
Falls back to ``DEFAULT_NAME_EMBEDDING_DIM`` so the graph table and the
pgvector ``documents`` table always agree on the configured model.
"""
from application.vectorstore.base import EmbeddingsSingleton
from application.vectorstore.base import get_embeddings
embedding = EmbeddingsSingleton.get_instance(
settings.EMBEDDINGS_NAME, settings.EMBEDDINGS_KEY
)
embedding = get_embeddings()
return getattr(embedding, "dimension", DEFAULT_NAME_EMBEDDING_DIM)
def _ensure_tables(self):
conn = self._get_connection()
@staticmethod
def create_schema(conn, *, dimension: int = DEFAULT_NAME_EMBEDDING_DIM) -> None:
"""Create the graph tables and indexes on ``conn`` without committing.
Shared by the boot hook (``ensure_vector_schema``) and the store's own
write-path safety net; the caller owns the transaction.
Args:
conn: Open psycopg connection to the pgvector database.
dimension: Width of the node name-embedding vectors.
"""
cursor = conn.cursor()
try:
cursor.execute("CREATE EXTENSION IF NOT EXISTS vector;")
embedding_dim = self._embedding_dim()
cursor.execute(
f"""
CREATE TABLE IF NOT EXISTS graph_nodes (
@@ -137,7 +192,7 @@ class GraphStore:
description TEXT,
degree INT DEFAULT 0,
doc_freq INT DEFAULT 0,
name_embedding vector({embedding_dim}),
name_embedding vector({dimension}),
UNIQUE (source_id, normalized_name)
);
"""
@@ -206,14 +261,44 @@ class GraphStore:
# post-filter in search_nodes_by_embedding that silently returns
# zero nodes, and graph_rag does not fall back when the source has
# nodes. Add an index deliberately once a graph is large enough.
finally:
cursor.close()
def _ensure_tables(self):
"""Create the graph schema under an advisory lock, then commit."""
# Same key as the pgvector store and the boot hook: one lock guards all
# DDL in this database, so concurrent workers never race each other.
from application.vectorstore.pgvector import SCHEMA_LOCK_KEY
conn = self._get_connection()
try:
cursor = conn.cursor()
try:
cursor.execute(
"SELECT pg_advisory_xact_lock(hashtext(%s));", (SCHEMA_LOCK_KEY,)
)
finally:
cursor.close()
self.create_schema(conn, dimension=self._embedding_dim())
conn.commit()
# The extension may have just been created; pick up its adapters.
self._register_pgvector_types(conn)
except Exception as e:
conn.rollback()
logging.error(f"Error creating graph tables: {e}")
raise
finally:
cursor.close()
def _ensure_tables_once(self) -> None:
"""Create the graph schema on this instance's first write, at most once.
Readers never create tables — boot owns the schema. This is the safety
net for a process that never ran the boot hook (scripts, tests, the
first extraction on a brand-new deployment).
"""
if getattr(self, "_tables_ensured", False):
return
self._ensure_tables()
self._tables_ensured = True
def _upsert_node(
self,
@@ -287,6 +372,7 @@ class GraphStore:
incremented, the type is refreshed if previously empty, and the
embedding is refreshed when provided. Returns the node id either way.
"""
self._ensure_tables_once()
conn = self._get_connection()
cursor = conn.cursor()
try:
@@ -351,6 +437,7 @@ class GraphStore:
source_chunk_ids: Optional[List[str]] = None,
) -> str:
"""Insert an edge and bump the degree of both endpoints. Returns its id."""
self._ensure_tables_once()
conn = self._get_connection()
cursor = conn.cursor()
try:
@@ -384,6 +471,7 @@ class GraphStore:
)
def link_node_chunk(self, source_id: str, node_id: str, chunk_id: str):
self._ensure_tables_once()
conn = self._get_connection()
cursor = conn.cursor()
try:
@@ -415,6 +503,7 @@ class GraphStore:
are not bumped here — the caller runs ``set_node_degrees`` once at the
end. Returns ``(nodes_upserted, edges_added)``.
"""
self._ensure_tables_once()
conn = self._get_connection()
cursor = conn.cursor()
node_ids: Dict[str, str] = {}
@@ -540,6 +629,53 @@ class GraphStore:
cursor.close()
conn.rollback()
def count_nodes_many(self, source_ids: List[str]) -> Dict[str, int]:
"""Node counts for several sources in one round trip.
Replaces the retriever's per-source ``count_nodes`` fan-out: N sources
used to cost N queries (each on its own fresh connection). Ids with no
rows are filled in with 0 in Python, so the caller always gets an entry
for every id it asked about.
Args:
source_ids: Source ids to count; empty/falsy entries are ignored.
Returns:
dict: ``{source_id: node_count}``, zero-filled. All zeros when the
query fails, which drives the ClassicRAG fallback exactly as a
failing ``count_nodes`` does.
"""
ids = [str(s) for s in source_ids if s]
if not ids:
return {}
counts: Dict[str, int] = {source_id: 0 for source_id in ids}
# ``source_id`` is a UUID column, so Postgres hands back the canonical
# lowercase text. Map it to the exact string the caller passed, or a
# differently-cased id would land under a second key and read as 0.
by_canonical = {source_id.lower(): source_id for source_id in ids}
conn = self._get_connection()
cursor = conn.cursor()
try:
cursor.execute(
"""
SELECT source_id, count(*)
FROM graph_nodes
WHERE source_id = ANY(%s)
GROUP BY source_id;
""",
(ids,),
)
for row in cursor.fetchall():
returned = str(row[0])
counts[by_canonical.get(returned.lower(), returned)] = int(row[1])
return counts
except Exception as e:
logging.error(f"Error counting nodes: {e}")
return counts
finally:
cursor.close()
conn.rollback()
def search_nodes_by_embedding(
self, source_id: str, query_embedding: List[float], k: int = 10
) -> List[Dict[str, Any]]:
@@ -860,6 +996,7 @@ class GraphStore:
(``WHERE id IN (src, dst)`` bumps the endpoint a single time when
``src == dst``). ``UNION`` deduplicates the two endpoints of each edge.
"""
self._ensure_tables_once()
conn = self._get_connection()
cursor = conn.cursor()
try:
@@ -891,6 +1028,7 @@ class GraphStore:
cursor.close()
def mark_chunk(self, source_id: str, chunk_id: str, status: str):
self._ensure_tables_once()
conn = self._get_connection()
cursor = conn.cursor()
try:
@@ -952,6 +1090,7 @@ class GraphStore:
def delete_by_source(self, source_id: str):
"""Remove every graph row for a source (no FK cascade across clusters)."""
self._ensure_tables_once()
conn = self._get_connection()
cursor = conn.cursor()
try:
@@ -972,10 +1111,26 @@ class GraphStore:
finally:
cursor.close()
def close(self) -> None:
"""Release this store's connection: back to the pool, or closed outright.
A pooled connection is rolled back first when it is still in a
transaction, so the next borrower gets a clean session.
"""
conn = getattr(self, "_connection", None)
if conn is None:
return
self._connection = None
pgconn.release(
getattr(self, "_connection_string", ""),
conn,
getattr(self, "_pooled", False),
)
def __del__(self):
if (
hasattr(self, "_connection")
and self._connection
and not self._connection.closed
):
self._connection.close()
"""Release the connection when the object is destroyed. Never raises."""
try:
self.close()
except Exception:
# Interpreter teardown can null out module globals; never raise here.
pass
+158 -4
View File
@@ -1,6 +1,6 @@
import logging
from abc import ABC, abstractmethod
from typing import ClassVar
from typing import ClassVar, Dict, Optional, Tuple
import httpx
import openai
@@ -38,6 +38,24 @@ class BaseLLM(ABC):
# calls by vendor. Subclasses override.
provider_name: ClassVar[str] = "unknown"
# Name of the gen kwarg this provider takes structured output on
# ("response_format" for OpenAI-wire classes, "response_schema" for
# Google); None = the provider has no structured-output kwarg.
structured_output_kwarg: ClassVar[Optional[str]] = None
# (json_schema, strict) last passed to ``prepare_structured_output_format``;
# lets a cross-provider fallback re-prepare the schema in the backup's own
# format instead of forwarding a kwarg the backup cannot read.
_structured_output_source: Optional[Tuple[Dict, bool]] = None
# Gen kwargs that carry structured output, across providers. Kept here
# (not on the provider subclasses) because the adapter has to recognize
# every provider's kwarg, not just its own.
_STRUCTURED_OUTPUT_KWARGS: ClassVar[Tuple[str, ...]] = (
"response_format",
"response_schema",
)
def __init__(
self,
decoded_token=None,
@@ -300,6 +318,132 @@ class BaseLLM(ABC):
prepared.append({**message, "content": parts})
return prepared
@staticmethod
def _fallback_enforces_structured_output(fallback) -> bool:
"""Whether the fallback's capabilities still allow schema enforcement."""
supports = getattr(fallback, "_supports_structured_output", None)
if supports is None:
return True
if not callable(supports):
return bool(supports)
try:
return bool(supports())
except Exception:
logger.debug(
"Structured-output capability check failed for %s; assuming supported",
type(fallback).__name__,
exc_info=True,
)
return True
@staticmethod
def _recover_structured_output_source(present: Dict) -> Optional[Tuple[Dict, bool]]:
"""Recover a raw (schema, strict) pair from already-prepared kwargs.
Callers that hand-build an OpenAI ``response_format`` never went through
``prepare_structured_output_format``, so nothing was recorded; the raw
schema is still readable out of the envelope. A Google
``response_schema`` is a lossy, type-mapped conversion — not reversible —
so it yields nothing.
"""
response_format = present.get("response_format")
if not isinstance(response_format, dict):
return None
json_schema = response_format.get("json_schema")
if not isinstance(json_schema, dict):
return None
schema = json_schema.get("schema")
if not isinstance(schema, dict) or not schema:
return None
return schema, bool(json_schema.get("strict", True))
def _adapt_structured_output_kwargs(self, fallback, kwargs: Dict) -> Dict:
"""Re-express the primary's structured-output kwargs for ``fallback``.
Structured output is provider-specific: OpenAI-wire classes take
``response_format``, Google takes ``response_schema``. Forwarding the
primary's kwarg verbatim to a different-family backup either loses
enforcement silently (Google swallows ``response_format`` in
``**kwargs``) or raises ``TypeError`` inside the OpenAI SDK
(``response_schema`` is not a Chat-Completions param), which turns the
fallback into no fallback at all.
Args:
fallback: Backup LLM the request is about to be re-sent to.
kwargs: The primary's generation kwargs.
Returns:
A copy of ``kwargs`` carrying the backup's own structured-output
kwarg, or neither when the backup cannot enforce a schema.
"""
adapted = dict(kwargs)
present = {
name: adapted.pop(name)
for name in self._STRUCTURED_OUTPUT_KWARGS
if name in adapted
}
if not present:
return adapted
target = getattr(fallback, "structured_output_kwarg", None)
if not target or not self._fallback_enforces_structured_output(fallback):
logger.warning(
f"Fallback {fallback.model_id} cannot enforce structured "
f"output; continuing unstructured"
)
return adapted
response_format = present.get("response_format")
if (
isinstance(response_format, dict)
and response_format.get("type") == "json_object"
):
# json_object mode is an OpenAI-wire shape with no wired equivalent
# elsewhere; keep it only within the same family.
if target == "response_format":
adapted[target] = response_format
else:
logger.warning(
f"Fallback {fallback.model_id} has no json_object mode; "
f"continuing unstructured"
)
return adapted
if target in present:
# Same wire family (OpenAI -> openai_compatible, Google -> Google):
# the prepared value is already in the backup's format.
adapted[target] = present[target]
return adapted
source = self._structured_output_source
if not source:
source = self._recover_structured_output_source(present)
if not source:
logger.warning(
f"Fallback {fallback.model_id} needs {target} but the source "
f"schema is unavailable; continuing unstructured"
)
return adapted
schema, strict = source
try:
prepared = fallback.prepare_structured_output_format(schema, strict=strict)
except Exception:
logger.warning(
f"Failed to prepare structured output for fallback "
f"{fallback.model_id}; continuing unstructured",
exc_info=True,
)
prepared = None
if prepared:
adapted[target] = prepared
else:
logger.warning(
f"Fallback {fallback.model_id} cannot enforce structured "
f"output; continuing unstructured"
)
return adapted
def _execute_with_fallback(
self, method_name: str, decorators: list, *args, **kwargs
):
@@ -371,6 +515,9 @@ class BaseLLM(ABC):
for decorator in decorators:
fallback_method = decorator(fallback_method)
fallback_kwargs = {**kwargs, "model": fallback.model_id}
fallback_kwargs = self._adapt_structured_output_kwargs(
fallback, fallback_kwargs
)
if fallback_kwargs.get("messages"):
fallback_kwargs["messages"] = self._prepare_fallback_messages(
fallback,
@@ -502,6 +649,9 @@ class BaseLLM(ABC):
for decorator in decorators:
fallback_method = decorator(fallback_method)
fallback_kwargs = {**kwargs, "model": fallback.model_id}
fallback_kwargs = self._adapt_structured_output_kwargs(
fallback, fallback_kwargs
)
if fallback_kwargs.get("messages"):
fallback_kwargs["messages"] = self._prepare_fallback_messages(
fallback,
@@ -673,9 +823,13 @@ class BaseLLM(ABC):
def _supports_structured_output(self):
return False
def prepare_structured_output_format(self, json_schema):
"""Prepare structured output format specific to the LLM provider"""
_ = json_schema
def prepare_structured_output_format(self, json_schema, strict=True):
"""Prepare structured output format specific to the LLM provider.
Overrides must record ``self._structured_output_source`` so a
cross-provider fallback can re-prepare the schema in its own format.
"""
_ = (json_schema, strict)
return None
def get_supported_attachment_types(self):
+3
View File
@@ -12,6 +12,7 @@ from application.storage.storage_creator import StorageCreator
class GoogleLLM(BaseLLM):
provider_name = "google"
structured_output_kwarg = "response_schema"
def __init__(
self, api_key=None, user_api_key=None, decoded_token=None, *args, **kwargs
@@ -674,6 +675,8 @@ class GoogleLLM(BaseLLM):
``strict`` is accepted for signature parity with the OpenAI provider;
Google enforces the schema natively via ``response_schema``.
"""
# Recorded so a cross-provider fallback can re-prepare the raw schema.
self._structured_output_source = (json_schema, strict) if json_schema else None
if not json_schema:
return None
type_map = {
+123 -5
View File
@@ -5,7 +5,7 @@ import json
import logging
import os.path
from openai import OpenAI
from openai import BadRequestError, OpenAI
from application.core.settings import settings
from application.llm.base import BaseLLM
@@ -14,6 +14,50 @@ from application.storage.storage_creator import StorageCreator
# Placeholder sent to OpenAI-compatible backends that require no credentials.
NO_API_KEY = "sk-no-key"
# Request keys that only make sense alongside ``tools``; all dropped together
# when an endpoint turns out not to serve tool calling.
_TOOL_REQUEST_KEYS = ("tools", "tool_choice", "parallel_tool_calls")
# Substrings an OpenAI-compatible server puts in a 400 when it understands the
# request but cannot serve tool calling: vLLM without --enable-auto-tool-choice
# / --tool-call-parser, llama.cpp, TGI, Ollama and friends. Matched case
# insensitively, and only for requests that actually carried tools — so a false
# positive degrades to a tool-less answer rather than a failed chat.
_TOOLS_UNSUPPORTED_MARKERS = (
"enable-auto-tool-choice",
"tool-call-parser",
"tool_choice",
"tool choice",
"tools are not supported",
"tools is not supported",
"does not support tools",
"doesn't support tools",
"tool calling",
"tool call is not supported",
"function calling",
"functions are not supported",
"does not support function",
"unsupported parameter: 'tools'",
"unknown field: tools",
)
def _is_tools_unsupported_error(error: Exception) -> bool:
"""Whether a 400 says the endpoint cannot serve tool calling.
Args:
error: The exception raised by the provider SDK.
Returns:
True when the server's message matches a tool-calling marker.
"""
parts = [str(error)]
body = getattr(error, "body", None)
if isinstance(body, dict):
parts.append(str(body.get("message") or ""))
haystack = " ".join(parts).lower()
return any(marker in haystack for marker in _TOOLS_UNSUPPORTED_MARKERS)
def _truncate_base64_for_logging(messages):
"""
@@ -115,6 +159,12 @@ class _RespChoice:
class OpenAILLM(BaseLLM):
provider_name = "openai"
structured_output_kwarg = "response_format"
# Flipped once this endpoint answered 400 on a request carrying tools.
# Instance-scoped (the assignment shadows this class default), so an
# agent loop pays for the discovery round trip at most once.
_tools_rejected = False
def __init__(
self,
@@ -562,6 +612,53 @@ class OpenAILLM(BaseLLM):
return normalized
return ""
def _note_tools_rejected(self, model, error) -> None:
"""Record that this endpoint refuses tool calling, warning once."""
if not self._tools_rejected:
logging.warning(
"Endpoint rejected tool calling (model=%s, base_url=%s): %s. "
"Retrying this request without tools and skipping tools for "
"the rest of this session — set supports_tools: false in the "
"model catalog for %s to avoid the discovery round trip.",
model,
self._effective_base_url,
error,
model,
)
self._tools_rejected = True
@staticmethod
def _params_without_tools(params: dict) -> dict:
"""Copy of ``params`` with every tool-calling key removed."""
return {
key: value for key, value in params.items()
if key not in _TOOL_REQUEST_KEYS
}
def _create_with_tool_fallback(self, create, params: dict, model):
"""Send a request, retrying once without tools on a tools-unsupported 400.
Args:
create: The provider ``create`` callable for this endpoint.
params: Request kwargs, possibly including ``tools``.
model: Model id, used for the warning.
Returns:
The provider response — from the first call, or from the
tool-less retry when the endpoint rejected tool calling.
Raises:
openai.BadRequestError: Any 400 unrelated to tool calling, and
any 400 on a request that carried no tools.
"""
try:
return create(**params)
except BadRequestError as error:
if not params.get("tools") or not _is_tools_unsupported_error(error):
raise
self._note_tools_rejected(model, error)
return create(**self._params_without_tools(params))
def _raw_gen(
self,
baseself,
@@ -620,7 +717,9 @@ class OpenAILLM(BaseLLM):
request_params["response_format"] = response_format
self._last_usage = None
self._stream_reached_finish = False
response = self.client.chat.completions.create(**request_params)
response = self._create_with_tool_fallback(
self.client.chat.completions.create, request_params, model
)
logging.debug("OpenAI request completed")
self._record_chat_usage(getattr(response, "usage", None))
if tools:
@@ -694,7 +793,12 @@ class OpenAILLM(BaseLLM):
self._last_usage = None
self._stream_reached_finish = False
self._last_finish_reason = None
response = self.client.chat.completions.create(**request_params)
# The request is issued before the loop, so a tools-unsupported 400
# lands here with nothing yielded yet — the retry inside
# ``_create_with_tool_fallback`` can never duplicate delivered output.
response = self._create_with_tool_fallback(
self.client.chat.completions.create, request_params, model
)
try:
for line in response:
@@ -1298,7 +1402,9 @@ class OpenAILLM(BaseLLM):
stream=False,
kwargs=kwargs,
)
response = self.client.responses.create(**params)
response = self._create_with_tool_fallback(
self.client.responses.create, params, model
)
if response is None:
raise RuntimeError("Responses API returned no response object")
logging.debug(
@@ -1371,7 +1477,11 @@ class OpenAILLM(BaseLLM):
stream=True,
kwargs=kwargs,
)
response = self.client.responses.create(**params)
# Issued before the event loop below, so the tool-less retry cannot
# duplicate output already yielded.
response = self._create_with_tool_fallback(
self.client.responses.create, params, model
)
func_calls = {}
reasoning_items = []
@@ -1485,6 +1595,12 @@ class OpenAILLM(BaseLLM):
response.close()
def _supports_tools(self):
# A 400 already told us this endpoint can't serve tool calling
# (vLLM without --enable-auto-tool-choice, and friends). Answer
# False from then on so later turns skip tools outright instead of
# paying for another rejected round trip.
if self._tools_rejected:
return False
# When the LLM was constructed via LLMCreator with a registered
# AvailableModel, ``self.capabilities`` is the per-model record.
# BYOM users can disable tool support; respect that. Otherwise
@@ -1515,6 +1631,8 @@ class OpenAILLM(BaseLLM):
kwargs["reasoning_effort"] = effort
def prepare_structured_output_format(self, json_schema, strict=True):
# Recorded so a cross-provider fallback can re-prepare the raw schema.
self._structured_output_source = (json_schema, strict) if json_schema else None
if not json_schema:
return None
try:
+2 -5
View File
@@ -288,12 +288,9 @@ class SemanticChunker(_BaseStrategyChunker):
sentences = self._split_sentences(text)
if len(sentences) < 2:
raise ValueError("too few sentences for semantic chunking")
from application.vectorstore.base import EmbeddingsSingleton
from application.core.settings import settings
from application.vectorstore.base import get_embeddings
embeddings = EmbeddingsSingleton.get_instance(
settings.EMBEDDINGS_NAME
).embed_documents(sentences)
embeddings = get_embeddings().embed_documents(sentences)
breakpoints = self._breakpoints(embeddings)
groups = self._group(sentences, breakpoints)
return [g for g in self._enforce_tokens(groups) if g.strip()]
+242 -2
View File
@@ -9,10 +9,11 @@ images (PNG, JPEG, TIFF, BMP, WEBP), WebVTT, and specialized XML formats.
import importlib.util
import logging
import os
import re
import tempfile
import zipfile
from pathlib import Path
from typing import Dict, List, Optional, Union
from typing import Dict, List, Optional, Tuple, Union
from application.parser.file.base_parser import BaseParser, DocumentParseError
from application.utils import truncate_to_line_boundary
@@ -213,6 +214,124 @@ def _parse_markup_bounded(
pass
# Suffixes whose text comes (wholly or partly) from OCR, and are therefore
# covered by the near-empty-output dropout guard. Everything else docling
# handles (docx/xlsx/html/vtt/...) has a native text layer and legitimately
# short files, so the guard would only produce false alarms there.
_IMAGE_SUFFIXES = frozenset(
{".png", ".jpg", ".jpeg", ".tiff", ".tif", ".bmp", ".webp"}
)
_OCR_GUARDED_SUFFIXES = _IMAGE_SUFFIXES | {".pdf"}
# Chars-per-page floor below which an OCR parse is treated as a pipeline
# dropout rather than as the document's real content. Scanned pages that
# carry any text at all clear this comfortably; the observed failure mode
# returns literally zero characters per page.
_DEFAULT_OCR_MIN_CHARS_PER_PAGE = 20
# Docling emits this placeholder for a picture it did not read; it is markup,
# not document text, so it must not count towards the chars-per-page ratio.
_IMAGE_PLACEHOLDER_RE = re.compile(r"<!--\s*image\s*-->")
def _ocr_min_chars_per_page() -> int:
"""Chars-per-page floor for the OCR dropout guard; 0 disables it."""
from application.core.settings import settings
try:
return int(
getattr(
settings,
"DOCLING_OCR_MIN_CHARS_PER_PAGE",
_DEFAULT_OCR_MIN_CHARS_PER_PAGE,
)
)
except (TypeError, ValueError):
return _DEFAULT_OCR_MIN_CHARS_PER_PAGE
def _text_char_count(content: Optional[str]) -> int:
"""Count real text characters in exported content, ignoring placeholders."""
if not content:
return 0
return len(_IMAGE_PLACEHOLDER_RE.sub("", content).strip())
def _positive_len(value: object) -> int:
"""``len(value)`` when it is a positive int, 0 when it is neither."""
try:
length = len(value)
except TypeError:
return 0
return length if isinstance(length, int) and length > 0 else 0
def _result_page_count(result: object, file: Path) -> int:
"""Page count of a conversion result, never below 1.
Args:
result: Docling ``ConversionResult``.
file: Path of the file that was converted.
Returns:
Number of pages; 1 for images and whenever docling reports none.
"""
if Path(file).suffix.lower() in _IMAGE_SUFFIXES:
return 1
document = getattr(result, "document", None)
pages = _positive_len(getattr(document, "pages", None))
if pages:
return pages
num_pages = getattr(document, "num_pages", None)
if callable(num_pages):
try:
reported = num_pages()
except Exception: # pragma: no cover - defensive, docling-version drift
reported = None
if isinstance(reported, int) and reported > 0:
return reported
return _positive_len(getattr(result, "pages", None)) or 1
def _pdf_text_layer_probe(file: Path) -> Tuple[int, int]:
"""Measure a PDF's embedded text layer with pypdfium2.
Used only to explain a suspected OCR dropout: a near-empty parse of a PDF
that *does* carry a text layer means docling failed to read text it never
needed OCR for, while an absent text layer points at the OCR stage itself.
Args:
file: Path to the PDF.
Returns:
Tuple of (page count, characters in the text layer); (0, 0) when the
file cannot be probed.
"""
try:
import pypdfium2 as pdfium
except ImportError:
return 0, 0
try:
pdf = pdfium.PdfDocument(str(file))
except Exception:
return 0, 0
try:
total = 0
for index in range(len(pdf)):
page = pdf[index]
textpage = page.get_textpage()
try:
total += max(0, textpage.count_chars())
finally:
textpage.close()
page.close()
return len(pdf), total
except Exception:
return 0, 0
finally:
pdf.close()
class DoclingParser(BaseParser):
"""Parser using docling for advanced document processing.
@@ -291,6 +410,13 @@ class DoclingParser(BaseParser):
ocr_options = self._get_ocr_options()
if ocr_options is not None:
pipeline_options.ocr_options = ocr_options
# Docling's *default* OCR options carry their own flag, so without
# this the setting was silently dropped whenever `_get_ocr_options`
# returned None (use_rapidocr=False) — including the dropout retry,
# whose whole point is forcing full-page OCR.
active_ocr_options = getattr(pipeline_options, "ocr_options", None)
if hasattr(active_ocr_options, "force_full_page_ocr"):
active_ocr_options.force_full_page_ocr = self.force_full_page_ocr
return DocumentConverter(
format_options={
@@ -383,18 +509,113 @@ class DoclingParser(BaseParser):
return content
def _ocr_guard_applies(self, file: Path) -> bool:
"""Whether the near-empty-output dropout guard covers this parse."""
return (
bool(self.ocr_enabled)
and _ocr_min_chars_per_page() > 0
and Path(file).suffix.lower() in _OCR_GUARDED_SUFFIXES
)
def _recover_from_ocr_dropout(
self, file: Path, first_pass_chars: int, pages: int
) -> str:
"""Retry a near-empty OCR parse once on a fresh full-page-OCR converter.
Docling caches its pipeline (and the threaded page queue behind it) on
the ``DocumentConverter`` instance, so a converter that has degraded
mid-worker keeps returning empty pages while a fresh one starts clean —
hence a brand new converter rather than a second `convert` call.
``self._converter`` is dropped either way instead of being replaced by
the retry converter: the degraded instance must not survive, but the
retry one is configured for full-page OCR, and keeping it would impose
that cost (and its worse results on text PDFs) on every later file in
the worker. The next parse lazily rebuilds one with normal options.
Args:
file: Path to the file being parsed.
first_pass_chars: Text characters the first pass produced.
pages: Page count reported for the first pass.
Returns:
The retry's content, when it clears the chars-per-page floor.
Raises:
DocumentParseError: If the retry is near-empty too, so the caller
fails loudly instead of indexing an empty document.
"""
name = Path(file).name
logger.warning(
"OCR output for %s is near-empty (%d chars over %d page(s)); "
"retrying once on a fresh converter with full-page OCR",
name,
first_pass_chars,
pages,
)
original_force_full_page_ocr = self.force_full_page_ocr
try:
self.force_full_page_ocr = True
result = self._create_converter().convert(str(file))
content = self._export_content(result.document)
finally:
self.force_full_page_ocr = original_force_full_page_ocr
self._converter = None
retry_chars = _text_char_count(content)
retry_pages = max(pages, _result_page_count(result, file))
if retry_chars >= _ocr_min_chars_per_page() * retry_pages:
logger.warning(
"Recovered %s on retry: %d chars over %d page(s) after a "
"near-empty first pass (%d chars); the degraded converter has "
"been discarded",
name,
retry_chars,
retry_pages,
first_pass_chars,
)
return content
detail = ""
if Path(file).suffix.lower() == ".pdf":
layer_pages, layer_chars = _pdf_text_layer_probe(file)
if layer_chars > 0:
detail = (
f" The PDF carries a {layer_chars}-char text layer over "
f"{layer_pages} page(s) that docling should have read "
"without OCR at all."
)
elif layer_pages > 0:
detail = (
f" The PDF has no text layer over {layer_pages} page(s), "
"so OCR was the only possible source."
)
raise DocumentParseError(
f"OCR produced {retry_chars} chars over {retry_pages} pages for "
f"{name}; likely OCR pipeline dropout — document not indexed."
+ detail
)
def parse_file(self, file: Path, errors: str = "ignore") -> Union[str, List[str]]:
"""Parse file using docling with hybrid OCR.
Uses smart OCR approach where the layout model detects text vs bitmap
regions. Text is extracted directly, bitmaps are OCR'd only when needed.
When OCR is enabled for a PDF or image, a near-empty result is treated
as a pipeline dropout rather than as the document's content: it is
retried once on a fresh converter, and failing that it raises.
Args:
file: Path to the file to parse
errors: Error handling mode (ignored, docling handles internally)
Returns:
Parsed document content as markdown string
Raises:
DocumentParseError: If docling fails, or if an OCR parse stays
near-empty across both passes.
"""
logger.info(f"parse_file called for: {file}")
@@ -405,10 +626,29 @@ class DoclingParser(BaseParser):
logger.info(f"Converting file with hybrid OCR: {file}")
result = self._converter.convert(str(file))
content = self._export_content(result.document)
logger.info(f"Parse complete, content length: {len(content)} chars")
pages = _result_page_count(result, file)
chars = _text_char_count(content)
logger.info(
"Parse complete for %s: %d chars (%d text) over %d page(s), "
"%.1f chars/page",
Path(file).name,
len(content),
chars,
pages,
chars / pages,
)
if self._ocr_guard_applies(file) and chars < (
_ocr_min_chars_per_page() * pages
):
return self._recover_from_ocr_dropout(file, chars, pages)
return content
except DocumentParseError:
# Already the loud, actionable failure — do not re-wrap it into a
# generic "Failed to parse ..." and lose the dropout diagnosis.
raise
except Exception as e:
logger.error(f"Error parsing file with docling: {e}", exc_info=True)
# ``errors`` governs *decoding* leniency, not whether a total
+229 -87
View File
@@ -1,8 +1,17 @@
import logging
from typing import Any, Dict, List, Optional, Tuple
from application.core.settings import settings
from application.llm.llm_creator import LLMCreator
from application.retriever.base import BaseRetriever
from application.retriever.fanout import ( # noqa: F401 (re-exported)
DEFAULT_MAX_PARALLEL_SOURCES,
EMBEDDING_ATTRS as _EMBEDDING_ATTRS,
embed_questions,
max_parallel_sources,
run_source_jobs,
store_embeddings,
)
from application.retriever.labels import labels_from_metadata
from application.utils import num_tokens_from_string
from application.vectorstore.vector_creator import VectorCreator
@@ -10,6 +19,11 @@ from application.vectorstore.vector_creator import VectorCreator
logger = logging.getLogger(__name__)
def _max_parallel_sources(n_sources: int) -> int:
"""Worker count for the per-source fan-out, bounded by the source count."""
return max_parallel_sources(n_sources, settings)
class ClassicRAG(BaseRetriever):
# The group's real top-k, set by the Dispatcher when it inflates ``chunks``
# for a prescreen fetch. None → ``chunks`` is already the top-k.
@@ -152,13 +166,25 @@ class ClassicRAG(BaseRetriever):
logger.error(f"Error rephrasing query: {e}", exc_info=True)
return self.original_question
def _fetch_candidates(self, docsearch, question, src_k, score_threshold):
def _fetch_candidates(
self,
docsearch,
question: str,
src_k: int,
score_threshold: Optional[float],
query_vector: Optional[List[float]] = None,
):
"""Fetch candidate hits for one vector store (vector search).
Returns plain hits, or ``(hit, score)`` pairs when ``include_scores`` is
set. Subclasses override this to change candidate sourcing (e.g. RRF
fusion) while inheriting the surrounding per-source resolution and
budgeting.
Args:
query_vector: Query embedding computed once for the whole
retrieval. Forwarded so the store skips embedding the query
again; stores that don't support it ignore the kwarg.
"""
# ``score_threshold`` is honoured by pgvector/mongodb and safely ignored
# by stores whose ``search`` swallows kwargs. The candidate count is
@@ -167,6 +193,8 @@ class ClassicRAG(BaseRetriever):
search_kwargs = {"k": k}
if score_threshold is not None:
search_kwargs["score_threshold"] = score_threshold
if query_vector is not None:
search_kwargs["query_vector"] = query_vector
if self.include_scores:
return docsearch.search_with_scores(question, **search_kwargs)
return docsearch.search(question, **search_kwargs)
@@ -175,6 +203,152 @@ class ClassicRAG(BaseRetriever):
"""Label for the scores ``_fetch_candidates`` attaches (None if unscored)."""
return getattr(docsearch, "score_kind", None)
def _resolve_source(
self, vectorstore_id: str, chunks_per_source: int
) -> Dict[str, Any]:
"""Resolve one source's fetch parameters (top-k, threshold, query).
Per-source overrides come from the Dispatcher; absent, the source gets
the global behaviour — byte-identical to the pre-override path.
"""
src_cfg = self.per_source_retrieval.get(vectorstore_id)
if src_cfg is None:
# No per-source override → the effective rephrase_query defaults to
# True, so use the (lazily-cached) rephrased question. In the
# non-deferred path the cache is already populated.
return {
"id": vectorstore_id,
"src_k": chunks_per_source,
"score_threshold": None,
"question": self._get_rephrased_question(),
}
src_k = max(1, int(src_cfg.chunks))
# Prescreen fetches a larger candidate set up front; the Dispatcher's
# prescreen stage trims back to max_keep afterwards. Raise the fetch
# size to candidate_k here.
ps_cfg = (
src_cfg.prescreen_config() if hasattr(src_cfg, "prescreen_config") else None
)
if ps_cfg is not None:
src_k = max(src_k, int(ps_cfg.candidate_k))
return {
"id": vectorstore_id,
"src_k": src_k,
"score_threshold": src_cfg.score_threshold,
"question": (
self._get_rephrased_question()
if src_cfg.rephrase_query
else self.original_question
),
}
def _plan_sources(self, chunks_per_source: int) -> List[Dict[str, Any]]:
"""Resolve every source's fetch parameters, in source order.
Runs on the calling thread: the lazy rephrase behind it is an LLM
side-call that must happen once, not once per worker.
"""
plans = []
for vectorstore_id in self.vectorstores:
if not vectorstore_id:
continue
try:
plans.append(self._resolve_source(vectorstore_id, chunks_per_source))
except Exception as e:
logger.error(
f"Error searching vectorstore {vectorstore_id}: {e}", exc_info=True
)
return plans
@staticmethod
def _store_embeddings(docsearch):
"""Return the embeddings object a vector store searches with, if any."""
return store_embeddings(docsearch)
def _embed_questions(
self, docsearch, questions: List[str]
) -> Dict[str, List[float]]:
"""Embed each distinct query once for the whole retrieval."""
return embed_questions(docsearch, questions)
def _search_source(
self,
plan: Dict[str, Any],
docsearch=None,
query_vector: Optional[List[float]] = None,
) -> Optional[Tuple[Any, Optional[str]]]:
"""Search one source, returning ``(candidates, score_kind)``.
Builds the vector store when not supplied, so each worker thread owns
its own store instance (and therefore its own DB connection). Errors are
logged and reported as ``None`` so one bad source cannot take the rest
of the retrieval down with it.
"""
try:
if docsearch is None:
docsearch = VectorCreator.create_vectorstore(
settings.VECTOR_STORE, plan["id"], settings.EMBEDDINGS_KEY
)
docs_temp = self._fetch_candidates(
docsearch,
plan["question"],
plan["src_k"],
plan["score_threshold"],
query_vector=query_vector,
)
score_kind = self._score_kind(docsearch) if self.include_scores else None
return docs_temp, score_kind
except Exception as e:
logger.error(
f"Error searching vectorstore {plan['id']}: {e}", exc_info=True
)
return None
def _fetch_all(
self, plans: List[Dict[str, Any]]
) -> List[Optional[Tuple[Any, Optional[str]]]]:
"""Fetch every source's candidates, one embedding and one fan-out.
The first store is built on the calling thread because its embeddings
object supplies the shared query vector — and priming the embeddings
singleton there keeps the workers off a concurrent model load. The
searches then run on a bounded pool, and results come back in the
original source order so the merge stays deterministic.
"""
first_store = None
try:
first_store = VectorCreator.create_vectorstore(
settings.VECTOR_STORE, plans[0]["id"], settings.EMBEDDINGS_KEY
)
except Exception as e:
logger.error(
f"Error searching vectorstore {plans[0]['id']}: {e}", exc_info=True
)
if first_store is None:
# The first source is already a logged failure; the rest still run,
# each embedding its own query (there is no store to borrow one from).
return [None] + self._run_jobs([(plan, None, None) for plan in plans[1:]])
questions = list(dict.fromkeys(plan["question"] for plan in plans))
vectors = self._embed_questions(first_store, questions)
return self._run_jobs(
[
(plan, first_store if idx == 0 else None, vectors.get(plan["question"]))
for idx, plan in enumerate(plans)
]
)
def _run_jobs(self, jobs: List[Tuple]) -> List[Optional[Tuple[Any, Optional[str]]]]:
"""Run the per-source search jobs, concurrently when there are several."""
if not jobs:
return []
return run_source_jobs(
lambda job: self._search_source(*job),
jobs,
workers=_max_parallel_sources(len(jobs)),
)
def _get_data(self):
if self.chunks == 0 or not self.vectorstores:
logger.info(
@@ -194,96 +368,64 @@ class ClassicRAG(BaseRetriever):
token_budget = max(int(self.doc_token_limit * 0.9), 100)
cumulative_tokens = 0
for vectorstore_id in self.vectorstores:
if vectorstore_id:
try:
# Per-source overrides (set by the Dispatcher). Absent →
# global behaviour, byte-identical to before.
src_cfg = self.per_source_retrieval.get(vectorstore_id)
if src_cfg is not None:
src_k = max(1, int(src_cfg.chunks))
# Prescreen fetches a larger candidate set up front; the
# Dispatcher's prescreen stage trims back to max_keep
# afterwards. Raise the fetch size to candidate_k here.
ps_cfg = (
src_cfg.prescreen_config()
if hasattr(src_cfg, "prescreen_config")
else None
)
if ps_cfg is not None:
src_k = max(src_k, int(ps_cfg.candidate_k))
score_threshold = src_cfg.score_threshold
question = (
self._get_rephrased_question()
if src_cfg.rephrase_query
else self.original_question
)
else:
src_k = chunks_per_source
score_threshold = None
# No per-source override → the effective rephrase_query
# defaults to True, so use the (lazily-cached) rephrased
# question. In the non-deferred path the cache is already
# populated, so this matches today's behaviour exactly.
question = self._get_rephrased_question()
# Resolve every source, then fetch them all (one query embedding, one
# bounded fan-out). The merge below stays serial and in source order, so
# dedupe/budget/trim semantics are exactly what they were.
plans = self._plan_sources(chunks_per_source)
results = self._fetch_all(plans) if plans else []
docsearch = VectorCreator.create_vectorstore(
settings.VECTOR_STORE, vectorstore_id, settings.EMBEDDINGS_KEY
)
docs_temp = self._fetch_candidates(
docsearch, question, src_k, score_threshold
)
score_kind = (
self._score_kind(docsearch) if self.include_scores else None
)
for plan, result in zip(plans, results):
if result is None:
continue
vectorstore_id = plan["id"]
src_k = plan["src_k"]
docs_temp, score_kind = result
try:
# ``_fetch_candidates`` over-fetches (k >= 20) so a prescreen
# stage has candidates to filter; trim back to src_k so
# ``chunks`` is the final top-k it claims to be. With
# prescreen on, src_k is already raised to candidate_k above,
# so the stage still sees its full candidate set.
kept = 0
# ``_fetch_candidates`` over-fetches (k >= 20) so a prescreen
# stage has candidates to filter; trim back to src_k so
# ``chunks`` is the final top-k it claims to be. With
# prescreen on, src_k is already raised to candidate_k above,
# so the stage still sees its full candidate set.
kept = 0
for doc in docs_temp:
if kept >= src_k or cumulative_tokens >= token_budget:
break
score = None
if isinstance(doc, tuple):
doc, score = doc
if hasattr(doc, "page_content") and hasattr(doc, "metadata"):
page_content = doc.page_content
metadata = doc.metadata
else:
page_content = doc.get("text", doc.get("page_content", ""))
metadata = doc.get("metadata", {})
labels = labels_from_metadata(
metadata, page_content, vectorstore_id
)
doc_text_with_header = f"{labels['filename']}\n{page_content}"
doc_tokens = num_tokens_from_string(doc_text_with_header)
if cumulative_tokens + doc_tokens < token_budget:
entry = {"text": page_content, **labels}
if self.include_scores:
entry["score"] = score
entry["score_kind"] = score_kind
all_docs.append(entry)
cumulative_tokens += doc_tokens
kept += 1
if cumulative_tokens >= token_budget:
for doc in docs_temp:
if kept >= src_k or cumulative_tokens >= token_budget:
break
except Exception as e:
logger.error(
f"Error searching vectorstore {vectorstore_id}: {e}",
exc_info=True,
)
continue
score = None
if isinstance(doc, tuple):
doc, score = doc
if hasattr(doc, "page_content") and hasattr(doc, "metadata"):
page_content = doc.page_content
metadata = doc.metadata
else:
page_content = doc.get("text", doc.get("page_content", ""))
metadata = doc.get("metadata", {})
labels = labels_from_metadata(metadata, page_content, vectorstore_id)
doc_text_with_header = f"{labels['filename']}\n{page_content}"
doc_tokens = num_tokens_from_string(doc_text_with_header)
if cumulative_tokens + doc_tokens < token_budget:
entry = {"text": page_content, **labels}
if self.include_scores:
entry["score"] = score
entry["score_kind"] = score_kind
all_docs.append(entry)
cumulative_tokens += doc_tokens
kept += 1
if cumulative_tokens >= token_budget:
break
except Exception as e:
logger.error(
f"Error searching vectorstore {vectorstore_id}: {e}",
exc_info=True,
)
continue
# ``chunks_per_source`` has a floor of 1 so no attached source is
# starved, which means N sources always yield at least N documents —
+127
View File
@@ -0,0 +1,127 @@
"""Shared helpers for fanning one query out across several vector stores.
Attaching N sources used to cost N query embeddings and N serial round trips.
The pieces here collapse that to one embedding plus one bounded, order-
preserving thread pool, and are reused by :mod:`application.retriever.classic_rag`
and :mod:`application.services.search_service` so both paths behave the same.
"""
from __future__ import annotations
import logging
from concurrent.futures import ThreadPoolExecutor
from typing import Any, Callable, Dict, Iterable, List, Optional, TypeVar
from application.core.settings import settings
logger = logging.getLogger(__name__)
# Fallback fan-out width when ``RETRIEVAL_MAX_PARALLEL_SOURCES`` is unset.
DEFAULT_MAX_PARALLEL_SOURCES = 4
# Attributes the vector stores keep their embeddings object on, in the order
# they are probed. Stores that build embeddings inline (elasticsearch, lancedb)
# expose none of them and are handled by the ``_get_embeddings`` fallback.
EMBEDDING_ATTRS = ("_embedding", "_embeddings", "embeddings")
Job = TypeVar("Job")
Result = TypeVar("Result")
def max_parallel_sources(n_sources: int, settings_obj: Any = None) -> int:
"""Worker count for the per-source fan-out, bounded by the source count.
Args:
n_sources: Number of sources about to be searched.
settings_obj: Settings to read ``RETRIEVAL_MAX_PARALLEL_SOURCES`` from;
defaults to the global settings.
Returns:
int: Worker count, at least 1 and never above ``n_sources``.
"""
config = settings if settings_obj is None else settings_obj
configured = getattr(
config, "RETRIEVAL_MAX_PARALLEL_SOURCES", DEFAULT_MAX_PARALLEL_SOURCES
)
try:
configured = int(configured)
except (TypeError, ValueError):
configured = DEFAULT_MAX_PARALLEL_SOURCES
return max(1, min(configured, n_sources))
def store_embeddings(docsearch) -> Optional[Any]:
"""Return the embeddings object a vector store searches with, if any."""
for attr in EMBEDDING_ATTRS:
embedder = getattr(docsearch, attr, None)
if embedder is not None and hasattr(embedder, "embed_query"):
return embedder
getter = getattr(docsearch, "_get_embeddings", None)
if callable(getter):
try:
return getter(settings.EMBEDDINGS_NAME, settings.EMBEDDINGS_KEY)
except Exception as e:
logger.debug("Could not resolve store embeddings: %s", e)
return None
def embed_questions(docsearch, questions: Iterable[str]) -> Dict[str, List[float]]:
"""Embed each distinct query once for the whole retrieval.
Every source of one retrieval shares a single embeddings config
(``settings.EMBEDDINGS_NAME`` is global — nothing per-source overrides it),
so a vector computed with one store's embedder is valid for all of them.
That turns N sources from N query embeddings into one.
Args:
docsearch: Any store of the retrieval; only its embedder is used.
questions: Queries to embed (duplicates are embedded once).
Returns:
dict: ``{question: vector}``, or ``{}`` when no embedder is reachable
or embedding fails — in which case every store embeds its own query,
exactly as before.
"""
embedder = store_embeddings(docsearch)
if embedder is None:
return {}
try:
return {
question: embedder.embed_query(question)
for question in dict.fromkeys(questions)
}
except Exception as e:
logger.warning(
"Query embedding failed (%s); each store will embed its own.", e
)
return {}
def run_source_jobs(
fn: Callable[[Job], Result],
jobs: Iterable[Job],
workers: Optional[int] = None,
) -> List[Result]:
"""Run one job per source, concurrently when there are several.
Args:
fn: Per-source callable; it must swallow its own errors, since a raise
here aborts the whole fan-out.
jobs: Job payloads, in source order.
workers: Pool size; defaults to :func:`max_parallel_sources`.
Returns:
list: ``fn`` results in the original job order, so the caller's merge
stays deterministic no matter which source finishes first.
"""
jobs = list(jobs)
if not jobs:
return []
if workers is None:
workers = max_parallel_sources(len(jobs))
if workers == 1:
return [fn(job) for job in jobs]
with ThreadPoolExecutor(
max_workers=workers, thread_name_prefix="rag-source"
) as pool:
return list(pool.map(fn, jobs))
+113 -36
View File
@@ -7,7 +7,11 @@ reused) rephrase.
Composes :class:`ClassicRAG` rather than subclassing: PPR doesn't fit the
``_fetch_candidates`` hook, but the composed instance supplies the rephrase, the
token-budget loop, and the per-source fallback when a source has no graph.
token-budget loop, and the fallback for sources that have no graph.
Per request the whole source group costs one node-count query, one query
embedding, and one ClassicRAG run for all the graphless sources together — all
over a single pooled connection shared with the vector store.
"""
from __future__ import annotations
@@ -25,7 +29,7 @@ from application.retriever.base import BaseRetriever
from application.retriever.classic_rag import ClassicRAG
from application.retriever.labels import labels_from_metadata
from application.utils import num_tokens_from_string
from application.vectorstore.base import EmbeddingsSingleton
from application.vectorstore.base import get_embeddings
SEED_NODES = 10
SUBGRAPH_HOPS = 1
@@ -88,9 +92,7 @@ class GraphRAGRetriever(BaseRetriever):
self.per_source_retrieval = {}
def _embed_query(self, question: str) -> List[float]:
embedding = EmbeddingsSingleton.get_instance(
settings.EMBEDDINGS_NAME, settings.EMBEDDINGS_KEY
)
embedding = get_embeddings()
return embedding.embed_query(question)
def _ppr_scores(self, subgraph, seeds) -> Dict[str, float]:
@@ -157,10 +159,17 @@ class GraphRAGRetriever(BaseRetriever):
base = self.base_chunks if self.base_chunks is not None else self.chunks
return max(1, base // max(1, len(self.vectorstores)))
def _graph_docs_for_source(self, store, source_id) -> List[Dict[str, Any]]:
"""Local PPR retrieval for one source (caller guarantees it has a graph)."""
question = self._classic._get_rephrased_question()
query_embedding = self._embed_query(question)
def _graph_docs_for_source(
self, store, source_id, query_embedding: List[float]
) -> List[Dict[str, Any]]:
"""Local PPR retrieval for one source (caller guarantees it has a graph).
Args:
store: Open :class:`GraphStore` shared by every source of this run.
source_id: Source to retrieve from.
query_embedding: Embedding of the rephrased question, computed once
by the caller for the whole retrieval.
"""
seed_rows = store.search_nodes_by_embedding(
source_id, query_embedding, k=SEED_NODES
)
@@ -204,15 +213,26 @@ class GraphRAGRetriever(BaseRetriever):
cumulative_tokens += doc_tokens
return docs
def _classic_for_source(self, source_id) -> List[Dict[str, Any]]:
"""Reuse the composed ClassicRAG to retrieve one source's chunks."""
def _classic_for_sources(self, source_ids) -> List[Dict[str, Any]]:
"""Reuse the composed ClassicRAG to retrieve a whole batch of sources.
One inner run for every graphless source instead of one run per source:
ClassicRAG then embeds the query once and fans the sources out itself.
The trade is that the per-source chunk split and the shared doc-token
budget apply across the batch — i.e. exactly plain ClassicRAG semantics
over those sources, rather than each source getting its own full budget.
"""
source_ids = [source_id for source_id in source_ids if source_id]
if not source_ids:
return []
wanted = set(source_ids)
original = self._classic.vectorstores
original_overrides = self._classic.per_source_retrieval
original_base = self._classic.base_chunks
try:
self._classic.vectorstores = [source_id]
self._classic.vectorstores = list(source_ids)
self._classic.per_source_retrieval = {
k: v for k, v in self.per_source_retrieval.items() if k == source_id
k: v for k, v in self.per_source_retrieval.items() if k in wanted
}
# The Dispatcher sets these on *this* object; the inner retriever is
# the one that reads them.
@@ -223,8 +243,79 @@ class GraphRAGRetriever(BaseRetriever):
self._classic.per_source_retrieval = original_overrides
self._classic.base_chunks = original_base
def _classic_for_source(self, source_id) -> List[Dict[str, Any]]:
"""Retrieve one source through the batched classic path."""
return self._classic_for_sources([source_id])
def _retrieve_with_store(self, store, sources) -> List[Dict[str, Any]]:
"""Split ``sources`` by graph presence, then batch each half.
Graph sources keep their own slot in source order; every graphless
source collapses into a single ClassicRAG run that occupies the slot of
the first graphless source. Sources whose PPR retrieval raises are
collected and retried as one more classic batch, appended at the end.
"""
try:
counts = store.count_nodes_many(sources)
except Exception as e:
logging.error(f"GraphRAG count_nodes failed for {sources}: {e}")
counts = {}
segments: List[List[Dict[str, Any]]] = []
graph_slots: Dict[str, int] = {}
graphed: List[str] = []
graphless: List[str] = []
classic_slot = None
for source_id in sources:
if counts.get(source_id, 0) > 0:
if source_id in graph_slots:
continue
graph_slots[source_id] = len(segments)
segments.append([])
graphed.append(source_id)
else:
if classic_slot is None:
classic_slot = len(segments)
segments.append([])
graphless.append(source_id)
failed: List[str] = []
query_embedding = None
if graphed:
# Embedded once for the whole retrieval, not once per graph source.
try:
query_embedding = self._embed_query(
self._classic._get_rephrased_question()
)
except Exception as e:
logging.error(
f"GraphRAG query embedding failed, falling back: {e}",
exc_info=True,
)
failed, graphed = list(graphed), []
for source_id in graphed:
try:
segments[graph_slots[source_id]] = self._graph_docs_for_source(
store, source_id, query_embedding
)
except Exception as e:
logging.error(
f"GraphRAG retrieval failed for {source_id}, falling back: {e}",
exc_info=True,
)
failed.append(source_id)
if graphless:
segments[classic_slot] = self._classic_for_sources(graphless)
if failed:
segments.append(self._classic_for_sources(failed))
return [doc for segment in segments for doc in segment]
def _get_data(self) -> List[Dict[str, Any]]:
if not self.vectorstores:
sources = [source_id for source_id in self.vectorstores if source_id]
if not sources:
return []
store = None
@@ -235,31 +326,17 @@ class GraphRAGRetriever(BaseRetriever):
logging.error(f"GraphRAG store unavailable, falling back: {e}")
store = None
all_docs: List[Dict[str, Any]] = []
for source_id in self.vectorstores:
if not source_id:
continue
has_graph = False
if store is not None:
try:
has_graph = store.count_nodes(source_id) > 0
except Exception as e:
logging.error(f"GraphRAG count_nodes failed for {source_id}: {e}")
has_graph = False
if not has_graph:
all_docs.extend(self._classic_for_source(source_id))
continue
if store is None:
return self._classic_for_sources(sources)
try:
return self._retrieve_with_store(store, sources)
finally:
# Hand the pooled connection back; the store is per-request.
try:
all_docs.extend(self._graph_docs_for_source(store, source_id))
store.close()
except Exception as e:
logging.error(
f"GraphRAG retrieval failed for {source_id}, falling back: {e}",
exc_info=True,
)
all_docs.extend(self._classic_for_source(source_id))
return all_docs
logging.debug("Error closing GraphRAG store: %s", e)
def search(self, query: str = "") -> List[Dict[str, Any]]:
if query:
+9 -3
View File
@@ -55,16 +55,22 @@ class HybridRetriever(ClassicRAG):
carry their own label."""
return "rrf"
def _fetch_candidates(self, docsearch, question, src_k, score_threshold):
def _fetch_candidates(
self, docsearch, question, src_k, score_threshold, query_vector=None
):
"""Return RRF-fused vector+keyword hits for one vector store.
Inherits the per-source resolution and budgeting from
:meth:`ClassicRAG._get_data`; only candidate sourcing differs.
RRF scores are not cosine similarities, so ``score_threshold`` is
intentionally not applied to the fused list.
intentionally not applied to the fused list. ``query_vector`` is the
retrieval's single query embedding — the keyword half never needs one.
"""
candidate_k = min(max(src_k * 2, 20), 500)
vector_hits = docsearch.search(question, k=candidate_k)
vector_kwargs = {"k": candidate_k}
if query_vector is not None:
vector_kwargs["query_vector"] = query_vector
vector_hits = docsearch.search(question, **vector_kwargs)
keyword_hits = docsearch.keyword_search(question, k=candidate_k)
fused = fuse_with_scores(vector_hits, keyword_hits)
if self.include_scores:
+87 -8
View File
@@ -8,9 +8,10 @@ MCP error responses, etc.).
from __future__ import annotations
import logging
from typing import Any, Dict, List
from typing import Any, Dict, List, Optional
from application.core.settings import settings
from application.retriever.fanout import embed_questions, run_source_jobs
from application.storage.db.repositories.agents import AgentsRepository
from application.storage.db.session import db_readonly
from application.vectorstore.vector_creator import VectorCreator
@@ -89,6 +90,79 @@ def _authorized_source_ids(conn, agent: Dict[str, Any], source_ids: List[str]) -
return allowed
def _search_one(
source_id: str,
docsearch: Any,
query: str,
k: int,
query_vector: Optional[List[float]],
) -> Optional[List[Any]]:
"""Search one source, returning its hits or ``None`` when it fails.
Builds the vector store when not supplied, so each worker thread owns its
own store instance (and therefore its own DB connection). Errors are logged
and reported as ``None`` so one broken index cannot take the rest down.
"""
try:
if docsearch is None:
docsearch = VectorCreator.create_vectorstore(
settings.VECTOR_STORE, source_id, settings.EMBEDDINGS_KEY
)
search_kwargs: Dict[str, Any] = {"k": k}
if query_vector is not None:
search_kwargs["query_vector"] = query_vector
return docsearch.search(query, **search_kwargs)
except Exception as e:
logger.error(
f"Error searching vectorstore {source_id}: {e}",
exc_info=True,
)
return None
def _fetch_sources(
query: str, source_ids: List[str], k: int
) -> List[Optional[List[Any]]]:
"""Fetch every source's hits: one query embedding, one bounded fan-out.
The first store is built on the calling thread because its embeddings
object supplies the shared query vector — and priming the embeddings
singleton there keeps the workers off a concurrent model load. Results come
back in the original source order so the merge stays deterministic.
"""
def _job(job) -> Optional[List[Any]]:
source_id, docsearch, query_vector = job
return _search_one(source_id, docsearch, query, k, query_vector)
first_store = None
try:
first_store = VectorCreator.create_vectorstore(
settings.VECTOR_STORE, source_ids[0], settings.EMBEDDINGS_KEY
)
except Exception as e:
logger.error(
f"Error searching vectorstore {source_ids[0]}: {e}",
exc_info=True,
)
if first_store is None:
# The first source is already a logged failure; the rest still run,
# each embedding its own query (there is no store to borrow one from).
return [None] + run_source_jobs(
_job, [(sid, None, None) for sid in source_ids[1:]]
)
vector = embed_questions(first_store, [query]).get(query)
return run_source_jobs(
_job,
[
(sid, first_store if idx == 0 else None, vector)
for idx, sid in enumerate(source_ids)
],
)
def _search_sources(
query: str, source_ids: List[str], chunks: int
) -> List[Dict[str, Any]]:
@@ -101,19 +175,24 @@ def _search_sources(
return []
results: List[Dict[str, Any]] = []
# Blank ids build no store but still count here, so the per-source budget
# matches what the serial implementation handed each real source.
chunks_per_source = max(1, chunks // len(source_ids))
seen_texts: set[int] = set()
for source_id in source_ids:
if not source_id or not source_id.strip():
active_ids = [sid for sid in source_ids if sid and sid.strip()]
if not active_ids:
return []
# Fetch every source up front, then merge serially in source order so the
# dedupe / cap semantics are exactly what they were.
fetched = _fetch_sources(query, active_ids, chunks_per_source * 2)
for source_id, docs in zip(active_ids, fetched):
if docs is None:
continue
try:
docsearch = VectorCreator.create_vectorstore(
settings.VECTOR_STORE, source_id, settings.EMBEDDINGS_KEY
)
docs = docsearch.search(query, k=chunks_per_source * 2)
for doc in docs:
if len(results) >= chunks:
break
+138 -3
View File
@@ -7,9 +7,15 @@ On app startup the Flask factory (and Celery worker init) can call
configured role to have ``CREATEDB`` privilege).
2. Apply every pending Alembic migration up to ``head``.
Both steps are gated by settings that default ON for dev convenience and
can be turned off in prod (``AUTO_CREATE_DB`` / ``AUTO_MIGRATE``) where
schema is managed out-of-band by a deploy pipeline.
:func:`ensure_vector_schema` does the same job for the *vector* database
(``pgvector``): it owns the ``documents`` table and, when GraphRAG is on,
the graph tables, so the retrieval hot path never runs DDL. That database
may be a separate cluster, which is why it has no Alembic migration.
Every step is gated by a setting that defaults ON for dev convenience and
can be turned off in prod (``AUTO_CREATE_DB`` / ``AUTO_MIGRATE`` /
``AUTO_VECTOR_SCHEMA``) where schema is managed out-of-band by a deploy
pipeline.
All heavy imports (alembic, psycopg, sqlalchemy.exc sub-symbols) are
deferred to inside the function so merely importing this module has no
@@ -19,6 +25,7 @@ side effects and is cheap for test collection.
from __future__ import annotations
import logging
import time
from typing import Optional
@@ -69,6 +76,134 @@ def ensure_database_ready(
_run_migrations(log)
def ensure_vector_schema(*, logger: Optional[logging.Logger] = None) -> None:
"""Create the pgvector schema once at boot and verify its dimension.
Owning the DDL here is what lets ``PGVectorStore`` construction stay free:
the retriever builds one store per source per request, and each used to run
``CREATE EXTENSION`` / ``CREATE TABLE`` / two ``CREATE INDEX`` statements
before its first SELECT. A dimension mismatch between the table and the
configured embeddings model is fatal here rather than silent garbage
retrieval at query time.
Args:
logger: Optional logger. Defaults to this module's logger.
Raises:
RuntimeError: The existing ``documents`` table was built for a
different embedding width than the configured model produces.
Exception: Any connection or DDL failure is re-raised so the app fails
fast, matching :func:`ensure_database_ready`.
"""
log = logger or logging.getLogger(__name__)
from application.core.settings import settings
store_kind = (settings.VECTOR_STORE or "").lower()
if store_kind != "pgvector":
log.debug(
"ensure_vector_schema: VECTOR_STORE is %r, not pgvector; nothing to do.",
settings.VECTOR_STORE,
)
return
dsn = getattr(settings, "PGVECTOR_CONNECTION_STRING", None)
if not dsn and getattr(settings, "POSTGRES_URI", None):
from application.core.db_uri import normalize_pgvector_connection_string
dsn = normalize_pgvector_connection_string(settings.POSTGRES_URI)
if not dsn:
log.info(
"ensure_vector_schema: no pgvector connection string configured "
"(PGVECTOR_CONNECTION_STRING / POSTGRES_URI); skipping."
)
return
import psycopg
from application.vectorstore.pgvector import (
DEFAULT_EMBEDDING_DIM,
SCHEMA_LOCK_KEY,
PGVectorStore,
)
# Loading the model here is deliberate: the process loads it on first
# retrieval anyway, and EmbeddingsSingleton caches it.
dim: Optional[int] = None
try:
from application.vectorstore.base import get_embeddings
dim = getattr(get_embeddings(), "dimension", None)
except Exception as exc: # noqa: BLE001 — never block boot on the model
log.warning(
"ensure_vector_schema: could not load the embeddings model (%s); "
"creating the table with %d dimensions and skipping the dimension "
"check.",
exc,
DEFAULT_EMBEDDING_DIM,
)
if dim is None:
log.warning(
"ensure_vector_schema: the embeddings model exposes no dimension; "
"using %d and skipping the dimension check.",
DEFAULT_EMBEDDING_DIM,
)
graph_enabled = bool(getattr(settings, "GRAPHRAG_ENABLED", False))
started = time.monotonic()
# A plain connection, never the store's pool: this can run pre-fork under
# ``gunicorn --preload``, and an inherited pooled socket is a broken one.
conn = psycopg.connect(dsn)
try:
cursor = conn.cursor()
try:
# Serialize concurrent workers; released when this transaction ends.
cursor.execute(
"SELECT pg_advisory_xact_lock(hashtext(%s));", (SCHEMA_LOCK_KEY,)
)
finally:
cursor.close()
PGVectorStore.create_schema(conn, dimension=dim or DEFAULT_EMBEDDING_DIM)
if graph_enabled:
from application.graphrag.store import (
DEFAULT_NAME_EMBEDDING_DIM,
GraphStore,
)
GraphStore.create_schema(
conn, dimension=dim or DEFAULT_NAME_EMBEDDING_DIM
)
conn.commit()
actual = PGVectorStore.table_dimension(conn)
if dim and actual and actual != dim:
raise RuntimeError(
f"pgvector table 'documents' has vector({actual}) but "
f"EMBEDDINGS_NAME={settings.EMBEDDINGS_NAME} produces {dim}-dim "
"vectors; re-ingest into a fresh table or point "
"PGVECTOR_CONNECTION_STRING at the matching database."
)
log.info(
"ensure_vector_schema: table 'documents' ready "
"(dimension=%s, graph tables=%s) in %d ms.",
actual or dim or DEFAULT_EMBEDDING_DIM,
"yes" if graph_enabled else "no",
int((time.monotonic() - started) * 1000),
)
except Exception as exc:
try:
conn.rollback()
except Exception:
# Connection already gone; nothing left to roll back.
pass
log.error("ensure_vector_schema: %s", exc)
raise
finally:
conn.close()
def _ensure_database_exists(uri: str, log: logging.Logger) -> None:
"""Create the target database if a connection reveals it's missing.
+100 -50
View File
@@ -1,6 +1,7 @@
import logging
import os
from abc import ABC, abstractmethod
from typing import Optional
import requests
@@ -196,18 +197,111 @@ class EmbeddingsSingleton:
}
if embeddings_name in embeddings_factory:
return embeddings_factory[embeddings_name](*args, **kwargs)
if args or kwargs:
logging.debug(
"Dropping %d positional and %d keyword argument(s) for pinned "
"embeddings model %s: its factory takes none.",
len(args),
len(kwargs),
embeddings_name,
)
return embeddings_factory[embeddings_name]()
else:
return EmbeddingsWrapper(embeddings_name, *args, **kwargs)
def _azure_configured() -> bool:
"""True when the Azure OpenAI deployment settings are all present."""
return bool(
settings.OPENAI_API_BASE
and settings.OPENAI_API_VERSION
and settings.AZURE_DEPLOYMENT_NAME
)
def get_embeddings(
embeddings_name: Optional[str] = None, embeddings_key: Optional[str] = None
):
"""Resolve the configured embeddings instance. The single entry point.
Callers that reach for :meth:`EmbeddingsSingleton.get_instance` directly
reproduce neither the bundled local-model path (the Docker image ships
``/app/models/all-mpnet-base-v2``) nor the OpenAI/Azure key handling: they
download a second copy of the model from the hub, and passing the key
positionally to the pinned HuggingFace names raises ``TypeError`` because
those factories take no arguments. Route every caller through here.
Args:
embeddings_name: Model name; defaults to ``settings.EMBEDDINGS_NAME``.
embeddings_key: API key; defaults to ``settings.EMBEDDINGS_KEY``.
Returns:
The shared embeddings instance for the resolved model.
"""
embeddings_name = embeddings_name or settings.EMBEDDINGS_NAME
embeddings_key = (
embeddings_key if embeddings_key is not None else settings.EMBEDDINGS_KEY
)
# Check for remote embeddings first
if settings.EMBEDDINGS_BASE_URL:
logging.info(
f"Using remote embeddings API at: {settings.EMBEDDINGS_BASE_URL}"
)
return EmbeddingsSingleton._remote_instance(embeddings_name, embeddings_key)
if embeddings_name == "openai_text-embedding-ada-002":
if _azure_configured():
embedding_instance = EmbeddingsSingleton.get_instance(
embeddings_name, model=settings.AZURE_EMBEDDINGS_DEPLOYMENT_NAME
)
else:
embedding_instance = EmbeddingsSingleton.get_instance(
embeddings_name, openai_api_key=embeddings_key
)
elif embeddings_name == "huggingface_sentence-transformers/all-mpnet-base-v2":
possible_paths = [
"/app/models/all-mpnet-base-v2", # Docker absolute path
"./models/all-mpnet-base-v2", # Relative path
]
local_model_path = None
for path in possible_paths:
if os.path.exists(path):
local_model_path = path
logging.info(f"Found local model at path: {path}")
break
else:
logging.info(f"Path does not exist: {path}")
if local_model_path:
embedding_instance = EmbeddingsSingleton.get_instance(
local_model_path,
)
else:
logging.warning(
f"Local model not found in any of the paths: {possible_paths}. Falling back to HuggingFace download."
)
embedding_instance = EmbeddingsSingleton.get_instance(
embeddings_name,
)
else:
embedding_instance = EmbeddingsSingleton.get_instance(embeddings_name)
return embedding_instance
class BaseVectorStore(ABC):
def __init__(self):
pass
@abstractmethod
def search(self, *args, **kwargs):
"""Search for similar documents/chunks in the vectorstore"""
"""Search for similar documents/chunks in the vectorstore.
Implementations accept an optional ``query_vector`` kwarg: the query
already embedded by the caller, so a multi-source retrieval embeds once
instead of once per store. A store that cannot use it must still swallow
the kwarg (every signature here ends in ``**kwargs``) and embed the
question itself.
"""
pass
def keyword_search(self, question, k=10):
@@ -279,53 +373,9 @@ class BaseVectorStore(ABC):
return deleted
def is_azure_configured(self):
return (
settings.OPENAI_API_BASE
and settings.OPENAI_API_VERSION
and settings.AZURE_DEPLOYMENT_NAME
)
"""Kept for compatibility; delegates to the module-level check."""
return _azure_configured()
def _get_embeddings(self, embeddings_name, embeddings_key=None):
# Check for remote embeddings first
if settings.EMBEDDINGS_BASE_URL:
logging.info(
f"Using remote embeddings API at: {settings.EMBEDDINGS_BASE_URL}"
)
return EmbeddingsSingleton._remote_instance(embeddings_name, embeddings_key)
if embeddings_name == "openai_text-embedding-ada-002":
if self.is_azure_configured():
embedding_instance = EmbeddingsSingleton.get_instance(
embeddings_name, model=settings.AZURE_EMBEDDINGS_DEPLOYMENT_NAME
)
else:
embedding_instance = EmbeddingsSingleton.get_instance(
embeddings_name, openai_api_key=embeddings_key
)
elif embeddings_name == "huggingface_sentence-transformers/all-mpnet-base-v2":
possible_paths = [
"/app/models/all-mpnet-base-v2", # Docker absolute path
"./models/all-mpnet-base-v2", # Relative path
]
local_model_path = None
for path in possible_paths:
if os.path.exists(path):
local_model_path = path
logging.info(f"Found local model at path: {path}")
break
else:
logging.info(f"Path does not exist: {path}")
if local_model_path:
embedding_instance = EmbeddingsSingleton.get_instance(
local_model_path,
)
else:
logging.warning(
f"Local model not found in any of the paths: {possible_paths}. Falling back to HuggingFace download."
)
embedding_instance = EmbeddingsSingleton.get_instance(
embeddings_name,
)
else:
embedding_instance = EmbeddingsSingleton.get_instance(embeddings_name)
return embedding_instance
"""Resolve embeddings for this store; see :func:`get_embeddings`."""
return get_embeddings(embeddings_name, embeddings_key)
+21 -3
View File
@@ -73,9 +73,27 @@ class ElasticsearchStore(BaseVectorStore):
return es_client
def search(self, question, k=2, index_name=settings.ELASTIC_INDEX, *args, **kwargs):
embeddings = self._get_embeddings(settings.EMBEDDINGS_NAME, self.embeddings_key)
vector = embeddings.embed_query(question)
def search(
self,
question,
k=2,
index_name=settings.ELASTIC_INDEX,
*args,
query_vector=None,
**kwargs,
):
"""Search by kNN + full text, fused with RRF.
Args:
query_vector: Precomputed embedding of ``question``; when given the
store skips embedding the query itself.
"""
vector = query_vector
if vector is None:
embeddings = self._get_embeddings(
settings.EMBEDDINGS_NAME, self.embeddings_key
)
vector = embeddings.embed_query(question)
knn = {
"filter": [{"match": {"metadata.source_id.keyword": self.source_id}}],
"field": "vector",
+15 -3
View File
@@ -174,16 +174,28 @@ class FaissStore(BaseVectorStore):
return [doc for doc, _ in self.search_with_scores(question, k, *args, **kwargs)]
def search_with_scores(
self, question: str, k: int = 4, *args, **kwargs
self,
question: str,
k: int = 4,
*args,
query_vector: Optional[List[float]] = None,
**kwargs,
) -> List[Tuple[Document, float]]:
"""Same search as :meth:`search`, pairing each hit with its L2 distance."""
"""Same search as :meth:`search`, pairing each hit with its L2 distance.
Args:
query_vector: Precomputed embedding of ``question``; when given the
store skips embedding the query itself.
"""
# FAISS has no relevance-threshold knob; drop it so the per-source
# score_threshold is safely ignored rather than crashing the forward.
kwargs.pop("score_threshold", None)
if self.index is None or self.index.ntotal == 0:
return []
vector = np.array([self.embeddings.embed_query(question)], dtype=np.float32)
if query_vector is None:
query_vector = self.embeddings.embed_query(question)
vector = np.array([query_vector], dtype=np.float32)
distances, rows = self.index.search(vector, min(k, self.index.ntotal))
results = []
+12 -3
View File
@@ -80,10 +80,19 @@ class LanceDBVectorStore(BaseVectorStore):
self.ensure_table_exists()
self.docsearch.add(vectors)
def search(self, query: str, k: int = 2, *args, **kwargs):
"""Search LanceDB for the top k most similar vectors."""
def search(self, query: str, k: int = 2, *args, query_vector=None, **kwargs):
"""Search LanceDB for the top k most similar vectors.
Args:
query_vector: Precomputed embedding of ``query``; when given the
store skips embedding the query itself.
"""
self.ensure_table_exists()
query_embedding = self._get_embeddings(settings.EMBEDDINGS_NAME, self.embeddings_key).embed_query(query)
query_embedding = query_vector
if query_embedding is None:
query_embedding = self._get_embeddings(
settings.EMBEDDINGS_NAME, self.embeddings_key
).embed_query(query)
results = self.docsearch.search(query_embedding).limit(k).to_list()
return [(result["_distance"], result["text"], result["metadata"]) for result in results]
+10 -2
View File
@@ -110,12 +110,20 @@ class MilvusStore(BaseVectorStore):
k: int = 2,
*args,
score_threshold: Optional[float] = None,
query_vector: Optional[List[float]] = None,
**kwargs,
) -> List[Tuple[Document, float]]:
"""Search, pairing each hit with its cosine similarity."""
"""Search, pairing each hit with its cosine similarity.
Args:
query_vector: Precomputed embedding of ``question``; when given the
store skips embedding the query itself.
"""
if query_vector is None:
query_vector = self._embeddings.embed_query(question)
results = self._client.search(
collection_name=self._collection,
data=[self._embeddings.embed_query(question)],
data=[query_vector],
filter=self._filter,
limit=k,
output_fields=["text", "metadata"],
+18 -5
View File
@@ -54,7 +54,9 @@ class MongoDBVectorStore(BaseVectorStore):
score_kind = "cosine_similarity"
def search(self, question, k=2, *args, score_threshold=None, **kwargs):
def search(
self, question, k=2, *args, score_threshold=None, query_vector=None, **kwargs
):
"""Search via Atlas ``$vectorSearch``.
Args:
@@ -62,21 +64,32 @@ class MongoDBVectorStore(BaseVectorStore):
k: Maximum number of results.
score_threshold: Optional ``vectorSearchScore`` floor in ``[0, 1]``;
results scoring below it are dropped.
query_vector: Precomputed embedding of ``question``, so a caller
searching several sources embeds the query only once.
"""
return [
doc
for doc, _ in self.search_with_scores(
question, k, *args, score_threshold=score_threshold, **kwargs
question,
k,
*args,
score_threshold=score_threshold,
query_vector=query_vector,
**kwargs,
)
]
def search_with_scores(self, question, k=2, *args, score_threshold=None, **kwargs):
def search_with_scores(
self, question, k=2, *args, score_threshold=None, query_vector=None, **kwargs
):
"""Same search as :meth:`search`, pairing each hit with its score.
The score is Atlas' ``vectorSearchScore`` — the same quantity
``score_threshold`` is compared against.
``score_threshold`` is compared against. ``query_vector`` skips the
per-store query embedding when the caller already has one.
"""
query_vector = self._embedding.embed_query(question)
if query_vector is None:
query_vector = self._embedding.embed_query(question)
pipeline = [
{
+112
View File
@@ -0,0 +1,112 @@
"""Process-wide psycopg connection pools keyed by DSN.
Every component that talks to the pgvector database — the vector store and the
GraphRAG graph store — checks out of the *same* pool for a given DSN, so a
request that touches N sources costs pooled checkouts instead of N fresh TCP +
TLS + auth handshakes. Keeping the registry in its own module (rather than in
``pgvector``) is what lets ``application.graphrag.store`` join it without
importing the vector store.
The boot-time schema hook deliberately does not use this: pools are built
lazily on first checkout, i.e. after the fork in a Celery/gunicorn worker, so a
preloading parent never hands a live socket to a child.
"""
from __future__ import annotations
import logging
import threading
from typing import Any, Dict
# Seconds a caller waits for a free pooled connection before giving up.
POOL_TIMEOUT_SECONDS = 30.0
# dsn -> psycopg_pool.ConnectionPool, one per process.
_POOLS: Dict[str, Any] = {}
_POOLS_LOCK = threading.Lock()
def configure_pooled_connection(conn) -> None:
"""Register pgvector's type adapters once per physical pooled connection.
Tolerates a database where ``CREATE EXTENSION vector`` has not run yet:
``register_vector`` looks the type up in the catalog and raises when it is
absent, which would leave the pool unable to open the very connection the
schema bootstrap needs. The write path re-registers once the type exists.
"""
from pgvector.psycopg import register_vector
try:
register_vector(conn)
except Exception as e:
logging.debug("pgvector types not registered yet: %s", e)
def pool_for(dsn: str, max_size: int):
"""Return this process's connection pool for ``dsn``, creating it lazily.
The pool is keyed by DSN alone and is built on first checkout — i.e. after
the fork in a Celery/gunicorn worker, so a preloading parent never hands a
live socket to a child. The boot-time schema hook must therefore use a
direct connection rather than this pool.
Args:
dsn: libpq connection string; the pool's only identity.
max_size: Upper bound on physical connections, used on creation only.
Returns:
psycopg_pool.ConnectionPool: The shared pool for ``dsn``.
"""
pool = _POOLS.get(dsn)
if pool is not None:
return pool
with _POOLS_LOCK:
pool = _POOLS.get(dsn)
if pool is None:
from psycopg_pool import ConnectionPool
pool = ConnectionPool(
conninfo=dsn,
min_size=1,
max_size=max_size,
open=True,
timeout=POOL_TIMEOUT_SECONDS,
name="docsgpt-pgvector",
configure=configure_pooled_connection,
check=ConnectionPool.check_connection,
)
_POOLS[dsn] = pool
return pool
def release(dsn: str, conn, pooled: bool) -> None:
"""Hand ``conn`` back: to ``dsn``'s pool when pooled, else close it.
A pooled connection is rolled back first when it is still in a transaction,
so the next borrower gets a clean session. Never raises — releasing is
always best-effort cleanup on a path that is often a destructor.
Args:
dsn: Connection string the connection was opened for.
conn: The psycopg connection being released; ``None`` is a no-op.
pooled: Whether the connection came from :func:`pool_for`.
"""
if conn is None:
return
try:
if pooled:
try:
if conn.info.transaction_status.name != "IDLE":
conn.rollback()
except Exception:
# Connection already broken; the pool discards it.
pass
pool = _POOLS.get(dsn)
if pool is not None:
pool.putconn(conn)
elif not conn.closed:
conn.close()
elif not conn.closed:
conn.close()
except Exception as e:
logging.debug("Error releasing pooled connection: %s", e)
+226 -33
View File
@@ -6,12 +6,28 @@ from typing import List, Optional, Any, Dict
from psycopg.types.json import Jsonb
from application.core.settings import settings
from application.vectorstore import pgconn
from application.vectorstore.base import BaseVectorStore
from application.vectorstore.document_class import Document
# table name -> IVFFlat ``lists`` (None when the table has no such index)
_IVFFLAT_LISTS_CACHE: Dict[str, Optional[int]] = {}
DEFAULT_EMBEDDING_DIM = 768
DEFAULT_POOL_MAX_SIZE = 8
# Advisory-lock key shared with the boot hook so concurrent workers serialize DDL.
SCHEMA_LOCK_KEY = "docsgpt:vectors:ddl"
# The connection pools moved to ``application.vectorstore.pgconn`` so the graph
# store can share them without importing this module. These names stay bound
# here — same objects, not copies — because callers and tests reach for
# ``pgvector._POOLS`` / patch ``pgvector._pool_for``.
POOL_TIMEOUT_SECONDS = pgconn.POOL_TIMEOUT_SECONDS
_POOLS = pgconn._POOLS
_POOLS_LOCK = pgconn._POOLS_LOCK
_configure_pooled_connection = pgconn.configure_pooled_connection
_pool_for = pgconn.pool_for
class PGVectorStore(BaseVectorStore):
def __init__(
@@ -63,17 +79,64 @@ class PGVectorStore(BaseVectorStore):
self._psycopg = psycopg
self._register_vector = register_vector
self._connection = None
self._ensure_table_exists()
self._pooled = False
self._pool_max_size = self._resolve_pool_max_size()
# No DDL here. The retriever builds one store per source per request, so
# construction must stay free: schema is owned at boot by
# ``ensure_vector_schema``, and the write path below re-checks it once as
# a safety net for processes that never ran the boot hook.
self._schema_ensured = False
@staticmethod
def _resolve_pool_max_size() -> int:
"""Pool size from settings, defensively — 0 means one direct connection."""
value = getattr(settings, "PGVECTOR_POOL_MAX_SIZE", DEFAULT_POOL_MAX_SIZE)
if isinstance(value, int) and not isinstance(value, bool) and value >= 0:
return value
return DEFAULT_POOL_MAX_SIZE
def _get_connection(self):
"""Get or create database connection"""
"""Get or create this store's connection, pooled unless pooling is off."""
if self._connection is None or self._connection.closed:
self._connection = self._psycopg.connect(self._connection_string)
# Register pgvector types
self._register_vector(self._connection)
self._apply_ivfflat_probes(self._connection)
if self._pool_max_size > 0:
self._connection = _pool_for(
self._connection_string, self._pool_max_size
).getconn()
self._pooled = True
else:
self._connection = self._psycopg.connect(self._connection_string)
# Register pgvector types
self._register_pgvector_types(self._connection)
self._pooled = False
self._apply_probes_once(self._connection)
return self._connection
def _register_pgvector_types(self, conn) -> None:
"""Register pgvector's adapters, tolerating a not-yet-created extension."""
try:
self._register_vector(conn)
except Exception as e:
logging.debug("pgvector types not registered yet: %s", e)
def _apply_probes_once(self, conn) -> None:
"""Set ``ivfflat.probes`` once per physical connection and table.
A pooled connection outlives the store that checked it out, and the
``SET`` is session-level, so the probe lookup must not repeat on every
checkout. The marker lives on the connection object itself because that
is what the pool recycles.
"""
if getattr(conn, "_docsgpt_probes_table", None) == self._table_name:
return
self._apply_ivfflat_probes(conn)
try:
# ``SET`` (not SET LOCAL) is undone by a rollback, so make it stick
# before any query runs on this connection.
conn.commit()
conn._docsgpt_probes_table = self._table_name
except Exception as e: # never let session tuning break a query
logging.debug("Could not persist ivfflat.probes: %s", e)
def _ivfflat_lists(self, conn) -> Optional[int]:
"""Return the ``lists`` value of this table's IVFFlat index, if any.
@@ -125,30 +188,48 @@ class PGVectorStore(BaseVectorStore):
except Exception as e: # older pgvector / no index — search still works
logging.debug("Could not set ivfflat.probes: %s", e)
def _ensure_table_exists(self):
"""Create table and enable pgvector extension if they don't exist"""
conn = self._get_connection()
@staticmethod
def create_schema(
conn,
*,
table_name: str = "documents",
vector_column: str = "embedding",
text_column: str = "text",
metadata_column: str = "metadata",
dimension: int = DEFAULT_EMBEDDING_DIM,
) -> None:
"""Create the extension, table and indexes on ``conn`` without committing.
Shared by the boot hook (``ensure_vector_schema``) and the store's own
write-path safety net; the caller owns the transaction.
Args:
conn: Open psycopg connection.
table_name: Documents table to create.
vector_column: Embedding column name.
text_column: Chunk-text column name.
metadata_column: JSONB metadata column name.
dimension: Width of the embedding vectors.
"""
cursor = conn.cursor()
try:
# Enable pgvector extension
cursor.execute("CREATE EXTENSION IF NOT EXISTS vector;")
embedding_dim = getattr(self._embedding, 'dimension', 768)
# Create table with vector column
create_table_query = f"""
CREATE TABLE IF NOT EXISTS {self._table_name} (
CREATE TABLE IF NOT EXISTS {table_name} (
id SERIAL PRIMARY KEY,
{self._text_column} TEXT NOT NULL,
{self._vector_column} vector({embedding_dim}),
{self._metadata_column} JSONB,
{text_column} TEXT NOT NULL,
{vector_column} vector({dimension}),
{metadata_column} JSONB,
source_id TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
"""
cursor.execute(create_table_query)
# NO vector index is created here, deliberately.
#
# This runs when the table is first created, i.e. on an EMPTY
@@ -166,25 +247,88 @@ class PGVectorStore(BaseVectorStore):
# data, once a corpus is large enough to need one.
# Create index for source_id filtering
source_index_query = f"""
CREATE INDEX IF NOT EXISTS {self._table_name}_source_id_idx
ON {self._table_name} (source_id);
CREATE INDEX IF NOT EXISTS {table_name}_source_id_idx
ON {table_name} (source_id);
"""
cursor.execute(source_index_query)
# Functional GIN index backing keyword_search full-text queries.
fts_index_query = f"""
CREATE INDEX IF NOT EXISTS {self._table_name}_text_fts_idx
ON {self._table_name} USING gin(to_tsvector('english', {self._text_column}));
CREATE INDEX IF NOT EXISTS {table_name}_text_fts_idx
ON {table_name} USING gin(to_tsvector('english', {text_column}));
"""
cursor.execute(fts_index_query)
finally:
cursor.close()
@staticmethod
def table_dimension(
conn, table_name: str = "documents", vector_column: str = "embedding"
) -> Optional[int]:
"""Width declared by the table's vector column, or ``None`` if unknown.
``None`` means the table is absent or the column is not a ``vector``.
"""
cursor = conn.cursor()
try:
cursor.execute("SELECT to_regclass(%s);", (table_name,))
row = cursor.fetchone()
if not row or row[0] is None:
return None
cursor.execute(
"SELECT format_type(a.atttypid, a.atttypmod) FROM pg_attribute a "
"WHERE a.attrelid = %s::regclass AND a.attname = %s "
"AND NOT a.attisdropped",
(table_name, vector_column),
)
row = cursor.fetchone()
if not row or not row[0]:
return None
match = re.search(r"vector\((\d+)\)", str(row[0]))
return int(match.group(1)) if match else None
finally:
cursor.close()
def _ensure_table_exists(self) -> None:
"""Create this store's schema under an advisory lock, then commit."""
conn = self._get_connection()
try:
cursor = conn.cursor()
try:
cursor.execute(
"SELECT pg_advisory_xact_lock(hashtext(%s));", (SCHEMA_LOCK_KEY,)
)
finally:
cursor.close()
self.create_schema(
conn,
table_name=self._table_name,
vector_column=self._vector_column,
text_column=self._text_column,
metadata_column=self._metadata_column,
dimension=getattr(self._embedding, "dimension", DEFAULT_EMBEDDING_DIM),
)
conn.commit()
# The extension may have just been created; pick up its adapters so
# the insert that follows can bind a vector.
self._register_pgvector_types(conn)
except Exception as e:
conn.rollback()
logging.error(f"Error creating table: {e}")
raise
finally:
cursor.close()
def _ensure_schema_once(self) -> None:
"""Create the schema on this instance's first write, at most once.
Readers never create schema — boot owns it. This is the safety net for a
process that never ran the boot hook (scripts, tests, the first ingest on
a brand-new deployment); a read against a missing table surfaces
psycopg's own error, like every other store.
"""
if self._schema_ensured:
return
self._ensure_table_exists()
self._schema_ensured = True
score_kind = "cosine_similarity"
@@ -194,6 +338,7 @@ class PGVectorStore(BaseVectorStore):
k: int = 2,
*args,
score_threshold: float = None,
query_vector: Optional[List[float]] = None,
**kwargs,
) -> List[Document]:
"""Search for similar documents using vector similarity.
@@ -204,11 +349,19 @@ class PGVectorStore(BaseVectorStore):
score_threshold: Optional cosine-similarity floor in ``[0, 1]``.
Cosine distance = ``1 - similarity``; rows with similarity below
the threshold (distance above ``1 - threshold``) are dropped.
query_vector: Precomputed embedding of ``question``. Supplied by a
caller searching several sources with one query, so the query is
embedded once instead of once per store.
"""
return [
doc
for doc, _ in self.search_with_scores(
question, k, *args, score_threshold=score_threshold, **kwargs
question,
k,
*args,
score_threshold=score_threshold,
query_vector=query_vector,
**kwargs,
)
]
@@ -291,6 +444,7 @@ class PGVectorStore(BaseVectorStore):
k: int = 2,
*args,
score_threshold: float = None,
query_vector: Optional[List[float]] = None,
**kwargs,
) -> List[tuple]:
"""Same search as :meth:`search`, pairing each hit with its similarity.
@@ -298,8 +452,13 @@ class PGVectorStore(BaseVectorStore):
The score is the cosine similarity (``1 - cosine_distance``) — the exact
quantity ``score_threshold`` is compared against, so a caller can read a
result's score and pick a threshold from it directly.
Args:
query_vector: Precomputed embedding of ``question``; when given the
store skips embedding the query itself.
"""
query_vector = self._embedding.embed_query(question)
if query_vector is None:
query_vector = self._embedding.embed_query(question)
conn = self._get_connection()
cursor = conn.cursor()
@@ -334,6 +493,11 @@ class PGVectorStore(BaseVectorStore):
(Document(page_content=text, metadata=metadata), score)
)
# End the read transaction. On a persistent (pooled) connection an
# uncommitted SELECT leaves the backend "idle in transaction",
# pinning a snapshot and blocking VACUUM for as long as the store
# lives.
conn.commit()
return documents
except Exception as e:
@@ -381,10 +545,16 @@ class PGVectorStore(BaseVectorStore):
metadata = metadata or {}
documents.append(Document(page_content=text, metadata=metadata))
conn.commit()
return documents
except Exception as e:
logging.error(f"Error in keyword search: {e}", exc_info=True)
try:
conn.rollback()
except Exception:
# Connection already gone; nothing left to roll back.
pass
return []
finally:
cursor.close()
@@ -402,7 +572,8 @@ class PGVectorStore(BaseVectorStore):
embeddings = self._embedding.embed_documents(texts)
metadatas = metadatas or [{}] * len(texts)
self._ensure_schema_once()
conn = self._get_connection()
cursor = conn.cursor()
@@ -474,11 +645,17 @@ class PGVectorStore(BaseVectorStore):
"text": text,
"metadata": metadata or {}
})
conn.commit()
return chunks
except Exception as e:
logging.error(f"Error getting chunks: {e}")
try:
conn.rollback()
except Exception:
# Connection already gone; nothing left to roll back.
pass
return []
finally:
cursor.close()
@@ -495,7 +672,8 @@ class PGVectorStore(BaseVectorStore):
if not embeddings:
raise ValueError("Could not generate embedding for chunk")
self._ensure_schema_once()
conn = self._get_connection()
cursor = conn.cursor()
@@ -570,7 +748,22 @@ class PGVectorStore(BaseVectorStore):
finally:
cursor.close()
def close(self) -> None:
"""Release this store's connection: back to the pool, or closed outright.
A pooled connection is rolled back first when it is still in a
transaction, so the next borrower gets a clean session.
"""
conn = getattr(self, "_connection", None)
if conn is None:
return
self._connection = None
pgconn.release(self._connection_string, conn, getattr(self, "_pooled", False))
def __del__(self):
"""Close database connection when object is destroyed"""
if hasattr(self, '_connection') and self._connection and not self._connection.closed:
self._connection.close()
"""Release the connection when the object is destroyed. Never raises."""
try:
self.close()
except Exception:
# Interpreter teardown can null out module globals; never raise here.
pass
+10 -2
View File
@@ -109,12 +109,20 @@ class QdrantStore(BaseVectorStore):
k: int = 2,
*args,
score_threshold: Optional[float] = None,
query_vector: Optional[List[float]] = None,
**kwargs,
) -> List[Tuple[Document, float]]:
"""Search, pairing each hit with its cosine similarity."""
"""Search, pairing each hit with its cosine similarity.
Args:
query_vector: Precomputed embedding of ``question``; when given the
store skips embedding the query itself.
"""
if query_vector is None:
query_vector = self._embeddings.embed_query(question)
hits = self._client.query_points(
collection_name=self._collection,
query=self._embeddings.embed_query(question),
query=query_vector,
query_filter=self._filter,
limit=k,
with_payload=True,