feat(rag): re-chunk rulebook by rule number for precise retrieval
Browse filesParser 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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
|