han-na commited on
Commit
a3ae00a
Β·
1 Parent(s): 90c180a

Initial Spring 2026 deployment

Browse files
.gitignore ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ env/
8
+ venv/
9
+ .venv/
10
+
11
+ # Environment variables
12
+ .env
13
+ .env.local
14
+ environment.yml
15
+
16
+ # IDE
17
+ .vscode/
18
+ .idea/
19
+ *.swp
20
+ *.swo
21
+
22
+ # Data files (upload separately or use external hosting)
23
+ *.csv
24
+ *.parquet
25
+ *.pickle
26
+ *.pkl
27
+ data/raw/
28
+ data/processed/
29
+
30
+ # Logs
31
+ *.log
32
+ logs/
33
+
34
+ # Database
35
+ *.db
36
+ *.sqlite
37
+
38
+ # OS
39
+ .DS_Store
40
+ Thumbs.db
41
+
42
+ # HuggingFace specific
43
+ flagged/
README.md CHANGED
@@ -1,20 +1,115 @@
1
  ---
2
  title: BPL RAG Spring 2026
3
- emoji: πŸš€
4
- colorFrom: red
5
- colorTo: red
6
- sdk: docker
7
- app_port: 8501
8
- tags:
9
- - streamlit
10
  pinned: false
11
- short_description: Ask questions in plain English to discover historical photos
12
  license: mit
13
  ---
14
 
15
- # Welcome to Streamlit!
16
 
17
- Edit `/src/streamlit_app.py` to customize this app to your heart's desire. :heart:
18
 
19
- If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
20
- forums](https://discuss.streamlit.io).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: BPL RAG Spring 2026
3
+ emoji: πŸ“š
4
+ colorFrom: blue
5
+ colorTo: green
6
+ sdk: streamlit
7
+ sdk_version: 1.32.0
8
+ app_file: app.py
 
9
  pinned: false
 
10
  license: mit
11
  ---
12
 
13
+ # Boston Public Library - RAG Search System (Spring 2026)
14
 
15
+ Natural language search for the BPL Digital Commonwealth collection using Retrieval-Augmented Generation.
16
 
17
+ ## 🎯 Project Overview
18
+
19
+ This system allows users to search through the Boston Public Library's digital collections using natural language queries. Built as part of the BU Spark! DS549 course in Spring 2026.
20
+
21
+ ### Key Features
22
+
23
+ - **Semantic Search**: Natural language queries across 445K+ BPL items
24
+ - **Full-Text Search**: Search within document content, not just metadata
25
+ - **Metadata Filtering**: Time-based, location-based, and type-based filtering
26
+ - **Evaluation Framework**: Built-in testing with gold-standard datasets
27
+ - **PostgreSQL + pgvector**: Scalable vector database backend
28
+
29
+ ## πŸš€ Quick Start
30
+
31
+ 1. Enter your query in natural language (e.g., "What were important events in Boston in 1919?")
32
+ 2. View retrieved documents with relevance scores
33
+ 3. Read AI-generated contextual explanations
34
+
35
+ ## πŸ“Š Example Queries
36
+
37
+ - "Find pictures of JFK's house on Cape Cod"
38
+ - "Are there any maps of Worcester, MA from the 18th century?"
39
+ - "Show me depictions of indigenous Americans"
40
+ - "What were important historical events in Boston in 1919?"
41
+
42
+ ## πŸ› οΈ Technical Stack
43
+
44
+ - **Frontend**: Streamlit
45
+ - **Database**: PostgreSQL with pgvector extension
46
+ - **Embeddings**: Sentence Transformers
47
+ - **LLM**: OpenAI GPT-4 / Anthropic Claude
48
+ - **Retrieval**: BM25 + Vector Search with metadata filtering
49
+ - **Evaluation**: DeepEval framework
50
+
51
+ ## πŸ“ Project Structure
52
+
53
+ ```
54
+ current_spring2026/
55
+ β”œβ”€β”€ app.py # Main Streamlit application
56
+ β”œβ”€β”€ pipeline.py # RAG pipeline orchestration
57
+ β”œβ”€β”€ config.py # Configuration management
58
+ β”œβ”€β”€ database/ # Database connection & queries
59
+ β”œβ”€β”€ embedding/ # Vector embeddings
60
+ β”œβ”€β”€ retrieval/ # Document retrieval logic
61
+ β”œβ”€β”€ generation/ # LLM response generation
62
+ β”œβ”€β”€ evaluation/ # Testing & metrics
63
+ β”œβ”€β”€ ingestion/ # Data processing pipeline
64
+ └── scripts/ # Utility scripts
65
+ ```
66
+
67
+ ## πŸ” Environment Variables
68
+
69
+ Required secrets (set in Space Settings β†’ Repository secrets):
70
+
71
+ ```
72
+ OPENAI_API_KEY=your_openai_key
73
+ DATABASE_URL=postgresql://user:pass@host:port/dbname
74
+ ```
75
+
76
+ ## πŸ“ˆ Spring 2026 Improvements
77
+
78
+ Building on Fall 2025 work, this version adds:
79
+
80
+ - βœ… Full-text document search (not just metadata)
81
+ - βœ… Gold-standard evaluation dataset
82
+ - βœ… Structured logging of queries and metrics
83
+ - βœ… Improved retrieval metrics (Precision, Recall, MRR)
84
+ - βœ… Enhanced UI with developer debug view
85
+
86
+ ## πŸ“š Data
87
+
88
+ - **Source**: Digital Commonwealth (BPL subset)
89
+ - **Records**: ~445,000 items
90
+ - **Types**: Photographs, maps, newspapers, manuscripts, books
91
+ - **Full-text**: 210K items with OCR (~1.5M pages)
92
+
93
+ ## πŸ‘₯ Team
94
+
95
+ **Spring 2026 Spark! Team**
96
+ - Boston University Data Science (DS549)
97
+ - Client: Eben English, Boston Public Library
98
+
99
+ **Previous Semesters**
100
+ - Fall 2025: Infrastructure & pgvector migration
101
+ - Fall 2024: Initial RAG prototype
102
+
103
+ ## πŸ“„ License
104
+
105
+ MIT License
106
+
107
+ ## πŸ”— Links
108
+
109
+ - [Digital Commonwealth](https://www.digitalcommonwealth.org/)
110
+ - [BPL Digital Repository](https://www.digitalcommonwealth.org/institutions/boston-public-library)
111
+ - [Project Documentation](https://github.com/your-repo-link)
112
+
113
+ ---
114
+
115
+ *Built with ❀️ by BU Spark! for Boston Public Library*
app.py ADDED
@@ -0,0 +1,492 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ import sys
3
+ import os
4
+ import html
5
+
6
+ # Add project root to path so we can import pipeline modules
7
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
8
+
9
+ from pipeline import run_query, PipelineResult
10
+ from retrieval.retriever import RetrievedDocument
11
+
12
+ # ── Page config ──────────────────────────────────────────────────────────────
13
+ st.set_page_config(
14
+ page_title="Digital Commonwealth Β· BPL Search",
15
+ page_icon="πŸ“š",
16
+ layout="wide",
17
+ initial_sidebar_state="collapsed",
18
+ )
19
+
20
+ # ── Custom CSS ────────────────────────────────────────────────────────────────
21
+ st.markdown("""
22
+ <style>
23
+ @import url('https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,600;1,400&family=Source+Sans+3:wght@300;400;500;600&display=swap');
24
+
25
+ /* ── Root palette ── */
26
+ :root {
27
+ --cream: #F7F3EC;
28
+ --ink: #1A1410;
29
+ --sepia: #7A5C3A;
30
+ --gold: #C8973A;
31
+ --rust: #A63D2F;
32
+ --muted: #8A7B6A;
33
+ --border: #D9CFC2;
34
+ --card-bg: #FFFDF9;
35
+ --tag-bg: #EDE5D8;
36
+ }
37
+
38
+ html, body, [class*="css"] {
39
+ font-family: 'Source Sans 3', sans-serif;
40
+ background-color: var(--cream);
41
+ color: var(--ink);
42
+ }
43
+
44
+ /* ── Hide default Streamlit chrome ── */
45
+ #MainMenu, footer, header { visibility: hidden; }
46
+ .block-container { padding-top: 2rem; padding-bottom: 3rem; max-width: 960px; }
47
+
48
+ /* ── Masthead ── */
49
+ .masthead {
50
+ text-align: center;
51
+ padding: 3.5rem 1rem 2rem;
52
+ border-bottom: 2px solid var(--border);
53
+ margin-bottom: 2.5rem;
54
+ }
55
+ .masthead-eyebrow {
56
+ font-family: 'Source Sans 3', sans-serif;
57
+ font-size: 0.72rem;
58
+ font-weight: 600;
59
+ letter-spacing: 0.22em;
60
+ text-transform: uppercase;
61
+ color: var(--sepia);
62
+ margin-bottom: 0.6rem;
63
+ }
64
+ .masthead-title {
65
+ font-family: 'Playfair Display', Georgia, serif;
66
+ font-size: 3rem;
67
+ font-weight: 400;
68
+ color: var(--ink);
69
+ line-height: 1.15;
70
+ margin: 0 0 0.5rem;
71
+ }
72
+ .masthead-title em {
73
+ font-style: italic;
74
+ color: var(--sepia);
75
+ }
76
+ .masthead-sub {
77
+ font-size: 1rem;
78
+ color: var(--muted);
79
+ font-weight: 300;
80
+ max-width: 540px;
81
+ margin: 0 auto;
82
+ line-height: 1.6;
83
+ }
84
+
85
+ /* ── Search box wrapper ── */
86
+ .search-wrapper {
87
+ background: var(--card-bg);
88
+ border: 1.5px solid var(--border);
89
+ border-radius: 4px;
90
+ padding: 1.6rem 1.8rem;
91
+ margin-bottom: 1.2rem;
92
+ box-shadow: 0 2px 12px rgba(26,20,16,0.06);
93
+ }
94
+ .search-label {
95
+ font-size: 0.75rem;
96
+ font-weight: 600;
97
+ letter-spacing: 0.16em;
98
+ text-transform: uppercase;
99
+ color: var(--sepia);
100
+ margin-bottom: 0.5rem;
101
+ }
102
+
103
+ /* ── Example query pills ── */
104
+ .pill-row { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 1rem; }
105
+ .pill {
106
+ background: var(--tag-bg);
107
+ border: 1px solid var(--border);
108
+ border-radius: 2px;
109
+ padding: 0.3rem 0.75rem;
110
+ font-size: 0.78rem;
111
+ color: var(--sepia);
112
+ cursor: pointer;
113
+ font-style: italic;
114
+ }
115
+
116
+ /* ── Divider ── */
117
+ .divider {
118
+ border: none;
119
+ border-top: 1px solid var(--border);
120
+ margin: 1.8rem 0;
121
+ }
122
+
123
+ /* ── Context banner ── */
124
+ .context-banner {
125
+ background: linear-gradient(135deg, #FDF6E8 0%, #F5EDD8 100%);
126
+ border-left: 4px solid var(--gold);
127
+ border-radius: 0 4px 4px 0;
128
+ padding: 1.2rem 1.5rem;
129
+ margin-bottom: 2rem;
130
+ font-size: 0.95rem;
131
+ line-height: 1.65;
132
+ color: var(--ink);
133
+ }
134
+ .context-banner strong { color: var(--sepia); }
135
+
136
+ /* ── Results header ── */
137
+ .results-header {
138
+ display: flex;
139
+ align-items: baseline;
140
+ justify-content: space-between;
141
+ margin-bottom: 1.2rem;
142
+ }
143
+ .results-count {
144
+ font-family: 'Playfair Display', serif;
145
+ font-size: 1.35rem;
146
+ color: var(--ink);
147
+ }
148
+ .results-count span { color: var(--sepia); font-style: italic; }
149
+ .results-meta {
150
+ font-size: 0.78rem;
151
+ color: var(--muted);
152
+ letter-spacing: 0.06em;
153
+ }
154
+
155
+ /* ── Result card ── */
156
+ .result-card {
157
+ background: var(--card-bg);
158
+ border: 1px solid var(--border);
159
+ border-radius: 4px;
160
+ padding: 1.4rem 1.6rem;
161
+ margin-bottom: 1rem;
162
+ transition: border-color 0.2s, box-shadow 0.2s;
163
+ position: relative;
164
+ }
165
+ .result-card:hover {
166
+ border-color: var(--gold);
167
+ box-shadow: 0 4px 18px rgba(200,151,58,0.12);
168
+ }
169
+ .card-type-badge {
170
+ display: inline-block;
171
+ font-size: 0.65rem;
172
+ font-weight: 600;
173
+ letter-spacing: 0.18em;
174
+ text-transform: uppercase;
175
+ color: var(--rust);
176
+ border: 1px solid var(--rust);
177
+ border-radius: 2px;
178
+ padding: 0.15rem 0.5rem;
179
+ margin-bottom: 0.6rem;
180
+ }
181
+ .card-title {
182
+ font-family: 'Playfair Display', serif;
183
+ font-size: 1.1rem;
184
+ font-weight: 600;
185
+ color: var(--ink);
186
+ margin-bottom: 0.3rem;
187
+ line-height: 1.3;
188
+ }
189
+ .card-meta {
190
+ font-size: 0.8rem;
191
+ color: var(--muted);
192
+ margin-bottom: 0.7rem;
193
+ line-height: 1.5;
194
+ }
195
+ .card-snippet {
196
+ font-size: 0.88rem;
197
+ color: #3D3228;
198
+ line-height: 1.65;
199
+ margin-bottom: 0.8rem;
200
+ }
201
+ .card-tags { display: flex; flex-wrap: wrap; gap: 0.4rem; }
202
+ .card-tag {
203
+ background: var(--tag-bg);
204
+ font-size: 0.72rem;
205
+ color: var(--sepia);
206
+ padding: 0.2rem 0.55rem;
207
+ border-radius: 2px;
208
+ border: 1px solid var(--border);
209
+ }
210
+ .card-link {
211
+ font-size: 0.78rem;
212
+ font-weight: 600;
213
+ color: var(--rust);
214
+ letter-spacing: 0.06em;
215
+ text-transform: uppercase;
216
+ text-decoration: none;
217
+ border-bottom: 1px solid transparent;
218
+ }
219
+ .card-link:hover { border-bottom-color: var(--rust); }
220
+
221
+ /* ── Relevance score bar ── */
222
+ .score-row { display: flex; align-items: center; gap: 0.6rem; margin-top: 0.6rem; }
223
+ .score-label { font-size: 0.7rem; color: var(--muted); letter-spacing: 0.08em; text-transform: uppercase; }
224
+ .score-bar-bg {
225
+ flex: 1;
226
+ height: 4px;
227
+ background: var(--tag-bg);
228
+ border-radius: 2px;
229
+ overflow: hidden;
230
+ max-width: 120px;
231
+ }
232
+ .score-bar-fill {
233
+ height: 100%;
234
+ background: linear-gradient(90deg, var(--gold), var(--rust));
235
+ border-radius: 2px;
236
+ }
237
+ .score-val { font-size: 0.7rem; color: var(--sepia); font-weight: 600; }
238
+
239
+ /* ── No results ── */
240
+ .no-results {
241
+ text-align: center;
242
+ padding: 4rem 2rem;
243
+ color: var(--muted);
244
+ }
245
+ .no-results-icon { font-size: 3rem; margin-bottom: 1rem; }
246
+ .no-results-title { font-family: 'Playfair Display', serif; font-size: 1.4rem; color: var(--ink); margin-bottom: 0.5rem; }
247
+
248
+ /* ── Footer ── */
249
+ .bpl-footer {
250
+ text-align: center;
251
+ padding: 2.5rem 1rem 1rem;
252
+ border-top: 1px solid var(--border);
253
+ margin-top: 3rem;
254
+ font-size: 0.78rem;
255
+ color: var(--muted);
256
+ letter-spacing: 0.05em;
257
+ }
258
+ .bpl-footer strong { color: var(--sepia); }
259
+ </style>
260
+ """, unsafe_allow_html=True)
261
+
262
+
263
+ # ── Example queries ───────────────────────────────────────────────────────────
264
+ EXAMPLE_QUERIES = [
265
+ "What happened in Boston in 1900?",
266
+ "Find photographs of Greece",
267
+ "Show me circus posters",
268
+ "Victorian era correspondence",
269
+ "Boston Traveler newspaper 1900",
270
+ "Women's suffrage documents",
271
+ ]
272
+
273
+ import re
274
+ def linkify_citations(text: str, num_docs: int) -> str:
275
+ """Replace [N] with clickable spans that scroll to result cards."""
276
+ def replace(match):
277
+ n = int(match.group(1))
278
+ if 1 <= n <= num_docs:
279
+ return (
280
+ f'<a href="javascript:void(0)" '
281
+ f'onclick="document.getElementById(\'result-{n}\').scrollIntoView({{behavior:\'smooth\'}})" '
282
+ f'style="color:var(--rust);font-weight:600;cursor:pointer;">[{n}]</a>'
283
+ )
284
+ return match.group(0)
285
+ import re
286
+ return re.sub(r'\[(\d+)\]', replace, text)
287
+
288
+
289
+ # ── Helper: format a RetrievedDocument into card fields ──────────────────────
290
+ def format_card(doc: RetrievedDocument) -> dict:
291
+ # Determine document type label
292
+ topics = doc.topics or []
293
+ title_lower = (doc.title or "").lower()
294
+
295
+ if any(t.lower() in ["photograph", "photography", "photographs"] for t in topics):
296
+ doc_type = "Photograph"
297
+ elif any(t.lower() in ["map", "maps", "cartography"] for t in topics):
298
+ doc_type = "Map"
299
+ elif any(w in title_lower for w in ["traveler", "globe", "herald", "gazette", "journal", "tribune"]):
300
+ doc_type = "Newspaper"
301
+ elif any(t.lower() in ["correspondence", "manuscript", "letter", "papers"] for t in topics):
302
+ doc_type = "Manuscript"
303
+ else:
304
+ doc_type = "Document"
305
+
306
+ # Date string
307
+ if doc.issue_date:
308
+ date_str = doc.issue_date
309
+ elif doc.year:
310
+ date_str = str(doc.year[0])
311
+ else:
312
+ date_str = "Date unknown"
313
+
314
+ snippet = doc.best_chunk_text[:300] if doc.best_chunk_text else ""
315
+ tags = list(set((doc.topics or []) + (doc.geography or [])))[:5]
316
+ if doc.best_chunk_text:
317
+ # Has chunk text = individual item
318
+ url = f"https://www.digitalcommonwealth.org/search/commonwealth:{doc.ark_id}"
319
+ else:
320
+ # No chunk text = collection-level metadata record
321
+ url = f"https://www.digitalcommonwealth.org/collections/commonwealth:{doc.ark_id}"
322
+
323
+ thumbnail_url = (
324
+ f"https://iiif.digitalcommonwealth.org/iiif/2/{doc.exemplary_image_id}/full/400,/0/default.jpg"
325
+ if doc.exemplary_image_id and doc.exemplary_image_id.strip() else ""
326
+ )
327
+ return {
328
+ "type": doc_type,
329
+ "title": doc.title or "Untitled",
330
+ "date": date_str,
331
+ "collection": doc.institution or "Boston Public Library",
332
+ "snippet": snippet,
333
+ "tags": tags,
334
+ "score": round(doc.final_score, 2),
335
+ "url": url,
336
+ "thumbnail": thumbnail_url
337
+ }
338
+
339
+
340
+ # ── Session state ─────────────────────────────────────────────────────────────
341
+ if "query" not in st.session_state:
342
+ st.session_state.query = ""
343
+ if "results" not in st.session_state:
344
+ st.session_state.results = None
345
+ if "searched" not in st.session_state:
346
+ st.session_state.searched = False
347
+ if "context" not in st.session_state:
348
+ st.session_state.context = ""
349
+ if "latency_ms" not in st.session_state:
350
+ st.session_state.latency_ms = 0
351
+
352
+
353
+ # ── Masthead ──────────────────────────────────────────────────────────────────
354
+ st.markdown("""
355
+ <div class="masthead">
356
+ <div class="masthead-eyebrow">Boston Public Library Β· Digital Commonwealth</div>
357
+ <h1 class="masthead-title">Search the <em>Archive</em></h1>
358
+ <p class="masthead-sub">
359
+ Ask anything in plain language β€” explore photographs, maps, newspapers,
360
+ manuscripts, and more from Massachusetts history.
361
+ </p>
362
+ </div>
363
+ """, unsafe_allow_html=True)
364
+
365
+ # ── Search box ────────────────────────────────────────────────────────────────
366
+ st.markdown('<div class="search-wrapper">', unsafe_allow_html=True)
367
+ st.markdown('<div class="search-label">Natural Language Query</div>', unsafe_allow_html=True)
368
+
369
+ col_input, col_btn = st.columns([5, 1])
370
+ with col_input:
371
+ query_input = st.text_input(
372
+ label="query",
373
+ label_visibility="collapsed",
374
+ placeholder='e.g. "Find photographs of Boston Harbor from the 1800s"',
375
+ value=st.session_state.query,
376
+ key="query_box",
377
+ )
378
+ with col_btn:
379
+ search_clicked = st.button("Search β†’", use_container_width=True, type="primary")
380
+
381
+ # Example query pills
382
+ st.markdown('<div class="search-label" style="margin-top:1rem;">Try an example</div>', unsafe_allow_html=True)
383
+ pill_cols = st.columns(3)
384
+ for i, example in enumerate(EXAMPLE_QUERIES):
385
+ with pill_cols[i % 3]:
386
+ if st.button(f'"{example}"', key=f"pill_{i}", use_container_width=True):
387
+ st.session_state.query = example
388
+ st.rerun()
389
+
390
+ st.markdown('</div>', unsafe_allow_html=True)
391
+
392
+ # ── Handle search ─────────────────────────────────────────────────────────────
393
+ active_query = st.session_state.query if st.session_state.query else query_input
394
+
395
+ if search_clicked and query_input.strip():
396
+ st.session_state.query = query_input.strip()
397
+ active_query = query_input.strip()
398
+
399
+ if active_query and (search_clicked or st.session_state.query):
400
+ with st.spinner("Searching the archive…"):
401
+ try:
402
+ result: PipelineResult = run_query(active_query)
403
+ cards = [format_card(doc) for doc in result.documents]
404
+ st.session_state.results = cards
405
+ st.session_state.context = result.generation.response
406
+ st.session_state.latency_ms = result.latency_ms
407
+ st.session_state.searched = True
408
+ except Exception as e:
409
+ st.error(f"Search failed: {e}")
410
+ st.session_state.searched = False
411
+
412
+ # ── Results ───────────────────────────────────────────────────────────────────
413
+ if st.session_state.searched and st.session_state.results is not None:
414
+ results = st.session_state.results
415
+ context = st.session_state.context
416
+ latency = st.session_state.latency_ms
417
+
418
+ st.markdown('<hr class="divider">', unsafe_allow_html=True)
419
+ if results:
420
+ # Context banner
421
+ context_with_links = linkify_citations(context, len(results))
422
+ st.markdown(f"""
423
+ <div class="context-banner">
424
+ <strong>About these results β€”</strong> {context_with_links}
425
+ </div>
426
+ """, unsafe_allow_html=True)
427
+
428
+ # Results header
429
+ st.markdown(f"""
430
+ <div class="results-header">
431
+ <div class="results-count">
432
+ Found <span>{len(results)} items</span> for "{st.session_state.query}"
433
+ </div>
434
+ <div class="results-meta">Ranked by relevance Β· {latency}ms Β· Digital Commonwealth BPL Subset</div>
435
+ </div>
436
+ """, unsafe_allow_html=True)
437
+
438
+ # Result cards
439
+ for i, r in enumerate(results, start=1):
440
+ score_pct = min(int(r["score"] * 100), 100)
441
+ bar_width = score_pct
442
+ tags_html = ''.join(f'<span class="card-tag">{t}</span>' for t in r["tags"])
443
+
444
+ thumbnail_html = (
445
+ f'<img src="{r["thumbnail"]}" style="width:100%;max-height:200px;object-fit:cover;border-radius:4px;margin-bottom:0.8rem;" />'
446
+ if r.get("thumbnail", "").startswith("https://") else "<div></div>"
447
+ )
448
+ # print(f"[debug] thumbnail: {r.get('thumbnail')!r}, thumbnail_html: {thumbnail_html!r}")
449
+
450
+ safe_snippet = html.escape(r["snippet"])
451
+ safe_title = html.escape(r["title"])
452
+
453
+ st.markdown(f"""
454
+ <div class="result-card" id="result-{i+1}">
455
+ {thumbnail_html}
456
+ <div class="card-type-badge">{r['type']}</div>
457
+ <div class="card-title">{safe_title}</div>
458
+ <div class="card-meta">
459
+ {r['date']} &nbsp;Β·&nbsp; {r['collection']}
460
+ </div>
461
+ <div class="card-snippet">{safe_snippet}</div>
462
+ <div class="card-tags">{tags_html}</div>
463
+ <div class="score-row">
464
+ <span class="score-label">Relevance</span>
465
+ <div class="score-bar-bg">
466
+ <div class="score-bar-fill" style="width:{bar_width}%"></div>
467
+ </div>
468
+ <span class="score-val">{score_pct}%</span>
469
+ &nbsp;&nbsp;
470
+ <a class="card-link" href="{r['url']}" target="_blank">View in Digital Commonwealth β†—</a>
471
+ </div>
472
+ </div>
473
+ """, unsafe_allow_html=True)
474
+
475
+ else:
476
+ st.markdown(f"""
477
+ <div class="no-results">
478
+ <div class="no-results-icon">πŸ—‚οΈ</div>
479
+ <div class="no-results-title">No matching materials found</div>
480
+ <p>Try rephrasing your query, or use one of the example searches above.<br/>
481
+ The full collection spans photographs, maps, newspapers, manuscripts, and more.</p>
482
+ </div>
483
+ """, unsafe_allow_html=True)
484
+
485
+ # ── Footer ────────────────────────────────────────────────────────────────────
486
+ st.markdown("""
487
+ <div class="bpl-footer">
488
+ <strong>Boston Public Library</strong> Β· Digital Commonwealth Β· BPL RAG Search<br>
489
+ A natural language search prototype built with Retrieval-Augmented Generation.<br>
490
+ Results are drawn from digitized items in the BPL subset of Digital Commonwealth.
491
+ </div>
492
+ """, unsafe_allow_html=True)
config.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Central configuration for the BPL RAG pipeline.
3
+ All tuneable constants live here β€” change here, affects everywhere.
4
+
5
+ Updated for HuggingFace deployment - supports both local .env and Streamlit secrets.
6
+ """
7
+
8
+ import os
9
+ from typing import Optional
10
+
11
+ # Try to load from .env file (for local development)
12
+ try:
13
+ from dotenv import load_dotenv
14
+ load_dotenv()
15
+ except:
16
+ pass
17
+
18
+ def get_secret(key: str, default: Optional[str] = None) -> str:
19
+ """
20
+ Get secret from environment or Streamlit secrets.
21
+ Works for both local development and HuggingFace deployment.
22
+
23
+ Priority:
24
+ 1. Environment variables (from .env or system)
25
+ 2. Streamlit secrets (on HuggingFace)
26
+ 3. Default value
27
+ """
28
+ # Try environment variable first
29
+ value = os.getenv(key)
30
+
31
+ if value:
32
+ return value
33
+
34
+ # Try Streamlit secrets (available on HuggingFace)
35
+ try:
36
+ import streamlit as st
37
+ if hasattr(st, 'secrets') and key in st.secrets:
38
+ return st.secrets[key]
39
+ except Exception:
40
+ pass
41
+
42
+ # Return default or raise error
43
+ if default is not None:
44
+ return default
45
+
46
+ raise ValueError(f"Secret '{key}' not found in environment or Streamlit secrets")
47
+
48
+ # ── OpenAI ────────────────────────────────────────────────────────────────────
49
+ OPENAI_API_KEY = get_secret("OPENAI_API_KEY")
50
+ OPENAI_CHAT_MODEL = "gpt-4o"
51
+
52
+ # ── BGE Embedding ─────────────────────────────────────────────────────────────
53
+ BGE_MODEL_NAME = "BAAI/bge-m3"
54
+ BGE_DEVICE = "cpu" # Changed from "cuda" for HuggingFace CPU deployment
55
+ BGE_BATCH_SIZE = 32 # Reduced from 128 for CPU
56
+
57
+ # ── Neo4j / GraphRAG ──────────────────────────────────────────────────────────
58
+ NEO4J_URI = get_secret("NEO4J_URI", "")
59
+ NEO4J_USER = get_secret("NEO4J_USER", "neo4j")
60
+ NEO4J_PASSWORD = get_secret("NEO4J_PASSWORD", "")
61
+
62
+ # GraphRAG is triggered only for content_driven queries
63
+ GRAPH_RAG_ENABLED = True
64
+ GRAPH_TOP_K = 100 # max additional docs from graph
65
+ GRAPH_MIN_ENTITY_MATCHES = 1 # min query entities a doc must match
66
+
67
+ # ── PostgreSQL / pgVector ─────────────────────────────────────────────────────
68
+ PG_HOST = get_secret("PG_HOST", "localhost")
69
+ PG_PORT = int(get_secret("PG_PORT", "5432"))
70
+ PG_DB = get_secret("PG_DB", "bpl_rag")
71
+ PG_USER = get_secret("PG_USER", "postgres")
72
+ PG_PASSWORD = get_secret("PG_PASSWORD", "")
73
+
74
+ PG_DSN = (
75
+ f"postgresql://{PG_USER}:{PG_PASSWORD}@{PG_HOST}:{PG_PORT}/{PG_DB}"
76
+ )
77
+
78
+ # ── Chunking ──────────────────────────────────────────────────────────────────
79
+ CHUNK_SIZE = 1024 # was 512 β€” BGE-M3 handles longer context well
80
+ CHUNK_OVERLAP = 150 # was 100 β€” proportionally larger overlap
81
+ CHUNK_TOKENIZER = "cl100k_base" # tiktoken encoding
82
+
83
+ # ── Retrieval ─────────────────────────────────────────────────────────────────
84
+ TOP_K_DENSE = 100 # candidates from vector search before rerank
85
+ TOP_K_BM25 = 100 # candidates from BM25
86
+ TOP_K_FINAL = 10 # results returned to the user
87
+ RRF_K = 60 # RRF constant (standard is 60)
88
+
89
+ # ── Metadata score blend weight ───────────────────────────────────────────────
90
+ # final_score = CONTENT_WEIGHT * content_rrf + METADATA_WEIGHT * metadata_sim
91
+ CONTENT_WEIGHT = 0.75
92
+ METADATA_WEIGHT = 0.25
93
+
94
+ # ── Ingestion ─────────────────────────────────────────────────────────────────
95
+ MIN_CHAR_COUNT = 100 # skip records with fewer chars of raw_text
96
+ JSON_DUMP_DIR = "data/raw" # folder containing local JSON dumps
97
+
98
+ # ── Generation ───────────────────────────────────────────────────────────────
99
+ MAX_CONTEXT_CHUNKS = 5 # how many chunks to pass to GPT-4o
100
+ GENERATION_MAX_TOKENS = 600
101
+
102
+ MIN_RELEVANCE_SCORE = 0.01 # documents below this are considered irrelevant
database/schema.py ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ database/schema.py
3
+
4
+ Creates all pgVector tables from scratch.
5
+ Run once: python -m database.schema
6
+ """
7
+
8
+ import psycopg2
9
+ from psycopg2.extras import RealDictCursor
10
+ from contextlib import contextmanager
11
+ from config import PG_DSN
12
+
13
+
14
+ @contextmanager
15
+ def get_conn():
16
+ conn = psycopg2.connect(PG_DSN)
17
+ try:
18
+ yield conn
19
+ conn.commit()
20
+ except Exception:
21
+ conn.rollback()
22
+ raise
23
+ finally:
24
+ conn.close()
25
+
26
+
27
+ @contextmanager
28
+ def get_cursor(conn):
29
+ cur = conn.cursor(cursor_factory=RealDictCursor)
30
+ try:
31
+ yield cur
32
+ finally:
33
+ cur.close()
34
+
35
+
36
+ DDL = """
37
+ CREATE EXTENSION IF NOT EXISTS vector;
38
+
39
+ -- ── Table 1: documents ───────────────────────────────────────────────────────
40
+ -- One row per source record.
41
+ -- Holds all metadata, dense metadata embedding, and sparse metadata embedding.
42
+
43
+ CREATE TABLE IF NOT EXISTS documents (
44
+ id BIGSERIAL PRIMARY KEY,
45
+
46
+ -- Identity
47
+ ark_id TEXT UNIQUE NOT NULL,
48
+ record_id TEXT,
49
+ source_url TEXT,
50
+ iiif_manifest TEXT,
51
+
52
+ -- Provenance
53
+ newspaper TEXT,
54
+ collection TEXT,
55
+ institution TEXT,
56
+
57
+ -- Bibliographic
58
+ title TEXT,
59
+ issue_date TEXT,
60
+ date_iso TEXT[],
61
+ date_start TIMESTAMPTZ,
62
+ year INT[],
63
+ publisher TEXT[],
64
+ place TEXT[],
65
+ language TEXT[],
66
+
67
+ -- Content signals
68
+ page_count INT,
69
+ pages TEXT[],
70
+ topics TEXT[],
71
+ geography TEXT[],
72
+ char_count INT,
73
+ genre TEXT[],
74
+ abstract TEXT,
75
+ exemplary_image_id TEXT,
76
+
77
+ -- Dense metadata embedding (BGE-M3 β†’ 1024 dims)
78
+ metadata_embedding VECTOR(1024),
79
+
80
+ -- Sparse metadata embedding (two-array format, more efficient than JSONB)
81
+ sparse_token_ids INTEGER[],
82
+ sparse_weights FLOAT4[],
83
+
84
+ -- Tracking
85
+ ingested_at TIMESTAMPTZ
86
+ );
87
+
88
+ -- HNSW index on metadata embedding (faster + smaller than ivfflat)
89
+ CREATE INDEX IF NOT EXISTS idx_documents_meta_emb
90
+ ON documents
91
+ USING hnsw (metadata_embedding vector_cosine_ops)
92
+ WITH (m = 16, ef_construction = 64);
93
+
94
+ CREATE INDEX IF NOT EXISTS idx_documents_ark ON documents (ark_id);
95
+ CREATE INDEX IF NOT EXISTS idx_documents_year ON documents USING GIN (year);
96
+ CREATE INDEX IF NOT EXISTS idx_documents_language ON documents USING GIN (language);
97
+ CREATE INDEX IF NOT EXISTS idx_documents_topics ON documents USING GIN (topics);
98
+ CREATE INDEX IF NOT EXISTS idx_documents_geo ON documents USING GIN (geography);
99
+ CREATE INDEX IF NOT EXISTS idx_documents_genre ON documents USING GIN (genre);
100
+
101
+
102
+ -- ── Table 2: chunks ──────────────────────────────────────────────────────────
103
+ -- One row per text chunk (512 tokens, 100 overlap).
104
+ -- No tsvector column β€” lexical search handled by BGE-M3 sparse instead.
105
+
106
+ CREATE TABLE IF NOT EXISTS chunks (
107
+ id BIGSERIAL PRIMARY KEY,
108
+ document_id BIGINT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
109
+ ark_id TEXT NOT NULL,
110
+
111
+ chunk_index INT NOT NULL,
112
+ chunk_text TEXT NOT NULL,
113
+
114
+ -- Dense full-text embedding (BGE-M3 β†’ 1024 dims)
115
+ text_embedding VECTOR(1024),
116
+
117
+ -- Sparse full-text embedding (two-array format)
118
+ sparse_token_ids INTEGER[],
119
+ sparse_weights FLOAT4[]
120
+ );
121
+
122
+ -- HNSW index on chunk text embedding
123
+ CREATE INDEX IF NOT EXISTS idx_chunks_text_emb
124
+ ON chunks
125
+ USING hnsw (text_embedding vector_cosine_ops)
126
+ WITH (m = 16, ef_construction = 64);
127
+
128
+ CREATE INDEX IF NOT EXISTS idx_chunks_document_id
129
+ ON chunks (document_id);
130
+
131
+
132
+ -- ── Table 3: query_logs ──────────────────────────────────────────────────────
133
+ -- Structured log of every query for evaluation and dashboarding.
134
+
135
+ CREATE TABLE IF NOT EXISTS query_logs (
136
+ id BIGSERIAL PRIMARY KEY,
137
+ queried_at TIMESTAMPTZ DEFAULT NOW(),
138
+
139
+ raw_query TEXT,
140
+ rewritten_query TEXT,
141
+ query_type TEXT,
142
+
143
+ filters JSONB,
144
+ retrieved_ark_ids TEXT[],
145
+ response TEXT,
146
+
147
+ relevancy_score FLOAT,
148
+ faithfulness_score FLOAT,
149
+ latency_ms INT
150
+ );
151
+ """
152
+
153
+
154
+ def create_schema():
155
+ print("Creating schema ...")
156
+ with get_conn() as conn:
157
+ with get_cursor(conn) as cur:
158
+ cur.execute(DDL)
159
+ print("Done. Tables: documents, chunks, query_logs")
160
+
161
+
162
+ if __name__ == "__main__":
163
+ create_schema()
embedding/embedder.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ from typing import List
3
+ import numpy as np
4
+ from config import BGE_MODEL_NAME, BGE_DEVICE, BGE_BATCH_SIZE
5
+ from FlagEmbedding import BGEM3FlagModel
6
+
7
+
8
+ class BGEEmbedder:
9
+
10
+ def __init__(self):
11
+ self._model = None
12
+
13
+ def _load(self):
14
+ if self._model is None:
15
+ print(f"Loading BGE-M3 model on {BGE_DEVICE}")
16
+ self._model = BGEM3FlagModel(
17
+ BGE_MODEL_NAME,
18
+ use_fp16=True,
19
+ device=BGE_DEVICE,
20
+ )
21
+
22
+ def encode_both(self, texts: List[str]) -> dict:
23
+ self._load()
24
+ texts = [t if t.strip() else " " for t in texts]
25
+ output = self._model.encode(
26
+ texts,
27
+ batch_size=BGE_BATCH_SIZE,
28
+ return_dense=True,
29
+ return_sparse=True,
30
+ return_colbert_vecs=False,
31
+ )
32
+ return {
33
+ "dense": output["dense_vecs"].astype(np.float32),
34
+ "sparse": output["lexical_weights"],
35
+ }
36
+
37
+ def embed(self, texts: List[str]) -> np.ndarray:
38
+ return self.encode_both(texts)["dense"]
39
+
40
+ def embed_sparse(self, texts: List[str]) -> List[dict]:
41
+ return self.encode_both(texts)["sparse"]
42
+
43
+ def embed_one(self, text: str, is_query: bool = False) -> np.ndarray:
44
+ if is_query:
45
+ text = f"Represent this sentence for searching relevant passages: {text}"
46
+ return self.encode_both([text])["dense"][0]
47
+
48
+ def embed_one_sparse(self, text: str, is_query: bool = False) -> dict:
49
+ return {}
50
+
51
+ def encode_one_both(self, text: str, is_query: bool = False) -> dict:
52
+ if is_query:
53
+ text = f"Represent this sentence for searching relevant passages: {text}"
54
+ output = self.encode_both([text])
55
+ sparse_list = output["sparse"]
56
+ return {
57
+ "dense": output["dense"][0],
58
+ "sparse": sparse_list[0] if sparse_list else {},
59
+ }
60
+
61
+ @staticmethod
62
+ def build_metadata_text(record: dict) -> str:
63
+ parts = []
64
+
65
+ if record.get("title"):
66
+ parts.append(f"Title: {record['title']}")
67
+
68
+ genres = record.get("genre") or []
69
+ if genres:
70
+ parts.append(f"Format: {', '.join(genres)}")
71
+
72
+ topics = record.get("topics") or []
73
+ if topics:
74
+ parts.append(f"Topics: {', '.join(topics)}")
75
+
76
+ geography = record.get("geography") or []
77
+ if geography:
78
+ parts.append(f"Geography: {', '.join(geography)}")
79
+
80
+ place = record.get("place") or []
81
+ if place:
82
+ parts.append(f"Place: {', '.join(place)}")
83
+
84
+ year = record.get("year") or []
85
+ if year:
86
+ parts.append(f"Year: {', '.join(str(y) for y in year)}")
87
+
88
+ collection = record.get("collection") or ""
89
+ if collection and collection != record.get("title"):
90
+ parts.append(f"Collection: {collection}")
91
+
92
+ # HTML already stripped at parse time, just truncate
93
+ abstract = record.get("abstract") or ""
94
+ if abstract:
95
+ parts.append(f"Description: {abstract}")
96
+
97
+ return " | ".join(parts)
98
+
99
+
100
+ embedder = BGEEmbedder()
evaluation/eval.py ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ evaluation/eval.py
3
+
4
+ Runs the full pipeline against test_queries.jsonl and computes retrieval metrics.
5
+
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import csv
12
+ import json
13
+ import sys
14
+ import traceback
15
+ from pathlib import Path
16
+ from typing import List
17
+
18
+ # ── Run from current_spring2026/ ──────────────────────────────────────────────
19
+ sys.path.insert(0, str(Path(__file__).parent.parent))
20
+
21
+ from pipeline import run_query, PipelineResult
22
+
23
+
24
+ # ── Metric helpers ────────────────────────────────────────────────────────────
25
+
26
+ def hit_at_k(retrieved: List[str], ground_truths: List[str], k: int) -> int:
27
+ return int(any(gt in retrieved[:k] for gt in ground_truths))
28
+
29
+
30
+ def reciprocal_rank(retrieved: List[str], ground_truths: List[str]) -> float:
31
+ for i, ark_id in enumerate(retrieved, start=1):
32
+ if ark_id in ground_truths:
33
+ return 1.0 / i
34
+ return 0.0
35
+
36
+
37
+ def recall_at_k(retrieved: List[str], ground_truths: List[str], k: int) -> float:
38
+ if not ground_truths:
39
+ return 0.0
40
+ hits = sum(1 for gt in ground_truths if gt in retrieved[:k])
41
+ return hits / len(ground_truths)
42
+
43
+
44
+ def precision_at_k(retrieved: List[str], ground_truths: List[str], k: int) -> float:
45
+ if k == 0:
46
+ return 0.0
47
+ hits = sum(1 for ark in retrieved[:k] if ark in ground_truths)
48
+ return hits / k
49
+
50
+
51
+ # ── Main ──────────────────────────────────────────────────────────────────────
52
+
53
+ def main():
54
+ parser = argparse.ArgumentParser()
55
+ parser.add_argument(
56
+ "--queries",
57
+ default=str(Path(__file__).parent.parent / "test_queries.jsonl"),
58
+ help="Path to test_queries.jsonl",
59
+ )
60
+ parser.add_argument(
61
+ "--out",
62
+ default=str(Path(__file__).parent / "eval_results.csv"),
63
+ help="Path to save per-query CSV results",
64
+ )
65
+ args = parser.parse_args()
66
+
67
+ K_VALUES = [10, 30, 50]
68
+ MAX_K = max(K_VALUES)
69
+
70
+ queries_path = Path(args.queries)
71
+ if not queries_path.exists():
72
+ print(f"ERROR: test queries file not found at {queries_path}")
73
+ sys.exit(1)
74
+
75
+ with open(queries_path) as f:
76
+ entries = [json.loads(line) for line in f if line.strip()]
77
+
78
+ print(f"Loaded {len(entries)} queries from {queries_path}")
79
+ print(f"Evaluating top-{K_VALUES} retrieved results\n")
80
+
81
+ rows = []
82
+
83
+ for i, entry in enumerate(entries):
84
+ question = entry["question"]
85
+ qtype = entry["question_type"]
86
+ ground_truths = [
87
+ g["ark_id"].removeprefix("commonwealth:")
88
+ for g in entry.get("ground_truths", [])
89
+ ]
90
+ reference_answer = entry.get("answer", "")
91
+
92
+ print(f"[{i+1:02d}/{len(entries)}] ({qtype}) {question[:70]}...")
93
+
94
+ try:
95
+ result: PipelineResult = run_query(question, top_k=MAX_K)
96
+ retrieved_ids = [doc.ark_id for doc in result.documents]
97
+
98
+ mrr = reciprocal_rank(retrieved_ids, ground_truths)
99
+
100
+ # Hallucination test: pipeline should return no docs (or say "no results")
101
+ if qtype == "hallucination_test":
102
+ hallucination_pass = int(
103
+ len(retrieved_ids) == 0
104
+ or "no relevant" in result.generation.response.lower()
105
+ or "not found" in result.generation.response.lower()
106
+ )
107
+ else:
108
+ hallucination_pass = ""
109
+
110
+ row = {
111
+ "question": question,
112
+ "question_type": qtype,
113
+ "classified_as": result.intent.query_type,
114
+ "rewritten_query": result.intent.rewritten_query,
115
+ "num_ground_truths": len(ground_truths),
116
+ "num_retrieved": len(retrieved_ids),
117
+ "mrr": round(mrr, 4),
118
+ "hallucination_pass": hallucination_pass,
119
+ "response_preview": result.generation.response[:150].replace("\n", " "),
120
+ "retrieved_ids": "|".join(retrieved_ids),
121
+ "ground_truth_ids": "|".join(ground_truths),
122
+ "latency_ms": result.latency_ms,
123
+ "error": "",
124
+ }
125
+ for k in K_VALUES:
126
+ row[f"hit_at_{k}"] = hit_at_k(retrieved_ids, ground_truths, k)
127
+ row[f"recall_at_{k}"] = round(recall_at_k(retrieved_ids, ground_truths, k), 4)
128
+ row[f"precision_at_{k}"] = round(precision_at_k(retrieved_ids, ground_truths, k), 4)
129
+
130
+ status = " " + " ".join(f"hit@{k}={row[f'hit_at_{k}']}" for k in K_VALUES)
131
+ status += f" mrr={mrr:.3f}"
132
+ if qtype == "hallucination_test":
133
+ status += f" hallucination_pass={hallucination_pass}"
134
+ print(status)
135
+
136
+ except Exception as e:
137
+ traceback.print_exc()
138
+ print(f" ERROR: {e}")
139
+ row = {
140
+ "question": question,
141
+ "question_type": qtype,
142
+ "classified_as": "",
143
+ "rewritten_query": "",
144
+ "num_ground_truths": len(ground_truths),
145
+ "num_retrieved": 0,
146
+ "mrr": "",
147
+ "hallucination_pass": "",
148
+ "response_preview": "",
149
+ "retrieved_ids": "",
150
+ "ground_truth_ids": "|".join(ground_truths),
151
+ "latency_ms": "",
152
+ "error": str(e),
153
+ }
154
+ for k in K_VALUES:
155
+ row[f"hit_at_{k}"] = ""
156
+ row[f"recall_at_{k}"] = ""
157
+ row[f"precision_at_{k}"] = ""
158
+
159
+ rows.append(row)
160
+
161
+ # ── Save CSV ──────────────────────────────────────────────────────────────
162
+ out_path = Path(args.out)
163
+ out_path.parent.mkdir(parents=True, exist_ok=True)
164
+ fieldnames = list(rows[0].keys())
165
+ with open(out_path, "w", newline="", encoding="utf-8") as f:
166
+ writer = csv.DictWriter(f, fieldnames=fieldnames)
167
+ writer.writeheader()
168
+ writer.writerows(rows)
169
+ print(f"\nPer-query results saved to {out_path}")
170
+
171
+ # ── Summary by query type ─────────────────────────────────────────────────
172
+ print("\n" + "=" * 55)
173
+ print("SUMMARY")
174
+ print("=" * 55)
175
+
176
+ summary_rows = []
177
+
178
+ for qtype in ["metadata", "full_text", "hallucination_test"]:
179
+ subset = [r for r in rows if r["question_type"] == qtype and r["mrr"] != ""]
180
+ if not subset:
181
+ continue
182
+
183
+ n = len(subset)
184
+ avg = lambda key: sum(r[key] for r in subset) / n # noqa: E731
185
+
186
+ print(f"\n{qtype} (n={n})")
187
+ print(f" MRR : {avg('mrr'):.3f}")
188
+
189
+ summary_row = {
190
+ "question_type": qtype,
191
+ "n": n,
192
+ "mrr": round(avg("mrr"), 4),
193
+ "hallucination_pass_rate": "",
194
+ }
195
+ for k in K_VALUES:
196
+ print(f" Hit@{k:<2} : {avg(f'hit_at_{k}'):.3f}")
197
+ print(f" Recall@{k:<2} : {avg(f'recall_at_{k}'):.3f}")
198
+ print(f" Precision@{k:<2} : {avg(f'precision_at_{k}'):.3f}")
199
+ summary_row[f"hit_at_{k}"] = round(avg(f"hit_at_{k}"), 4)
200
+ summary_row[f"recall_at_{k}"] = round(avg(f"recall_at_{k}"), 4)
201
+ summary_row[f"precision_at_{k}"] = round(avg(f"precision_at_{k}"), 4)
202
+
203
+ if qtype == "hallucination_test":
204
+ hall_subset = [r for r in rows if r["question_type"] == qtype and r["hallucination_pass"] != ""]
205
+ if hall_subset:
206
+ pass_rate = sum(r["hallucination_pass"] for r in hall_subset) / len(hall_subset)
207
+ print(f" Hallucination pass : {pass_rate:.3f}")
208
+ summary_row["hallucination_pass_rate"] = round(pass_rate, 4)
209
+
210
+ summary_rows.append(summary_row)
211
+
212
+ errors = [r for r in rows if r["error"]]
213
+ if errors:
214
+ print(f"\nFailed queries: {len(errors)}")
215
+ for r in errors:
216
+ print(f" - {r['question'][:60]}: {r['error']}")
217
+
218
+ print()
219
+
220
+ # ── Save summary CSV ──────────────────────────────────────────────────────
221
+ if summary_rows:
222
+ summary_path = out_path.with_name(out_path.stem + "_summary.csv")
223
+ summary_fieldnames = list(summary_rows[0].keys())
224
+ with open(summary_path, "w", newline="", encoding="utf-8") as f:
225
+ writer = csv.DictWriter(f, fieldnames=summary_fieldnames)
226
+ writer.writeheader()
227
+ writer.writerows(summary_rows)
228
+ print(f"Summary results saved to {summary_path}")
229
+
230
+
231
+ if __name__ == "__main__":
232
+ main()
evaluation/eval_graphrag.py ADDED
@@ -0,0 +1,283 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ evaluation/eval_graphrag.py
3
+
4
+ Evaluates two isolated retrieval systems on ALL queries:
5
+ System 1 β€” Dense + Sparse only
6
+ System 2 β€” GraphRAG only
7
+
8
+ No filtering β€” every query is evaluated against both systems.
9
+ Summary broken down by use_graph=True vs use_graph=False.
10
+
11
+ Metrics: Hit@K, Recall@K, Precision@K for K = 5, 10, 20, 30, 50
12
+
13
+ Run:
14
+ python -m evaluation.eval_graphrag
15
+ python -m evaluation.eval_graphrag --queries test_queries.jsonl --out evaluation/graphrag_results.json
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import json
22
+ import sys
23
+ from pathlib import Path
24
+ from typing import List
25
+
26
+ import numpy as np
27
+
28
+ sys.path.insert(0, str(Path(__file__).parent.parent))
29
+
30
+ from retrieval.query_understanding import classify_query
31
+ from retrieval.retriever import retrieve
32
+ from graph.graph_retriever import retrieve_by_query
33
+ from embedding.embedder import embedder
34
+ from config import GRAPH_TOP_K
35
+
36
+
37
+
38
+ K_VALUES = [5, 10, 20, 30, 50]
39
+ MAX_K = max(K_VALUES)
40
+
41
+
42
+ # ── Metric helpers ────────────────────────────────────────────────────────────
43
+
44
+ def hit_at_k(retrieved: List[str], ground_truths: List[str], k: int) -> int:
45
+ return int(any(gt in retrieved[:k] for gt in ground_truths))
46
+
47
+
48
+ def recall_at_k(retrieved: List[str], ground_truths: List[str], k: int) -> float:
49
+ if not ground_truths:
50
+ return 0.0
51
+ hits = sum(1 for gt in ground_truths if gt in retrieved[:k])
52
+ return hits / len(ground_truths)
53
+
54
+
55
+ def precision_at_k(retrieved: List[str], ground_truths: List[str], k: int) -> float:
56
+ if k == 0:
57
+ return 0.0
58
+ hits = sum(1 for ark in retrieved[:k] if ark in ground_truths)
59
+ return hits / k
60
+
61
+
62
+ def compute_metrics(retrieved: List[str], ground_truths: List[str]) -> dict:
63
+ metrics = {}
64
+ for k in K_VALUES:
65
+ metrics[f"hit_{k}"] = hit_at_k(retrieved, ground_truths, k)
66
+ metrics[f"recall_{k}"] = round(recall_at_k(retrieved, ground_truths, k), 4)
67
+ metrics[f"precision_{k}"] = round(precision_at_k(retrieved, ground_truths, k), 4)
68
+ return metrics
69
+
70
+
71
+ # ── GraphRAG-only retrieval ───────────────────────────────────────────────────
72
+
73
+ def retrieve_graph_only(query_emb: np.ndarray, top_k: int = MAX_K) -> List[str]:
74
+ """Run GraphRAG retrieval only β€” no dense or sparse search."""
75
+ graph_results = retrieve_by_query(
76
+ query_embedding = query_emb,
77
+ exclude_ark_ids = set(),
78
+ top_k = top_k,
79
+ )
80
+ return [r.ark_id for r in graph_results] if graph_results else []
81
+
82
+
83
+ # ── Main ──────────────────────────────────────────────────────────────────────
84
+
85
+ def main():
86
+ parser = argparse.ArgumentParser(description="GraphRAG vs Dense+Sparse evaluation")
87
+ parser.add_argument(
88
+ "--queries",
89
+ default=str(Path(__file__).parent.parent / "test_queries.jsonl"),
90
+ )
91
+ parser.add_argument(
92
+ "--out",
93
+ default=str(Path(__file__).parent / "graphrag_results.json"),
94
+ )
95
+ args = parser.parse_args()
96
+
97
+ queries_path = Path(args.queries)
98
+ if not queries_path.exists():
99
+ print(f"ERROR: test queries file not found at {queries_path}")
100
+ sys.exit(1)
101
+
102
+ with open(queries_path) as f:
103
+ all_entries = [json.loads(line) for line in f if line.strip()]
104
+
105
+ print(f"Loaded {len(all_entries)} queries\n")
106
+
107
+ # ── Classify all queries ──────────────────────────────────────────────────
108
+ print("Classifying all queries...")
109
+ classified = []
110
+ for entry in all_entries:
111
+ intent = classify_query(entry["question"])
112
+ classified.append((entry, intent))
113
+ print(f" Classified {len(classified)} queries")
114
+ print(f" use_graph=True : {sum(1 for _, i in classified if i.use_graph)}")
115
+ print(f" use_graph=False : {sum(1 for _, i in classified if not i.use_graph)}")
116
+ print(f"\nEvaluating at K = {K_VALUES}\n")
117
+
118
+ rows = []
119
+
120
+ for i, (entry, intent) in enumerate(classified):
121
+ question = entry["question"]
122
+ qtype = entry["question_type"]
123
+ ground_truths = [
124
+ g["ark_id"].removeprefix("commonwealth:")
125
+ for g in entry.get("ground_truths", [])
126
+ ]
127
+
128
+ print(f"[{i+1:02d}/{len(classified)}] {question[:70]}...")
129
+ print(f" use_graph={intent.use_graph} | rewritten='{intent.rewritten_query[:60]}'")
130
+
131
+ try:
132
+ # ── System 1: Dense + Sparse ───────────────────────────────────
133
+ # retrieve() returns (documents, query_emb) β€” unpack both
134
+ dense_docs, query_emb = retrieve(intent, top_k=MAX_K)
135
+ dense_ids = [d.ark_id for d in dense_docs]
136
+
137
+ # ── System 2: GraphRAG only ────────────────────────────────────
138
+ # Reuse query_emb from dense retrieval β€” no re-embedding
139
+ graph_ids = retrieve_graph_only(query_emb, top_k=MAX_K)
140
+
141
+ # ── Compute metrics ────────────────────────────────────────────
142
+ dense_metrics = compute_metrics(dense_ids, ground_truths)
143
+ graph_metrics = compute_metrics(graph_ids, ground_truths)
144
+
145
+ # ── Build row ──────────────────────────────────────────────────
146
+ row = {
147
+ "question": question,
148
+ "question_type": qtype,
149
+ "rewritten_query": intent.rewritten_query,
150
+ "use_graph": intent.use_graph,
151
+ "num_ground_truths": len(ground_truths),
152
+ "dense_retrieved": len(dense_ids),
153
+ "graph_retrieved": len(graph_ids),
154
+ "ground_truth_ids": ground_truths,
155
+ "dense_ids": dense_ids,
156
+ "graph_ids": graph_ids,
157
+ "error": "",
158
+ }
159
+
160
+ for k in K_VALUES:
161
+ row[f"dense_hit_{k}"] = dense_metrics[f"hit_{k}"]
162
+ row[f"dense_recall_{k}"] = dense_metrics[f"recall_{k}"]
163
+ row[f"dense_precision_{k}"] = dense_metrics[f"precision_{k}"]
164
+ row[f"graph_hit_{k}"] = graph_metrics[f"hit_{k}"]
165
+ row[f"graph_recall_{k}"] = graph_metrics[f"recall_{k}"]
166
+ row[f"graph_precision_{k}"] = graph_metrics[f"precision_{k}"]
167
+ row[f"hit_delta_{k}"] = graph_metrics[f"hit_{k}"] - dense_metrics[f"hit_{k}"]
168
+ row[f"recall_delta_{k}"] = round(graph_metrics[f"recall_{k}"] - dense_metrics[f"recall_{k}"], 4)
169
+ row[f"precision_delta_{k}"] = round(graph_metrics[f"precision_{k}"] - dense_metrics[f"precision_{k}"], 4)
170
+
171
+ for k in [10, 30]:
172
+ d = dense_metrics[f"hit_{k}"]
173
+ g = graph_metrics[f"hit_{k}"]
174
+ print(f" hit@{k}: dense={d} graph={g} Ξ”={g-d:+d}")
175
+
176
+ except Exception as e:
177
+ print(f" ERROR: {e}")
178
+ row = {
179
+ "question": question,
180
+ "question_type": qtype,
181
+ "rewritten_query": intent.rewritten_query,
182
+ "use_graph": intent.use_graph,
183
+ "num_ground_truths": len(ground_truths),
184
+ "dense_retrieved": 0,
185
+ "graph_retrieved": 0,
186
+ "ground_truth_ids": ground_truths,
187
+ "dense_ids": [],
188
+ "graph_ids": [],
189
+ "error": str(e),
190
+ }
191
+ for k in K_VALUES:
192
+ for prefix in ["dense", "graph"]:
193
+ row[f"{prefix}_hit_{k}"] = ""
194
+ row[f"{prefix}_recall_{k}"] = ""
195
+ row[f"{prefix}_precision_{k}"] = ""
196
+ row[f"hit_delta_{k}"] = ""
197
+ row[f"recall_delta_{k}"] = ""
198
+ row[f"precision_delta_{k}"] = ""
199
+
200
+ rows.append(row)
201
+
202
+ # ── Save JSON ─────────────────────────────────────────────────────────────
203
+ out_path = Path(args.out)
204
+ out_path.parent.mkdir(parents=True, exist_ok=True)
205
+
206
+ valid = [r for r in rows if r["error"] == ""]
207
+ errors = [r for r in rows if r["error"] != ""]
208
+
209
+ def avg(key, subset):
210
+ vals = [r[key] for r in subset if r[key] != ""]
211
+ return round(sum(vals) / len(vals), 4) if vals else 0.0
212
+
213
+ def summary_for(subset):
214
+ if not subset:
215
+ return {}
216
+ return {
217
+ f"hit@{k}": {"dense": avg(f"dense_hit_{k}", subset), "graph": avg(f"graph_hit_{k}", subset), "delta": round(avg(f"graph_hit_{k}", subset) - avg(f"dense_hit_{k}", subset), 4)}
218
+ for k in K_VALUES
219
+ } | {
220
+ f"recall@{k}": {"dense": avg(f"dense_recall_{k}", subset), "graph": avg(f"graph_recall_{k}", subset), "delta": round(avg(f"graph_recall_{k}", subset) - avg(f"dense_recall_{k}", subset), 4)}
221
+ for k in K_VALUES
222
+ } | {
223
+ f"precision@{k}": {"dense": avg(f"dense_precision_{k}", subset), "graph": avg(f"graph_precision_{k}", subset), "delta": round(avg(f"graph_precision_{k}", subset) - avg(f"dense_precision_{k}", subset), 4)}
224
+ for k in K_VALUES
225
+ }
226
+
227
+ graph_queries = [r for r in valid if r["use_graph"]]
228
+ no_graph_queries = [r for r in valid if not r["use_graph"]]
229
+
230
+ output = {
231
+ "metadata": {
232
+ "total_queries": len(rows),
233
+ "valid_queries": len(valid),
234
+ "failed_queries": len(errors),
235
+ "use_graph_true": len(graph_queries),
236
+ "use_graph_false": len(no_graph_queries),
237
+ "k_values": K_VALUES,
238
+ },
239
+ "summary": {
240
+ "all_queries": summary_for(valid),
241
+ "use_graph_true": summary_for(graph_queries),
242
+ "use_graph_false": summary_for(no_graph_queries),
243
+ },
244
+ "results": rows,
245
+ }
246
+
247
+ with open(out_path, "w", encoding="utf-8") as f:
248
+ json.dump(output, f, indent=2)
249
+ print(f"\nResults saved to {out_path}")
250
+
251
+ # ── Print summary ─────────────────────────────────────────────────────────
252
+ for label, subset in [
253
+ ("ALL QUERIES", valid),
254
+ ("use_graph=True", graph_queries),
255
+ ("use_graph=False", no_graph_queries),
256
+ ]:
257
+ n_sub = len(subset)
258
+ if n_sub == 0:
259
+ continue
260
+ print(f"\n{'='*70}")
261
+ print(f"{label} (n={n_sub})")
262
+ print(f"{'='*70}")
263
+ print(f"\n{'K':<6} {'Dense Hit':>10} {'Graph Hit':>10} {'Ξ” Hit':>8} {'Dense Rec':>10} {'Graph Rec':>10} {'Ξ” Rec':>8}")
264
+ print("-" * 70)
265
+ for k in K_VALUES:
266
+ dh = avg(f"dense_hit_{k}", subset)
267
+ gh = avg(f"graph_hit_{k}", subset)
268
+ dr = avg(f"dense_recall_{k}", subset)
269
+ gr = avg(f"graph_recall_{k}", subset)
270
+ print(
271
+ f"{k:<6} {dh:>10.3f} {gh:>10.3f} {gh-dh:>+8.3f} "
272
+ f"{dr:>10.3f} {gr:>10.3f} {gr-dr:>+8.3f}"
273
+ )
274
+
275
+ if errors:
276
+ print(f"\nFailed queries: {len(errors)}")
277
+ for r in errors:
278
+ print(f" - {r['question'][:60]}: {r['error']}")
279
+ print()
280
+
281
+
282
+ if __name__ == "__main__":
283
+ main()
evaluation/graphrag_results.json ADDED
The diff for this file is too large to render. See raw diff
 
evaluation/graphrag_results_spacy.json ADDED
@@ -0,0 +1,3226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "metadata": {
3
+ "total_queries": 51,
4
+ "valid_queries": 0,
5
+ "failed_queries": 51,
6
+ "use_graph_true": 0,
7
+ "use_graph_false": 0,
8
+ "k_values": [
9
+ 5,
10
+ 10,
11
+ 20,
12
+ 30,
13
+ 50
14
+ ]
15
+ },
16
+ "summary": {
17
+ "all_queries": {},
18
+ "use_graph_true": {},
19
+ "use_graph_false": {}
20
+ },
21
+ "results": [
22
+ {
23
+ "question": "I want to see a variety of depictions of indigenous Americans from across the United States.",
24
+ "question_type": "metadata",
25
+ "rewritten_query": "depictions of indigenous Americans United States",
26
+ "use_graph": false,
27
+ "num_ground_truths": 4,
28
+ "dense_retrieved": 0,
29
+ "graph_retrieved": 0,
30
+ "ground_truth_ids": [
31
+ "70796j511",
32
+ "8g84ms67v",
33
+ "70796j910",
34
+ "1v53kk250"
35
+ ],
36
+ "dense_ids": [],
37
+ "graph_ids": [],
38
+ "error": "list index out of range",
39
+ "dense_hit_5": "",
40
+ "dense_recall_5": "",
41
+ "dense_precision_5": "",
42
+ "graph_hit_5": "",
43
+ "graph_recall_5": "",
44
+ "graph_precision_5": "",
45
+ "hit_delta_5": "",
46
+ "recall_delta_5": "",
47
+ "precision_delta_5": "",
48
+ "dense_hit_10": "",
49
+ "dense_recall_10": "",
50
+ "dense_precision_10": "",
51
+ "graph_hit_10": "",
52
+ "graph_recall_10": "",
53
+ "graph_precision_10": "",
54
+ "hit_delta_10": "",
55
+ "recall_delta_10": "",
56
+ "precision_delta_10": "",
57
+ "dense_hit_20": "",
58
+ "dense_recall_20": "",
59
+ "dense_precision_20": "",
60
+ "graph_hit_20": "",
61
+ "graph_recall_20": "",
62
+ "graph_precision_20": "",
63
+ "hit_delta_20": "",
64
+ "recall_delta_20": "",
65
+ "precision_delta_20": "",
66
+ "dense_hit_30": "",
67
+ "dense_recall_30": "",
68
+ "dense_precision_30": "",
69
+ "graph_hit_30": "",
70
+ "graph_recall_30": "",
71
+ "graph_precision_30": "",
72
+ "hit_delta_30": "",
73
+ "recall_delta_30": "",
74
+ "precision_delta_30": "",
75
+ "dense_hit_50": "",
76
+ "dense_recall_50": "",
77
+ "dense_precision_50": "",
78
+ "graph_hit_50": "",
79
+ "graph_recall_50": "",
80
+ "graph_precision_50": "",
81
+ "hit_delta_50": "",
82
+ "recall_delta_50": "",
83
+ "precision_delta_50": ""
84
+ },
85
+ {
86
+ "question": "Find pictures of JFK before he became president.",
87
+ "question_type": "metadata",
88
+ "rewritten_query": "John F. Kennedy photographs before presidency",
89
+ "use_graph": false,
90
+ "num_ground_truths": 4,
91
+ "dense_retrieved": 0,
92
+ "graph_retrieved": 0,
93
+ "ground_truth_ids": [
94
+ "7w62g495q",
95
+ "7w62gc97q",
96
+ "7w62g5435",
97
+ "7w62gb97h"
98
+ ],
99
+ "dense_ids": [],
100
+ "graph_ids": [],
101
+ "error": "list index out of range",
102
+ "dense_hit_5": "",
103
+ "dense_recall_5": "",
104
+ "dense_precision_5": "",
105
+ "graph_hit_5": "",
106
+ "graph_recall_5": "",
107
+ "graph_precision_5": "",
108
+ "hit_delta_5": "",
109
+ "recall_delta_5": "",
110
+ "precision_delta_5": "",
111
+ "dense_hit_10": "",
112
+ "dense_recall_10": "",
113
+ "dense_precision_10": "",
114
+ "graph_hit_10": "",
115
+ "graph_recall_10": "",
116
+ "graph_precision_10": "",
117
+ "hit_delta_10": "",
118
+ "recall_delta_10": "",
119
+ "precision_delta_10": "",
120
+ "dense_hit_20": "",
121
+ "dense_recall_20": "",
122
+ "dense_precision_20": "",
123
+ "graph_hit_20": "",
124
+ "graph_recall_20": "",
125
+ "graph_precision_20": "",
126
+ "hit_delta_20": "",
127
+ "recall_delta_20": "",
128
+ "precision_delta_20": "",
129
+ "dense_hit_30": "",
130
+ "dense_recall_30": "",
131
+ "dense_precision_30": "",
132
+ "graph_hit_30": "",
133
+ "graph_recall_30": "",
134
+ "graph_precision_30": "",
135
+ "hit_delta_30": "",
136
+ "recall_delta_30": "",
137
+ "precision_delta_30": "",
138
+ "dense_hit_50": "",
139
+ "dense_recall_50": "",
140
+ "dense_precision_50": "",
141
+ "graph_hit_50": "",
142
+ "graph_recall_50": "",
143
+ "graph_precision_50": "",
144
+ "hit_delta_50": "",
145
+ "recall_delta_50": "",
146
+ "precision_delta_50": ""
147
+ },
148
+ {
149
+ "question": "I have to do a project about the battle of Bunker Hill during the American Revolution, can you help me find some information about that?",
150
+ "question_type": "metadata",
151
+ "rewritten_query": "Battle of Bunker Hill American Revolution",
152
+ "use_graph": true,
153
+ "num_ground_truths": 4,
154
+ "dense_retrieved": 0,
155
+ "graph_retrieved": 0,
156
+ "ground_truth_ids": [
157
+ "3f462x95q",
158
+ "wd376807m",
159
+ "nc587w492",
160
+ "8c97n816r"
161
+ ],
162
+ "dense_ids": [],
163
+ "graph_ids": [],
164
+ "error": "list index out of range",
165
+ "dense_hit_5": "",
166
+ "dense_recall_5": "",
167
+ "dense_precision_5": "",
168
+ "graph_hit_5": "",
169
+ "graph_recall_5": "",
170
+ "graph_precision_5": "",
171
+ "hit_delta_5": "",
172
+ "recall_delta_5": "",
173
+ "precision_delta_5": "",
174
+ "dense_hit_10": "",
175
+ "dense_recall_10": "",
176
+ "dense_precision_10": "",
177
+ "graph_hit_10": "",
178
+ "graph_recall_10": "",
179
+ "graph_precision_10": "",
180
+ "hit_delta_10": "",
181
+ "recall_delta_10": "",
182
+ "precision_delta_10": "",
183
+ "dense_hit_20": "",
184
+ "dense_recall_20": "",
185
+ "dense_precision_20": "",
186
+ "graph_hit_20": "",
187
+ "graph_recall_20": "",
188
+ "graph_precision_20": "",
189
+ "hit_delta_20": "",
190
+ "recall_delta_20": "",
191
+ "precision_delta_20": "",
192
+ "dense_hit_30": "",
193
+ "dense_recall_30": "",
194
+ "dense_precision_30": "",
195
+ "graph_hit_30": "",
196
+ "graph_recall_30": "",
197
+ "graph_precision_30": "",
198
+ "hit_delta_30": "",
199
+ "recall_delta_30": "",
200
+ "precision_delta_30": "",
201
+ "dense_hit_50": "",
202
+ "dense_recall_50": "",
203
+ "dense_precision_50": "",
204
+ "graph_hit_50": "",
205
+ "graph_recall_50": "",
206
+ "graph_precision_50": "",
207
+ "hit_delta_50": "",
208
+ "recall_delta_50": "",
209
+ "precision_delta_50": ""
210
+ },
211
+ {
212
+ "question": "What were some important historical events that happened in Boston in 1919?",
213
+ "question_type": "metadata",
214
+ "rewritten_query": "important historical events in Boston 1919",
215
+ "use_graph": false,
216
+ "num_ground_truths": 4,
217
+ "dense_retrieved": 0,
218
+ "graph_retrieved": 0,
219
+ "ground_truth_ids": [
220
+ "2j62s563b",
221
+ "5h73vx18p",
222
+ "5h73qh68q",
223
+ "x920px83j"
224
+ ],
225
+ "dense_ids": [],
226
+ "graph_ids": [],
227
+ "error": "list index out of range",
228
+ "dense_hit_5": "",
229
+ "dense_recall_5": "",
230
+ "dense_precision_5": "",
231
+ "graph_hit_5": "",
232
+ "graph_recall_5": "",
233
+ "graph_precision_5": "",
234
+ "hit_delta_5": "",
235
+ "recall_delta_5": "",
236
+ "precision_delta_5": "",
237
+ "dense_hit_10": "",
238
+ "dense_recall_10": "",
239
+ "dense_precision_10": "",
240
+ "graph_hit_10": "",
241
+ "graph_recall_10": "",
242
+ "graph_precision_10": "",
243
+ "hit_delta_10": "",
244
+ "recall_delta_10": "",
245
+ "precision_delta_10": "",
246
+ "dense_hit_20": "",
247
+ "dense_recall_20": "",
248
+ "dense_precision_20": "",
249
+ "graph_hit_20": "",
250
+ "graph_recall_20": "",
251
+ "graph_precision_20": "",
252
+ "hit_delta_20": "",
253
+ "recall_delta_20": "",
254
+ "precision_delta_20": "",
255
+ "dense_hit_30": "",
256
+ "dense_recall_30": "",
257
+ "dense_precision_30": "",
258
+ "graph_hit_30": "",
259
+ "graph_recall_30": "",
260
+ "graph_precision_30": "",
261
+ "hit_delta_30": "",
262
+ "recall_delta_30": "",
263
+ "precision_delta_30": "",
264
+ "dense_hit_50": "",
265
+ "dense_recall_50": "",
266
+ "dense_precision_50": "",
267
+ "graph_hit_50": "",
268
+ "graph_recall_50": "",
269
+ "graph_precision_50": "",
270
+ "hit_delta_50": "",
271
+ "recall_delta_50": "",
272
+ "precision_delta_50": ""
273
+ },
274
+ {
275
+ "question": "What caused the Great Fire of Boston in 1872?",
276
+ "question_type": "metadata",
277
+ "rewritten_query": "causes of the Great Fire of Boston 1872",
278
+ "use_graph": true,
279
+ "num_ground_truths": 4,
280
+ "dense_retrieved": 0,
281
+ "graph_retrieved": 0,
282
+ "ground_truth_ids": [
283
+ "xp68kr375",
284
+ "xp68kr35m",
285
+ "xp68kr53j",
286
+ "c821gq65v"
287
+ ],
288
+ "dense_ids": [],
289
+ "graph_ids": [],
290
+ "error": "list index out of range",
291
+ "dense_hit_5": "",
292
+ "dense_recall_5": "",
293
+ "dense_precision_5": "",
294
+ "graph_hit_5": "",
295
+ "graph_recall_5": "",
296
+ "graph_precision_5": "",
297
+ "hit_delta_5": "",
298
+ "recall_delta_5": "",
299
+ "precision_delta_5": "",
300
+ "dense_hit_10": "",
301
+ "dense_recall_10": "",
302
+ "dense_precision_10": "",
303
+ "graph_hit_10": "",
304
+ "graph_recall_10": "",
305
+ "graph_precision_10": "",
306
+ "hit_delta_10": "",
307
+ "recall_delta_10": "",
308
+ "precision_delta_10": "",
309
+ "dense_hit_20": "",
310
+ "dense_recall_20": "",
311
+ "dense_precision_20": "",
312
+ "graph_hit_20": "",
313
+ "graph_recall_20": "",
314
+ "graph_precision_20": "",
315
+ "hit_delta_20": "",
316
+ "recall_delta_20": "",
317
+ "precision_delta_20": "",
318
+ "dense_hit_30": "",
319
+ "dense_recall_30": "",
320
+ "dense_precision_30": "",
321
+ "graph_hit_30": "",
322
+ "graph_recall_30": "",
323
+ "graph_precision_30": "",
324
+ "hit_delta_30": "",
325
+ "recall_delta_30": "",
326
+ "precision_delta_30": "",
327
+ "dense_hit_50": "",
328
+ "dense_recall_50": "",
329
+ "dense_precision_50": "",
330
+ "graph_hit_50": "",
331
+ "graph_recall_50": "",
332
+ "graph_precision_50": "",
333
+ "hit_delta_50": "",
334
+ "recall_delta_50": "",
335
+ "precision_delta_50": ""
336
+ },
337
+ {
338
+ "question": "Find postcards of Cape Cod from the early 1900s",
339
+ "question_type": "metadata",
340
+ "rewritten_query": "postcards of Cape Cod",
341
+ "use_graph": false,
342
+ "num_ground_truths": 4,
343
+ "dense_retrieved": 0,
344
+ "graph_retrieved": 0,
345
+ "ground_truth_ids": [
346
+ "0k225t71f",
347
+ "cn69mp144",
348
+ "cn69mp02b",
349
+ "0k225t45t"
350
+ ],
351
+ "dense_ids": [],
352
+ "graph_ids": [],
353
+ "error": "list index out of range",
354
+ "dense_hit_5": "",
355
+ "dense_recall_5": "",
356
+ "dense_precision_5": "",
357
+ "graph_hit_5": "",
358
+ "graph_recall_5": "",
359
+ "graph_precision_5": "",
360
+ "hit_delta_5": "",
361
+ "recall_delta_5": "",
362
+ "precision_delta_5": "",
363
+ "dense_hit_10": "",
364
+ "dense_recall_10": "",
365
+ "dense_precision_10": "",
366
+ "graph_hit_10": "",
367
+ "graph_recall_10": "",
368
+ "graph_precision_10": "",
369
+ "hit_delta_10": "",
370
+ "recall_delta_10": "",
371
+ "precision_delta_10": "",
372
+ "dense_hit_20": "",
373
+ "dense_recall_20": "",
374
+ "dense_precision_20": "",
375
+ "graph_hit_20": "",
376
+ "graph_recall_20": "",
377
+ "graph_precision_20": "",
378
+ "hit_delta_20": "",
379
+ "recall_delta_20": "",
380
+ "precision_delta_20": "",
381
+ "dense_hit_30": "",
382
+ "dense_recall_30": "",
383
+ "dense_precision_30": "",
384
+ "graph_hit_30": "",
385
+ "graph_recall_30": "",
386
+ "graph_precision_30": "",
387
+ "hit_delta_30": "",
388
+ "recall_delta_30": "",
389
+ "precision_delta_30": "",
390
+ "dense_hit_50": "",
391
+ "dense_recall_50": "",
392
+ "dense_precision_50": "",
393
+ "graph_hit_50": "",
394
+ "graph_recall_50": "",
395
+ "graph_precision_50": "",
396
+ "hit_delta_50": "",
397
+ "recall_delta_50": "",
398
+ "precision_delta_50": ""
399
+ },
400
+ {
401
+ "question": "Show me depictions of women working in Boston during the early 1900s.",
402
+ "question_type": "hallucination_test",
403
+ "rewritten_query": "depictions of women working in Boston",
404
+ "use_graph": false,
405
+ "num_ground_truths": 0,
406
+ "dense_retrieved": 0,
407
+ "graph_retrieved": 0,
408
+ "ground_truth_ids": [],
409
+ "dense_ids": [],
410
+ "graph_ids": [],
411
+ "error": "list index out of range",
412
+ "dense_hit_5": "",
413
+ "dense_recall_5": "",
414
+ "dense_precision_5": "",
415
+ "graph_hit_5": "",
416
+ "graph_recall_5": "",
417
+ "graph_precision_5": "",
418
+ "hit_delta_5": "",
419
+ "recall_delta_5": "",
420
+ "precision_delta_5": "",
421
+ "dense_hit_10": "",
422
+ "dense_recall_10": "",
423
+ "dense_precision_10": "",
424
+ "graph_hit_10": "",
425
+ "graph_recall_10": "",
426
+ "graph_precision_10": "",
427
+ "hit_delta_10": "",
428
+ "recall_delta_10": "",
429
+ "precision_delta_10": "",
430
+ "dense_hit_20": "",
431
+ "dense_recall_20": "",
432
+ "dense_precision_20": "",
433
+ "graph_hit_20": "",
434
+ "graph_recall_20": "",
435
+ "graph_precision_20": "",
436
+ "hit_delta_20": "",
437
+ "recall_delta_20": "",
438
+ "precision_delta_20": "",
439
+ "dense_hit_30": "",
440
+ "dense_recall_30": "",
441
+ "dense_precision_30": "",
442
+ "graph_hit_30": "",
443
+ "graph_recall_30": "",
444
+ "graph_precision_30": "",
445
+ "hit_delta_30": "",
446
+ "recall_delta_30": "",
447
+ "precision_delta_30": "",
448
+ "dense_hit_50": "",
449
+ "dense_recall_50": "",
450
+ "dense_precision_50": "",
451
+ "graph_hit_50": "",
452
+ "graph_recall_50": "",
453
+ "graph_precision_50": "",
454
+ "hit_delta_50": "",
455
+ "recall_delta_50": "",
456
+ "precision_delta_50": ""
457
+ },
458
+ {
459
+ "question": "Find materials related to abolitionist movements in Massachusetts.",
460
+ "question_type": "metadata",
461
+ "rewritten_query": "abolitionist movements in Massachusetts",
462
+ "use_graph": true,
463
+ "num_ground_truths": 4,
464
+ "dense_retrieved": 0,
465
+ "graph_retrieved": 0,
466
+ "ground_truth_ids": [
467
+ "m900pj71z",
468
+ "5h740p602",
469
+ "m900qg763",
470
+ "m900qr11x"
471
+ ],
472
+ "dense_ids": [],
473
+ "graph_ids": [],
474
+ "error": "list index out of range",
475
+ "dense_hit_5": "",
476
+ "dense_recall_5": "",
477
+ "dense_precision_5": "",
478
+ "graph_hit_5": "",
479
+ "graph_recall_5": "",
480
+ "graph_precision_5": "",
481
+ "hit_delta_5": "",
482
+ "recall_delta_5": "",
483
+ "precision_delta_5": "",
484
+ "dense_hit_10": "",
485
+ "dense_recall_10": "",
486
+ "dense_precision_10": "",
487
+ "graph_hit_10": "",
488
+ "graph_recall_10": "",
489
+ "graph_precision_10": "",
490
+ "hit_delta_10": "",
491
+ "recall_delta_10": "",
492
+ "precision_delta_10": "",
493
+ "dense_hit_20": "",
494
+ "dense_recall_20": "",
495
+ "dense_precision_20": "",
496
+ "graph_hit_20": "",
497
+ "graph_recall_20": "",
498
+ "graph_precision_20": "",
499
+ "hit_delta_20": "",
500
+ "recall_delta_20": "",
501
+ "precision_delta_20": "",
502
+ "dense_hit_30": "",
503
+ "dense_recall_30": "",
504
+ "dense_precision_30": "",
505
+ "graph_hit_30": "",
506
+ "graph_recall_30": "",
507
+ "graph_precision_30": "",
508
+ "hit_delta_30": "",
509
+ "recall_delta_30": "",
510
+ "precision_delta_30": "",
511
+ "dense_hit_50": "",
512
+ "dense_recall_50": "",
513
+ "dense_precision_50": "",
514
+ "graph_hit_50": "",
515
+ "graph_recall_50": "",
516
+ "graph_precision_50": "",
517
+ "hit_delta_50": "",
518
+ "recall_delta_50": "",
519
+ "precision_delta_50": ""
520
+ },
521
+ {
522
+ "question": "Find old photographs of Beacon Hill, Boston.",
523
+ "question_type": "metadata",
524
+ "rewritten_query": "photographs of Beacon Hill, Boston",
525
+ "use_graph": false,
526
+ "num_ground_truths": 4,
527
+ "dense_retrieved": 0,
528
+ "graph_retrieved": 0,
529
+ "ground_truth_ids": [
530
+ "8623j613c",
531
+ "7h14d6210",
532
+ "ht24xf08g",
533
+ "9019x004d"
534
+ ],
535
+ "dense_ids": [],
536
+ "graph_ids": [],
537
+ "error": "list index out of range",
538
+ "dense_hit_5": "",
539
+ "dense_recall_5": "",
540
+ "dense_precision_5": "",
541
+ "graph_hit_5": "",
542
+ "graph_recall_5": "",
543
+ "graph_precision_5": "",
544
+ "hit_delta_5": "",
545
+ "recall_delta_5": "",
546
+ "precision_delta_5": "",
547
+ "dense_hit_10": "",
548
+ "dense_recall_10": "",
549
+ "dense_precision_10": "",
550
+ "graph_hit_10": "",
551
+ "graph_recall_10": "",
552
+ "graph_precision_10": "",
553
+ "hit_delta_10": "",
554
+ "recall_delta_10": "",
555
+ "precision_delta_10": "",
556
+ "dense_hit_20": "",
557
+ "dense_recall_20": "",
558
+ "dense_precision_20": "",
559
+ "graph_hit_20": "",
560
+ "graph_recall_20": "",
561
+ "graph_precision_20": "",
562
+ "hit_delta_20": "",
563
+ "recall_delta_20": "",
564
+ "precision_delta_20": "",
565
+ "dense_hit_30": "",
566
+ "dense_recall_30": "",
567
+ "dense_precision_30": "",
568
+ "graph_hit_30": "",
569
+ "graph_recall_30": "",
570
+ "graph_precision_30": "",
571
+ "hit_delta_30": "",
572
+ "recall_delta_30": "",
573
+ "precision_delta_30": "",
574
+ "dense_hit_50": "",
575
+ "dense_recall_50": "",
576
+ "dense_precision_50": "",
577
+ "graph_hit_50": "",
578
+ "graph_recall_50": "",
579
+ "graph_precision_50": "",
580
+ "hit_delta_50": "",
581
+ "recall_delta_50": "",
582
+ "precision_delta_50": ""
583
+ },
584
+ {
585
+ "question": "What was the Boston Tea Party and why did it happen?",
586
+ "question_type": "metadata",
587
+ "rewritten_query": "Boston Tea Party causes and events",
588
+ "use_graph": true,
589
+ "num_ground_truths": 4,
590
+ "dense_retrieved": 0,
591
+ "graph_retrieved": 0,
592
+ "ground_truth_ids": [
593
+ "d504v765w",
594
+ "zs2626294",
595
+ "f1884c31d",
596
+ "6d571125r"
597
+ ],
598
+ "dense_ids": [],
599
+ "graph_ids": [],
600
+ "error": "list index out of range",
601
+ "dense_hit_5": "",
602
+ "dense_recall_5": "",
603
+ "dense_precision_5": "",
604
+ "graph_hit_5": "",
605
+ "graph_recall_5": "",
606
+ "graph_precision_5": "",
607
+ "hit_delta_5": "",
608
+ "recall_delta_5": "",
609
+ "precision_delta_5": "",
610
+ "dense_hit_10": "",
611
+ "dense_recall_10": "",
612
+ "dense_precision_10": "",
613
+ "graph_hit_10": "",
614
+ "graph_recall_10": "",
615
+ "graph_precision_10": "",
616
+ "hit_delta_10": "",
617
+ "recall_delta_10": "",
618
+ "precision_delta_10": "",
619
+ "dense_hit_20": "",
620
+ "dense_recall_20": "",
621
+ "dense_precision_20": "",
622
+ "graph_hit_20": "",
623
+ "graph_recall_20": "",
624
+ "graph_precision_20": "",
625
+ "hit_delta_20": "",
626
+ "recall_delta_20": "",
627
+ "precision_delta_20": "",
628
+ "dense_hit_30": "",
629
+ "dense_recall_30": "",
630
+ "dense_precision_30": "",
631
+ "graph_hit_30": "",
632
+ "graph_recall_30": "",
633
+ "graph_precision_30": "",
634
+ "hit_delta_30": "",
635
+ "recall_delta_30": "",
636
+ "precision_delta_30": "",
637
+ "dense_hit_50": "",
638
+ "dense_recall_50": "",
639
+ "dense_precision_50": "",
640
+ "graph_hit_50": "",
641
+ "graph_recall_50": "",
642
+ "graph_precision_50": "",
643
+ "hit_delta_50": "",
644
+ "recall_delta_50": "",
645
+ "precision_delta_50": ""
646
+ },
647
+ {
648
+ "question": "What events took place in Boston during the American Revolution?",
649
+ "question_type": "metadata",
650
+ "rewritten_query": "events in Boston during the American Revolution",
651
+ "use_graph": true,
652
+ "num_ground_truths": 4,
653
+ "dense_retrieved": 0,
654
+ "graph_retrieved": 0,
655
+ "ground_truth_ids": [
656
+ "9s161871m",
657
+ "st74cw36c",
658
+ "wd3768027",
659
+ "dv142f145"
660
+ ],
661
+ "dense_ids": [],
662
+ "graph_ids": [],
663
+ "error": "list index out of range",
664
+ "dense_hit_5": "",
665
+ "dense_recall_5": "",
666
+ "dense_precision_5": "",
667
+ "graph_hit_5": "",
668
+ "graph_recall_5": "",
669
+ "graph_precision_5": "",
670
+ "hit_delta_5": "",
671
+ "recall_delta_5": "",
672
+ "precision_delta_5": "",
673
+ "dense_hit_10": "",
674
+ "dense_recall_10": "",
675
+ "dense_precision_10": "",
676
+ "graph_hit_10": "",
677
+ "graph_recall_10": "",
678
+ "graph_precision_10": "",
679
+ "hit_delta_10": "",
680
+ "recall_delta_10": "",
681
+ "precision_delta_10": "",
682
+ "dense_hit_20": "",
683
+ "dense_recall_20": "",
684
+ "dense_precision_20": "",
685
+ "graph_hit_20": "",
686
+ "graph_recall_20": "",
687
+ "graph_precision_20": "",
688
+ "hit_delta_20": "",
689
+ "recall_delta_20": "",
690
+ "precision_delta_20": "",
691
+ "dense_hit_30": "",
692
+ "dense_recall_30": "",
693
+ "dense_precision_30": "",
694
+ "graph_hit_30": "",
695
+ "graph_recall_30": "",
696
+ "graph_precision_30": "",
697
+ "hit_delta_30": "",
698
+ "recall_delta_30": "",
699
+ "precision_delta_30": "",
700
+ "dense_hit_50": "",
701
+ "dense_recall_50": "",
702
+ "dense_precision_50": "",
703
+ "graph_hit_50": "",
704
+ "graph_recall_50": "",
705
+ "graph_precision_50": "",
706
+ "hit_delta_50": "",
707
+ "recall_delta_50": "",
708
+ "precision_delta_50": ""
709
+ },
710
+ {
711
+ "question": "Who was Crispus Attucks and what was his role in American history?",
712
+ "question_type": "metadata",
713
+ "rewritten_query": "Crispus Attucks role in American history",
714
+ "use_graph": true,
715
+ "num_ground_truths": 4,
716
+ "dense_retrieved": 0,
717
+ "graph_retrieved": 0,
718
+ "ground_truth_ids": [
719
+ "2801pm92n",
720
+ "vh53x3780",
721
+ "jq08ds22z",
722
+ "41688262q"
723
+ ],
724
+ "dense_ids": [],
725
+ "graph_ids": [],
726
+ "error": "list index out of range",
727
+ "dense_hit_5": "",
728
+ "dense_recall_5": "",
729
+ "dense_precision_5": "",
730
+ "graph_hit_5": "",
731
+ "graph_recall_5": "",
732
+ "graph_precision_5": "",
733
+ "hit_delta_5": "",
734
+ "recall_delta_5": "",
735
+ "precision_delta_5": "",
736
+ "dense_hit_10": "",
737
+ "dense_recall_10": "",
738
+ "dense_precision_10": "",
739
+ "graph_hit_10": "",
740
+ "graph_recall_10": "",
741
+ "graph_precision_10": "",
742
+ "hit_delta_10": "",
743
+ "recall_delta_10": "",
744
+ "precision_delta_10": "",
745
+ "dense_hit_20": "",
746
+ "dense_recall_20": "",
747
+ "dense_precision_20": "",
748
+ "graph_hit_20": "",
749
+ "graph_recall_20": "",
750
+ "graph_precision_20": "",
751
+ "hit_delta_20": "",
752
+ "recall_delta_20": "",
753
+ "precision_delta_20": "",
754
+ "dense_hit_30": "",
755
+ "dense_recall_30": "",
756
+ "dense_precision_30": "",
757
+ "graph_hit_30": "",
758
+ "graph_recall_30": "",
759
+ "graph_precision_30": "",
760
+ "hit_delta_30": "",
761
+ "recall_delta_30": "",
762
+ "precision_delta_30": "",
763
+ "dense_hit_50": "",
764
+ "dense_recall_50": "",
765
+ "dense_precision_50": "",
766
+ "graph_hit_50": "",
767
+ "graph_recall_50": "",
768
+ "graph_precision_50": "",
769
+ "hit_delta_50": "",
770
+ "recall_delta_50": "",
771
+ "precision_delta_50": ""
772
+ },
773
+ {
774
+ "question": "Are there any newspaper articles covering the women's suffrage movement in Massachusetts?",
775
+ "question_type": "full_text",
776
+ "rewritten_query": "women's suffrage movement in Massachusetts",
777
+ "use_graph": true,
778
+ "num_ground_truths": 4,
779
+ "dense_retrieved": 0,
780
+ "graph_retrieved": 0,
781
+ "ground_truth_ids": [
782
+ "mw232h306",
783
+ "4t64q022m",
784
+ "5d86wc88j",
785
+ "nv93cf51s"
786
+ ],
787
+ "dense_ids": [],
788
+ "graph_ids": [],
789
+ "error": "list index out of range",
790
+ "dense_hit_5": "",
791
+ "dense_recall_5": "",
792
+ "dense_precision_5": "",
793
+ "graph_hit_5": "",
794
+ "graph_recall_5": "",
795
+ "graph_precision_5": "",
796
+ "hit_delta_5": "",
797
+ "recall_delta_5": "",
798
+ "precision_delta_5": "",
799
+ "dense_hit_10": "",
800
+ "dense_recall_10": "",
801
+ "dense_precision_10": "",
802
+ "graph_hit_10": "",
803
+ "graph_recall_10": "",
804
+ "graph_precision_10": "",
805
+ "hit_delta_10": "",
806
+ "recall_delta_10": "",
807
+ "precision_delta_10": "",
808
+ "dense_hit_20": "",
809
+ "dense_recall_20": "",
810
+ "dense_precision_20": "",
811
+ "graph_hit_20": "",
812
+ "graph_recall_20": "",
813
+ "graph_precision_20": "",
814
+ "hit_delta_20": "",
815
+ "recall_delta_20": "",
816
+ "precision_delta_20": "",
817
+ "dense_hit_30": "",
818
+ "dense_recall_30": "",
819
+ "dense_precision_30": "",
820
+ "graph_hit_30": "",
821
+ "graph_recall_30": "",
822
+ "graph_precision_30": "",
823
+ "hit_delta_30": "",
824
+ "recall_delta_30": "",
825
+ "precision_delta_30": "",
826
+ "dense_hit_50": "",
827
+ "dense_recall_50": "",
828
+ "dense_precision_50": "",
829
+ "graph_hit_50": "",
830
+ "graph_recall_50": "",
831
+ "graph_precision_50": "",
832
+ "hit_delta_50": "",
833
+ "recall_delta_50": "",
834
+ "precision_delta_50": ""
835
+ },
836
+ {
837
+ "question": "Find documents or letters from the Civil War era written by Massachusetts soldiers.",
838
+ "question_type": "hallucination_test",
839
+ "rewritten_query": "documents or letters from Civil War era Massachusetts soldiers",
840
+ "use_graph": false,
841
+ "num_ground_truths": 0,
842
+ "dense_retrieved": 0,
843
+ "graph_retrieved": 0,
844
+ "ground_truth_ids": [],
845
+ "dense_ids": [],
846
+ "graph_ids": [],
847
+ "error": "list index out of range",
848
+ "dense_hit_5": "",
849
+ "dense_recall_5": "",
850
+ "dense_precision_5": "",
851
+ "graph_hit_5": "",
852
+ "graph_recall_5": "",
853
+ "graph_precision_5": "",
854
+ "hit_delta_5": "",
855
+ "recall_delta_5": "",
856
+ "precision_delta_5": "",
857
+ "dense_hit_10": "",
858
+ "dense_recall_10": "",
859
+ "dense_precision_10": "",
860
+ "graph_hit_10": "",
861
+ "graph_recall_10": "",
862
+ "graph_precision_10": "",
863
+ "hit_delta_10": "",
864
+ "recall_delta_10": "",
865
+ "precision_delta_10": "",
866
+ "dense_hit_20": "",
867
+ "dense_recall_20": "",
868
+ "dense_precision_20": "",
869
+ "graph_hit_20": "",
870
+ "graph_recall_20": "",
871
+ "graph_precision_20": "",
872
+ "hit_delta_20": "",
873
+ "recall_delta_20": "",
874
+ "precision_delta_20": "",
875
+ "dense_hit_30": "",
876
+ "dense_recall_30": "",
877
+ "dense_precision_30": "",
878
+ "graph_hit_30": "",
879
+ "graph_recall_30": "",
880
+ "graph_precision_30": "",
881
+ "hit_delta_30": "",
882
+ "recall_delta_30": "",
883
+ "precision_delta_30": "",
884
+ "dense_hit_50": "",
885
+ "dense_recall_50": "",
886
+ "dense_precision_50": "",
887
+ "graph_hit_50": "",
888
+ "graph_recall_50": "",
889
+ "graph_precision_50": "",
890
+ "hit_delta_50": "",
891
+ "recall_delta_50": "",
892
+ "precision_delta_50": ""
893
+ },
894
+ {
895
+ "question": "What role did Frederick Douglass play in Massachusetts history?",
896
+ "question_type": "metadata",
897
+ "rewritten_query": "Frederick Douglass role in Massachusetts history",
898
+ "use_graph": true,
899
+ "num_ground_truths": 4,
900
+ "dense_retrieved": 0,
901
+ "graph_retrieved": 0,
902
+ "ground_truth_ids": [
903
+ "dv143022b",
904
+ "7s75fx33m",
905
+ "dv142z173",
906
+ "5m60th82r"
907
+ ],
908
+ "dense_ids": [],
909
+ "graph_ids": [],
910
+ "error": "list index out of range",
911
+ "dense_hit_5": "",
912
+ "dense_recall_5": "",
913
+ "dense_precision_5": "",
914
+ "graph_hit_5": "",
915
+ "graph_recall_5": "",
916
+ "graph_precision_5": "",
917
+ "hit_delta_5": "",
918
+ "recall_delta_5": "",
919
+ "precision_delta_5": "",
920
+ "dense_hit_10": "",
921
+ "dense_recall_10": "",
922
+ "dense_precision_10": "",
923
+ "graph_hit_10": "",
924
+ "graph_recall_10": "",
925
+ "graph_precision_10": "",
926
+ "hit_delta_10": "",
927
+ "recall_delta_10": "",
928
+ "precision_delta_10": "",
929
+ "dense_hit_20": "",
930
+ "dense_recall_20": "",
931
+ "dense_precision_20": "",
932
+ "graph_hit_20": "",
933
+ "graph_recall_20": "",
934
+ "graph_precision_20": "",
935
+ "hit_delta_20": "",
936
+ "recall_delta_20": "",
937
+ "precision_delta_20": "",
938
+ "dense_hit_30": "",
939
+ "dense_recall_30": "",
940
+ "dense_precision_30": "",
941
+ "graph_hit_30": "",
942
+ "graph_recall_30": "",
943
+ "graph_precision_30": "",
944
+ "hit_delta_30": "",
945
+ "recall_delta_30": "",
946
+ "precision_delta_30": "",
947
+ "dense_hit_50": "",
948
+ "dense_recall_50": "",
949
+ "dense_precision_50": "",
950
+ "graph_hit_50": "",
951
+ "graph_recall_50": "",
952
+ "graph_precision_50": "",
953
+ "hit_delta_50": "",
954
+ "recall_delta_50": "",
955
+ "precision_delta_50": ""
956
+ },
957
+ {
958
+ "question": "What did the Boston Public Library look like when it was first built?",
959
+ "question_type": "metadata",
960
+ "rewritten_query": "Boston Public Library original construction appearance",
961
+ "use_graph": false,
962
+ "num_ground_truths": 4,
963
+ "dense_retrieved": 0,
964
+ "graph_retrieved": 0,
965
+ "ground_truth_ids": [
966
+ "c821gx37x",
967
+ "gt54pc92n",
968
+ "7p88d826d",
969
+ "c821gx27p"
970
+ ],
971
+ "dense_ids": [],
972
+ "graph_ids": [],
973
+ "error": "list index out of range",
974
+ "dense_hit_5": "",
975
+ "dense_recall_5": "",
976
+ "dense_precision_5": "",
977
+ "graph_hit_5": "",
978
+ "graph_recall_5": "",
979
+ "graph_precision_5": "",
980
+ "hit_delta_5": "",
981
+ "recall_delta_5": "",
982
+ "precision_delta_5": "",
983
+ "dense_hit_10": "",
984
+ "dense_recall_10": "",
985
+ "dense_precision_10": "",
986
+ "graph_hit_10": "",
987
+ "graph_recall_10": "",
988
+ "graph_precision_10": "",
989
+ "hit_delta_10": "",
990
+ "recall_delta_10": "",
991
+ "precision_delta_10": "",
992
+ "dense_hit_20": "",
993
+ "dense_recall_20": "",
994
+ "dense_precision_20": "",
995
+ "graph_hit_20": "",
996
+ "graph_recall_20": "",
997
+ "graph_precision_20": "",
998
+ "hit_delta_20": "",
999
+ "recall_delta_20": "",
1000
+ "precision_delta_20": "",
1001
+ "dense_hit_30": "",
1002
+ "dense_recall_30": "",
1003
+ "dense_precision_30": "",
1004
+ "graph_hit_30": "",
1005
+ "graph_recall_30": "",
1006
+ "graph_precision_30": "",
1007
+ "hit_delta_30": "",
1008
+ "recall_delta_30": "",
1009
+ "precision_delta_30": "",
1010
+ "dense_hit_50": "",
1011
+ "dense_recall_50": "",
1012
+ "dense_precision_50": "",
1013
+ "graph_hit_50": "",
1014
+ "graph_recall_50": "",
1015
+ "graph_precision_50": "",
1016
+ "hit_delta_50": "",
1017
+ "recall_delta_50": "",
1018
+ "precision_delta_50": ""
1019
+ },
1020
+ {
1021
+ "question": "When was the Longfellow Bridge between Boston and Cambridge built?",
1022
+ "question_type": "metadata",
1023
+ "rewritten_query": "Longfellow Bridge construction date",
1024
+ "use_graph": true,
1025
+ "num_ground_truths": 4,
1026
+ "dense_retrieved": 0,
1027
+ "graph_retrieved": 0,
1028
+ "ground_truth_ids": [
1029
+ "3f463c80z",
1030
+ "8s45qv70f",
1031
+ "pr76f347h",
1032
+ "4f16kv932"
1033
+ ],
1034
+ "dense_ids": [],
1035
+ "graph_ids": [],
1036
+ "error": "list index out of range",
1037
+ "dense_hit_5": "",
1038
+ "dense_recall_5": "",
1039
+ "dense_precision_5": "",
1040
+ "graph_hit_5": "",
1041
+ "graph_recall_5": "",
1042
+ "graph_precision_5": "",
1043
+ "hit_delta_5": "",
1044
+ "recall_delta_5": "",
1045
+ "precision_delta_5": "",
1046
+ "dense_hit_10": "",
1047
+ "dense_recall_10": "",
1048
+ "dense_precision_10": "",
1049
+ "graph_hit_10": "",
1050
+ "graph_recall_10": "",
1051
+ "graph_precision_10": "",
1052
+ "hit_delta_10": "",
1053
+ "recall_delta_10": "",
1054
+ "precision_delta_10": "",
1055
+ "dense_hit_20": "",
1056
+ "dense_recall_20": "",
1057
+ "dense_precision_20": "",
1058
+ "graph_hit_20": "",
1059
+ "graph_recall_20": "",
1060
+ "graph_precision_20": "",
1061
+ "hit_delta_20": "",
1062
+ "recall_delta_20": "",
1063
+ "precision_delta_20": "",
1064
+ "dense_hit_30": "",
1065
+ "dense_recall_30": "",
1066
+ "dense_precision_30": "",
1067
+ "graph_hit_30": "",
1068
+ "graph_recall_30": "",
1069
+ "graph_precision_30": "",
1070
+ "hit_delta_30": "",
1071
+ "recall_delta_30": "",
1072
+ "precision_delta_30": "",
1073
+ "dense_hit_50": "",
1074
+ "dense_recall_50": "",
1075
+ "dense_precision_50": "",
1076
+ "graph_hit_50": "",
1077
+ "graph_recall_50": "",
1078
+ "graph_precision_50": "",
1079
+ "hit_delta_50": "",
1080
+ "recall_delta_50": "",
1081
+ "precision_delta_50": ""
1082
+ },
1083
+ {
1084
+ "question": "What were the biggest insurance companies in Boston in the early 20th century?",
1085
+ "question_type": "metadata",
1086
+ "rewritten_query": "biggest insurance companies in Boston early 20th century",
1087
+ "use_graph": true,
1088
+ "num_ground_truths": 4,
1089
+ "dense_retrieved": 0,
1090
+ "graph_retrieved": 0,
1091
+ "ground_truth_ids": [
1092
+ "9306wq12z",
1093
+ "70797z85h",
1094
+ "8k71nw59n",
1095
+ "xp68kn172"
1096
+ ],
1097
+ "dense_ids": [],
1098
+ "graph_ids": [],
1099
+ "error": "list index out of range",
1100
+ "dense_hit_5": "",
1101
+ "dense_recall_5": "",
1102
+ "dense_precision_5": "",
1103
+ "graph_hit_5": "",
1104
+ "graph_recall_5": "",
1105
+ "graph_precision_5": "",
1106
+ "hit_delta_5": "",
1107
+ "recall_delta_5": "",
1108
+ "precision_delta_5": "",
1109
+ "dense_hit_10": "",
1110
+ "dense_recall_10": "",
1111
+ "dense_precision_10": "",
1112
+ "graph_hit_10": "",
1113
+ "graph_recall_10": "",
1114
+ "graph_precision_10": "",
1115
+ "hit_delta_10": "",
1116
+ "recall_delta_10": "",
1117
+ "precision_delta_10": "",
1118
+ "dense_hit_20": "",
1119
+ "dense_recall_20": "",
1120
+ "dense_precision_20": "",
1121
+ "graph_hit_20": "",
1122
+ "graph_recall_20": "",
1123
+ "graph_precision_20": "",
1124
+ "hit_delta_20": "",
1125
+ "recall_delta_20": "",
1126
+ "precision_delta_20": "",
1127
+ "dense_hit_30": "",
1128
+ "dense_recall_30": "",
1129
+ "dense_precision_30": "",
1130
+ "graph_hit_30": "",
1131
+ "graph_recall_30": "",
1132
+ "graph_precision_30": "",
1133
+ "hit_delta_30": "",
1134
+ "recall_delta_30": "",
1135
+ "precision_delta_30": "",
1136
+ "dense_hit_50": "",
1137
+ "dense_recall_50": "",
1138
+ "dense_precision_50": "",
1139
+ "graph_hit_50": "",
1140
+ "graph_recall_50": "",
1141
+ "graph_precision_50": "",
1142
+ "hit_delta_50": "",
1143
+ "recall_delta_50": "",
1144
+ "precision_delta_50": ""
1145
+ },
1146
+ {
1147
+ "question": "Who was Samuel Adams and what did he do in Boston?",
1148
+ "question_type": "metadata",
1149
+ "rewritten_query": "Samuel Adams activities in Boston",
1150
+ "use_graph": true,
1151
+ "num_ground_truths": 4,
1152
+ "dense_retrieved": 0,
1153
+ "graph_retrieved": 0,
1154
+ "ground_truth_ids": [
1155
+ "fj2373328",
1156
+ "jm217635p",
1157
+ "2v23zv79k",
1158
+ "7p88gj11s"
1159
+ ],
1160
+ "dense_ids": [],
1161
+ "graph_ids": [],
1162
+ "error": "list index out of range",
1163
+ "dense_hit_5": "",
1164
+ "dense_recall_5": "",
1165
+ "dense_precision_5": "",
1166
+ "graph_hit_5": "",
1167
+ "graph_recall_5": "",
1168
+ "graph_precision_5": "",
1169
+ "hit_delta_5": "",
1170
+ "recall_delta_5": "",
1171
+ "precision_delta_5": "",
1172
+ "dense_hit_10": "",
1173
+ "dense_recall_10": "",
1174
+ "dense_precision_10": "",
1175
+ "graph_hit_10": "",
1176
+ "graph_recall_10": "",
1177
+ "graph_precision_10": "",
1178
+ "hit_delta_10": "",
1179
+ "recall_delta_10": "",
1180
+ "precision_delta_10": "",
1181
+ "dense_hit_20": "",
1182
+ "dense_recall_20": "",
1183
+ "dense_precision_20": "",
1184
+ "graph_hit_20": "",
1185
+ "graph_recall_20": "",
1186
+ "graph_precision_20": "",
1187
+ "hit_delta_20": "",
1188
+ "recall_delta_20": "",
1189
+ "precision_delta_20": "",
1190
+ "dense_hit_30": "",
1191
+ "dense_recall_30": "",
1192
+ "dense_precision_30": "",
1193
+ "graph_hit_30": "",
1194
+ "graph_recall_30": "",
1195
+ "graph_precision_30": "",
1196
+ "hit_delta_30": "",
1197
+ "recall_delta_30": "",
1198
+ "precision_delta_30": "",
1199
+ "dense_hit_50": "",
1200
+ "dense_recall_50": "",
1201
+ "dense_precision_50": "",
1202
+ "graph_hit_50": "",
1203
+ "graph_recall_50": "",
1204
+ "graph_precision_50": "",
1205
+ "hit_delta_50": "",
1206
+ "recall_delta_50": "",
1207
+ "precision_delta_50": ""
1208
+ },
1209
+ {
1210
+ "question": "What was the role of the Boston Navy Yard?",
1211
+ "question_type": "metadata",
1212
+ "rewritten_query": "role of the Boston Navy Yard",
1213
+ "use_graph": true,
1214
+ "num_ground_truths": 4,
1215
+ "dense_retrieved": 0,
1216
+ "graph_retrieved": 0,
1217
+ "ground_truth_ids": [
1218
+ "2v240047f",
1219
+ "1j92kc44m",
1220
+ "8k71p1131",
1221
+ "5425rv86w"
1222
+ ],
1223
+ "dense_ids": [],
1224
+ "graph_ids": [],
1225
+ "error": "list index out of range",
1226
+ "dense_hit_5": "",
1227
+ "dense_recall_5": "",
1228
+ "dense_precision_5": "",
1229
+ "graph_hit_5": "",
1230
+ "graph_recall_5": "",
1231
+ "graph_precision_5": "",
1232
+ "hit_delta_5": "",
1233
+ "recall_delta_5": "",
1234
+ "precision_delta_5": "",
1235
+ "dense_hit_10": "",
1236
+ "dense_recall_10": "",
1237
+ "dense_precision_10": "",
1238
+ "graph_hit_10": "",
1239
+ "graph_recall_10": "",
1240
+ "graph_precision_10": "",
1241
+ "hit_delta_10": "",
1242
+ "recall_delta_10": "",
1243
+ "precision_delta_10": "",
1244
+ "dense_hit_20": "",
1245
+ "dense_recall_20": "",
1246
+ "dense_precision_20": "",
1247
+ "graph_hit_20": "",
1248
+ "graph_recall_20": "",
1249
+ "graph_precision_20": "",
1250
+ "hit_delta_20": "",
1251
+ "recall_delta_20": "",
1252
+ "precision_delta_20": "",
1253
+ "dense_hit_30": "",
1254
+ "dense_recall_30": "",
1255
+ "dense_precision_30": "",
1256
+ "graph_hit_30": "",
1257
+ "graph_recall_30": "",
1258
+ "graph_precision_30": "",
1259
+ "hit_delta_30": "",
1260
+ "recall_delta_30": "",
1261
+ "precision_delta_30": "",
1262
+ "dense_hit_50": "",
1263
+ "dense_recall_50": "",
1264
+ "dense_precision_50": "",
1265
+ "graph_hit_50": "",
1266
+ "graph_recall_50": "",
1267
+ "graph_precision_50": "",
1268
+ "hit_delta_50": "",
1269
+ "recall_delta_50": "",
1270
+ "precision_delta_50": ""
1271
+ },
1272
+ {
1273
+ "question": "What was the Old State House in Boston used for?",
1274
+ "question_type": "metadata",
1275
+ "rewritten_query": "Old State House Boston usage",
1276
+ "use_graph": false,
1277
+ "num_ground_truths": 4,
1278
+ "dense_retrieved": 0,
1279
+ "graph_retrieved": 0,
1280
+ "ground_truth_ids": [
1281
+ "ht24xf50g",
1282
+ "c534jd829",
1283
+ "ht24xf521",
1284
+ "5h73rs02f"
1285
+ ],
1286
+ "dense_ids": [],
1287
+ "graph_ids": [],
1288
+ "error": "list index out of range",
1289
+ "dense_hit_5": "",
1290
+ "dense_recall_5": "",
1291
+ "dense_precision_5": "",
1292
+ "graph_hit_5": "",
1293
+ "graph_recall_5": "",
1294
+ "graph_precision_5": "",
1295
+ "hit_delta_5": "",
1296
+ "recall_delta_5": "",
1297
+ "precision_delta_5": "",
1298
+ "dense_hit_10": "",
1299
+ "dense_recall_10": "",
1300
+ "dense_precision_10": "",
1301
+ "graph_hit_10": "",
1302
+ "graph_recall_10": "",
1303
+ "graph_precision_10": "",
1304
+ "hit_delta_10": "",
1305
+ "recall_delta_10": "",
1306
+ "precision_delta_10": "",
1307
+ "dense_hit_20": "",
1308
+ "dense_recall_20": "",
1309
+ "dense_precision_20": "",
1310
+ "graph_hit_20": "",
1311
+ "graph_recall_20": "",
1312
+ "graph_precision_20": "",
1313
+ "hit_delta_20": "",
1314
+ "recall_delta_20": "",
1315
+ "precision_delta_20": "",
1316
+ "dense_hit_30": "",
1317
+ "dense_recall_30": "",
1318
+ "dense_precision_30": "",
1319
+ "graph_hit_30": "",
1320
+ "graph_recall_30": "",
1321
+ "graph_precision_30": "",
1322
+ "hit_delta_30": "",
1323
+ "recall_delta_30": "",
1324
+ "precision_delta_30": "",
1325
+ "dense_hit_50": "",
1326
+ "dense_recall_50": "",
1327
+ "dense_precision_50": "",
1328
+ "graph_hit_50": "",
1329
+ "graph_recall_50": "",
1330
+ "graph_precision_50": "",
1331
+ "hit_delta_50": "",
1332
+ "recall_delta_50": "",
1333
+ "precision_delta_50": ""
1334
+ },
1335
+ {
1336
+ "question": "Who was Paul Revere and what was his significance?",
1337
+ "question_type": "metadata",
1338
+ "rewritten_query": "Paul Revere significance",
1339
+ "use_graph": true,
1340
+ "num_ground_truths": 4,
1341
+ "dense_retrieved": 0,
1342
+ "graph_retrieved": 0,
1343
+ "ground_truth_ids": [
1344
+ "wh246s53h",
1345
+ "cr56qq138",
1346
+ "fj2372144",
1347
+ "gt54mc309"
1348
+ ],
1349
+ "dense_ids": [],
1350
+ "graph_ids": [],
1351
+ "error": "list index out of range",
1352
+ "dense_hit_5": "",
1353
+ "dense_recall_5": "",
1354
+ "dense_precision_5": "",
1355
+ "graph_hit_5": "",
1356
+ "graph_recall_5": "",
1357
+ "graph_precision_5": "",
1358
+ "hit_delta_5": "",
1359
+ "recall_delta_5": "",
1360
+ "precision_delta_5": "",
1361
+ "dense_hit_10": "",
1362
+ "dense_recall_10": "",
1363
+ "dense_precision_10": "",
1364
+ "graph_hit_10": "",
1365
+ "graph_recall_10": "",
1366
+ "graph_precision_10": "",
1367
+ "hit_delta_10": "",
1368
+ "recall_delta_10": "",
1369
+ "precision_delta_10": "",
1370
+ "dense_hit_20": "",
1371
+ "dense_recall_20": "",
1372
+ "dense_precision_20": "",
1373
+ "graph_hit_20": "",
1374
+ "graph_recall_20": "",
1375
+ "graph_precision_20": "",
1376
+ "hit_delta_20": "",
1377
+ "recall_delta_20": "",
1378
+ "precision_delta_20": "",
1379
+ "dense_hit_30": "",
1380
+ "dense_recall_30": "",
1381
+ "dense_precision_30": "",
1382
+ "graph_hit_30": "",
1383
+ "graph_recall_30": "",
1384
+ "graph_precision_30": "",
1385
+ "hit_delta_30": "",
1386
+ "recall_delta_30": "",
1387
+ "precision_delta_30": "",
1388
+ "dense_hit_50": "",
1389
+ "dense_recall_50": "",
1390
+ "dense_precision_50": "",
1391
+ "graph_hit_50": "",
1392
+ "graph_recall_50": "",
1393
+ "graph_precision_50": "",
1394
+ "hit_delta_50": "",
1395
+ "recall_delta_50": "",
1396
+ "precision_delta_50": ""
1397
+ },
1398
+ {
1399
+ "question": "What happened at the Boston Massacre?",
1400
+ "question_type": "metadata",
1401
+ "rewritten_query": "Boston Massacre events",
1402
+ "use_graph": true,
1403
+ "num_ground_truths": 4,
1404
+ "dense_retrieved": 0,
1405
+ "graph_retrieved": 0,
1406
+ "ground_truth_ids": [
1407
+ "jq08ds22z",
1408
+ "vh53x374w",
1409
+ "vh53x3780",
1410
+ "dv142f145"
1411
+ ],
1412
+ "dense_ids": [],
1413
+ "graph_ids": [],
1414
+ "error": "list index out of range",
1415
+ "dense_hit_5": "",
1416
+ "dense_recall_5": "",
1417
+ "dense_precision_5": "",
1418
+ "graph_hit_5": "",
1419
+ "graph_recall_5": "",
1420
+ "graph_precision_5": "",
1421
+ "hit_delta_5": "",
1422
+ "recall_delta_5": "",
1423
+ "precision_delta_5": "",
1424
+ "dense_hit_10": "",
1425
+ "dense_recall_10": "",
1426
+ "dense_precision_10": "",
1427
+ "graph_hit_10": "",
1428
+ "graph_recall_10": "",
1429
+ "graph_precision_10": "",
1430
+ "hit_delta_10": "",
1431
+ "recall_delta_10": "",
1432
+ "precision_delta_10": "",
1433
+ "dense_hit_20": "",
1434
+ "dense_recall_20": "",
1435
+ "dense_precision_20": "",
1436
+ "graph_hit_20": "",
1437
+ "graph_recall_20": "",
1438
+ "graph_precision_20": "",
1439
+ "hit_delta_20": "",
1440
+ "recall_delta_20": "",
1441
+ "precision_delta_20": "",
1442
+ "dense_hit_30": "",
1443
+ "dense_recall_30": "",
1444
+ "dense_precision_30": "",
1445
+ "graph_hit_30": "",
1446
+ "graph_recall_30": "",
1447
+ "graph_precision_30": "",
1448
+ "hit_delta_30": "",
1449
+ "recall_delta_30": "",
1450
+ "precision_delta_30": "",
1451
+ "dense_hit_50": "",
1452
+ "dense_recall_50": "",
1453
+ "dense_precision_50": "",
1454
+ "graph_hit_50": "",
1455
+ "graph_recall_50": "",
1456
+ "graph_precision_50": "",
1457
+ "hit_delta_50": "",
1458
+ "recall_delta_50": "",
1459
+ "precision_delta_50": ""
1460
+ },
1461
+ {
1462
+ "question": "Who were the Sons of Liberty?",
1463
+ "question_type": "metadata",
1464
+ "rewritten_query": "Sons of Liberty",
1465
+ "use_graph": true,
1466
+ "num_ground_truths": 4,
1467
+ "dense_retrieved": 0,
1468
+ "graph_retrieved": 0,
1469
+ "ground_truth_ids": [
1470
+ "fj2372543",
1471
+ "vd66w6545",
1472
+ "9880w844w",
1473
+ "zs2626294"
1474
+ ],
1475
+ "dense_ids": [],
1476
+ "graph_ids": [],
1477
+ "error": "list index out of range",
1478
+ "dense_hit_5": "",
1479
+ "dense_recall_5": "",
1480
+ "dense_precision_5": "",
1481
+ "graph_hit_5": "",
1482
+ "graph_recall_5": "",
1483
+ "graph_precision_5": "",
1484
+ "hit_delta_5": "",
1485
+ "recall_delta_5": "",
1486
+ "precision_delta_5": "",
1487
+ "dense_hit_10": "",
1488
+ "dense_recall_10": "",
1489
+ "dense_precision_10": "",
1490
+ "graph_hit_10": "",
1491
+ "graph_recall_10": "",
1492
+ "graph_precision_10": "",
1493
+ "hit_delta_10": "",
1494
+ "recall_delta_10": "",
1495
+ "precision_delta_10": "",
1496
+ "dense_hit_20": "",
1497
+ "dense_recall_20": "",
1498
+ "dense_precision_20": "",
1499
+ "graph_hit_20": "",
1500
+ "graph_recall_20": "",
1501
+ "graph_precision_20": "",
1502
+ "hit_delta_20": "",
1503
+ "recall_delta_20": "",
1504
+ "precision_delta_20": "",
1505
+ "dense_hit_30": "",
1506
+ "dense_recall_30": "",
1507
+ "dense_precision_30": "",
1508
+ "graph_hit_30": "",
1509
+ "graph_recall_30": "",
1510
+ "graph_precision_30": "",
1511
+ "hit_delta_30": "",
1512
+ "recall_delta_30": "",
1513
+ "precision_delta_30": "",
1514
+ "dense_hit_50": "",
1515
+ "dense_recall_50": "",
1516
+ "dense_precision_50": "",
1517
+ "graph_hit_50": "",
1518
+ "graph_recall_50": "",
1519
+ "graph_precision_50": "",
1520
+ "hit_delta_50": "",
1521
+ "recall_delta_50": "",
1522
+ "precision_delta_50": ""
1523
+ },
1524
+ {
1525
+ "question": "What is Faneuil Hall's historical significance?",
1526
+ "question_type": "metadata",
1527
+ "rewritten_query": "Faneuil Hall historical significance",
1528
+ "use_graph": true,
1529
+ "num_ground_truths": 4,
1530
+ "dense_retrieved": 0,
1531
+ "graph_retrieved": 0,
1532
+ "ground_truth_ids": [
1533
+ "p2677g44k",
1534
+ "7m01kw73f",
1535
+ "v405sz97p",
1536
+ "xp68kq91r"
1537
+ ],
1538
+ "dense_ids": [],
1539
+ "graph_ids": [],
1540
+ "error": "list index out of range",
1541
+ "dense_hit_5": "",
1542
+ "dense_recall_5": "",
1543
+ "dense_precision_5": "",
1544
+ "graph_hit_5": "",
1545
+ "graph_recall_5": "",
1546
+ "graph_precision_5": "",
1547
+ "hit_delta_5": "",
1548
+ "recall_delta_5": "",
1549
+ "precision_delta_5": "",
1550
+ "dense_hit_10": "",
1551
+ "dense_recall_10": "",
1552
+ "dense_precision_10": "",
1553
+ "graph_hit_10": "",
1554
+ "graph_recall_10": "",
1555
+ "graph_precision_10": "",
1556
+ "hit_delta_10": "",
1557
+ "recall_delta_10": "",
1558
+ "precision_delta_10": "",
1559
+ "dense_hit_20": "",
1560
+ "dense_recall_20": "",
1561
+ "dense_precision_20": "",
1562
+ "graph_hit_20": "",
1563
+ "graph_recall_20": "",
1564
+ "graph_precision_20": "",
1565
+ "hit_delta_20": "",
1566
+ "recall_delta_20": "",
1567
+ "precision_delta_20": "",
1568
+ "dense_hit_30": "",
1569
+ "dense_recall_30": "",
1570
+ "dense_precision_30": "",
1571
+ "graph_hit_30": "",
1572
+ "graph_recall_30": "",
1573
+ "graph_precision_30": "",
1574
+ "hit_delta_30": "",
1575
+ "recall_delta_30": "",
1576
+ "precision_delta_30": "",
1577
+ "dense_hit_50": "",
1578
+ "dense_recall_50": "",
1579
+ "dense_precision_50": "",
1580
+ "graph_hit_50": "",
1581
+ "graph_recall_50": "",
1582
+ "graph_precision_50": "",
1583
+ "hit_delta_50": "",
1584
+ "recall_delta_50": "",
1585
+ "precision_delta_50": ""
1586
+ },
1587
+ {
1588
+ "question": "What was the Big Dig and why was it significant?",
1589
+ "question_type": "metadata",
1590
+ "rewritten_query": "Big Dig significance",
1591
+ "use_graph": true,
1592
+ "num_ground_truths": 4,
1593
+ "dense_retrieved": 0,
1594
+ "graph_retrieved": 0,
1595
+ "ground_truth_ids": [
1596
+ "t722qd95f",
1597
+ "ww72m211q",
1598
+ "4q77fs75b",
1599
+ "cn69pp161"
1600
+ ],
1601
+ "dense_ids": [],
1602
+ "graph_ids": [],
1603
+ "error": "list index out of range",
1604
+ "dense_hit_5": "",
1605
+ "dense_recall_5": "",
1606
+ "dense_precision_5": "",
1607
+ "graph_hit_5": "",
1608
+ "graph_recall_5": "",
1609
+ "graph_precision_5": "",
1610
+ "hit_delta_5": "",
1611
+ "recall_delta_5": "",
1612
+ "precision_delta_5": "",
1613
+ "dense_hit_10": "",
1614
+ "dense_recall_10": "",
1615
+ "dense_precision_10": "",
1616
+ "graph_hit_10": "",
1617
+ "graph_recall_10": "",
1618
+ "graph_precision_10": "",
1619
+ "hit_delta_10": "",
1620
+ "recall_delta_10": "",
1621
+ "precision_delta_10": "",
1622
+ "dense_hit_20": "",
1623
+ "dense_recall_20": "",
1624
+ "dense_precision_20": "",
1625
+ "graph_hit_20": "",
1626
+ "graph_recall_20": "",
1627
+ "graph_precision_20": "",
1628
+ "hit_delta_20": "",
1629
+ "recall_delta_20": "",
1630
+ "precision_delta_20": "",
1631
+ "dense_hit_30": "",
1632
+ "dense_recall_30": "",
1633
+ "dense_precision_30": "",
1634
+ "graph_hit_30": "",
1635
+ "graph_recall_30": "",
1636
+ "graph_precision_30": "",
1637
+ "hit_delta_30": "",
1638
+ "recall_delta_30": "",
1639
+ "precision_delta_30": "",
1640
+ "dense_hit_50": "",
1641
+ "dense_recall_50": "",
1642
+ "dense_precision_50": "",
1643
+ "graph_hit_50": "",
1644
+ "graph_recall_50": "",
1645
+ "graph_precision_50": "",
1646
+ "hit_delta_50": "",
1647
+ "recall_delta_50": "",
1648
+ "precision_delta_50": ""
1649
+ },
1650
+ {
1651
+ "question": "What was the history of the Boston Common?",
1652
+ "question_type": "metadata",
1653
+ "rewritten_query": "history of the Boston Common",
1654
+ "use_graph": true,
1655
+ "num_ground_truths": 4,
1656
+ "dense_retrieved": 0,
1657
+ "graph_retrieved": 0,
1658
+ "ground_truth_ids": [
1659
+ "c821gs31f",
1660
+ "2801pn560",
1661
+ "2801pn446",
1662
+ "9g54z626j"
1663
+ ],
1664
+ "dense_ids": [],
1665
+ "graph_ids": [],
1666
+ "error": "list index out of range",
1667
+ "dense_hit_5": "",
1668
+ "dense_recall_5": "",
1669
+ "dense_precision_5": "",
1670
+ "graph_hit_5": "",
1671
+ "graph_recall_5": "",
1672
+ "graph_precision_5": "",
1673
+ "hit_delta_5": "",
1674
+ "recall_delta_5": "",
1675
+ "precision_delta_5": "",
1676
+ "dense_hit_10": "",
1677
+ "dense_recall_10": "",
1678
+ "dense_precision_10": "",
1679
+ "graph_hit_10": "",
1680
+ "graph_recall_10": "",
1681
+ "graph_precision_10": "",
1682
+ "hit_delta_10": "",
1683
+ "recall_delta_10": "",
1684
+ "precision_delta_10": "",
1685
+ "dense_hit_20": "",
1686
+ "dense_recall_20": "",
1687
+ "dense_precision_20": "",
1688
+ "graph_hit_20": "",
1689
+ "graph_recall_20": "",
1690
+ "graph_precision_20": "",
1691
+ "hit_delta_20": "",
1692
+ "recall_delta_20": "",
1693
+ "precision_delta_20": "",
1694
+ "dense_hit_30": "",
1695
+ "dense_recall_30": "",
1696
+ "dense_precision_30": "",
1697
+ "graph_hit_30": "",
1698
+ "graph_recall_30": "",
1699
+ "graph_precision_30": "",
1700
+ "hit_delta_30": "",
1701
+ "recall_delta_30": "",
1702
+ "precision_delta_30": "",
1703
+ "dense_hit_50": "",
1704
+ "dense_recall_50": "",
1705
+ "dense_precision_50": "",
1706
+ "graph_hit_50": "",
1707
+ "graph_recall_50": "",
1708
+ "graph_precision_50": "",
1709
+ "hit_delta_50": "",
1710
+ "recall_delta_50": "",
1711
+ "precision_delta_50": ""
1712
+ },
1713
+ {
1714
+ "question": "Why is Boston named Boston?",
1715
+ "question_type": "metadata",
1716
+ "rewritten_query": "origin of the name Boston",
1717
+ "use_graph": false,
1718
+ "num_ground_truths": 4,
1719
+ "dense_retrieved": 0,
1720
+ "graph_retrieved": 0,
1721
+ "ground_truth_ids": [
1722
+ "z316sk199",
1723
+ "m900r167m",
1724
+ "z316sk156",
1725
+ "8k71p274z"
1726
+ ],
1727
+ "dense_ids": [],
1728
+ "graph_ids": [],
1729
+ "error": "list index out of range",
1730
+ "dense_hit_5": "",
1731
+ "dense_recall_5": "",
1732
+ "dense_precision_5": "",
1733
+ "graph_hit_5": "",
1734
+ "graph_recall_5": "",
1735
+ "graph_precision_5": "",
1736
+ "hit_delta_5": "",
1737
+ "recall_delta_5": "",
1738
+ "precision_delta_5": "",
1739
+ "dense_hit_10": "",
1740
+ "dense_recall_10": "",
1741
+ "dense_precision_10": "",
1742
+ "graph_hit_10": "",
1743
+ "graph_recall_10": "",
1744
+ "graph_precision_10": "",
1745
+ "hit_delta_10": "",
1746
+ "recall_delta_10": "",
1747
+ "precision_delta_10": "",
1748
+ "dense_hit_20": "",
1749
+ "dense_recall_20": "",
1750
+ "dense_precision_20": "",
1751
+ "graph_hit_20": "",
1752
+ "graph_recall_20": "",
1753
+ "graph_precision_20": "",
1754
+ "hit_delta_20": "",
1755
+ "recall_delta_20": "",
1756
+ "precision_delta_20": "",
1757
+ "dense_hit_30": "",
1758
+ "dense_recall_30": "",
1759
+ "dense_precision_30": "",
1760
+ "graph_hit_30": "",
1761
+ "graph_recall_30": "",
1762
+ "graph_precision_30": "",
1763
+ "hit_delta_30": "",
1764
+ "recall_delta_30": "",
1765
+ "precision_delta_30": "",
1766
+ "dense_hit_50": "",
1767
+ "dense_recall_50": "",
1768
+ "dense_precision_50": "",
1769
+ "graph_hit_50": "",
1770
+ "graph_recall_50": "",
1771
+ "graph_precision_50": "",
1772
+ "hit_delta_50": "",
1773
+ "recall_delta_50": "",
1774
+ "precision_delta_50": ""
1775
+ },
1776
+ {
1777
+ "question": "Show me some newspapers covering the 1916 World Series.",
1778
+ "question_type": "full_text",
1779
+ "rewritten_query": "1916 World Series newspapers",
1780
+ "use_graph": false,
1781
+ "num_ground_truths": 4,
1782
+ "dense_retrieved": 0,
1783
+ "graph_retrieved": 0,
1784
+ "ground_truth_ids": [
1785
+ "x346mh47g",
1786
+ "r7823s29k",
1787
+ "c247n520p",
1788
+ "g158jw90g"
1789
+ ],
1790
+ "dense_ids": [],
1791
+ "graph_ids": [],
1792
+ "error": "list index out of range",
1793
+ "dense_hit_5": "",
1794
+ "dense_recall_5": "",
1795
+ "dense_precision_5": "",
1796
+ "graph_hit_5": "",
1797
+ "graph_recall_5": "",
1798
+ "graph_precision_5": "",
1799
+ "hit_delta_5": "",
1800
+ "recall_delta_5": "",
1801
+ "precision_delta_5": "",
1802
+ "dense_hit_10": "",
1803
+ "dense_recall_10": "",
1804
+ "dense_precision_10": "",
1805
+ "graph_hit_10": "",
1806
+ "graph_recall_10": "",
1807
+ "graph_precision_10": "",
1808
+ "hit_delta_10": "",
1809
+ "recall_delta_10": "",
1810
+ "precision_delta_10": "",
1811
+ "dense_hit_20": "",
1812
+ "dense_recall_20": "",
1813
+ "dense_precision_20": "",
1814
+ "graph_hit_20": "",
1815
+ "graph_recall_20": "",
1816
+ "graph_precision_20": "",
1817
+ "hit_delta_20": "",
1818
+ "recall_delta_20": "",
1819
+ "precision_delta_20": "",
1820
+ "dense_hit_30": "",
1821
+ "dense_recall_30": "",
1822
+ "dense_precision_30": "",
1823
+ "graph_hit_30": "",
1824
+ "graph_recall_30": "",
1825
+ "graph_precision_30": "",
1826
+ "hit_delta_30": "",
1827
+ "recall_delta_30": "",
1828
+ "precision_delta_30": "",
1829
+ "dense_hit_50": "",
1830
+ "dense_recall_50": "",
1831
+ "dense_precision_50": "",
1832
+ "graph_hit_50": "",
1833
+ "graph_recall_50": "",
1834
+ "graph_precision_50": "",
1835
+ "hit_delta_50": "",
1836
+ "recall_delta_50": "",
1837
+ "precision_delta_50": ""
1838
+ },
1839
+ {
1840
+ "question": "Give me some historical info on the Zulu people in South Africa.",
1841
+ "question_type": "metadata",
1842
+ "rewritten_query": "historical information on the Zulu people in South Africa",
1843
+ "use_graph": false,
1844
+ "num_ground_truths": 4,
1845
+ "dense_retrieved": 0,
1846
+ "graph_retrieved": 0,
1847
+ "ground_truth_ids": [
1848
+ "1z40mh38h",
1849
+ "br86fd210",
1850
+ "jm218041r",
1851
+ "7p88cv41v"
1852
+ ],
1853
+ "dense_ids": [],
1854
+ "graph_ids": [],
1855
+ "error": "list index out of range",
1856
+ "dense_hit_5": "",
1857
+ "dense_recall_5": "",
1858
+ "dense_precision_5": "",
1859
+ "graph_hit_5": "",
1860
+ "graph_recall_5": "",
1861
+ "graph_precision_5": "",
1862
+ "hit_delta_5": "",
1863
+ "recall_delta_5": "",
1864
+ "precision_delta_5": "",
1865
+ "dense_hit_10": "",
1866
+ "dense_recall_10": "",
1867
+ "dense_precision_10": "",
1868
+ "graph_hit_10": "",
1869
+ "graph_recall_10": "",
1870
+ "graph_precision_10": "",
1871
+ "hit_delta_10": "",
1872
+ "recall_delta_10": "",
1873
+ "precision_delta_10": "",
1874
+ "dense_hit_20": "",
1875
+ "dense_recall_20": "",
1876
+ "dense_precision_20": "",
1877
+ "graph_hit_20": "",
1878
+ "graph_recall_20": "",
1879
+ "graph_precision_20": "",
1880
+ "hit_delta_20": "",
1881
+ "recall_delta_20": "",
1882
+ "precision_delta_20": "",
1883
+ "dense_hit_30": "",
1884
+ "dense_recall_30": "",
1885
+ "dense_precision_30": "",
1886
+ "graph_hit_30": "",
1887
+ "graph_recall_30": "",
1888
+ "graph_precision_30": "",
1889
+ "hit_delta_30": "",
1890
+ "recall_delta_30": "",
1891
+ "precision_delta_30": "",
1892
+ "dense_hit_50": "",
1893
+ "dense_recall_50": "",
1894
+ "dense_precision_50": "",
1895
+ "graph_hit_50": "",
1896
+ "graph_recall_50": "",
1897
+ "graph_precision_50": "",
1898
+ "hit_delta_50": "",
1899
+ "recall_delta_50": "",
1900
+ "precision_delta_50": ""
1901
+ },
1902
+ {
1903
+ "question": "Show me poems by Emily Dickinson, in her own handwriting",
1904
+ "question_type": "metadata",
1905
+ "rewritten_query": "poems by Emily Dickinson in her handwriting",
1906
+ "use_graph": false,
1907
+ "num_ground_truths": 4,
1908
+ "dense_retrieved": 0,
1909
+ "graph_retrieved": 0,
1910
+ "ground_truth_ids": [
1911
+ "fq977x21b",
1912
+ "kh04mw646",
1913
+ "fq977x56f",
1914
+ "fq977z101"
1915
+ ],
1916
+ "dense_ids": [],
1917
+ "graph_ids": [],
1918
+ "error": "list index out of range",
1919
+ "dense_hit_5": "",
1920
+ "dense_recall_5": "",
1921
+ "dense_precision_5": "",
1922
+ "graph_hit_5": "",
1923
+ "graph_recall_5": "",
1924
+ "graph_precision_5": "",
1925
+ "hit_delta_5": "",
1926
+ "recall_delta_5": "",
1927
+ "precision_delta_5": "",
1928
+ "dense_hit_10": "",
1929
+ "dense_recall_10": "",
1930
+ "dense_precision_10": "",
1931
+ "graph_hit_10": "",
1932
+ "graph_recall_10": "",
1933
+ "graph_precision_10": "",
1934
+ "hit_delta_10": "",
1935
+ "recall_delta_10": "",
1936
+ "precision_delta_10": "",
1937
+ "dense_hit_20": "",
1938
+ "dense_recall_20": "",
1939
+ "dense_precision_20": "",
1940
+ "graph_hit_20": "",
1941
+ "graph_recall_20": "",
1942
+ "graph_precision_20": "",
1943
+ "hit_delta_20": "",
1944
+ "recall_delta_20": "",
1945
+ "precision_delta_20": "",
1946
+ "dense_hit_30": "",
1947
+ "dense_recall_30": "",
1948
+ "dense_precision_30": "",
1949
+ "graph_hit_30": "",
1950
+ "graph_recall_30": "",
1951
+ "graph_precision_30": "",
1952
+ "hit_delta_30": "",
1953
+ "recall_delta_30": "",
1954
+ "precision_delta_30": "",
1955
+ "dense_hit_50": "",
1956
+ "dense_recall_50": "",
1957
+ "dense_precision_50": "",
1958
+ "graph_hit_50": "",
1959
+ "graph_recall_50": "",
1960
+ "graph_precision_50": "",
1961
+ "hit_delta_50": "",
1962
+ "recall_delta_50": "",
1963
+ "precision_delta_50": ""
1964
+ },
1965
+ {
1966
+ "question": "Who was William Lloyd Garrison and what did he do?",
1967
+ "question_type": "metadata",
1968
+ "rewritten_query": "William Lloyd Garrison biography and achievements",
1969
+ "use_graph": true,
1970
+ "num_ground_truths": 4,
1971
+ "dense_retrieved": 0,
1972
+ "graph_retrieved": 0,
1973
+ "ground_truth_ids": [
1974
+ "2514p017p",
1975
+ "8c97n842c",
1976
+ "70796c89x",
1977
+ "2v23x8010"
1978
+ ],
1979
+ "dense_ids": [],
1980
+ "graph_ids": [],
1981
+ "error": "list index out of range",
1982
+ "dense_hit_5": "",
1983
+ "dense_recall_5": "",
1984
+ "dense_precision_5": "",
1985
+ "graph_hit_5": "",
1986
+ "graph_recall_5": "",
1987
+ "graph_precision_5": "",
1988
+ "hit_delta_5": "",
1989
+ "recall_delta_5": "",
1990
+ "precision_delta_5": "",
1991
+ "dense_hit_10": "",
1992
+ "dense_recall_10": "",
1993
+ "dense_precision_10": "",
1994
+ "graph_hit_10": "",
1995
+ "graph_recall_10": "",
1996
+ "graph_precision_10": "",
1997
+ "hit_delta_10": "",
1998
+ "recall_delta_10": "",
1999
+ "precision_delta_10": "",
2000
+ "dense_hit_20": "",
2001
+ "dense_recall_20": "",
2002
+ "dense_precision_20": "",
2003
+ "graph_hit_20": "",
2004
+ "graph_recall_20": "",
2005
+ "graph_precision_20": "",
2006
+ "hit_delta_20": "",
2007
+ "recall_delta_20": "",
2008
+ "precision_delta_20": "",
2009
+ "dense_hit_30": "",
2010
+ "dense_recall_30": "",
2011
+ "dense_precision_30": "",
2012
+ "graph_hit_30": "",
2013
+ "graph_recall_30": "",
2014
+ "graph_precision_30": "",
2015
+ "hit_delta_30": "",
2016
+ "recall_delta_30": "",
2017
+ "precision_delta_30": "",
2018
+ "dense_hit_50": "",
2019
+ "dense_recall_50": "",
2020
+ "dense_precision_50": "",
2021
+ "graph_hit_50": "",
2022
+ "graph_recall_50": "",
2023
+ "graph_precision_50": "",
2024
+ "hit_delta_50": "",
2025
+ "recall_delta_50": "",
2026
+ "precision_delta_50": ""
2027
+ },
2028
+ {
2029
+ "question": "Were there ever any breweries based in Boston?",
2030
+ "question_type": "metadata",
2031
+ "rewritten_query": "breweries based in Boston",
2032
+ "use_graph": false,
2033
+ "num_ground_truths": 4,
2034
+ "dense_retrieved": 0,
2035
+ "graph_retrieved": 0,
2036
+ "ground_truth_ids": [
2037
+ "br86bd53r",
2038
+ "t435gm15v",
2039
+ "t435gm11r",
2040
+ "t435gm032"
2041
+ ],
2042
+ "dense_ids": [],
2043
+ "graph_ids": [],
2044
+ "error": "list index out of range",
2045
+ "dense_hit_5": "",
2046
+ "dense_recall_5": "",
2047
+ "dense_precision_5": "",
2048
+ "graph_hit_5": "",
2049
+ "graph_recall_5": "",
2050
+ "graph_precision_5": "",
2051
+ "hit_delta_5": "",
2052
+ "recall_delta_5": "",
2053
+ "precision_delta_5": "",
2054
+ "dense_hit_10": "",
2055
+ "dense_recall_10": "",
2056
+ "dense_precision_10": "",
2057
+ "graph_hit_10": "",
2058
+ "graph_recall_10": "",
2059
+ "graph_precision_10": "",
2060
+ "hit_delta_10": "",
2061
+ "recall_delta_10": "",
2062
+ "precision_delta_10": "",
2063
+ "dense_hit_20": "",
2064
+ "dense_recall_20": "",
2065
+ "dense_precision_20": "",
2066
+ "graph_hit_20": "",
2067
+ "graph_recall_20": "",
2068
+ "graph_precision_20": "",
2069
+ "hit_delta_20": "",
2070
+ "recall_delta_20": "",
2071
+ "precision_delta_20": "",
2072
+ "dense_hit_30": "",
2073
+ "dense_recall_30": "",
2074
+ "dense_precision_30": "",
2075
+ "graph_hit_30": "",
2076
+ "graph_recall_30": "",
2077
+ "graph_precision_30": "",
2078
+ "hit_delta_30": "",
2079
+ "recall_delta_30": "",
2080
+ "precision_delta_30": "",
2081
+ "dense_hit_50": "",
2082
+ "dense_recall_50": "",
2083
+ "dense_precision_50": "",
2084
+ "graph_hit_50": "",
2085
+ "graph_recall_50": "",
2086
+ "graph_precision_50": "",
2087
+ "hit_delta_50": "",
2088
+ "recall_delta_50": "",
2089
+ "precision_delta_50": ""
2090
+ },
2091
+ {
2092
+ "question": "What were the results of the 1935 Boston Marathon?",
2093
+ "question_type": "full_text",
2094
+ "rewritten_query": "1935 Boston Marathon results",
2095
+ "use_graph": false,
2096
+ "num_ground_truths": 4,
2097
+ "dense_retrieved": 0,
2098
+ "graph_retrieved": 0,
2099
+ "ground_truth_ids": [
2100
+ "3t946b82t",
2101
+ "3t946c14w",
2102
+ "1j92pv18d",
2103
+ "3t946b36q"
2104
+ ],
2105
+ "dense_ids": [],
2106
+ "graph_ids": [],
2107
+ "error": "list index out of range",
2108
+ "dense_hit_5": "",
2109
+ "dense_recall_5": "",
2110
+ "dense_precision_5": "",
2111
+ "graph_hit_5": "",
2112
+ "graph_recall_5": "",
2113
+ "graph_precision_5": "",
2114
+ "hit_delta_5": "",
2115
+ "recall_delta_5": "",
2116
+ "precision_delta_5": "",
2117
+ "dense_hit_10": "",
2118
+ "dense_recall_10": "",
2119
+ "dense_precision_10": "",
2120
+ "graph_hit_10": "",
2121
+ "graph_recall_10": "",
2122
+ "graph_precision_10": "",
2123
+ "hit_delta_10": "",
2124
+ "recall_delta_10": "",
2125
+ "precision_delta_10": "",
2126
+ "dense_hit_20": "",
2127
+ "dense_recall_20": "",
2128
+ "dense_precision_20": "",
2129
+ "graph_hit_20": "",
2130
+ "graph_recall_20": "",
2131
+ "graph_precision_20": "",
2132
+ "hit_delta_20": "",
2133
+ "recall_delta_20": "",
2134
+ "precision_delta_20": "",
2135
+ "dense_hit_30": "",
2136
+ "dense_recall_30": "",
2137
+ "dense_precision_30": "",
2138
+ "graph_hit_30": "",
2139
+ "graph_recall_30": "",
2140
+ "graph_precision_30": "",
2141
+ "hit_delta_30": "",
2142
+ "recall_delta_30": "",
2143
+ "precision_delta_30": "",
2144
+ "dense_hit_50": "",
2145
+ "dense_recall_50": "",
2146
+ "dense_precision_50": "",
2147
+ "graph_hit_50": "",
2148
+ "graph_recall_50": "",
2149
+ "graph_precision_50": "",
2150
+ "hit_delta_50": "",
2151
+ "recall_delta_50": "",
2152
+ "precision_delta_50": ""
2153
+ },
2154
+ {
2155
+ "question": "Can you help me find some old dessert recipes from the 1800s?",
2156
+ "question_type": "full_text",
2157
+ "rewritten_query": "dessert recipes from the 1800s",
2158
+ "use_graph": false,
2159
+ "num_ground_truths": 4,
2160
+ "dense_retrieved": 0,
2161
+ "graph_retrieved": 0,
2162
+ "ground_truth_ids": [
2163
+ "j0992n65t",
2164
+ "j0992q789",
2165
+ "xk81rw520",
2166
+ "w663bd023"
2167
+ ],
2168
+ "dense_ids": [],
2169
+ "graph_ids": [],
2170
+ "error": "list index out of range",
2171
+ "dense_hit_5": "",
2172
+ "dense_recall_5": "",
2173
+ "dense_precision_5": "",
2174
+ "graph_hit_5": "",
2175
+ "graph_recall_5": "",
2176
+ "graph_precision_5": "",
2177
+ "hit_delta_5": "",
2178
+ "recall_delta_5": "",
2179
+ "precision_delta_5": "",
2180
+ "dense_hit_10": "",
2181
+ "dense_recall_10": "",
2182
+ "dense_precision_10": "",
2183
+ "graph_hit_10": "",
2184
+ "graph_recall_10": "",
2185
+ "graph_precision_10": "",
2186
+ "hit_delta_10": "",
2187
+ "recall_delta_10": "",
2188
+ "precision_delta_10": "",
2189
+ "dense_hit_20": "",
2190
+ "dense_recall_20": "",
2191
+ "dense_precision_20": "",
2192
+ "graph_hit_20": "",
2193
+ "graph_recall_20": "",
2194
+ "graph_precision_20": "",
2195
+ "hit_delta_20": "",
2196
+ "recall_delta_20": "",
2197
+ "precision_delta_20": "",
2198
+ "dense_hit_30": "",
2199
+ "dense_recall_30": "",
2200
+ "dense_precision_30": "",
2201
+ "graph_hit_30": "",
2202
+ "graph_recall_30": "",
2203
+ "graph_precision_30": "",
2204
+ "hit_delta_30": "",
2205
+ "recall_delta_30": "",
2206
+ "precision_delta_30": "",
2207
+ "dense_hit_50": "",
2208
+ "dense_recall_50": "",
2209
+ "dense_precision_50": "",
2210
+ "graph_hit_50": "",
2211
+ "graph_recall_50": "",
2212
+ "graph_precision_50": "",
2213
+ "hit_delta_50": "",
2214
+ "recall_delta_50": "",
2215
+ "precision_delta_50": ""
2216
+ },
2217
+ {
2218
+ "question": "I am writing a report about the history of people making boats in Massachusetts, can you help me find some information?",
2219
+ "question_type": "metadata",
2220
+ "rewritten_query": "history of boat making in Massachusetts",
2221
+ "use_graph": false,
2222
+ "num_ground_truths": 4,
2223
+ "dense_retrieved": 0,
2224
+ "graph_retrieved": 0,
2225
+ "ground_truth_ids": [
2226
+ "cn69mf744",
2227
+ "1831dm900",
2228
+ "n009xf24t",
2229
+ "3r075h422"
2230
+ ],
2231
+ "dense_ids": [],
2232
+ "graph_ids": [],
2233
+ "error": "list index out of range",
2234
+ "dense_hit_5": "",
2235
+ "dense_recall_5": "",
2236
+ "dense_precision_5": "",
2237
+ "graph_hit_5": "",
2238
+ "graph_recall_5": "",
2239
+ "graph_precision_5": "",
2240
+ "hit_delta_5": "",
2241
+ "recall_delta_5": "",
2242
+ "precision_delta_5": "",
2243
+ "dense_hit_10": "",
2244
+ "dense_recall_10": "",
2245
+ "dense_precision_10": "",
2246
+ "graph_hit_10": "",
2247
+ "graph_recall_10": "",
2248
+ "graph_precision_10": "",
2249
+ "hit_delta_10": "",
2250
+ "recall_delta_10": "",
2251
+ "precision_delta_10": "",
2252
+ "dense_hit_20": "",
2253
+ "dense_recall_20": "",
2254
+ "dense_precision_20": "",
2255
+ "graph_hit_20": "",
2256
+ "graph_recall_20": "",
2257
+ "graph_precision_20": "",
2258
+ "hit_delta_20": "",
2259
+ "recall_delta_20": "",
2260
+ "precision_delta_20": "",
2261
+ "dense_hit_30": "",
2262
+ "dense_recall_30": "",
2263
+ "dense_precision_30": "",
2264
+ "graph_hit_30": "",
2265
+ "graph_recall_30": "",
2266
+ "graph_precision_30": "",
2267
+ "hit_delta_30": "",
2268
+ "recall_delta_30": "",
2269
+ "precision_delta_30": "",
2270
+ "dense_hit_50": "",
2271
+ "dense_recall_50": "",
2272
+ "dense_precision_50": "",
2273
+ "graph_hit_50": "",
2274
+ "graph_recall_50": "",
2275
+ "graph_precision_50": "",
2276
+ "hit_delta_50": "",
2277
+ "recall_delta_50": "",
2278
+ "precision_delta_50": ""
2279
+ },
2280
+ {
2281
+ "question": "I want to find some examples of what tickets to a baseball game looked like back in the old days.",
2282
+ "question_type": "metadata",
2283
+ "rewritten_query": "baseball game tickets examples",
2284
+ "use_graph": false,
2285
+ "num_ground_truths": 3,
2286
+ "dense_retrieved": 0,
2287
+ "graph_retrieved": 0,
2288
+ "ground_truth_ids": [
2289
+ "gq67jx919",
2290
+ "gq67jx97z",
2291
+ "gq67jz002"
2292
+ ],
2293
+ "dense_ids": [],
2294
+ "graph_ids": [],
2295
+ "error": "list index out of range",
2296
+ "dense_hit_5": "",
2297
+ "dense_recall_5": "",
2298
+ "dense_precision_5": "",
2299
+ "graph_hit_5": "",
2300
+ "graph_recall_5": "",
2301
+ "graph_precision_5": "",
2302
+ "hit_delta_5": "",
2303
+ "recall_delta_5": "",
2304
+ "precision_delta_5": "",
2305
+ "dense_hit_10": "",
2306
+ "dense_recall_10": "",
2307
+ "dense_precision_10": "",
2308
+ "graph_hit_10": "",
2309
+ "graph_recall_10": "",
2310
+ "graph_precision_10": "",
2311
+ "hit_delta_10": "",
2312
+ "recall_delta_10": "",
2313
+ "precision_delta_10": "",
2314
+ "dense_hit_20": "",
2315
+ "dense_recall_20": "",
2316
+ "dense_precision_20": "",
2317
+ "graph_hit_20": "",
2318
+ "graph_recall_20": "",
2319
+ "graph_precision_20": "",
2320
+ "hit_delta_20": "",
2321
+ "recall_delta_20": "",
2322
+ "precision_delta_20": "",
2323
+ "dense_hit_30": "",
2324
+ "dense_recall_30": "",
2325
+ "dense_precision_30": "",
2326
+ "graph_hit_30": "",
2327
+ "graph_recall_30": "",
2328
+ "graph_precision_30": "",
2329
+ "hit_delta_30": "",
2330
+ "recall_delta_30": "",
2331
+ "precision_delta_30": "",
2332
+ "dense_hit_50": "",
2333
+ "dense_recall_50": "",
2334
+ "dense_precision_50": "",
2335
+ "graph_hit_50": "",
2336
+ "graph_recall_50": "",
2337
+ "graph_precision_50": "",
2338
+ "hit_delta_50": "",
2339
+ "recall_delta_50": "",
2340
+ "precision_delta_50": ""
2341
+ },
2342
+ {
2343
+ "question": "Who were the Boston Americans?",
2344
+ "question_type": "metadata",
2345
+ "rewritten_query": "Boston Americans baseball team",
2346
+ "use_graph": true,
2347
+ "num_ground_truths": 4,
2348
+ "dense_retrieved": 0,
2349
+ "graph_retrieved": 0,
2350
+ "ground_truth_ids": [
2351
+ "sf268644d",
2352
+ "sf268560k",
2353
+ "sf268672k",
2354
+ "sf268850z"
2355
+ ],
2356
+ "dense_ids": [],
2357
+ "graph_ids": [],
2358
+ "error": "list index out of range",
2359
+ "dense_hit_5": "",
2360
+ "dense_recall_5": "",
2361
+ "dense_precision_5": "",
2362
+ "graph_hit_5": "",
2363
+ "graph_recall_5": "",
2364
+ "graph_precision_5": "",
2365
+ "hit_delta_5": "",
2366
+ "recall_delta_5": "",
2367
+ "precision_delta_5": "",
2368
+ "dense_hit_10": "",
2369
+ "dense_recall_10": "",
2370
+ "dense_precision_10": "",
2371
+ "graph_hit_10": "",
2372
+ "graph_recall_10": "",
2373
+ "graph_precision_10": "",
2374
+ "hit_delta_10": "",
2375
+ "recall_delta_10": "",
2376
+ "precision_delta_10": "",
2377
+ "dense_hit_20": "",
2378
+ "dense_recall_20": "",
2379
+ "dense_precision_20": "",
2380
+ "graph_hit_20": "",
2381
+ "graph_recall_20": "",
2382
+ "graph_precision_20": "",
2383
+ "hit_delta_20": "",
2384
+ "recall_delta_20": "",
2385
+ "precision_delta_20": "",
2386
+ "dense_hit_30": "",
2387
+ "dense_recall_30": "",
2388
+ "dense_precision_30": "",
2389
+ "graph_hit_30": "",
2390
+ "graph_recall_30": "",
2391
+ "graph_precision_30": "",
2392
+ "hit_delta_30": "",
2393
+ "recall_delta_30": "",
2394
+ "precision_delta_30": "",
2395
+ "dense_hit_50": "",
2396
+ "dense_recall_50": "",
2397
+ "dense_precision_50": "",
2398
+ "graph_hit_50": "",
2399
+ "graph_recall_50": "",
2400
+ "graph_precision_50": "",
2401
+ "hit_delta_50": "",
2402
+ "recall_delta_50": "",
2403
+ "precision_delta_50": ""
2404
+ },
2405
+ {
2406
+ "question": "Can you help me find some historical maps of Boston's subway system from the early 1900s?",
2407
+ "question_type": "metadata",
2408
+ "rewritten_query": "historical maps of Boston subway system",
2409
+ "use_graph": false,
2410
+ "num_ground_truths": 4,
2411
+ "dense_retrieved": 0,
2412
+ "graph_retrieved": 0,
2413
+ "ground_truth_ids": [
2414
+ "4x51m294f",
2415
+ "0v83bg708",
2416
+ "g732gs78m",
2417
+ "34852076k"
2418
+ ],
2419
+ "dense_ids": [],
2420
+ "graph_ids": [],
2421
+ "error": "list index out of range",
2422
+ "dense_hit_5": "",
2423
+ "dense_recall_5": "",
2424
+ "dense_precision_5": "",
2425
+ "graph_hit_5": "",
2426
+ "graph_recall_5": "",
2427
+ "graph_precision_5": "",
2428
+ "hit_delta_5": "",
2429
+ "recall_delta_5": "",
2430
+ "precision_delta_5": "",
2431
+ "dense_hit_10": "",
2432
+ "dense_recall_10": "",
2433
+ "dense_precision_10": "",
2434
+ "graph_hit_10": "",
2435
+ "graph_recall_10": "",
2436
+ "graph_precision_10": "",
2437
+ "hit_delta_10": "",
2438
+ "recall_delta_10": "",
2439
+ "precision_delta_10": "",
2440
+ "dense_hit_20": "",
2441
+ "dense_recall_20": "",
2442
+ "dense_precision_20": "",
2443
+ "graph_hit_20": "",
2444
+ "graph_recall_20": "",
2445
+ "graph_precision_20": "",
2446
+ "hit_delta_20": "",
2447
+ "recall_delta_20": "",
2448
+ "precision_delta_20": "",
2449
+ "dense_hit_30": "",
2450
+ "dense_recall_30": "",
2451
+ "dense_precision_30": "",
2452
+ "graph_hit_30": "",
2453
+ "graph_recall_30": "",
2454
+ "graph_precision_30": "",
2455
+ "hit_delta_30": "",
2456
+ "recall_delta_30": "",
2457
+ "precision_delta_30": "",
2458
+ "dense_hit_50": "",
2459
+ "dense_recall_50": "",
2460
+ "dense_precision_50": "",
2461
+ "graph_hit_50": "",
2462
+ "graph_recall_50": "",
2463
+ "graph_precision_50": "",
2464
+ "hit_delta_50": "",
2465
+ "recall_delta_50": "",
2466
+ "precision_delta_50": ""
2467
+ },
2468
+ {
2469
+ "question": "My class is reading Moby Dick and I have to do a project about whaling in Massachusetts, can you help me find some sources?",
2470
+ "question_type": "metadata",
2471
+ "rewritten_query": "whaling in Massachusetts",
2472
+ "use_graph": false,
2473
+ "num_ground_truths": 4,
2474
+ "dense_retrieved": 0,
2475
+ "graph_retrieved": 0,
2476
+ "ground_truth_ids": [
2477
+ "rv049z346",
2478
+ "5h73rx028",
2479
+ "cv43p6866",
2480
+ "nk322p393"
2481
+ ],
2482
+ "dense_ids": [],
2483
+ "graph_ids": [],
2484
+ "error": "list index out of range",
2485
+ "dense_hit_5": "",
2486
+ "dense_recall_5": "",
2487
+ "dense_precision_5": "",
2488
+ "graph_hit_5": "",
2489
+ "graph_recall_5": "",
2490
+ "graph_precision_5": "",
2491
+ "hit_delta_5": "",
2492
+ "recall_delta_5": "",
2493
+ "precision_delta_5": "",
2494
+ "dense_hit_10": "",
2495
+ "dense_recall_10": "",
2496
+ "dense_precision_10": "",
2497
+ "graph_hit_10": "",
2498
+ "graph_recall_10": "",
2499
+ "graph_precision_10": "",
2500
+ "hit_delta_10": "",
2501
+ "recall_delta_10": "",
2502
+ "precision_delta_10": "",
2503
+ "dense_hit_20": "",
2504
+ "dense_recall_20": "",
2505
+ "dense_precision_20": "",
2506
+ "graph_hit_20": "",
2507
+ "graph_recall_20": "",
2508
+ "graph_precision_20": "",
2509
+ "hit_delta_20": "",
2510
+ "recall_delta_20": "",
2511
+ "precision_delta_20": "",
2512
+ "dense_hit_30": "",
2513
+ "dense_recall_30": "",
2514
+ "dense_precision_30": "",
2515
+ "graph_hit_30": "",
2516
+ "graph_recall_30": "",
2517
+ "graph_precision_30": "",
2518
+ "hit_delta_30": "",
2519
+ "recall_delta_30": "",
2520
+ "precision_delta_30": "",
2521
+ "dense_hit_50": "",
2522
+ "dense_recall_50": "",
2523
+ "dense_precision_50": "",
2524
+ "graph_hit_50": "",
2525
+ "graph_recall_50": "",
2526
+ "graph_precision_50": "",
2527
+ "hit_delta_50": "",
2528
+ "recall_delta_50": "",
2529
+ "precision_delta_50": ""
2530
+ },
2531
+ {
2532
+ "question": "Is it true that Harvard used to have a good football team?",
2533
+ "question_type": "full_text",
2534
+ "rewritten_query": "Harvard football team historical performance",
2535
+ "use_graph": false,
2536
+ "num_ground_truths": 4,
2537
+ "dense_retrieved": 0,
2538
+ "graph_retrieved": 0,
2539
+ "ground_truth_ids": [
2540
+ "90200b57q",
2541
+ "g158jv887",
2542
+ "t148nn16h",
2543
+ "0z70h6684"
2544
+ ],
2545
+ "dense_ids": [],
2546
+ "graph_ids": [],
2547
+ "error": "list index out of range",
2548
+ "dense_hit_5": "",
2549
+ "dense_recall_5": "",
2550
+ "dense_precision_5": "",
2551
+ "graph_hit_5": "",
2552
+ "graph_recall_5": "",
2553
+ "graph_precision_5": "",
2554
+ "hit_delta_5": "",
2555
+ "recall_delta_5": "",
2556
+ "precision_delta_5": "",
2557
+ "dense_hit_10": "",
2558
+ "dense_recall_10": "",
2559
+ "dense_precision_10": "",
2560
+ "graph_hit_10": "",
2561
+ "graph_recall_10": "",
2562
+ "graph_precision_10": "",
2563
+ "hit_delta_10": "",
2564
+ "recall_delta_10": "",
2565
+ "precision_delta_10": "",
2566
+ "dense_hit_20": "",
2567
+ "dense_recall_20": "",
2568
+ "dense_precision_20": "",
2569
+ "graph_hit_20": "",
2570
+ "graph_recall_20": "",
2571
+ "graph_precision_20": "",
2572
+ "hit_delta_20": "",
2573
+ "recall_delta_20": "",
2574
+ "precision_delta_20": "",
2575
+ "dense_hit_30": "",
2576
+ "dense_recall_30": "",
2577
+ "dense_precision_30": "",
2578
+ "graph_hit_30": "",
2579
+ "graph_recall_30": "",
2580
+ "graph_precision_30": "",
2581
+ "hit_delta_30": "",
2582
+ "recall_delta_30": "",
2583
+ "precision_delta_30": "",
2584
+ "dense_hit_50": "",
2585
+ "dense_recall_50": "",
2586
+ "dense_precision_50": "",
2587
+ "graph_hit_50": "",
2588
+ "graph_recall_50": "",
2589
+ "graph_precision_50": "",
2590
+ "hit_delta_50": "",
2591
+ "recall_delta_50": "",
2592
+ "precision_delta_50": ""
2593
+ },
2594
+ {
2595
+ "question": "Find some newspaper articles about the sinking of the Lusitania during WWI",
2596
+ "question_type": "full_text",
2597
+ "rewritten_query": "sinking of the Lusitania during World War I",
2598
+ "use_graph": false,
2599
+ "num_ground_truths": 4,
2600
+ "dense_retrieved": 0,
2601
+ "graph_retrieved": 0,
2602
+ "ground_truth_ids": [
2603
+ "0v83gc74f",
2604
+ "v118zs65j",
2605
+ "7d27h447j",
2606
+ "4b29jq26h"
2607
+ ],
2608
+ "dense_ids": [],
2609
+ "graph_ids": [],
2610
+ "error": "list index out of range",
2611
+ "dense_hit_5": "",
2612
+ "dense_recall_5": "",
2613
+ "dense_precision_5": "",
2614
+ "graph_hit_5": "",
2615
+ "graph_recall_5": "",
2616
+ "graph_precision_5": "",
2617
+ "hit_delta_5": "",
2618
+ "recall_delta_5": "",
2619
+ "precision_delta_5": "",
2620
+ "dense_hit_10": "",
2621
+ "dense_recall_10": "",
2622
+ "dense_precision_10": "",
2623
+ "graph_hit_10": "",
2624
+ "graph_recall_10": "",
2625
+ "graph_precision_10": "",
2626
+ "hit_delta_10": "",
2627
+ "recall_delta_10": "",
2628
+ "precision_delta_10": "",
2629
+ "dense_hit_20": "",
2630
+ "dense_recall_20": "",
2631
+ "dense_precision_20": "",
2632
+ "graph_hit_20": "",
2633
+ "graph_recall_20": "",
2634
+ "graph_precision_20": "",
2635
+ "hit_delta_20": "",
2636
+ "recall_delta_20": "",
2637
+ "precision_delta_20": "",
2638
+ "dense_hit_30": "",
2639
+ "dense_recall_30": "",
2640
+ "dense_precision_30": "",
2641
+ "graph_hit_30": "",
2642
+ "graph_recall_30": "",
2643
+ "graph_precision_30": "",
2644
+ "hit_delta_30": "",
2645
+ "recall_delta_30": "",
2646
+ "precision_delta_30": "",
2647
+ "dense_hit_50": "",
2648
+ "dense_recall_50": "",
2649
+ "dense_precision_50": "",
2650
+ "graph_hit_50": "",
2651
+ "graph_recall_50": "",
2652
+ "graph_precision_50": "",
2653
+ "hit_delta_50": "",
2654
+ "recall_delta_50": "",
2655
+ "precision_delta_50": ""
2656
+ },
2657
+ {
2658
+ "question": "Were there any people from Boston on the Titanic when it sank?",
2659
+ "question_type": "full_text",
2660
+ "rewritten_query": "Boston passengers on the Titanic",
2661
+ "use_graph": true,
2662
+ "num_ground_truths": 4,
2663
+ "dense_retrieved": 0,
2664
+ "graph_retrieved": 0,
2665
+ "ground_truth_ids": [
2666
+ "sn00j357m",
2667
+ "nc586t329",
2668
+ "xd07q011v",
2669
+ "5138rj364"
2670
+ ],
2671
+ "dense_ids": [],
2672
+ "graph_ids": [],
2673
+ "error": "list index out of range",
2674
+ "dense_hit_5": "",
2675
+ "dense_recall_5": "",
2676
+ "dense_precision_5": "",
2677
+ "graph_hit_5": "",
2678
+ "graph_recall_5": "",
2679
+ "graph_precision_5": "",
2680
+ "hit_delta_5": "",
2681
+ "recall_delta_5": "",
2682
+ "precision_delta_5": "",
2683
+ "dense_hit_10": "",
2684
+ "dense_recall_10": "",
2685
+ "dense_precision_10": "",
2686
+ "graph_hit_10": "",
2687
+ "graph_recall_10": "",
2688
+ "graph_precision_10": "",
2689
+ "hit_delta_10": "",
2690
+ "recall_delta_10": "",
2691
+ "precision_delta_10": "",
2692
+ "dense_hit_20": "",
2693
+ "dense_recall_20": "",
2694
+ "dense_precision_20": "",
2695
+ "graph_hit_20": "",
2696
+ "graph_recall_20": "",
2697
+ "graph_precision_20": "",
2698
+ "hit_delta_20": "",
2699
+ "recall_delta_20": "",
2700
+ "precision_delta_20": "",
2701
+ "dense_hit_30": "",
2702
+ "dense_recall_30": "",
2703
+ "dense_precision_30": "",
2704
+ "graph_hit_30": "",
2705
+ "graph_recall_30": "",
2706
+ "graph_precision_30": "",
2707
+ "hit_delta_30": "",
2708
+ "recall_delta_30": "",
2709
+ "precision_delta_30": "",
2710
+ "dense_hit_50": "",
2711
+ "dense_recall_50": "",
2712
+ "dense_precision_50": "",
2713
+ "graph_hit_50": "",
2714
+ "graph_recall_50": "",
2715
+ "graph_precision_50": "",
2716
+ "hit_delta_50": "",
2717
+ "recall_delta_50": "",
2718
+ "precision_delta_50": ""
2719
+ },
2720
+ {
2721
+ "question": "What caused the Great Depression?",
2722
+ "question_type": "full_text",
2723
+ "rewritten_query": "causes of the Great Depression",
2724
+ "use_graph": true,
2725
+ "num_ground_truths": 4,
2726
+ "dense_retrieved": 0,
2727
+ "graph_retrieved": 0,
2728
+ "ground_truth_ids": [
2729
+ "sn00jh80w",
2730
+ "jq08d420g",
2731
+ "8k71w2448",
2732
+ "kw52rs605"
2733
+ ],
2734
+ "dense_ids": [],
2735
+ "graph_ids": [],
2736
+ "error": "list index out of range",
2737
+ "dense_hit_5": "",
2738
+ "dense_recall_5": "",
2739
+ "dense_precision_5": "",
2740
+ "graph_hit_5": "",
2741
+ "graph_recall_5": "",
2742
+ "graph_precision_5": "",
2743
+ "hit_delta_5": "",
2744
+ "recall_delta_5": "",
2745
+ "precision_delta_5": "",
2746
+ "dense_hit_10": "",
2747
+ "dense_recall_10": "",
2748
+ "dense_precision_10": "",
2749
+ "graph_hit_10": "",
2750
+ "graph_recall_10": "",
2751
+ "graph_precision_10": "",
2752
+ "hit_delta_10": "",
2753
+ "recall_delta_10": "",
2754
+ "precision_delta_10": "",
2755
+ "dense_hit_20": "",
2756
+ "dense_recall_20": "",
2757
+ "dense_precision_20": "",
2758
+ "graph_hit_20": "",
2759
+ "graph_recall_20": "",
2760
+ "graph_precision_20": "",
2761
+ "hit_delta_20": "",
2762
+ "recall_delta_20": "",
2763
+ "precision_delta_20": "",
2764
+ "dense_hit_30": "",
2765
+ "dense_recall_30": "",
2766
+ "dense_precision_30": "",
2767
+ "graph_hit_30": "",
2768
+ "graph_recall_30": "",
2769
+ "graph_precision_30": "",
2770
+ "hit_delta_30": "",
2771
+ "recall_delta_30": "",
2772
+ "precision_delta_30": "",
2773
+ "dense_hit_50": "",
2774
+ "dense_recall_50": "",
2775
+ "dense_precision_50": "",
2776
+ "graph_hit_50": "",
2777
+ "graph_recall_50": "",
2778
+ "graph_precision_50": "",
2779
+ "hit_delta_50": "",
2780
+ "recall_delta_50": "",
2781
+ "precision_delta_50": ""
2782
+ },
2783
+ {
2784
+ "question": "What information can you find about the Coconut Grove fire disaster?",
2785
+ "question_type": "metadata",
2786
+ "rewritten_query": "Coconut Grove fire disaster",
2787
+ "use_graph": true,
2788
+ "num_ground_truths": 4,
2789
+ "dense_retrieved": 0,
2790
+ "graph_retrieved": 0,
2791
+ "ground_truth_ids": [
2792
+ "x346fh915",
2793
+ "x346fh99c",
2794
+ "7s75dh42x",
2795
+ "7s75dh42x"
2796
+ ],
2797
+ "dense_ids": [],
2798
+ "graph_ids": [],
2799
+ "error": "list index out of range",
2800
+ "dense_hit_5": "",
2801
+ "dense_recall_5": "",
2802
+ "dense_precision_5": "",
2803
+ "graph_hit_5": "",
2804
+ "graph_recall_5": "",
2805
+ "graph_precision_5": "",
2806
+ "hit_delta_5": "",
2807
+ "recall_delta_5": "",
2808
+ "precision_delta_5": "",
2809
+ "dense_hit_10": "",
2810
+ "dense_recall_10": "",
2811
+ "dense_precision_10": "",
2812
+ "graph_hit_10": "",
2813
+ "graph_recall_10": "",
2814
+ "graph_precision_10": "",
2815
+ "hit_delta_10": "",
2816
+ "recall_delta_10": "",
2817
+ "precision_delta_10": "",
2818
+ "dense_hit_20": "",
2819
+ "dense_recall_20": "",
2820
+ "dense_precision_20": "",
2821
+ "graph_hit_20": "",
2822
+ "graph_recall_20": "",
2823
+ "graph_precision_20": "",
2824
+ "hit_delta_20": "",
2825
+ "recall_delta_20": "",
2826
+ "precision_delta_20": "",
2827
+ "dense_hit_30": "",
2828
+ "dense_recall_30": "",
2829
+ "dense_precision_30": "",
2830
+ "graph_hit_30": "",
2831
+ "graph_recall_30": "",
2832
+ "graph_precision_30": "",
2833
+ "hit_delta_30": "",
2834
+ "recall_delta_30": "",
2835
+ "precision_delta_30": "",
2836
+ "dense_hit_50": "",
2837
+ "dense_recall_50": "",
2838
+ "dense_precision_50": "",
2839
+ "graph_hit_50": "",
2840
+ "graph_recall_50": "",
2841
+ "graph_precision_50": "",
2842
+ "hit_delta_50": "",
2843
+ "recall_delta_50": "",
2844
+ "precision_delta_50": ""
2845
+ },
2846
+ {
2847
+ "question": "How did Boston mark the end of Prohibition?",
2848
+ "question_type": "full_text",
2849
+ "rewritten_query": "Boston end of Prohibition celebrations",
2850
+ "use_graph": false,
2851
+ "num_ground_truths": 4,
2852
+ "dense_retrieved": 0,
2853
+ "graph_retrieved": 0,
2854
+ "ground_truth_ids": [
2855
+ "h128w283j",
2856
+ "g732mx538",
2857
+ "q524n563d",
2858
+ "zc781f69b"
2859
+ ],
2860
+ "dense_ids": [],
2861
+ "graph_ids": [],
2862
+ "error": "list index out of range",
2863
+ "dense_hit_5": "",
2864
+ "dense_recall_5": "",
2865
+ "dense_precision_5": "",
2866
+ "graph_hit_5": "",
2867
+ "graph_recall_5": "",
2868
+ "graph_precision_5": "",
2869
+ "hit_delta_5": "",
2870
+ "recall_delta_5": "",
2871
+ "precision_delta_5": "",
2872
+ "dense_hit_10": "",
2873
+ "dense_recall_10": "",
2874
+ "dense_precision_10": "",
2875
+ "graph_hit_10": "",
2876
+ "graph_recall_10": "",
2877
+ "graph_precision_10": "",
2878
+ "hit_delta_10": "",
2879
+ "recall_delta_10": "",
2880
+ "precision_delta_10": "",
2881
+ "dense_hit_20": "",
2882
+ "dense_recall_20": "",
2883
+ "dense_precision_20": "",
2884
+ "graph_hit_20": "",
2885
+ "graph_recall_20": "",
2886
+ "graph_precision_20": "",
2887
+ "hit_delta_20": "",
2888
+ "recall_delta_20": "",
2889
+ "precision_delta_20": "",
2890
+ "dense_hit_30": "",
2891
+ "dense_recall_30": "",
2892
+ "dense_precision_30": "",
2893
+ "graph_hit_30": "",
2894
+ "graph_recall_30": "",
2895
+ "graph_precision_30": "",
2896
+ "hit_delta_30": "",
2897
+ "recall_delta_30": "",
2898
+ "precision_delta_30": "",
2899
+ "dense_hit_50": "",
2900
+ "dense_recall_50": "",
2901
+ "dense_precision_50": "",
2902
+ "graph_hit_50": "",
2903
+ "graph_recall_50": "",
2904
+ "graph_precision_50": "",
2905
+ "hit_delta_50": "",
2906
+ "recall_delta_50": "",
2907
+ "precision_delta_50": ""
2908
+ },
2909
+ {
2910
+ "question": "Find some information about Charles Ponzi, including newspaper articles and photographs if possible",
2911
+ "question_type": "full_text",
2912
+ "rewritten_query": "Charles Ponzi",
2913
+ "use_graph": true,
2914
+ "num_ground_truths": 5,
2915
+ "dense_retrieved": 0,
2916
+ "graph_retrieved": 0,
2917
+ "ground_truth_ids": [
2918
+ "6682zs44c",
2919
+ "b8515n701",
2920
+ "sb39fz533",
2921
+ "xs55tr436",
2922
+ "6t059v54k"
2923
+ ],
2924
+ "dense_ids": [],
2925
+ "graph_ids": [],
2926
+ "error": "list index out of range",
2927
+ "dense_hit_5": "",
2928
+ "dense_recall_5": "",
2929
+ "dense_precision_5": "",
2930
+ "graph_hit_5": "",
2931
+ "graph_recall_5": "",
2932
+ "graph_precision_5": "",
2933
+ "hit_delta_5": "",
2934
+ "recall_delta_5": "",
2935
+ "precision_delta_5": "",
2936
+ "dense_hit_10": "",
2937
+ "dense_recall_10": "",
2938
+ "dense_precision_10": "",
2939
+ "graph_hit_10": "",
2940
+ "graph_recall_10": "",
2941
+ "graph_precision_10": "",
2942
+ "hit_delta_10": "",
2943
+ "recall_delta_10": "",
2944
+ "precision_delta_10": "",
2945
+ "dense_hit_20": "",
2946
+ "dense_recall_20": "",
2947
+ "dense_precision_20": "",
2948
+ "graph_hit_20": "",
2949
+ "graph_recall_20": "",
2950
+ "graph_precision_20": "",
2951
+ "hit_delta_20": "",
2952
+ "recall_delta_20": "",
2953
+ "precision_delta_20": "",
2954
+ "dense_hit_30": "",
2955
+ "dense_recall_30": "",
2956
+ "dense_precision_30": "",
2957
+ "graph_hit_30": "",
2958
+ "graph_recall_30": "",
2959
+ "graph_precision_30": "",
2960
+ "hit_delta_30": "",
2961
+ "recall_delta_30": "",
2962
+ "precision_delta_30": "",
2963
+ "dense_hit_50": "",
2964
+ "dense_recall_50": "",
2965
+ "dense_precision_50": "",
2966
+ "graph_hit_50": "",
2967
+ "graph_recall_50": "",
2968
+ "graph_precision_50": "",
2969
+ "hit_delta_50": "",
2970
+ "recall_delta_50": "",
2971
+ "precision_delta_50": ""
2972
+ },
2973
+ {
2974
+ "question": "I am writing a report about historical presidential assassinations, can you help me find some sources?",
2975
+ "question_type": "full_text",
2976
+ "rewritten_query": "historical presidential assassinations",
2977
+ "use_graph": true,
2978
+ "num_ground_truths": 4,
2979
+ "dense_retrieved": 0,
2980
+ "graph_retrieved": 0,
2981
+ "ground_truth_ids": [
2982
+ "5x21v606k",
2983
+ "cn69tz48n",
2984
+ "3b597p07t",
2985
+ "bz60m1252"
2986
+ ],
2987
+ "dense_ids": [],
2988
+ "graph_ids": [],
2989
+ "error": "list index out of range",
2990
+ "dense_hit_5": "",
2991
+ "dense_recall_5": "",
2992
+ "dense_precision_5": "",
2993
+ "graph_hit_5": "",
2994
+ "graph_recall_5": "",
2995
+ "graph_precision_5": "",
2996
+ "hit_delta_5": "",
2997
+ "recall_delta_5": "",
2998
+ "precision_delta_5": "",
2999
+ "dense_hit_10": "",
3000
+ "dense_recall_10": "",
3001
+ "dense_precision_10": "",
3002
+ "graph_hit_10": "",
3003
+ "graph_recall_10": "",
3004
+ "graph_precision_10": "",
3005
+ "hit_delta_10": "",
3006
+ "recall_delta_10": "",
3007
+ "precision_delta_10": "",
3008
+ "dense_hit_20": "",
3009
+ "dense_recall_20": "",
3010
+ "dense_precision_20": "",
3011
+ "graph_hit_20": "",
3012
+ "graph_recall_20": "",
3013
+ "graph_precision_20": "",
3014
+ "hit_delta_20": "",
3015
+ "recall_delta_20": "",
3016
+ "precision_delta_20": "",
3017
+ "dense_hit_30": "",
3018
+ "dense_recall_30": "",
3019
+ "dense_precision_30": "",
3020
+ "graph_hit_30": "",
3021
+ "graph_recall_30": "",
3022
+ "graph_precision_30": "",
3023
+ "hit_delta_30": "",
3024
+ "recall_delta_30": "",
3025
+ "precision_delta_30": "",
3026
+ "dense_hit_50": "",
3027
+ "dense_recall_50": "",
3028
+ "dense_precision_50": "",
3029
+ "graph_hit_50": "",
3030
+ "graph_recall_50": "",
3031
+ "graph_precision_50": "",
3032
+ "hit_delta_50": "",
3033
+ "recall_delta_50": "",
3034
+ "precision_delta_50": ""
3035
+ },
3036
+ {
3037
+ "question": "Find some information about the Bread and Roses strike in Lawrence, Mass., including pictures",
3038
+ "question_type": "full_text",
3039
+ "rewritten_query": "Bread and Roses strike Lawrence Massachusetts",
3040
+ "use_graph": true,
3041
+ "num_ground_truths": 4,
3042
+ "dense_retrieved": 0,
3043
+ "graph_retrieved": 0,
3044
+ "ground_truth_ids": [
3045
+ "wd3794346",
3046
+ "2801ss217",
3047
+ "dz016w71v",
3048
+ "1n79q7966"
3049
+ ],
3050
+ "dense_ids": [],
3051
+ "graph_ids": [],
3052
+ "error": "list index out of range",
3053
+ "dense_hit_5": "",
3054
+ "dense_recall_5": "",
3055
+ "dense_precision_5": "",
3056
+ "graph_hit_5": "",
3057
+ "graph_recall_5": "",
3058
+ "graph_precision_5": "",
3059
+ "hit_delta_5": "",
3060
+ "recall_delta_5": "",
3061
+ "precision_delta_5": "",
3062
+ "dense_hit_10": "",
3063
+ "dense_recall_10": "",
3064
+ "dense_precision_10": "",
3065
+ "graph_hit_10": "",
3066
+ "graph_recall_10": "",
3067
+ "graph_precision_10": "",
3068
+ "hit_delta_10": "",
3069
+ "recall_delta_10": "",
3070
+ "precision_delta_10": "",
3071
+ "dense_hit_20": "",
3072
+ "dense_recall_20": "",
3073
+ "dense_precision_20": "",
3074
+ "graph_hit_20": "",
3075
+ "graph_recall_20": "",
3076
+ "graph_precision_20": "",
3077
+ "hit_delta_20": "",
3078
+ "recall_delta_20": "",
3079
+ "precision_delta_20": "",
3080
+ "dense_hit_30": "",
3081
+ "dense_recall_30": "",
3082
+ "dense_precision_30": "",
3083
+ "graph_hit_30": "",
3084
+ "graph_recall_30": "",
3085
+ "graph_precision_30": "",
3086
+ "hit_delta_30": "",
3087
+ "recall_delta_30": "",
3088
+ "precision_delta_30": "",
3089
+ "dense_hit_50": "",
3090
+ "dense_recall_50": "",
3091
+ "dense_precision_50": "",
3092
+ "graph_hit_50": "",
3093
+ "graph_recall_50": "",
3094
+ "graph_precision_50": "",
3095
+ "hit_delta_50": "",
3096
+ "recall_delta_50": "",
3097
+ "precision_delta_50": ""
3098
+ },
3099
+ {
3100
+ "question": "Find a variety of newspaper articles about women who were accused of killing their husbands",
3101
+ "question_type": "full_text",
3102
+ "rewritten_query": "newspaper articles about women accused of killing their husbands",
3103
+ "use_graph": false,
3104
+ "num_ground_truths": 4,
3105
+ "dense_retrieved": 0,
3106
+ "graph_retrieved": 0,
3107
+ "ground_truth_ids": [
3108
+ "7079cb86r",
3109
+ "1v53sm39k",
3110
+ "m326t676k",
3111
+ "x920pd34h"
3112
+ ],
3113
+ "dense_ids": [],
3114
+ "graph_ids": [],
3115
+ "error": "list index out of range",
3116
+ "dense_hit_5": "",
3117
+ "dense_recall_5": "",
3118
+ "dense_precision_5": "",
3119
+ "graph_hit_5": "",
3120
+ "graph_recall_5": "",
3121
+ "graph_precision_5": "",
3122
+ "hit_delta_5": "",
3123
+ "recall_delta_5": "",
3124
+ "precision_delta_5": "",
3125
+ "dense_hit_10": "",
3126
+ "dense_recall_10": "",
3127
+ "dense_precision_10": "",
3128
+ "graph_hit_10": "",
3129
+ "graph_recall_10": "",
3130
+ "graph_precision_10": "",
3131
+ "hit_delta_10": "",
3132
+ "recall_delta_10": "",
3133
+ "precision_delta_10": "",
3134
+ "dense_hit_20": "",
3135
+ "dense_recall_20": "",
3136
+ "dense_precision_20": "",
3137
+ "graph_hit_20": "",
3138
+ "graph_recall_20": "",
3139
+ "graph_precision_20": "",
3140
+ "hit_delta_20": "",
3141
+ "recall_delta_20": "",
3142
+ "precision_delta_20": "",
3143
+ "dense_hit_30": "",
3144
+ "dense_recall_30": "",
3145
+ "dense_precision_30": "",
3146
+ "graph_hit_30": "",
3147
+ "graph_recall_30": "",
3148
+ "graph_precision_30": "",
3149
+ "hit_delta_30": "",
3150
+ "recall_delta_30": "",
3151
+ "precision_delta_30": "",
3152
+ "dense_hit_50": "",
3153
+ "dense_recall_50": "",
3154
+ "dense_precision_50": "",
3155
+ "graph_hit_50": "",
3156
+ "graph_recall_50": "",
3157
+ "graph_precision_50": "",
3158
+ "hit_delta_50": "",
3159
+ "recall_delta_50": "",
3160
+ "precision_delta_50": ""
3161
+ },
3162
+ {
3163
+ "question": "Find some newspaper articles about major snowstorms that have hit Massachusetts over the years",
3164
+ "question_type": "full_text",
3165
+ "rewritten_query": "major snowstorms in Massachusetts",
3166
+ "use_graph": false,
3167
+ "num_ground_truths": 4,
3168
+ "dense_retrieved": 0,
3169
+ "graph_retrieved": 0,
3170
+ "ground_truth_ids": [
3171
+ "0z70h781w",
3172
+ "pg15jt06t",
3173
+ "v4060z71q",
3174
+ "d7920t25x"
3175
+ ],
3176
+ "dense_ids": [],
3177
+ "graph_ids": [],
3178
+ "error": "list index out of range",
3179
+ "dense_hit_5": "",
3180
+ "dense_recall_5": "",
3181
+ "dense_precision_5": "",
3182
+ "graph_hit_5": "",
3183
+ "graph_recall_5": "",
3184
+ "graph_precision_5": "",
3185
+ "hit_delta_5": "",
3186
+ "recall_delta_5": "",
3187
+ "precision_delta_5": "",
3188
+ "dense_hit_10": "",
3189
+ "dense_recall_10": "",
3190
+ "dense_precision_10": "",
3191
+ "graph_hit_10": "",
3192
+ "graph_recall_10": "",
3193
+ "graph_precision_10": "",
3194
+ "hit_delta_10": "",
3195
+ "recall_delta_10": "",
3196
+ "precision_delta_10": "",
3197
+ "dense_hit_20": "",
3198
+ "dense_recall_20": "",
3199
+ "dense_precision_20": "",
3200
+ "graph_hit_20": "",
3201
+ "graph_recall_20": "",
3202
+ "graph_precision_20": "",
3203
+ "hit_delta_20": "",
3204
+ "recall_delta_20": "",
3205
+ "precision_delta_20": "",
3206
+ "dense_hit_30": "",
3207
+ "dense_recall_30": "",
3208
+ "dense_precision_30": "",
3209
+ "graph_hit_30": "",
3210
+ "graph_recall_30": "",
3211
+ "graph_precision_30": "",
3212
+ "hit_delta_30": "",
3213
+ "recall_delta_30": "",
3214
+ "precision_delta_30": "",
3215
+ "dense_hit_50": "",
3216
+ "dense_recall_50": "",
3217
+ "dense_precision_50": "",
3218
+ "graph_hit_50": "",
3219
+ "graph_recall_50": "",
3220
+ "graph_precision_50": "",
3221
+ "hit_delta_50": "",
3222
+ "recall_delta_50": "",
3223
+ "precision_delta_50": ""
3224
+ }
3225
+ ]
3226
+ }
evaluation/logger.py ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ evaluation/logger.py
3
+
4
+ Structured logger for every query that passes through the pipeline.
5
+ Writes to:
6
+ 1. The `query_logs` table in PostgreSQL (for dashboarding and SQL analysis)
7
+ 2. A local CSV file (for portability and DeepEval input)
8
+
9
+ Usage:
10
+ from evaluation.logger import log_query
11
+ log_query(intent, retrieved_docs, generation_result, latency_ms=240)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import csv
17
+ import json
18
+ import os
19
+ from datetime import datetime, timezone
20
+ from pathlib import Path
21
+ from typing import List, Optional
22
+
23
+ from database.schema import get_conn, get_cursor
24
+ from retrieval.query_understanding import QueryIntent
25
+ from retrieval.retriever import RetrievedDocument
26
+ from generation.generator import GenerationResult
27
+
28
+ LOG_CSV_PATH = Path("logs/query_log.csv")
29
+
30
+ CSV_HEADERS = [
31
+ "queried_at",
32
+ "raw_query",
33
+ "rewritten_query",
34
+ "query_type",
35
+ "year_min",
36
+ "year_max",
37
+ "geography",
38
+ "topics",
39
+ "retrieved_ark_ids",
40
+ "response",
41
+ "latency_ms",
42
+ "relevancy_score",
43
+ "faithfulness_score",
44
+ ]
45
+
46
+
47
+ def _ensure_csv(path: Path):
48
+ """Create CSV with headers if it doesn't exist."""
49
+ path.parent.mkdir(parents=True, exist_ok=True)
50
+ if not path.exists():
51
+ with open(path, "w", newline="", encoding="utf-8") as f:
52
+ writer = csv.DictWriter(f, fieldnames=CSV_HEADERS)
53
+ writer.writeheader()
54
+
55
+
56
+ def log_query(
57
+ intent: QueryIntent,
58
+ retrieved_docs: List[RetrievedDocument],
59
+ generation_result: GenerationResult,
60
+ latency_ms: int = 0,
61
+ relevancy_score: Optional[float] = None,
62
+ faithfulness_score: Optional[float] = None,
63
+ ):
64
+ """
65
+ Persist one query event to the DB and CSV log.
66
+ Fails silently on DB errors so a logging failure never breaks the search UX.
67
+ """
68
+ queried_at = datetime.now(timezone.utc).isoformat()
69
+ retrieved_arks = [d.ark_id for d in retrieved_docs]
70
+ filters_json = json.dumps({
71
+ "year_min": intent.date_filter.year_min,
72
+ "year_max": intent.date_filter.year_max,
73
+ "doc_types": intent.doc_types,
74
+ })
75
+
76
+ # ── 1. Write to PostgreSQL ──────────────────────────────────────────────
77
+ try:
78
+ with get_conn() as conn:
79
+ with get_cursor(conn) as cur:
80
+ cur.execute(
81
+ """
82
+ INSERT INTO query_logs (
83
+ queried_at, raw_query, rewritten_query, query_type,
84
+ filters, retrieved_ark_ids, response,
85
+ relevancy_score, faithfulness_score, latency_ms
86
+ ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
87
+ """,
88
+ (
89
+ queried_at,
90
+ intent.raw_query,
91
+ intent.rewritten_query,
92
+ intent.query_type,
93
+ filters_json,
94
+ retrieved_arks,
95
+ generation_result.response,
96
+ relevancy_score,
97
+ faithfulness_score,
98
+ latency_ms,
99
+ ),
100
+ )
101
+ except Exception as e:
102
+ print(f"[logger] DB write failed (non-fatal): {e}")
103
+
104
+ # ── 2. Append to CSV ────────────────────────────────────────────────────
105
+ try:
106
+ _ensure_csv(LOG_CSV_PATH)
107
+ row = {
108
+ "queried_at": queried_at,
109
+ "raw_query": intent.raw_query,
110
+ "rewritten_query": intent.rewritten_query,
111
+ "query_type": intent.query_type,
112
+ "year_min": intent.date_filter.year_min,
113
+ "year_max": intent.date_filter.year_max,
114
+ "geography": "",
115
+ "topics": "",
116
+ "retrieved_ark_ids": "|".join(retrieved_arks),
117
+ "response": generation_result.response,
118
+ "latency_ms": latency_ms,
119
+ "relevancy_score": relevancy_score,
120
+ "faithfulness_score": faithfulness_score,
121
+ }
122
+ with open(LOG_CSV_PATH, "a", newline="", encoding="utf-8") as f:
123
+ writer = csv.DictWriter(f, fieldnames=CSV_HEADERS)
124
+ writer.writerow(row)
125
+ except Exception as e:
126
+ print(f"[logger] CSV write failed (non-fatal): {e}")
generation/generator.py ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # """
2
+ # generation/generator.py
3
+
4
+ # Passes the top retrieved documents + their best chunk text to GPT-4o
5
+ # and generates a contextually grounded response.
6
+
7
+ # Design constraints (per client feedback):
8
+ # - Response must be anchored to retrieved content β€” no hallucination
9
+ # - Not a chatbot β€” a structured search result with explanation
10
+ # - Should explain why results are relevant
11
+ # """
12
+
13
+ # from __future__ import annotations
14
+
15
+ # from dataclasses import dataclass
16
+ # from typing import List
17
+
18
+ # from openai import OpenAI
19
+
20
+ # from config import OPENAI_API_KEY, OPENAI_CHAT_MODEL, MAX_CONTEXT_CHUNKS, GENERATION_MAX_TOKENS
21
+ # from retrieval.retriever import RetrievedDocument
22
+
23
+ # client = OpenAI(api_key=OPENAI_API_KEY)
24
+
25
+
26
+ # # ── Result dataclass ──────────────────────────────────────────────────────────
27
+
28
+ # @dataclass
29
+ # class GenerationResult:
30
+ # response: str
31
+ # source_titles: List[str]
32
+ # source_urls: List[str]
33
+
34
+
35
+ # # ── Prompt builders ───────────────────────────────────────────────────────────
36
+
37
+ # SYSTEM_PROMPT = """
38
+ # You are a search assistant for the Boston Public Library's Digital Commonwealth archive,
39
+ # which contains historical newspapers, photographs, maps, manuscripts, and other materials
40
+ # from Massachusetts institutions.
41
+
42
+ # You will be given:
43
+ # 1. A user's search query
44
+ # 2. A set of retrieved documents with their metadata and a relevant excerpt
45
+
46
+ # Your task:
47
+ # - Write a 2-4 sentence explanation of what was found and why these materials are relevant
48
+ # - Ground your explanation ONLY in the retrieved documents provided
49
+ # - Do not invent facts, dates, or events not present in the excerpts
50
+ # - Use a clear, accessible tone suitable for researchers, students, and the general public
51
+ # - Do not mention scores, rankings, or technical retrieval details
52
+ # - If the results are from a narrow time period or place, mention that context
53
+ # """.strip()
54
+
55
+
56
+ # def _build_context(docs: List[RetrievedDocument], max_chunks: int = MAX_CONTEXT_CHUNKS) -> str:
57
+ # """
58
+ # Format the top retrieved documents as a context block for GPT-4o.
59
+ # Only includes documents that have a non-empty chunk text.
60
+ # """
61
+ # lines = []
62
+ # included = 0
63
+
64
+ # for i, doc in enumerate(docs):
65
+ # if not doc.best_chunk_text.strip():
66
+ # continue
67
+ # if included >= max_chunks:
68
+ # break
69
+
70
+ # lines.append(f"[Document {included + 1}]")
71
+ # lines.append(f"Title : {doc.title}")
72
+ # lines.append(f"Date : {doc.issue_date or ', '.join(str(y) for y in doc.year)}")
73
+ # lines.append(f"Institution: {doc.institution}")
74
+ # if doc.topics:
75
+ # lines.append(f"Topics : {', '.join(doc.topics)}")
76
+ # if doc.geography:
77
+ # lines.append(f"Geography : {', '.join(doc.geography)}")
78
+ # lines.append(f"Excerpt : {doc.best_chunk_text[:600]}") # cap excerpt length
79
+ # lines.append("")
80
+
81
+ # included += 1
82
+
83
+ # return "\n".join(lines)
84
+
85
+
86
+ # # ── Generator ─────────────────────────────────────────────────────────────────
87
+
88
+ # def generate(raw_query: str, docs: List[RetrievedDocument]) -> GenerationResult:
89
+ # """
90
+ # Generate a response given the user query and retrieved documents.
91
+
92
+ # Returns a GenerationResult with the response text and source metadata.
93
+ # """
94
+ # if not docs:
95
+ # return GenerationResult(
96
+ # response = "No relevant materials were found for your query. "
97
+ # "Try rephrasing or broadening your search.",
98
+ # source_titles = [],
99
+ # source_urls = [],
100
+ # )
101
+
102
+ # context = _build_context(docs)
103
+
104
+ # user_message = f"""User query: {raw_query}
105
+
106
+ # Retrieved documents:
107
+ # {context}
108
+
109
+ # Please write a brief explanation of what was found and why these materials are relevant to the query.
110
+ # """
111
+
112
+ # response = client.chat.completions.create(
113
+ # model = OPENAI_CHAT_MODEL,
114
+ # temperature = 0.3, # slight creativity for natural prose, still grounded
115
+ # max_tokens = GENERATION_MAX_TOKENS,
116
+ # messages = [
117
+ # {"role": "system", "content": SYSTEM_PROMPT},
118
+ # {"role": "user", "content": user_message},
119
+ # ],
120
+ # )
121
+
122
+ # response_text = response.choices[0].message.content.strip()
123
+
124
+ # return GenerationResult(
125
+ # response = response_text,
126
+ # source_titles = [d.title for d in docs],
127
+ # source_urls = [d.source_url for d in docs],
128
+ # )
129
+
130
+ """
131
+ generation/generator.py
132
+
133
+ Generates a single grounded summary that cites each retrieved document
134
+ inline by number, e.g. [1], [2], so users can trace claims to results.
135
+ """
136
+
137
+ from __future__ import annotations
138
+ from dataclasses import dataclass, field
139
+ from typing import List
140
+ from openai import OpenAI
141
+
142
+ from config import OPENAI_API_KEY, OPENAI_CHAT_MODEL, GENERATION_MAX_TOKENS
143
+ from retrieval.retriever import RetrievedDocument
144
+
145
+ client = OpenAI(api_key=OPENAI_API_KEY)
146
+
147
+
148
+ @dataclass
149
+ class GenerationResult:
150
+ response: str
151
+ source_titles: List[str]
152
+ source_urls: List[str]
153
+
154
+
155
+ SYSTEM_PROMPT = """
156
+ You are a search assistant for the Boston Public Library's Digital Commonwealth archive,
157
+ which contains historical newspapers, photographs, maps, manuscripts, and other materials
158
+ from Massachusetts institutions.
159
+
160
+ You will be given a user query and a numbered list of retrieved documents with their
161
+ metadata and text excerpts.
162
+
163
+ Write a 3-5 sentence response that:
164
+ - Summarizes what was found and why it is relevant to the query
165
+ - Cites specific documents inline using their number, e.g. [1], [2], [3]
166
+ - Is grounded ONLY in the provided documents β€” do not invent facts
167
+ - Uses clear, accessible language suitable for researchers and the general public
168
+ - Does not mention scores, rankings, or technical retrieval details
169
+
170
+ Example format:
171
+ "Several materials related to the 1919 Boston Molasses Disaster are available [1][2].
172
+ The Boston Traveler covered the event extensively in its January 1919 issues [1],
173
+ while photographs of the aftermath document the structural damage to the North End [3]."
174
+
175
+ If the documents are not relevant to the query, say so clearly and suggest refining the search.
176
+ """.strip()
177
+
178
+
179
+ def _build_context(docs: List[RetrievedDocument]) -> str:
180
+ """Format retrieved documents as a numbered list for GPT-4o."""
181
+ lines = []
182
+ for i, doc in enumerate(docs, start=1):
183
+ date_str = doc.issue_date or (str(doc.year[0]) if doc.year else "unknown date")
184
+ excerpt = doc.best_chunk_text[:300] if doc.best_chunk_text else "No text excerpt β€” collection-level record."
185
+ lines.append(f"[{i}] Title: {doc.title}")
186
+ lines.append(f" Date: {date_str} | Institution: {doc.institution}")
187
+ if doc.topics:
188
+ lines.append(f" Topics: {', '.join(doc.topics)}")
189
+ lines.append(f" Excerpt: {excerpt}")
190
+ lines.append("")
191
+ return "\n".join(lines)
192
+
193
+
194
+ def generate(raw_query: str, docs: List[RetrievedDocument]) -> GenerationResult:
195
+ """
196
+ Generate a single cited summary referencing documents by [number].
197
+ """
198
+ if not docs:
199
+ return GenerationResult(
200
+ response = "No relevant materials were found for your query. Try rephrasing or using a more specific historical topic.",
201
+ source_titles = [],
202
+ source_urls = [],
203
+ )
204
+
205
+ context = _build_context(docs)
206
+
207
+ user_message = f"""User query: {raw_query}
208
+
209
+ Retrieved documents:
210
+ {context}
211
+
212
+ Write a concise summary that cites the relevant documents inline by number."""
213
+
214
+ response = client.chat.completions.create(
215
+ model = OPENAI_CHAT_MODEL,
216
+ temperature = 0.2,
217
+ max_tokens = GENERATION_MAX_TOKENS,
218
+ messages = [
219
+ {"role": "system", "content": SYSTEM_PROMPT},
220
+ {"role": "user", "content": user_message},
221
+ ],
222
+ )
223
+
224
+ if not response.choices:
225
+ raise ValueError("OpenAI returned empty choices (finish_reason may indicate content filter)")
226
+ response_text = response.choices[0].message.content.strip()
227
+
228
+ return GenerationResult(
229
+ response = response_text,
230
+ source_titles = [d.title for d in docs],
231
+ source_urls = [d.source_url for d in docs],
232
+ )
ingestion/chunker.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ from typing import List
3
+ from transformers import AutoTokenizer
4
+ from config import CHUNK_SIZE, CHUNK_OVERLAP
5
+
6
+ class Chunker:
7
+ def __init__(self, chunk_size=CHUNK_SIZE, chunk_overlap=CHUNK_OVERLAP):
8
+ self.chunk_size = chunk_size
9
+ self.chunk_overlap = chunk_overlap
10
+ self.enc = AutoTokenizer.from_pretrained("BAAI/bge-m3")
11
+
12
+ def chunk(self, text: str) -> List[str]:
13
+ if not text or not text.strip():
14
+ return []
15
+
16
+ tokens = self.enc.encode(text, add_special_tokens=False)
17
+
18
+ if len(tokens) == 0:
19
+ return []
20
+
21
+ chunks = []
22
+ start = 0
23
+ step = self.chunk_size - self.chunk_overlap
24
+
25
+ while start < len(tokens):
26
+ end = min(start + self.chunk_size, len(tokens))
27
+ chunk_ids = tokens[start:end]
28
+ chunk_text = self.enc.decode(chunk_ids, skip_special_tokens=True).strip()
29
+ if chunk_text:
30
+ chunks.append(chunk_text)
31
+ if end == len(tokens):
32
+ break
33
+ start += step
34
+
35
+ return chunks
36
+
37
+ def chunk_record(self, record: dict) -> List[dict]:
38
+ raw_text = record.get("clean_text") or record.get("raw_text") or ""
39
+ texts = self.chunk(raw_text)
40
+ return [
41
+ {
42
+ "ark_id": record["ark_id"],
43
+ "chunk_index": i,
44
+ "chunk_text": text,
45
+ }
46
+ for i, text in enumerate(texts)
47
+ ]
48
+
49
+
50
+ # Module-level singleton
51
+ chunker = Chunker()
ingestion/ingest.py ADDED
@@ -0,0 +1,411 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ingestion/ingest.py
3
+
4
+ Ingestion pipeline for two data sources:
5
+
6
+ Source A β€” Full-text records (PRIMARY)
7
+ Layout: data/fulltext/<collection_name>/<year>.json
8
+ Structure: { "year": 1946, "records": [ {record}, ... ] }
9
+ Fields: flat, includes clean_text (or raw_text) and all metadata
10
+ These get both metadata + chunk embeddings (dense + sparse).
11
+
12
+ Source B β€” Metadata-only records (SECONDARY)
13
+ Layout: data/metadata/metadata.jsonl
14
+ Structure: { "data": { "id": "commonwealth:...", "attributes": { ... } } }
15
+ These get only metadata embeddings (dense + sparse). No chunks.
16
+
17
+ Run:
18
+ python -m ingestion.ingest
19
+ python -m ingestion.ingest --fulltext-dir data/fulltext --skip-metadata
20
+ python -m ingestion.ingest --metadata-file data/metadata/metadata.jsonl --skip-fulltext
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import json
27
+ from datetime import datetime, timezone
28
+ from pathlib import Path
29
+ from typing import Iterator, List
30
+
31
+ import numpy as np
32
+ from psycopg2.extras import execute_values
33
+
34
+ from config import MIN_CHAR_COUNT, BGE_BATCH_SIZE
35
+ from database.schema import get_conn, get_cursor
36
+ from embedding.embedder import embedder
37
+ from ingestion.chunker import chunker
38
+ import re
39
+ import html
40
+
41
+ DEFAULT_FULLTEXT_DIR = "data/fulltext"
42
+ DEFAULT_METADATA_FILE = "data/metadata/metadata.jsonl"
43
+
44
+
45
+ # ── Helpers ───────────────────────────────────────────────────────────────────
46
+
47
+ def sparse_to_arrays(sparse: dict) -> tuple[list, list]:
48
+ """Convert {token_id: weight} dict to (token_ids[], weights[]) arrays."""
49
+ token_ids = [int(k) for k in sparse.keys()]
50
+ weights = [float(v) for v in sparse.values()]
51
+ return token_ids, weights
52
+
53
+
54
+ def is_valid(record: dict) -> bool:
55
+ return bool(record.get("ark_id"))
56
+
57
+
58
+ def has_fulltext(record: dict) -> bool:
59
+ text = record.get("clean_text") or record.get("raw_text") or ""
60
+ return (
61
+ not record.get("_metadata_only", False)
62
+ and len(text) >= MIN_CHAR_COUNT
63
+ )
64
+
65
+
66
+ def parse_date_start(record: dict):
67
+ raw = record.get("date_start", "") or ""
68
+ if not raw:
69
+ return None
70
+ try:
71
+ return datetime.fromisoformat(raw.replace("Z", "+00:00"))
72
+ except ValueError:
73
+ return None
74
+
75
+
76
+ # ── Source A: Full-text records ───────────────────────────────────────────────
77
+
78
+ def iter_fulltext_records(fulltext_dir: str) -> Iterator[dict]:
79
+ root = Path(fulltext_dir)
80
+ if not root.exists():
81
+ print(f" [ingest] Full-text dir not found: {fulltext_dir} β€” skipping.")
82
+ return
83
+
84
+ json_files = sorted(root.rglob("*.json"))
85
+ if not json_files:
86
+ print(f" [ingest] No .json files found under {fulltext_dir}")
87
+ return
88
+
89
+ for fpath in json_files:
90
+ collection = fpath.parent.name
91
+ print(f" Reading {fpath.relative_to(root)} ...")
92
+ with open(fpath, "r", encoding="utf-8") as f:
93
+ data = json.load(f)
94
+ for rec in data.get("records", []):
95
+ if not rec.get("collection"):
96
+ rec["collection"] = collection
97
+ yield rec
98
+
99
+
100
+ # ── Source B: Metadata-only records ──────────────────────────────────────────
101
+
102
+ def _parse_metadata_record(raw: dict) -> dict:
103
+ data = raw.get("data", {})
104
+ attrs = data.get("attributes", {})
105
+ record_id = data.get("id", attrs.get("id", ""))
106
+ ark_id = record_id.split(":")[-1] if ":" in record_id else record_id
107
+ abstract_raw = attrs.get("abstract_tsi", "") or ""
108
+ abstract_unescaped = html.unescape(abstract_raw)
109
+ abstract_clean = re.sub(r"<[^>]+>", " ", abstract_unescaped).strip()
110
+ abstract_clean = re.sub(r"\s+", " ", abstract_clean)
111
+
112
+
113
+ return {
114
+ "ark_id": ark_id,
115
+ "record_id": record_id,
116
+ "source_url": attrs.get("identifier_uri_ss", ""),
117
+ "iiif_manifest": attrs.get("identifier_iiif_manifest_ss", ""),
118
+ "newspaper": "",
119
+ "collection": attrs.get("title_info_primary_tsi", ""),
120
+ "title": attrs.get("title_info_primary_tsi", ""),
121
+ "issue_date": attrs.get("title_info_partnum_tsi", ""),
122
+ "date_iso": attrs.get("date_edtf_ssm", []),
123
+ "date_start": attrs.get("date_start_dtsi", ""),
124
+ "year": attrs.get("date_facet_yearly_itim", []),
125
+ "publisher": attrs.get("publisher_tsim", []),
126
+ "place": attrs.get("publication_place_tsim", []),
127
+ "language": attrs.get("language_ssim", []),
128
+ "institution": attrs.get("institution_name_ssi", ""),
129
+ "page_count": len(attrs.get("filenames_ssim", [])),
130
+ "pages": attrs.get("filenames_ssim", []),
131
+ "topics": attrs.get("subject_topic_tsim", []),
132
+ "geography": attrs.get("subject_geographic_ssim", []),
133
+ "clean_text": "",
134
+ "raw_text": "",
135
+ "char_count": 0,
136
+ "ingested_at": datetime.now(timezone.utc).isoformat(),
137
+ "_metadata_only": True,
138
+ "genre": attrs.get("genre_basic_ssim", []),
139
+ # Strip HTML once at parse time instead of repeatedly at embed time
140
+ "exemplary_image_id": attrs.get("exemplary_image_ssi", ""),
141
+ "abstract": abstract_clean,
142
+ }
143
+
144
+
145
+ def iter_metadata_records(
146
+ metadata_file: str,
147
+ seen_record_ids: set,
148
+ ) -> Iterator[dict]:
149
+ fpath = Path(metadata_file)
150
+ if not fpath.exists():
151
+ print(f" [ingest] Metadata file not found: {metadata_file} β€” skipping.")
152
+ return
153
+
154
+ print(f" Reading metadata JSONL: {fpath.name} ...")
155
+ skipped = 0
156
+ with open(fpath, "r", encoding="utf-8") as f:
157
+ for line in f:
158
+ line = line.strip()
159
+ if not line:
160
+ continue
161
+ try:
162
+ raw = json.loads(line)
163
+ except json.JSONDecodeError:
164
+ continue
165
+ rec = _parse_metadata_record(raw)
166
+ if rec["record_id"] in seen_record_ids:
167
+ skipped += 1
168
+ continue
169
+ yield rec
170
+
171
+ if skipped:
172
+ print(f" Skipped {skipped} metadata records already covered by full-text source.")
173
+
174
+
175
+ # ── DB writes ─────────────────────────────────────────────────────────────────
176
+
177
+ def upsert_documents(
178
+ records: List[dict],
179
+ meta_embeddings: np.ndarray,
180
+ meta_sparse: List[dict],
181
+ conn,
182
+ ) -> dict:
183
+ """Upsert into documents table. Returns {ark_id: db_id}."""
184
+ rows = []
185
+ for rec, emb, sparse in zip(records, meta_embeddings, meta_sparse):
186
+ token_ids, weights = sparse_to_arrays(sparse)
187
+ rows.append((
188
+ rec["ark_id"],
189
+ rec.get("record_id", ""),
190
+ rec.get("source_url", ""),
191
+ rec.get("iiif_manifest", ""),
192
+ rec.get("newspaper", ""),
193
+ rec.get("collection", ""),
194
+ rec.get("institution", ""),
195
+ rec.get("title", ""),
196
+ rec.get("issue_date", ""),
197
+ rec.get("date_iso") or [],
198
+ parse_date_start(rec),
199
+ rec.get("year") or [],
200
+ rec.get("publisher") or [],
201
+ rec.get("place") or [],
202
+ rec.get("language") or [],
203
+ rec.get("page_count", 0),
204
+ rec.get("pages") or [],
205
+ rec.get("topics") or [],
206
+ rec.get("geography") or [],
207
+ rec.get("char_count", 0),
208
+ rec.get("genre") or [],
209
+ rec.get("abstract", ""),
210
+ rec.get("exemplary_image_id", ""),
211
+ emb.tolist(),
212
+ token_ids,
213
+ weights,
214
+ rec.get("ingested_at", datetime.now(timezone.utc).isoformat()),
215
+ ))
216
+
217
+ sql = """
218
+ INSERT INTO documents (
219
+ ark_id, record_id, source_url, iiif_manifest,
220
+ newspaper, collection, institution,
221
+ title, issue_date, date_iso, date_start, year,
222
+ publisher, place, language,
223
+ page_count, pages, topics, geography,
224
+ char_count,
225
+ genre, abstract, exemplary_image_id,
226
+ metadata_embedding, sparse_token_ids, sparse_weights,
227
+ ingested_at
228
+ )
229
+ VALUES %s
230
+ ON CONFLICT (ark_id) DO UPDATE SET
231
+ metadata_embedding = EXCLUDED.metadata_embedding,
232
+ sparse_token_ids = EXCLUDED.sparse_token_ids,
233
+ sparse_weights = EXCLUDED.sparse_weights,
234
+ char_count = EXCLUDED.char_count,
235
+ genre = EXCLUDED.genre,
236
+ abstract = EXCLUDED.abstract,
237
+ exemplary_image_id = EXCLUDED.exemplary_image_id,
238
+ ingested_at = EXCLUDED.ingested_at
239
+ RETURNING ark_id, id
240
+ """
241
+ with get_cursor(conn) as cur:
242
+ results = execute_values(cur, sql, rows, fetch=True)
243
+ return {row["ark_id"]: row["id"] for row in results}
244
+
245
+
246
+ def insert_chunks(
247
+ chunk_rows: List[dict],
248
+ text_embeddings: np.ndarray,
249
+ sparse_embeddings: List[dict],
250
+ ark_to_doc_id: dict,
251
+ conn,
252
+ ):
253
+ if not chunk_rows:
254
+ return
255
+
256
+ doc_ids = list({
257
+ ark_to_doc_id[c["ark_id"]]
258
+ for c in chunk_rows
259
+ if c["ark_id"] in ark_to_doc_id
260
+ })
261
+
262
+ with get_cursor(conn) as cur:
263
+ cur.execute("DELETE FROM chunks WHERE document_id = ANY(%s)", (doc_ids,))
264
+
265
+ rows = []
266
+ for chunk, emb, sparse in zip(chunk_rows, text_embeddings, sparse_embeddings):
267
+ doc_id = ark_to_doc_id.get(chunk["ark_id"])
268
+ if doc_id is None:
269
+ continue
270
+ token_ids, weights = sparse_to_arrays(sparse)
271
+ rows.append((
272
+ doc_id,
273
+ chunk["ark_id"],
274
+ chunk["chunk_index"],
275
+ chunk["chunk_text"],
276
+ emb.tolist(),
277
+ token_ids,
278
+ weights,
279
+ ))
280
+
281
+ with get_cursor(conn) as cur:
282
+ execute_values(
283
+ cur,
284
+ """
285
+ INSERT INTO chunks (
286
+ document_id, ark_id, chunk_index, chunk_text,
287
+ text_embedding, sparse_token_ids, sparse_weights
288
+ )
289
+ VALUES %s
290
+ """,
291
+ rows,
292
+ )
293
+
294
+
295
+ # ── Batch flush ───────────────────────────────────────────────────────────────
296
+
297
+ def flush_batch(batch: List[dict]) -> tuple[int, int]:
298
+ """Embed and write one batch. Returns (n_docs, n_chunks)."""
299
+
300
+ # Single forward pass for metadata β€” dense + sparse together
301
+ meta_texts = [embedder.build_metadata_text(r) for r in batch]
302
+ meta_output = embedder.encode_both(meta_texts)
303
+ meta_embs = meta_output["dense"]
304
+ meta_sparse = meta_output["sparse"]
305
+
306
+ # Chunk full-text records
307
+ all_chunks: List[dict] = []
308
+ for rec in batch:
309
+ if has_fulltext(rec):
310
+ all_chunks.extend(chunker.chunk_record(rec))
311
+
312
+ # Single forward pass for chunks β€” dense + sparse together
313
+ if all_chunks:
314
+ chunk_texts = [c["chunk_text"] for c in all_chunks]
315
+ chunk_output = embedder.encode_both(chunk_texts)
316
+ chunk_embs = chunk_output["dense"]
317
+ chunk_sparse = chunk_output["sparse"]
318
+ else:
319
+ chunk_embs = np.array([])
320
+ chunk_sparse = []
321
+
322
+ with get_conn() as conn:
323
+ ark_to_doc_id = upsert_documents(batch, meta_embs, meta_sparse, conn)
324
+ if all_chunks:
325
+ insert_chunks(all_chunks, chunk_embs, chunk_sparse, ark_to_doc_id, conn)
326
+
327
+ return len(batch), len(all_chunks)
328
+
329
+
330
+ # ── Main ──────────────────────────────────────────────────────────────────────
331
+
332
+ def run_ingestion(
333
+ fulltext_dir: str = DEFAULT_FULLTEXT_DIR,
334
+ metadata_file: str = DEFAULT_METADATA_FILE,
335
+ batch_size: int = BGE_BATCH_SIZE,
336
+ skip_fulltext: bool = False,
337
+ skip_metadata: bool = False,
338
+ ):
339
+ print(f"\n{'='*60}")
340
+ print("BPL RAG Ingestion Pipeline")
341
+ print(f" Full-text dir : {fulltext_dir}")
342
+ print(f" Metadata file : {metadata_file}")
343
+ print(f" Batch size : {batch_size}")
344
+ print(f"{'='*60}\n")
345
+
346
+ total_docs = total_chunks = skipped = 0
347
+ batch: List[dict] = []
348
+ seen_record_ids: set[str] = set()
349
+
350
+ def flush(b):
351
+ nonlocal total_docs, total_chunks
352
+ n_docs, n_chunks = flush_batch(b)
353
+ total_docs += n_docs
354
+ total_chunks += n_chunks
355
+ print(
356
+ f" Flushed {n_docs} docs | {n_chunks} chunks | "
357
+ f"totals β†’ {total_docs} docs / {total_chunks} chunks"
358
+ )
359
+
360
+ if not skip_fulltext:
361
+ print("── Pass 1: Full-text records ──────────────────────────────")
362
+ for record in iter_fulltext_records(fulltext_dir):
363
+ if not is_valid(record):
364
+ skipped += 1
365
+ continue
366
+ seen_record_ids.add(record.get("record_id", ""))
367
+ batch.append(record)
368
+ if len(batch) >= batch_size:
369
+ flush(batch)
370
+ batch = []
371
+ if batch:
372
+ flush(batch)
373
+ batch = []
374
+
375
+ if not skip_metadata:
376
+ print("\n── Pass 2: Metadata-only records ──────────────────────────")
377
+ for record in iter_metadata_records(metadata_file, seen_record_ids):
378
+ if not is_valid(record):
379
+ skipped += 1
380
+ continue
381
+ batch.append(record)
382
+ if len(batch) >= batch_size:
383
+ flush(batch)
384
+ batch = []
385
+ if batch:
386
+ flush(batch)
387
+
388
+ print(f"\nβœ“ Ingestion complete.")
389
+ print(f" Documents ingested : {total_docs}")
390
+ print(f" Chunks created : {total_chunks}")
391
+ print(f" Records skipped : {skipped}")
392
+
393
+
394
+ # ── CLI ───────────────────────────────────────────────────────────────────────
395
+
396
+ if __name__ == "__main__":
397
+ parser = argparse.ArgumentParser(description="BPL RAG ingestion pipeline")
398
+ parser.add_argument("--fulltext-dir", default=DEFAULT_FULLTEXT_DIR)
399
+ parser.add_argument("--metadata-file", default=DEFAULT_METADATA_FILE)
400
+ parser.add_argument("--batch-size", type=int, default=BGE_BATCH_SIZE)
401
+ parser.add_argument("--skip-fulltext", action="store_true")
402
+ parser.add_argument("--skip-metadata", action="store_true")
403
+ args = parser.parse_args()
404
+
405
+ run_ingestion(
406
+ fulltext_dir = args.fulltext_dir,
407
+ metadata_file = args.metadata_file,
408
+ batch_size = args.batch_size,
409
+ skip_fulltext = args.skip_fulltext,
410
+ skip_metadata = args.skip_metadata,
411
+ )
pipeline.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ pipeline.py
3
+
4
+ Top-level entry point wiring all components together.
5
+ Graph retrieval is now fully integrated into retriever.py as a third RRF path.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sys
11
+ import time
12
+ from dataclasses import dataclass, field
13
+ from typing import List
14
+
15
+ from retrieval.query_understanding import classify_query, QueryIntent
16
+ from retrieval.retriever import retrieve, RetrievedDocument
17
+ from generation.generator import generate, GenerationResult
18
+ from evaluation.logger import log_query
19
+ from config import TOP_K_FINAL
20
+
21
+
22
+ # ── Output dataclass ──────────────────────────────────────────────────────────
23
+
24
+ @dataclass
25
+ class PipelineResult:
26
+ intent: QueryIntent
27
+ documents: List[RetrievedDocument]
28
+ generation: GenerationResult
29
+ latency_ms: int
30
+
31
+
32
+ # ── Main pipeline ─────────────────────────────────────────────────────────────
33
+
34
+ def run_query(raw_query: str, top_k: int = TOP_K_FINAL) -> PipelineResult:
35
+ """
36
+ End-to-end query pipeline:
37
+ 1. Classify and rewrite query (GPT-4o)
38
+ 2. Retrieve via dense + sparse + graph + metadata (all paths, RRF fused)
39
+ 3. Generate cited response (GPT-4o)
40
+ 4. Log event
41
+ """
42
+ start = time.monotonic()
43
+
44
+ # ── Step 1: Classify ───────────────────────────────────────────────────
45
+ intent = classify_query(raw_query)
46
+ print(f"[pipeline] Query type : {intent.query_type}")
47
+ print(f"[pipeline] Rewritten : {intent.rewritten_query}")
48
+ print(f"[pipeline] Date filter : {intent.date_filter}")
49
+
50
+ # ── Step 2: Retrieve (all paths fused via RRF) ─────────────────────────
51
+ documents, _ = retrieve(intent, top_k=top_k)
52
+ print(f"[pipeline] Retrieved : {len(documents)} documents")
53
+
54
+ # ── Step 3: Generate ───────────────────────────────────────────────────
55
+ if not documents:
56
+ generation = GenerationResult(
57
+ response = "No relevant materials were found for your query in the Digital Commonwealth collection. Try rephrasing or using a more specific historical topic.",
58
+ source_titles = [],
59
+ source_urls = [],
60
+ )
61
+ else:
62
+ generation = generate(raw_query, documents)
63
+
64
+ latency_ms = int((time.monotonic() - start) * 1000)
65
+ print(f"[pipeline] Latency : {latency_ms}ms")
66
+
67
+ # ── Step 4: Log ────────────────────────────────────────────────────────
68
+ log_query(
69
+ intent = intent,
70
+ retrieved_docs = documents,
71
+ generation_result = generation,
72
+ latency_ms = latency_ms,
73
+ )
74
+
75
+ return PipelineResult(
76
+ intent = intent,
77
+ documents = documents,
78
+ generation = generation,
79
+ latency_ms = latency_ms,
80
+ )
81
+
82
+
83
+ def print_result(result: PipelineResult):
84
+ print("\n" + "=" * 60)
85
+ print("RESPONSE")
86
+ print("=" * 60)
87
+ print(result.generation.response)
88
+
89
+ print("\n" + "-" * 60)
90
+ print(f"RESULTS ({len(result.documents)} docs)")
91
+ print("-" * 60)
92
+ for i, doc in enumerate(result.documents, 1):
93
+ date_str = doc.issue_date or (str(doc.year[0]) if doc.year else "unknown")
94
+ print(f" {i}. {doc.title} ({date_str})")
95
+ print(f" {doc.source_url}")
96
+ print(f" score={doc.final_score:.4f}")
97
+
98
+ print(f"\n[latency: {result.latency_ms}ms]")
99
+
100
+
101
+ # ── CLI ───────────────────────────────────────────────────────────────────────
102
+
103
+ if __name__ == "__main__":
104
+ if len(sys.argv) < 2:
105
+ print("Usage: python pipeline.py \"<query>\"")
106
+ sys.exit(1)
107
+
108
+ query = " ".join(sys.argv[1:])
109
+ result = run_query(query)
110
+ print_result(result)
requirements.txt CHANGED
@@ -1,3 +1,84 @@
1
- altair
2
- pandas
3
- streamlit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Core dependencies
2
+ streamlit==1.56.0
3
+ python-dotenv==1.2.2
4
+
5
+ # Database
6
+ psycopg2-binary==2.9.11
7
+ neo4j==6.1.0
8
+
9
+ # ML/AI - OpenAI & Embeddings
10
+ openai==2.30.0
11
+ sentence-transformers==5.3.0
12
+ transformers==5.5.0
13
+ tokenizers==0.22.2
14
+ tiktoken==0.12.0
15
+
16
+ # PyTorch (CPU version for HuggingFace)
17
+ --extra-index-url https://download.pytorch.org/whl/cpu
18
+ torch==2.5.1+cpu
19
+
20
+ # Hugging Face
21
+ huggingface-hub==1.9.0
22
+ accelerate==1.13.0
23
+ safetensors==0.7.0
24
+ datasets==4.8.4
25
+ peft==0.18.1
26
+
27
+ # NLP - spaCy
28
+ spacy==3.8.14
29
+ spacy-legacy==3.0.12
30
+ spacy-loggers==1.0.5
31
+ en-core-web-sm @ https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl
32
+
33
+ # Evaluation
34
+ deepeval==3.9.5
35
+
36
+ # Data processing
37
+ pandas==3.0.2
38
+ numpy==2.4.4
39
+ pyarrow==23.0.1
40
+ scikit-learn==1.8.0
41
+ scipy==1.17.1
42
+
43
+ # HTTP & Async
44
+ aiohttp==3.13.5
45
+ httpx==0.28.1
46
+ requests==2.33.1
47
+ beautifulsoup4==4.14.3
48
+
49
+ # Utilities
50
+ pydantic==2.12.5
51
+ pydantic-core==2.41.5
52
+ pydantic-settings==2.13.1
53
+ python-dateutil==2.9.0.post0
54
+ pytz==2026.1.post1
55
+ pyyaml==6.0.3
56
+ click==8.3.2
57
+ tqdm==4.67.3
58
+ filelock==3.25.2
59
+ joblib==1.5.3
60
+ tenacity==9.1.4
61
+
62
+ # NLP utilities
63
+ blis==1.3.3
64
+ catalogue==2.0.10
65
+ confection==1.3.3
66
+ cymem==2.0.13
67
+ murmurhash==1.0.15
68
+ preshed==3.0.13
69
+ srsly==2.5.3
70
+ thinc==8.3.13
71
+ wasabi==1.1.3
72
+ weasel==1.0.0
73
+
74
+ # Visualization
75
+ altair==6.0.0
76
+ pillow==12.2.0
77
+
78
+ # Other
79
+ flagembedding==1.2.5
80
+ inscriptis==2.7.1
81
+ lxml==6.0.2
82
+ regex==2026.4.4
83
+ sentencepiece==0.2.1
84
+ typer==0.24.1
retrieval/query_understanding.py ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ retrieval/query_understanding.py
3
+
4
+ Uses GPT-4o to:
5
+ 1. Rewrite the query for better embedding
6
+ 2. Extract hard filters (date range)
7
+
8
+ GraphRAG is always enabled. No classification into content_driven/metadata_driven.
9
+ Retrieval always runs all paths: chunk search, metadata search, and graph.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ from dataclasses import dataclass, field
16
+ from typing import Optional
17
+ from openai import OpenAI
18
+
19
+ from config import OPENAI_API_KEY, OPENAI_CHAT_MODEL
20
+
21
+ client = OpenAI(api_key=OPENAI_API_KEY)
22
+
23
+
24
+ # ── Data structures ───────────────────────────────────────────────────────────
25
+
26
+ @dataclass
27
+ class DateFilter:
28
+ year_min: Optional[int] = None
29
+ year_max: Optional[int] = None
30
+
31
+
32
+ @dataclass
33
+ class QueryIntent:
34
+ raw_query: str = ""
35
+ rewritten_query: str = ""
36
+ date_filter: DateFilter = field(default_factory=DateFilter)
37
+
38
+
39
+ # ── System prompt ─────────────────────────────────────────────────────────────
40
+
41
+ SYSTEM_PROMPT = """
42
+ You are a search assistant for the Boston Public Library's Digital Commonwealth archive.
43
+ The archive contains historical newspapers, photographs, maps, manuscripts, and other
44
+ digitised materials from Massachusetts institutions (1900-1946).
45
+
46
+ Given a user query, return a JSON object with exactly these fields:
47
+
48
+ {
49
+ "rewritten_query": "<clean semantic version of the query for embedding>",
50
+ "year_min": <integer or null>,
51
+ "year_max": <integer or null>
52
+ }
53
+
54
+ Rules for rewritten_query:
55
+ - Expand abbreviations and archaic terms to modern equivalents
56
+ - Remove filler words ("can you find me", "I want to see")
57
+ - Preserve proper nouns, dates, and geographic names exactly
58
+ - Output a concise noun-phrase suitable for semantic embedding
59
+
60
+ Rules for year_min / year_max:
61
+ - Only set if the user explicitly mentions a time period
62
+ - "1900s" = year_min 1900, year_max 1909
63
+ - "early 20th century" = year_min 1900, year_max 1930
64
+ - Otherwise null
65
+
66
+ Return ONLY valid JSON. No markdown, no explanation.
67
+ """.strip()
68
+
69
+
70
+ # ── Classifier ────────────────────────────────────────────────────────────────
71
+
72
+ def classify_query(raw_query: str) -> QueryIntent:
73
+ """
74
+ Rewrite query and extract date filters.
75
+ Name kept as classify_query for backward compatibility.
76
+ """
77
+ response = client.chat.completions.create(
78
+ model = OPENAI_CHAT_MODEL,
79
+ temperature = 0,
80
+ max_tokens = 300,
81
+ messages = [
82
+ {"role": "system", "content": SYSTEM_PROMPT},
83
+ {"role": "user", "content": raw_query},
84
+ ],
85
+ )
86
+
87
+ if not response.choices:
88
+ raise ValueError(f"OpenAI returned empty choices (finish_reason may indicate content filter)")
89
+
90
+ raw_json = response.choices[0].message.content
91
+ if raw_json is None:
92
+ raise ValueError("OpenAI returned null content")
93
+
94
+ raw_json = raw_json.strip()
95
+
96
+ if raw_json.startswith("```"):
97
+ raw_json = raw_json.split("```")[1]
98
+ if raw_json.startswith("json"):
99
+ raw_json = raw_json[4:]
100
+ raw_json = raw_json.strip()
101
+
102
+ try:
103
+ parsed = json.loads(raw_json)
104
+ except json.JSONDecodeError as e:
105
+ raise ValueError(f"GPT-4o returned invalid JSON: {e}\nRaw: {raw_json}")
106
+
107
+ return QueryIntent(
108
+ raw_query = raw_query,
109
+ rewritten_query = parsed.get("rewritten_query", raw_query),
110
+ date_filter = DateFilter(
111
+ year_min = parsed.get("year_min"),
112
+ year_max = parsed.get("year_max"),
113
+ ),
114
+ )
115
+
116
+
117
+ if __name__ == "__main__":
118
+ test_queries = [
119
+ "What were some important historical events that happened in Boston in 1919?",
120
+ "Find pictures of JFK's house on Cape Cod",
121
+ "Are there any maps of Worcester, MA from the 18th century?",
122
+ "Who was Mayor Fitzgerald?",
123
+ ]
124
+ for q in test_queries:
125
+ intent = classify_query(q)
126
+ print(f"\nQuery : {q}")
127
+ print(f"Rewritten : {intent.rewritten_query}")
128
+ print(f"Date : {intent.date_filter}")
retrieval/retriever.py ADDED
@@ -0,0 +1,439 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ retrieval/retriever.py
3
+
4
+ Hybrid retrieval β€” three parallel paths fused via RRF:
5
+ A) Dense search on chunks (full-text)
6
+ B) Sparse re-rank on dense candidates
7
+ C) Graph retrieval via Neo4j (entity + co-occurrence)
8
+ D) Metadata embedding search on documents
9
+ E) RRF fusion of all result lists
10
+ F) Final rerank by combined score
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from dataclasses import dataclass
17
+ from typing import List, Tuple
18
+
19
+ import numpy as np
20
+ import time
21
+
22
+ from config import (
23
+ TOP_K_DENSE,
24
+ TOP_K_BM25,
25
+ TOP_K_FINAL,
26
+ RRF_K,
27
+ CONTENT_WEIGHT,
28
+ METADATA_WEIGHT,
29
+ MIN_RELEVANCE_SCORE,
30
+ GRAPH_RAG_ENABLED,
31
+ GRAPH_TOP_K,
32
+ )
33
+ from database.schema import get_conn, get_cursor
34
+ from embedding.embedder import embedder
35
+ from retrieval.query_understanding import QueryIntent
36
+
37
+
38
+ # ── Result dataclass ──────────────────────────────────────────────────────────
39
+
40
+ @dataclass
41
+ class RetrievedDocument:
42
+ ark_id: str
43
+ document_id: int
44
+ title: str
45
+ source_url: str
46
+ institution: str
47
+ issue_date: str
48
+ year: List[int]
49
+ topics: List[str]
50
+ geography: List[str]
51
+ best_chunk_text: str
52
+ best_chunk_index: int
53
+ rrf_score: float
54
+ metadata_sim: float
55
+ final_score: float
56
+ exemplary_image_id: str = ""
57
+
58
+
59
+ # ── Filter clause ─────────────────────────────────────────────────────────────
60
+
61
+ def _build_filter_clause(intent: QueryIntent) -> tuple[str, list]:
62
+ """Build SQL WHERE clause from date filters only."""
63
+ conditions = []
64
+ params = []
65
+
66
+ if intent.date_filter.year_min is not None:
67
+ conditions.append(
68
+ "(EXTRACT(YEAR FROM d.date_start) >= %s OR d.date_start IS NULL)"
69
+ )
70
+ params.append(float(intent.date_filter.year_min))
71
+
72
+ if intent.date_filter.year_max is not None:
73
+ conditions.append(
74
+ "(EXTRACT(YEAR FROM d.date_start) <= %s OR d.date_start IS NULL)"
75
+ )
76
+ params.append(float(intent.date_filter.year_max))
77
+
78
+ where = "WHERE " + " AND ".join(conditions) if conditions else ""
79
+ return where, params
80
+
81
+
82
+ # ── Dense chunk search ────────────────────────────────────────────────────────
83
+
84
+ def _dense_search(
85
+ query_embedding: np.ndarray,
86
+ where_clause: str,
87
+ where_params: list,
88
+ conn,
89
+ top_k: int = TOP_K_DENSE,
90
+ ) -> List[dict]:
91
+ """HNSW vector search on chunk embeddings with post-filter date constraints."""
92
+ emb_list = query_embedding.tolist()
93
+
94
+ # Build date filter for application AFTER HNSW search
95
+ date_filter = ""
96
+ if where_clause:
97
+ # Strip "WHERE " and keep the rest
98
+ date_filter = "WHERE " + where_clause.replace("WHERE ", "")
99
+
100
+ sql = f"""
101
+ WITH top_chunks AS (
102
+ -- Step 1: HNSW search unrestricted - fast
103
+ SELECT
104
+ c.document_id,
105
+ c.id AS chunk_id,
106
+ c.chunk_index,
107
+ c.chunk_text,
108
+ c.sparse_token_ids,
109
+ c.sparse_weights,
110
+ (c.text_embedding <=> %s::vector) AS distance
111
+ FROM chunks c
112
+ ORDER BY c.text_embedding <=> %s::vector
113
+ LIMIT %s
114
+ ),
115
+ nearest_chunks AS (
116
+ -- Step 2: Deduplicate to best chunk per document
117
+ SELECT DISTINCT ON (document_id)
118
+ *,
119
+ 1 - distance AS dense_score
120
+ FROM top_chunks
121
+ ORDER BY document_id, distance
122
+ )
123
+ SELECT
124
+ nc.*,
125
+ d.ark_id, d.title, d.source_url, d.institution,
126
+ d.issue_date, d.year, d.topics, d.geography,
127
+ d.metadata_embedding,
128
+ d.exemplary_image_id,
129
+ d.sparse_token_ids AS doc_sparse_token_ids,
130
+ d.sparse_weights AS doc_sparse_weights
131
+ FROM nearest_chunks nc
132
+ JOIN documents d ON d.id = nc.document_id
133
+ {date_filter}
134
+ ORDER BY nc.dense_score DESC
135
+ """
136
+
137
+ # HNSW fetches top_k * 3 chunks, then date filter is applied after join
138
+ params = [emb_list, emb_list, top_k * 3] + where_params
139
+
140
+ with get_cursor(conn) as cur:
141
+ cur.execute(sql, params)
142
+ return cur.fetchall()
143
+
144
+ # ── Sparse re-ranking on dense candidates ────────────────────────────────────
145
+
146
+ def _sparse_search(
147
+ query_sparse: dict,
148
+ dense_results: List[dict],
149
+ top_k: int = TOP_K_BM25,
150
+ ) -> List[dict]:
151
+ """Score sparse on dense candidates only β€” no extra DB call."""
152
+ if not query_sparse or not dense_results:
153
+ return []
154
+
155
+ scored = []
156
+ for row in dense_results:
157
+ token_ids = row.get("sparse_token_ids") or []
158
+ weights = row.get("sparse_weights") or []
159
+ chunk_sparse = dict(zip([str(t) for t in token_ids], weights))
160
+ score = sum(
161
+ float(query_sparse.get(tok, 0.0)) * float(weight)
162
+ for tok, weight in chunk_sparse.items()
163
+ )
164
+ row_dict = dict(row)
165
+ row_dict["bm25_score"] = score
166
+ scored.append(row_dict)
167
+
168
+ scored.sort(key=lambda x: x["bm25_score"], reverse=True)
169
+ return scored[:top_k]
170
+
171
+
172
+ # ── Metadata document search ──────────────────────────────────────────────────
173
+
174
+ def _metadata_search(
175
+ query_embedding: np.ndarray,
176
+ where_clause: str,
177
+ where_params: list,
178
+ conn,
179
+ top_k: int = TOP_K_DENSE,
180
+ ) -> List[dict]:
181
+ """Search all documents by metadata embedding similarity."""
182
+ emb_list = query_embedding.tolist()
183
+
184
+ sql = f"""
185
+ SELECT
186
+ d.id AS document_id,
187
+ d.ark_id,
188
+ d.title,
189
+ d.source_url,
190
+ d.institution,
191
+ d.issue_date,
192
+ d.year,
193
+ d.topics,
194
+ d.geography,
195
+ d.metadata_embedding,
196
+ d.exemplary_image_id,
197
+ d.sparse_token_ids AS doc_sparse_token_ids,
198
+ d.sparse_weights AS doc_sparse_weights,
199
+ '' AS chunk_text,
200
+ 0 AS chunk_index,
201
+ 1 - (d.metadata_embedding <=> %s::vector) AS dense_score
202
+ FROM documents d
203
+ {where_clause}
204
+ ORDER BY d.metadata_embedding <=> %s::vector
205
+ LIMIT %s
206
+ """
207
+
208
+ params = [emb_list] + where_params + [emb_list, top_k]
209
+
210
+ with get_cursor(conn) as cur:
211
+ cur.execute(sql, params)
212
+ return cur.fetchall()
213
+
214
+
215
+ # ── Graph retrieval ───────────────────────────────────────────────────────────
216
+
217
+ def _graph_search(
218
+ query_embedding: np.ndarray,
219
+ exclude_ark_ids: set,
220
+ conn,
221
+ top_k: int = GRAPH_TOP_K,
222
+ ) -> List[dict]:
223
+ """
224
+ Graph retrieval via Neo4j entity matching + two-hop traversal.
225
+ Fetches best chunk per document by embedding similarity from PostgreSQL.
226
+ Returns results in the same dict format as dense/metadata search for RRF.
227
+ """
228
+ from graph.graph_retriever import retrieve_by_query
229
+
230
+ graph_results = retrieve_by_query(
231
+ query_embedding = query_embedding,
232
+ exclude_ark_ids = exclude_ark_ids,
233
+ top_k = top_k,
234
+ )
235
+
236
+ if not graph_results:
237
+ print("[retriever] Graph returned no results")
238
+ return []
239
+
240
+ # Fetch full document details + best chunk by embedding similarity
241
+ graph_ark_ids = [r.ark_id for r in graph_results]
242
+ emb_list = query_embedding.tolist()
243
+
244
+ sql = """
245
+ SELECT
246
+ d.id AS document_id,
247
+ d.ark_id,
248
+ d.title,
249
+ d.source_url,
250
+ d.institution,
251
+ d.issue_date,
252
+ d.year,
253
+ d.topics,
254
+ d.geography,
255
+ d.metadata_embedding,
256
+ d.exemplary_image_id,
257
+ d.sparse_token_ids AS doc_sparse_token_ids,
258
+ d.sparse_weights AS doc_sparse_weights,
259
+ c.chunk_text,
260
+ c.chunk_index
261
+ FROM documents d
262
+ LEFT JOIN LATERAL (
263
+ SELECT chunk_text, chunk_index
264
+ FROM chunks
265
+ WHERE document_id = d.id
266
+ ORDER BY text_embedding <=> %s::vector
267
+ LIMIT 1
268
+ ) c ON true
269
+ WHERE d.ark_id = ANY(%s)
270
+ """
271
+
272
+ with get_cursor(conn) as cur:
273
+ cur.execute(sql, (emb_list, graph_ark_ids))
274
+ rows = {row["ark_id"]: row for row in cur.fetchall()}
275
+
276
+ # Build result dicts in same format as dense/metadata results
277
+ results = []
278
+ for graph_result in graph_results:
279
+ row = rows.get(graph_result.ark_id)
280
+ if not row:
281
+ continue
282
+ result = dict(row)
283
+ result["graph_score"] = graph_result.graph_score
284
+ results.append(result)
285
+
286
+ return results
287
+
288
+
289
+ # ── RRF fusion ────────────────────────────────────────────────────────────────
290
+
291
+ def _reciprocal_rank_fusion(
292
+ *result_lists: List[dict],
293
+ k: int = RRF_K,
294
+ ) -> List[tuple[str, float, dict]]:
295
+ """
296
+ Merge any number of ranked lists using RRF.
297
+ Each list contributes 1/(k + rank) to the score.
298
+ Empty lists are skipped silently.
299
+ """
300
+ scores: dict[str, float] = {}
301
+ rows: dict[str, dict] = {}
302
+
303
+ for result_list in result_lists:
304
+ if not result_list:
305
+ continue
306
+ for rank, row in enumerate(result_list, start=1):
307
+ aid = row["ark_id"]
308
+ scores[aid] = scores.get(aid, 0.0) + 1.0 / (k + rank)
309
+ if aid not in rows:
310
+ rows[aid] = row
311
+
312
+ ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)
313
+ return [(aid, score, rows[aid]) for aid, score in ranked]
314
+
315
+
316
+ # ── Final rerank ──────────────────────────────────────────────────────────────
317
+
318
+ def _rerank(
319
+ rrf_results: List[tuple[str, float, dict]],
320
+ query_embedding: np.ndarray,
321
+ top_k: int = TOP_K_FINAL,
322
+ ) -> List[RetrievedDocument]:
323
+ """
324
+ Final rerank blending RRF score with metadata embedding similarity.
325
+ """
326
+ if not rrf_results:
327
+ return []
328
+
329
+ final: List[RetrievedDocument] = []
330
+
331
+ for ark_id, rrf_score, row in rrf_results:
332
+ meta_emb = row.get("metadata_embedding")
333
+ if meta_emb is not None:
334
+ if isinstance(meta_emb, str):
335
+ meta_emb = json.loads(meta_emb)
336
+ meta_vec = np.array(meta_emb, dtype=np.float32)
337
+ meta_sim = float(np.dot(query_embedding, meta_vec))
338
+ meta_sim = max(0.0, min(1.0, meta_sim))
339
+ else:
340
+ meta_sim = 0.0
341
+
342
+ final_score = CONTENT_WEIGHT * rrf_score + METADATA_WEIGHT * meta_sim
343
+
344
+ final.append(RetrievedDocument(
345
+ ark_id = ark_id,
346
+ document_id = row["document_id"],
347
+ title = row.get("title", ""),
348
+ source_url = row.get("source_url", ""),
349
+ institution = row.get("institution", ""),
350
+ issue_date = row.get("issue_date", ""),
351
+ year = row.get("year") or [],
352
+ topics = row.get("topics") or [],
353
+ geography = row.get("geography") or [],
354
+ best_chunk_text = row.get("chunk_text", ""),
355
+ best_chunk_index = row.get("chunk_index", 0),
356
+ exemplary_image_id = row.get("exemplary_image_id") or "",
357
+ rrf_score = rrf_score,
358
+ metadata_sim = meta_sim,
359
+ final_score = final_score,
360
+ ))
361
+
362
+ final.sort(key=lambda x: x.final_score, reverse=True)
363
+ final = [doc for doc in final if doc.final_score >= MIN_RELEVANCE_SCORE]
364
+ return final[:top_k]
365
+
366
+
367
+ # ── Public API ────────────────────────────────────────────────────────────────
368
+
369
+ def retrieve(intent: QueryIntent, top_k: int = TOP_K_FINAL) -> Tuple[List[RetrievedDocument], np.ndarray]:
370
+ """
371
+ Full hybrid retrieval:
372
+ 1. Build date filter from intent
373
+ 2. Embed query (dense + sparse in one pass)
374
+ 3. Dense chunk search (HNSW)
375
+ 4. Sparse re-rank on dense candidates
376
+ 5. Metadata document search
377
+ 6. Graph retrieval via Neo4j (if enabled)
378
+ 7. RRF fusion of all result lists
379
+ 8. Final rerank and threshold filter
380
+
381
+ Returns (documents, query_embedding) β€” embedding returned to avoid
382
+ re-computation in downstream callers.
383
+ """
384
+ where_clause, where_params = _build_filter_clause(intent)
385
+
386
+ query_output = embedder.encode_one_both(intent.rewritten_query, is_query=True)
387
+ query_emb = query_output["dense"]
388
+ query_sparse = query_output["sparse"]
389
+
390
+ with get_conn() as conn:
391
+ # Path A: chunk-level dense search
392
+ t = time.monotonic()
393
+ print("[retrieve] starting dense", flush=True)
394
+ print(f"[retrieve] where_clause: '{where_clause}'", flush=True)
395
+ print(f"[retrieve] where_params: {where_params}", flush=True)
396
+ dense_results = _dense_search(query_emb, where_clause, where_params, conn)
397
+ dense_results = _dense_search(
398
+ query_emb, where_clause, where_params, conn
399
+ )
400
+ print(f"[timing] dense: {time.monotonic()-t:.2f}s", flush=True)
401
+ # Path B: sparse re-rank on dense candidates
402
+ t = time.monotonic()
403
+ print("[retrieve] starting sparse", flush=True)
404
+ sparse_results = _sparse_search(
405
+ query_sparse, dense_results
406
+ )
407
+
408
+ # Path C: document-level metadata search
409
+ t = time.monotonic()
410
+ print("[retrieve] starting meta", flush=True)
411
+ meta_results = _metadata_search(
412
+ query_emb, where_clause, where_params, conn,
413
+ top_k=TOP_K_DENSE,
414
+ )
415
+ print(f"[timing] meta: {time.monotonic()-t:.2f}s", flush=True)
416
+ # Path D: graph retrieval
417
+ graph_results = []
418
+ if GRAPH_RAG_ENABLED:
419
+ existing_ark_ids = {r["ark_id"] for r in dense_results + meta_results}
420
+ t = time.monotonic()
421
+ print("[retrieve] starting graph", flush=True)
422
+ graph_results = _graph_search(
423
+ query_emb,
424
+ exclude_ark_ids = existing_ark_ids,
425
+ conn = conn,
426
+ top_k = GRAPH_TOP_K,
427
+ )
428
+ print(f"[timing] graph: {time.monotonic()-t:.2f}s", flush=True)
429
+
430
+
431
+ # Fuse all paths via RRF
432
+ rrf_results = _reciprocal_rank_fusion(
433
+ dense_results,
434
+ sparse_results,
435
+ meta_results,
436
+ graph_results,
437
+ )
438
+
439
+ return _rerank(rrf_results, query_emb, top_k=top_k), query_emb
scripts/update_abstracts_and_embeddings.py ADDED
@@ -0,0 +1,221 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ scripts/update_abstracts_and_embeddings.py
3
+
4
+ For all metadata-only documents that have a non-empty abstract in the
5
+ JSONL source file:
6
+ 1. Updates the `abstract` column in PostgreSQL with the full text
7
+ 2. Reconstructs the metadata text string (identical to ingestion)
8
+ 3. Re-computes dense + sparse embeddings using BGE-M3
9
+ 4. Updates metadata_embedding, sparse_token_ids, sparse_weights in DB
10
+
11
+ Run:
12
+ python scripts/update_abstracts_and_embeddings.py
13
+ python scripts/update_abstracts_and_embeddings.py --metadata-file data/metadata/metadata.jsonl
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import html
20
+ import json
21
+ import re
22
+ import sys
23
+ import time
24
+ from datetime import datetime, timezone
25
+ from pathlib import Path
26
+ from typing import List
27
+
28
+ # Add project root to path so imports work when run as script
29
+ project_root = Path(__file__).parent.parent
30
+ sys.path.insert(0, str(project_root))
31
+
32
+ from database.schema import get_conn, get_cursor
33
+ from embedding.embedder import embedder
34
+
35
+ DEFAULT_METADATA_FILE = "data/metadata/metadata.jsonl"
36
+ BATCH_SIZE = 64
37
+
38
+
39
+ # ── HTML cleaning (identical to _parse_metadata_record) ───────────────────────
40
+
41
+ def clean_abstract(raw: str) -> str:
42
+ """Strip HTML exactly as _parse_metadata_record does β€” no truncation."""
43
+ text = html.unescape(raw)
44
+ text = re.sub(r"<[^>]+>", " ", text).strip()
45
+ text = re.sub(r"\s+", " ", text)
46
+ return text
47
+
48
+
49
+ # ── Build metadata text (identical to build_metadata_text in embedder.py) ─────
50
+
51
+ def build_metadata_text(record: dict) -> str:
52
+ """
53
+ Reconstruct the metadata text string exactly as embedder.build_metadata_text()
54
+ does β€” with abstract truncation removed.
55
+ """
56
+ parts = []
57
+
58
+ if record.get("title"):
59
+ parts.append(f"Title: {record['title']}")
60
+
61
+ genres = record.get("genre") or []
62
+ if genres:
63
+ parts.append(f"Format: {', '.join(genres)}")
64
+
65
+ topics = record.get("topics") or []
66
+ if topics:
67
+ parts.append(f"Topics: {', '.join(topics)}")
68
+
69
+ geography = record.get("geography") or []
70
+ if geography:
71
+ parts.append(f"Geography: {', '.join(geography)}")
72
+
73
+ place = record.get("place") or []
74
+ if place:
75
+ parts.append(f"Place: {', '.join(place)}")
76
+
77
+ year = record.get("year") or []
78
+ if year:
79
+ parts.append(f"Year: {', '.join(str(y) for y in year)}")
80
+
81
+ collection = record.get("collection") or ""
82
+ if collection and collection != record.get("title"):
83
+ parts.append(f"Collection: {collection}")
84
+
85
+ abstract = record.get("abstract") or ""
86
+ if abstract:
87
+ # No truncation β€” full abstract
88
+ parts.append(f"Description: {abstract}")
89
+
90
+ return " | ".join(parts)
91
+
92
+
93
+ # ── Update DB ─────────────────────────────────────────────────────────────────
94
+
95
+ def update_batch(batch: List[dict]):
96
+ """
97
+ For a batch of records with full abstracts:
98
+ 1. Compute new metadata text
99
+ 2. Embed dense + sparse in one forward pass
100
+ 3. Update abstract, metadata_embedding, sparse_token_ids, sparse_weights
101
+ """
102
+ meta_texts = [build_metadata_text(r) for r in batch]
103
+
104
+ output = embedder.encode_both(meta_texts)
105
+ dense_embs = output["dense"]
106
+ sparse_embs = output["sparse"]
107
+
108
+ with get_conn() as conn:
109
+ for rec, dense_emb, sparse in zip(batch, dense_embs, sparse_embs):
110
+ token_ids = [int(k) for k in sparse.keys()]
111
+ weights = [float(v) for v in sparse.values()]
112
+
113
+ with get_cursor(conn) as cur:
114
+ cur.execute(
115
+ """
116
+ UPDATE documents
117
+ SET
118
+ abstract = %s,
119
+ metadata_embedding = %s,
120
+ sparse_token_ids = %s,
121
+ sparse_weights = %s,
122
+ ingested_at = %s
123
+ WHERE ark_id = %s
124
+ """,
125
+ (
126
+ rec["abstract"],
127
+ dense_emb.tolist(),
128
+ token_ids,
129
+ weights,
130
+ datetime.now(timezone.utc).isoformat(),
131
+ rec["ark_id"],
132
+ )
133
+ )
134
+
135
+
136
+ # ── Main ──────────────────────────────────────────────────────────────────────
137
+
138
+ def run(metadata_file: str = DEFAULT_METADATA_FILE):
139
+ fpath = Path(metadata_file)
140
+ if not fpath.exists():
141
+ print(f"File not found: {metadata_file}")
142
+ return
143
+
144
+ print(f"\n{'='*60}")
145
+ print("Update Abstracts + Embeddings for Metadata Records")
146
+ print(f" Source : {metadata_file}")
147
+ print(f" Batch : {BATCH_SIZE}")
148
+ print(f"{'='*60}\n")
149
+
150
+ print("Reading metadata JSONL...")
151
+ records_to_update = []
152
+
153
+ with open(fpath, "r", encoding="utf-8") as f:
154
+ for line in f:
155
+ line = line.strip()
156
+ if not line:
157
+ continue
158
+ try:
159
+ raw = json.loads(line)
160
+ except json.JSONDecodeError:
161
+ continue
162
+
163
+ data = raw.get("data", {})
164
+ attrs = data.get("attributes", {})
165
+ record_id = data.get("id", "")
166
+ ark_id = record_id.split(":")[-1] if ":" in record_id else record_id
167
+
168
+ abstract_raw = attrs.get("abstract_tsi", "") or ""
169
+ if not abstract_raw.strip():
170
+ continue
171
+
172
+ abstract = clean_abstract(abstract_raw)
173
+ if not abstract:
174
+ continue
175
+
176
+ records_to_update.append({
177
+ "ark_id": ark_id,
178
+ "abstract": abstract,
179
+ "genre": attrs.get("genre_basic_ssim", []),
180
+ "topics": attrs.get("subject_topic_tsim", []),
181
+ "geography": attrs.get("subject_geographic_ssim", []),
182
+ "place": attrs.get("publication_place_tsim", []),
183
+ "year": attrs.get("date_facet_yearly_itim", []),
184
+ "title": attrs.get("title_info_primary_tsi", ""),
185
+ "collection": attrs.get("title_info_primary_tsi", ""),
186
+ "institution": attrs.get("institution_name_ssi", ""),
187
+ })
188
+
189
+ print(f" Found {len(records_to_update)} records with non-empty abstracts\n")
190
+
191
+ if not records_to_update:
192
+ print("Nothing to update.")
193
+ return
194
+
195
+ total_updated = 0
196
+ start_time = time.monotonic()
197
+
198
+ for i in range(0, len(records_to_update), BATCH_SIZE):
199
+ batch = records_to_update[i:i + BATCH_SIZE]
200
+ update_batch(batch)
201
+ total_updated += len(batch)
202
+
203
+ elapsed = time.monotonic() - start_time
204
+ remaining = (elapsed / total_updated) * (len(records_to_update) - total_updated) if total_updated else 0
205
+ print(
206
+ f" [{total_updated}/{len(records_to_update)}] "
207
+ f"ETA: {remaining/60:.1f}min"
208
+ )
209
+
210
+ print(f"\nβœ“ Done.")
211
+ print(f" Records updated : {total_updated}")
212
+ print(f" Total time : {(time.monotonic()-start_time)/60:.1f} min")
213
+
214
+
215
+ # ── CLI ───────────────────────────────────────────────────────────────────────
216
+
217
+ if __name__ == "__main__":
218
+ parser = argparse.ArgumentParser()
219
+ parser.add_argument("--metadata-file", default=DEFAULT_METADATA_FILE)
220
+ args = parser.parse_args()
221
+ run(metadata_file=args.metadata_file)