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