Beemer Claude Fable 5 commited on
Commit
ebba99b
·
1 Parent(s): 8878510

Bilingual corpus (phase 3) + multi-vector embeddings + language-routed retrieval

Browse files

Parallel French legislation for all 59 instruments (same-schema fra XML;
SOR->DORS URL mapping; lang='fr' chunks, art./annexe citations). English
queries never see French chunks; French queries (accent/stopword detection)
search both languages. Long chunks now embed as up to 8 windows scored by
best window -- D-memo tails reachable semantically. Reranker window 1000->
2000 (eval-held, Hit@5 +0.01). devises->espèces bridge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Files changed (5) hide show
  1. canlex/embed.py +37 -5
  2. canlex/index.py +74 -25
  3. canlex/ingest.py +55 -1
  4. canlex/rerank.py +5 -1
  5. canlex/synonyms.py +3 -0
canlex/embed.py CHANGED
@@ -39,8 +39,14 @@ def load_chunks():
39
  return chunks
40
 
41
 
 
 
 
 
 
 
42
  def embed_text(chunk):
43
- """Compact, retrieval-focused representation of one section."""
44
  # The section title is the strongest topical signal, so it is repeated to
45
  # emphasise it. Title selection is doc_type-aware (see index.topical_title):
46
  # a D-memo's marginal_note is a generic banner so its actual subject in
@@ -53,6 +59,27 @@ def embed_text(chunk):
53
  return " . ".join(p for p in parts if p)
54
 
55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  class Embedder:
57
  """Local transformer sentence-embedder: bge-small-en-v1.5 as ONNX on CPU.
58
 
@@ -122,10 +149,15 @@ def build():
122
  if not chunks:
123
  print(f"No processed data in {PROCESSED_DIR}. Run 'canlex.ingest' first.")
124
  return
125
- print(f"Embedding {len(chunks)} sections with {EMB_REPO} ...")
126
- vectors = Embedder().encode([embed_text(c) for c in chunks])
127
- ids = np.array([c["id"] for c in chunks])
128
- np.savez(EMB_PATH, ids=ids, vectors=vectors)
 
 
 
 
 
129
  print(f" {vectors.shape[0]} vectors, dim {vectors.shape[1]} -> {EMB_PATH.name}")
130
 
131
 
 
39
  return chunks
40
 
41
 
42
+ # A chunk longer than the embed window gets additional vectors for its tail,
43
+ # up to this many per chunk (an 87K-char D-memo section would otherwise need
44
+ # 40+; eight windows cover 16K chars, past which BM25 carries the tail).
45
+ _MAX_WINDOWS = 8
46
+
47
+
48
  def embed_text(chunk):
49
+ """Compact, retrieval-focused representation of one section (head only)."""
50
  # The section title is the strongest topical signal, so it is repeated to
51
  # emphasise it. Title selection is doc_type-aware (see index.topical_title):
52
  # a D-memo's marginal_note is a generic banner so its actual subject in
 
59
  return " . ".join(p for p in parts if p)
60
 
61
 
62
+ def embed_texts(chunk):
63
+ """One representation per embedding window of a chunk.
64
+
65
+ The first is the classic head representation; a chunk whose body exceeds
66
+ the embed window additionally gets tail windows (same topical-title
67
+ anchor, successive body slices), so a fact buried mid-memo is reachable
68
+ through semantic recall instead of BM25 alone. All windows share the
69
+ chunk's id -- the index scores a chunk by its best window."""
70
+ from .index import topical_title
71
+ texts = [embed_text(chunk)]
72
+ body = chunk["text"]
73
+ if len(body) > _MAX_BODY:
74
+ note = topical_title(chunk)
75
+ anchor = " . ".join(p for p in (chunk["act_short"], note) if p)
76
+ for start in range(_MAX_BODY, len(body), _MAX_BODY):
77
+ if len(texts) >= _MAX_WINDOWS:
78
+ break
79
+ texts.append(f"{anchor} . {body[start:start + _MAX_BODY]}")
80
+ return texts
81
+
82
+
83
  class Embedder:
84
  """Local transformer sentence-embedder: bge-small-en-v1.5 as ONNX on CPU.
85
 
 
149
  if not chunks:
150
  print(f"No processed data in {PROCESSED_DIR}. Run 'canlex.ingest' first.")
151
  return
152
+ texts, ids = [], []
153
+ for c in chunks:
154
+ for t in embed_texts(c):
155
+ texts.append(t)
156
+ ids.append(c["id"])
157
+ print(f"Embedding {len(chunks)} sections as {len(texts)} windows "
158
+ f"with {EMB_REPO} ...")
159
+ vectors = Embedder().encode(texts)
160
+ np.savez(EMB_PATH, ids=np.array(ids), vectors=vectors)
161
  print(f" {vectors.shape[0]} vectors, dim {vectors.shape[1]} -> {EMB_PATH.name}")
162
 
163
 
canlex/index.py CHANGED
@@ -135,6 +135,28 @@ def _stem(word):
135
  return stemmed
136
 
137
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  def _fold_accents(text):
139
  """ASCII-fold accented characters so a French or mixed query tokenizes
140
  usefully instead of shattering: the [a-z0-9] token pattern used to split
@@ -215,6 +237,9 @@ class LegislationIndex:
215
  raise RuntimeError(
216
  f"No processed legislation in {PROCESSED_DIR}. Run 'canlex.ingest' first.")
217
  self._tri_index = None # lazy; see _fuzzy_term
 
 
 
218
  self._build_bm25()
219
  self._build_note_tokens()
220
  self._build_xref()
@@ -299,20 +324,22 @@ class LegislationIndex:
299
  try:
300
  import numpy as np
301
  from .embed import Embedder
 
 
 
 
 
 
302
  with np.load(emb_path) as data:
303
- id_to_vec = dict(zip(data["ids"].tolist(), data["vectors"]))
304
- dim = int(data["vectors"].shape[1])
305
- missing = 0
306
- rows = []
307
- for c in self.chunks:
308
- vec = id_to_vec.get(c["id"])
309
- if vec is None:
310
- missing += 1
311
- rows.append(np.zeros(dim, dtype=np.float32))
312
- else:
313
- rows.append(vec)
314
  self._np = np
315
- self.vectors = np.vstack(rows)
 
 
 
316
  self.embedder = Embedder()
317
  self.semantic = True
318
  if missing:
@@ -389,17 +416,21 @@ class LegislationIndex:
389
  return scores
390
 
391
  def _semantic_ranking(self, query, allowed=None):
 
392
  qv = self.embedder.encode_query(query)
393
- sims = self.vectors @ qv
 
 
 
 
394
  if allowed is not None:
395
  # Mask out-of-scope chunks so a filtered search ranks (and reports
396
  # confidence) within its own scope, not corpus-wide.
397
- sims = self._np.where(
398
- self._np.asarray(allowed, dtype=bool), sims, -1.0)
399
  # Over-fetch 4x: the recall-stage source cap (_capped_top) drops the
400
  # surplus chunks of any one deep source, and the extra depth is what
401
  # lets other sources backfill the freed CANDIDATES slots.
402
- order = self._np.argsort(sims)[::-1][:CANDIDATES * 4]
403
  # The top cosine similarity doubles as a corpus-coverage signal: a query
404
  # the corpus cannot answer has no passage close to it.
405
  return [int(i) for i in order], float(sims.max())
@@ -591,11 +622,14 @@ class LegislationIndex:
591
  return {pos: (label, " ".join(snippet[:240].split()))
592
  for pos, (score, label, snippet) in best.items()}
593
 
594
- def _filter_ok(self, c, act, doc_type):
595
  """One predicate for both recall masking and the late result filter.
596
  The act filter matches short name, code, or full name -- an agent
597
  passing 'Immigration and Refugee Protection Act' should not get
598
- silence."""
 
 
 
599
  if act:
600
  a = act.lower()
601
  if a not in (c["act_short"].lower(), c["act_code"].lower(),
@@ -610,13 +644,21 @@ class LegislationIndex:
610
  # Expand legal abbreviations (PRRA, H&C, ...) into statutory wording for
611
  # the recall stages; the reranker sees the original AND expanded forms.
612
  expanded = expand_query(query)
 
613
  confidence = None
614
  fused = defaultdict(float)
615
  # A filtered search competes only within its scope: the mask reaches
616
  # both recall stages, so results come from the best of the whole
617
- # filtered corpus rather than whatever survived open recall.
618
- mask = ([self._filter_ok(c, act, doc_type) for c in self.chunks]
619
- if (act or doc_type) else None)
 
 
 
 
 
 
 
620
  bm25 = self._bm25_scores(expanded, allowed=mask)
621
  for rank, idx in enumerate(self._capped_top(
622
  sorted(bm25, key=bm25.get, reverse=True))):
@@ -684,7 +726,7 @@ class LegislationIndex:
684
  fused[idx] -= BACKMATTER_PENALTY
685
 
686
  candidates = [i for i in sorted(fused, key=fused.get, reverse=True)
687
- if self._filter_ok(self.chunks[i], act, doc_type)]
688
  if not candidates:
689
  return []
690
  scores = {i: fused[i] for i in candidates}
@@ -748,14 +790,19 @@ class LegislationIndex:
748
  results.append(result)
749
  return results
750
 
751
- def get_section(self, act, section):
 
 
752
  act = act.lower()
 
753
  for c in self.chunks:
754
  if c["section"] == section and act in (
755
  c["act_short"].lower(), c["act_code"].lower(),
756
  c.get("act_name", "").lower()):
757
- return c
758
- return None
 
 
759
 
760
  def _build_xref(self):
761
  """Index legislation by (act, section); find each Act's definitions
@@ -767,6 +814,8 @@ class LegislationIndex:
767
  for c in self.chunks:
768
  if c.get("doc_type", "legislation") != "legislation":
769
  continue
 
 
770
  self._by_section[(c["act_code"], c["section"])] = c
771
  if c["act_code"] not in self._defs_section and (
772
  c["marginal_note"].strip().lower() in (
 
135
  return stemmed
136
 
137
 
138
+ # Query-language detection for the bilingual corpus: accented characters or
139
+ # two-plus French function words mark a French query. French queries may draw
140
+ # on BOTH languages (guidance and case law exist only in English); English
141
+ # queries never surface French chunks (the English corpus is complete, and a
142
+ # French twin would only duplicate results).
143
+ _FR_HINT = re.compile(r"[àâçéèêëîïôùûüÿœæ]", re.IGNORECASE)
144
+ # ASCII-only entries: tokens are matched pre-folding with [a-zA-Z']+, and any
145
+ # accented query already short-circuits through _FR_HINT above.
146
+ _FR_WORDS = frozenset(
147
+ "le la les de des une du et est dans pour que qui aux au sur par ne pas "
148
+ "peut doit selon lors quelle quel cette ces son ses leur d'un d'une "
149
+ "interdiction fouille saisie renvoi territoire".split())
150
+
151
+
152
+ def query_lang(query):
153
+ """'fr' when the query reads as French, else 'en'."""
154
+ if _FR_HINT.search(query):
155
+ return "fr"
156
+ tokens = re.findall(r"[a-zA-Z']+", query.lower())
157
+ return "fr" if sum(1 for t in tokens if t in _FR_WORDS) >= 2 else "en"
158
+
159
+
160
  def _fold_accents(text):
161
  """ASCII-fold accented characters so a French or mixed query tokenizes
162
  usefully instead of shattering: the [a-z0-9] token pattern used to split
 
237
  raise RuntimeError(
238
  f"No processed legislation in {PROCESSED_DIR}. Run 'canlex.ingest' first.")
239
  self._tri_index = None # lazy; see _fuzzy_term
240
+ # Cached recall mask for the common case (English query, no
241
+ # filters): everything but the French twins.
242
+ self._en_mask = [c.get("lang", "en") != "fr" for c in self.chunks]
243
  self._build_bm25()
244
  self._build_note_tokens()
245
  self._build_xref()
 
324
  try:
325
  import numpy as np
326
  from .embed import Embedder
327
+ # Multi-vector layout: rows share a chunk id when the chunk was
328
+ # embedded as several windows (long D-memo sections etc.); a
329
+ # chunk's semantic score is the max over its windows. Rows whose
330
+ # id matches no loaded chunk are dropped; chunks with no rows
331
+ # count as missing (BM25-only for them).
332
+ chunk_ordinal = {c["id"]: i for i, c in enumerate(self.chunks)}
333
  with np.load(emb_path) as data:
334
+ all_ids = data["ids"].tolist()
335
+ all_vecs = data["vectors"]
336
+ keep = [(chunk_ordinal[i], r) for r, i in enumerate(all_ids)
337
+ if i in chunk_ordinal]
 
 
 
 
 
 
 
338
  self._np = np
339
+ self.vectors = all_vecs[[r for _, r in keep]]
340
+ self._row_chunk = np.array([o for o, _ in keep], dtype=np.int64)
341
+ covered = set(self._row_chunk.tolist())
342
+ missing = len(self.chunks) - len(covered)
343
  self.embedder = Embedder()
344
  self.semantic = True
345
  if missing:
 
416
  return scores
417
 
418
  def _semantic_ranking(self, query, allowed=None):
419
+ np = self._np
420
  qv = self.embedder.encode_query(query)
421
+ row_sims = self.vectors @ qv
422
+ # Aggregate windows to chunks by max: a long section scores as its
423
+ # best-matching window, so a fact buried mid-memo is reachable.
424
+ sims = np.full(len(self.chunks), -1.0, dtype=np.float32)
425
+ np.maximum.at(sims, self._row_chunk, row_sims)
426
  if allowed is not None:
427
  # Mask out-of-scope chunks so a filtered search ranks (and reports
428
  # confidence) within its own scope, not corpus-wide.
429
+ sims = np.where(np.asarray(allowed, dtype=bool), sims, -1.0)
 
430
  # Over-fetch 4x: the recall-stage source cap (_capped_top) drops the
431
  # surplus chunks of any one deep source, and the extra depth is what
432
  # lets other sources backfill the freed CANDIDATES slots.
433
+ order = np.argsort(sims)[::-1][:CANDIDATES * 4]
434
  # The top cosine similarity doubles as a corpus-coverage signal: a query
435
  # the corpus cannot answer has no passage close to it.
436
  return [int(i) for i in order], float(sims.max())
 
622
  return {pos: (label, " ".join(snippet[:240].split()))
623
  for pos, (score, label, snippet) in best.items()}
624
 
625
+ def _filter_ok(self, c, act, doc_type, qlang="en"):
626
  """One predicate for both recall masking and the late result filter.
627
  The act filter matches short name, code, or full name -- an agent
628
  passing 'Immigration and Refugee Protection Act' should not get
629
+ silence. English queries never see French chunks; French queries see
630
+ both languages (guidance/case law exist only in English)."""
631
+ if qlang == "en" and c.get("lang", "en") == "fr":
632
+ return False
633
  if act:
634
  a = act.lower()
635
  if a not in (c["act_short"].lower(), c["act_code"].lower(),
 
644
  # Expand legal abbreviations (PRRA, H&C, ...) into statutory wording for
645
  # the recall stages; the reranker sees the original AND expanded forms.
646
  expanded = expand_query(query)
647
+ qlang = query_lang(query)
648
  confidence = None
649
  fused = defaultdict(float)
650
  # A filtered search competes only within its scope: the mask reaches
651
  # both recall stages, so results come from the best of the whole
652
+ # filtered corpus rather than whatever survived open recall. An
653
+ # unfiltered English query uses the cached English-only mask; a
654
+ # French query searches both languages unmasked.
655
+ if act or doc_type:
656
+ mask = [self._filter_ok(c, act, doc_type, qlang)
657
+ for c in self.chunks]
658
+ elif qlang == "en":
659
+ mask = self._en_mask
660
+ else:
661
+ mask = None
662
  bm25 = self._bm25_scores(expanded, allowed=mask)
663
  for rank, idx in enumerate(self._capped_top(
664
  sorted(bm25, key=bm25.get, reverse=True))):
 
726
  fused[idx] -= BACKMATTER_PENALTY
727
 
728
  candidates = [i for i in sorted(fused, key=fused.get, reverse=True)
729
+ if self._filter_ok(self.chunks[i], act, doc_type, qlang)]
730
  if not candidates:
731
  return []
732
  scores = {i: fused[i] for i in candidates}
 
790
  results.append(result)
791
  return results
792
 
793
+ def get_section(self, act, section, lang="en"):
794
+ """The section chunk in the requested language, falling back to the
795
+ other language rather than returning nothing."""
796
  act = act.lower()
797
+ fallback = None
798
  for c in self.chunks:
799
  if c["section"] == section and act in (
800
  c["act_short"].lower(), c["act_code"].lower(),
801
  c.get("act_name", "").lower()):
802
+ if c.get("lang", "en") == lang:
803
+ return c
804
+ fallback = fallback or c
805
+ return fallback
806
 
807
  def _build_xref(self):
808
  """Index legislation by (act, section); find each Act's definitions
 
814
  for c in self.chunks:
815
  if c.get("doc_type", "legislation") != "legislation":
816
  continue
817
+ if c.get("lang", "en") == "fr":
818
+ continue # xref keys are per (act, section); English canonical
819
  self._by_section[(c["act_code"], c["section"])] = c
820
  if c["act_code"] not in self._defs_section and (
821
  c["marginal_note"].strip().lower() in (
canlex/ingest.py CHANGED
@@ -399,8 +399,58 @@ def ingest(code, force=False):
399
  return chunks
400
 
401
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
402
  def main():
403
  force = "--force" in sys.argv
 
404
  only = [a for a in sys.argv[1:] if not a.startswith("-")]
405
  codes = only or list(SOURCES)
406
  failures = []
@@ -409,7 +459,11 @@ def main():
409
  print(f" SKIP {code}: not a known source")
410
  continue
411
  try:
412
- ingest(code, force=force)
 
 
 
 
413
  except Exception as exc:
414
  failures.append((code, exc))
415
  print(f" FAILED {code}: {type(exc).__name__}: {exc}")
 
399
  return chunks
400
 
401
 
402
+ # --- French corpus (phase 3 of bilingual support) ----------------------------
403
+ # Justice Laws publishes every consolidation as parallel French XML in the
404
+ # SAME schema (English element names, French text, identical section
405
+ # numbering), so the whole parser -- schedules included -- works unchanged.
406
+ # French chunks carry lang='fr', a '-fr-' id infix, French citation style
407
+ # ('art.' / 'annexe') and the French web URL; retrieval filters by query
408
+ # language (index.py), and get_section/xref/list_acts stay English-first.
409
+
410
+ def _fr_xml_url(src):
411
+ return (src["xml_url"]
412
+ .replace("/eng/XML/", "/fra/XML/")
413
+ .replace("/XML/SOR-", "/XML/DORS-") # règlements: SOR -> DORS
414
+ .replace("_c._", "_ch._")) # C.R.C. chapters: c. -> ch.
415
+
416
+
417
+ def _frenchify(chunks, code):
418
+ out = []
419
+ for c in chunks:
420
+ c = dict(c)
421
+ c["id"] = c["id"].replace(f"{code}-", f"{code}-fr-", 1)
422
+ c["lang"] = "fr"
423
+ c["citation"] = (c["citation"]
424
+ .replace(", s. ", ", art. ")
425
+ .replace(", Schedule", ", annexe"))
426
+ c["source_url"] = (c["source_url"]
427
+ .replace("/eng/acts/", "/fra/lois/")
428
+ .replace("/eng/regulations/", "/fra/reglements/"))
429
+ out.append(c)
430
+ return out
431
+
432
+
433
+ def ingest_fr(code, force=False):
434
+ src = SOURCES[code]
435
+ dest = RAW_DIR / f"{code}-fr.xml"
436
+ if not dest.exists() or force:
437
+ url = _fr_xml_url(src)
438
+ print(f" downloading {url}")
439
+ req = urllib.request.Request(url, headers={"User-Agent": "CanLex/0.1"})
440
+ with urllib.request.urlopen(req, timeout=120) as resp:
441
+ dest.write_bytes(resp.read())
442
+ time.sleep(1.0)
443
+ chunks = _frenchify(parse_legislation(dest, code), code)
444
+ out = PROCESSED_DIR / f"{code}-fr.json"
445
+ out.write_text(json.dumps(chunks, ensure_ascii=False, indent=2),
446
+ encoding="utf-8")
447
+ print(f" {len(chunks)} articles -> {out.name}")
448
+ return chunks
449
+
450
+
451
  def main():
452
  force = "--force" in sys.argv
453
+ french = "--fr" in sys.argv
454
  only = [a for a in sys.argv[1:] if not a.startswith("-")]
455
  codes = only or list(SOURCES)
456
  failures = []
 
459
  print(f" SKIP {code}: not a known source")
460
  continue
461
  try:
462
+ if french:
463
+ print(f"Ingesting {code} (français)...")
464
+ ingest_fr(code, force=force)
465
+ else:
466
+ ingest(code, force=force)
467
  except Exception as exc:
468
  failures.append((code, exc))
469
  print(f" FAILED {code}: {type(exc).__name__}: {exc}")
canlex/rerank.py CHANGED
@@ -10,7 +10,11 @@ from tokenizers import Tokenizer
10
  RERANK_REPO = "Xenova/bge-reranker-base"
11
  RERANK_ONNX = "onnx/model_quantized.onnx" # int8: ~3x faster on CPU, negligible quality loss
12
  MAX_TOKENS = 512
13
- _MAX_DOC_CHARS = int(os.environ.get("CANLEX_RERANK_DOC_CHARS", "1000"))
 
 
 
 
14
  # cap doc text before tokenizing. Cross-encoder cost is
15
  # ~linear in tokens per doc, so halving this ~halves the
16
  # dominant query cost; env-sweepable, eval-gated (a
 
10
  RERANK_REPO = "Xenova/bge-reranker-base"
11
  RERANK_ONNX = "onnx/model_quantized.onnx" # int8: ~3x faster on CPU, negligible quality loss
12
  MAX_TOKENS = 512
13
+ _MAX_DOC_CHARS = int(os.environ.get("CANLEX_RERANK_DOC_CHARS", "2000"))
14
+ # 1000 -> 2000 adopted 2026-07-23: the doubled reading
15
+ # window held every 203-Q eval metric and lifted Hit@5
16
+ # +0.01; the cross-encoder now judges a full-size piece
17
+ # rather than 60% of one.
18
  # cap doc text before tokenizing. Cross-encoder cost is
19
  # ~linear in tokens per doc, so halving this ~halves the
20
  # dominant query cost; env-sweepable, eval-gated (a
canlex/synonyms.py CHANGED
@@ -77,6 +77,9 @@ _SYNONYMS = [
77
  (r"criminalit[ée]", "criminality convicted offence"),
78
  (r"renvoi", "removal order"),
79
  (r"saisie?s?", "seizure seized goods"),
 
 
 
80
  (r"r[ée]sidents? permanents?", "permanent resident"),
81
  (r"[ée]trangers?", "foreign national"),
82
  (r"d[ée]tention", "detention detained review"),
 
77
  (r"criminalit[ée]", "criminality convicted offence"),
78
  (r"renvoi", "removal order"),
79
  (r"saisie?s?", "seizure seized goods"),
80
+ # 'devises' is the everyday word for currency; the PCMLTFA's French text
81
+ # says 'espèces et effets' -- bridge to both statutory vocabularies.
82
+ (r"devises", "currency monetary instruments espèces effets"),
83
  (r"r[ée]sidents? permanents?", "permanent resident"),
84
  (r"[ée]trangers?", "foreign national"),
85
  (r"d[ée]tention", "detention detained review"),