Files
library-desk/tests/test_hybrid_rag.py
T
jpmschweitzerandClaude a687b770ef fix: clear ruff so the pre-push gate passes
105 findings to zero. Most were mechanical — 67 unused imports, and assorted
f-strings without placeholders. Three groups needed a decision.

The 15 F821 "undefined name" were forward references, not runtime errors. Each
annotation is quoted — `-> "WikiService"`, `Optional["IngestionService"]` — with
the real import inside the function body to break an import cycle. A quoted
annotation is never evaluated, so the code ran; the names were simply
unresolvable to any checker. They now have a TYPE_CHECKING block, which costs
nothing at import time and keeps the cycle broken.

The 6 E402 split two ways. `import secrets`, `Security`, `Request` and
`HTTPBearer` in dependencies.py had drifted below several hundred lines of
factory functions for no reason — stdlib and fastapi, no cycle to avoid — and
moved up. The other three are deliberate and now say so: the VectorService and
GraphService aliases import back into dependencies.py, and main.py's routers
expect a configured app, so both must stay put.

Bare `except:` narrowed to `except Exception:` in three places, which stops them
swallowing KeyboardInterrupt and SystemExit.

The 5 unused locals were all genuinely dead. One is worth naming rather than
fixing: qdrant_client.delete()'s return value was bound and never read, so a
failed delete is indistinguishable from a successful one — the assignment is
gone, but nothing checks the status either way and that has not changed here.
`timing = {}` in _retrieve_parallel looked like it might mean the reported
per-leg timings were always zero; traced, and they come from output["timing"],
so the local was only vestigial.

426 passed, 29 skipped, unchanged. The app imports and the service aliases still
resolve, which is the check that mattered after moving imports in
dependencies.py.

The gate still prints "not gated here yet: test (T-56)" — lint is green, tests
remain unwired, and that is left visible rather than silently absent.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 17:04:58 +02:00

729 lines
23 KiB
Python

"""
Comprehensive tests for HybridRAG system.
Tests cover all 6 phases:
- Phase 0: Query Enhancement (keyword/synonym extraction)
- Phase 1: Parallel Retrieval (vector + graph + web)
- Phase 2: RRF Fusion
- Phase 3: Enrichment (related dossiers)
- Phase 4: LLM Re-ranking
- Phase 5: Context Formatting
- Phase 6: Persistence (search storage)
Uses 'llm-tester' user to avoid contaminating production data.
Run with: pytest tests/test_hybrid_rag.py -v -s
"""
import pytest
import pytest_asyncio
from typing import AsyncGenerator
import json
from src.clients.neo4j_client import Neo4jClient
from src.clients.qdrant_client import QdrantClientWrapper
from src.clients.wikijs_client import WikiJSClient
from src.clients.searxng_client import SearXNGClient
from src.clients.ollama_client import OllamaClient
from src.clients.content_extractor import ContentExtractor
from src.services.hybrid_rag_service import HybridRAGService
from src.services.vector_service import VectorService
from src.services.graph_service import GraphService
from src.models.hybrid_rag import HybridRAGConfig
from src.config import get_settings
# Test user to isolate test data
TEST_USER = "llm-tester"
@pytest.fixture
def settings():
"""Get application settings."""
return get_settings()
@pytest_asyncio.fixture
async def neo4j_client(settings, neo4j_test_uri) -> AsyncGenerator[Neo4jClient, None]:
"""Get connected Neo4j client."""
client = Neo4jClient(
uri=neo4j_test_uri,
user=settings.neo4j_user,
password=settings.neo4j_password
)
await client.connect()
yield client
await client.close()
@pytest.fixture
def qdrant_client(qdrant_test_url) -> QdrantClientWrapper:
"""Get Qdrant client."""
return QdrantClientWrapper(url=qdrant_test_url)
@pytest_asyncio.fixture
async def wiki_client(wikijs_test_config) -> AsyncGenerator[WikiJSClient, None]:
"""Get Wiki.js client."""
client = WikiJSClient(
base_url=wikijs_test_config["base_url"],
api_token=wikijs_test_config["api_token"]
)
yield client
@pytest.fixture
def searxng_client(searxng_test_url) -> SearXNGClient:
"""Get SearXNG client."""
return SearXNGClient(base_url=searxng_test_url)
@pytest.fixture
def ollama_client(ollama_test_config) -> OllamaClient:
"""Get Ollama client."""
return OllamaClient(
base_url=ollama_test_config["base_url"],
model=ollama_test_config["model"]
)
@pytest.fixture
def content_extractor(settings) -> ContentExtractor:
"""Get ContentExtractor client."""
return ContentExtractor(
timeout=settings.content_extraction_timeout,
max_length=settings.content_max_length
)
@pytest_asyncio.fixture
async def vector_service(qdrant_client, wiki_client, ollama_client):
"""Get VectorService instance."""
return VectorService(qdrant_client, wiki_client, ollama_client)
@pytest_asyncio.fixture
async def graph_service(neo4j_client, wiki_client):
"""Get GraphService instance."""
return GraphService(neo4j_client, wiki_client)
@pytest_asyncio.fixture
async def hybrid_rag_service(
vector_service,
graph_service,
searxng_client,
ollama_client,
content_extractor,
settings
):
"""Get HybridRAGService instance."""
return HybridRAGService(
vector_service=vector_service,
graph_service=graph_service,
searxng_client=searxng_client,
ollama_client=ollama_client,
content_extractor=content_extractor,
settings=settings
)
@pytest_asyncio.fixture
async def test_wiki_page(wiki_client):
"""
Create test wiki page for llm-tester user.
Creates a page about Docker and Kubernetes for testing.
"""
from src.core.multi_tenancy import get_wikijs_namespace
namespace = get_wikijs_namespace(TEST_USER)
path = f"{namespace}/testing/docker-kubernetes"
# Create test page
page_data = {
"title": "Docker and Kubernetes Testing",
"path": path,
"content": """# Docker and Kubernetes
Docker is a containerization platform that packages applications into containers.
Kubernetes (k8s) is an orchestration platform for managing Docker containers at scale.
## Key Technologies
- Docker: Container runtime
- Kubernetes: Orchestration platform
- Helm: Package manager for Kubernetes
- kubectl: Command-line tool for k8s
## Use Cases
Our infrastructure uses Docker containers orchestrated by Kubernetes clusters.
We deploy microservices using Helm charts and manage them with kubectl.
""",
"description": "Test page for HybridRAG testing",
"tags": ["testing", "infrastructure", "docker"]
}
try:
# Delete if exists
existing = await wiki_client.search_pages(query="Docker and Kubernetes Testing")
for page in existing:
if page.get("path") == path:
await wiki_client.delete_page(page["id"])
# Create new
page = await wiki_client.create_page(**page_data)
yield page
# Cleanup
try:
await wiki_client.delete_page(page["id"])
except Exception:
pass
except Exception as e:
pytest.skip(f"Could not create test page: {e}")
@pytest_asyncio.fixture
async def test_graph_data(graph_service, test_wiki_page):
"""
Populate graph with test data for llm-tester.
Extracts entities from test page.
"""
try:
summary = await graph_service.update_from_page(
page_id=test_wiki_page["id"],
user=TEST_USER
)
yield summary
except Exception as e:
pytest.skip(f"Could not populate graph: {e}")
@pytest_asyncio.fixture
async def test_vector_data(vector_service, test_wiki_page):
"""
Populate vector DB with test data for llm-tester.
Creates embeddings from test page.
"""
try:
summary = await vector_service.update_from_page(
page_id=test_wiki_page["id"],
user=TEST_USER
)
yield summary
except Exception as e:
pytest.skip(f"Could not populate vectors: {e}")
# ============================================================================
# Unit Tests - Individual Components
# ============================================================================
class TestRRFFusion:
"""Test two-stage Reciprocal Rank Fusion algorithm."""
def test_wiki_merge_single_source(self, hybrid_rag_service):
"""Test wiki merge with single source (vector only)."""
vector_results = [
{"page_id": 1, "title": "Doc 1", "content": "test"},
{"page_id": 2, "title": "Doc 2", "content": "test"}
]
merged = hybrid_rag_service._merge_wiki_sources(vector_results, [], k=60)
assert len(merged) == 2
assert merged[0]["wiki_rrf_score"] > merged[1]["wiki_rrf_score"] # Rank 1 > Rank 2
assert merged[0]["found_by"] == ["vector"]
def test_wiki_merge_multiple_sources_same_doc(self, hybrid_rag_service):
"""Test wiki merge with same document from vector and graph."""
vector_results = [{"page_id": 1, "title": "Doc 1", "content": "test"}]
graph_results = [{"page_id": 1, "title": "Doc 1", "content": ""}]
merged = hybrid_rag_service._merge_wiki_sources(vector_results, graph_results, k=60)
assert len(merged) == 1 # Deduplicated
assert len(merged[0]["found_by"]) == 2 # Both sources
assert "vector" in merged[0]["found_by"]
assert "graph" in merged[0]["found_by"]
# Wiki RRF score should be sum: 1/(60+1) + 1/(60+1)
expected_score = 1/61 + 1/61
assert abs(merged[0]["wiki_rrf_score"] - expected_score) < 0.001
def test_final_rrf_wiki_and_web(self, hybrid_rag_service):
"""Test final RRF between wiki and web results."""
# Pre-merged wiki results
wiki_results = [
{"page_id": 1, "title": "Wiki 1", "content": "test", "found_by": ["vector"]}
]
web_results = [
{"url": "https://example.com/1", "title": "Web 1", "content": "test"},
{"url": "https://example.com/2", "title": "Web 2", "content": "test"}
]
fused = hybrid_rag_service._reciprocal_rank_fusion(wiki_results, web_results, k=60)
assert len(fused) == 3
# Wiki rank 1 and web rank 1 should have same RRF score
wiki_score = next(r["rrf_score"] for r in fused if r["source_type"] == "wiki")
web_score = next(r["rrf_score"] for r in fused if r["source_type"] == "web")
assert abs(wiki_score - web_score) < 0.001 # Equal footing
class TestContextFormatting:
"""Test context formatting for LLM."""
def test_format_basic(self, hybrid_rag_service):
"""Test basic context formatting."""
from src.models.hybrid_rag import HybridRAGResult
results = [
HybridRAGResult(
source_type="vector",
title="Test Document",
content="This is test content for formatting",
page_id=1,
rrf_score=0.5,
final_rank=1,
sources=["vector"]
)
]
context = hybrid_rag_service._format_context_for_llm(results)
assert "Test Document" in context
assert "[VECTOR]" in context
assert "test content" in context
def test_format_with_related_dossiers(self, hybrid_rag_service):
"""Test context formatting with related dossiers."""
from src.models.hybrid_rag import HybridRAGResult, RelatedDossier
results = [
HybridRAGResult(
source_type="vector+graph",
title="Test Document",
content="Content",
page_id=1,
rrf_score=0.5,
final_rank=1,
sources=["vector", "graph"],
related_dossiers=[
RelatedDossier(
page_id=2,
title="Related Doc",
path="/test/related",
tag="infrastructure",
shared_entities=5
)
]
)
]
context = hybrid_rag_service._format_context_for_llm(results)
assert "Related research: infrastructure" in context
# ============================================================================
# Integration Tests - Phase Testing
# ============================================================================
class TestPhase0_QueryEnhancement:
"""Test Phase 0: Query Enhancement (keyword/synonym extraction)."""
@pytest.mark.asyncio
async def test_extract_keywords_basic(self, hybrid_rag_service):
"""Test basic keyword extraction."""
query = "Docker container orchestration with Kubernetes"
keywords_data = await hybrid_rag_service._extract_keywords_and_synonyms(query)
assert "core_keywords" in keywords_data
assert "entities" in keywords_data
assert "synonyms" in keywords_data
assert "expansions" in keywords_data
# Should extract Docker and Kubernetes
all_terms = (
keywords_data["core_keywords"] +
keywords_data["entities"]
)
assert any("docker" in term.lower() for term in all_terms)
assert any("kubernetes" in term.lower() or "k8s" in term.lower() for term in all_terms)
@pytest.mark.asyncio
async def test_extract_keywords_with_abbreviations(self, hybrid_rag_service):
"""Test keyword extraction handles abbreviations."""
query = "k8s cluster management"
keywords_data = await hybrid_rag_service._extract_keywords_and_synonyms(query)
# Should expand k8s to kubernetes
all_data = json.dumps(keywords_data).lower()
assert "k8s" in all_data or "kubernetes" in all_data
class TestPhase1_ParallelRetrieval:
"""Test Phase 1: Parallel Retrieval."""
@pytest.mark.asyncio
async def test_parallel_retrieval_all_sources(
self,
hybrid_rag_service,
test_wiki_page,
test_graph_data,
test_vector_data
):
"""Test parallel retrieval from all sources."""
config = HybridRAGConfig(
enable_vector=True,
enable_graph=True,
enable_web=True,
vector_limit=5,
graph_limit=5,
web_limit=3
)
keywords_data = {
"core_keywords": ["docker", "kubernetes"],
"entities": ["Docker", "Kubernetes"],
"synonyms": {"docker": ["container"], "kubernetes": ["k8s"]},
"expansions": {"k8s": ["kubernetes"]}
}
results = await hybrid_rag_service._retrieve_parallel(
query="docker kubernetes",
user=TEST_USER,
config=config,
keywords_data=keywords_data
)
assert "vector" in results
assert "graph" in results
assert "web" in results
assert "timing" in results
# Should have timing for each source
assert results["timing"]["vector_ms"] >= 0
assert results["timing"]["graph_ms"] >= 0
assert results["timing"]["web_ms"] >= 0
@pytest.mark.asyncio
async def test_parallel_retrieval_graceful_degradation(self, hybrid_rag_service):
"""Test graceful degradation when sources fail."""
config = HybridRAGConfig(
enable_vector=True,
enable_graph=True,
enable_web=True
)
keywords_data = {"core_keywords": ["test"], "entities": [], "synonyms": {}, "expansions": {}}
# Even if some sources fail, should return results from working sources
results = await hybrid_rag_service._retrieve_parallel(
query="test query",
user=TEST_USER,
config=config,
keywords_data=keywords_data
)
# Should have all keys even if empty
assert "vector" in results
assert "graph" in results
assert "web" in results
class TestPhase3_Enrichment:
"""Test Phase 3: Graph Enrichment."""
@pytest.mark.asyncio
async def test_enrich_with_related_dossiers(
self,
hybrid_rag_service,
graph_service,
test_wiki_page,
test_graph_data
):
"""Test enriching results with related dossiers."""
# Create mock fused results
fused_results = [
{
"result": {
"page_id": test_wiki_page["id"],
"title": test_wiki_page["title"],
"content": "test"
},
"rrf_score": 0.5,
"sources": ["vector"]
}
]
enriched = await hybrid_rag_service._enrich_with_related_dossiers(
fused_results,
user=TEST_USER
)
assert len(enriched) == 1
assert "related_dossiers" in enriched[0]
# May or may not have related docs depending on graph state
assert isinstance(enriched[0]["related_dossiers"], list)
class TestPhase6_Persistence:
"""Test Phase 6: Search Persistence."""
@pytest.mark.asyncio
async def test_persist_search_creates_node(
self,
hybrid_rag_service,
neo4j_client,
test_wiki_page
):
"""Test that search persistence creates SearchQuery node."""
keywords_data = {
"core_keywords": ["docker", "kubernetes"],
"entities": [],
"synonyms": {},
"expansions": {}
}
raw_results = {
"vector": [{"page_id": test_wiki_page["id"], "title": "Test", "content": "test"}],
"graph": [],
"web": []
}
final_results = [
{
"result": {"page_id": test_wiki_page["id"], "title": "Test"},
"rrf_score": 0.5,
"final_rank": 1,
"sources": ["vector"]
}
]
timing = {"total_ms": 1000}
import uuid as _uuid
search_id = await hybrid_rag_service._persist_search_for_librarian(
search_id=str(_uuid.uuid4()),
query="test query",
user=TEST_USER,
keywords_data=keywords_data,
raw_results=raw_results,
final_results=final_results,
timing=timing
)
assert search_id is not None
# Verify SearchQuery node was created
from src.core.multi_tenancy import get_neo4j_user_base_label
user_label = get_neo4j_user_base_label(TEST_USER)
query = f"""
MATCH (sq:{user_label}_SearchQuery:SearchQuery {{id: $search_id}})
RETURN sq.query as query, sq.processed as processed
"""
result = await neo4j_client.execute_query(query, {"search_id": search_id})
assert len(result) == 1
assert result[0]["query"] == "test query"
assert not result[0]["processed"]
# Cleanup
cleanup_query = f"""
MATCH (sq:{user_label}_SearchQuery:SearchQuery {{id: $search_id}})
DETACH DELETE sq
"""
await neo4j_client.execute_query(cleanup_query, {"search_id": search_id})
# ============================================================================
# End-to-End Tests
# ============================================================================
class TestHybridRAG_EndToEnd:
"""End-to-end tests for complete HybridRAG flow."""
@pytest.mark.asyncio
async def test_full_search_pipeline(
self,
hybrid_rag_service,
test_wiki_page,
test_graph_data,
test_vector_data
):
"""
Test complete HybridRAG search pipeline with all 6 phases.
This is the main end-to-end test that validates:
- Phase 0: Query enhancement
- Phase 1: Parallel retrieval
- Phase 2: RRF fusion
- Phase 3: Enrichment
- Phase 4: Re-ranking
- Phase 5: Context formatting
- Phase 6: Persistence
"""
query = "How does Docker work with Kubernetes?"
config = HybridRAGConfig(
vector_limit=5,
graph_limit=5,
web_limit=3,
enable_reranking=True,
enable_enrichment=True,
final_result_count=10
)
# Execute full search
response = await hybrid_rag_service.search(
query=query,
user=TEST_USER,
config=config
)
# Validate response structure
assert response.query == query
assert response.keywords is not None
assert response.results is not None
assert response.context is not None
assert response.source_counts is not None
assert response.total_results >= 0
assert response.timing is not None
assert response.config_used == config
assert response.search_id is not None
# Validate timing breakdown
assert response.timing.query_enhancement_ms >= 0
assert response.timing.vector_ms >= 0
assert response.timing.graph_ms >= 0
assert response.timing.web_ms >= 0
assert response.timing.fusion_ms >= 0
assert response.timing.enrichment_ms >= 0
assert response.timing.reranking_ms >= 0
assert response.timing.persistence_ms >= 0
assert response.timing.total_ms >= 0
# Validate keywords extraction
assert len(response.keywords.core_keywords) > 0
# Validate context is formatted
assert len(response.context) > 0
# Log results for inspection
print("\n=== HybridRAG E2E Test Results ===")
print(f"Query: {response.query}")
print(f"Total Results: {response.total_results}")
print(f"Source Counts: {response.source_counts}")
print(f"Keywords: {response.keywords.core_keywords}")
print(f"Total Time: {response.timing.total_ms:.0f}ms")
print(f"Search ID: {response.search_id}")
if response.results:
print("\nTop Result:")
top = response.results[0]
print(f" Title: {top.title}")
print(f" Source: {top.source_type}")
print(f" RRF Score: {top.rrf_score:.4f}")
print(f" Rank: {top.final_rank}")
@pytest.mark.asyncio
async def test_search_with_disabled_sources(
self,
hybrid_rag_service,
test_wiki_page,
test_vector_data
):
"""Test HybridRAG with some sources disabled."""
config = HybridRAGConfig(
enable_vector=True,
enable_graph=False, # Disabled
enable_web=False, # Disabled
enable_reranking=False,
final_result_count=5
)
response = await hybrid_rag_service.search(
query="docker containers",
user=TEST_USER,
config=config
)
# Should only have vector results
assert response.total_results >= 0
if response.total_results > 0:
assert all(
"vector" in result.sources
for result in response.results
)
@pytest.mark.asyncio
async def test_search_performance_target(
self,
hybrid_rag_service,
test_wiki_page,
test_graph_data,
test_vector_data
):
"""Test that search completes within performance target (<3.5s)."""
import time
config = HybridRAGConfig()
start = time.time()
await hybrid_rag_service.search(
query="kubernetes orchestration",
user=TEST_USER,
config=config
)
duration_ms = (time.time() - start) * 1000
print(f"\nPerformance: {duration_ms:.0f}ms (target: <3500ms)")
# Soft assertion - warn if exceeds target
if duration_ms > 3500:
print(f"WARNING: Search exceeded 3.5s target ({duration_ms:.0f}ms)")
# ============================================================================
# Cleanup Tests
# ============================================================================
@pytest.mark.asyncio
async def test_cleanup_test_data(neo4j_client, qdrant_client):
"""
Cleanup test data for llm-tester user.
Run this to clean up test data:
pytest tests/test_hybrid_rag.py::test_cleanup_test_data -v -s
"""
from src.core.multi_tenancy import (
get_neo4j_user_base_label,
get_neo4j_user_label,
get_qdrant_collection_name
)
# Clean Neo4j
user_base_label = get_neo4j_user_base_label(TEST_USER)
user_doc_label = get_neo4j_user_label(TEST_USER)
# Delete all test user nodes
delete_query = f"""
MATCH (n)
WHERE n:{user_base_label} OR n:{user_doc_label}
DETACH DELETE n
"""
await neo4j_client.execute_query(delete_query, {})
# Clean Qdrant
collection_name = get_qdrant_collection_name(TEST_USER)
try:
await qdrant_client.delete_collection(collection_name)
except Exception:
pass
print(f"\n✓ Cleaned up test data for user: {TEST_USER}")