rohitsar567 Claude Opus 4.7 (1M context) commited on
Commit
244fa54
·
1 Parent(s): a2ce565

feat(rag): tools/ingest_kb_summaries.py — embed kb/policies/*.md (KI-019)

Browse files

224 hand-written policy summaries in kb/policies/<id>.md were never
embedded. Retrieval matched against raw PDF prose instead of clean
natural-language section text. Bad for "what does X cover?" queries.

New ingester:
- Parses YAML frontmatter for canonical policy_id, insurer_slug,
policy_name, uin_code.
- Splits body at H2 boundaries (Identity, Eligibility, Waiting periods,
Coverage, Sub-limits, Exclusions, etc.) — one section per
schema-field family.
- SKIPS sections where every H3 field is "_not specified_" (no
information content).
- Embeds each surviving section via BGE-small.
- Inserts to same Chroma `policies` collection with doc_type="summary"
and kb_section=<H2 title> for facet filtering.

Expected impact: retrieve.py now has summary chunks alongside raw
wordings — better recall for natural-language Q&A, cleaner citations
(one summary section vs 3-4 raw chunks per topic).

Ingestion currently running locally; once complete, will re-upload
Chroma to HF Dataset so live HF Space picks up summary chunks on the
next factory-reboot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files changed (1) hide show
  1. tools/ingest_kb_summaries.py +175 -0
tools/ingest_kb_summaries.py ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Ingest kb/policies/*.md natural-language summaries into Chroma (KI-019).
2
+
3
+ Why this exists:
4
+ The current retrieval index has 5,401 chunks from raw PDF wordings —
5
+ dense legalese that's hard to match against natural-language user
6
+ questions like "what does Care Supreme cover?". Meanwhile, every
7
+ policy has a hand-written kb/policies/<policy_id>.md summary
8
+ organized by schema field (Eligibility / Waiting Periods / Coverage /
9
+ etc.) — perfect retrieval targets, but never embedded.
10
+
11
+ Strategy:
12
+ For each kb/policies/*.md:
13
+ 1. Parse the YAML frontmatter for canonical (policy_id, insurer_slug,
14
+ policy_name, uin_code).
15
+ 2. Split the body at H2 boundaries (one section per schema "family"
16
+ — Identity, Eligibility, Waiting periods, Sub-limits, etc.).
17
+ 3. SKIP sections where every field's Value is "_not specified_"
18
+ (no information content, dead weight in the index).
19
+ 4. Embed each surviving section via BGE-small.
20
+ 5. Insert into Chroma with `doc_type="summary"`, `kb_section=<H2 title>`
21
+ so retrieval can prefer summary chunks when intent is natural-Q&A.
22
+
23
+ Output: ~6-10 chunks per policy × 224 policies ≈ 1,500-2,000 summary
24
+ chunks added to the same `policies` Chroma collection.
25
+
26
+ Run AFTER tools/ingest_reviews.py:
27
+ PYTHONPATH=. python tools/ingest_kb_summaries.py
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import asyncio
32
+ import re
33
+ from pathlib import Path
34
+ from typing import Any
35
+
36
+ import chromadb
37
+ from chromadb.config import Settings
38
+
39
+ from backend.config import settings
40
+ from backend.providers.local_embeddings import LocalEmbeddings
41
+
42
+
43
+ ROOT = Path(__file__).resolve().parent.parent
44
+ KB_DIR = ROOT / "kb" / "policies"
45
+
46
+
47
+ def parse_frontmatter(text: str) -> tuple[dict, str]:
48
+ """Return (frontmatter_dict, body_after_frontmatter)."""
49
+ if not text.startswith("---"):
50
+ return {}, text
51
+ end = text.find("\n---", 3)
52
+ if end == -1:
53
+ return {}, text
54
+ fm_text = text[3:end].strip()
55
+ body = text[end + 4:].lstrip()
56
+ meta: dict[str, str] = {}
57
+ for line in fm_text.splitlines():
58
+ if ":" in line:
59
+ k, _, v = line.partition(":")
60
+ meta[k.strip()] = v.strip().strip('"').strip("'")
61
+ return meta, body
62
+
63
+
64
+ def split_h2_sections(body: str) -> list[dict]:
65
+ """Return list of {title, content} for each `## ...` section.
66
+ Drops sections where every H3 field is `_not specified_`."""
67
+ sections = re.split(r"\n##\s+", body)
68
+ out: list[dict] = []
69
+ for sec in sections:
70
+ sec = sec.strip()
71
+ if not sec:
72
+ continue
73
+ if "\n" in sec:
74
+ title, _, content = sec.partition("\n")
75
+ else:
76
+ title, content = sec, ""
77
+ title = title.strip()
78
+ # Skip the very first header-only block (insurer/policy header)
79
+ if title.startswith("#") or "Insurer:" in title or "Policy ID:" in content[:200]:
80
+ continue
81
+ # Skip sections with no real information (all `_not specified_`)
82
+ if re.search(r"\*\*Value:\*\*\s+_?[A-Za-z0-9]", content):
83
+ out.append({"title": title, "content": content.strip()})
84
+ return out
85
+
86
+
87
+ def chunk_text(title: str, content: str, policy_meta: dict) -> str:
88
+ """Build the embedded text for one section."""
89
+ header = (
90
+ f"POLICY SUMMARY — {policy_meta.get('policy_name','?')} "
91
+ f"({policy_meta.get('insurer_name', policy_meta.get('insurer_slug','?'))})\n"
92
+ f"Section: {title}\n"
93
+ )
94
+ return header + content
95
+
96
+
97
+ async def main() -> None:
98
+ files = sorted(KB_DIR.glob("*.md"))
99
+ if not files:
100
+ print(f"No markdown files in {KB_DIR}")
101
+ return
102
+
103
+ client = chromadb.PersistentClient(
104
+ path=str(settings.VECTORS_DIR),
105
+ settings=Settings(anonymized_telemetry=False),
106
+ )
107
+ coll = client.get_or_create_collection(
108
+ name="policies",
109
+ metadata={"hnsw:space": "cosine"},
110
+ )
111
+ embedder = LocalEmbeddings()
112
+
113
+ total_policies = 0
114
+ total_chunks = 0
115
+ skipped = 0
116
+
117
+ for f in files:
118
+ try:
119
+ text = f.read_text(encoding="utf-8")
120
+ except Exception as e:
121
+ print(f" SKIP {f.name}: {type(e).__name__}: {e}")
122
+ skipped += 1
123
+ continue
124
+
125
+ meta, body = parse_frontmatter(text)
126
+ policy_id = meta.get("policy_id") or f.stem
127
+ sections = split_h2_sections(body)
128
+ if not sections:
129
+ skipped += 1
130
+ continue
131
+
132
+ # Delete any prior summary chunks for this policy (idempotent across re-runs)
133
+ parent_id = f"summary_{policy_id}"
134
+ try:
135
+ coll.delete(where={"policy_id": parent_id})
136
+ except Exception:
137
+ pass
138
+
139
+ texts = [chunk_text(s["title"], s["content"], meta) for s in sections]
140
+ try:
141
+ vecs = await embedder.embed(texts, input_type="document")
142
+ except Exception as e:
143
+ print(f" ERR {policy_id}: embed failed: {type(e).__name__}: {e}")
144
+ skipped += 1
145
+ continue
146
+
147
+ ids = []
148
+ metadatas = []
149
+ for i, s in enumerate(sections):
150
+ section_slug = re.sub(r"[^a-z0-9]+", "_", s["title"].lower()).strip("_")
151
+ ids.append(f"{parent_id}_{section_slug}_{i}")
152
+ metadatas.append({
153
+ "policy_id": parent_id,
154
+ "insurer_slug": meta.get("insurer_slug", ""),
155
+ "policy_name": meta.get("policy_name", policy_id),
156
+ "doc_type": "summary",
157
+ "kb_section": s["title"],
158
+ "source_url": "",
159
+ "page_start": 0,
160
+ "page_end": 0,
161
+ "chunk_idx": i,
162
+ "local_path": str(f.relative_to(ROOT)),
163
+ })
164
+ coll.add(ids=ids, documents=texts, embeddings=vecs, metadatas=metadatas)
165
+ total_policies += 1
166
+ total_chunks += len(sections)
167
+ if total_policies % 25 == 0:
168
+ print(f" ... {total_policies:>3d} policies done | {total_chunks:>4d} chunks")
169
+
170
+ print()
171
+ print(f"Done. Policies indexed: {total_policies}, chunks added: {total_chunks}, skipped: {skipped}, total files: {len(files)}")
172
+
173
+
174
+ if __name__ == "__main__":
175
+ asyncio.run(main())