markdown-memory 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
File without changes
@@ -0,0 +1,518 @@
1
+ """Hybrid retrieval: BM25 (FTS5) and cosine vector search fused with Reciprocal Rank Fusion.
2
+
3
+ Two refinements, both measured on a labelled query set, sit in front of the fusion:
4
+
5
+ * **Passage max-sim.** A section is ranked by the closest of its vectors - the section
6
+ as a whole or any single passage (table row, list item, paragraph, code block). One
7
+ vector per section buries a relevant table row under everything around it.
8
+ * **IDF keyword gate.** RRF rewards a section for appearing in both rankings, so a stray
9
+ match on a common word ("data", "deploy") used to lift a wrong section above the
10
+ correct one that only the vector index had found. A keyword hit now counts only when
11
+ it covers at least half of the query's information (IDF-weighted), or when it matches
12
+ an identifier-like term (``--flag``, ``ENV_VAR``, ``/path``) that is rare in the corpus.
13
+ Spelling alone does not make an identifier: ``HTTP``, ``API`` or ``2024`` look like one
14
+ and are ordinary vocabulary wherever many sections mention them.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ import math
21
+ from collections.abc import Iterable, Sequence
22
+ from concurrent.futures import Future, ThreadPoolExecutor
23
+ from typing import NoReturn, TypeVar
24
+
25
+ from markdown_memory.db import WEIGHTS_META_KEY, WEIGHTS_REVOKED, Database
26
+ from markdown_memory.embedders import Embedder, short_weights
27
+ from markdown_memory.exceptions import MarkdownMemoryError, SearchError
28
+ from markdown_memory.models import SearchResult
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ RRF_K = 60
33
+ # Scoped search filters after each index has applied its own limit.
34
+ _SCOPED_OVERFETCH = 4
35
+ CANDIDATES_PER_INDEX = 20
36
+ MAX_RESULT_LIMIT = 50
37
+ _MAX_QUERY_TERMS = 32
38
+ KEYWORD_GATE = 0.5 # minimum IDF-weighted share of the query a keyword hit must cover
39
+ # An identifier-like term passes the gate by itself only while it is rare: found in at most
40
+ # this many sections, or this share of all sections, whichever is larger.
41
+ IDENTIFIER_MAX_SECTIONS = 3
42
+ IDENTIFIER_MAX_SHARE = 0.05
43
+ _STALE_RETRIES = 1 # re-rank once when a concurrent re-index replaced ranked sections
44
+ _PASSAGES_PER_CANDIDATE = 10 # passage neighbours fetched per wanted section
45
+
46
+
47
+ # Function words carry no topical signal, yet OR-ing them into the MATCH expression makes
48
+ # FTS5 return a page of irrelevant sections - noise that RRF would then weigh equally
49
+ # against the vector ranking. They are dropped unless the query consists of nothing else.
50
+ _STOPWORDS = frozenset(
51
+ [
52
+ "a",
53
+ "about",
54
+ "after",
55
+ "all",
56
+ "also",
57
+ "am",
58
+ "an",
59
+ "and",
60
+ "any",
61
+ "are",
62
+ "as",
63
+ "at",
64
+ "be",
65
+ "because",
66
+ "been",
67
+ "before",
68
+ "being",
69
+ "both",
70
+ "but",
71
+ "by",
72
+ "can",
73
+ "could",
74
+ "did",
75
+ "do",
76
+ "does",
77
+ "doing",
78
+ "each",
79
+ "for",
80
+ "from",
81
+ "had",
82
+ "has",
83
+ "have",
84
+ "having",
85
+ "he",
86
+ "her",
87
+ "here",
88
+ "him",
89
+ "his",
90
+ "how",
91
+ "i",
92
+ "if",
93
+ "in",
94
+ "into",
95
+ "is",
96
+ "it",
97
+ "its",
98
+ "me",
99
+ "more",
100
+ "most",
101
+ "my",
102
+ "no",
103
+ "nor",
104
+ "not",
105
+ "of",
106
+ "on",
107
+ "once",
108
+ "only",
109
+ "or",
110
+ "other",
111
+ "our",
112
+ "out",
113
+ "over",
114
+ "own",
115
+ "same",
116
+ "she",
117
+ "should",
118
+ "so",
119
+ "some",
120
+ "such",
121
+ "than",
122
+ "that",
123
+ "the",
124
+ "their",
125
+ "them",
126
+ "then",
127
+ "there",
128
+ "these",
129
+ "they",
130
+ "this",
131
+ "those",
132
+ "through",
133
+ "to",
134
+ "too",
135
+ "under",
136
+ "until",
137
+ "up",
138
+ "us",
139
+ "very",
140
+ "was",
141
+ "we",
142
+ "were",
143
+ "what",
144
+ "when",
145
+ "where",
146
+ "which",
147
+ "while",
148
+ "who",
149
+ "whom",
150
+ "why",
151
+ "will",
152
+ "with",
153
+ "would",
154
+ "you",
155
+ "your",
156
+ ]
157
+ )
158
+
159
+
160
+ def build_fts_query(query: str) -> str | None:
161
+ """Translate free text into a safe FTS5 MATCH expression (see ``fts_terms``)."""
162
+ return " OR ".join(fts_terms(query)) or None
163
+
164
+
165
+ def fts_terms(query: str) -> list[str]:
166
+ """The quoted FTS5 phrases a free-text query is searched by.
167
+
168
+ Each whitespace-separated term becomes a quoted phrase, so FTS5 operators and
169
+ punctuation in user input (``--max-retries``, ``MDMEM_DB_PATH``, ``NEAR(``, ``"``)
170
+ are matched literally instead of being parsed as query syntax. The tokenizer
171
+ splits a quoted term such as ``"MDMEM_DB_PATH"`` into the adjacent tokens
172
+ ``mdmem db path``, giving exact-identifier matching. Terms are OR-ed: BM25
173
+ ranks sections matching more (and rarer) terms first.
174
+ """
175
+ terms = [
176
+ term for term in _sanitize(query).split() if any(character.isalnum() for character in term)
177
+ ]
178
+ # Stopwords go first, duplicates second: "where is the WHERE clause" must keep the
179
+ # keyword even though its lower-case twin (a stopword) came earlier.
180
+ meaningful = _without_duplicates(term for term in terms if not _is_stopword(term))
181
+ chosen = (meaningful or _without_duplicates(terms))[:_MAX_QUERY_TERMS]
182
+ return ['"' + term.replace('"', '""') + '"' for term in chosen]
183
+
184
+
185
+ def _sanitize(text: str) -> str:
186
+ """Make ``text`` bindable: NUL ends SQLite's C strings, lone surrogates are not UTF-8."""
187
+ return text.replace("\x00", " ").encode("utf-8", errors="replace").decode("utf-8")
188
+
189
+
190
+ def _without_duplicates(terms: Iterable[str]) -> list[str]:
191
+ unique: dict[str, str] = {}
192
+ for term in terms:
193
+ unique.setdefault(term.lower(), term)
194
+ return list(unique.values())
195
+
196
+
197
+ _IDENTIFIER_MARKS = frozenset("_./\\:@#$=")
198
+
199
+
200
+ def _is_identifier(quoted_term: str) -> bool:
201
+ """Spelled like a flag, path, environment variable, constant or version.
202
+
203
+ Spelling cannot tell ``ENOSPC`` from ``HTTP``: whether a match on such a term may
204
+ bypass the keyword gate also depends on how rare it is (see ``HybridSearcher._gate``).
205
+ """
206
+ term = quoted_term.strip('"')
207
+ return (
208
+ term.startswith("-")
209
+ or any(character in _IDENTIFIER_MARKS or character.isdigit() for character in term[:-1])
210
+ or (len(term) > 1 and term.isupper())
211
+ )
212
+
213
+
214
+ _SENTENCE_PUNCTUATION = "\"'()[]{}<>?!.,;:"
215
+
216
+
217
+ def _is_stopword(term: str) -> bool:
218
+ """True for plain function words only.
219
+
220
+ Anything that looks like an identifier is kept even when it spells a stopword:
221
+ flags (``--all``, ``-i``), decorated names (``@Before``, ``IS_ON``) and upper-case
222
+ keywords (``WHERE``, ``NOT NULL``) are exactly what keyword search exists for.
223
+ """
224
+ word = term.strip(_SENTENCE_PUNCTUATION)
225
+ if not word.isalpha() or (len(word) > 1 and word.isupper()):
226
+ return False
227
+ return word.lower() in _STOPWORDS
228
+
229
+
230
+ def reciprocal_rank_fusion(rankings: Sequence[Sequence[int]], k: int = RRF_K) -> dict[int, float]:
231
+ """``RRF(d) = sum over rankings of 1 / (k + rank(d))`` with 1-based ranks."""
232
+ scores: dict[int, float] = {}
233
+ for ranking in rankings:
234
+ for rank, item in enumerate(ranking, start=1):
235
+ scores[item] = scores.get(item, 0.0) + 1.0 / (k + rank)
236
+ return scores
237
+
238
+
239
+ class HybridSearcher:
240
+ """Runs keyword and vector search concurrently and fuses the two rankings."""
241
+
242
+ def __init__(
243
+ self,
244
+ db: Database,
245
+ embedder: Embedder,
246
+ *,
247
+ candidates_per_index: int = CANDIDATES_PER_INDEX,
248
+ rrf_k: int = RRF_K,
249
+ scope: str | None = None,
250
+ ) -> None:
251
+ self._db = db
252
+ self._embedder = embedder
253
+ self._candidates = candidates_per_index
254
+ self._rrf_k = rrf_k
255
+ # One database can hold several documentation roots: the default is keyed per root,
256
+ # but a configured MARKDOWN_MEMORY_DB can point two of them at one file. Without
257
+ # this, an agent working in one project gets confident answers out of another
258
+ # project's documentation.
259
+ self._scope = scope
260
+ # Two long-lived workers so each keeps its own (per-thread) SQLite connection.
261
+ self._pool = ThreadPoolExecutor(max_workers=2, thread_name_prefix="mdmem-search")
262
+
263
+ def close(self) -> None:
264
+ self._pool.shutdown(wait=True)
265
+
266
+ def search(self, query: str, limit: int = 5) -> list[SearchResult]:
267
+ """Top ``limit`` sections by descending RRF score."""
268
+ query = _sanitize(query).strip()
269
+ if not query:
270
+ return []
271
+ limit = max(1, min(limit, MAX_RESULT_LIMIT))
272
+ # Re-indexing a document replaces its section rows, so ids ranked a moment ago can
273
+ # be gone by the time they are fetched. The new rows are already committed: rank
274
+ # again rather than hand back a short (or empty) page with no explanation.
275
+ for _ in range(_STALE_RETRIES):
276
+ results, stale = self._search_once(query, limit)
277
+ if not stale:
278
+ return results
279
+ logger.info("Sections changed during the search; ranking again")
280
+ return self._search_once(query, limit)[0]
281
+
282
+ def _search_once(self, query: str, limit: int) -> tuple[list[SearchResult], bool]:
283
+ """One ranking pass: the results, and whether a better-ranked section had vanished."""
284
+ candidates = max(self._candidates, limit)
285
+ try:
286
+ fts_future = self._pool.submit(self._keyword_ranking, query, candidates)
287
+ vec_future = self._pool.submit(self._vector_ranking, query, candidates)
288
+ except RuntimeError as exc: # the executor refuses work after close()
289
+ raise SearchError("The search engine has been shut down") from exc
290
+ fts_ranking, fts_error = _settle(fts_future, [])
291
+ (vec_ranking, passages), vec_error = _settle(vec_future, ([], {}))
292
+ if fts_error is not None and vec_error is not None:
293
+ raise fts_error
294
+ for name, error in (("keyword", fts_error), ("vector", vec_error)):
295
+ if error is not None:
296
+ logger.warning("%s search failed; using the other index only: %s", name, error)
297
+
298
+ scores = reciprocal_rank_fusion([fts_ranking, vec_ranking], self._rrf_k)
299
+ fts_ranks = {section_id: rank for rank, section_id in enumerate(fts_ranking, start=1)}
300
+ vec_ranks = {section_id: rank for rank, section_id in enumerate(vec_ranking, start=1)}
301
+ ordered = sorted(scores, key=lambda section_id: (-scores[section_id], section_id))
302
+
303
+ hydrated = self._db.get_sections_with_documents(ordered[:limit])
304
+ if len(hydrated) < len(ordered[:limit]):
305
+ # Deleted by a concurrent re-index between ranking and fetch: the next-best
306
+ # candidates fill the page instead of leaving it short.
307
+ hydrated.update(self._db.get_sections_with_documents(ordered[limit:]))
308
+ results: list[SearchResult] = []
309
+ stale = False
310
+ for section_id in ordered:
311
+ if len(results) == limit:
312
+ break
313
+ pair = hydrated.get(section_id)
314
+ if pair is None:
315
+ stale = True
316
+ continue
317
+ section, document = pair
318
+ results.append(
319
+ SearchResult(
320
+ section_id=section.id,
321
+ file_path=document.file_path,
322
+ document_title=document.title,
323
+ heading_title=section.heading_title,
324
+ heading_path=section.heading_path,
325
+ content=section.content,
326
+ start_line=section.start_line,
327
+ end_line=section.end_line,
328
+ score=scores[section_id],
329
+ fts_rank=fts_ranks.get(section_id),
330
+ vec_rank=vec_ranks.get(section_id),
331
+ matched_passage=passages.get(section_id),
332
+ )
333
+ )
334
+ return results, stale
335
+
336
+ def _keyword_ranking(self, query: str, limit: int) -> list[int]:
337
+ terms = fts_terms(query)
338
+ if not terms:
339
+ return []
340
+ hits = self._db.fts_search(" OR ".join(terms), limit, self._scope)
341
+ hits = self._gate(terms, hits)
342
+ # Heading-only sections are signposts: their children carry the same breadcrumb
343
+ # words plus the actual text. They stay only when nothing else matched.
344
+ with_body = self._db.sections_with_passages(hits)
345
+ return [hit for hit in hits if hit in with_body] or hits
346
+
347
+ def _gate(self, terms: Sequence[str], hits: list[int]) -> list[int]:
348
+ """Keep the hits whose matched terms carry >= ``KEYWORD_GATE`` of the query's IDF."""
349
+ if len(terms) < 2 or not hits:
350
+ return hits
351
+ total = self._db.count_rows("sections")
352
+ weights: dict[str, float] = {}
353
+ frequencies: dict[str, int] = {}
354
+ matched: dict[str, set[int]] = {}
355
+ for term in terms:
356
+ frequency = self._db.fts_document_frequency(term)
357
+ weights[term] = math.log(1 + (total - frequency + 0.5) / (frequency + 0.5))
358
+ matched[term] = self._db.fts_matching(term, hits) if frequency else set()
359
+ frequencies[term] = frequency
360
+ budget = sum(weights.values()) or 1.0
361
+ # A term that merely looks like an identifier ("HTTP", "RAM", "2024") and occurs
362
+ # all over the corpus is vocabulary: admitting every section that mentions it is
363
+ # exactly the noise this gate exists to remove. It still counts towards coverage.
364
+ rare = max(IDENTIFIER_MAX_SECTIONS, int(total * IDENTIFIER_MAX_SHARE))
365
+ exact: set[int] = set()
366
+ for term in terms:
367
+ if _is_identifier(term) and frequencies[term] <= rare:
368
+ exact |= matched[term]
369
+
370
+ def coverage(hit: int) -> float:
371
+ return sum(weights[term] for term in terms if hit in matched[term]) / budget
372
+
373
+ return [hit for hit in hits if hit in exact or coverage(hit) >= KEYWORD_GATE]
374
+
375
+ def _vector_ranking(self, query: str, limit: int) -> tuple[list[int], dict[int, str]]:
376
+ """Sections by their closest vector, plus each section's best-matching passage.
377
+
378
+ Empty when the stored vectors came from other weights than the ones answering
379
+ now: the distance between two models' vectors measures nothing, and returning it
380
+ as a semantic result is worse than returning no semantic result at all. Keyword
381
+ ranking reads no vector and is unaffected, so the search still answers - with the
382
+ half of it that is still true, and `index_status` carries the reason.
383
+ """
384
+ embedding = self._embedder.embed_query(query)
385
+ # After the embedding, never before: the embedder loads lazily and cannot say
386
+ # which weights it is until it has loaded, so asking first would suppress
387
+ # ranking on every first query of a process.
388
+ recorded = self._refuse_foreign_vectors()
389
+ best, passages = self._nearest(embedding, limit)
390
+ # Again, against what was read rather than what was checked: a model *name* change
391
+ # in another process discards every vector and rebuilds it, and a check that
392
+ # happened before those rows were read cannot speak for them.
393
+ if self._db.get_meta(WEIGHTS_META_KEY) != recorded or (
394
+ # Let through only because nothing was stored to disagree with; whatever the
395
+ # lookup found was written since, by weights other than these.
396
+ best and recorded != self._embedder.weights_revision
397
+ ):
398
+ raise SearchError(
399
+ "The index was rebuilt by another model while this search was ranking; "
400
+ "only keyword ranking is used"
401
+ )
402
+ ranking = sorted(best, key=lambda section_id: (best[section_id], section_id))[:limit]
403
+ return ranking, {sid: passages[sid] for sid in ranking if sid in passages}
404
+
405
+ def _refuse_foreign_vectors(self) -> str | None:
406
+ """Fail this ranking if the loaded model is not the one that built the vectors.
407
+
408
+ Search asks for itself rather than trusting a flag an indexing run would have had
409
+ to write: a cache whose weights changed while no document did leaves indexing a
410
+ clean no-op, and nothing would ever have set that flag. Failing rather than
411
+ returning nothing puts it on the path that already exists for one index being
412
+ unusable - the other index answers alone, and only losing both is an error.
413
+
414
+ Returns what was recorded, so the caller can tell whether it still is.
415
+ """
416
+ recorded = self._db.get_meta(WEIGHTS_META_KEY)
417
+ weights = self._embedder.weights_revision
418
+ if recorded is not None and weights != recorded and self._db.count_rows("units_vec") == 0:
419
+ # A revision over no vectors - a run died between claiming it and writing the
420
+ # first one - has nothing to rank against, so nothing to warn about either.
421
+ return recorded
422
+ if recorded is None:
423
+ if weights is None or self._db.count_rows("units_vec") == 0:
424
+ return None # nothing named on either side, or nothing to rank
425
+ # Vectors no revision vouches for, and weights that can say what they are:
426
+ # nothing says the two are the same model, so they are not ranked together.
427
+ # Recorded, so the next index run loads its model and re-embeds them.
428
+ self._record(
429
+ "No record says which weights built this index's vectors, so they are not "
430
+ "compared with a query: only keyword ranking is used until index_directory "
431
+ "re-embeds them."
432
+ )
433
+ if weights == recorded:
434
+ # A mismatch recorded by another process is left standing, even though these
435
+ # weights agree: it may be the only thing telling the next index run that a
436
+ # repair is pending. That run withdraws it once the whole index agrees.
437
+ return recorded
438
+ if recorded == WEIGHTS_REVOKED:
439
+ # An indexing run is replacing the vectors, and has said so where
440
+ # `index_status` reads it; until it finishes, no weights - old or new - have
441
+ # a whole index to rank against.
442
+ raise SearchError(
443
+ "This index is being re-embedded with other weights, so only keyword "
444
+ "ranking is used until index_directory finishes."
445
+ )
446
+ message = (
447
+ f"This index was built by weights {short_weights(recorded)} and the model "
448
+ f"answering now reports {short_weights(weights)}: the distance "
449
+ "between two models' vectors measures nothing, so only keyword ranking is used "
450
+ "until index_directory re-embeds this documentation root."
451
+ )
452
+ self._record(message)
453
+
454
+ def _record(self, message: str) -> NoReturn:
455
+ """Persist why vectors are not ranked, then fail the vector half of this search.
456
+
457
+ Persisted, because the answer this query is about to give is half of one, and the
458
+ agent reading it is told the index is healthy by an `index_status` that no
459
+ indexing run will correct - weights can change while no document does.
460
+ """
461
+ # Only where nothing is recorded yet, decided in the write itself: an indexing run's
462
+ # account - which names the directories still to re-index - says more than this
463
+ # query can, and may land between a check and a write.
464
+ self._db.record_weights_mismatch(message, replace=False)
465
+ raise SearchError(message)
466
+
467
+ def _nearest(
468
+ self, embedding: list[float], limit: int
469
+ ) -> tuple[dict[int, float], dict[int, str]]:
470
+ """Closest sections and their best passages, restricted to this server's root.
471
+
472
+ A vec0 KNN query applies its own ``k`` before anything can filter it, so a scoped
473
+ search widens ``k`` until it has a full page or has seen the whole index. A fixed
474
+ multiplier is not enough: a neighbouring root in the same database can be
475
+ arbitrarily larger than this one.
476
+ """
477
+ ceiling = max(self._db.count_rows("sections"), limit)
478
+ fetch = limit if self._scope is None else min(limit * _SCOPED_OVERFETCH, ceiling)
479
+ while True:
480
+ best: dict[int, float] = dict(self._db.vec_search(embedding, fetch))
481
+ # Nothing came back at all, so there is nothing a wider net can catch: the
482
+ # root is mid-rebuild, or its vectors were discarded and not yet replaced.
483
+ # Without this, each empty pass quadruples the fetch and asks again, all the
484
+ # way up to the size of the corpus, on every scoped query.
485
+ if not best:
486
+ return {}, {}
487
+ passages: dict[int, str] = {}
488
+ for section_id, distance, passage in self._db.unit_search(
489
+ embedding, fetch * _PASSAGES_PER_CANDIDATE
490
+ ):
491
+ passages.setdefault(section_id, passage) # closest first: keep the best one
492
+ if distance < best.get(section_id, math.inf):
493
+ best[section_id] = distance
494
+ if self._scope is not None and best:
495
+ allowed = self._db.sections_under(list(best), self._scope)
496
+ best = {sid: distance for sid, distance in best.items() if sid in allowed}
497
+ passages = {sid: text for sid, text in passages.items() if sid in allowed}
498
+ if self._scope is None or len(best) >= limit or fetch >= ceiling:
499
+ return best, passages
500
+ fetch = min(fetch * _SCOPED_OVERFETCH, ceiling)
501
+
502
+
503
+ _R = TypeVar("_R")
504
+
505
+
506
+ def _settle(future: Future[_R], empty: _R) -> tuple[_R, MarkdownMemoryError | None]:
507
+ """Resolve one ranking; any failure degrades to ``empty`` plus its error.
508
+
509
+ Unanticipated exceptions are wrapped rather than re-raised so that one broken index
510
+ can neither abandon the other index's result nor surface as an opaque tool crash.
511
+ """
512
+ try:
513
+ return future.result(), None
514
+ except MarkdownMemoryError as exc:
515
+ return empty, exc
516
+ except Exception as exc:
517
+ logger.exception("Unexpected failure in a search index")
518
+ return empty, SearchError(f"{type(exc).__name__}: {exc}")