Files
Alex 574f96341e refactor: rename the application package to docsgpt
The backend import package is now docsgpt, the name it will carry on PyPI;
application was far too generic to install into anyone's site-packages.
git mv plus a mechanical rewrite of every import, dotted string and path
reference: 734 Python files, the compose files, Dockerfile, workflows, docs,
setup scripts, devcontainer, k8s manifests, vscode config, pytest and coverage
config, .gitignore. Behaviour is unchanged.

Kept for one release:
- A top-level application package whose meta-path finder resolves
  application.x.y to the already-imported docsgpt.x.y object, so old imports
  and entry points (celery -A application.app.celery,
  uvicorn application.asgi:asgi_app) keep working with a FutureWarning.
- Celery registers every application.* task name as an alias of its
  docsgpt.* task on start-up, so messages queued by the previous release still
  run. The redbeat key prefix moves to redbeat:docsgpt:v2: so schedule entries
  the previous release wrote are left unread instead of firing twice.

The backend image builds from the repository root (docker build -f
docsgpt/Dockerfile .) so it can ship the alias package; a root .dockerignore
allow-lists docsgpt/ and application/ and keeps caches, local data, .env
files, the sample index files and the Dockerfile out. Compose and the image
workflows point at the new context.
2026-09-07 10:20:43 +01:00

86 lines
3.3 KiB
Python

"""Hybrid retriever fusing vector and keyword search via RRF.
Subclasses :class:`ClassicRAG` so the Dispatcher builds it with identical
ctor kwargs and it inherits rephrase + token-budgeting. Only the per-source
fetch is overridden: for each vector store it pulls vector hits and keyword
hits, then fuses them with Reciprocal Rank Fusion. Stores without keyword
support (``keyword_search`` returns ``[]``) reduce to exact vector-only
behaviour.
"""
from typing import Any, List, Optional
from docsgpt.retriever.classic_rag import ClassicRAG
RRF_K = 60
def _doc_key(doc):
"""Stable identity for a hit so the same chunk fuses across both lists."""
if hasattr(doc, "page_content") and hasattr(doc, "metadata"):
content = doc.page_content
metadata = doc.metadata or {}
else:
content = doc.get("text", doc.get("page_content", ""))
metadata = doc.get("metadata") or {}
source = metadata.get("source", "")
return (source, content)
def fuse_with_scores(vector_hits, keyword_hits, k=RRF_K):
"""Fuse two ranked hit lists by RRF, keeping each hit's fused score.
Each list contributes ``1 / (k + rank)`` per document (rank 0-based);
documents are returned as ``(doc, fused_score)`` ordered by score, highest
first. A document present in only one list is ranked solely on that list's
contribution, so an empty ``keyword_hits`` yields exactly the vector
ordering.
"""
scores = {}
docs = {}
for hits in (vector_hits, keyword_hits):
for rank, doc in enumerate(hits):
key = _doc_key(doc)
scores[key] = scores.get(key, 0.0) + 1.0 / (k + rank)
if key not in docs:
docs[key] = doc
ordered = sorted(docs.keys(), key=lambda key: scores[key], reverse=True)
return [(docs[key], scores[key]) for key in ordered]
class HybridRetriever(ClassicRAG):
"""ClassicRAG variant that fuses vector + keyword search with RRF."""
def _score_kind(self, docsearch: Any) -> str:
"""RRF scores rank hits against each other, not against a similarity
cutoff — they are not comparable to the store's cosine scores, so they
carry their own label."""
return "rrf"
def _fetch_candidates(
self,
docsearch: Any,
question: str,
src_k: int,
score_threshold: Optional[float],
query_vector: Optional[List[float]] = None,
) -> List[Any]:
"""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. ``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_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:
return fused
return [doc for doc, _ in fused]