# Story 5.2: Query Router

Status: drafted

## Story

As a **system**,
I want **to route queries to the appropriate retrieval method**,
So that **exact lookups use structured search and conceptual queries use semantic search**.

## Acceptance Criteria

1. **AC5.2.1:** Query classification determines retrieval strategy
   - Classify query as EXACT_LOOKUP, SEMANTIC, or HYBRID
   - EXACT_LOOKUP: Contains specific codes, symbols, or identifiers
   - SEMANTIC: Conceptual or descriptive questions
   - HYBRID: Contains both specific identifiers and conceptual elements
   - Classification uses rule-based pattern matching initially
   - Returns QueryType enum value

2. **AC5.2.2:** EXACT_LOOKUP queries route to structured search
   - Queries like "What does code 07 mean?" → PostgreSQL lookup
   - Search extracted_records table for exact matches
   - Search symbol_dictionaries table for symbol meanings
   - Return structured results with source metadata
   - Prefer exact matches over semantic similarity

3. **AC5.2.3:** SEMANTIC queries route to vector search
   - Queries like "Tell me about construction results" → Milvus search
   - Use existing SemanticRetriever from Story 5.1
   - Return top-k similar records
   - Include similarity scores for confidence calculation

4. **AC5.2.4:** HYBRID queries merge both retrieval methods
   - Queries like "What screens show item 2024-4_0019 and why?" → Both methods
   - First execute exact lookup for identifiers
   - Then execute semantic search for conceptual parts
   - Merge and deduplicate results
   - Prioritize exact matches, supplement with semantic context

5. **AC5.2.5:** Router integrates with existing query endpoint
   - QueryRouter callable from POST /query endpoint
   - Maintains existing API contract (QueryRequest/QueryResponse)
   - Backwards compatible with Story 5.1 implementation
   - Processing time tracked per retrieval method
   - Structured logging includes query_type classification

## Tasks / Subtasks

- [ ] **Task 1: Create QueryRouter service** (AC: 5.2.1)
  - [ ] Create `src/retrieval/router.py` module
  - [ ] Define QueryType enum (EXACT_LOOKUP, SEMANTIC, HYBRID)
  - [ ] Implement classify_query() method with rule-based patterns
  - [ ] Detect exact patterns: codes (e.g., "07", "2024-4_0019"), quoted strings
  - [ ] Detect semantic patterns: descriptive words, "tell me about", "explain"
  - [ ] Detect hybrid patterns: combination of both
  - [ ] Add structured logging for classification decisions
  - [ ] Return QueryType with confidence score

- [ ] **Task 2: Implement ExactRetriever service** (AC: 5.2.2)
  - [ ] Create `src/retrieval/exact_retriever.py` module
  - [ ] Accept query string and optional document_ids filter
  - [ ] Extract search terms (codes, symbols, identifiers) from query
  - [ ] Query PostgreSQL extracted_records table for exact matches
  - [ ] Query PostgreSQL symbol_dictionaries table for symbol meanings
  - [ ] Use ILIKE for case-insensitive matching
  - [ ] Apply document_ids filter if provided
  - [ ] Format results as SourceReference objects
  - [ ] Return empty list if no exact matches found

- [ ] **Task 3: Implement HybridRetriever service** (AC: 5.2.4)
  - [ ] Create `src/retrieval/hybrid_retriever.py` module
  - [ ] Accept query string and optional document_ids filter
  - [ ] Execute ExactRetriever for identifier extraction
  - [ ] Execute SemanticRetriever for conceptual search
  - [ ] Merge results from both retrievers
  - [ ] Deduplicate by record_id (prefer exact matches)
  - [ ] Maintain source order: exact matches first, then semantic
  - [ ] Combine similarity scores appropriately
  - [ ] Return merged list of SourceReference objects

- [ ] **Task 4: Update QueryRouter to use all retrievers** (AC: 5.2.1-5.2.4)
  - [ ] Wire ExactRetriever, SemanticRetriever, HybridRetriever in QueryRouter
  - [ ] Implement route() method that calls appropriate retriever
  - [ ] Pass document_ids filter through to retrievers
  - [ ] Track retrieval method used in logs
  - [ ] Return sources and query_type metadata
  - [ ] Handle errors from individual retrievers gracefully

- [ ] **Task 5: Integrate QueryRouter into query endpoint** (AC: 5.2.5)
  - [ ] Update `src/api/routes/query.py` to use QueryRouter
  - [ ] Replace direct SemanticRetriever call with QueryRouter.route()
  - [ ] Maintain backwards compatibility with existing API
  - [ ] Add query_type to response metadata (optional field)
  - [ ] Log query classification and routing decision
  - [ ] Ensure processing time includes routing overhead

- [ ] **Task 6: Unit tests for QueryRouter** (AC: 5.2.1)
  - [ ] Test classify_query() with EXACT_LOOKUP patterns
  - [ ] Test classify_query() with SEMANTIC patterns
  - [ ] Test classify_query() with HYBRID patterns
  - [ ] Test edge cases (empty query, ambiguous patterns)
  - [ ] Mock retrievers to test routing logic
  - [ ] Verify QueryType enum values returned correctly

- [ ] **Task 7: Unit tests for ExactRetriever** (AC: 5.2.2)
  - [ ] Test exact code lookup (e.g., "07", "2024-4_0019")
  - [ ] Test symbol dictionary lookup (e.g., "○", "×")
  - [ ] Test document_ids filtering
  - [ ] Test case-insensitive matching
  - [ ] Test no results found scenario
  - [ ] Mock PostgreSQL queries

- [ ] **Task 8: Unit tests for HybridRetriever** (AC: 5.2.4)
  - [ ] Test merging exact and semantic results
  - [ ] Test deduplication of overlapping results
  - [ ] Test ordering (exact first, then semantic)
  - [ ] Test when only exact results available
  - [ ] Test when only semantic results available
  - [ ] Mock ExactRetriever and SemanticRetriever

- [ ] **Task 9: Integration tests for query routing** (AC: All)
  - [ ] Test POST /query with exact lookup query
  - [ ] Test POST /query with semantic query
  - [ ] Test POST /query with hybrid query
  - [ ] Verify correct retrieval method used
  - [ ] Verify results match expected retrieval strategy
  - [ ] Test with real PostgreSQL and Milvus
  - [ ] Verify backwards compatibility with Story 5.1 tests

- [ ] **Task 10: Update documentation and examples** (AC: All)
  - [ ] Update `examples/test_query_endpoint.py` with routing examples
  - [ ] Show example of exact lookup query
  - [ ] Show example of semantic query
  - [ ] Show example of hybrid query
  - [ ] Update README with query routing explanation
  - [ ] Add query classification documentation to API docs

## Dev Notes

### Architecture Patterns and Constraints

**From architecture.md:**
- **Service Layer:** Create `src/retrieval/router.py`, `src/retrieval/exact_retriever.py`, `src/retrieval/hybrid_retriever.py`
- **Existing Services:** Reuse `src/retrieval/semantic_retriever.py` from Story 5.1
- **Database Layer:** Use existing PostgreSQL session management from `src/db/session.py`
- **Error Handling:** Use `RetrievalError`, `QueryError` from `src.core.exceptions`
- **Logging:** Structured logging with structlog (log query_type, retrieval_method, processing_time)
- **Models:** Pydantic models for QueryType enum and routing metadata

**From PRD:**
- **FR23:** System routes queries to appropriate retrieval method (exact vs semantic)
- **FR24:** System generates answers using LLM with retrieved context
- **NFR2:** Response time ≤ 5 seconds for queries
- **NFR5:** Answer accuracy ≥90% (routing improves accuracy by using optimal retrieval)

### Learnings from Previous Story

**From Story 5.1: Query Endpoint (Status: done)**

**Services Created:**
- `SemanticRetriever` at `src/retrieval/semantic_retriever.py` - Reuse for SEMANTIC queries
- `AnswerGenerator` at `src/retrieval/answer_generator.py` - Reuse for answer generation
- `POST /query` endpoint at `src/api/routes/query.py` - Integrate QueryRouter here

**Key Patterns:**
- **Async operations:** All retrievers must be async for consistency
- **Retry logic:** Exponential backoff (1s, 2s, 4s) for external API calls
- **Structured logging:** Log query_type, retrieval_method, source_count, processing_time
- **Error handling:** Catch and log exceptions, return appropriate HTTP status codes
- **SourceReference model:** Consistent format across all retrievers

**Technical Details:**
- **Milvus search:** COSINE similarity, top_k=5, ef=64
- **Embedding model:** Azure OpenAI text-embedding-3-large (3072 dims)
- **Document filtering:** Use document_ids filter expression
- **Timeout protection:** 5-second timeout for entire query processing

**Integration Points:**
- All retrievers accept `query: str` and `document_ids: list[UUID] | None`
- All retrievers return `tuple[list[SourceReference], float]` (sources, top_similarity)
- AnswerGenerator accepts sources from any retriever type
- Query endpoint tracks total processing time including routing

**Schema Lesson from 5.1:**
- Milvus schema has `row_number` field (not `col_range`)
- Format location as "Row N" for display
- Always verify schema fields before querying

[Source: docs/sprint-artifacts/5-1-query-endpoint.md]

### Query Classification Patterns

**Rule-Based Classification Algorithm:**

```python
import re
from enum import Enum

class QueryType(Enum):
    EXACT_LOOKUP = "exact_lookup"
    SEMANTIC = "semantic"
    HYBRID = "hybrid"

def classify_query(query: str) -> QueryType:
    """Classify query type using rule-based patterns.

    Returns:
        QueryType enum value
    """
    query_lower = query.lower()

    # Exact lookup patterns
    exact_patterns = [
        r'\b\d{2}\b',  # Two-digit codes (07, 12)
        r'\b\d{4}-\d+-\d+\b',  # Item codes (2024-4_0019)
        r'code\s+\w+',  # "code 07"
        r'symbol\s+[○×△]',  # "symbol ○"
        r'what\s+(does|is)\s+["\']?\w+["\']?\s+mean',  # "what does 07 mean"
    ]

    # Semantic patterns
    semantic_patterns = [
        r'tell me about',
        r'explain',
        r'describe',
        r'what are',
        r'how do',
        r'construction results?',
        r'hardware components?',
    ]

    has_exact = any(re.search(p, query_lower) for p in exact_patterns)
    has_semantic = any(re.search(p, query_lower) for p in semantic_patterns)

    if has_exact and has_semantic:
        return QueryType.HYBRID
    elif has_exact:
        return QueryType.EXACT_LOOKUP
    else:
        return QueryType.SEMANTIC  # Default to semantic
```

**Examples:**

| Query | Classification | Reasoning |
|-------|----------------|-----------|
| "What does code 07 mean?" | EXACT_LOOKUP | Contains "code 07" pattern |
| "Tell me about construction results" | SEMANTIC | Contains "tell me about" pattern |
| "What screens show item 2024-4_0019 and why?" | HYBRID | Contains item code + "why" (semantic) |
| "What is the status of project?" | SEMANTIC | No specific identifiers |
| "Symbol ○ meaning" | EXACT_LOOKUP | Contains symbol pattern |

### PostgreSQL Exact Lookup Pattern

**Exact Retriever Implementation:**

```python
from sqlalchemy import select, or_
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models import ExtractedRecord, SymbolDictionary

async def retrieve_exact(
    query: str,
    document_ids: list[UUID] | None,
    session: AsyncSession
) -> list[SourceReference]:
    """Retrieve exact matches from PostgreSQL.

    Args:
        query: Search query with identifiers
        document_ids: Optional document filter
        session: Database session

    Returns:
        List of source references with exact matches
    """
    # Extract search terms (codes, symbols)
    search_terms = extract_search_terms(query)

    sources = []

    # Search extracted_records
    stmt = select(ExtractedRecord).where(
        or_(
            ExtractedRecord.content.contains(term)
            for term in search_terms
        )
    )

    if document_ids:
        stmt = stmt.where(ExtractedRecord.document_id.in_(document_ids))

    result = await session.execute(stmt)
    records = result.scalars().all()

    for record in records:
        sources.append(
            SourceReference(
                file=record.document.filename,
                sheet=record.sheet_name,
                location=f"Row {record.row_number}",
                context=str(record.content)
            )
        )

    # Also search symbol_dictionaries
    # ... similar pattern ...

    return sources

def extract_search_terms(query: str) -> list[str]:
    """Extract identifiable terms from query."""
    patterns = [
        r'\b\d{2}\b',  # Two-digit codes
        r'\b\d{4}-\d+-\d+\b',  # Item codes
        r'[○×△]',  # Symbols
    ]
    terms = []
    for pattern in patterns:
        terms.extend(re.findall(pattern, query))
    return terms
```

### Hybrid Retrieval Merging Strategy

**Merge Algorithm:**

```python
async def retrieve_hybrid(
    query: str,
    document_ids: list[UUID] | None
) -> tuple[list[SourceReference], float]:
    """Retrieve using both exact and semantic methods, merge results.

    Strategy:
    1. Execute both retrievers in parallel
    2. Deduplicate by source location (file + sheet + location)
    3. Order: exact matches first, then semantic matches
    4. Use highest similarity score for confidence
    """
    # Execute both in parallel
    exact_task = exact_retriever.retrieve(query, document_ids)
    semantic_task = semantic_retriever.retrieve(query, document_ids)

    exact_sources, _ = await exact_task
    semantic_sources, top_similarity = await semantic_task

    # Deduplicate by location
    seen_locations = set()
    merged = []

    # Add exact matches first
    for source in exact_sources:
        location_key = (source.file, source.sheet, source.location)
        if location_key not in seen_locations:
            merged.append(source)
            seen_locations.add(location_key)

    # Add semantic matches (skip duplicates)
    for source in semantic_sources:
        location_key = (source.file, source.sheet, source.location)
        if location_key not in seen_locations:
            merged.append(source)
            seen_locations.add(location_key)

    return merged, top_similarity
```

### QueryRouter Integration

**Router Service Structure:**

```python
from src.retrieval.exact_retriever import ExactRetriever
from src.retrieval.semantic_retriever import SemanticRetriever
from src.retrieval.hybrid_retriever import HybridRetriever

class QueryRouter:
    """Routes queries to appropriate retrieval method."""

    def __init__(self):
        self.exact_retriever = ExactRetriever()
        self.semantic_retriever = SemanticRetriever()
        self.hybrid_retriever = HybridRetriever(
            exact_retriever=self.exact_retriever,
            semantic_retriever=self.semantic_retriever
        )
        self.logger = structlog.get_logger(__name__)

    async def route(
        self,
        query: str,
        document_ids: list[UUID] | None = None
    ) -> tuple[list[SourceReference], float, QueryType]:
        """Route query to appropriate retriever.

        Returns:
            Tuple of (sources, top_similarity, query_type)
        """
        # Classify query
        query_type = self.classify_query(query)

        self.logger.info(
            "query_classified",
            query=query,
            query_type=query_type.value
        )

        # Route to appropriate retriever
        if query_type == QueryType.EXACT_LOOKUP:
            sources, similarity = await self.exact_retriever.retrieve(
                query, document_ids
            )
        elif query_type == QueryType.SEMANTIC:
            sources, similarity = await self.semantic_retriever.retrieve(
                query, document_ids
            )
        else:  # HYBRID
            sources, similarity = await self.hybrid_retriever.retrieve(
                query, document_ids
            )

        self.logger.info(
            "query_routed",
            query_type=query_type.value,
            source_count=len(sources),
            top_similarity=similarity
        )

        return sources, similarity, query_type

    def classify_query(self, query: str) -> QueryType:
        """Classify query using rule-based patterns."""
        # ... implementation from above ...
```

**Update Query Endpoint:**

```python
# In src/api/routes/query.py

from src.retrieval.router import QueryRouter

@router.post("/query", response_model=QueryResponse)
async def query_documents(
    request: QueryRequest,
    api_key: ApiKey = Depends(get_api_key),
    session: AsyncSession = Depends(get_session)
):
    """Query documents with natural language."""
    start_time = time.time()

    # Validate document_ids
    if request.document_ids:
        await validate_document_ids(request.document_ids, session)

    # Route query and retrieve sources
    router = QueryRouter()
    sources, top_similarity, query_type = await router.route(
        query=request.query,
        document_ids=request.document_ids
    )

    # Generate answer (existing logic)
    answer_gen = AnswerGenerator()
    answer = await answer_gen.generate(
        query=request.query,
        sources=sources
    )

    # Calculate confidence
    confidence = calculate_confidence(top_similarity)

    processing_time = int((time.time() - start_time) * 1000)

    return QueryResponse(
        query=request.query,
        answer=answer,
        confidence=confidence,
        sources=sources if request.include_context else [],
        processing_time_ms=processing_time,
        # Optional: query_type=query_type.value  (future enhancement)
    )
```

### Testing Strategy

**Unit Tests:**
- **QueryRouter.classify_query()**: Test pattern matching for all three types
- **ExactRetriever.retrieve()**: Mock PostgreSQL, test exact matching
- **HybridRetriever.retrieve()**: Mock both retrievers, test merge logic
- **QueryRouter.route()**: Mock retrievers, test routing decisions

**Integration Tests:**
- Test POST /query with exact lookup (e.g., "What does code 07 mean?")
- Test POST /query with semantic query (e.g., "Tell me about construction")
- Test POST /query with hybrid query (e.g., "Why is item 2024-4_0019 applicable?")
- Verify correct retriever invoked for each query type
- Verify existing Story 5.1 tests still pass (backwards compatibility)

**Performance Tests:**
- Verify routing adds minimal overhead (< 50ms)
- Verify hybrid queries complete within 5-second timeout
- Verify parallel execution of retrievers in hybrid mode

### Prerequisites

- **Story 5.1 COMPLETE:** Query endpoint with SemanticRetriever
- **Story 4.4 COMPLETE:** VectorStore with Milvus indexing
- **Story 4.1 COMPLETE:** PostgreSQL structured_store with extracted_records
- **Database:** PostgreSQL with indexed content
- **Milvus:** Collection with indexed vectors
- **Azure OpenAI:** For embeddings (SemanticRetriever) and answer generation

### Success Criteria

- ✅ QueryRouter classifies queries as EXACT_LOOKUP, SEMANTIC, or HYBRID
- ✅ ExactRetriever queries PostgreSQL for exact matches
- ✅ SemanticRetriever continues to work for semantic queries (from 5.1)
- ✅ HybridRetriever merges results from both methods
- ✅ Query endpoint uses QueryRouter instead of direct SemanticRetriever
- ✅ Backwards compatible with Story 5.1 API contract
- ✅ Unit tests pass (15+ tests)
- ✅ Integration tests pass with real services
- ✅ Processing time ≤ 5 seconds maintained
- ✅ Examples demonstrate all three query types
- ✅ Documentation updated

### Out of Scope

- **LLM-based query classification:** Use rule-based patterns for MVP (future: use LLM for complex queries)
- **Query rewriting:** Don't modify user's query before retrieval
- **Result ranking:** Use default ordering (exact first, then similarity)
- **Query logging:** Persisting query history (Story 5.6)
- **Advanced hybrid strategies:** Simple merge for MVP (future: weighted scoring)
- **Query caching:** No caching of routing decisions or results

### Next Steps

After Story 5.2:
- **Story 5.3:** Answer generation with enhanced source citations
- **Story 5.4:** Advanced confidence scoring (use retrieval method in scoring)
- **Story 5.5:** Batch query support
- **Story 5.6:** Query logging and analytics
- **Future:** LLM-based query classification for ambiguous queries

## Technical Design

### Data Flow Diagram

**Query Routing Flow:**
```
POST /query
    ↓
[Authentication] → Validate API Key
    ↓
[Validation] → Validate Request Body
    ↓
[Document Check] → Verify document_ids exist (if provided)
    ↓
[QueryRouter] → classify_query() → QueryType
    ↓
    ├─→ EXACT_LOOKUP → ExactRetriever → PostgreSQL
    │                    ↓
    │                  [Sources]
    │
    ├─→ SEMANTIC → SemanticRetriever → Milvus
    │                ↓
    │              [Sources]
    │
    └─→ HYBRID → HybridRetriever
                   ├─→ ExactRetriever → PostgreSQL
                   └─→ SemanticRetriever → Milvus
                   ↓
                 [Merge & Deduplicate]
    ↓
[AnswerGenerator] → Generate answer from sources
    ↓
[Confidence] → Calculate from similarity + query_type
    ↓
[Response] → Return QueryResponse
```

### Module Structure

```
src/
├── api/
│   └── routes/
│       └── query.py              # UPDATED - Use QueryRouter
├── retrieval/
│   ├── __init__.py
│   ├── router.py                 # NEW - Query classification and routing
│   ├── exact_retriever.py        # NEW - PostgreSQL exact lookup
│   ├── semantic_retriever.py     # EXISTING - From Story 5.1
│   ├── hybrid_retriever.py       # NEW - Merge exact + semantic
│   └── answer_generator.py       # EXISTING - From Story 5.1
└── db/
    ├── models.py                  # EXISTING - Use ExtractedRecord, SymbolDictionary
    └── session.py                 # EXISTING - Database sessions
```

### API Response Examples

**Exact Lookup Query:**
```json
POST /query
{
  "query": "What does code 07 mean?",
  "include_context": true
}

Response (200 OK):
{
  "query": "What does code 07 mean?",
  "answer": "Code 07 means 'No required fields' according to the symbol dictionary in the Distribution Specifications document.",
  "confidence": 0.95,
  "sources": [
    {
      "file": "distribution_spec.xlsx",
      "sheet": "Symbol Dictionary",
      "location": "Row 8",
      "context": "Symbol: 07. Meaning: No required fields. Category: Status Code"
    }
  ],
  "processing_time_ms": 450
}
```

**Semantic Query:**
```json
POST /query
{
  "query": "Tell me about construction results",
  "include_context": true
}

Response (200 OK):
{
  "query": "Tell me about construction results",
  "answer": "Construction results show completion of foundation work with status 'Approved'. The project includes structural components A, B, and C with completion dates ranging from Q1 to Q3 2024.",
  "confidence": 0.78,
  "sources": [
    {
      "file": "construction_report.xlsx",
      "sheet": "Results Summary",
      "location": "Row 12",
      "context": "Phase: Foundation. Status: Approved. Completion: Q1 2024. Components: A, B, C"
    }
  ],
  "processing_time_ms": 1200
}
```

**Hybrid Query:**
```json
POST /query
{
  "query": "What screens show item 2024-4_0019 and why?",
  "include_context": true
}

Response (200 OK):
{
  "query": "What screens show item 2024-4_0019 and why?",
  "answer": "Item 2024-4_0019 appears in the Screen column because it is a display component for the user interface. The item is marked as 'Applicable' (○) for screen display, indicating it should be visible in the UI.",
  "confidence": 0.88,
  "sources": [
    {
      "file": "distribution_spec.xlsx",
      "sheet": "Item Mapping",
      "location": "Row 42",
      "context": "Item Code: 2024-4_0019. Report: -. Screen: ○. Description: Display component for UI"
    },
    {
      "file": "ui_components.xlsx",
      "sheet": "Screen Items",
      "location": "Row 15",
      "context": "Component 2024-4_0019 is designated for screen display to provide user feedback during workflow execution"
    }
  ],
  "processing_time_ms": 1450
}
```

### Example Usage

```python
import httpx
import asyncio

API_BASE_URL = "http://localhost:8000"
API_KEY = "your-api-key-here"

async def test_query_routing():
    """Example: Test different query types with routing."""
    async with httpx.AsyncClient() as client:
        # Test EXACT_LOOKUP query
        print("=== EXACT LOOKUP Query ===")
        response = await client.post(
            f"{API_BASE_URL}/query",
            json={
                "query": "What does code 07 mean?",
                "include_context": True
            },
            headers={"X-API-Key": API_KEY}
        )
        result = response.json()
        print(f"Answer: {result['answer']}")
        print(f"Confidence: {result['confidence']}")
        print(f"Processing time: {result['processing_time_ms']}ms\n")

        # Test SEMANTIC query
        print("=== SEMANTIC Query ===")
        response = await client.post(
            f"{API_BASE_URL}/query",
            json={
                "query": "Tell me about construction results",
                "include_context": True
            },
            headers={"X-API-Key": API_KEY}
        )
        result = response.json()
        print(f"Answer: {result['answer']}")
        print(f"Confidence: {result['confidence']}")
        print(f"Processing time: {result['processing_time_ms']}ms\n")

        # Test HYBRID query
        print("=== HYBRID Query ===")
        response = await client.post(
            f"{API_BASE_URL}/query",
            json={
                "query": "What screens show item 2024-4_0019 and why?",
                "include_context": True
            },
            headers={"X-API-Key": API_KEY}
        )
        result = response.json()
        print(f"Answer: {result['answer']}")
        print(f"Sources: {len(result['sources'])}")
        print(f"Confidence: {result['confidence']}")
        print(f"Processing time: {result['processing_time_ms']}ms\n")

if __name__ == "__main__":
    asyncio.run(test_query_routing())
```

---

## Definition of Done

- [ ] All acceptance criteria (AC5.2.1 - AC5.2.5) implemented and tested
- [ ] QueryRouter service created in `src/retrieval/router.py`
- [ ] QueryType enum defined (EXACT_LOOKUP, SEMANTIC, HYBRID)
- [ ] classify_query() method with rule-based patterns implemented
- [ ] ExactRetriever service created in `src/retrieval/exact_retriever.py`
- [ ] HybridRetriever service created in `src/retrieval/hybrid_retriever.py`
- [ ] SemanticRetriever reused from Story 5.1
- [ ] Query endpoint updated to use QueryRouter
- [ ] Backwards compatible with Story 5.1 API contract
- [ ] PostgreSQL exact lookups working (extracted_records, symbol_dictionaries)
- [ ] Hybrid merge strategy implemented (exact first, then semantic)
- [ ] Deduplication working correctly in hybrid mode
- [ ] Unit tests pass (15+ tests)
- [ ] Integration tests pass with real PostgreSQL and Milvus
- [ ] Error handling for all retrieval paths
- [ ] Processing time ≤ 5 seconds maintained
- [ ] Structured logging includes query_type and retrieval_method
- [ ] Examples created showing all three query types
- [ ] Code follows project style (Black, Ruff, mypy strict)
- [ ] Documentation strings (docstrings) for all public methods
- [ ] API documentation updated
- [ ] Sprint status updated to "done"
- [ ] Git commit with proper commit message

## Dev Agent Record

### Context Reference

<!-- Path(s) to story context XML will be added here by context workflow -->

### Agent Model Used

{{agent_model_name_version}}

### Debug Log References

### Completion Notes List

### File List
