feat: Add force_hierarchical mode to prevent fallback

## Changes

Allow users to force hierarchical search mode without fallback to simple
search, enabling testing of hierarchical UI even when 0 summaries are found.

**Backend (flask_app.py):**
- Added `force_hierarchical` parameter to `hierarchical_search()`
- When True, never fallback to simple search (return empty hierarchical result)
- Added `fallback_reason` field to explain why no results
- Pass `force_hierarchical=True` when `force_mode == "hierarchical"`
- Applied to all fallback points:
  - No Weaviate client
  - No summaries found in Stage 1
  - No sections after author/work filtering
  - Exception during search

**Frontend (templates/search.html):**
- Display warning message when `fallback_reason` exists
- Yellow alert box with explanation and suggestions
- Works even when `results_data.results` is empty

## Usage

1. Select "🌳 Hiérarchique (2-étapes)" in Mode dropdown
2. Enter any query (even if no matching summaries)
3. See hierarchical UI with warning instead of fallback

## Example

Query: "Qu'est-ce que la justice ?" (not in Peirce corpus)
- Mode forced: Hierarchical
- Result: 0 sections, warning displayed
- No silent fallback to simple search

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-01 14:24:44 +01:00
parent 0dcccc93d1
commit f6000de230
2 changed files with 89 additions and 29 deletions

View File

@@ -291,6 +291,7 @@ def hierarchical_search(
author_filter: Optional[str] = None, author_filter: Optional[str] = None,
work_filter: Optional[str] = None, work_filter: Optional[str] = None,
sections_limit: int = 5, sections_limit: int = 5,
force_hierarchical: bool = False,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Two-stage hierarchical semantic search: Summary → Chunks. """Two-stage hierarchical semantic search: Summary → Chunks.
@@ -303,6 +304,7 @@ def hierarchical_search(
author_filter: Filter by author name. author_filter: Filter by author name.
work_filter: Filter by work title. work_filter: Filter by work title.
sections_limit: Number of top sections to retrieve (default: 5). sections_limit: Number of top sections to retrieve (default: 5).
force_hierarchical: If True, never fallback to simple search (for testing).
Returns: Returns:
Dictionary with hierarchical search results: Dictionary with hierarchical search results:
@@ -310,17 +312,28 @@ def hierarchical_search(
- sections: List of section dictionaries with nested chunks - sections: List of section dictionaries with nested chunks
- results: Flat list of all chunks (for compatibility) - results: Flat list of all chunks (for compatibility)
- total_chunks: Total number of chunks found - total_chunks: Total number of chunks found
- fallback_reason: Explanation if forced but 0 results (optional)
""" """
try: try:
with get_weaviate_client() as client: with get_weaviate_client() as client:
if client is None: if client is None:
# Fallback to simple search # Fallback to simple search only if not forced
if not force_hierarchical:
results = simple_search(query, limit, author_filter, work_filter) results = simple_search(query, limit, author_filter, work_filter)
return { return {
"mode": "simple", "mode": "simple",
"results": results, "results": results,
"total_chunks": len(results), "total_chunks": len(results),
} }
else:
# Forced hierarchical with no client
return {
"mode": "hierarchical",
"sections": [],
"results": [],
"total_chunks": 0,
"fallback_reason": "Weaviate client unavailable",
}
# ═══════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════
# STAGE 1: Search Summary collection for relevant sections # STAGE 1: Search Summary collection for relevant sections
@@ -338,13 +351,24 @@ def hierarchical_search(
) )
if not summaries_result.objects: if not summaries_result.objects:
# No summaries found, fallback to simple search # No summaries found
if not force_hierarchical:
# Auto-detection: fallback to simple search
results = simple_search(query, limit, author_filter, work_filter) results = simple_search(query, limit, author_filter, work_filter)
return { return {
"mode": "simple", "mode": "simple",
"results": results, "results": results,
"total_chunks": len(results), "total_chunks": len(results),
} }
else:
# Forced hierarchical: return empty hierarchical result
return {
"mode": "hierarchical",
"sections": [],
"results": [],
"total_chunks": 0,
"fallback_reason": f"Aucune section pertinente trouvée (0/{sections_limit} summaries)",
}
# Extract section data # Extract section data
sections_data = [] sections_data = []
@@ -393,13 +417,27 @@ def hierarchical_search(
sections_data = filtered_sections sections_data = filtered_sections
if not sections_data: if not sections_data:
# No sections match filters, fallback to simple search # No sections match filters
if not force_hierarchical:
# Auto-detection: fallback to simple search
results = simple_search(query, limit, author_filter, work_filter) results = simple_search(query, limit, author_filter, work_filter)
return { return {
"mode": "simple", "mode": "simple",
"results": results, "results": results,
"total_chunks": len(results), "total_chunks": len(results),
} }
else:
# Forced hierarchical: return empty hierarchical result
filters_str = f"author={author_filter}" if author_filter else ""
if work_filter:
filters_str += f", work={work_filter}" if filters_str else f"work={work_filter}"
return {
"mode": "hierarchical",
"sections": [],
"results": [],
"total_chunks": 0,
"fallback_reason": f"Aucune section ne correspond aux filtres ({filters_str})",
}
# ═══════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════
# STAGE 2: Search Chunk collection filtered by sections # STAGE 2: Search Chunk collection filtered by sections
@@ -461,13 +499,23 @@ def hierarchical_search(
except Exception as e: except Exception as e:
print(f"Erreur recherche hiérarchique: {e}") print(f"Erreur recherche hiérarchique: {e}")
# Fallback to simple search on error # Fallback to simple search on error (unless forced)
if not force_hierarchical:
results = simple_search(query, limit, author_filter, work_filter) results = simple_search(query, limit, author_filter, work_filter)
return { return {
"mode": "simple", "mode": "simple",
"results": results, "results": results,
"total_chunks": len(results), "total_chunks": len(results),
} }
else:
# Forced hierarchical: return error in hierarchical format
return {
"mode": "hierarchical",
"sections": [],
"results": [],
"total_chunks": 0,
"fallback_reason": f"Erreur lors de la recherche hiérarchique: {str(e)}",
}
def should_use_hierarchical_search(query: str) -> bool: def should_use_hierarchical_search(query: str) -> bool:
@@ -584,6 +632,7 @@ def search_passages(
author_filter=author_filter, author_filter=author_filter,
work_filter=work_filter, work_filter=work_filter,
sections_limit=sections_limit, sections_limit=sections_limit,
force_hierarchical=(force_mode == "hierarchical"), # No fallback if explicitly forced
) )
else: else:
results = simple_search(query, limit, author_filter, work_filter) results = simple_search(query, limit, author_filter, work_filter)

View File

@@ -130,7 +130,7 @@
{% if query %} {% if query %}
<div class="ornament">·</div> <div class="ornament">·</div>
{% if results_data and results_data.results %} {% if results_data %}
<!-- Mode indicator and stats --> <!-- Mode indicator and stats -->
<div class="mb-3" style="display: flex; align-items: center; gap: 1rem; flex-wrap: wrap;"> <div class="mb-3" style="display: flex; align-items: center; gap: 1rem; flex-wrap: wrap;">
<div> <div>
@@ -160,6 +160,17 @@
{% endif %} {% endif %}
</div> </div>
<!-- Fallback reason warning (when forced hierarchical but no results) -->
{% if results_data.fallback_reason %}
<div class="alert" style="background-color: #fff3cd; border: 1px solid #ffc107; color: #856404; padding: 1rem; border-radius: 4px; margin-bottom: 1rem;">
<strong>⚠️ Mode hiérarchique forcé :</strong> {{ results_data.fallback_reason }}
<br>
<small>💡 Essayez une requête sur un sujet présent dans le corpus (ex: "croyance", "signe", "inférence") ou basculez en mode Auto-détection.</small>
</div>
{% endif %}
{% if results_data.results %}
<!-- Hierarchical display --> <!-- Hierarchical display -->
{% if results_data.mode == "hierarchical" and results_data.sections %} {% if results_data.mode == "hierarchical" and results_data.sections %}
{% for section in results_data.sections %} {% for section in results_data.sections %}