# SPDX-License-Identifier: AGPL-3.0-only # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 """Retrieval + tool tests: RRF fusion, min-score floor, scope, source-map.""" import math import pytest from core.rag import config, retrieval, store, tool from core.rag.chunking import Chunk VOCAB = ["alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel"] def _embed(text): v = [float(text.lower().count(w)) for w in VOCAB] n = math.sqrt(sum(x * x for x in v)) or 1.0 return [x / n for x in v] @pytest.fixture def bow_embeddings(monkeypatch): """Bag-of-words embedder matching the vectors stored in the db.""" from core.rag import embeddings monkeypatch.setattr( embeddings, "encode", lambda texts, *, model_name = None, normalize = True: [_embed(t) for t in texts], ) monkeypatch.setattr(embeddings, "dim", lambda model_name = None: len(VOCAB)) def _chunk( text, index = 0, page = None, ): return Chunk( text = text, token_count = len(text.split()), page_number = page, source_page_index = 0, chunk_index = index, page_char_start = 0, page_char_end = len(text), ) def _add_doc( conn, scope, doc_id, filename, sha, text, page = None, ): store.create_document(conn, scope = scope, filename = filename, sha256 = sha, document_id = doc_id) store.add_chunks(conn, scope, doc_id, [_chunk(text, 0, page)], [_embed(text)]) def test_rrf_ranks_doc_in_both_lists_first(): # A chunk near the top of both rankings beats one in a single list. lexical = [ retrieval.Hit("a", 1.0, lexical_score = 1.0), retrieval.Hit("b", 0.5, lexical_score = 0.5), ] dense = [ retrieval.Hit("a", 0.9, dense_score = 0.9), retrieval.Hit("c", 0.8, dense_score = 0.8), ] fused = retrieval._rrf([lexical, dense], rrf_k = 60, top_k = 10) assert fused[0].chunk_id == "a" assert fused[0].lexical_score == 1.0 and fused[0].dense_score == 0.9 def test_retrieve_hybrid_returns_relevant_chunk(rag_conn, bow_embeddings): _add_doc(rag_conn, "kb_a", "d1", "f1", "h1", "alpha bravo charlie") _add_doc(rag_conn, "kb_a", "d2", "f2", "h2", "golf hotel delta") hits = retrieval.retrieve_hybrid(rag_conn, "kb_a", "alpha bravo", k = 5) assert hits[0].chunk_id == "d1:0" def test_retrieve_dense_round_trips(rag_conn, bow_embeddings): _add_doc(rag_conn, "kb_a", "d1", "f", "h1", "alpha alpha") _add_doc(rag_conn, "kb_a", "d2", "f", "h2", "hotel golf") hits = retrieval.retrieve_dense(rag_conn, "kb_a", "alpha", 5) assert hits[0].chunk_id == "d1:0" assert hits[0].dense_score is not None and hits[0].dense_score > 0.99 def test_filter_min_score_gates_dense_hits(): hits = [ retrieval.Hit("a", 1.0, dense_score = 0.9), retrieval.Hit("b", 0.5, dense_score = 0.2), retrieval.Hit("c", 0.4, lexical_score = 0.4), # no dense_score -> kept ] out = retrieval.filter_min_score(hits, 0.5) ids = {h.chunk_id for h in out} assert ids == {"a", "c"} # b below floor, c lexical-only passes assert retrieval.filter_min_score(hits, 0.0) == hits # floor off = identity def test_tool_kb_scope_wins_over_thread(rag_conn, bow_embeddings, monkeypatch): seen = {} def fake(conn, scope, q, **k): seen["scope"] = scope return [] monkeypatch.setattr(retrieval, "retrieve_hybrid", fake) tool.search_knowledge_base(query = "q", scope_kb_id = "K", scope_thread_id = "T") assert seen["scope"] == "kb_K" def test_tool_empty_query_errors(rag_home): assert tool.search_knowledge_base(query = " ").startswith("Error") def test_tool_missing_scope_message(rag_home): out = tool.search_knowledge_base(query = "hello") assert "No documents" in out def test_tool_formats_chunks_and_sources(rag_conn, bow_embeddings, monkeypatch): _add_doc(rag_conn, "kb_a", "d1", "paper.pdf", "h1", "body text here", page = 3) monkeypatch.setattr( retrieval, "retrieve_hybrid", lambda conn, scope, q, **k: [retrieval.Hit("d1:0", 1.0)], ) text, sources = tool.search_knowledge_base_with_sources(query = "q", scope_kb_id = "a") assert '' in text assert "body text here" in text assert sources == [ { "citationId": 1, "chunkId": "d1:0", "documentId": "d1", "filename": "paper.pdf", "page": 3, "text": "body text here", "score": 1.0, } ] def test_tool_kb_scope_retrieves_from_db(rag_conn, bow_embeddings): # End-to-end (no retrieve stub): doc found via its scope_kb_id (#8). _add_doc(rag_conn, "kb_K", "d1", "kb.pdf", "h1", "alpha bravo charlie", page = 1) text, sources = tool.search_knowledge_base_with_sources(query = "alpha bravo", scope_kb_id = "K") assert "No matching chunks" not in text assert sources and sources[0]["chunkId"] == "d1:0" assert sources[0]["filename"] == "kb.pdf" # A different KB id sees nothing (scope isolation). other, other_sources = tool.search_knowledge_base_with_sources( query = "alpha bravo", scope_kb_id = "OTHER" ) assert other_sources == [] and "No matching chunks" in other def test_dispatcher_appends_sources_sentinel(rag_conn, bow_embeddings, monkeypatch): # JSON source-map appended after the sentinel; text before it stays clean. import json from core.inference import tools _add_doc(rag_conn, "kb_a", "d1", "paper.pdf", "h1", "body text here", page = 3) monkeypatch.setattr( retrieval, "retrieve_hybrid", lambda conn, scope, q, **k: [retrieval.Hit("d1:0", 1.0)], ) out = tools._search_knowledge_base({"query": "q"}, {"kb_id": "a"}) assert tools.RAG_SOURCES_SENTINEL in out model_text, _, payload = out.partition(tools.RAG_SOURCES_SENTINEL) assert "__RAG_SOURCES__" not in model_text # model never sees the JSON assert ' injected. monkeypatch.setattr(retrieval, "retrieve_hybrid", _hits(0.8, key = "dense_score")) found = tool.search_for_autoinject(query = "q", scope_kb_id = "a", min_dense_score = 0.55) assert found is not None text, sources = found assert ' nothing injected. monkeypatch.setattr(retrieval, "retrieve_hybrid", _hits(0.30, key = "dense_score")) assert tool.search_for_autoinject(query = "q", scope_kb_id = "a", min_dense_score = 0.55) is None # Lexical-only hit (no dense score) does not auto-inject. monkeypatch.setattr(retrieval, "retrieve_hybrid", _hits(1.0, key = "lexical_score")) assert tool.search_for_autoinject(query = "q", scope_kb_id = "a", min_dense_score = 0.55) is None def test_search_for_autoinject_bm25_gates_on_dense_probe(rag_conn, bow_embeddings, monkeypatch): # BM25 hits carry no cosine, so the gate uses a dense 1-NN probe (#5). _add_doc(rag_conn, "kb_a", "d1", "paper.pdf", "h1", "body text here", page = 3) monkeypatch.setattr( retrieval, "retrieve_hybrid", lambda conn, scope, q, **k: [retrieval.Hit("d1:0", 1.0, lexical_score = 2.5)], ) monkeypatch.setattr( retrieval, "retrieve_dense", lambda conn, scope, q, k = None, **kw: [retrieval.Hit("d1:0", 0.82, dense_score = 0.82)], ) found = tool.search_for_autoinject( query = "q", scope_kb_id = "a", mode = "lexical", min_dense_score = 0.70 ) assert found is not None and found[1][0]["chunkId"] == "d1:0" monkeypatch.setattr( retrieval, "retrieve_dense", lambda conn, scope, q, k = None, **kw: [retrieval.Hit("d1:0", 0.40, dense_score = 0.40)], ) assert ( tool.search_for_autoinject(query = "q", scope_kb_id = "a", mode = "lexical", min_dense_score = 0.70) is None ) def test_search_for_autoinject_empty_query_or_scope(rag_home): assert tool.search_for_autoinject(query = " ", scope_kb_id = "a") is None assert tool.search_for_autoinject(query = "hello") is None # no scope def test_build_rag_autoinject_emits_pipeline(monkeypatch): # Auto-inject yields the same tool card + source-map a real call would. from core.inference import tools from storage import rag_db monkeypatch.setattr(rag_db, "RAG_AVAILABLE", True, raising = False) monkeypatch.setattr( tool, "search_for_autoinject", lambda **k: ( 'hi', [{"citationId": 1, "filename": "d.pdf"}], ), ) conv = [{"role": "user", "content": "When was DeepSeek V4 released?"}] out = tools.build_rag_autoinject(conv, {"thread_id": "t1"}) assert out is not None kinds = [e["type"] for e in out["events"]] assert "tool_start" in kinds and "tool_end" in kinds te = next(e for e in out["events"] if e["type"] == "tool_end") assert te["tool_name"] == "search_knowledge_base" assert tools.RAG_SOURCES_SENTINEL in te["result"] assert out["messages"][0]["tool_calls"][0]["function"]["name"] == "search_knowledge_base" assert "__RAG_SOURCES__" not in out["messages"][1]["content"] def test_build_rag_autoinject_skips_without_hit(monkeypatch): from core.inference import tools from storage import rag_db monkeypatch.setattr(rag_db, "RAG_AVAILABLE", True, raising = False) monkeypatch.setattr(tool, "search_for_autoinject", lambda **k: None) assert ( tools.build_rag_autoinject([{"role": "user", "content": "hi"}], {"thread_id": "t1"}) is None ) def test_build_rag_autoinject_enabled_by_default(monkeypatch): from core.inference import tools from storage import rag_db monkeypatch.delenv("RAG_AUTOINJECT", raising = False) monkeypatch.delenv("RAG_AUTOINJECT_MIN_SCORE", raising = False) monkeypatch.setattr(rag_db, "RAG_AVAILABLE", True, raising = False) seen: dict = {} def fake(**k): seen.update(k) return ("x", [{"citationId": 1}]) monkeypatch.setattr(tool, "search_for_autoinject", fake) out = tools.build_rag_autoinject([{"role": "user", "content": "hi"}], {"thread_id": "t1"}) assert out is not None assert seen["min_dense_score"] == 0.70 # high-precision floor by default def test_build_rag_autoinject_caps_top_k(monkeypatch): from core.inference import tools from storage import rag_db monkeypatch.setenv("RAG_AUTOINJECT", "1") monkeypatch.setenv("RAG_AUTOINJECT_TOP_K", "4") monkeypatch.setattr(rag_db, "RAG_AVAILABLE", True, raising = False) seen: dict = {} def fake(**k): seen.update(k) return ("x", [{"citationId": 1}]) monkeypatch.setattr(tool, "search_for_autoinject", fake) conv = [{"role": "user", "content": "q"}] tools.build_rag_autoinject(conv, {"thread_id": "t1"}) assert seen["top_k"] == 4 # lean default tools.build_rag_autoinject(conv, {"thread_id": "t1", "default_top_k": 2}) assert seen["top_k"] == 2 # lower user setting wins def test_build_rag_autoinject_disabled_by_env(monkeypatch): from core.inference import tools monkeypatch.setenv("RAG_AUTOINJECT", "0") assert ( tools.build_rag_autoinject([{"role": "user", "content": "hi"}], {"thread_id": "t1"}) is None ) # No scope -> also a no-op. monkeypatch.delenv("RAG_AUTOINJECT", raising = False) assert tools.build_rag_autoinject([{"role": "user", "content": "hi"}], None) is None def test_retrieve_hybrid_mode_selects_backend(monkeypatch): # ``mode`` runs only the chosen backend; hybrid uses config counts + rrf_k. calls: list = [] monkeypatch.setattr( retrieval, "retrieve_lexical", lambda c, s, q, k = None: calls.append(("lex", k)) or [], ) monkeypatch.setattr( retrieval, "retrieve_dense", lambda c, s, q, k = None, *, model_name = None: calls.append(("dense", k)) or [], ) monkeypatch.setattr( retrieval, "_rrf", lambda rankings, rrf_k, top_k: calls.append(("rrf", rrf_k, top_k)) or [], ) calls.clear() retrieval.retrieve_hybrid(None, "kb_a", "q", k = 5, mode = "lexical") assert [c[0] for c in calls] == ["lex"] # dense + rrf skipped calls.clear() retrieval.retrieve_hybrid(None, "kb_a", "q", k = 5, mode = "dense") assert [c[0] for c in calls] == ["dense"] calls.clear() retrieval.retrieve_hybrid(None, "kb_a", "q", k = 5, mode = "hybrid") # Candidate pools + rrf_k come from config (no per-request override). assert ("lex", config.TOP_K_LEXICAL) in calls assert ("dense", config.TOP_K_DENSE) in calls rrf = next(c for c in calls if c[0] == "rrf") assert rrf[1] == config.RRF_K and rrf[2] == 5 # config rrf_k + final top_k def test_scope_overrides_reach_retrieval(monkeypatch): from core.inference import tools from storage import rag_db monkeypatch.setattr(rag_db, "RAG_AVAILABLE", True, raising = False) seen: dict = {} def fake_search(**kw): seen.update(kw) return ("text", []) monkeypatch.setattr(tool, "search_knowledge_base_with_sources", fake_search) tools._search_knowledge_base( {"query": "q"}, {"kb_id": "a", "mode": "dense", "default_top_k": 11}, ) assert seen["mode"] == "dense" assert seen["top_k"] == 11 # Unknown mode falls back to hybrid. seen.clear() tools._search_knowledge_base({"query": "q"}, {"kb_id": "a", "mode": "bogus"}) assert seen["mode"] == "hybrid" def test_build_rag_autoinject_scope_overrides_env(monkeypatch): from core.inference import tools from storage import rag_db monkeypatch.setattr(rag_db, "RAG_AVAILABLE", True, raising = False) seen: dict = {} def fake_autoinject(**k): seen.update(k) return ('hi', [{"citationId": 1}]) monkeypatch.setattr(tool, "search_for_autoinject", fake_autoinject) conv = [{"role": "user", "content": "q"}] # Scope enables + overrides the floor though env says off. monkeypatch.setenv("RAG_AUTOINJECT", "0") out = tools.build_rag_autoinject( conv, { "thread_id": "t1", "autoinject": True, "autoinject_min_score": 0.8, "mode": "dense", }, ) assert out is not None assert seen["min_dense_score"] == 0.8 assert seen["mode"] == "dense" # Explicit False disables even with the env default on. monkeypatch.setenv("RAG_AUTOINJECT", "1") assert tools.build_rag_autoinject(conv, {"thread_id": "t1", "autoinject": False}) is None