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.
- markdown_memory/__init__.py +47 -0
- markdown_memory/autoindex.py +170 -0
- markdown_memory/config.py +235 -0
- markdown_memory/db.py +1546 -0
- markdown_memory/discovery.py +195 -0
- markdown_memory/embedders.py +513 -0
- markdown_memory/exceptions.py +71 -0
- markdown_memory/freshness.py +138 -0
- markdown_memory/headings.py +185 -0
- markdown_memory/indexer.py +725 -0
- markdown_memory/model_cache.py +272 -0
- markdown_memory/models.py +339 -0
- markdown_memory/parser.py +869 -0
- markdown_memory/py.typed +0 -0
- markdown_memory/search.py +518 -0
- markdown_memory/server.py +520 -0
- markdown_memory-0.1.0.dist-info/METADATA +579 -0
- markdown_memory-0.1.0.dist-info/RECORD +21 -0
- markdown_memory-0.1.0.dist-info/WHEEL +4 -0
- markdown_memory-0.1.0.dist-info/entry_points.txt +3 -0
- markdown_memory-0.1.0.dist-info/licenses/LICENSE +21 -0
markdown_memory/py.typed
ADDED
|
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}")
|