22
33Tokenization rules (v1, see README for rationale):
44 - YAML frontmatter is stripped entirely BEFORE tokenization, except:
5+ * `title:` is appended to the body text (high-signal; some titles phrase
6+ the claim differently from the body `# H1`).
57 * `keywords:` values (list or inline) are appended to the body text.
68 * `applies-when:` values (block scalar or inline) are appended to the body.
7- Those two fields are explicitly search-relevant per PLAN section 2.
9+ `keywords`/`applies-when` are search-relevant per PLAN section 2; `title`
10+ was added later (see `_frontmatter_search_text`).
811 - Code fences are kept verbatim. Code identifiers are valuable search terms.
912 - Markdown is otherwise treated as plain text: we don't strip headings,
1013 wikilinks, list markers, etc. (BM25 handles the noise; over-stripping
2225
2326from __future__ import annotations
2427
28+ import hashlib
2529import os
2630import pickle
2731import re
@@ -75,9 +79,23 @@ def _strip_and_extract_frontmatter(text: str) -> tuple[str, FrontmatterDict]:
7579
7680
7781def _frontmatter_search_text (fm : Mapping [str , object ]) -> str :
78- """Pull out the search-relevant frontmatter fields per PLAN section 2."""
82+ """Pull out the search-relevant frontmatter fields: ``title``, ``keywords``
83+ and ``applies-when``.
84+
85+ ``title`` is included (beyond PLAN section 2's original keywords/applies-when)
86+ so titles are first-class search signal: most titles are mirrored by the body
87+ ``# H1`` and would be found anyway, but a real minority phrase the claim
88+ differently in the title than the H1, and those terms are only searchable if
89+ the title is indexed. A side effect makes the contract clean: because this is
90+ the single text source shared by BM25, the embedding index AND
91+ :func:`index_content_hash`, indexing the title also means a re-title changes
92+ the content hash and correctly triggers a reindex."""
7993 parts : list [str ] = []
8094
95+ title = fm .get ("title" )
96+ if isinstance (title , str ) and title .strip ():
97+ parts .append (title )
98+
8199 keywords = fm .get ("keywords" )
82100 if isinstance (keywords , list ):
83101 keywords_list = cast (list [object ], keywords )
@@ -98,7 +116,7 @@ def _frontmatter_search_text(fm: Mapping[str, object]) -> str:
98116def tokenize (text : str ) -> list [str ]:
99117 """Tokenize a full markdown document for BM25.
100118
101- Strips YAML frontmatter from the body, but pulls `keywords` and
119+ Strips YAML frontmatter from the body, but pulls `title`, ` keywords` and
102120 `applies-when` into the searchable text first.
103121 """
104122 body , fm = _strip_and_extract_frontmatter (text )
@@ -107,6 +125,27 @@ def tokenize(text: str) -> list[str]:
107125 return [tok for tok in _TOKEN_SPLIT_RE .split (combined ) if len (tok ) >= 2 ]
108126
109127
128+ def index_content_hash (raw : str ) -> str :
129+ """Stable hash of the text that actually feeds the indexes.
130+
131+ Both BM25 (:func:`tokenize`) and the embedding index
132+ (``embeddings._embedding_text``) index the body plus the ``title``,
133+ ``keywords`` and ``applies-when`` frontmatter — and nothing else (all three
134+ derive their text from :func:`_frontmatter_search_text`, so this hash covers
135+ exactly what is searched, no more). Mutable bookkeeping fields (``fired``,
136+ ``last_surfaced``, ``reconsolidated``, …) are stripped before indexing, so a
137+ write that only touches them leaves every token and every embedding
138+ byte-identical. Keying staleness on this hash instead of the file mtime means
139+ such writes no longer trigger a needless re-tokenise / re-encode on the
140+ priming hot path — which is what made every priming surface (it bumps
141+ ``fired``/``last_surfaced``) re-index the corpus. A re-title, by contrast,
142+ DOES change this hash and so reindexes — correct, since the title is searched.
143+ """
144+ body , fm = _strip_and_extract_frontmatter (raw )
145+ indexed_text = _frontmatter_search_text (fm ) + "\n " + body
146+ return hashlib .blake2b (indexed_text .encode ("utf-8" ), digest_size = 16 ).hexdigest ()
147+
148+
110149# ── Public aliases for cross-module use ────────────────────────────────────────
111150# ``embeddings.py`` reuses the same file-selection and frontmatter discipline.
112151# We expose non-private aliases so it can import them without tripping
@@ -126,6 +165,12 @@ class _DocRecord:
126165 path : str # absolute path string
127166 mtime : float
128167 raw_text : str # full file text, kept for snippet generation
168+ # blake2b of the indexed text (body + title + keywords + applies-when). Lets
169+ # the staleness check tell a real content edit from a bookkeeping-only mtime
170+ # bump. Defaults to "" so indexes pickled before this field existed still
171+ # unpickle — a missing hash never matches, so the first staleness check
172+ # after an upgrade falls through to a rebuild that repopulates it.
173+ content_hash : str = ""
129174
130175
131176class BM25Hit (NamedTuple ):
@@ -280,7 +325,14 @@ def build_index(knowledge_dir: Path) -> BM25Index:
280325 toks = tokenize (raw )
281326 if not toks :
282327 continue
283- docs .append (_DocRecord (path = str (md ), mtime = md .stat ().st_mtime , raw_text = raw ))
328+ docs .append (
329+ _DocRecord (
330+ path = str (md ),
331+ mtime = md .stat ().st_mtime ,
332+ raw_text = raw ,
333+ content_hash = index_content_hash (raw ),
334+ )
335+ )
284336 tokenized .append (toks )
285337
286338 bm25 = BM25Okapi (tokenized ) if tokenized else None
@@ -375,6 +427,42 @@ def is_stale(docs: Sequence[_TimestampedDoc], current_files: Mapping[str, float]
375427 return False
376428
377429
430+ def only_bookkeeping_changed (
431+ docs : Sequence [_TimestampedDoc ],
432+ current_files : Mapping [str , float ],
433+ ) -> bool :
434+ """True iff every change since the index was built is confined to non-indexed
435+ (bookkeeping/title) frontmatter — so a rebuild would be byte-identical and the
436+ cached index can be reused as-is.
437+
438+ Call only after :func:`is_stale` returned True. Returns False (→ caller must
439+ rebuild) if any file was added or removed, if a changed file can't be read,
440+ or if a changed file's indexed-content hash differs from its cached record.
441+ Only files whose mtime advanced are read; the unchanged majority is skipped
442+ via the same cheap mtime comparison ``is_stale`` uses. A cached record with no
443+ stored ``content_hash`` (index pickled before that field existed) never
444+ matches, so the first post-upgrade staleness check falls through to a rebuild
445+ that repopulates the hashes. Shared by the BM25 and embedding indices.
446+ """
447+ tracked : dict [str , _TimestampedDoc ] = {rec .path : rec for rec in docs }
448+ if set (current_files ) != set (tracked ):
449+ return False
450+ for path , mtime in current_files .items ():
451+ rec = tracked [path ]
452+ if mtime <= rec .mtime + 1e-6 :
453+ continue # unchanged — no need to read the file
454+ cached_hash = getattr (rec , "content_hash" , "" )
455+ if not cached_hash :
456+ return False # pre-upgrade record (or genuinely empty) → rebuild
457+ try :
458+ raw = Path (path ).read_text (encoding = "utf-8" )
459+ except (OSError , UnicodeDecodeError ):
460+ return False
461+ if index_content_hash (raw ) != cached_hash :
462+ return False # indexed content really changed
463+ return True
464+
465+
378466def load_or_build (
379467 knowledge_dir : Path ,
380468 index_path : Path | None = None ,
@@ -390,6 +478,14 @@ def load_or_build(
390478 current_files = {str (p ): p .stat ().st_mtime for p in _iter_markdown (knowledge_dir )}
391479 if not is_stale (cached .docs , current_files ):
392480 return cached
481+ # Stale by mtime, but if the only writes were bookkeeping/title
482+ # frontmatter the indexed text is unchanged — reuse without a
483+ # rebuild. (We don't re-save: stored mtimes stay behind, so the
484+ # next request re-reads the same handful and confirms again. That
485+ # read is sub-millisecond; a full re-tokenise + BM25Okapi rebuild
486+ # on every priming surface is what we're avoiding.)
487+ if only_bookkeeping_changed (cached .docs , current_files ):
488+ return cached
393489
394490 index = build_index (knowledge_dir )
395491 try :
0 commit comments