tarzanagh commited on
Commit
4c67792
·
verified ·
1 Parent(s): be250e6

Rename: passage_entity pipeline (was hipporag_pipeline)

Browse files
src/passage_entity/__init__.py ADDED
File without changes
src/passage_entity/benchmark_runner.py ADDED
@@ -0,0 +1,737 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Entry point for running multihop benchmarks (MuSiQue, HotpotQA, 2WikiMultiHopQA)
3
+ Passage-entity KG pipeline benchmark runner for QAFD-RAG.
4
+
5
+ Usage::
6
+
7
+ python -m src.passage_entity.benchmark_runner \\
8
+ --dataset musique \\
9
+ --llm_model gpt-4o-mini \\
10
+ --embedding_model nvidia-nv-embed-v2 \\
11
+ --num_queries 100 \\
12
+ --qafd_alpha 10.0
13
+
14
+ The script will:
15
+ 1. Load corpus and questions from ``data/multihop/``.
16
+ 2. Build (or load) the knowledge graph.
17
+ 3. Run retrieval + QA.
18
+ 4. Evaluate Recall@K, Exact Match, and F1.
19
+ """
20
+
21
+ import argparse
22
+ import asyncio
23
+ import collections
24
+ import json
25
+ import logging
26
+ import os
27
+ import re
28
+ import string
29
+ import sys
30
+ import time
31
+ from typing import Dict, List, Optional, Set, Tuple
32
+
33
+ import numpy as np
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Ensure the QAFD-RAG root is on the path so ``src.*`` imports work
37
+ # when this file is executed as ``python -m src.passage_entity.benchmark_runner``
38
+ # ---------------------------------------------------------------------------
39
+ _project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
40
+ if _project_root not in sys.path:
41
+ sys.path.insert(0, _project_root)
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # Bypass src/__init__.py (which imports heavy AWS deps) by registering
45
+ # src as a plain namespace package before any sub-package imports.
46
+ # ---------------------------------------------------------------------------
47
+ import types as _types
48
+ for _pkg_path in ["src", "src.retrievers", "src.passage_entity"]:
49
+ if _pkg_path not in sys.modules:
50
+ _m = _types.ModuleType(_pkg_path)
51
+ _m.__path__ = [os.path.join(_project_root, *_pkg_path.split("."))]
52
+ _m.__package__ = _pkg_path
53
+ sys.modules[_pkg_path] = _m
54
+
55
+ # Load only the modules we actually need (no aioboto3, no AWS, no SAPIEN)
56
+ import importlib.util as _ilu
57
+ def _load_mod(fqn, filepath):
58
+ spec = _ilu.spec_from_file_location(fqn, filepath)
59
+ mod = _ilu.module_from_spec(spec)
60
+ sys.modules[fqn] = mod
61
+ spec.loader.exec_module(mod)
62
+ return mod
63
+
64
+ _src = os.path.join(_project_root, "src")
65
+ _load_mod("src.retrievers.base", os.path.join(_src, "retrievers", "base.py"))
66
+ _load_mod("src.retrievers.flow_diffusion", os.path.join(_src, "retrievers", "flow_diffusion.py"))
67
+
68
+ from src.passage_entity.config import PassageEntityConfig
69
+ from src.passage_entity.embedding_store import EmbeddingModelWrapper
70
+ from src.passage_entity.kg_builder import KGBuilder
71
+ from src.passage_entity.openie import OpenIE
72
+ from src.passage_entity.reranker import FactReranker
73
+ from src.passage_entity.retriever import PassageEntityRetriever
74
+ from src.passage_entity.prompts import make_qa_messages
75
+ from src.passage_entity.utils import QuerySolution
76
+
77
+ # ---------------------------------------------------------------------------
78
+ # Minimal OpenAI LLM + Embedding (no AWS deps, no src/llm.py)
79
+ # ---------------------------------------------------------------------------
80
+ from openai import AsyncOpenAI
81
+
82
+ # Shared client — avoids "Event loop is closed" errors from abandoned clients
83
+ _openai_clients: dict = {}
84
+
85
+ def _get_client(base_url="https://api.openai.com/v1", api_key=""):
86
+ key = (base_url, api_key)
87
+ if key not in _openai_clients:
88
+ _openai_clients[key] = AsyncOpenAI(
89
+ base_url=base_url,
90
+ api_key=api_key or os.environ.get("OPENAI_API_KEY", ""),
91
+ )
92
+ return _openai_clients[key]
93
+
94
+ async def _openai_complete(model, prompt, system_prompt=None, history_messages=[],
95
+ base_url="https://api.openai.com/v1", api_key="", **kwargs):
96
+ client = _get_client(base_url, api_key)
97
+ kwargs.pop("hashing_kv", None)
98
+ kwargs.pop("keyword_extraction", None)
99
+ messages = []
100
+ if system_prompt:
101
+ messages.append({"role": "system", "content": system_prompt})
102
+ messages.extend(history_messages)
103
+ messages.append({"role": "user", "content": prompt})
104
+ response = await client.chat.completions.create(model=model, messages=messages, **kwargs)
105
+ return response.choices[0].message.content
106
+
107
+ async def _openai_embed(texts, model="text-embedding-3-small", api_key=""):
108
+ client = _get_client(api_key=api_key)
109
+ cleaned = [t if t.strip() else " " for t in texts]
110
+ response = await client.embeddings.create(model=model, input=cleaned, encoding_format="float")
111
+ return np.array([dp.embedding for dp in response.data])
112
+
113
+ logger = logging.getLogger(__name__)
114
+
115
+ # ===========================================================================
116
+ # Gold extraction helpers (from the original pipeline main_qafd.py)
117
+ # ===========================================================================
118
+
119
+ def get_gold_docs(samples: List[dict], dataset_name: str = None) -> List[List[str]]:
120
+ gold_docs = []
121
+ for sample in samples:
122
+ if "supporting_facts" in sample:
123
+ gold_titles = {item[0] for item in sample["supporting_facts"]}
124
+ pairs = [item for item in sample["context"] if item[0] in gold_titles]
125
+ if dataset_name and dataset_name.startswith("hotpotqa"):
126
+ gd = [item[0] + "\n" + "".join(item[1]) for item in pairs]
127
+ else:
128
+ gd = [item[0] + "\n" + " ".join(item[1]) for item in pairs]
129
+ elif "contexts" in sample:
130
+ gd = [
131
+ item["title"] + "\n" + item["text"]
132
+ for item in sample["contexts"]
133
+ if item["is_supporting"]
134
+ ]
135
+ elif "paragraphs" in sample:
136
+ paras = [
137
+ p for p in sample["paragraphs"]
138
+ if p.get("is_supporting", True)
139
+ ]
140
+ gd = [
141
+ p["title"] + "\n" + p.get("text", p.get("paragraph_text", ""))
142
+ for p in paras
143
+ ]
144
+ else:
145
+ gd = []
146
+ gold_docs.append(list(set(gd)))
147
+ return gold_docs
148
+
149
+
150
+ def get_gold_answers(samples: List[dict]) -> List[Set[str]]:
151
+ answers = []
152
+ for s in samples:
153
+ ans = s.get("answer") or s.get("gold_ans") or s.get("reference")
154
+ if ans is None and "obj" in s:
155
+ ans = list(
156
+ {s["obj"], s.get("possible_answers", ""), s.get("o_wiki_title", ""), s.get("o_aliases", "")}
157
+ )
158
+ if ans is None:
159
+ ans = ""
160
+ if isinstance(ans, str):
161
+ ans = [ans]
162
+ ans_set = set(ans)
163
+ if "answer_aliases" in s:
164
+ ans_set.update(s["answer_aliases"])
165
+ answers.append(ans_set)
166
+ return answers
167
+
168
+
169
+ # ===========================================================================
170
+ # Evaluation metrics
171
+ # ===========================================================================
172
+
173
+ def _normalize_answer(s: str) -> str:
174
+ """Lower-case, remove articles, punctuation, extra whitespace."""
175
+ s = s.lower()
176
+ s = re.sub(r"\b(a|an|the)\b", " ", s)
177
+ s = "".join(ch for ch in s if ch not in string.punctuation)
178
+ return " ".join(s.split())
179
+
180
+
181
+ def exact_match(prediction: str, gold_answers: Set[str]) -> float:
182
+ pred_norm = _normalize_answer(prediction)
183
+ return float(any(_normalize_answer(g) == pred_norm for g in gold_answers))
184
+
185
+
186
+ def f1_score(prediction: str, gold_answers: Set[str]) -> float:
187
+ pred_tokens = _normalize_answer(prediction).split()
188
+ best_f1 = 0.0
189
+ for gold in gold_answers:
190
+ gold_tokens = _normalize_answer(gold).split()
191
+ common = collections.Counter(pred_tokens) & collections.Counter(gold_tokens)
192
+ num_same = sum(common.values())
193
+ if num_same == 0:
194
+ continue
195
+ precision = num_same / len(pred_tokens)
196
+ recall = num_same / len(gold_tokens)
197
+ f1 = 2 * precision * recall / (precision + recall)
198
+ best_f1 = max(best_f1, f1)
199
+ return best_f1
200
+
201
+
202
+ def recall_at_k(
203
+ gold_docs: List[List[str]], retrieved_docs: List[List[str]], k_list: List[int]
204
+ ) -> Dict[str, float]:
205
+ """Compute Recall@K across all queries."""
206
+ results = {}
207
+ for k in k_list:
208
+ recalls = []
209
+ for gd, rd in zip(gold_docs, retrieved_docs):
210
+ if not gd:
211
+ continue
212
+ retrieved_set = set(rd[:k])
213
+ found = sum(1 for g in gd if g in retrieved_set)
214
+ recalls.append(found / len(gd))
215
+ results[f"Recall@{k}"] = round(np.mean(recalls), 4) if recalls else 0.0
216
+ return results
217
+
218
+
219
+ # ===========================================================================
220
+ # QA (reading comprehension)
221
+ # ===========================================================================
222
+
223
+ def _run_sync(coro):
224
+ try:
225
+ loop = asyncio.get_running_loop()
226
+ except RuntimeError:
227
+ loop = None
228
+ if loop is not None and loop.is_running():
229
+ import concurrent.futures
230
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
231
+ return pool.submit(asyncio.run, coro).result()
232
+ else:
233
+ return asyncio.run(coro)
234
+
235
+
236
+ def run_qa(
237
+ queries: List[QuerySolution],
238
+ llm_func,
239
+ qa_top_k: int = 5,
240
+ ) -> List[QuerySolution]:
241
+ """Run reading-comprehension QA over retrieved passages."""
242
+ for qs in queries:
243
+ passages = qs.docs[:qa_top_k]
244
+ msgs = make_qa_messages(passages, qs.question)
245
+ # Convert messages to single call
246
+ system_prompt = None
247
+ history = []
248
+ user_prompt = ""
249
+ for msg in msgs:
250
+ if msg["role"] == "system":
251
+ system_prompt = msg["content"]
252
+ elif msg["role"] == "assistant":
253
+ history.append(msg)
254
+ elif msg["role"] == "user":
255
+ if user_prompt:
256
+ history.append({"role": "user", "content": user_prompt})
257
+ user_prompt = msg["content"]
258
+
259
+ try:
260
+ response = _run_sync(
261
+ llm_func(
262
+ prompt=user_prompt,
263
+ system_prompt=system_prompt,
264
+ history_messages=history,
265
+ max_tokens=512,
266
+ )
267
+ )
268
+ # Extract answer
269
+ if "Answer:" in response:
270
+ qs.answer = response.split("Answer:")[-1].strip()
271
+ else:
272
+ qs.answer = response.strip()
273
+ except Exception as e:
274
+ logger.error(f"QA error: {e}")
275
+ qs.answer = ""
276
+ return queries
277
+
278
+
279
+ def run_qa_ultradomain(
280
+ queries: List[QuerySolution],
281
+ llm_func,
282
+ qa_top_k: int = 5,
283
+ ) -> List[QuerySolution]:
284
+ """Generate full responses for UltraDomain (not short answers)."""
285
+ for qs in queries:
286
+ passages = qs.docs[:qa_top_k]
287
+ context = "\n\n".join(passages)
288
+ prompt = (
289
+ f"Based on the following context, provide a comprehensive and detailed "
290
+ f"answer to the question.\n\n"
291
+ f"Context:\n{context}\n\n"
292
+ f"Question: {qs.question}\n\n"
293
+ f"Answer:"
294
+ )
295
+ try:
296
+ response = _run_sync(
297
+ llm_func(prompt=prompt, max_tokens=1024)
298
+ )
299
+ qs.answer = response.strip()
300
+ except Exception as e:
301
+ logger.error(f"QA error: {e}")
302
+ qs.answer = ""
303
+ return queries
304
+
305
+
306
+ def run_quality_eval(
307
+ queries: List[str],
308
+ responses: List[str],
309
+ llm_func,
310
+ num_eval_rounds: int = 5,
311
+ ) -> Dict[str, List[float]]:
312
+ """Evaluate response quality using LLM scoring (same as entity-graph pipeline).
313
+
314
+ Each response is evaluated num_eval_rounds times on 5 criteria.
315
+ Returns dict of criterion -> list of per-query average scores.
316
+ """
317
+ criteria = ["comprehensiveness", "diversity", "logicality", "relevance", "coherence"]
318
+ result = {c: [] for c in criteria}
319
+
320
+ for i, (query, response) in enumerate(zip(queries, responses)):
321
+ if not response:
322
+ for c in criteria:
323
+ result[c].append(0.0)
324
+ continue
325
+
326
+ criterion_scores = {c: [] for c in criteria}
327
+ for _ in range(num_eval_rounds):
328
+ prompt = f"""Evaluate the following response to a question based on five criteria. Rate each criterion from 0-100.
329
+
330
+ Question: {query}
331
+ Response: {response}
332
+
333
+ Please evaluate based on these criteria:
334
+ - Comprehensiveness: How much detail does the answer provide to cover all aspects and details of the question?
335
+ - Diversity: How varied and rich is the answer in providing different perspectives and insights on the question?
336
+ - Logicality: How logically does the answer respond to all parts of the question?
337
+ - Relevance: How relevant is the answer to the question, staying focused and addressing the intended topic or issue?
338
+ - Coherence: How well does the answer maintain internal logical connections between its parts, ensuring a smooth and consistent structure?
339
+
340
+ Provide scores in JSON format:
341
+ {{
342
+ "comprehensiveness": [score],
343
+ "diversity": [score],
344
+ "logicality": [score],
345
+ "relevance": [score],
346
+ "coherence": [score]
347
+ }}"""
348
+ try:
349
+ eval_response = _run_sync(
350
+ llm_func(prompt=prompt, max_tokens=200)
351
+ )
352
+ import re as _re
353
+ json_match = _re.search(r'\{.*\}', eval_response, _re.DOTALL)
354
+ if json_match:
355
+ scores = json.loads(json_match.group())
356
+ for c in criteria:
357
+ if c in scores:
358
+ val = float(scores[c])
359
+ if 0 <= val <= 100:
360
+ criterion_scores[c].append(val)
361
+ except Exception:
362
+ continue
363
+
364
+ for c in criteria:
365
+ if criterion_scores[c]:
366
+ result[c].append(np.mean(criterion_scores[c]))
367
+ else:
368
+ result[c].append(0.0)
369
+
370
+ return result
371
+
372
+
373
+ # ===========================================================================
374
+ # Main
375
+ # ===========================================================================
376
+
377
+ def main():
378
+ parser = argparse.ArgumentParser(
379
+ description="QAFD-RAG passage-entity benchmark runner",
380
+ formatter_class=argparse.RawDescriptionHelpFormatter,
381
+ )
382
+ parser.add_argument("--dataset", type=str, default="musique",
383
+ help="Dataset name (e.g. musique, hotpotqa, 2wikimultihopqa, mix)")
384
+ parser.add_argument("--task", type=str, default="multihop",
385
+ choices=["multihop", "ultradomain"],
386
+ help="Task type (determines data loading)")
387
+ parser.add_argument("--num_queries", type=int, default=-1,
388
+ help="Number of queries (-1 = all)")
389
+ parser.add_argument("--data_dir", type=str, default="data/multihop",
390
+ help="Directory with corpus/question JSON files (multihop only)")
391
+ parser.add_argument("--save_dir", type=str, default="outputs",
392
+ help="Output directory")
393
+
394
+ # LLM
395
+ parser.add_argument("--llm_model", type=str, default="gpt-4o-mini")
396
+ parser.add_argument("--llm_base_url", type=str, default="https://api.openai.com/v1")
397
+ parser.add_argument("--llm_api_key", type=str, default="")
398
+
399
+ # Embedding
400
+ parser.add_argument("--embedding_model", type=str, default="nvidia-nv-embed-v2",
401
+ help="Key from QAFD-RAG embedding registry")
402
+
403
+ # Indexing
404
+ parser.add_argument("--force_index", action="store_true")
405
+ parser.add_argument("--force_openie", action="store_true")
406
+
407
+ # QAFD
408
+ parser.add_argument("--qafd_alpha", type=float, default=2.0)
409
+ parser.add_argument("--qafd_epsilon", type=float, default=0.01)
410
+ parser.add_argument("--qafd_max_iterations", type=int, default=500)
411
+ parser.add_argument("--qafd_weight_scheme", type=str, default="original")
412
+ parser.add_argument("--qafd_step_size", type=float, default=0.2)
413
+
414
+ # Retrieval
415
+ parser.add_argument("--linking_top_k", type=int, default=5)
416
+ parser.add_argument("--retrieval_top_k", type=int, default=200)
417
+ parser.add_argument("--passage_node_weight", type=float, default=0.05)
418
+
419
+ # QA
420
+ parser.add_argument("--qa_top_k", type=int, default=5)
421
+ parser.add_argument("--skip_qa", action="store_true",
422
+ help="Only run retrieval, skip QA step")
423
+
424
+ # Query-aware enhancements (all default = original behaviour)
425
+ parser.add_argument("--sim_mode", type=str, default="normalized",
426
+ choices=["normalized", "relu", "relu_sq"],
427
+ help="Similarity contrast function (default=normalized)")
428
+ parser.add_argument("--qa_sink_gamma", type=float, default=0.0,
429
+ help="Query-aware sink capacity (0=off)")
430
+ parser.add_argument("--qa_warm_delta", type=float, default=0.0,
431
+ help="Query-aware seed bias (0=off)")
432
+ parser.add_argument("--qa_warm_walk", action="store_true",
433
+ help="Use QA edge weights in warm-start random walk")
434
+ parser.add_argument("--qa_warm_steps", type=int, default=2,
435
+ help="Number of warm-start steps (default 2)")
436
+ parser.add_argument("--qa_accum_gamma", type=float, default=0.0,
437
+ help="Query-aware x accumulation boost (0=off)")
438
+ parser.add_argument("--qa_post_lambda", type=float, default=0.0,
439
+ help="Post-diffusion query reranking (0=off)")
440
+ parser.add_argument("--batch_push", action="store_true",
441
+ help="Use batch push-relabel (all excess nodes per iter)")
442
+
443
+ # Reranker
444
+ parser.add_argument("--rerank_dspy_path", type=str, default=None)
445
+
446
+ args = parser.parse_args()
447
+
448
+ logging.basicConfig(
449
+ level=logging.INFO,
450
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
451
+ )
452
+
453
+ # ----------------------------------------------------------------
454
+ # Config
455
+ # ----------------------------------------------------------------
456
+ config = PassageEntityConfig(
457
+ llm_model=args.llm_model,
458
+ llm_base_url=args.llm_base_url,
459
+ llm_api_key=args.llm_api_key,
460
+ embedding_model_key=args.embedding_model,
461
+ dataset=args.dataset,
462
+ save_dir=args.save_dir,
463
+ force_index_from_scratch=args.force_index,
464
+ force_openie_from_scratch=args.force_openie,
465
+ linking_top_k=args.linking_top_k,
466
+ retrieval_top_k=args.retrieval_top_k,
467
+ passage_node_weight=args.passage_node_weight,
468
+ qa_top_k=args.qa_top_k,
469
+ use_qafd=True,
470
+ qafd_alpha=args.qafd_alpha,
471
+ qafd_epsilon=args.qafd_epsilon,
472
+ qafd_max_iterations=args.qafd_max_iterations,
473
+ qafd_weight_scheme=args.qafd_weight_scheme,
474
+ qafd_step_size=args.qafd_step_size,
475
+ sim_mode=args.sim_mode,
476
+ qa_sink_gamma=args.qa_sink_gamma,
477
+ qa_warm_walk=args.qa_warm_walk,
478
+ qa_warm_steps=args.qa_warm_steps,
479
+ qa_accum_gamma=args.qa_accum_gamma,
480
+ batch_push=args.batch_push,
481
+ qa_warm_delta=args.qa_warm_delta,
482
+ qa_post_lambda=args.qa_post_lambda,
483
+ rerank_dspy_file_path=args.rerank_dspy_path,
484
+ )
485
+
486
+ # ----------------------------------------------------------------
487
+ # LLM function
488
+ # ----------------------------------------------------------------
489
+ _api_key = config.llm_api_key or os.environ.get("OPENAI_API_KEY", "")
490
+
491
+ async def llm_func(prompt, system_prompt=None, history_messages=[], **kwargs):
492
+ return await _openai_complete(
493
+ model=config.llm_model,
494
+ prompt=prompt,
495
+ system_prompt=system_prompt,
496
+ history_messages=history_messages,
497
+ base_url=config.llm_base_url,
498
+ api_key=_api_key,
499
+ **kwargs,
500
+ )
501
+
502
+ # ----------------------------------------------------------------
503
+ # Embedding function (must match the model used to build the KG)
504
+ # ----------------------------------------------------------------
505
+ emb_key = config.embedding_model_key
506
+ if emb_key in ("openai-small", "openai-large"):
507
+ openai_model = "text-embedding-3-small" if emb_key == "openai-small" else "text-embedding-3-large"
508
+ async def embed_func(texts):
509
+ return await _openai_embed(texts, model=openai_model, api_key=_api_key)
510
+ else:
511
+ # Local embedding model — use QAFD-RAG's embedding registry
512
+ logger.info(f"Loading local embedding model: {emb_key}")
513
+ _emb_cfg = type("Cfg", (), {
514
+ "embedding_model_name": {
515
+ "nvidia-nv-embed-v2": "nvidia/NV-Embed-v2",
516
+ "jina-v3": "jinaai/jina-embeddings-v3",
517
+ "gritlm": "GritLM/GritLM-7B",
518
+ }.get(emb_key, emb_key),
519
+ "embedding_batch_size": config.embedding_batch_size,
520
+ })()
521
+ _emb_src = os.path.join(_project_root, "src", "embedding_models")
522
+ if emb_key == "nvidia-nv-embed-v2":
523
+ _mod = _load_mod("src.embedding_models.NVEmbedV2", os.path.join(_emb_src, "NVEmbedV2.py"))
524
+ _local_model = _mod.NVEmbedV2EmbeddingModel(_emb_cfg)
525
+ elif emb_key == "jina-v3":
526
+ _mod = _load_mod("src.embedding_models.JinaV3", os.path.join(_emb_src, "JinaV3.py"))
527
+ _local_model = _mod.JinaV3EmbeddingModel(_emb_cfg)
528
+ elif emb_key == "gritlm":
529
+ _mod = _load_mod("src.embedding_models.GritLM", os.path.join(_emb_src, "GritLM.py"))
530
+ _local_model = _mod.GritLMEmbeddingModel(_emb_cfg)
531
+ else:
532
+ raise ValueError(f"Unknown embedding model: {emb_key}")
533
+ async def embed_func(texts):
534
+ return np.array(_local_model.batch_encode(texts))
535
+ embedding_model = EmbeddingModelWrapper(embed_func, batch_size=config.embedding_batch_size)
536
+
537
+ # ----------------------------------------------------------------
538
+ # Load data
539
+ # ----------------------------------------------------------------
540
+ if args.task == "ultradomain":
541
+ # UltraDomain: load from HuggingFace, each record has context + input
542
+ from datasets import load_dataset as hf_load_dataset
543
+
544
+ dataset_file = f"{args.dataset}.jsonl"
545
+ logger.info(f"Loading UltraDomain dataset: {dataset_file}")
546
+ hf_dataset = hf_load_dataset(
547
+ "TommyChien/UltraDomain", data_files=dataset_file, split="train"
548
+ )
549
+
550
+ num_q = args.num_queries if args.num_queries > 0 else len(hf_dataset)
551
+ num_q = min(num_q, len(hf_dataset))
552
+
553
+ # Each record's context becomes the corpus.
554
+ # UltraDomain contexts can be very long (30K+ chars), so we chunk them
555
+ # into ~500-word passages to fit embedding model token limits.
556
+ docs = []
557
+ chunk_size = 500 # words per chunk
558
+ chunk_overlap = 50 # word overlap between chunks
559
+ for i in range(num_q):
560
+ ctx = hf_dataset[i].get("context", "")
561
+ if not ctx:
562
+ continue
563
+ words = ctx.split()
564
+ if len(words) <= chunk_size:
565
+ docs.append(ctx)
566
+ else:
567
+ for start in range(0, len(words), chunk_size - chunk_overlap):
568
+ chunk = " ".join(words[start : start + chunk_size])
569
+ if chunk.strip():
570
+ docs.append(chunk)
571
+
572
+ all_queries = [hf_dataset[i]["input"] for i in range(num_q)]
573
+ samples = [dict(hf_dataset[i]) for i in range(num_q)]
574
+ gold_answers = [
575
+ set(s.get("answers", [s.get("label", "")])) for s in samples
576
+ ]
577
+ gold_docs = None # UltraDomain has no gold supporting docs
578
+
579
+ else:
580
+ # Multihop: load from local JSON files
581
+ corpus_path = os.path.join(args.data_dir, f"{args.dataset}_corpus.json")
582
+ questions_path = os.path.join(args.data_dir, f"{args.dataset}.json")
583
+
584
+ logger.info(f"Loading corpus from {corpus_path}")
585
+ with open(corpus_path) as f:
586
+ corpus = json.load(f)
587
+ docs = [f"{d['title']}\n{d['text']}" for d in corpus]
588
+
589
+ logger.info(f"Loading questions from {questions_path}")
590
+ with open(questions_path) as f:
591
+ samples = json.load(f)
592
+
593
+ all_queries = [s["question"] for s in samples]
594
+ if args.num_queries > 0:
595
+ all_queries = all_queries[: args.num_queries]
596
+ samples = samples[: args.num_queries]
597
+
598
+ gold_answers = get_gold_answers(samples)
599
+ try:
600
+ gold_docs = get_gold_docs(samples, args.dataset)
601
+ except Exception:
602
+ gold_docs = None
603
+
604
+ print("=" * 70)
605
+ print(f" Graph type: passage-entity")
606
+ print(f" Task: {args.task}")
607
+ print(f" Dataset: {args.dataset}")
608
+ print(f" Queries: {len(all_queries)}")
609
+ print(f" Corpus: {len(docs)} documents")
610
+ print(f" LLM: {config.llm_model}")
611
+ print(f" Embedding: {config.embedding_model_key}")
612
+ print(f" QAFD alpha: {config.qafd_alpha}")
613
+ print("=" * 70)
614
+
615
+ # ----------------------------------------------------------------
616
+ # Build / load KG
617
+ # ----------------------------------------------------------------
618
+ openie = OpenIE(llm_func)
619
+ builder = KGBuilder(config, embedding_model, openie)
620
+
621
+ if builder.graph.vcount() > 0 and not config.force_index_from_scratch:
622
+ logger.info(f"Using existing KG: {config.working_dir} "
623
+ f"({builder.graph.vcount()} nodes, {builder.graph.ecount()} edges)")
624
+ else:
625
+ logger.info("Building KG from scratch ...")
626
+ builder.index(docs)
627
+
628
+ # ----------------------------------------------------------------
629
+ # Retriever
630
+ # ----------------------------------------------------------------
631
+ reranker = FactReranker(llm_func, dspy_file_path=config.rerank_dspy_file_path)
632
+ retriever = PassageEntityRetriever(
633
+ config=config,
634
+ embedding_model=embedding_model,
635
+ reranker=reranker,
636
+ graph=builder.graph,
637
+ chunk_embedding_store=builder.chunk_embedding_store,
638
+ entity_embedding_store=builder.entity_embedding_store,
639
+ fact_embedding_store=builder.fact_embedding_store,
640
+ openie_results_path=builder.openie_results_path,
641
+ )
642
+
643
+ logger.info("Running retrieval ...")
644
+ retrieval_results = retriever.retrieve(
645
+ queries=all_queries, num_to_retrieve=config.retrieval_top_k
646
+ )
647
+
648
+ # ----------------------------------------------------------------
649
+ # Retrieval evaluation
650
+ # ----------------------------------------------------------------
651
+ if gold_docs is not None:
652
+ k_list = [1, 2, 5, 10, 20, 50, 100, 200]
653
+ retrieved = [r.docs for r in retrieval_results]
654
+ retrieval_metrics = recall_at_k(gold_docs, retrieved, k_list)
655
+ print("\n--- Retrieval Metrics ---")
656
+ for metric, val in retrieval_metrics.items():
657
+ print(f" {metric}: {val}")
658
+ else:
659
+ retrieval_metrics = {}
660
+
661
+ # ----------------------------------------------------------------
662
+ # QA + Evaluation (task-aware)
663
+ # ----------------------------------------------------------------
664
+ avg_em, avg_f1 = None, None
665
+ quality_scores = None
666
+
667
+ if not args.skip_qa:
668
+ logger.info("Running QA ...")
669
+
670
+ if args.task == "ultradomain":
671
+ # UltraDomain: generate full responses, evaluate with quality scores
672
+ retrieval_results = run_qa_ultradomain(
673
+ retrieval_results, llm_func, qa_top_k=config.qa_top_k
674
+ )
675
+ # Quality evaluation (same as entity-graph pipeline)
676
+ quality_scores = run_quality_eval(
677
+ all_queries, [qs.answer for qs in retrieval_results], llm_func
678
+ )
679
+ if quality_scores:
680
+ print("\n--- Quality Metrics ---")
681
+ overall = []
682
+ for criterion, scores in quality_scores.items():
683
+ avg = np.mean(scores)
684
+ std = np.std(scores)
685
+ print(f" {criterion:<25} {avg:.2f} +/- {std:.2f}")
686
+ overall.append(avg)
687
+ print(f" {'Overall Average':<25} {np.mean(overall):.2f}")
688
+ else:
689
+ # Multihop: generate short answers, evaluate with F1/EM
690
+ retrieval_results = run_qa(retrieval_results, llm_func, qa_top_k=config.qa_top_k)
691
+
692
+ em_scores, f1_scores = [], []
693
+ for qs, ga in zip(retrieval_results, gold_answers):
694
+ qs.gold_answers = list(ga)
695
+ em_scores.append(exact_match(qs.answer or "", ga))
696
+ f1_scores.append(f1_score(qs.answer or "", ga))
697
+
698
+ avg_em = round(np.mean(em_scores), 4)
699
+ avg_f1 = round(np.mean(f1_scores), 4)
700
+ print("\n--- QA Metrics ---")
701
+ print(f" Exact Match: {avg_em}")
702
+ print(f" F1 Score: {avg_f1}")
703
+
704
+ # ----------------------------------------------------------------
705
+ # Save results
706
+ # ----------------------------------------------------------------
707
+ os.makedirs(config.working_dir, exist_ok=True)
708
+ results_path = os.path.join(config.working_dir, f"results_{args.dataset}.json")
709
+ output = {
710
+ "graph_type": "passage-entity",
711
+ "dataset": args.dataset,
712
+ "task": args.task,
713
+ "num_queries": len(all_queries),
714
+ "retrieval_metrics": retrieval_metrics,
715
+ "qa_em": avg_em,
716
+ "qa_f1": avg_f1,
717
+ "quality_scores": quality_scores,
718
+ "config": {
719
+ "llm_model": config.llm_model,
720
+ "embedding_model_key": config.embedding_model_key,
721
+ "qafd_alpha": config.qafd_alpha,
722
+ "qafd_epsilon": config.qafd_epsilon,
723
+ "qafd_max_iterations": config.qafd_max_iterations,
724
+ "qafd_weight_scheme": config.qafd_weight_scheme,
725
+ "linking_top_k": config.linking_top_k,
726
+ "retrieval_top_k": config.retrieval_top_k,
727
+ },
728
+ "per_query": [qs.to_dict() for qs in retrieval_results],
729
+ }
730
+ with open(results_path, "w") as f:
731
+ json.dump(output, f, indent=2, default=str)
732
+ print(f"\nResults saved to {results_path}")
733
+ print("=" * 70)
734
+
735
+
736
+ if __name__ == "__main__":
737
+ main()
src/passage_entity/config.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Configuration dataclass for the passage-entity KG pipeline in QAFD-RAG.
3
+
4
+ Combines passage-entity BaseConfig fields with QAFD algorithm parameters.
5
+ """
6
+
7
+ from dataclasses import dataclass, field
8
+ from typing import Optional, Literal
9
+
10
+
11
+ @dataclass
12
+ class PassageEntityConfig:
13
+ """Unified configuration for indexing, retrieval, and QAFD."""
14
+
15
+ # ── LLM ────────────────────────────────────────────────────────────────
16
+ llm_model: str = "gpt-4o-mini"
17
+ llm_base_url: str = "https://api.openai.com/v1"
18
+ llm_api_key: str = "" # falls back to OPENAI_API_KEY env
19
+ max_new_tokens: Optional[int] = 2048
20
+ temperature: float = 0.0
21
+
22
+ # ── Embedding ──────────────────────────────────────────────────────────
23
+ embedding_model_key: str = "nvidia-nv-embed-v2" # key in QAFD-RAG registry
24
+ embedding_batch_size: int = 16
25
+
26
+ # ── Dataset / paths ────────────────────────────────────────────────────
27
+ dataset: Optional[str] = None # musique, hotpotqa, 2wikimultihopqa
28
+ save_dir: str = "outputs"
29
+ force_index_from_scratch: bool = False
30
+ force_openie_from_scratch: bool = False
31
+ save_openie: bool = True
32
+
33
+ # ── Graph construction ─────────────────────────────────────────────────
34
+ is_directed_graph: bool = False
35
+ synonymy_edge_topk: int = 2047
36
+ synonymy_edge_query_batch_size: int = 1000
37
+ synonymy_edge_key_batch_size: int = 10000
38
+ synonymy_edge_sim_threshold: float = 0.8
39
+
40
+ # ── Retrieval ──────────────────────────────────────────────────────────
41
+ linking_top_k: int = 5
42
+ retrieval_top_k: int = 200
43
+ passage_node_weight: float = 0.05
44
+ damping: float = 0.5
45
+
46
+ # ── QA ─────────────────────────────────────────────────────────────────
47
+ qa_top_k: int = 5
48
+
49
+ # ── QAFD algorithm parameters ──────────────────────────────────────────
50
+ use_qafd: bool = True
51
+ qafd_alpha: float = 2.0
52
+ qafd_epsilon: float = 0.01
53
+ qafd_max_iterations: int = 500
54
+ qafd_weight_scheme: str = "original" # "multiply", "add", "original"
55
+ qafd_use_node_degree: bool = True
56
+ qafd_step_size: float = 0.2
57
+ qafd_random_seed: int = 42
58
+
59
+ # ── Query-aware enhancements (all default = original behaviour) ────────
60
+ sim_mode: str = "normalized" # Similarity contrast: "normalized", "relu", "relu_sq"
61
+ qa_sink_gamma: float = 0.0 # Query-aware sink capacity (0=off)
62
+ qa_warm_delta: float = 0.0 # Query-aware seed bias (0=off)
63
+ qa_warm_walk: bool = False # Use QA edge weights in warm-start walk
64
+ qa_warm_steps: int = 2 # Number of warm-start steps (default 2)
65
+ qa_accum_gamma: float = 0.0 # Query-aware x accumulation boost (0=off)
66
+ qa_post_lambda: float = 0.0 # Post-diffusion reranking (0=off)
67
+ batch_push: bool = False # Batch push-relabel (process all excess nodes per iter)
68
+
69
+ # ── Reranker ───────────────────────────────────────────────────────────
70
+ rerank_dspy_file_path: Optional[str] = None # path to DSPy JSON; None → built-in prompt
71
+
72
+ def __post_init__(self):
73
+ if self.save_dir == "outputs" and self.dataset:
74
+ self.save_dir = f"outputs/{self.dataset}"
75
+
76
+ @property
77
+ def working_dir(self) -> str:
78
+ """Model-specific sub-directory under save_dir.
79
+
80
+ Also checks kg/multihop/ for pre-downloaded KGs from HuggingFace.
81
+ If found there, uses that path instead of outputs/.
82
+ """
83
+ import os
84
+ llm_label = self.llm_model.replace("/", "_")
85
+ emb_label = self.embedding_model_key.replace("/", "_")
86
+
87
+ # Check HuggingFace download location (kg/multihop/{llm}_{emb}_{dataset}/)
88
+ if self.dataset:
89
+ for task_dir in ["multihop", "ultradomain"]:
90
+ hf_path = os.path.join("kg", task_dir, f"{llm_label}_{emb_label}_{self.dataset}")
91
+ if os.path.isdir(hf_path) and os.path.exists(os.path.join(hf_path, "graph.pickle")):
92
+ return hf_path
93
+
94
+ return f"{self.save_dir}/{llm_label}_{emb_label}"
src/passage_entity/embedding_store.py ADDED
@@ -0,0 +1,236 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Parquet-backed embedding store, adapted from the original EmbeddingStore.
3
+
4
+ Uses QAFD-RAG's async embedding functions (wrapped synchronously) so we
5
+ can share models / GPU memory with the rest of the QAFD-RAG system.
6
+ """
7
+
8
+ import asyncio
9
+ import logging
10
+ import os
11
+ from copy import deepcopy
12
+ from typing import List, Dict, Optional, Callable, Any
13
+
14
+ import numpy as np
15
+ import pandas as pd
16
+
17
+ from .utils import compute_mdhash_id
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class EmbeddingModelWrapper:
23
+ """Thin sync wrapper around a QAFD-RAG *async* embedding function.
24
+
25
+ The wrapped function must have the signature::
26
+
27
+ async def embed(texts: list[str], **kwargs) -> np.ndarray
28
+
29
+ Parameters
30
+ ----------
31
+ embed_func : callable
32
+ An async embedding function from ``QAFD-RAG/src/llm.py``.
33
+ batch_size : int
34
+ Max texts per call.
35
+ """
36
+
37
+ def __init__(self, embed_func: Callable, batch_size: int = 16):
38
+ self._embed_func = embed_func
39
+ self.batch_size = batch_size
40
+
41
+ # ------------------------------------------------------------------
42
+ def batch_encode(self, texts, instruction: str = None, norm: bool = True) -> np.ndarray:
43
+ """Synchronously encode *texts* into embeddings."""
44
+ if isinstance(texts, str):
45
+ texts = [texts]
46
+ all_embeddings = []
47
+ # Use larger batch for API-based embeddings (OpenAI supports up to 2048)
48
+ effective_batch = max(self.batch_size, 512)
49
+ for start in range(0, len(texts), effective_batch):
50
+ batch = texts[start : start + effective_batch]
51
+ if instruction:
52
+ batch = [f"{instruction} {t}" for t in batch]
53
+ embs = self._run_async(self._embed_func(batch))
54
+ if not isinstance(embs, np.ndarray):
55
+ embs = np.array(embs)
56
+ if norm:
57
+ norms = np.linalg.norm(embs, axis=1, keepdims=True)
58
+ norms = np.where(norms == 0, 1, norms)
59
+ embs = embs / norms
60
+ all_embeddings.append(embs)
61
+ return np.vstack(all_embeddings)
62
+
63
+ # ------------------------------------------------------------------
64
+ @staticmethod
65
+ def _run_async(coro):
66
+ """Run an async coroutine synchronously."""
67
+ try:
68
+ loop = asyncio.get_running_loop()
69
+ except RuntimeError:
70
+ loop = None
71
+
72
+ if loop is not None and loop.is_running():
73
+ # We are inside an already-running event loop (e.g. Jupyter).
74
+ import concurrent.futures
75
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
76
+ return pool.submit(asyncio.run, coro).result()
77
+ else:
78
+ return asyncio.run(coro)
79
+
80
+
81
+ class EmbeddingStore:
82
+ """Parquet-backed vector store.
83
+
84
+ Mirrors the original EmbeddingStore but uses ``EmbeddingModelWrapper``
85
+ (which calls QAFD-RAG's async embedding functions under the hood).
86
+ """
87
+
88
+ def __init__(
89
+ self,
90
+ embedding_model: EmbeddingModelWrapper,
91
+ db_filename: str,
92
+ batch_size: int,
93
+ namespace: str,
94
+ ):
95
+ self.embedding_model = embedding_model
96
+ self.batch_size = batch_size
97
+ self.namespace = namespace
98
+
99
+ if not os.path.exists(db_filename):
100
+ logger.info(f"Creating directory: {db_filename}")
101
+ os.makedirs(db_filename, exist_ok=True)
102
+
103
+ self.filename = os.path.join(db_filename, f"vdb_{self.namespace}.parquet")
104
+ self._load_data()
105
+
106
+ # ------------------------------------------------------------------
107
+ # Data persistence
108
+ # ------------------------------------------------------------------
109
+
110
+ def _load_data(self):
111
+ if os.path.exists(self.filename):
112
+ df = pd.read_parquet(self.filename)
113
+ self.hash_ids = df["hash_id"].values.tolist()
114
+ self.texts = df["content"].values.tolist()
115
+ self.embeddings = df["embedding"].values.tolist()
116
+ self._rebuild_indices()
117
+ assert len(self.hash_ids) == len(self.texts) == len(self.embeddings)
118
+ logger.info(f"Loaded {len(self.hash_ids)} records from {self.filename}")
119
+ else:
120
+ self.hash_ids, self.texts, self.embeddings = [], [], []
121
+ self.hash_id_to_idx: Dict[str, int] = {}
122
+ self.hash_id_to_row: Dict[str, dict] = {}
123
+ self.hash_id_to_text: Dict[str, str] = {}
124
+ self.text_to_hash_id: Dict[str, str] = {}
125
+
126
+ def _rebuild_indices(self):
127
+ self.hash_id_to_idx = {h: idx for idx, h in enumerate(self.hash_ids)}
128
+ self.hash_id_to_row = {
129
+ h: {"hash_id": h, "content": t} for h, t in zip(self.hash_ids, self.texts)
130
+ }
131
+ self.hash_id_to_text = {h: self.texts[idx] for idx, h in enumerate(self.hash_ids)}
132
+ self.text_to_hash_id = {self.texts[idx]: h for idx, h in enumerate(self.hash_ids)}
133
+
134
+ def _save_data(self):
135
+ data = pd.DataFrame({
136
+ "hash_id": self.hash_ids,
137
+ "content": self.texts,
138
+ "embedding": self.embeddings,
139
+ })
140
+ data.to_parquet(self.filename, index=False)
141
+ self._rebuild_indices()
142
+ logger.info(f"Saved {len(self.hash_ids)} records to {self.filename}")
143
+
144
+ def _upsert(self, hash_ids, texts, embeddings):
145
+ self.embeddings.extend(embeddings)
146
+ self.hash_ids.extend(hash_ids)
147
+ self.texts.extend(texts)
148
+ self._save_data()
149
+
150
+ # ------------------------------------------------------------------
151
+ # Public API
152
+ # ------------------------------------------------------------------
153
+
154
+ def get_missing_string_hash_ids(self, texts: List[str]) -> Dict[str, dict]:
155
+ nodes_dict = {}
156
+ for text in texts:
157
+ hid = compute_mdhash_id(text, prefix=self.namespace + "-")
158
+ nodes_dict[hid] = {"content": text}
159
+
160
+ if not nodes_dict:
161
+ return {}
162
+
163
+ existing = set(self.hash_id_to_row.keys())
164
+ missing = {h: {"hash_id": h, "content": v["content"]}
165
+ for h, v in nodes_dict.items() if h not in existing}
166
+ return missing
167
+
168
+ def insert_strings(self, texts: List[str]):
169
+ nodes_dict = {}
170
+ for text in texts:
171
+ if not text or not text.strip():
172
+ continue
173
+ hid = compute_mdhash_id(text, prefix=self.namespace + "-")
174
+ nodes_dict[hid] = {"content": text}
175
+
176
+ all_ids = list(nodes_dict.keys())
177
+ if not all_ids:
178
+ return
179
+
180
+ existing = set(self.hash_id_to_row.keys())
181
+ missing_ids = [h for h in all_ids if h not in existing]
182
+
183
+ logger.info(
184
+ f"Inserting {len(missing_ids)} new records, "
185
+ f"{len(all_ids) - len(missing_ids)} already exist."
186
+ )
187
+ if not missing_ids:
188
+ return
189
+
190
+ texts_to_encode = [nodes_dict[h]["content"] for h in missing_ids]
191
+ missing_embeddings = self.embedding_model.batch_encode(texts_to_encode)
192
+ # Convert ndarray rows to list of lists for parquet storage
193
+ if isinstance(missing_embeddings, np.ndarray):
194
+ missing_embeddings = missing_embeddings.tolist()
195
+ self._upsert(missing_ids, texts_to_encode, missing_embeddings)
196
+
197
+ def delete(self, hash_ids):
198
+ indices = sorted(
199
+ [self.hash_id_to_idx[h] for h in hash_ids], reverse=True
200
+ )
201
+ for idx in indices:
202
+ self.hash_ids.pop(idx)
203
+ self.texts.pop(idx)
204
+ self.embeddings.pop(idx)
205
+ self._save_data()
206
+
207
+ # Lookups
208
+ def get_row(self, hash_id: str) -> dict:
209
+ return self.hash_id_to_row[hash_id]
210
+
211
+ def get_hash_id(self, text: str) -> str:
212
+ return self.text_to_hash_id[text]
213
+
214
+ def get_rows(self, hash_ids: List[str], dtype=np.float32) -> Dict[str, dict]:
215
+ if not hash_ids:
216
+ return {}
217
+ return {hid: self.hash_id_to_row[hid] for hid in hash_ids}
218
+
219
+ def get_all_ids(self) -> List[str]:
220
+ return deepcopy(self.hash_ids)
221
+
222
+ def get_all_id_to_rows(self) -> Dict[str, dict]:
223
+ return deepcopy(self.hash_id_to_row)
224
+
225
+ def get_all_texts(self) -> set:
226
+ return set(row["content"] for row in self.hash_id_to_row.values())
227
+
228
+ def get_embedding(self, hash_id: str, dtype=np.float32) -> np.ndarray:
229
+ return np.array(self.embeddings[self.hash_id_to_idx[hash_id]], dtype=dtype)
230
+
231
+ def get_embeddings(self, hash_ids: List[str], dtype=np.float32) -> np.ndarray:
232
+ if not hash_ids:
233
+ return np.array([])
234
+ indices = np.array([self.hash_id_to_idx[h] for h in hash_ids], dtype=np.intp)
235
+ all_embs = np.array(self.embeddings, dtype=dtype)
236
+ return all_embs[indices]
src/passage_entity/graph_adapter.py ADDED
@@ -0,0 +1,518 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Bridge between igraph (the original graph format) and QAFD-RAG's flow diffusion.
3
+
4
+ Provides:
5
+ - ``igraph_to_networkx``: convert an igraph.Graph to NetworkX (kept for
6
+ compatibility, but no longer used in the main retrieval path).
7
+ - ``IGraphQAFD``: igraph-native QAFD that matches the original
8
+ ``QueryAwareFlowDiffusion`` exactly — numpy arrays, C-based neighbor
9
+ lookups, no NetworkX conversion overhead.
10
+ """
11
+
12
+ import logging
13
+ import random
14
+ from typing import Dict, List, Optional, Tuple
15
+
16
+ import numpy as np
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ # ===========================================================================
22
+ # igraph --> NetworkX (kept for compatibility; not used in hot path)
23
+ # ===========================================================================
24
+
25
+ def igraph_to_networkx(ig_graph):
26
+ """Convert an igraph.Graph to a NetworkX (undirected) graph."""
27
+ import networkx as nx
28
+
29
+ G = nx.Graph()
30
+ name_attr = ig_graph.vs.attribute_names()
31
+ has_name = "name" in name_attr
32
+
33
+ for v in ig_graph.vs:
34
+ node_id = v["name"] if has_name else v.index
35
+ G.add_node(node_id)
36
+
37
+ has_weight = "weight" in ig_graph.es.attribute_names()
38
+
39
+ for e in ig_graph.es:
40
+ src = ig_graph.vs[e.source]["name"] if has_name else e.source
41
+ tgt = ig_graph.vs[e.target]["name"] if has_name else e.target
42
+ w = e["weight"] if has_weight else 1.0
43
+ G.add_edge(src, tgt, weight=w)
44
+
45
+ return G
46
+
47
+
48
+ # ===========================================================================
49
+ # igraph-native Query-Aware Flow Diffusion
50
+ # ===========================================================================
51
+
52
+ def _cosine_similarity(vec1: np.ndarray, vec2: np.ndarray, mode: str = "normalized") -> float:
53
+ """Cosine similarity with configurable contrast.
54
+
55
+ Modes:
56
+ "normalized": (cos+1)/2 → [0, 1] (original, low contrast)
57
+ "relu": max(0, cos) → [0, 1] (natural contrast)
58
+ "relu_sq": max(0, cos)² → [0, 1] (sharpest contrast)
59
+ """
60
+ if len(vec1) == 0 or len(vec2) == 0:
61
+ return 0.0
62
+ dot = np.dot(vec1, vec2)
63
+ m1 = np.linalg.norm(vec1)
64
+ m2 = np.linalg.norm(vec2)
65
+ if m1 == 0 or m2 == 0:
66
+ return 0.0
67
+ raw = dot / (m1 * m2)
68
+ if mode == "relu":
69
+ return max(0.0, raw)
70
+ elif mode == "relu_sq":
71
+ r = max(0.0, raw)
72
+ return r * r
73
+ else: # "normalized" — original
74
+ return max(0.0, (raw + 1.0) / 2.0)
75
+
76
+
77
+ class IGraphQAFD:
78
+ """Query-Aware Flow Diffusion directly on igraph — matches the original exactly.
79
+
80
+ Uses numpy arrays for mass/x/sink_capacity and igraph's C-based
81
+ ``graph.neighbors()`` for fast neighbour lookups.
82
+
83
+ Parameters
84
+ ----------
85
+ graph : igraph.Graph
86
+ node_name_to_idx : dict
87
+ Mapping from node name (str) -> vertex index (int).
88
+ source_weights : np.ndarray
89
+ Per-node seed weights (length = number of nodes). Will be normalised.
90
+ node_embeddings : dict
91
+ Mapping node_name -> np.ndarray embedding.
92
+ query_embedding : np.ndarray
93
+ Query embedding vector.
94
+ alpha, epsilon, max_iterations, step_size : float / int
95
+ Algorithm parameters.
96
+ weight_scheme : str
97
+ "original", "multiply", or "add".
98
+ random_seed : int
99
+ """
100
+
101
+ def __init__(
102
+ self,
103
+ graph,
104
+ node_name_to_idx: Dict[str, int],
105
+ source_weights: np.ndarray,
106
+ node_embeddings: Dict[str, np.ndarray],
107
+ query_embedding: Optional[np.ndarray],
108
+ alpha: float = 10.0,
109
+ epsilon: float = 1e-6,
110
+ max_iterations: int = 10000,
111
+ step_size: float = 0.2,
112
+ weight_scheme: str = "original",
113
+ hybrid_a: float = 1.0,
114
+ hybrid_b: float = 0.5,
115
+ use_node_degree: bool = True,
116
+ random_seed: int = 42,
117
+ threshold: float = 1e-5,
118
+ # ── Query-aware enhancements (all default OFF = original behaviour) ──
119
+ sim_mode: str = "normalized", # Similarity contrast: "normalized", "relu", "relu_sq"
120
+ qa_sink_gamma: float = 0.0, # query-aware sink capacity
121
+ qa_warm_delta: float = 0.0, # query-aware seed bias
122
+ qa_warm_walk: bool = False, # query-aware warm-start random walk (uses edge weights)
123
+ qa_warm_steps: int = 2, # number of warm-start steps (default 2)
124
+ qa_accum_gamma: float = 0.0, # query-aware x accumulation boost
125
+ ):
126
+ self.graph = graph
127
+ self.node_name_to_idx = node_name_to_idx
128
+ self.idx_to_node_name = {v: k for k, v in node_name_to_idx.items()}
129
+ self.node_embeddings = node_embeddings or {}
130
+ self.query_embedding = query_embedding
131
+ self.alpha = alpha
132
+ self.epsilon = epsilon
133
+ self.max_iterations = max_iterations
134
+ self.step_size = step_size
135
+ self.weight_scheme = weight_scheme
136
+ self.hybrid_a = hybrid_a
137
+ self.hybrid_b = hybrid_b
138
+ self.use_node_degree = use_node_degree
139
+ self.sim_mode = sim_mode
140
+ self.qa_sink_gamma = qa_sink_gamma
141
+ self.qa_warm_delta = qa_warm_delta
142
+ self.qa_warm_walk = qa_warm_walk
143
+ self.qa_accum_gamma = qa_accum_gamma
144
+
145
+ n = len(node_name_to_idx)
146
+
147
+ # Precompute per-node query similarity (used by sink/warm QA)
148
+ self._node_query_sim = np.zeros(n)
149
+ if (qa_sink_gamma > 0 or qa_warm_delta > 0) and query_embedding is not None:
150
+ for i in range(n):
151
+ name = self.idx_to_node_name.get(i)
152
+ if name:
153
+ emb = self.node_embeddings.get(name)
154
+ if emb is not None:
155
+ self._node_query_sim[i] = _cosine_similarity(emb, query_embedding, mode=sim_mode)
156
+
157
+ # Normalise source weights (threshold small values, then normalise)
158
+ sw = np.copy(source_weights).astype(np.float64)
159
+ sw[sw < threshold] = 0.0
160
+ sw_sum = np.sum(sw)
161
+ if sw_sum > 0:
162
+ sw /= sw_sum
163
+ else:
164
+ sw = np.ones(n) / n
165
+ self.source_weights = sw
166
+
167
+ # State arrays
168
+ self.mass = np.zeros(n)
169
+ self.sink_capacity = np.zeros(n)
170
+ self.x = np.zeros(n)
171
+
172
+ # Edge weight cache
173
+ self._edge_weight_cache: Dict[Tuple[int, int], float] = {}
174
+
175
+ random.seed(random_seed)
176
+
177
+ # Warm-start x: multi-step lazy random walk from seed distribution
178
+ if qa_warm_delta > 0:
179
+ x = self.source_weights * (1.0 + qa_warm_delta * self._node_query_sim)
180
+ x_sum = np.sum(x)
181
+ if x_sum > 0:
182
+ x /= x_sum
183
+ else:
184
+ x = self.source_weights.copy()
185
+
186
+ for _ in range(qa_warm_steps):
187
+ x_new = np.zeros(n)
188
+ for i in range(n):
189
+ if x[i] > 0:
190
+ neighbors = self.graph.neighbors(i)
191
+ if not neighbors:
192
+ continue
193
+ if qa_warm_walk and query_embedding is not None:
194
+ # Query-aware walk: spread proportional to edge weights
195
+ weights = []
196
+ for j in neighbors:
197
+ w = self._get_edge_weight(i, j)
198
+ weights.append(w)
199
+ total_w = sum(weights)
200
+ if total_w > 0:
201
+ for j, w in zip(neighbors, weights):
202
+ x_new[j] += x[i] * w / total_w
203
+ else:
204
+ spread = x[i] / len(neighbors)
205
+ for j in neighbors:
206
+ x_new[j] += spread
207
+ else:
208
+ # Original: uniform spread
209
+ spread = x[i] / len(neighbors)
210
+ for j in neighbors:
211
+ x_new[j] += spread
212
+ x = (self.source_weights + x_new) / 2.0
213
+ self.x = x
214
+
215
+ # ------------------------------------------------------------------
216
+ def _get_edge_weight(self, i: int, j: int) -> float:
217
+ """Get (cached) query-aware edge weight between node indices i and j."""
218
+ key = (i, j)
219
+ if key in self._edge_weight_cache:
220
+ return self._edge_weight_cache[key]
221
+
222
+ try:
223
+ eid = self.graph.get_eid(i, j)
224
+ attrs = self.graph.es[eid].attributes()
225
+ w = attrs.get("weight", 1.0)
226
+ except Exception:
227
+ self._edge_weight_cache[key] = 0.0
228
+ return 0.0
229
+
230
+ if w <= 0:
231
+ self._edge_weight_cache[key] = 0.0
232
+ return 0.0
233
+
234
+ # Query-aware modulation
235
+ if self.weight_scheme == "none" or not self.node_embeddings or self.query_embedding is None:
236
+ self._edge_weight_cache[key] = w
237
+ return w
238
+
239
+ n1 = self.idx_to_node_name.get(i)
240
+ n2 = self.idx_to_node_name.get(j)
241
+ if n1 is None or n2 is None:
242
+ self._edge_weight_cache[key] = w
243
+ return w
244
+
245
+ e1 = self.node_embeddings.get(n1)
246
+ e2 = self.node_embeddings.get(n2)
247
+ if e1 is None and e2 is None:
248
+ self._edge_weight_cache[key] = w
249
+ return w
250
+
251
+ zero = np.zeros_like(self.query_embedding)
252
+ s1 = _cosine_similarity(e1 if e1 is not None else zero, self.query_embedding, mode=self.sim_mode)
253
+ s2 = _cosine_similarity(e2 if e2 is not None else zero, self.query_embedding, mode=self.sim_mode)
254
+
255
+ if self.weight_scheme == "multiply":
256
+ # Product (Eq. 5b): w * sim(u,q) * sim(v,q)
257
+ qw = w * s1 * s2
258
+ elif self.weight_scheme == "add":
259
+ # Mean (Eq. 5a): (w + sim(u,q) + sim(v,q)) / 3
260
+ qw = (w + s1 + s2) / 3.0
261
+ else: # "original" = Hybrid (Eq. 5c)
262
+ # w * (a + b * avg_query_sim)
263
+ qf = (s1 + s2) / 2.0
264
+ qw = w * (self.hybrid_a + self.hybrid_b * qf)
265
+
266
+ self._edge_weight_cache[key] = qw
267
+ return qw
268
+
269
+ # ------------------------------------------------------------------
270
+ def _initialize(self):
271
+ """Set sink capacities and inject mass at seeds."""
272
+ n = len(self.source_weights)
273
+
274
+ if self.use_node_degree:
275
+ for i in range(n):
276
+ self.sink_capacity[i] = max(self.graph.degree(i), 1.0)
277
+ else:
278
+ self.sink_capacity[:] = 1.0
279
+
280
+ total_sink = np.sum(self.sink_capacity)
281
+ self.sink_capacity = 10.0 * self.sink_capacity / total_sink
282
+
283
+ # Phase 1: query-aware sink capacity — relevant nodes absorb more
284
+ if self.qa_sink_gamma > 0:
285
+ self.sink_capacity *= (1.0 + self.qa_sink_gamma * self._node_query_sim)
286
+
287
+ total_sink = np.sum(self.sink_capacity)
288
+
289
+ # Inject mass at seeds
290
+ self.mass[:] = 0.0
291
+ for i in range(n):
292
+ if self.source_weights[i] > 0:
293
+ self.mass[i] = self.alpha * total_sink * self.source_weights[i]
294
+
295
+ # ------------------------------------------------------------------
296
+ def _get_structural_weight(self, i: int, j: int) -> float:
297
+ """Get original (non-query-aware) edge weight."""
298
+ try:
299
+ eid = self.graph.get_eid(i, j)
300
+ return self.graph.es[eid].attributes().get("weight", 1.0)
301
+ except Exception:
302
+ return 0.0
303
+
304
+ def _push(self, node_idx: int) -> bool:
305
+ """Push excess mass from node to neighbours.
306
+
307
+ Decoupled accumulation/routing: x accumulates by structural degree
308
+ (independent of query), mass routes by query-aware edge weights.
309
+ This ensures query-aware modulation steers flow without penalising
310
+ the accumulation rate at query-relevant nodes.
311
+ """
312
+ neighbors = self.graph.neighbors(node_idx)
313
+ if not neighbors:
314
+ return False
315
+
316
+ # Query-aware weights (for routing)
317
+ w_qa = 0.0
318
+ for j in neighbors:
319
+ w_qa += self._get_edge_weight(node_idx, j)
320
+
321
+ if w_qa == 0:
322
+ return False
323
+
324
+ excess = self.mass[node_idx] - self.sink_capacity[node_idx]
325
+ if excess <= 0:
326
+ return False
327
+
328
+ # Structural weights (for accumulation) — decoupled from QA
329
+ w_struct = 0.0
330
+ for j in neighbors:
331
+ w_struct += self._get_structural_weight(node_idx, j)
332
+ if w_struct == 0:
333
+ w_struct = w_qa # fallback
334
+
335
+ # Accumulate importance based on STRUCTURAL degree (not QA)
336
+ accum = self.step_size * excess / (w_struct + 1e-8)
337
+ if self.qa_accum_gamma > 0:
338
+ accum *= (1.0 + self.qa_accum_gamma * self._node_query_sim[node_idx])
339
+ self.x[node_idx] += accum
340
+ self.mass[node_idx] = self.sink_capacity[node_idx]
341
+
342
+ # Route mass using QUERY-AWARE weights
343
+ for j in neighbors:
344
+ w_ij = self._get_edge_weight(node_idx, j)
345
+ if w_ij > 0:
346
+ self.mass[j] += excess * w_ij / (w_qa + 1e-8)
347
+
348
+ return True
349
+
350
+ # ------------------------------------------------------------------
351
+ def run(self, batch_push: bool = False) -> np.ndarray:
352
+ """Run push-relabel flow diffusion. Returns per-node scores (np.ndarray).
353
+
354
+ batch_push: If True, process ALL excess nodes per iteration (parallel
355
+ push-relabel). This makes edge weights effective because each iteration
356
+ touches all excess nodes' edges, not just one random node's.
357
+ """
358
+ self._initialize()
359
+
360
+ iterations = 0
361
+ pushes = 0
362
+
363
+ while iterations < self.max_iterations:
364
+ iterations += 1
365
+
366
+ # Find nodes with excess mass (vectorised)
367
+ excess_mask = self.mass > (self.sink_capacity + self.epsilon)
368
+ excess_indices = np.nonzero(excess_mask)[0]
369
+
370
+ if len(excess_indices) == 0:
371
+ logger.info(f"QAFD converged in {iterations} iters ({pushes} pushes)")
372
+ break
373
+
374
+ if batch_push:
375
+ # Batch push: process ALL excess nodes in this iteration
376
+ for node_idx in excess_indices:
377
+ if self._push(int(node_idx)):
378
+ pushes += 1
379
+ else:
380
+ # Single push: process one random excess node (original)
381
+ node_idx = int(random.choice(excess_indices))
382
+ if self._push(node_idx):
383
+ pushes += 1
384
+
385
+ if iterations % 10 == 0:
386
+ remaining = np.sum(np.maximum(0, self.mass - self.sink_capacity))
387
+ if remaining < self.epsilon:
388
+ logger.info(f"QAFD converged in {iterations} iters ({pushes} pushes)")
389
+ break
390
+
391
+ if iterations >= self.max_iterations:
392
+ logger.warning(f"QAFD did not converge after {self.max_iterations} iterations")
393
+
394
+ logger.info(f"QAFD: {iterations} iters, {pushes} pushes, batch={batch_push}")
395
+ return self.x
396
+
397
+
398
+ # ===========================================================================
399
+ # Convenience wrapper matching the interface used by retriever.py
400
+ # ===========================================================================
401
+
402
+ def run_igraph_qafd(
403
+ graph,
404
+ node_name_to_idx: Dict[str, int],
405
+ passage_node_idxs: List[int],
406
+ source_weights: np.ndarray,
407
+ node_embeddings: Dict[str, np.ndarray],
408
+ query_embedding: Optional[np.ndarray],
409
+ alpha: float = 10.0,
410
+ epsilon: float = 1e-6,
411
+ max_iterations: int = 10000,
412
+ step_size: float = 0.2,
413
+ weight_scheme: str = "original",
414
+ hybrid_a: float = 1.0,
415
+ hybrid_b: float = 0.5,
416
+ use_node_degree: bool = True,
417
+ random_seed: int = 42,
418
+ sim_mode: str = "normalized",
419
+ qa_sink_gamma: float = 0.0,
420
+ qa_warm_delta: float = 0.0,
421
+ qa_warm_walk: bool = False,
422
+ qa_warm_steps: int = 2,
423
+ qa_accum_gamma: float = 0.0,
424
+ qa_post_lambda: float = 0.0,
425
+ batch_push: bool = False,
426
+ ) -> Tuple[np.ndarray, np.ndarray]:
427
+ """Run QAFD on igraph and return (sorted_doc_ids, sorted_doc_scores).
428
+
429
+ sim_mode: Similarity contrast function ("normalized", "relu", "relu_sq")
430
+ Query-aware enhancement flags (all default 0.0 = original behaviour):
431
+ qa_sink_gamma: Scale sink capacity by (1 + gamma * sim(node, query))
432
+ qa_warm_delta: Bias warm-start x toward query-relevant seeds
433
+ qa_post_lambda: Rerank output by (1 + lambda * sim(passage, query))
434
+ """
435
+ qafd = IGraphQAFD(
436
+ graph=graph,
437
+ node_name_to_idx=node_name_to_idx,
438
+ source_weights=source_weights,
439
+ node_embeddings=node_embeddings,
440
+ query_embedding=query_embedding,
441
+ alpha=alpha,
442
+ epsilon=epsilon,
443
+ max_iterations=max_iterations,
444
+ step_size=step_size,
445
+ weight_scheme=weight_scheme,
446
+ hybrid_a=hybrid_a,
447
+ hybrid_b=hybrid_b,
448
+ use_node_degree=use_node_degree,
449
+ random_seed=random_seed,
450
+ sim_mode=sim_mode,
451
+ qa_sink_gamma=qa_sink_gamma,
452
+ qa_warm_delta=qa_warm_delta,
453
+ qa_warm_walk=qa_warm_walk,
454
+ qa_warm_steps=qa_warm_steps,
455
+ qa_accum_gamma=qa_accum_gamma,
456
+ )
457
+
458
+ node_scores = qafd.run(batch_push=batch_push)
459
+
460
+ # Extract passage scores
461
+ doc_scores = np.array([node_scores[idx] for idx in passage_node_idxs])
462
+
463
+ # Phase 3: post-diffusion query-aware reranking
464
+ if qa_post_lambda > 0 and query_embedding is not None and node_embeddings:
465
+ idx_to_name = qafd.idx_to_node_name
466
+ for pi, pidx in enumerate(passage_node_idxs):
467
+ name = idx_to_name.get(pidx)
468
+ if name:
469
+ emb = node_embeddings.get(name)
470
+ if emb is not None:
471
+ sim = _cosine_similarity(emb, query_embedding, mode=sim_mode)
472
+ doc_scores[pi] *= (1.0 + qa_post_lambda * sim)
473
+
474
+ total = np.sum(doc_scores)
475
+ if total > 0:
476
+ doc_scores = doc_scores / total
477
+ else:
478
+ doc_scores = np.ones(len(doc_scores)) / max(len(doc_scores), 1)
479
+
480
+ sorted_ids = np.argsort(doc_scores)[::-1]
481
+ sorted_scores = doc_scores[sorted_ids]
482
+
483
+ return sorted_ids, sorted_scores
484
+
485
+
486
+ # ===========================================================================
487
+ # Fast PPR via igraph (matches the original actual benchmark method)
488
+ # ===========================================================================
489
+
490
+ def run_ppr(
491
+ graph,
492
+ node_name_to_idx: Dict[str, int],
493
+ passage_node_idxs: List[int],
494
+ reset_prob: np.ndarray,
495
+ damping: float = 0.5,
496
+ ) -> Tuple[np.ndarray, np.ndarray]:
497
+ """Run Personalized PageRank on igraph and return (sorted_doc_ids, sorted_doc_scores).
498
+
499
+ This matches the original ``run_ppr()`` with ``use_qafd=False``.
500
+ Uses igraph's C-based prpack implementation — converges instantly.
501
+ """
502
+ reset_prob = np.where(np.isnan(reset_prob) | (reset_prob < 0), 0, reset_prob)
503
+
504
+ pagerank_scores = graph.personalized_pagerank(
505
+ vertices=range(len(node_name_to_idx)),
506
+ damping=damping,
507
+ directed=False,
508
+ weights="weight",
509
+ reset=reset_prob,
510
+ implementation="prpack",
511
+ )
512
+
513
+ doc_scores = np.array([pagerank_scores[idx] for idx in passage_node_idxs])
514
+
515
+ sorted_ids = np.argsort(doc_scores)[::-1]
516
+ sorted_scores = doc_scores[sorted_ids]
517
+
518
+ return sorted_ids, sorted_scores
src/passage_entity/kg_builder.py ADDED
@@ -0,0 +1,438 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Knowledge graph builder following the original ``index()`` method.
3
+
4
+ Steps:
5
+ 1. Insert docs into chunk embedding store.
6
+ 2. Run OpenIE (NER + triple extraction).
7
+ 3. Build igraph with entity nodes, passage nodes, fact edges,
8
+ passage-to-entity edges, and synonymy edges.
9
+ 4. Save to ``graph.pickle``.
10
+ """
11
+
12
+ import json
13
+ import logging
14
+ import os
15
+ import re
16
+ from collections import defaultdict
17
+ from typing import Dict, List, Set, Tuple
18
+
19
+ import igraph as ig
20
+ import numpy as np
21
+ import torch
22
+ from tqdm import tqdm
23
+
24
+ from .config import PassageEntityConfig
25
+ from .embedding_store import EmbeddingStore, EmbeddingModelWrapper
26
+ from .openie import OpenIE
27
+ from .utils import (
28
+ NerRawOutput,
29
+ TripleRawOutput,
30
+ compute_mdhash_id,
31
+ text_processing,
32
+ extract_entity_nodes,
33
+ flatten_facts,
34
+ reformat_openie_results,
35
+ filter_invalid_triples,
36
+ )
37
+
38
+ logger = logging.getLogger(__name__)
39
+
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # KNN helper (simplified from the original embed_utils.py)
43
+ # ---------------------------------------------------------------------------
44
+
45
+ def retrieve_knn(
46
+ query_ids: List[str],
47
+ key_ids: List[str],
48
+ query_vecs: np.ndarray,
49
+ key_vecs: np.ndarray,
50
+ k: int = 2047,
51
+ query_batch_size: int = 1000,
52
+ key_batch_size: int = 10000,
53
+ ) -> Dict:
54
+ """Batched top-k cosine nearest-neighbour search using PyTorch."""
55
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
56
+ if len(key_vecs) == 0:
57
+ return {}
58
+
59
+ q = torch.tensor(np.array(query_vecs), dtype=torch.float32)
60
+ q = torch.nn.functional.normalize(q, dim=1)
61
+ keys = torch.tensor(np.array(key_vecs), dtype=torch.float32)
62
+ keys = torch.nn.functional.normalize(keys, dim=1)
63
+
64
+ results = {}
65
+
66
+ def _batches(vecs, bs):
67
+ for i in range(0, len(vecs), bs):
68
+ yield vecs[i : i + bs], i
69
+
70
+ for qb, qstart in tqdm(
71
+ _batches(q, query_batch_size),
72
+ total=(len(q) + query_batch_size - 1) // query_batch_size,
73
+ desc="KNN",
74
+ ):
75
+ qb = qb.to(device)
76
+ batch_scores, batch_indices = [], []
77
+ offset = 0
78
+ for kb, _ in _batches(keys, key_batch_size):
79
+ kb = kb.to(device)
80
+ actual_kb_size = kb.size(0)
81
+ sim = torch.mm(qb, kb.T)
82
+ topk_s, topk_i = torch.topk(sim, min(k, actual_kb_size), dim=1, largest=True, sorted=True)
83
+ topk_i += offset
84
+ batch_scores.append(topk_s)
85
+ batch_indices.append(topk_i)
86
+ del sim
87
+ kb = kb.cpu()
88
+ torch.cuda.empty_cache()
89
+ offset += actual_kb_size
90
+
91
+ batch_scores = torch.cat(batch_scores, dim=1)
92
+ batch_indices = torch.cat(batch_indices, dim=1)
93
+ final_s, final_i = torch.topk(
94
+ batch_scores, min(k, batch_scores.size(1)), dim=1, largest=True, sorted=True
95
+ )
96
+ final_i = final_i.cpu()
97
+ final_s = final_s.cpu()
98
+ for i in range(final_i.size(0)):
99
+ qi = qstart + i
100
+ topk_rel = batch_indices[i][final_i[i]].cpu()
101
+ topk_keys = [key_ids[idx] for idx in topk_rel.numpy()]
102
+ results[query_ids[qi]] = (topk_keys, final_s[i].cpu().numpy().tolist())
103
+ qb = qb.cpu()
104
+ torch.cuda.empty_cache()
105
+
106
+ return results
107
+
108
+
109
+ # ===========================================================================
110
+ # KGBuilder
111
+ # ===========================================================================
112
+
113
+ class KGBuilder:
114
+ """Build a passage-entity knowledge graph and persist it to disk."""
115
+
116
+ def __init__(
117
+ self,
118
+ config: PassageEntityConfig,
119
+ embedding_model: EmbeddingModelWrapper,
120
+ openie: OpenIE,
121
+ ):
122
+ self.config = config
123
+ self.embedding_model = embedding_model
124
+ self.openie = openie
125
+
126
+ wd = config.working_dir
127
+ os.makedirs(wd, exist_ok=True)
128
+
129
+ self.chunk_embedding_store = EmbeddingStore(
130
+ embedding_model, os.path.join(wd, "chunk_embeddings"),
131
+ config.embedding_batch_size, "chunk",
132
+ )
133
+ self.entity_embedding_store = EmbeddingStore(
134
+ embedding_model, os.path.join(wd, "entity_embeddings"),
135
+ config.embedding_batch_size, "entity",
136
+ )
137
+ self.fact_embedding_store = EmbeddingStore(
138
+ embedding_model, os.path.join(wd, "fact_embeddings"),
139
+ config.embedding_batch_size, "fact",
140
+ )
141
+
142
+ self._graph_pickle_path = os.path.join(wd, "graph.pickle")
143
+ self.openie_results_path = os.path.join(
144
+ config.save_dir,
145
+ f"openie_results_ner_{config.llm_model.replace('/', '_')}.json",
146
+ )
147
+
148
+ self.graph: ig.Graph = self._load_or_create_graph()
149
+ self.node_to_node_stats: Dict[Tuple[str, str], float] = {}
150
+ self.ent_node_to_chunk_ids: Dict[str, set] = {}
151
+
152
+ # ------------------------------------------------------------------
153
+ # Graph init
154
+ # ------------------------------------------------------------------
155
+
156
+ def _load_or_create_graph(self) -> ig.Graph:
157
+ if (
158
+ not self.config.force_index_from_scratch
159
+ and os.path.exists(self._graph_pickle_path)
160
+ ):
161
+ g = ig.Graph.Read_Pickle(self._graph_pickle_path)
162
+ logger.info(
163
+ f"Loaded graph from {self._graph_pickle_path}: "
164
+ f"{g.vcount()} nodes, {g.ecount()} edges"
165
+ )
166
+ return g
167
+ return ig.Graph(directed=self.config.is_directed_graph)
168
+
169
+ # ------------------------------------------------------------------
170
+ # index()
171
+ # ------------------------------------------------------------------
172
+
173
+ def index(self, docs: List[str]):
174
+ """Index documents: embed chunks, run OpenIE, build KG, save."""
175
+ logger.info("=== Indexing documents ===")
176
+
177
+ # 1) Insert chunks into embedding store
178
+ self.chunk_embedding_store.insert_strings(docs)
179
+ chunk_to_rows = self.chunk_embedding_store.get_all_id_to_rows()
180
+
181
+ # 2) Run OpenIE (or load cached)
182
+ all_openie_info, chunk_keys_to_process = self._load_existing_openie(
183
+ chunk_to_rows.keys()
184
+ )
185
+ new_openie_rows = {k: chunk_to_rows[k] for k in chunk_keys_to_process}
186
+
187
+ if len(chunk_keys_to_process) > 0:
188
+ logger.info(f"Running OpenIE on {len(chunk_keys_to_process)} new chunks")
189
+ ner_dict, triple_dict = self.openie.batch_openie(new_openie_rows)
190
+ self._merge_openie_results(
191
+ all_openie_info, new_openie_rows, ner_dict, triple_dict
192
+ )
193
+
194
+ if self.config.save_openie:
195
+ self._save_openie_results(all_openie_info)
196
+
197
+ ner_results, triple_results = reformat_openie_results(all_openie_info)
198
+
199
+ # Sanity check — fill missing entries
200
+ for cid in chunk_to_rows:
201
+ if cid not in ner_results:
202
+ ner_results[cid] = NerRawOutput(cid, None, [], {})
203
+ if cid not in triple_results:
204
+ triple_results[cid] = TripleRawOutput(cid, None, [], {})
205
+
206
+ chunk_ids = list(chunk_to_rows.keys())
207
+ chunk_triples = [
208
+ [text_processing(t) for t in triple_results[cid].triples]
209
+ for cid in chunk_ids
210
+ ]
211
+ entity_nodes, chunk_triple_entities = extract_entity_nodes(chunk_triples)
212
+ facts = flatten_facts(chunk_triples)
213
+
214
+ # 3) Encode entities + facts
215
+ logger.info("Encoding entities")
216
+ self.entity_embedding_store.insert_strings(entity_nodes)
217
+ logger.info("Encoding facts")
218
+ self.fact_embedding_store.insert_strings([str(f) for f in facts])
219
+
220
+ # 4) Build graph edges
221
+ logger.info("Building graph edges")
222
+ self.node_to_node_stats = {}
223
+ self.ent_node_to_chunk_ids = {}
224
+
225
+ self._add_fact_edges(chunk_ids, chunk_triples)
226
+ num_new = self._add_passage_edges(chunk_ids, chunk_triple_entities)
227
+
228
+ if num_new > 0:
229
+ logger.info(f"{num_new} new chunks → adding synonymy edges")
230
+ self._add_synonymy_edges()
231
+ self._augment_graph()
232
+ self._save_graph()
233
+
234
+ logger.info("=== Indexing complete ===")
235
+
236
+ # ------------------------------------------------------------------
237
+ # Edge builders
238
+ # ------------------------------------------------------------------
239
+
240
+ def _add_fact_edges(self, chunk_ids: List[str], chunk_triples: List[list]):
241
+ current_nodes = set(self.graph.vs["name"]) if "name" in self.graph.vs.attribute_names() else set()
242
+
243
+ for chunk_key, triples in tqdm(
244
+ zip(chunk_ids, chunk_triples), desc="Fact edges", total=len(chunk_ids)
245
+ ):
246
+ entities_in_chunk: set = set()
247
+ if chunk_key not in current_nodes:
248
+ for triple in triples:
249
+ triple = tuple(triple)
250
+ nk1 = compute_mdhash_id(triple[0], prefix="entity-")
251
+ nk2 = compute_mdhash_id(triple[2], prefix="entity-")
252
+ self.node_to_node_stats[(nk1, nk2)] = (
253
+ self.node_to_node_stats.get((nk1, nk2), 0.0) + 1
254
+ )
255
+ self.node_to_node_stats[(nk2, nk1)] = (
256
+ self.node_to_node_stats.get((nk2, nk1), 0.0) + 1
257
+ )
258
+ entities_in_chunk.update([nk1, nk2])
259
+
260
+ for node in entities_in_chunk:
261
+ self.ent_node_to_chunk_ids[node] = (
262
+ self.ent_node_to_chunk_ids.get(node, set()) | {chunk_key}
263
+ )
264
+
265
+ def _add_passage_edges(
266
+ self, chunk_ids: List[str], chunk_triple_entities: List[List[str]]
267
+ ) -> int:
268
+ current_nodes = set(self.graph.vs["name"]) if "name" in self.graph.vs.attribute_names() else set()
269
+ num_new = 0
270
+ for idx, chunk_key in tqdm(
271
+ enumerate(chunk_ids), desc="Passage edges", total=len(chunk_ids)
272
+ ):
273
+ if chunk_key not in current_nodes:
274
+ for ent in chunk_triple_entities[idx]:
275
+ nk = compute_mdhash_id(ent, prefix="entity-")
276
+ self.node_to_node_stats[(chunk_key, nk)] = 1.0
277
+ num_new += 1
278
+ return num_new
279
+
280
+ def _add_synonymy_edges(self):
281
+ logger.info("Expanding graph with synonymy edges")
282
+ entity_id_to_row = self.entity_embedding_store.get_all_id_to_rows()
283
+ entity_node_keys = list(entity_id_to_row.keys())
284
+ entity_embs = self.entity_embedding_store.get_embeddings(entity_node_keys)
285
+
286
+ knn = retrieve_knn(
287
+ query_ids=entity_node_keys,
288
+ key_ids=entity_node_keys,
289
+ query_vecs=entity_embs,
290
+ key_vecs=entity_embs,
291
+ k=self.config.synonymy_edge_topk,
292
+ query_batch_size=self.config.synonymy_edge_query_batch_size,
293
+ key_batch_size=self.config.synonymy_edge_key_batch_size,
294
+ )
295
+
296
+ for nk in tqdm(knn, desc="Synonymy edges"):
297
+ entity = entity_id_to_row[nk]["content"]
298
+ if len(re.sub('[^A-Za-z0-9]', '', entity)) <= 2:
299
+ continue
300
+ nns_keys, nns_scores = knn[nk]
301
+ num_nns = 0
302
+ for nn, score in zip(nns_keys, nns_scores):
303
+ if score < self.config.synonymy_edge_sim_threshold or num_nns > 100:
304
+ break
305
+ nn_phrase = entity_id_to_row.get(nn, {}).get("content", "")
306
+ if nn != nk and nn_phrase:
307
+ self.node_to_node_stats[(nk, nn)] = score
308
+ num_nns += 1
309
+
310
+ # ------------------------------------------------------------------
311
+ # Graph augmentation
312
+ # ------------------------------------------------------------------
313
+
314
+ def _augment_graph(self):
315
+ self._add_new_nodes()
316
+ self._add_new_edges()
317
+ info = self._get_graph_info()
318
+ logger.info(f"Graph info: {info}")
319
+
320
+ def _add_new_nodes(self):
321
+ existing = {v["name"]: v for v in self.graph.vs if "name" in v.attributes()}
322
+
323
+ entity_rows = self.entity_embedding_store.get_all_id_to_rows()
324
+ passage_rows = self.chunk_embedding_store.get_all_id_to_rows()
325
+ all_rows = {**entity_rows, **passage_rows}
326
+
327
+ new_nodes: Dict[str, list] = {}
328
+ for nid, node in all_rows.items():
329
+ node["name"] = nid
330
+ if nid not in existing:
331
+ for k, v in node.items():
332
+ new_nodes.setdefault(k, []).append(v)
333
+
334
+ if new_nodes:
335
+ self.graph.add_vertices(
336
+ n=len(next(iter(new_nodes.values()))), attributes=new_nodes
337
+ )
338
+
339
+ def _add_new_edges(self):
340
+ edge_src, edge_tgt, weights = [], [], []
341
+ for (s, t), w in self.node_to_node_stats.items():
342
+ if s == t:
343
+ continue
344
+ edge_src.append(s)
345
+ edge_tgt.append(t)
346
+ weights.append(w)
347
+
348
+ current_ids = set(self.graph.vs["name"])
349
+ valid_edges, valid_w = [], []
350
+ for s, t, w in zip(edge_src, edge_tgt, weights):
351
+ if s in current_ids and t in current_ids:
352
+ valid_edges.append((s, t))
353
+ valid_w.append(w)
354
+ else:
355
+ logger.warning(f"Skipping invalid edge {s} -> {t}")
356
+
357
+ self.graph.add_edges(valid_edges, attributes={"weight": valid_w})
358
+
359
+ def _save_graph(self):
360
+ logger.info(
361
+ f"Writing graph: {self.graph.vcount()} nodes, {self.graph.ecount()} edges"
362
+ )
363
+ self.graph.write_pickle(self._graph_pickle_path)
364
+
365
+ def _get_graph_info(self) -> Dict:
366
+ ent_keys = set(self.entity_embedding_store.get_all_ids())
367
+ pass_keys = set(self.chunk_embedding_store.get_all_ids())
368
+ return {
369
+ "num_entity_nodes": len(ent_keys),
370
+ "num_passage_nodes": len(pass_keys),
371
+ "num_total_nodes": len(ent_keys) + len(pass_keys),
372
+ "num_facts": len(self.fact_embedding_store.get_all_ids()),
373
+ "num_edges": len(self.node_to_node_stats),
374
+ }
375
+
376
+ # ------------------------------------------------------------------
377
+ # OpenIE persistence
378
+ # ------------------------------------------------------------------
379
+
380
+ def _load_existing_openie(self, chunk_keys) -> Tuple[list, set]:
381
+ chunk_keys_to_save: set = set()
382
+ if (
383
+ not self.config.force_openie_from_scratch
384
+ and os.path.isfile(self.openie_results_path)
385
+ ):
386
+ data = json.load(open(self.openie_results_path))
387
+ all_info = data.get("docs", [])
388
+ # Standardise indices
389
+ for item in all_info:
390
+ item["idx"] = compute_mdhash_id(item["passage"], "chunk-")
391
+ existing_keys = {info["idx"] for info in all_info}
392
+ for ck in chunk_keys:
393
+ if ck not in existing_keys:
394
+ chunk_keys_to_save.add(ck)
395
+ else:
396
+ all_info = []
397
+ chunk_keys_to_save = set(chunk_keys)
398
+ return all_info, chunk_keys_to_save
399
+
400
+ def _merge_openie_results(self, all_info, chunks, ner_dict, triple_dict):
401
+ for ck, row in chunks.items():
402
+ passage = row["content"]
403
+ try:
404
+ info = {
405
+ "idx": ck,
406
+ "passage": passage,
407
+ "extracted_entities": ner_dict[ck].unique_entities,
408
+ "extracted_triples": triple_dict[ck].triples,
409
+ }
410
+ except Exception as e:
411
+ logger.error(f"Error merging chunk {ck}: {e}")
412
+ info = {
413
+ "idx": ck,
414
+ "passage": passage,
415
+ "extracted_entities": [],
416
+ "extracted_triples": [],
417
+ }
418
+ all_info.append(info)
419
+
420
+ def _save_openie_results(self, all_info: list):
421
+ num_phrases = sum(len(c["extracted_entities"]) for c in all_info)
422
+ if num_phrases > 0:
423
+ avg_chars = round(
424
+ sum(len(e) for c in all_info for e in c["extracted_entities"]) / num_phrases, 4
425
+ )
426
+ avg_words = round(
427
+ sum(len(e.split()) for c in all_info for e in c["extracted_entities"]) / num_phrases, 4
428
+ )
429
+ else:
430
+ avg_chars, avg_words = 0, 0
431
+
432
+ os.makedirs(os.path.dirname(self.openie_results_path), exist_ok=True)
433
+ with open(self.openie_results_path, "w") as f:
434
+ json.dump(
435
+ {"docs": all_info, "avg_ent_chars": avg_chars, "avg_ent_words": avg_words},
436
+ f,
437
+ )
438
+ logger.info(f"OpenIE results saved to {self.openie_results_path}")
src/passage_entity/openie.py ADDED
@@ -0,0 +1,231 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ OpenIE extraction (NER + triple extraction) using QAFD-RAG's LLM functions.
3
+
4
+ Follows the original openie_openai.py logic but calls the async LLM wrappers
5
+ from ``QAFD-RAG/src/llm.py`` synchronously via ``asyncio.run``.
6
+ """
7
+
8
+ import asyncio
9
+ import json
10
+ import logging
11
+ import re
12
+ from concurrent.futures import ThreadPoolExecutor, as_completed
13
+ from dataclasses import dataclass
14
+ from typing import Dict, Any, List, Tuple, TypedDict, Callable
15
+
16
+ from tqdm import tqdm
17
+
18
+ from .prompts import make_ner_messages, make_triple_messages
19
+ from .utils import (
20
+ NerRawOutput,
21
+ TripleRawOutput,
22
+ fix_broken_generated_json,
23
+ filter_invalid_triples,
24
+ )
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ class ChunkInfo(TypedDict):
30
+ num_tokens: int
31
+ content: str
32
+
33
+
34
+ def _run_sync(coro):
35
+ """Run async coroutine from sync context."""
36
+ try:
37
+ loop = asyncio.get_running_loop()
38
+ except RuntimeError:
39
+ loop = None
40
+
41
+ if loop is not None and loop.is_running():
42
+ import concurrent.futures
43
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
44
+ return pool.submit(asyncio.run, coro).result()
45
+ else:
46
+ return asyncio.run(coro)
47
+
48
+
49
+ def _extract_ner_from_response(response_text: str) -> List[str]:
50
+ pattern = r'\{[^{}]*"named_entities"\s*:\s*\[[^\]]*\][^{}]*\}'
51
+ match = re.search(pattern, response_text, re.DOTALL)
52
+ if match is None:
53
+ return []
54
+ try:
55
+ return eval(match.group())["named_entities"]
56
+ except Exception:
57
+ return []
58
+
59
+
60
+ def _extract_triples_from_response(response_text: str) -> List[List[str]]:
61
+ pattern = r'\{[^{}]*"triples"\s*:\s*\[[^\]]*\][^{}]*\}'
62
+ match = re.search(pattern, response_text, re.DOTALL)
63
+ if match is None:
64
+ return []
65
+ try:
66
+ return eval(match.group())["triples"]
67
+ except Exception:
68
+ return []
69
+
70
+
71
+ class OpenIE:
72
+ """Synchronous OpenIE using QAFD-RAG's async LLM function.
73
+
74
+ Parameters
75
+ ----------
76
+ llm_func : callable
77
+ An async function with the signature::
78
+
79
+ async def llm_func(prompt, system_prompt=None,
80
+ history_messages=[], **kwargs) -> str
81
+
82
+ Typically one of the ``gpt_*_complete`` helpers from ``src/llm.py``.
83
+ """
84
+
85
+ def __init__(self, llm_func: Callable):
86
+ self.llm_func = llm_func
87
+
88
+ def _call_llm(self, messages: List[Dict[str, str]]) -> str:
89
+ """Convert chat messages to a single LLM call."""
90
+ system_prompt = None
91
+ history = []
92
+ user_prompt = ""
93
+ for msg in messages:
94
+ if msg["role"] == "system":
95
+ system_prompt = msg["content"]
96
+ elif msg["role"] == "assistant":
97
+ history.append(msg)
98
+ elif msg["role"] == "user":
99
+ # All user messages except the last go into history
100
+ if user_prompt:
101
+ history.append({"role": "user", "content": user_prompt})
102
+ user_prompt = msg["content"]
103
+
104
+ return _run_sync(
105
+ self.llm_func(
106
+ prompt=user_prompt,
107
+ system_prompt=system_prompt,
108
+ history_messages=history,
109
+ max_tokens=2048,
110
+ )
111
+ )
112
+
113
+ # ------------------------------------------------------------------
114
+ def ner(self, chunk_key: str, passage: str) -> NerRawOutput:
115
+ messages = make_ner_messages(passage)
116
+ raw_response = ""
117
+ metadata: Dict[str, Any] = {}
118
+ try:
119
+ raw_response = self._call_llm(messages)
120
+ real_response = fix_broken_generated_json(raw_response)
121
+ extracted = _extract_ner_from_response(real_response)
122
+ unique_entities = list(dict.fromkeys(extracted))
123
+ except Exception as e:
124
+ logger.warning(f"NER error for chunk {chunk_key}: {e}")
125
+ metadata["error"] = str(e)
126
+ return NerRawOutput(
127
+ chunk_id=chunk_key,
128
+ response=raw_response,
129
+ unique_entities=[],
130
+ metadata=metadata,
131
+ )
132
+
133
+ return NerRawOutput(
134
+ chunk_id=chunk_key,
135
+ response=raw_response,
136
+ unique_entities=unique_entities,
137
+ metadata=metadata,
138
+ )
139
+
140
+ # ------------------------------------------------------------------
141
+ def triple_extraction(
142
+ self, chunk_key: str, passage: str, named_entities: List[str]
143
+ ) -> TripleRawOutput:
144
+ messages = make_triple_messages(passage, named_entities)
145
+ raw_response = ""
146
+ metadata: Dict[str, Any] = {}
147
+ try:
148
+ raw_response = self._call_llm(messages)
149
+ real_response = fix_broken_generated_json(raw_response)
150
+ extracted = _extract_triples_from_response(real_response)
151
+ triplets = filter_invalid_triples(triples=extracted)
152
+ except Exception as e:
153
+ logger.warning(f"Triple extraction error for chunk {chunk_key}: {e}")
154
+ metadata["error"] = str(e)
155
+ return TripleRawOutput(
156
+ chunk_id=chunk_key,
157
+ response=raw_response,
158
+ metadata=metadata,
159
+ triples=[],
160
+ )
161
+
162
+ return TripleRawOutput(
163
+ chunk_id=chunk_key,
164
+ response=raw_response,
165
+ metadata=metadata,
166
+ triples=triplets,
167
+ )
168
+
169
+ # ------------------------------------------------------------------
170
+ def openie(self, chunk_key: str, passage: str) -> Dict[str, Any]:
171
+ ner_output = self.ner(chunk_key=chunk_key, passage=passage)
172
+ triple_output = self.triple_extraction(
173
+ chunk_key=chunk_key,
174
+ passage=passage,
175
+ named_entities=ner_output.unique_entities,
176
+ )
177
+ return {"ner": ner_output, "triplets": triple_output}
178
+
179
+ # ------------------------------------------------------------------
180
+ def batch_openie(
181
+ self, chunks: Dict[str, dict]
182
+ ) -> Tuple[Dict[str, NerRawOutput], Dict[str, TripleRawOutput]]:
183
+ """Run NER + triple extraction over all chunks using multithreading.
184
+
185
+ Parameters
186
+ ----------
187
+ chunks : dict
188
+ Mapping ``chunk_hash_id -> {"content": text, ...}``.
189
+
190
+ Returns
191
+ -------
192
+ (ner_dict, triple_dict)
193
+ """
194
+ chunk_passages = {k: v["content"] for k, v in chunks.items()}
195
+
196
+ # ---- NER pass ----
197
+ ner_results: List[NerRawOutput] = []
198
+
199
+ with ThreadPoolExecutor() as executor:
200
+ ner_futures = {
201
+ executor.submit(self.ner, ckey, passage): ckey
202
+ for ckey, passage in chunk_passages.items()
203
+ }
204
+ for future in tqdm(
205
+ as_completed(ner_futures), total=len(ner_futures), desc="NER"
206
+ ):
207
+ ner_results.append(future.result())
208
+
209
+ # ---- Triple extraction pass ----
210
+ triple_results: List[TripleRawOutput] = []
211
+
212
+ with ThreadPoolExecutor() as executor:
213
+ re_futures = {
214
+ executor.submit(
215
+ self.triple_extraction,
216
+ nr.chunk_id,
217
+ chunk_passages[nr.chunk_id],
218
+ nr.unique_entities,
219
+ ): nr.chunk_id
220
+ for nr in ner_results
221
+ }
222
+ for future in tqdm(
223
+ as_completed(re_futures),
224
+ total=len(re_futures),
225
+ desc="Triple extraction",
226
+ ):
227
+ triple_results.append(future.result())
228
+
229
+ ner_dict = {r.chunk_id: r for r in ner_results}
230
+ triple_dict = {r.chunk_id: r for r in triple_results}
231
+ return ner_dict, triple_dict
src/passage_entity/prompts.py ADDED
@@ -0,0 +1,288 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Inline prompt templates for NER, triple extraction, query NER,
3
+ fact reranking, and RAG QA — adapted from the original template files.
4
+ """
5
+
6
+ from string import Template
7
+ from typing import List, Dict
8
+
9
+ # ============================================================================
10
+ # NER (passage → named entities)
11
+ # ============================================================================
12
+
13
+ NER_SYSTEM = (
14
+ "Your task is to extract named entities from the given paragraph. "
15
+ "Respond with a JSON list of entities."
16
+ )
17
+
18
+ NER_ONE_SHOT_INPUT = (
19
+ "Radio City\n"
20
+ "Radio City is India's first private FM radio station and was started on 3 July 2001.\n"
21
+ "It plays Hindi, English and regional songs.\n"
22
+ "Radio City recently forayed into New Media in May 2008 with the launch of a music "
23
+ "portal - PlanetRadiocity.com that offers music related news, videos, songs, and "
24
+ "other music-related features."
25
+ )
26
+
27
+ NER_ONE_SHOT_OUTPUT = (
28
+ '{"named_entities":\n'
29
+ ' ["Radio City", "India", "3 July 2001", "Hindi", "English", '
30
+ '"May 2008", "PlanetRadiocity.com"]\n'
31
+ '}'
32
+ )
33
+
34
+ def make_ner_messages(passage: str) -> List[Dict[str, str]]:
35
+ return [
36
+ {"role": "system", "content": NER_SYSTEM},
37
+ {"role": "user", "content": NER_ONE_SHOT_INPUT},
38
+ {"role": "assistant", "content": NER_ONE_SHOT_OUTPUT},
39
+ {"role": "user", "content": passage},
40
+ ]
41
+
42
+ # ============================================================================
43
+ # Query NER (question → named entities)
44
+ # ============================================================================
45
+
46
+ QUERY_NER_SYSTEM = "You're a very effective entity extraction system."
47
+
48
+ QUERY_NER_ONE_SHOT_INPUT = (
49
+ "Please extract all named entities that are important for solving the questions below.\n"
50
+ "Place the named entities in json format.\n\n"
51
+ "Question: Which magazine was started first Arthur's Magazine or First for Women?\n"
52
+ )
53
+
54
+ QUERY_NER_ONE_SHOT_OUTPUT = (
55
+ '\n{"named_entities": ["First for Women", "Arthur\'s Magazine"]}\n'
56
+ )
57
+
58
+ def make_query_ner_messages(query: str) -> List[Dict[str, str]]:
59
+ return [
60
+ {"role": "system", "content": QUERY_NER_SYSTEM},
61
+ {"role": "user", "content": QUERY_NER_ONE_SHOT_INPUT},
62
+ {"role": "assistant", "content": QUERY_NER_ONE_SHOT_OUTPUT},
63
+ {"role": "user", "content": f"Question: {query}"},
64
+ ]
65
+
66
+ # ============================================================================
67
+ # Triple extraction (passage + entities → RDF triples)
68
+ # ============================================================================
69
+
70
+ TRIPLE_SYSTEM = (
71
+ "Your task is to construct an RDF (Resource Description Framework) graph from "
72
+ "the given passages and named entity lists. "
73
+ "Respond with a JSON list of triples, with each triple representing a relationship "
74
+ "in the RDF graph. \n\n"
75
+ "Pay attention to the following requirements:\n"
76
+ "- Each triple should contain at least one, but preferably two, of the named entities "
77
+ "in the list for each passage.\n"
78
+ "- Clearly resolve pronouns to their specific names to maintain clarity.\n"
79
+ )
80
+
81
+ _TRIPLE_FRAME = (
82
+ "Convert the paragraph into a JSON dict, it has a named entity list and a triple list.\n"
83
+ "Paragraph:\n```\n{passage}\n```\n\n{named_entity_json}\n"
84
+ )
85
+
86
+ TRIPLE_ONE_SHOT_INPUT = _TRIPLE_FRAME.format(
87
+ passage=NER_ONE_SHOT_INPUT,
88
+ named_entity_json=NER_ONE_SHOT_OUTPUT,
89
+ )
90
+
91
+ TRIPLE_ONE_SHOT_OUTPUT = (
92
+ '{"triples": [\n'
93
+ ' ["Radio City", "located in", "India"],\n'
94
+ ' ["Radio City", "is", "private FM radio station"],\n'
95
+ ' ["Radio City", "started on", "3 July 2001"],\n'
96
+ ' ["Radio City", "plays songs in", "Hindi"],\n'
97
+ ' ["Radio City", "plays songs in", "English"],\n'
98
+ ' ["Radio City", "forayed into", "New Media"],\n'
99
+ ' ["Radio City", "launched", "PlanetRadiocity.com"],\n'
100
+ ' ["PlanetRadiocity.com", "launched in", "May 2008"],\n'
101
+ ' ["PlanetRadiocity.com", "is", "music portal"],\n'
102
+ ' ["PlanetRadiocity.com", "offers", "news"],\n'
103
+ ' ["PlanetRadiocity.com", "offers", "videos"],\n'
104
+ ' ["PlanetRadiocity.com", "offers", "songs"]\n'
105
+ ' ]\n'
106
+ '}'
107
+ )
108
+
109
+
110
+ def make_triple_messages(passage: str, named_entities: List[str]) -> List[Dict[str, str]]:
111
+ import json as _json
112
+ named_entity_json = _json.dumps({"named_entities": named_entities})
113
+ user_content = _TRIPLE_FRAME.format(passage=passage, named_entity_json=named_entity_json)
114
+ return [
115
+ {"role": "system", "content": TRIPLE_SYSTEM},
116
+ {"role": "user", "content": TRIPLE_ONE_SHOT_INPUT},
117
+ {"role": "assistant", "content": TRIPLE_ONE_SHOT_OUTPUT},
118
+ {"role": "user", "content": user_content},
119
+ ]
120
+
121
+ # ============================================================================
122
+ # Fact reranker / filter (DSPy-style prompt from the original pipeline)
123
+ # ============================================================================
124
+
125
+ RERANKER_SYSTEM = (
126
+ "Your input fields are:\n"
127
+ "1. `question` (str): Query for retrieval\n"
128
+ "2. `fact_before_filter` (str): Candidate facts to be filtered\n\n"
129
+ "Your output fields are:\n"
130
+ '1. `fact_after_filter` (Fact): Filtered facts in JSON format\n\n'
131
+ "All interactions will be structured in the following way, with the appropriate "
132
+ "values filled in.\n\n"
133
+ "[[ ## question ## ]]\n{question}\n\n"
134
+ "[[ ## fact_before_filter ## ]]\n{fact_before_filter}\n\n"
135
+ "[[ ## fact_after_filter ## ]]\n{fact_after_filter} "
136
+ '# note: the value you produce must be pareseable according to the following JSON schema: '
137
+ '{"type": "object", "properties": {"fact": {"type": "array", '
138
+ '"description": "A list of facts, each fact is a list of 3 strings: [subject, predicate, object]", '
139
+ '"items": {"type": "array", "items": {"type": "string"}}, '
140
+ '"title": "Fact"}}, "required": ["fact"], "title": "Fact"}\n\n'
141
+ "[[ ## completed ## ]]\n\n"
142
+ "In adhering to this structure, your objective is: \n"
143
+ " You are a critical component of a high-stakes question-answering system used by "
144
+ "top researchers and decision-makers worldwide. Your task is to filter facts based on their "
145
+ "relevance to a given query, ensuring that the most crucial information is presented to "
146
+ "these stakeholders. The query requires careful analysis and possibly multi-hop reasoning "
147
+ "to connect different pieces of information. You must select up to 4 relevant facts from "
148
+ "the provided candidate list that have a strong connection to the query, aiding in reasoning "
149
+ "and providing an accurate answer. The output should be in JSON format, e.g., "
150
+ '{"fact": [["s1", "p1", "o1"], ["s2", "p2", "o2"]]}, and if no facts are relevant, '
151
+ 'return an empty list, {"fact": []}. The accuracy of your response is paramount, as it '
152
+ "will directly impact the decisions made by these high-level stakeholders. You must only "
153
+ "use facts from the candidate list and not generate new facts. The future of critical "
154
+ "decision-making relies on your ability to accurately filter and present relevant information."
155
+ )
156
+
157
+ RERANKER_INPUT_TEMPLATE = (
158
+ "[[ ## question ## ]]\n{question}\n\n"
159
+ "[[ ## fact_before_filter ## ]]\n{fact_before_filter}\n\n"
160
+ "Respond with the corresponding output fields, starting with the field "
161
+ "`[[ ## fact_after_filter ## ]]` (must be formatted as a valid Python Fact), "
162
+ "and then ending with the marker for `[[ ## completed ## ]]`."
163
+ )
164
+
165
+ RERANKER_OUTPUT_TEMPLATE = (
166
+ "[[ ## fact_after_filter ## ]]\n{fact_after_filter}\n\n"
167
+ "[[ ## completed ## ]]"
168
+ )
169
+
170
+ # Built-in few-shot demos (from the original filter_default_prompt.py)
171
+ RERANKER_DEMOS = [
172
+ {
173
+ "question": "Are Imperial River (Florida) and Amaradia (Dolj) both located in the same country?",
174
+ "fact_before_filter": '{"fact": [["imperial river", "is located in", "florida"], ["imperial river", "is a river in", "united states"], ["imperial river", "may refer to", "south america"], ["amaradia", "flows through", "ro ia de amaradia"], ["imperial river", "may refer to", "united states"]]}',
175
+ "fact_after_filter": '{"fact":[["imperial river","is located in","florida"],["imperial river","is a river in","united states"],["amaradia","flows through","ro ia de amaradia"]]}',
176
+ },
177
+ {
178
+ "question": "When is the director of film The Ancestor 's birthday?",
179
+ "fact_before_filter": '{"fact": [["jean jacques annaud", "born on", "1 october 1943"], ["tsui hark", "born on", "15 february 1950"], ["pablo trapero", "born on", "4 october 1971"], ["the ancestor", "directed by", "guido brignone"], ["benh zeitlin", "born on", "october 14 1982"]]}',
180
+ "fact_after_filter": '{"fact":[["the ancestor","directed by","guido brignone"]]}',
181
+ },
182
+ {
183
+ "question": "In what geographic region is the country where Teafuone is located?",
184
+ "fact_before_filter": '{"fact": [["teafuaniua", "is on the", "east"], ["motuloa", "lies between", "teafuaniua"], ["motuloa", "lies between", "teafuanonu"], ["teafuone", "is", "islet"], ["teafuone", "located in", "nukufetau"]]}',
185
+ "fact_after_filter": '{"fact":[["teafuone","is","islet"],["teafuone","located in","nukufetau"]]}',
186
+ },
187
+ {
188
+ "question": "When did the director of film S.O.B. (Film) die?",
189
+ "fact_before_filter": '{"fact": [["allan dwan", "died on", "28 december 1981"], ["s o b", "written and directed by", "blake edwards"], ["robert aldrich", "died on", "december 5 1983"], ["robert siodmak", "died on", "10 march 1973"], ["bernardo bertolucci", "died on", "26 november 2018"]]}',
190
+ "fact_after_filter": '{"fact":[["s o b","written and directed by","blake edwards"]]}',
191
+ },
192
+ ]
193
+
194
+
195
+ def make_reranker_messages(question: str, fact_before_filter_json: str) -> List[Dict[str, str]]:
196
+ """Build the full chat history for fact reranking."""
197
+ messages = [{"role": "system", "content": RERANKER_SYSTEM}]
198
+ for demo in RERANKER_DEMOS:
199
+ messages.append({
200
+ "role": "user",
201
+ "content": RERANKER_INPUT_TEMPLATE.format(
202
+ question=demo["question"],
203
+ fact_before_filter=demo["fact_before_filter"],
204
+ ),
205
+ })
206
+ messages.append({
207
+ "role": "assistant",
208
+ "content": RERANKER_OUTPUT_TEMPLATE.format(
209
+ fact_after_filter=demo["fact_after_filter"],
210
+ ),
211
+ })
212
+ messages.append({
213
+ "role": "user",
214
+ "content": RERANKER_INPUT_TEMPLATE.format(
215
+ question=question,
216
+ fact_before_filter=fact_before_filter_json,
217
+ ),
218
+ })
219
+ return messages
220
+
221
+ # ============================================================================
222
+ # RAG QA prompt (MuSiQue-style, also used for HotpotQA / 2Wiki)
223
+ # ============================================================================
224
+
225
+ RAG_QA_SYSTEM = (
226
+ "As an advanced reading comprehension assistant, your task is to analyze text passages "
227
+ "and corresponding questions meticulously. "
228
+ "Your response start after \"Thought: \", where you will methodically break down the "
229
+ "reasoning process, illustrating how you arrive at conclusions. "
230
+ "Conclude with \"Answer: \" to present a concise, definitive response, devoid of "
231
+ "additional elaborations."
232
+ )
233
+
234
+ _RAG_QA_ONE_SHOT_DOCS = (
235
+ "Wikipedia Title: The Last Horse\n"
236
+ "The Last Horse (Spanish:El último caballo) is a 1950 Spanish comedy film directed "
237
+ "by Edgar Neville starring Fernando Fernán Gómez.\n\n"
238
+ "Wikipedia Title: Southampton\n"
239
+ "The University of Southampton, which was founded in 1862 and received its Royal "
240
+ "Charter as a university in 1952, has over 22,000 students. The university is ranked "
241
+ "in the top 100 research universities in the world in the Academic Ranking of World "
242
+ "Universities 2010.\n\n"
243
+ "Wikipedia Title: Neville A. Stanton\n"
244
+ "Neville A. Stanton is a British Professor of Human Factors and Ergonomics at the "
245
+ "University of Southampton. Prof Stanton is a Chartered Engineer (C.Eng), Chartered "
246
+ "Psychologist (C.Psychol) and Chartered Ergonomist (C.ErgHF).\n"
247
+ )
248
+
249
+ RAG_QA_ONE_SHOT_INPUT = (
250
+ f"{_RAG_QA_ONE_SHOT_DOCS}\n\n"
251
+ "Question: When was Neville A. Stanton's employer founded?\nThought: "
252
+ )
253
+
254
+ RAG_QA_ONE_SHOT_OUTPUT = (
255
+ "The employer of Neville A. Stanton is University of Southampton. "
256
+ "The University of Southampton was founded in 1862. "
257
+ "\nAnswer: 1862."
258
+ )
259
+
260
+
261
+ def make_qa_messages(passages: List[str], question: str) -> List[Dict[str, str]]:
262
+ """Build QA chat messages from retrieved passages and question."""
263
+ prompt_user = ""
264
+ for p in passages:
265
+ prompt_user += f"Wikipedia Title: {p}\n\n"
266
+ prompt_user += f"Question: {question}\nThought: "
267
+ return [
268
+ {"role": "system", "content": RAG_QA_SYSTEM},
269
+ {"role": "user", "content": RAG_QA_ONE_SHOT_INPUT},
270
+ {"role": "assistant", "content": RAG_QA_ONE_SHOT_OUTPUT},
271
+ {"role": "user", "content": prompt_user},
272
+ ]
273
+
274
+ # ============================================================================
275
+ # Query instruction strings (for embedding model)
276
+ # ============================================================================
277
+
278
+ QUERY_INSTRUCTIONS = {
279
+ "ner_to_node": "Given a phrase, retrieve synonymous or relevant phrases that best match this phrase.",
280
+ "query_to_node": "Given a question, retrieve relevant phrases that are mentioned in this question.",
281
+ "query_to_fact": "Given a question, retrieve relevant triplet facts that matches this question.",
282
+ "query_to_sentence": "Given a question, retrieve relevant sentences that best answer the question.",
283
+ "query_to_passage": "Given a question, retrieve relevant documents that best answer the question.",
284
+ }
285
+
286
+ def get_query_instruction(linking_method: str) -> str:
287
+ default = "Given a question, retrieve relevant documents that best answer the question."
288
+ return QUERY_INSTRUCTIONS.get(linking_method, default)
src/passage_entity/reranker.py ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Fact reranker following the original DSPy filter logic.
3
+
4
+ Uses QAFD-RAG's async LLM functions (wrapped synchronously) to call the
5
+ same prompt structure that the original DSPyFilter uses.
6
+ """
7
+
8
+ import ast
9
+ import asyncio
10
+ import difflib
11
+ import json
12
+ import logging
13
+ import re
14
+ from copy import deepcopy
15
+ from typing import Callable, Dict, List, Tuple, Any
16
+
17
+ from .prompts import make_reranker_messages
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ def _run_sync(coro):
23
+ try:
24
+ loop = asyncio.get_running_loop()
25
+ except RuntimeError:
26
+ loop = None
27
+ if loop is not None and loop.is_running():
28
+ import concurrent.futures
29
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
30
+ return pool.submit(asyncio.run, coro).result()
31
+ else:
32
+ return asyncio.run(coro)
33
+
34
+
35
+ class FactReranker:
36
+ """Rerank candidate fact triples using an LLM (DSPy-style filtering).
37
+
38
+ Parameters
39
+ ----------
40
+ llm_func : callable
41
+ Async LLM function from ``src/llm.py``.
42
+ dspy_file_path : str or None
43
+ Path to a DSPy-saved JSON file with custom demos/system prompt.
44
+ If ``None``, uses built-in demos from ``prompts.py``.
45
+ """
46
+
47
+ def __init__(self, llm_func: Callable, dspy_file_path: str = None):
48
+ self.llm_func = llm_func
49
+ self.dspy_file_path = dspy_file_path
50
+
51
+ if dspy_file_path is not None:
52
+ self._custom_template = self._load_dspy_template(dspy_file_path)
53
+ else:
54
+ self._custom_template = None
55
+
56
+ # ------------------------------------------------------------------
57
+ @staticmethod
58
+ def _load_dspy_template(path: str) -> List[Dict[str, str]]:
59
+ """Load a DSPy-saved JSON and convert to chat messages."""
60
+ data = json.load(open(path))
61
+ system_prompt = data["prog"]["system"]
62
+ demos = data["prog"]["demos"]
63
+
64
+ one_in = (
65
+ "[[ ## question ## ]]\n{question}\n\n"
66
+ "[[ ## fact_before_filter ## ]]\n{fact_before_filter}\n\n"
67
+ "Respond with the corresponding output fields, starting with the field "
68
+ "`[[ ## fact_after_filter ## ]]` (must be formatted as a valid Python Fact), "
69
+ "and then ending with the marker for `[[ ## completed ## ]]`."
70
+ )
71
+ one_out = (
72
+ "[[ ## fact_after_filter ## ]]\n{fact_after_filter}\n\n"
73
+ "[[ ## completed ## ]]"
74
+ )
75
+
76
+ msgs = [{"role": "system", "content": system_prompt}]
77
+ for demo in demos:
78
+ msgs.append({
79
+ "role": "user",
80
+ "content": one_in.format(
81
+ question=demo["question"],
82
+ fact_before_filter=demo["fact_before_filter"],
83
+ ),
84
+ })
85
+ if "fact_after_filter" in demo:
86
+ msgs.append({
87
+ "role": "assistant",
88
+ "content": one_out.format(
89
+ fact_after_filter=demo["fact_after_filter"],
90
+ ),
91
+ })
92
+ return msgs
93
+
94
+ # ------------------------------------------------------------------
95
+ def _build_messages(
96
+ self, question: str, fact_before_filter_json: str
97
+ ) -> List[Dict[str, str]]:
98
+ if self._custom_template is not None:
99
+ msgs = deepcopy(self._custom_template)
100
+ one_in = (
101
+ "[[ ## question ## ]]\n{question}\n\n"
102
+ "[[ ## fact_before_filter ## ]]\n{fact_before_filter}\n\n"
103
+ "Respond with the corresponding output fields, starting with the field "
104
+ "`[[ ## fact_after_filter ## ]]` (must be formatted as a valid Python Fact), "
105
+ "and then ending with the marker for `[[ ## completed ## ]]`."
106
+ )
107
+ msgs.append({
108
+ "role": "user",
109
+ "content": one_in.format(
110
+ question=question,
111
+ fact_before_filter=fact_before_filter_json,
112
+ ),
113
+ })
114
+ return msgs
115
+ else:
116
+ return make_reranker_messages(question, fact_before_filter_json)
117
+
118
+ # ------------------------------------------------------------------
119
+ def _call_llm(self, messages: List[Dict[str, str]]) -> str:
120
+ system_prompt = None
121
+ history = []
122
+ user_prompt = ""
123
+ for msg in messages:
124
+ if msg["role"] == "system":
125
+ system_prompt = msg["content"]
126
+ elif msg["role"] == "assistant":
127
+ history.append(msg)
128
+ elif msg["role"] == "user":
129
+ if user_prompt:
130
+ history.append({"role": "user", "content": user_prompt})
131
+ user_prompt = msg["content"]
132
+
133
+ return _run_sync(
134
+ self.llm_func(
135
+ prompt=user_prompt,
136
+ system_prompt=system_prompt,
137
+ history_messages=history,
138
+ max_tokens=512,
139
+ )
140
+ )
141
+
142
+ # ------------------------------------------------------------------
143
+ @staticmethod
144
+ def _parse_filter(response: str) -> List[List[str]]:
145
+ """Extract fact_after_filter from the DSPy-style response."""
146
+ sections = [(None, [])]
147
+ header_re = re.compile(r'\[\[ ## (\w+) ## \]\]')
148
+ for line in response.splitlines():
149
+ m = header_re.match(line.strip())
150
+ if m:
151
+ sections.append((m.group(1), []))
152
+ else:
153
+ sections[-1][1].append(line)
154
+ sections = [(k, "\n".join(v).strip()) for k, v in sections]
155
+
156
+ parsed: List[List[str]] = []
157
+ for k, value in sections:
158
+ if k == "fact_after_filter":
159
+ try:
160
+ try:
161
+ pv = json.loads(value)
162
+ except json.JSONDecodeError:
163
+ try:
164
+ pv = ast.literal_eval(value)
165
+ except (ValueError, SyntaxError):
166
+ pv = value
167
+ if isinstance(pv, dict) and "fact" in pv:
168
+ parsed = pv["fact"]
169
+ except Exception as e:
170
+ logger.warning(f"Error parsing reranker output: {e}")
171
+ return parsed
172
+
173
+ # ------------------------------------------------------------------
174
+ def rerank(
175
+ self,
176
+ query: str,
177
+ candidate_items: List[Tuple],
178
+ candidate_indices: List[int],
179
+ len_after_rerank: int = None,
180
+ ) -> Tuple[List[int], List[Tuple], dict]:
181
+ """Rerank candidate facts by LLM-based filtering.
182
+
183
+ Returns
184
+ -------
185
+ (sorted_indices, sorted_facts, metadata_dict)
186
+ """
187
+ fact_json = json.dumps({"fact": [list(c) for c in candidate_items]})
188
+ try:
189
+ msgs = self._build_messages(query, fact_json)
190
+ response = self._call_llm(msgs)
191
+ generated_facts = self._parse_filter(response)
192
+ except Exception as e:
193
+ logger.warning(f"Reranker exception: {e}")
194
+ generated_facts = []
195
+
196
+ result_indices = []
197
+ for gf in generated_facts:
198
+ matches = difflib.get_close_matches(
199
+ str(gf), [str(i) for i in candidate_items], n=1, cutoff=0.0
200
+ )
201
+ if matches:
202
+ try:
203
+ result_indices.append(candidate_items.index(eval(matches[0])))
204
+ except Exception as e:
205
+ logger.warning(f"Index matching error: {e}")
206
+
207
+ sorted_indices = [candidate_indices[i] for i in result_indices]
208
+ sorted_items = [candidate_items[i] for i in result_indices]
209
+
210
+ if len_after_rerank is not None:
211
+ sorted_indices = sorted_indices[:len_after_rerank]
212
+ sorted_items = sorted_items[:len_after_rerank]
213
+
214
+ return sorted_indices, sorted_items, {"confidence": None}
215
+
216
+ def __call__(self, *args, **kwargs):
217
+ return self.rerank(*args, **kwargs)
src/passage_entity/retriever.py ADDED
@@ -0,0 +1,414 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Full retrieval pipeline following the original retrieve + graph_search_with_fact_entities.
3
+
4
+ Steps:
5
+ 1. Encode query for fact matching and passage matching.
6
+ 2. Score facts, rerank with LLM.
7
+ 3. Compute entity seed weights and passage weights.
8
+ 4. Run MultiSeedFlowDiffusionRetriever (QAFD) on the KG.
9
+ 5. Return ranked passages.
10
+ """
11
+
12
+ import json
13
+ import logging
14
+ import os
15
+ import time
16
+ from typing import Callable, Dict, List, Optional, Tuple
17
+
18
+ import igraph as ig
19
+ import numpy as np
20
+ from tqdm import tqdm
21
+
22
+ from .config import PassageEntityConfig
23
+ from .embedding_store import EmbeddingStore, EmbeddingModelWrapper
24
+ from .graph_adapter import run_igraph_qafd
25
+ from .prompts import get_query_instruction
26
+ from .reranker import FactReranker
27
+ from .utils import (
28
+ QuerySolution,
29
+ NerRawOutput,
30
+ TripleRawOutput,
31
+ compute_mdhash_id,
32
+ text_processing,
33
+ extract_entity_nodes,
34
+ flatten_facts,
35
+ min_max_normalize,
36
+ reformat_openie_results,
37
+ )
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+
42
+ class PassageEntityRetriever:
43
+ """End-to-end retriever: query -> ranked passages.
44
+
45
+ Uses the pre-built KG (igraph), embedding stores, and QAFD flow diffusion.
46
+ """
47
+
48
+ def __init__(
49
+ self,
50
+ config: PassageEntityConfig,
51
+ embedding_model: EmbeddingModelWrapper,
52
+ reranker: FactReranker,
53
+ graph: ig.Graph,
54
+ chunk_embedding_store: EmbeddingStore,
55
+ entity_embedding_store: EmbeddingStore,
56
+ fact_embedding_store: EmbeddingStore,
57
+ openie_results_path: str,
58
+ ):
59
+ self.config = config
60
+ self.embedding_model = embedding_model
61
+ self.reranker = reranker
62
+ self.graph = graph
63
+
64
+ self.chunk_store = chunk_embedding_store
65
+ self.entity_store = entity_embedding_store
66
+ self.fact_store = fact_embedding_store
67
+ self.openie_results_path = openie_results_path
68
+
69
+ # Filled by prepare()
70
+ self._ready = False
71
+ self.entity_node_keys: List[str] = []
72
+ self.passage_node_keys: List[str] = []
73
+ self.fact_node_keys: List[str] = []
74
+ self.entity_embeddings: np.ndarray = np.array([])
75
+ self.passage_embeddings: np.ndarray = np.array([])
76
+ self.fact_embeddings: np.ndarray = np.array([])
77
+ self.node_name_to_vertex_idx: Dict[str, int] = {}
78
+ self.entity_node_idxs: List[int] = []
79
+ self.passage_node_idxs: List[int] = []
80
+ self.ent_node_to_chunk_ids: Dict[str, set] = {}
81
+
82
+ # Cached query embeddings
83
+ self._query_emb_fact: Dict[str, np.ndarray] = {}
84
+ self._query_emb_pass: Dict[str, np.ndarray] = {}
85
+
86
+ # Timing accumulators
87
+ self.rerank_time = 0.0
88
+ self.qafd_time = 0.0
89
+ self.total_time = 0.0
90
+
91
+ # ------------------------------------------------------------------
92
+ # Preparation (mirrors the passage-entity pipeline prepare_retrieval_objects)
93
+ # ------------------------------------------------------------------
94
+
95
+ def prepare(self):
96
+ """Load embeddings, build lookup structures. Call once before retrieve()."""
97
+ logger.info("Preparing retrieval objects ...")
98
+
99
+ self.entity_node_keys = list(self.entity_store.get_all_ids())
100
+ self.passage_node_keys = list(self.chunk_store.get_all_ids())
101
+ self.fact_node_keys = list(self.fact_store.get_all_ids())
102
+
103
+ # Node index mapping
104
+ try:
105
+ name_to_idx = {v["name"]: idx for idx, v in enumerate(self.graph.vs)}
106
+ self.node_name_to_vertex_idx = name_to_idx
107
+ self.entity_node_idxs = [name_to_idx[k] for k in self.entity_node_keys]
108
+ self.passage_node_idxs = [name_to_idx[k] for k in self.passage_node_keys]
109
+ except Exception as e:
110
+ logger.error(f"Graph index mapping failed: {e}")
111
+ self.node_name_to_vertex_idx = {}
112
+ self.entity_node_idxs = []
113
+ self.passage_node_idxs = []
114
+
115
+ # Embeddings
116
+ self.entity_embeddings = np.array(
117
+ self.entity_store.get_embeddings(self.entity_node_keys)
118
+ ) if self.entity_node_keys else np.array([])
119
+
120
+ self.passage_embeddings = np.array(
121
+ self.chunk_store.get_embeddings(self.passage_node_keys)
122
+ ) if self.passage_node_keys else np.array([])
123
+
124
+ self.fact_embeddings = np.array(
125
+ self.fact_store.get_embeddings(self.fact_node_keys)
126
+ ) if self.fact_node_keys else np.array([])
127
+
128
+ # Build ent_node_to_chunk_ids from openie results
129
+ self.ent_node_to_chunk_ids = {}
130
+ if os.path.isfile(self.openie_results_path):
131
+ all_info = json.load(open(self.openie_results_path)).get("docs", [])
132
+ ner_dict, triple_dict = reformat_openie_results(all_info)
133
+ for cid in self.passage_node_keys:
134
+ if cid not in triple_dict:
135
+ continue
136
+ triples = [text_processing(t) for t in triple_dict[cid].triples]
137
+ for triple in triples:
138
+ if len(triple) == 3:
139
+ for ent in [triple[0], triple[2]]:
140
+ nk = compute_mdhash_id(ent, prefix="entity-")
141
+ self.ent_node_to_chunk_ids.setdefault(nk, set()).add(cid)
142
+
143
+ self._ready = True
144
+ logger.info(
145
+ f"Ready. entities={len(self.entity_node_keys)}, "
146
+ f"passages={len(self.passage_node_keys)}, "
147
+ f"facts={len(self.fact_node_keys)}"
148
+ )
149
+
150
+ # ------------------------------------------------------------------
151
+ # Query embedding
152
+ # ------------------------------------------------------------------
153
+
154
+ def _encode_queries(self, queries: List[str]):
155
+ to_encode = [q for q in queries if q not in self._query_emb_fact]
156
+ if not to_encode:
157
+ return
158
+
159
+ fact_embs = self.embedding_model.batch_encode(
160
+ to_encode, instruction=get_query_instruction("query_to_fact"), norm=True
161
+ )
162
+ pass_embs = self.embedding_model.batch_encode(
163
+ to_encode, instruction=get_query_instruction("query_to_passage"), norm=True
164
+ )
165
+ for q, fe, pe in zip(to_encode, fact_embs, pass_embs):
166
+ self._query_emb_fact[q] = fe
167
+ self._query_emb_pass[q] = pe
168
+
169
+ # ------------------------------------------------------------------
170
+ # Fact scoring
171
+ # ------------------------------------------------------------------
172
+
173
+ def _get_fact_scores(self, query: str) -> np.ndarray:
174
+ qe = self._query_emb_fact.get(query)
175
+ if qe is None:
176
+ qe = self.embedding_model.batch_encode(
177
+ query, instruction=get_query_instruction("query_to_fact"), norm=True
178
+ )
179
+ if len(self.fact_embeddings) == 0:
180
+ return np.array([])
181
+ scores = np.dot(self.fact_embeddings, qe.T)
182
+ scores = np.squeeze(scores) if scores.ndim == 2 else scores
183
+ return min_max_normalize(scores)
184
+
185
+ # ------------------------------------------------------------------
186
+ # Dense passage retrieval (fallback)
187
+ # ------------------------------------------------------------------
188
+
189
+ def _dense_passage_retrieval(self, query: str) -> Tuple[np.ndarray, np.ndarray]:
190
+ qe = self._query_emb_pass.get(query)
191
+ if qe is None:
192
+ qe = self.embedding_model.batch_encode(
193
+ query, instruction=get_query_instruction("query_to_passage"), norm=True
194
+ )
195
+ scores = np.dot(self.passage_embeddings, qe.T)
196
+ scores = np.squeeze(scores) if scores.ndim == 2 else scores
197
+ scores = min_max_normalize(scores)
198
+ sorted_ids = np.argsort(scores)[::-1]
199
+ return sorted_ids, scores[sorted_ids]
200
+
201
+ # ------------------------------------------------------------------
202
+ # Rerank facts
203
+ # ------------------------------------------------------------------
204
+
205
+ def _rerank_facts(
206
+ self, query: str, fact_scores: np.ndarray
207
+ ) -> Tuple[List[int], List[tuple], dict]:
208
+ link_top_k = self.config.linking_top_k
209
+ if len(fact_scores) == 0 or len(self.fact_node_keys) == 0:
210
+ return [], [], {}
211
+
212
+ if len(fact_scores) <= link_top_k:
213
+ cand_indices = np.argsort(fact_scores)[::-1].tolist()
214
+ else:
215
+ cand_indices = np.argsort(fact_scores)[-link_top_k:][::-1].tolist()
216
+
217
+ real_ids = [self.fact_node_keys[i] for i in cand_indices]
218
+ rows = self.fact_store.get_rows(real_ids)
219
+ cand_facts = [eval(rows[rid]["content"]) for rid in real_ids]
220
+
221
+ top_indices, top_facts, meta = self.reranker(
222
+ query, cand_facts, cand_indices, len_after_rerank=link_top_k
223
+ )
224
+ return top_indices, top_facts, meta
225
+
226
+ # ------------------------------------------------------------------
227
+ # Graph search (core of passage-entity retrieval)
228
+ # ------------------------------------------------------------------
229
+
230
+ def _graph_search(
231
+ self,
232
+ query: str,
233
+ fact_scores: np.ndarray,
234
+ top_k_facts: List[tuple],
235
+ top_k_fact_indices: List[int],
236
+ ) -> Tuple[np.ndarray, np.ndarray]:
237
+ """Compute seed weights -> run QAFD -> return sorted passage ids + scores."""
238
+ link_top_k = self.config.linking_top_k
239
+ n_nodes = self.graph.vcount()
240
+
241
+ # --- entity seed weights ---
242
+ linking_score_map: Dict[str, float] = {}
243
+ phrase_scores: Dict[str, list] = {}
244
+ phrase_weights = np.zeros(n_nodes)
245
+ passage_weights = np.zeros(n_nodes)
246
+ number_of_occurs = np.zeros(n_nodes)
247
+ phrases_and_ids = set()
248
+
249
+ for rank, f in enumerate(top_k_facts):
250
+ subj = f[0].lower()
251
+ obj = f[2].lower()
252
+ fs = (
253
+ fact_scores[top_k_fact_indices[rank]]
254
+ if fact_scores.ndim > 0
255
+ else float(fact_scores)
256
+ )
257
+
258
+ for phrase in [subj, obj]:
259
+ pk = compute_mdhash_id(phrase, prefix="entity-")
260
+ pid = self.node_name_to_vertex_idx.get(pk)
261
+ if pid is not None:
262
+ wfs = fs
263
+ num_chunks = len(self.ent_node_to_chunk_ids.get(pk, set()))
264
+ if num_chunks > 0:
265
+ wfs /= num_chunks
266
+ phrase_weights[pid] += wfs
267
+ number_of_occurs[pid] += 1
268
+ phrases_and_ids.add((phrase, pid))
269
+
270
+ # Normalise
271
+ nonzero = number_of_occurs > 0
272
+ phrase_weights[nonzero] /= number_of_occurs[nonzero]
273
+
274
+ for phrase, pid in phrases_and_ids:
275
+ if pid is not None:
276
+ phrase_scores.setdefault(phrase, []).append(phrase_weights[pid])
277
+
278
+ for phrase, scores in phrase_scores.items():
279
+ linking_score_map[phrase] = float(np.mean(scores))
280
+
281
+ # Keep only top-k entity seeds
282
+ if link_top_k and linking_score_map:
283
+ linking_score_map = dict(
284
+ sorted(linking_score_map.items(), key=lambda x: x[1], reverse=True)[
285
+ :link_top_k
286
+ ]
287
+ )
288
+ top_phrases = {
289
+ compute_mdhash_id(p, prefix="entity-")
290
+ for p in linking_score_map
291
+ }
292
+ for nk in self.node_name_to_vertex_idx:
293
+ if nk not in top_phrases:
294
+ pid = self.node_name_to_vertex_idx.get(nk)
295
+ if pid is not None:
296
+ phrase_weights[pid] = 0.0
297
+
298
+ # --- passage seed weights ---
299
+ dpr_ids, dpr_scores = self._dense_passage_retrieval(query)
300
+ norm_dpr = min_max_normalize(dpr_scores)
301
+ pw = self.config.passage_node_weight
302
+
303
+ for i, did in enumerate(dpr_ids.tolist()):
304
+ pk = self.passage_node_keys[did]
305
+ pid = self.node_name_to_vertex_idx.get(pk)
306
+ if pid is not None:
307
+ passage_weights[pid] = norm_dpr[i] * pw
308
+
309
+ node_weights = phrase_weights + passage_weights
310
+
311
+ if np.sum(node_weights) == 0:
312
+ logger.warning("All node weights are zero after seed selection, falling back to DPR")
313
+ return dpr_ids, dpr_scores
314
+
315
+ # --- Build node embeddings dict (cached) ---
316
+ if not hasattr(self, '_node_emb_dict') or self._node_emb_dict is None:
317
+ self._node_emb_dict = {}
318
+ for i, nk in enumerate(self.entity_node_keys):
319
+ if i < len(self.entity_embeddings):
320
+ self._node_emb_dict[nk] = self.entity_embeddings[i]
321
+ for i, nk in enumerate(self.passage_node_keys):
322
+ if i < len(self.passage_embeddings):
323
+ self._node_emb_dict[nk] = self.passage_embeddings[i]
324
+
325
+ query_emb = self._query_emb_fact.get(query)
326
+
327
+ # --- Run QAFD ---
328
+ qafd_start = time.time()
329
+ sorted_ids, sorted_scores = run_igraph_qafd(
330
+ graph=self.graph,
331
+ node_name_to_idx=self.node_name_to_vertex_idx,
332
+ passage_node_idxs=self.passage_node_idxs,
333
+ source_weights=node_weights,
334
+ node_embeddings=self._node_emb_dict,
335
+ query_embedding=query_emb,
336
+ alpha=self.config.qafd_alpha,
337
+ epsilon=self.config.qafd_epsilon,
338
+ max_iterations=self.config.qafd_max_iterations,
339
+ step_size=self.config.qafd_step_size,
340
+ weight_scheme=self.config.qafd_weight_scheme,
341
+ use_node_degree=self.config.qafd_use_node_degree,
342
+ random_seed=self.config.qafd_random_seed,
343
+ sim_mode=self.config.sim_mode,
344
+ qa_sink_gamma=self.config.qa_sink_gamma,
345
+ qa_warm_delta=self.config.qa_warm_delta,
346
+ qa_warm_walk=self.config.qa_warm_walk,
347
+ qa_warm_steps=self.config.qa_warm_steps,
348
+ qa_accum_gamma=self.config.qa_accum_gamma,
349
+ qa_post_lambda=self.config.qa_post_lambda,
350
+ batch_push=self.config.batch_push,
351
+ )
352
+ qafd_elapsed = time.time() - qafd_start
353
+ self.qafd_time += qafd_elapsed
354
+ logger.info(f"QAFD completed in {qafd_elapsed:.2f}s")
355
+
356
+ return sorted_ids, sorted_scores
357
+
358
+ # ------------------------------------------------------------------
359
+ # Public interface
360
+ # ------------------------------------------------------------------
361
+
362
+ def retrieve(
363
+ self,
364
+ queries: List[str],
365
+ num_to_retrieve: int = None,
366
+ gold_docs: List[List[str]] = None,
367
+ ) -> List[QuerySolution]:
368
+ """Retrieve documents for a batch of queries.
369
+
370
+ Returns a list of ``QuerySolution`` objects.
371
+ """
372
+ if not self._ready:
373
+ self.prepare()
374
+
375
+ if num_to_retrieve is None:
376
+ num_to_retrieve = self.config.retrieval_top_k
377
+
378
+ self._encode_queries(queries)
379
+
380
+ results = []
381
+ t0 = time.time()
382
+
383
+ for q in tqdm(queries, desc="Retrieving"):
384
+ rerank_t0 = time.time()
385
+ fact_scores = self._get_fact_scores(q)
386
+ top_indices, top_facts, _ = self._rerank_facts(q, fact_scores)
387
+ self.rerank_time += time.time() - rerank_t0
388
+
389
+ if len(top_facts) == 0:
390
+ logger.info("No facts after reranking -> fallback to DPR")
391
+ sorted_ids, sorted_scores = self._dense_passage_retrieval(q)
392
+ else:
393
+ sorted_ids, sorted_scores = self._graph_search(
394
+ q, fact_scores, top_facts, top_indices
395
+ )
396
+
397
+ top_docs = [
398
+ self.chunk_store.get_row(self.passage_node_keys[idx])["content"]
399
+ for idx in sorted_ids[:num_to_retrieve]
400
+ ]
401
+ results.append(
402
+ QuerySolution(
403
+ question=q,
404
+ docs=top_docs,
405
+ doc_scores=sorted_scores[:num_to_retrieve],
406
+ )
407
+ )
408
+
409
+ self.total_time += time.time() - t0
410
+ logger.info(
411
+ f"Retrieval done. total={self.total_time:.1f}s, "
412
+ f"rerank={self.rerank_time:.1f}s, qafd={self.qafd_time:.1f}s"
413
+ )
414
+ return results
src/passage_entity/utils.py ADDED
@@ -0,0 +1,221 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Utility functions and data classes for the passage-entity KG pipeline.
3
+
4
+ Adapted from the original misc_utils.py and llm_utils.py.
5
+ """
6
+
7
+ import json
8
+ import re
9
+ import logging
10
+ from dataclasses import dataclass
11
+ from hashlib import md5
12
+ from typing import Dict, Any, List, Tuple, Literal, Union, Optional
13
+
14
+ import numpy as np
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # Data classes
20
+ # ---------------------------------------------------------------------------
21
+
22
+ @dataclass
23
+ class NerRawOutput:
24
+ chunk_id: str
25
+ response: str
26
+ unique_entities: List[str]
27
+ metadata: Dict[str, Any]
28
+
29
+
30
+ @dataclass
31
+ class TripleRawOutput:
32
+ chunk_id: str
33
+ response: str
34
+ triples: List[List[str]]
35
+ metadata: Dict[str, Any]
36
+
37
+
38
+ @dataclass
39
+ class QuerySolution:
40
+ question: str
41
+ docs: List[str]
42
+ doc_scores: np.ndarray = None
43
+ answer: str = None
44
+ gold_answers: List[str] = None
45
+ gold_docs: Optional[List[str]] = None
46
+
47
+ def to_dict(self):
48
+ return {
49
+ "question": self.question,
50
+ "answer": self.answer,
51
+ "gold_answers": self.gold_answers,
52
+ "docs": self.docs[:5],
53
+ "doc_scores": (
54
+ [round(v, 4) for v in self.doc_scores.tolist()[:5]]
55
+ if self.doc_scores is not None
56
+ else None
57
+ ),
58
+ "gold_docs": self.gold_docs,
59
+ }
60
+
61
+
62
+ Triple = Union[List[str], Tuple[str, str, str]]
63
+
64
+ # ---------------------------------------------------------------------------
65
+ # Hashing
66
+ # ---------------------------------------------------------------------------
67
+
68
+ def compute_mdhash_id(content: str, prefix: str = "") -> str:
69
+ """Compute the MD5 hash of *content* and optionally prepend *prefix*."""
70
+ return prefix + md5(content.encode()).hexdigest()
71
+
72
+ # ---------------------------------------------------------------------------
73
+ # Text processing
74
+ # ---------------------------------------------------------------------------
75
+
76
+ def text_processing(text):
77
+ """Lower-case, strip non-alphanumeric characters (except spaces)."""
78
+ if isinstance(text, list):
79
+ return [text_processing(t) for t in text]
80
+ if not isinstance(text, str):
81
+ text = str(text)
82
+ return re.sub('[^A-Za-z0-9 ]', ' ', text.lower()).strip()
83
+
84
+ # ---------------------------------------------------------------------------
85
+ # OpenIE helpers
86
+ # ---------------------------------------------------------------------------
87
+
88
+ def extract_entity_nodes(chunk_triples: List[List[Triple]]) -> Tuple[List[str], List[List[str]]]:
89
+ """Extract unique entity nodes from chunk triples.
90
+
91
+ Returns:
92
+ graph_nodes: globally unique list of entity strings.
93
+ chunk_triple_entities: per-chunk list of entity strings.
94
+ """
95
+ chunk_triple_entities = []
96
+ for triples in chunk_triples:
97
+ triple_entities = set()
98
+ for t in triples:
99
+ if len(t) == 3:
100
+ triple_entities.update([t[0], t[2]])
101
+ else:
102
+ logger.warning(f"Invalid triple during graph construction: {t}")
103
+ chunk_triple_entities.append(list(triple_entities))
104
+ graph_nodes = list(np.unique([ent for ents in chunk_triple_entities for ent in ents]))
105
+ return graph_nodes, chunk_triple_entities
106
+
107
+
108
+ def flatten_facts(chunk_triples: List[List[Triple]]) -> List[Tuple]:
109
+ """Flatten per-chunk triples into a unique list of tuples."""
110
+ graph_triples = []
111
+ for triples in chunk_triples:
112
+ graph_triples.extend([tuple(t) for t in triples])
113
+ return list(set(graph_triples))
114
+
115
+
116
+ def reformat_openie_results(corpus_openie_results):
117
+ """Convert saved openie JSON list into (ner_dict, triple_dict)."""
118
+ ner_output_dict = {
119
+ chunk_item['idx']: NerRawOutput(
120
+ chunk_id=chunk_item['idx'],
121
+ response=None,
122
+ metadata={},
123
+ unique_entities=list(np.unique(chunk_item['extracted_entities']))
124
+ )
125
+ for chunk_item in corpus_openie_results
126
+ }
127
+ triple_output_dict = {
128
+ chunk_item['idx']: TripleRawOutput(
129
+ chunk_id=chunk_item['idx'],
130
+ response=None,
131
+ metadata={},
132
+ triples=filter_invalid_triples(triples=chunk_item['extracted_triples'])
133
+ )
134
+ for chunk_item in corpus_openie_results
135
+ }
136
+ return ner_output_dict, triple_output_dict
137
+
138
+ # ---------------------------------------------------------------------------
139
+ # Normalization
140
+ # ---------------------------------------------------------------------------
141
+
142
+ def min_max_normalize(x: np.ndarray) -> np.ndarray:
143
+ min_val = np.min(x)
144
+ max_val = np.max(x)
145
+ range_val = max_val - min_val
146
+ if range_val == 0:
147
+ return np.ones_like(x)
148
+ return (x - min_val) / range_val
149
+
150
+ # ---------------------------------------------------------------------------
151
+ # JSON repair helpers (from the original pipeline llm_utils)
152
+ # ---------------------------------------------------------------------------
153
+
154
+ def fix_broken_generated_json(json_str: str) -> str:
155
+ """Attempt to fix truncated JSON by closing open brackets/braces."""
156
+ def find_unclosed(s):
157
+ unclosed = []
158
+ inside_string = False
159
+ escape_next = False
160
+ for char in s:
161
+ if inside_string:
162
+ if escape_next:
163
+ escape_next = False
164
+ elif char == '\\':
165
+ escape_next = True
166
+ elif char == '"':
167
+ inside_string = False
168
+ else:
169
+ if char == '"':
170
+ inside_string = True
171
+ elif char in '{[':
172
+ unclosed.append(char)
173
+ elif char in '}]':
174
+ if unclosed and (
175
+ (char == '}' and unclosed[-1] == '{') or
176
+ (char == ']' and unclosed[-1] == '[')
177
+ ):
178
+ unclosed.pop()
179
+ return unclosed
180
+
181
+ try:
182
+ json.loads(json_str)
183
+ return json_str
184
+ except json.JSONDecodeError:
185
+ pass
186
+
187
+ last_comma_index = json_str.rfind(',')
188
+ if last_comma_index != -1:
189
+ json_str = json_str[:last_comma_index]
190
+
191
+ unclosed = find_unclosed(json_str)
192
+ closing_map = {'{': '}', '[': ']'}
193
+ for open_char in reversed(unclosed):
194
+ json_str += closing_map[open_char]
195
+ return json_str
196
+
197
+
198
+ def filter_invalid_triples(triples: List[List[str]]) -> List[List[str]]:
199
+ """Keep only unique triples with exactly 3 elements."""
200
+ unique_triples = set()
201
+ valid_triples = []
202
+ for triple in triples:
203
+ if len(triple) != 3:
204
+ continue
205
+ valid_triple = [str(item) for item in triple]
206
+ key = tuple(valid_triple)
207
+ if key not in unique_triples:
208
+ unique_triples.add(key)
209
+ valid_triples.append(valid_triple)
210
+ return valid_triples
211
+
212
+
213
+ def string_to_bool(v) -> bool:
214
+ if isinstance(v, bool):
215
+ return v
216
+ if v.lower() in ("yes", "true", "t", "y", "1"):
217
+ return True
218
+ elif v.lower() in ("no", "false", "f", "n", "0"):
219
+ return False
220
+ else:
221
+ raise ValueError(f"Cannot convert {v!r} to bool")