unsloth/studio/backend/core/rag/retrieval.py
Daniel Han ab0828b976 Studio: fix RAG correctness bugs
Backend:
- Deterministic SQLite connection cleanup. The RAG code used bare
  `with get_connection() as conn:`, which commits but never closes, leaning
  on GC to release handles (the rest of studio_db closes explicitly). Add a
  closing_connection() context manager that commits/rolls back like sqlite3's
  own manager and always closes, and route all 30 RAG call sites through it.
- filter_by_min_score no longer drops BM25-only and figure-ref hits. min_score
  is a cosine floor, so it now gates only hits that carry a dense_score;
  lexical and figure-ref hits (dense_score is None) pass through instead of
  being silently discarded when the floor is raised.
- Fix two tests that could not pass against the production code: the RRF
  fusion test asserted the wrong winner (c edges out b: 0.032266 vs 0.032258),
  and two tool-handler scope tests stubbed retrieve_hybrid without accepting
  the embedder_model kwarg the handler now passes (TypeError was swallowed,
  leaving captured["scope"] unset).

Frontend:
- Removing an in-flight upload chip now routes through the teardown thunk
  already registered for the aggregate-progress toast (abort, unsubscribe,
  release the index slot, delete the backend doc with the correct kb/thread
  scope key it closed over) and clears the toast entry. Deleting directly
  leaked the concurrency slot and hardcoded the thread scope, mis-targeting
  KB-scoped docs. Applied in both the composer hook and the compare-view
  composer; drop the now-vestigial chip-scope-key tracking and unused
  activeThreadId selectors. Add index-progress-store.remove(id).
2026-05-31 09:56:23 +00:00

243 lines
7.6 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""RAG retrieval: BM25, dense, and RRF hybrid. Hits carry dense_score for thresholding."""
from __future__ import annotations
import re
from dataclasses import dataclass
from utils.rag.config import (
RAG_RRF_K,
RAG_TOP_K_BM25,
RAG_TOP_K_DENSE,
RAG_TOP_K_HYBRID,
)
from . import bm25, embeddings, vector_store
# Match "Figure 1", "Figure 1.2", "Figure B.1", "Table 4", "Fig. 5" anywhere in
# the query. Feeds a third retrieval source that looks up chunks anchored by these
# refs — dense vectors don't preserve figure numbers, so without this an exact-numbered
# query gets out-ranked by chunks describing other figures with more shared vocabulary.
_FIGURE_REF_RE = re.compile(
r"\b(Figure|Fig\.|Table|Tab\.)\s+([A-Z]?\.?\d+(?:\.\d+)?)\b",
re.IGNORECASE,
)
def _extract_figure_refs(query: str) -> list[str]:
"""Return normalized 'Figure N' / 'Table N' references found in query."""
refs: list[str] = []
seen: set[str] = set()
for m in _FIGURE_REF_RE.finditer(query):
head = m.group(1).lower()
label = "Figure" if head.startswith("fig") else "Table"
ref = f"{label} {m.group(2)}"
if ref not in seen:
seen.add(ref)
refs.append(ref)
return refs
@dataclass(frozen = True)
class Hit:
chunk_id: str
score: float
document_id: str | None = None
chunk_index: int | None = None
kind: str = "text"
source_page_index: int | None = None
page_char_start: int | None = None
page_char_end: int | None = None
line_start: int | None = None
line_end: int | None = None
# Raw cosine; None for BM25-only hits.
dense_score: float | None = None
def retrieve_bm25(scope: str, query: str, k: int | None = None) -> list[Hit]:
limit = k or RAG_TOP_K_BM25
return [Hit(chunk_id = cid, score = s) for cid, s in bm25.search(scope, query, limit)]
def retrieve_figure_refs(
scope: str,
query: str,
*,
k: int = 5,
document_ids: list[str] | None = None,
) -> list[Hit]:
"""Look up chunks anchored at a 'Figure N:' / 'Table N:' caption that
the query references. Returns at most ``k`` hits — usually 0 or 1.
Chunks produced by the figure-boundary chunker start with the literal
caption, so a SQL prefix match is enough; we don't need full-text
search here.
"""
refs = _extract_figure_refs(query)
if not refs:
return []
from .db import get_rag_connection
placeholders_docs = ""
params: list = [scope]
if document_ids:
placeholders_docs = f" AND document_id IN ({','.join('?' * len(document_ids))})"
params.extend(document_ids)
like_clauses: list[str] = []
for ref in refs:
# Match "Figure 1:" and "Figure 1." (period-terminated captions).
like_clauses.append(
"json_extract(payload_json, '$.text') LIKE ?"
" OR json_extract(payload_json, '$.text') LIKE ?"
)
params.extend([f"{ref}:%", f"{ref}.%"])
sql = (
"SELECT chunk_id, document_id, chunk_index, kind"
" FROM rag_vectors"
f" WHERE scope = ?{placeholders_docs}"
" AND kind = 'text'"
f" AND ({' OR '.join(like_clauses)})"
f" LIMIT {int(k)}"
)
out: list[Hit] = []
with get_rag_connection() as conn:
for row in conn.execute(sql, params):
out.append(
Hit(
chunk_id = row[0],
score = 1.0,
document_id = row[1],
chunk_index = row[2],
kind = row[3] or "text",
)
)
return out
def retrieve_dense(
scope: str,
query: str,
k: int | None = None,
*,
document_ids: list[str] | None = None,
embedder_model: str | None = None,
) -> list[Hit]:
"""Dense retrieval. embedder_model MUST match the model that populated this scope."""
limit = k or RAG_TOP_K_DENSE
vector = embeddings.encode(
[query],
normalize = True,
model_name = embedder_model,
)[0].tolist()
raw = vector_store.search(
scope,
query_vector = vector,
top_k = limit,
document_ids = document_ids,
)
out: list[Hit] = []
for r in raw:
payload = r["payload"]
out.append(
Hit(
chunk_id = r["chunk_id"],
score = r["score"],
document_id = payload.get("document_id"),
chunk_index = payload.get("chunk_index"),
kind = payload.get("kind", "text"),
source_page_index = payload.get("source_page_index"),
page_char_start = payload.get("page_char_start"),
page_char_end = payload.get("page_char_end"),
line_start = payload.get("line_start"),
line_end = payload.get("line_end"),
dense_score = r["score"],
)
)
return out
def _rrf_fuse(
rankings: list[list[Hit]],
*,
rrf_k: int,
top_k: int,
) -> list[Hit]:
fused: dict[str, float] = {}
seen: dict[str, Hit] = {}
# Preserve dense_score through fusion for downstream thresholding.
dense_scores: dict[str, float] = {}
for ranking in rankings:
for rank, hit in enumerate(ranking):
fused[hit.chunk_id] = fused.get(hit.chunk_id, 0.0) + 1.0 / (
rrf_k + rank + 1
)
if hit.chunk_id not in seen:
seen[hit.chunk_id] = hit
if hit.dense_score is not None:
dense_scores[hit.chunk_id] = hit.dense_score
ordered = sorted(fused.items(), key = lambda kv: kv[1], reverse = True)[:top_k]
return [
Hit(
chunk_id = cid,
score = score,
document_id = seen[cid].document_id,
chunk_index = seen[cid].chunk_index,
kind = seen[cid].kind,
source_page_index = seen[cid].source_page_index,
page_char_start = seen[cid].page_char_start,
page_char_end = seen[cid].page_char_end,
line_start = seen[cid].line_start,
line_end = seen[cid].line_end,
dense_score = dense_scores.get(cid),
)
for cid, score in ordered
]
def retrieve_hybrid(
scope: str,
query: str,
*,
k: int | None = None,
k_bm25: int | None = None,
k_dense: int | None = None,
document_ids: list[str] | None = None,
embedder_model: str | None = None,
) -> list[Hit]:
bm25_hits = retrieve_bm25(scope, query, k_bm25 or RAG_TOP_K_BM25)
dense_hits = retrieve_dense(
scope,
query,
k_dense or RAG_TOP_K_DENSE,
document_ids = document_ids,
embedder_model = embedder_model,
)
rankings = [bm25_hits, dense_hits]
fig_hits = retrieve_figure_refs(scope, query, document_ids = document_ids)
if fig_hits:
rankings.append(fig_hits)
return _rrf_fuse(
rankings,
rrf_k = RAG_RRF_K,
top_k = k or RAG_TOP_K_HYBRID,
)
def filter_by_min_score(hits: list[Hit], min_score: float) -> list[Hit]:
"""Apply the dense-similarity floor.
min_score is a cosine threshold, so it only gates hits that carry a
dense_score. BM25-only and figure-ref hits (dense_score is None) are matched
by a different signal that the cosine floor does not apply to, so they pass
through rather than being silently dropped when min_score is raised.
"""
if min_score <= 0.0:
return hits
return [h for h in hits if h.dense_score is None or h.dense_score >= min_score]