Spaces:
Sleeping
feat(rag): tools/ingest_kb_summaries.py — embed kb/policies/*.md (KI-019)
Browse files224 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>
- tools/ingest_kb_summaries.py +175 -0
|
@@ -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())
|