LIANJie-Jason Claude Opus 4.6 commited on
Commit
7758af3
Β·
1 Parent(s): c65382c

feat: add SQL routing, fallback, and context merging to retriever

Browse files
Files changed (2) hide show
  1. src/retriever.py +97 -28
  2. 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(db_results: list[dict], web_results: list[dict]) -> str:
74
- """Build combined context string from local and web results."""
 
 
75
  db_context = format_db_results_as_context(db_results)
76
  web_context = format_web_results_as_context(web_results)
77
 
78
- if not db_results and not web_results:
 
 
 
79
  return NO_SOURCES_REFUSAL
80
 
81
- if not db_results and web_results:
82
- return (
 
 
 
 
 
 
 
 
 
 
 
 
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\n"
86
- + web_context
87
  )
88
 
89
- if db_results and not web_results:
90
- return (
91
- "ONLY use the local sources below. Do not add any outside knowledge.\n\n"
92
- + db_context
93
- )
 
94
 
95
- # Both local and web
96
- return (
97
- "IMPORTANT: Local document sources are the PRIMARY authority. "
98
- "Web sources are SUPPLEMENTARY only. If any web source contradicts "
99
- "a local document, trust the local document.\n\n"
100
- + db_context + "\n\n" + web_context
101
- )
102
 
103
 
104
- def retrieve(query: str, cfg: dict) -> dict:
105
- """Run dual retrieval: vector DB + optional web search.
106
 
107
- Returns dict with: context, db_results, web_results, has_sources.
108
  """
109
- db_results = retrieve_from_vectordb(query, cfg)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- # Double web results when no local docs
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