File size: 7,532 Bytes
340a9e4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
"""

Graph Builder cho Medical RAG Pipeline.



Pipeline:

    1. Load MedQuad dataset từ HuggingFace

    2. Parse documents qua LlamaIndex PropertyGraphIndex

    3. Chuyển đổi sang PyTorch Geometric graph

       - Nodes: mỗi text chunk = một node

       - Edges: cosine similarity > threshold (document similarity graph)

    4. Trả về pyg_data, nodes, và raw embeddings



Lý do dùng document similarity graph:

    Hai documents có nội dung liên quan (e.g. cùng nói về diabetes)

    sẽ được kết nối. GNN sẽ propagate thông tin qua các cạnh này,

    enriching embeddings với neighborhood context.

"""

import torch
import torch.nn.functional as F
from torch_geometric.data import Data
from torch_geometric.utils import add_self_loops, coalesce

from llama_index.core import PropertyGraphIndex, Document, Settings
from llama_index.core.indices.property_graph import ImplicitPathExtractor
from datasets import load_dataset

from typing import List, Tuple


def load_healthcare_documents(num_samples: int = 250) -> List[Document]:
    """

    Load dataset y tế từ HuggingFace.



    Dataset: keivalya/MedQuad-MedicalQnADataset

    Mỗi Document = 1 câu trả lời y tế, với câu hỏi lưu trong metadata.



    Args:

        num_samples: số lượng documents cần load (giới hạn để tiết kiệm RAM)



    Returns:

        List[Document] - danh sách LlamaIndex Document objects

    """
    print(f"Đang load {num_samples} medical documents từ HuggingFace...")

    dataset = load_dataset(
        "keivalya/MedQuad-MedicalQnADataset",
        split="train"
    )
    num_samples = min(num_samples, len(dataset))
    dataset = dataset.select(range(num_samples))

    docs = []
    for i, row in enumerate(dataset):
        # Truncate text dài để tiết kiệm RAM khi embedding
        answer_text = str(row.get("Answer", "")).strip()[:800]
        question_text = str(row.get("Question", "")).strip()[:200]

        if not answer_text:
            continue

        doc = Document(
            text=answer_text,
            metadata={
                "question": question_text,
                "doc_id": str(i),
                "source": "MedQuad"
            }
        )
        docs.append(doc)

    print(f"✓ Loaded {len(docs)} medical documents")
    return docs


def build_llamaindex_property_graph(

    documents: List[Document],

    embed_model,

) -> Tuple[PropertyGraphIndex, List]:
    """

    Xây dựng LlamaIndex PropertyGraphIndex từ documents.



    Sử dụng ImplicitPathExtractor - không cần LLM,

    phù hợp để deploy trên free tier.



    PropertyGraphIndex lưu trữ:

        - ChunkNodes: các đoạn văn bản từ documents

        - Implicit edges: dựa trên cấu trúc document



    Args:

        documents: danh sách LlamaIndex Document objects

        embed_model: HuggingFace embedding model



    Returns:

        (index, text_nodes) - PropertyGraphIndex và list TextNode objects

    """
    print("Đang xây dựng LlamaIndex PropertyGraphIndex...")

    # Cấu hình Settings - không dùng LLM của LlamaIndex
    # vì sẽ dùng llama-cpp trực tiếp cho generation
    Settings.embed_model = embed_model
    Settings.llm = None

    index = PropertyGraphIndex.from_documents(
        documents,
        embed_model=embed_model,
        kg_extractors=[ImplicitPathExtractor()],  # no LLM required
        show_progress=True,
    )

    # Lấy tất cả text nodes từ docstore
    # index.docstore.docs = {id: TextNode} dict
    text_nodes = list(index.docstore.docs.values())

    print(f"✓ PropertyGraphIndex built: {len(text_nodes)} text nodes")
    return index, text_nodes


def build_pyg_graph(

    text_nodes: List,

    embed_model,

    similarity_threshold: float = 0.70,

) -> Tuple[Data, List, torch.Tensor]:
    """

    Chuyển LlamaIndex text nodes sang PyTorch Geometric graph.



    Strategy xây dựng graph:

        - Mỗi text chunk = 1 node trong graph

        - Edge giữa 2 nodes nếu cosine_similarity > threshold

        - Thêm self-loops cho GCN stability (A_hat = A + I)



    Rationale: Documents về cùng chủ đề y tế (e.g. diabetes, hypertension)

    sẽ có high cosine similarity và được kết nối.

    GNN sẽ propagate thông tin qua các edges này, giúp mỗi node

    "biết" về context của các documents liên quan.



    Args:

        text_nodes: danh sách LlamaIndex TextNode objects

        embed_model: HuggingFace embedding model

        similarity_threshold: ngưỡng cosine similarity để tạo edge



    Returns:

        (pyg_data, nodes, raw_embeddings)

            pyg_data: PyTorch Geometric Data object

            nodes: ordered list of TextNode objects

            raw_embeddings: [N, D] tensor - raw text embeddings

    """
    N = len(text_nodes)
    print(f"Đang embed {N} nodes và xây dựng PyG graph...")

    # ── Step 1: Embed all nodes ──────────────────────────────────────────────
    texts = [node.get_content() for node in text_nodes]
    embeddings = embed_model.get_text_embedding_batch(texts, show_progress=True)
    x = torch.tensor(embeddings, dtype=torch.float)  # [N, D]

    # ── Step 2: Tính cosine similarity matrix ────────────────────────────────
    x_norm = F.normalize(x, dim=-1)            # [N, D] - L2 normalized
    sim_matrix = x_norm @ x_norm.T             # [N, N] - cosine similarity

    # ── Step 3: Tạo edges từ similarity ──────────────────────────────────────
    # Loại bỏ diagonal (self-similarity = 1.0 không có ý nghĩa cấu trúc)
    mask = (sim_matrix > similarity_threshold) & \
           ~torch.eye(N, dtype=torch.bool)

    edge_pairs = mask.nonzero(as_tuple=False)   # [E, 2]

    if len(edge_pairs) > 0:
        edge_index = edge_pairs.T               # [2, E]
        print(f"  Tạo được {edge_index.shape[1]} edges (threshold={similarity_threshold})")
    else:
        # Fallback: nếu không có edges, kết nối mỗi node với top-1 neighbor
        print(f"  Không có edges trên ngưỡng {similarity_threshold}, "
              f"dùng top-1 nearest neighbors...")
        sim_matrix.fill_diagonal_(-1.0)
        top1 = sim_matrix.argmax(dim=1)         # [N]
        src = torch.arange(N)
        edge_index = torch.stack([src, top1])   # [2, N]

    # ── Step 4: Thêm self-loops và loại bỏ duplicate edges ───────────────────
    # Self-loops quan trọng cho GCN:
    # Khi aggregate neighbors, node cũng aggregate chính nó
    edge_index, _ = add_self_loops(edge_index, num_nodes=N)
    edge_index = coalesce(edge_index, num_nodes=N)  # remove duplicates

    # ── Step 5: Build PyG Data object ────────────────────────────────────────
    pyg_data = Data(x=x, edge_index=edge_index, num_nodes=N)

    print(f"✓ PyG graph: {N} nodes, {edge_index.shape[1]} edges")
    print(f"  Average degree: {edge_index.shape[1]/N:.1f}")

    return pyg_data, text_nodes, x