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

Remove old folder name

Browse files
src/hipporag_pipeline/__init__.py DELETED
File without changes
src/hipporag_pipeline/benchmark_runner.py DELETED
@@ -1,737 +0,0 @@
1
- """
2
- Entry point for running multihop benchmarks (MuSiQue, HotpotQA, 2WikiMultiHopQA)
3
- with the HippoRAG-style KG pipeline + QAFD inside QAFD-RAG.
4
-
5
- Usage::
6
-
7
- python -m src.hipporag_pipeline.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.hipporag_pipeline.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.hipporag_pipeline"]:
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.hipporag_pipeline.config import HippoRAGConfig
69
- from src.hipporag_pipeline.embedding_store import EmbeddingModelWrapper
70
- from src.hipporag_pipeline.kg_builder import KGBuilder
71
- from src.hipporag_pipeline.openie import OpenIE
72
- from src.hipporag_pipeline.reranker import FactReranker
73
- from src.hipporag_pipeline.retriever import HippoRAGRetriever
74
- from src.hipporag_pipeline.prompts import make_qa_messages
75
- from src.hipporag_pipeline.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 HippoRAG 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="HippoRAG + QAFD 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 = HippoRAGConfig(
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 = HippoRAGRetriever(
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/hipporag_pipeline/config.py DELETED
@@ -1,94 +0,0 @@
1
- """
2
- Configuration dataclass for the HippoRAG-style KG pipeline inside QAFD-RAG.
3
-
4
- Combines HippoRAG 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 HippoRAGConfig:
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/hipporag_pipeline/embedding_store.py DELETED
@@ -1,236 +0,0 @@
1
- """
2
- Parquet-backed embedding store, adapted from HippoRAG's 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 HippoRAG's 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/hipporag_pipeline/graph_adapter.py DELETED
@@ -1,518 +0,0 @@
1
- """
2
- Bridge between igraph (HippoRAG's 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 HippoRAG's
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 HippoRAG 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 HippoRAG's 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 HippoRAG's ``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/hipporag_pipeline/kg_builder.py DELETED
@@ -1,438 +0,0 @@
1
- """
2
- Knowledge graph builder following HippoRAG's ``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 HippoRAGConfig
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 HippoRAG's 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 HippoRAG-style knowledge graph and persist it to disk."""
115
-
116
- def __init__(
117
- self,
118
- config: HippoRAGConfig,
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/hipporag_pipeline/openie.py DELETED
@@ -1,231 +0,0 @@
1
- """
2
- OpenIE extraction (NER + triple extraction) using QAFD-RAG's LLM functions.
3
-
4
- Follows HippoRAG's 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/hipporag_pipeline/prompts.py DELETED
@@ -1,288 +0,0 @@
1
- """
2
- Inline prompt templates for NER, triple extraction, query NER,
3
- fact reranking, and RAG QA — adapted from HippoRAG's 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 HippoRAG)
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 HippoRAG's 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/hipporag_pipeline/reranker.py DELETED
@@ -1,217 +0,0 @@
1
- """
2
- Fact reranker following HippoRAG's DSPy filter logic.
3
-
4
- Uses QAFD-RAG's async LLM functions (wrapped synchronously) to call the
5
- same prompt structure that HippoRAG's 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/hipporag_pipeline/retriever.py DELETED
@@ -1,414 +0,0 @@
1
- """
2
- Full retrieval pipeline following HippoRAG's 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 HippoRAGConfig
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 HippoRAGRetriever:
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: HippoRAGConfig,
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 HippoRAG 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 HippoRAG 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/hipporag_pipeline/utils.py DELETED
@@ -1,221 +0,0 @@
1
- """
2
- Utility functions and data classes for the HippoRAG-style KG pipeline.
3
-
4
- Adapted from HippoRAG's 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 HippoRAG 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")