GonzaViss commited on
Commit
b248094
·
1 Parent(s): 5e66d92

feat(rag): re-chunk rulebook by rule number for precise retrieval

Browse files

Parser now emits a separate paragraph per top-level rule (NNN.) so
each keyword and sub-rule becomes its own chunk instead of one 30K-char
blob covering the entire section. Adds a fallback rule-split in
_chunk_section for any section that still arrives as a single oversized
paragraph. Corpus v1.2.0 re-ingested with 464 chunks (201 rulebook).

Also boosts official sources (rulebook, tournament_rules, patch_notes)
by 5% in RRF fusion so base-rule chunks rank above errata on tie.

backend/app/rag/retrieval.py CHANGED
@@ -90,6 +90,10 @@ def fts_search(
90
  ]
91
 
92
 
 
 
 
 
93
  def _rrf_fuse(
94
  vector_results: list[Chunk],
95
  fts_results: list[Chunk],
@@ -102,6 +106,9 @@ def _rrf_fuse(
102
  1 / (rrf_k + rank_l(d)), where rank_l(d) is 1-based (only counted if d
103
  appears in l).
104
 
 
 
 
105
  Dedup key: chunk.id.
106
  Tie-break: chunk that appeared in vector_results wins (stable).
107
  Preserves original similarity from vector side; FTS-only chunks keep 0.0.
@@ -115,13 +122,15 @@ def _rrf_fuse(
115
 
116
  for rank_0, chunk in enumerate(vector_results):
117
  rank = rank_0 + 1 # 1-based
118
- scores[chunk.id] = scores.get(chunk.id, 0.0) + 1.0 / (rrf_k + rank)
 
119
  chunks_by_id[chunk.id] = chunk # vector side wins for Chunk object
120
  in_vector.add(chunk.id)
121
 
122
  for rank_0, chunk in enumerate(fts_results):
123
  rank = rank_0 + 1 # 1-based
124
- scores[chunk.id] = scores.get(chunk.id, 0.0) + 1.0 / (rrf_k + rank)
 
125
  if chunk.id not in chunks_by_id:
126
  chunks_by_id[chunk.id] = chunk # FTS-only: use FTS chunk (similarity=0.0)
127
 
 
90
  ]
91
 
92
 
93
+ _OFFICIAL_SOURCES = frozenset({"rulebook", "tournament_rules", "patch_notes"})
94
+ _OFFICIAL_BOOST = 1.05 # official rule sources get a 5% score boost over errata
95
+
96
+
97
  def _rrf_fuse(
98
  vector_results: list[Chunk],
99
  fts_results: list[Chunk],
 
106
  1 / (rrf_k + rank_l(d)), where rank_l(d) is 1-based (only counted if d
107
  appears in l).
108
 
109
+ Rulebook chunks receive a _RULEBOOK_BOOST multiplier so base-rule chunks
110
+ rank above errata chunks when scores are comparable.
111
+
112
  Dedup key: chunk.id.
113
  Tie-break: chunk that appeared in vector_results wins (stable).
114
  Preserves original similarity from vector side; FTS-only chunks keep 0.0.
 
122
 
123
  for rank_0, chunk in enumerate(vector_results):
124
  rank = rank_0 + 1 # 1-based
125
+ boost = _OFFICIAL_BOOST if chunk.source_type in _OFFICIAL_SOURCES else 1.0
126
+ scores[chunk.id] = scores.get(chunk.id, 0.0) + boost / (rrf_k + rank)
127
  chunks_by_id[chunk.id] = chunk # vector side wins for Chunk object
128
  in_vector.add(chunk.id)
129
 
130
  for rank_0, chunk in enumerate(fts_results):
131
  rank = rank_0 + 1 # 1-based
132
+ boost = _OFFICIAL_BOOST if chunk.source_type in _OFFICIAL_SOURCES else 1.0
133
+ scores[chunk.id] = scores.get(chunk.id, 0.0) + boost / (rrf_k + rank)
134
  if chunk.id not in chunks_by_id:
135
  chunks_by_id[chunk.id] = chunk # FTS-only: use FTS chunk (similarity=0.0)
136
 
backend/scripts/add_corpus_v1_2_0.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Migración de corpus v1.1.0 → v1.2.0 para chunks no-rulebook.
3
+
4
+ Copia errata, tournament_rules y patch_notes desde v1.1.0.
5
+ El rulebook se re-ingestea por separado con el parser re-chunkeado.
6
+ """
7
+ from dotenv import load_dotenv
8
+
9
+ load_dotenv()
10
+
11
+ import os
12
+
13
+ import psycopg2
14
+
15
+ conn = psycopg2.connect(os.getenv("DATABASE_URL"))
16
+ cur = conn.cursor()
17
+ cur.execute("""
18
+ INSERT INTO corpus_chunks
19
+ (id, content, embedding, source_type, source_document,
20
+ section, parent_section, corpus_version, ingested_at)
21
+ SELECT
22
+ id, content, embedding, source_type, source_document,
23
+ section, parent_section, 'v1.2.0', NOW()
24
+ FROM corpus_chunks
25
+ WHERE corpus_version = 'v1.1.0'
26
+ AND source_type != 'rulebook'
27
+ ON CONFLICT (id) DO NOTHING
28
+ """)
29
+ conn.commit()
30
+ print(f"Copiados: {cur.rowcount} chunks no-rulebook a v1.2.0")
31
+ conn.close()
backend/scripts/ingest.py CHANGED
@@ -26,10 +26,13 @@ DATABASE_URL = os.getenv("DATABASE_URL")
26
  EMBED_MODEL = "BAAI/bge-m3"
27
  CHUNK_SIZE = 512 # tokens aproximados
28
  CHUNK_OVERLAP = 50
 
29
 
30
  SOURCES = [
31
  ("data/processed/rulebook.md", "rulebook"),
32
  ("data/processed/errata.md", "errata"),
 
 
33
  ]
34
 
35
 
@@ -69,6 +72,10 @@ def _chunk_section(section: dict, source_type: str, source_document: str) -> lis
69
 
70
  # Dividir en párrafos y agrupar respetando el tamaño
71
  paragraphs = [p.strip() for p in content.split("\n\n") if p.strip()]
 
 
 
 
72
  chunks = []
73
  current: list[str] = []
74
  current_tokens = 0
 
26
  EMBED_MODEL = "BAAI/bge-m3"
27
  CHUNK_SIZE = 512 # tokens aproximados
28
  CHUNK_OVERLAP = 50
29
+ _RULE_SPLIT = re.compile(r"(?=\b\d{3,}\.\s)")
30
 
31
  SOURCES = [
32
  ("data/processed/rulebook.md", "rulebook"),
33
  ("data/processed/errata.md", "errata"),
34
+ ("data/processed/tournament_rules.md", "tournament_rules"),
35
+ ("data/processed/patch_notes.md", "patch_notes"),
36
  ]
37
 
38
 
 
72
 
73
  # Dividir en párrafos y agrupar respetando el tamaño
74
  paragraphs = [p.strip() for p in content.split("\n\n") if p.strip()]
75
+
76
+ # Fallback: si sigue siendo 1 párrafo gigante, dividir por número de regla (NNN.)
77
+ if len(paragraphs) <= 1 and _approx_tokens(content) > CHUNK_SIZE:
78
+ paragraphs = [p.strip() for p in _RULE_SPLIT.split(content) if p.strip()]
79
  chunks = []
80
  current: list[str] = []
81
  current_tokens = 0
backend/scripts/parse_rulebook.py CHANGED
@@ -7,10 +7,14 @@ Detecta headers por tamaño de fuente relativo al body text:
7
  - > 1.2x body → H3 (subsección)
8
  - else → párrafo
9
  """
 
 
10
  import pymupdf
11
  from pathlib import Path
12
  from statistics import mode
13
 
 
 
14
 
15
  def _extract_spans(doc: pymupdf.Document) -> list[dict]:
16
  spans = []
@@ -59,6 +63,8 @@ def _spans_to_markdown(spans: list[dict], body_size: float) -> str:
59
  for span in spans:
60
  kind = _classify(span["size"], body_size)
61
  if kind == "body":
 
 
62
  current_body.append(span["text"])
63
  else:
64
  flush_body()
 
7
  - > 1.2x body → H3 (subsección)
8
  - else → párrafo
9
  """
10
+ import re
11
+
12
  import pymupdf
13
  from pathlib import Path
14
  from statistics import mode
15
 
16
+ _RULE_BOUNDARY = re.compile(r"^\d{3,}\.")
17
+
18
 
19
  def _extract_spans(doc: pymupdf.Document) -> list[dict]:
20
  spans = []
 
63
  for span in spans:
64
  kind = _classify(span["size"], body_size)
65
  if kind == "body":
66
+ if current_body and _RULE_BOUNDARY.match(span["text"]):
67
+ flush_body()
68
  current_body.append(span["text"])
69
  else:
70
  flush_body()
backend/tests/test_add_corpus_v1_2_0.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for add_corpus_v1_2_0 migration script."""
2
+ import sys
3
+ from unittest.mock import MagicMock, patch
4
+
5
+
6
+ def _run_migration(database_url: str = "postgresql://fake"):
7
+ if "scripts.add_corpus_v1_2_0" in sys.modules:
8
+ del sys.modules["scripts.add_corpus_v1_2_0"]
9
+ mock_conn = MagicMock()
10
+ mock_cur = MagicMock()
11
+ mock_conn.cursor.return_value = mock_cur
12
+ mock_cur.rowcount = 150
13
+ with patch("psycopg2.connect", return_value=mock_conn), \
14
+ patch.dict("os.environ", {"DATABASE_URL": database_url}):
15
+ import scripts.add_corpus_v1_2_0 # noqa: F401
16
+ return mock_conn, mock_cur
17
+
18
+
19
+ def test_migration_inserts_non_rulebook_chunks_from_v1_1_0():
20
+ _, mock_cur = _run_migration()
21
+ sql = mock_cur.execute.call_args[0][0]
22
+ assert "v1.1.0" in sql
23
+ assert "v1.2.0" in sql
24
+ assert "rulebook" in sql
25
+
26
+
27
+ def test_migration_commits_transaction():
28
+ mock_conn, _ = _run_migration()
29
+ mock_conn.commit.assert_called_once()
30
+
31
+
32
+ def test_migration_closes_connection():
33
+ mock_conn, _ = _run_migration()
34
+ mock_conn.close.assert_called_once()
backend/tests/test_ingest.py CHANGED
@@ -192,3 +192,29 @@ def test_build_chunks_source_document_is_stem(tmp_path):
192
  f.write_text(md, encoding="utf-8")
193
  chunks = build_chunks(str(f), "errata")
194
  assert all(c["source_document"] == "errata" for c in chunks)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
192
  f.write_text(md, encoding="utf-8")
193
  chunks = build_chunks(str(f), "errata")
194
  assert all(c["source_document"] == "errata" for c in chunks)
195
+
196
+
197
+ # ---------------------------------------------------------------------------
198
+ # _chunk_section — fallback rule-split
199
+ # ---------------------------------------------------------------------------
200
+
201
+ def _make_giant_rule_block() -> dict:
202
+ """Un único párrafo sin \\n\\n con números de regla embebidos (~3200 tokens)."""
203
+ rule_block = " ".join(
204
+ f"{800 + i}. This is a long rule description that takes up space. " * 15
205
+ for i in range(8)
206
+ )
207
+ return {"content": rule_block, "header": "Keywords", "level": 2}
208
+
209
+
210
+ def test_chunk_section_falls_back_to_rule_split_for_single_huge_paragraph():
211
+ section = _make_giant_rule_block()
212
+ chunks = _chunk_section(section, "rulebook", "rulebook.md")
213
+ assert len(chunks) > 1, "Fallback rule-split debe generar múltiples chunks"
214
+
215
+
216
+ def test_chunk_section_fallback_chunks_contain_rule_numbers():
217
+ section = _make_giant_rule_block()
218
+ chunks = _chunk_section(section, "rulebook", "rulebook.md")
219
+ rule_nums = {c["content"].split(".")[0].strip() for c in chunks}
220
+ assert any(n.isdigit() and len(n) == 3 for n in rule_nums)
backend/tests/test_parsers.py CHANGED
@@ -1,5 +1,5 @@
1
  from pathlib import Path
2
- from scripts.parse_rulebook import parse_rulebook
3
 
4
  FIXTURES = Path(__file__).parent / "fixtures"
5
  SAMPLE_PDF = FIXTURES / "rulebook_sample.pdf"
@@ -41,3 +41,42 @@ def test_parse_rulebook_matches_expected_output():
41
  result = parse_rulebook(SAMPLE_PDF)
42
  expected = EXPECTED_MD.read_text(encoding="utf-8")
43
  assert result.strip() == expected.strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from pathlib import Path
2
+ from scripts.parse_rulebook import parse_rulebook, _spans_to_markdown
3
 
4
  FIXTURES = Path(__file__).parent / "fixtures"
5
  SAMPLE_PDF = FIXTURES / "rulebook_sample.pdf"
 
41
  result = parse_rulebook(SAMPLE_PDF)
42
  expected = EXPECTED_MD.read_text(encoding="utf-8")
43
  assert result.strip() == expected.strip()
44
+
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # _spans_to_markdown — rule boundary splitting
48
+ # ---------------------------------------------------------------------------
49
+
50
+ def _body_span(text: str, size: float = 10.0) -> dict:
51
+ return {"text": text, "size": size}
52
+
53
+
54
+ def test_spans_to_markdown_splits_rules_into_separate_paragraphs():
55
+ spans = [
56
+ _body_span("805. Accelerate (Action): blah blah blah."),
57
+ _body_span("806. Ambush (Passive): blah blah blah."),
58
+ _body_span("807. Armor N (Passive): blah blah blah."),
59
+ ]
60
+ result = _spans_to_markdown(spans, body_size=10.0)
61
+ paragraphs = [p for p in result.split("\n\n") if p.strip()]
62
+ assert len(paragraphs) == 3
63
+
64
+
65
+ def test_spans_to_markdown_non_rule_body_merges():
66
+ spans = [
67
+ _body_span("This is a sentence."),
68
+ _body_span("This continues the same paragraph."),
69
+ ]
70
+ result = _spans_to_markdown(spans, body_size=10.0)
71
+ paragraphs = [p for p in result.split("\n\n") if p.strip()]
72
+ assert len(paragraphs) == 1
73
+
74
+
75
+ def test_spans_to_markdown_rule_content_is_preserved():
76
+ spans = [
77
+ _body_span("805. Accelerate (Action): Move this unit."),
78
+ _body_span("806. Ambush (Passive): React to attack."),
79
+ ]
80
+ result = _spans_to_markdown(spans, body_size=10.0)
81
+ assert "805. Accelerate" in result
82
+ assert "806. Ambush" in result