ashe0042 commited on
Commit
d53dec4
·
1 Parent(s): b293818

Config 4: KG-augmented RAG with cross-reference graph expansion (KG working)

Browse files
Files changed (1) hide show
  1. src/configs/kg_augmented.py +237 -0
src/configs/kg_augmented.py ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Config 4: KG-augmented RAG. RRF retrieve -> expand via cross-reference KG -> generate.
3
+
4
+ paragraph_id is only unique within a source (e.g. "5" exists in both
5
+ corporations_act_2001 and cps234), so the KG is keyed by "source:paragraph_id"
6
+ rather than paragraph_id alone to avoid cross-source collisions.
7
+ """
8
+
9
+ import os
10
+ import re
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ from dotenv import load_dotenv
15
+ from openai import OpenAI
16
+
17
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
18
+
19
+ from db import get_connection
20
+ from retrieval import embed_query, rrf_search
21
+
22
+ load_dotenv()
23
+
24
+ GENERATION_MODEL = "gpt-4o"
25
+ CONFIG_NAME = "kg_augmented"
26
+ MAX_CONTEXT_CHUNKS = 10
27
+
28
+ OPENAI_API_KEY = os.environ["OPENAI_API_KEY"]
29
+
30
+ client = OpenAI(api_key=OPENAI_API_KEY)
31
+
32
+ SYSTEM_PROMPT = (
33
+ "You are a precise legal research assistant for Australian financial regulation. "
34
+ "Answer using ONLY the provided context. "
35
+ "Every claim must cite the exact source and paragraph ID in the format "
36
+ "[source | paragraph_id]. The source name must be the exact source identifier "
37
+ "from the context (e.g. corporations_act_2001, cps234), not the word 'source'. "
38
+ "If the context does not support the answer, "
39
+ "say 'Not found in retrieved context.' "
40
+ "Context marked [KG-EXPANDED] provides supporting provisions referenced by "
41
+ "the primary retrieved provisions."
42
+ )
43
+
44
+ SECTION_REF_PATTERN = re.compile(r"section\s+(\d+[A-Z]*)", re.IGNORECASE)
45
+ SUBSECTION_REF_PATTERN = re.compile(r"subsection\s+\((\d+)\)", re.IGNORECASE)
46
+ CPS_REF_PATTERN = re.compile(r"CPS\s+(\d+)", re.IGNORECASE)
47
+ PARAGRAPH_REF_PATTERN = re.compile(r"paragraph\s+(\d+)", re.IGNORECASE)
48
+
49
+
50
+ def _base_section_id(paragraph_id: str) -> str:
51
+ return paragraph_id.split("(")[0]
52
+
53
+
54
+ def build_kg(conn) -> dict:
55
+ with conn.cursor() as cur:
56
+ cur.execute(
57
+ "SELECT source, paragraph_id, text FROM corpus_chunks WHERE paragraph_id NOT LIKE %s",
58
+ ("%_SCHEDULE",),
59
+ )
60
+ rows = cur.fetchall()
61
+
62
+ ids_by_source: dict[str, set] = {}
63
+ base_index_by_source: dict[str, dict] = {}
64
+ for source, paragraph_id, _ in rows:
65
+ ids_by_source.setdefault(source, set()).add(paragraph_id)
66
+ base_index_by_source.setdefault(source, {}).setdefault(
67
+ _base_section_id(paragraph_id), []
68
+ ).append(paragraph_id)
69
+
70
+ cps_source_by_number = {}
71
+ for source in ids_by_source:
72
+ match = re.fullmatch(r"cps(\d+)", source, re.IGNORECASE)
73
+ if match:
74
+ cps_source_by_number[match.group(1)] = source
75
+
76
+ kg: dict[str, list[str]] = {}
77
+
78
+ for source, paragraph_id, text in rows:
79
+ referenced = set()
80
+ base_id = _base_section_id(paragraph_id)
81
+
82
+ for match in SECTION_REF_PATTERN.finditer(text):
83
+ candidate = match.group(1)
84
+ if candidate == base_id:
85
+ continue
86
+ for full_id in base_index_by_source.get(source, {}).get(candidate, ()):
87
+ referenced.add(f"{source}:{full_id}")
88
+
89
+ for match in SUBSECTION_REF_PATTERN.finditer(text):
90
+ candidate = f"{base_id}({match.group(1)})"
91
+ if candidate != paragraph_id and candidate in ids_by_source.get(source, ()):
92
+ referenced.add(f"{source}:{candidate}")
93
+
94
+ for match in CPS_REF_PATTERN.finditer(text):
95
+ ref_source = cps_source_by_number.get(match.group(1))
96
+ if ref_source and ref_source != source:
97
+ first_id = min(ids_by_source[ref_source], key=lambda pid: (len(pid), pid))
98
+ referenced.add(f"{ref_source}:{first_id}")
99
+
100
+ for match in PARAGRAPH_REF_PATTERN.finditer(text):
101
+ candidate = match.group(1)
102
+ if candidate != paragraph_id and candidate in ids_by_source.get(source, ()):
103
+ referenced.add(f"{source}:{candidate}")
104
+
105
+ if referenced:
106
+ kg[f"{source}:{paragraph_id}"] = sorted(referenced)
107
+
108
+ return kg
109
+
110
+
111
+ def _fetch_chunk(conn, source: str, paragraph_id: str) -> dict | None:
112
+ with conn.cursor() as cur:
113
+ cur.execute(
114
+ "SELECT id, text FROM corpus_chunks WHERE source = %s AND paragraph_id = %s LIMIT 1",
115
+ (source, paragraph_id),
116
+ )
117
+ row = cur.fetchone()
118
+ return {"id": row[0], "text": row[1]} if row else None
119
+
120
+
121
+ def expand_with_kg(chunks: list[dict], kg: dict, conn, max_hops: int = 1) -> list[dict]:
122
+ expanded = []
123
+ seen = set()
124
+
125
+ for chunk in chunks:
126
+ seen.add((chunk["source"], chunk["paragraph_id"]))
127
+ expanded.append(
128
+ {
129
+ "id": chunk["id"],
130
+ "source": chunk["source"],
131
+ "paragraph_id": chunk["paragraph_id"],
132
+ "text": chunk["text"],
133
+ "kg_expanded": False,
134
+ "referenced_by": None,
135
+ }
136
+ )
137
+
138
+ frontier = [(chunk["source"], chunk["paragraph_id"]) for chunk in chunks]
139
+
140
+ for _ in range(max_hops):
141
+ if len(expanded) >= MAX_CONTEXT_CHUNKS:
142
+ break
143
+ next_frontier = []
144
+ for source, paragraph_id in frontier:
145
+ if len(expanded) >= MAX_CONTEXT_CHUNKS:
146
+ break
147
+ for ref in kg.get(f"{source}:{paragraph_id}", []):
148
+ if len(expanded) >= MAX_CONTEXT_CHUNKS:
149
+ break
150
+ ref_source, ref_paragraph_id = ref.split(":", 1)
151
+ if (ref_source, ref_paragraph_id) in seen:
152
+ continue
153
+ row = _fetch_chunk(conn, ref_source, ref_paragraph_id)
154
+ if row is None:
155
+ continue
156
+ seen.add((ref_source, ref_paragraph_id))
157
+ expanded.append(
158
+ {
159
+ "id": row["id"],
160
+ "source": ref_source,
161
+ "paragraph_id": ref_paragraph_id,
162
+ "text": row["text"],
163
+ "kg_expanded": True,
164
+ "referenced_by": paragraph_id,
165
+ }
166
+ )
167
+ next_frontier.append((ref_source, ref_paragraph_id))
168
+ frontier = next_frontier
169
+
170
+ return expanded
171
+
172
+
173
+ _kg_conn = get_connection()
174
+ KG = build_kg(_kg_conn)
175
+ _kg_conn.close()
176
+
177
+
178
+ def _format_chunk(chunk: dict) -> str:
179
+ if chunk["kg_expanded"]:
180
+ return f"[KG-EXPANDED | {chunk['source']} | {chunk['paragraph_id']}]\n{chunk['text']}\n"
181
+ return f"[{chunk['source']} | {chunk['paragraph_id']}]\n{chunk['text']}\n"
182
+
183
+
184
+ def run(query: str, source_filter: list[str] | None = None) -> dict:
185
+ query_embedding = embed_query(query)
186
+ retrieved_chunks = rrf_search(query, query_embedding, source_filter=source_filter, top_k=5)
187
+
188
+ conn = get_connection()
189
+ expanded_chunks = expand_with_kg(retrieved_chunks, KG, conn, max_hops=1)
190
+ conn.close()
191
+
192
+ context = "\n".join(_format_chunk(chunk) for chunk in expanded_chunks)
193
+
194
+ user_prompt = context + "\n\nQuestion: " + query
195
+
196
+ response = client.chat.completions.create(
197
+ model=GENERATION_MODEL,
198
+ messages=[
199
+ {"role": "system", "content": SYSTEM_PROMPT},
200
+ {"role": "user", "content": user_prompt},
201
+ ],
202
+ )
203
+ answer = response.choices[0].message.content
204
+
205
+ return {
206
+ "query": query,
207
+ "answer": answer,
208
+ "retrieved_chunks": [
209
+ {
210
+ "source": chunk["source"],
211
+ "paragraph_id": chunk["paragraph_id"],
212
+ "text": chunk["text"][:200],
213
+ "kg_expanded": chunk["kg_expanded"],
214
+ "referenced_by": chunk["referenced_by"],
215
+ }
216
+ for chunk in expanded_chunks
217
+ ],
218
+ "model": GENERATION_MODEL,
219
+ "config_name": CONFIG_NAME,
220
+ }
221
+
222
+
223
+ if __name__ == "__main__":
224
+ smoke_query = "What are the general obligations of a financial services licensee?"
225
+
226
+ result = run(smoke_query)
227
+
228
+ print(result["answer"])
229
+ print("\nRetrieved paragraph IDs:")
230
+ for chunk in result["retrieved_chunks"]:
231
+ if chunk["kg_expanded"]:
232
+ print(
233
+ f"- [KG-EXPANDED] [{chunk['source']}] {chunk['paragraph_id']} "
234
+ f"(referenced_by={chunk['referenced_by']})"
235
+ )
236
+ else:
237
+ print(f"- [{chunk['source']}] {chunk['paragraph_id']}")