Spaces:
Paused
Paused
Commit Β·
7758af3
1
Parent(s): c65382c
feat: add SQL routing, fallback, and context merging to retriever
Browse files- src/retriever.py +97 -28
- tests/test_retriever.py +22 -0
src/retriever.py
CHANGED
|
@@ -1,4 +1,6 @@
|
|
| 1 |
-
"""Dual retriever: ChromaDB vector search + optional web search."""
|
|
|
|
|
|
|
| 2 |
|
| 3 |
from src.search import search, format_web_results_as_context
|
| 4 |
|
|
@@ -70,60 +72,127 @@ def format_db_results_as_context(chunks: list[dict]) -> str:
|
|
| 70 |
return "\n".join(parts)
|
| 71 |
|
| 72 |
|
| 73 |
-
def build_combined_context(
|
| 74 |
-
|
|
|
|
|
|
|
| 75 |
db_context = format_db_results_as_context(db_results)
|
| 76 |
web_context = format_web_results_as_context(web_results)
|
| 77 |
|
| 78 |
-
|
|
|
|
|
|
|
|
|
|
| 79 |
return NO_SOURCES_REFUSAL
|
| 80 |
|
| 81 |
-
|
| 82 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
"NOTE: No local documents are indexed. All sources below come from "
|
| 84 |
"academic web search. You MUST include the URL link for every source cited. "
|
| 85 |
-
"State clearly that you have no curated local sources.\n
|
| 86 |
-
+ web_context
|
| 87 |
)
|
| 88 |
|
| 89 |
-
if
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
|
|
|
| 94 |
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
)
|
| 102 |
|
| 103 |
|
| 104 |
-
def
|
| 105 |
-
"""
|
| 106 |
|
| 107 |
-
Returns
|
| 108 |
"""
|
| 109 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
|
|
|
|
| 111 |
web_enabled = cfg.get("web_search", {}).get("enabled", False)
|
| 112 |
web_results = []
|
| 113 |
if web_enabled:
|
| 114 |
backend = cfg.get("web_search", {}).get("backend", "semantic_scholar")
|
| 115 |
max_results = cfg.get("web_search", {}).get("max_results", 5)
|
| 116 |
-
|
| 117 |
-
if not db_results:
|
| 118 |
max_results *= 2
|
| 119 |
web_results = search(query, backend=backend, limit=max_results)
|
| 120 |
|
| 121 |
-
combined_context = build_combined_context(db_results, web_results)
|
| 122 |
-
has_sources = bool(db_results) or bool(web_results)
|
| 123 |
|
| 124 |
return {
|
| 125 |
"context": combined_context,
|
| 126 |
"db_results": db_results,
|
| 127 |
"web_results": web_results,
|
|
|
|
| 128 |
"has_sources": has_sources,
|
| 129 |
}
|
|
|
|
| 1 |
+
"""Dual retriever: ChromaDB vector search + optional web search + SQL retrieval."""
|
| 2 |
+
|
| 3 |
+
import re
|
| 4 |
|
| 5 |
from src.search import search, format_web_results_as_context
|
| 6 |
|
|
|
|
| 72 |
return "\n".join(parts)
|
| 73 |
|
| 74 |
|
| 75 |
+
def build_combined_context(
|
| 76 |
+
db_results: list[dict], web_results: list[dict], sql_context: str = "",
|
| 77 |
+
) -> str:
|
| 78 |
+
"""Build combined context string from local, SQL, and web results."""
|
| 79 |
db_context = format_db_results_as_context(db_results)
|
| 80 |
web_context = format_web_results_as_context(web_results)
|
| 81 |
|
| 82 |
+
has_local = bool(db_results) or bool(sql_context)
|
| 83 |
+
has_web = bool(web_results)
|
| 84 |
+
|
| 85 |
+
if not has_local and not has_web:
|
| 86 |
return NO_SOURCES_REFUSAL
|
| 87 |
|
| 88 |
+
parts = []
|
| 89 |
+
|
| 90 |
+
if has_local and not has_web:
|
| 91 |
+
parts.append("ONLY use the local sources below. Do not add any outside knowledge.\n")
|
| 92 |
+
|
| 93 |
+
if has_local and has_web:
|
| 94 |
+
parts.append(
|
| 95 |
+
"IMPORTANT: Local document sources are the PRIMARY authority. "
|
| 96 |
+
"Web sources are SUPPLEMENTARY only. If any web source contradicts "
|
| 97 |
+
"a local document, trust the local document.\n"
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
if not has_local and has_web:
|
| 101 |
+
parts.append(
|
| 102 |
"NOTE: No local documents are indexed. All sources below come from "
|
| 103 |
"academic web search. You MUST include the URL link for every source cited. "
|
| 104 |
+
"State clearly that you have no curated local sources.\n"
|
|
|
|
| 105 |
)
|
| 106 |
|
| 107 |
+
if sql_context:
|
| 108 |
+
parts.append(sql_context)
|
| 109 |
+
if db_context:
|
| 110 |
+
parts.append(db_context)
|
| 111 |
+
if web_context:
|
| 112 |
+
parts.append(web_context)
|
| 113 |
|
| 114 |
+
return "\n\n".join(parts)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def _extract_table_from_query(sql_query: str) -> str:
|
| 118 |
+
"""Extract the first table name from a SQL query (best-effort)."""
|
| 119 |
+
match = re.search(r'\bFROM\s+"?(\w+)"?', sql_query, re.IGNORECASE)
|
| 120 |
+
return match.group(1) if match else ""
|
| 121 |
|
| 122 |
|
| 123 |
+
def _run_sql_retrieval(sql_query: str, cfg: dict) -> tuple[list[dict], str]:
|
| 124 |
+
"""Execute SQL query and format results as context.
|
| 125 |
|
| 126 |
+
Returns (sql_rows, sql_context).
|
| 127 |
"""
|
| 128 |
+
import json as _json
|
| 129 |
+
import os
|
| 130 |
+
from src.sql_retriever import execute_sql_query, format_sql_results_as_context, _lookup_source_file
|
| 131 |
+
|
| 132 |
+
sql_rows = execute_sql_query(sql_query, cfg)
|
| 133 |
+
if not sql_rows:
|
| 134 |
+
return [], ""
|
| 135 |
+
|
| 136 |
+
sql_db_dir = cfg.get("paths", {}).get("sql_db", "sql_db")
|
| 137 |
+
if not os.path.isabs(sql_db_dir):
|
| 138 |
+
from pathlib import Path
|
| 139 |
+
project_root = Path(__file__).resolve().parent.parent
|
| 140 |
+
sql_db_dir = os.path.join(str(project_root), sql_db_dir)
|
| 141 |
+
schema_path = os.path.join(sql_db_dir, "sql_schemas.json")
|
| 142 |
+
schema = {}
|
| 143 |
+
if os.path.exists(schema_path):
|
| 144 |
+
try:
|
| 145 |
+
with open(schema_path) as f:
|
| 146 |
+
schema = _json.load(f)
|
| 147 |
+
except Exception:
|
| 148 |
+
pass
|
| 149 |
+
table_name = _extract_table_from_query(sql_query)
|
| 150 |
+
source_file = _lookup_source_file(table_name, schema)
|
| 151 |
+
sql_context = format_sql_results_as_context(sql_rows, sql_query, source_file)
|
| 152 |
+
return sql_rows, sql_context
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def retrieve(
|
| 156 |
+
query: str, cfg: dict, route: str = "vector", sql_query: str = None,
|
| 157 |
+
) -> dict:
|
| 158 |
+
"""Run retrieval with SQL/vector routing and fallback.
|
| 159 |
+
|
| 160 |
+
Returns dict with: context, db_results, web_results, sql_results, has_sources.
|
| 161 |
+
"""
|
| 162 |
+
sql_enabled = cfg.get("sql", {}).get("enabled", True)
|
| 163 |
+
sql_context = ""
|
| 164 |
+
sql_rows = []
|
| 165 |
+
|
| 166 |
+
# ββ SQL retrieval ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 167 |
+
if sql_enabled and route in ("sql", "both") and sql_query:
|
| 168 |
+
sql_rows, sql_context = _run_sql_retrieval(sql_query, cfg)
|
| 169 |
+
|
| 170 |
+
# ββ Vector retrieval βββββββββββββββββββββββββββββββββββββββοΏ½οΏ½οΏ½βββββ
|
| 171 |
+
db_results = []
|
| 172 |
+
if route in ("vector", "both") or (route == "sql" and not sql_rows):
|
| 173 |
+
db_results = retrieve_from_vectordb(query, cfg)
|
| 174 |
+
|
| 175 |
+
# ββ Fallback: vector found nothing, try SQL ββββββββββββββββββββββ
|
| 176 |
+
if sql_enabled and not db_results and not sql_rows and route == "vector" and sql_query:
|
| 177 |
+
sql_rows, sql_context = _run_sql_retrieval(sql_query, cfg)
|
| 178 |
|
| 179 |
+
# ββ Web search βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 180 |
web_enabled = cfg.get("web_search", {}).get("enabled", False)
|
| 181 |
web_results = []
|
| 182 |
if web_enabled:
|
| 183 |
backend = cfg.get("web_search", {}).get("backend", "semantic_scholar")
|
| 184 |
max_results = cfg.get("web_search", {}).get("max_results", 5)
|
| 185 |
+
if not db_results and not sql_rows:
|
|
|
|
| 186 |
max_results *= 2
|
| 187 |
web_results = search(query, backend=backend, limit=max_results)
|
| 188 |
|
| 189 |
+
combined_context = build_combined_context(db_results, web_results, sql_context=sql_context)
|
| 190 |
+
has_sources = bool(db_results) or bool(web_results) or bool(sql_rows)
|
| 191 |
|
| 192 |
return {
|
| 193 |
"context": combined_context,
|
| 194 |
"db_results": db_results,
|
| 195 |
"web_results": web_results,
|
| 196 |
+
"sql_results": sql_rows,
|
| 197 |
"has_sources": has_sources,
|
| 198 |
}
|
tests/test_retriever.py
CHANGED
|
@@ -35,3 +35,25 @@ def test_build_combined_context_both():
|
|
| 35 |
result = build_combined_context(db, web)
|
| 36 |
assert "PRIMARY" in result
|
| 37 |
assert "SUPPLEMENTARY" in result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
result = build_combined_context(db, web)
|
| 36 |
assert "PRIMARY" in result
|
| 37 |
assert "SUPPLEMENTARY" in result
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def test_build_combined_context_with_sql():
|
| 41 |
+
from src.retriever import build_combined_context
|
| 42 |
+
sql = "=== SQL Query Results (PRIMARY) ===\n[CHUNK-SQL-001] x = 1"
|
| 43 |
+
db = [{"text": "data", "metadata": {"source": "f.pdf", "dataset": "d", "page": "1"}, "distance": 0.1}]
|
| 44 |
+
result = build_combined_context(db, [], sql_context=sql)
|
| 45 |
+
assert "SQL Query Results" in result
|
| 46 |
+
assert "CHUNK-LOCAL" in result
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def test_build_combined_context_sql_only():
|
| 50 |
+
from src.retriever import build_combined_context
|
| 51 |
+
sql = "=== SQL Query Results (PRIMARY) ===\n[CHUNK-SQL-001] x = 1"
|
| 52 |
+
result = build_combined_context([], [], sql_context=sql)
|
| 53 |
+
assert "SQL Query Results" in result
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def test_build_combined_context_sql_no_sources():
|
| 57 |
+
from src.retriever import build_combined_context
|
| 58 |
+
result = build_combined_context([], [], sql_context="")
|
| 59 |
+
assert "No relevant" in result or "don't have" in result
|