import torch import pandas as pd import torch.nn.functional as F from cores.embedder.embeddings_init import TextEmbedder from cores.kgraph.knowledge_graph import KnowledgeGraph from online.qwen_model import QwenLLM class GraphRAGApp: """ Construct a LLM Q&A Application using knowledge graph-assisted Qwen3.5-4B """ def __init__(self, emb_path, csv_path, model_name="sentence-transformers/all-MiniLM-L6-v2", topk = 10, semantic_contribute=0.5, score_tolerance_factor=0.3, verbose=False ): # Load embeddings and perform normalization data = torch.load(emb_path, map_location=torch.device('cpu')) self.x_norm = F.normalize(data["h_text"], dim=1) # semantic embeddings self.h_norm = F.normalize(data["h_struct"], dim=1) # structural embeddings self.node2id = data["node2id"] # nodes mapping # Text to Structural projection self.query_proj = torch.nn.Linear(384, 384) # query projection layer self.query_proj.load_state_dict(data["query_proj"]) self.query_proj.eval() # Reverse mapping self.id2node = {v: k for k, v in self.node2id.items()} # Load KG df = pd.read_csv(csv_path, names=["Subject", "Predicate", "Object"]) self.kg = KnowledgeGraph(df, self.node2id) self.kg.build_graph() self.kg.build_adjacency() # Load embedding model (to encode the query), embedding dimension is 384 for all-MiniLM-L6-v2 model self.query_embedder = TextEmbedder(model_name=model_name) self.top_k = topk self.sem_contr= semantic_contribute self.score_tol_factor = score_tolerance_factor # Initialize Qwen self.llm = QwenLLM() self.verbose = verbose # ---------- HYBRID RETRIEVAL ---------- def retrieve(self, query: str): """ Function to embed the query and retrieve context-relevant nodes\n This is equivalent implementation to the BaseRetrieval of LlamaIndex Parameters: + query: User query sentence + top_k: K most relevant nodes + alpha: Contribution of the semantic similarity to the hybrid score """ # Use the same embedder in training stage to encode the query # Encode + normalize query (semantic space) q_emb = self.query_embedder.encode([query])[0] q_emb = F.normalize(q_emb, dim=0) # Project query into structural space q_struct = F.normalize(self.query_proj(q_emb), dim=0) # Compute similarities sim_sem = F.cosine_similarity(q_emb, self.x_norm) sim_struct = F.cosine_similarity(q_struct, self.h_norm) # Hybrid score scores = self.sem_contr * sim_sem + (1 - self.sem_contr) * sim_struct topk = torch.topk(scores, k=self.top_k) topk_indices = topk.indices.tolist() #-----For debugging----- if self.verbose: # Embedding correlation (global, precomputed ideally) diag_corr = (self.x_norm * self.h_norm).sum(dim=1).mean() # Score statistics sem_mean = sim_sem.mean().item() struct_mean = sim_struct.mean().item() sem_std = sim_sem.std().item() struct_std = sim_struct.std().item() # Top-k contribution topk_sem = sim_sem[topk.indices] topk_struct = sim_struct[topk.indices] sem_contrib = topk_sem.mean().item() struct_contrib = topk_struct.mean().item() # Dominance ratio dominance = struct_contrib / (sem_contrib + 1e-8) print("=== DIAGNOSTICS ===") print(f"Embedding corr (h_struct ยท h_text): {diag_corr:.4f}") print(f"Semantic mean/std: {sem_mean:.4f} / {sem_std:.4f}") print(f"Structural mean/std: {struct_mean:.4f} / {struct_std:.4f}") print(f"Top-k semantic avg: {sem_contrib:.4f}") print(f"Top-k structural avg: {struct_contrib:.4f}") print(f"Structural/Semantic ratio: {dominance:.4f}") print("===================") return topk_indices, scores.detach().cpu() # ---------- CONTEXT BUILD ---------- def build_context(self, nodes: list, scores: torch.Tensor): """ Build KG-based context for LLM with score-aware selection """ context = [] for node_id in nodes: node = self.id2node[node_id] # Retrieve neighbors of each topK node neighbor_rels = self.kg.get_neighbors(node) # Get the corresponding score of each neighbor neighbor_scores = torch.tensor([ scores[self.node2id[o]] for (_, _, o) in neighbor_rels ]) if neighbor_scores.shape[0] == 0: continue # Compute lower bound scoring threshold to remove irrelevant neighbors in an adaptive way # to preserve triplets whose Object's score >= lower_threshold mean = neighbor_scores.mean() std = neighbor_scores.std() lower_threshold = mean + self.score_tol_factor * std # Remove triplets with insufficient Object's score valid_neighbors = [(r, o) for (s, r, o) in neighbor_rels if scores[self.node2id[o]] >= lower_threshold] if len(valid_neighbors) == 0: # Fallback: keep at least one fact for this node best_idx = torch.argmax(neighbor_scores) valid_neighbors = [neighbor_rels[best_idx][1:]] # Compress triplets into multi-facts format: # + Subject: # - Predicate_1 Object_1 # - Predicate_2 Object_2 # - ... compressed_fact = "+ " + node.capitalize() + ":\n\t- " + "\n\t- ".join([' '.join(fact) for fact in valid_neighbors]) context.append(compressed_fact) return "\n".join(context) # ---------- FULL PIPELINE ---------- def answer(self, query, stream=True): """ Main function to response a user query """ node_ids, scores = self.retrieve(query) context = self.build_context(node_ids, scores) # -----For debugging----- if self.verbose: print("===" * 30) print("Context:") print(context) print("===" * 30) prompt = f""" You are given medical and biological facts: {context} Question: {query} Instructions: - Use the facts internally. - Use only clear facts. - Skip any unclear or inconsistent fact immediately. - Do not evaluate, interpret, or reason about facts. - Do not mention skipped facts. - You may reuse important technical terms from the facts (e.g., domain-specific words). - Write a natural answer instead of reproducing the input structure. - Do NOT mention the word "fact" or refer to the given data. - Answer as if you already know the information. - Ignore unclear or inconsistent facts silently. - Do not verify or evaluate correctness. - If the facts do not cover the question, just express that you haven't taught about it yet. Output format (strict): Follow this example exactly Children should eat fruits and vegetables. They provide essential nutrients and support healthy growth. Rules: - No bullet points or lists. - No restating input text. - No extra text before or after the answer block. Start immediately: """ output = self.llm.generate(prompt, stream) return output