deeptutor-knowledge 1.7.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.
Files changed (168) hide show
  1. deeptutor/api/routers/knowledge.py +5225 -0
  2. deeptutor/api/routers/marginnote4.py +278 -0
  3. deeptutor/capabilities/ima/__init__.py +6 -0
  4. deeptutor/capabilities/ima/binding.py +118 -0
  5. deeptutor/capabilities/ima/capability.py +92 -0
  6. deeptutor/capabilities/ima/prompts/en/system.md +33 -0
  7. deeptutor/capabilities/ima/prompts/zh/system.md +24 -0
  8. deeptutor/capabilities/ima/tools.py +476 -0
  9. deeptutor/capabilities/marginnote4/__init__.py +13 -0
  10. deeptutor/capabilities/marginnote4/binding.py +66 -0
  11. deeptutor/capabilities/marginnote4/capability.py +80 -0
  12. deeptutor/capabilities/marginnote4/models.py +173 -0
  13. deeptutor/capabilities/marginnote4/prompts/en/system.md +54 -0
  14. deeptutor/capabilities/marginnote4/prompts/zh/system.md +44 -0
  15. deeptutor/capabilities/marginnote4/store.py +562 -0
  16. deeptutor/capabilities/marginnote4/tools.py +329 -0
  17. deeptutor/capabilities/obsidian/__init__.py +6 -0
  18. deeptutor/capabilities/obsidian/binding.py +75 -0
  19. deeptutor/capabilities/obsidian/capability.py +82 -0
  20. deeptutor/capabilities/obsidian/prompts/en/system.md +32 -0
  21. deeptutor/capabilities/obsidian/prompts/zh/system.md +28 -0
  22. deeptutor/capabilities/obsidian/tools.py +399 -0
  23. deeptutor/capabilities/obsidian/vault.py +341 -0
  24. deeptutor/knowledge/__init__.py +28 -0
  25. deeptutor/knowledge/add_documents.py +734 -0
  26. deeptutor/knowledge/indexing_run.py +432 -0
  27. deeptutor/knowledge/initializer.py +448 -0
  28. deeptutor/knowledge/manager.py +2570 -0
  29. deeptutor/knowledge/module_service.py +29 -0
  30. deeptutor/knowledge/naming.py +40 -0
  31. deeptutor/knowledge/progress_events.py +45 -0
  32. deeptutor/knowledge/progress_tracker.py +355 -0
  33. deeptutor/knowledge/staging.py +69 -0
  34. deeptutor/services/config/knowledge_base_config.py +239 -0
  35. deeptutor/services/embedding/__init__.py +46 -0
  36. deeptutor/services/embedding/adapters/__init__.py +40 -0
  37. deeptutor/services/embedding/adapters/base.py +196 -0
  38. deeptutor/services/embedding/adapters/cohere.py +176 -0
  39. deeptutor/services/embedding/adapters/dashscope_native.py +242 -0
  40. deeptutor/services/embedding/adapters/gemini.py +462 -0
  41. deeptutor/services/embedding/adapters/jina.py +161 -0
  42. deeptutor/services/embedding/adapters/ollama.py +145 -0
  43. deeptutor/services/embedding/adapters/openai_compatible.py +406 -0
  44. deeptutor/services/embedding/adapters/openai_sdk.py +150 -0
  45. deeptutor/services/embedding/client.py +323 -0
  46. deeptutor/services/embedding/config.py +114 -0
  47. deeptutor/services/embedding/request_options.py +46 -0
  48. deeptutor/services/embedding/validation.py +128 -0
  49. deeptutor/services/github_source/__init__.py +5 -0
  50. deeptutor/services/github_source/client.py +164 -0
  51. deeptutor/services/github_source/sync.py +384 -0
  52. deeptutor/services/github_source/sync_service.py +72 -0
  53. deeptutor/services/rag/__init__.py +21 -0
  54. deeptutor/services/rag/embedding_binding.py +331 -0
  55. deeptutor/services/rag/embedding_signature.py +63 -0
  56. deeptutor/services/rag/eval/__init__.py +92 -0
  57. deeptutor/services/rag/eval/dataset.py +244 -0
  58. deeptutor/services/rag/eval/diff.py +312 -0
  59. deeptutor/services/rag/eval/matching.py +218 -0
  60. deeptutor/services/rag/eval/metrics.py +213 -0
  61. deeptutor/services/rag/eval/report.py +111 -0
  62. deeptutor/services/rag/eval/runner.py +309 -0
  63. deeptutor/services/rag/factory.py +369 -0
  64. deeptutor/services/rag/file_routing.py +5 -0
  65. deeptutor/services/rag/index_probe.py +327 -0
  66. deeptutor/services/rag/index_versioning.py +387 -0
  67. deeptutor/services/rag/kb_paths.py +50 -0
  68. deeptutor/services/rag/linked_kb.py +237 -0
  69. deeptutor/services/rag/pipelines/__init__.py +7 -0
  70. deeptutor/services/rag/pipelines/base.py +41 -0
  71. deeptutor/services/rag/pipelines/graphrag/__init__.py +18 -0
  72. deeptutor/services/rag/pipelines/graphrag/compatibility.py +42 -0
  73. deeptutor/services/rag/pipelines/graphrag/completion_adapter.py +251 -0
  74. deeptutor/services/rag/pipelines/graphrag/config.py +357 -0
  75. deeptutor/services/rag/pipelines/graphrag/engine.py +479 -0
  76. deeptutor/services/rag/pipelines/graphrag/errors.py +328 -0
  77. deeptutor/services/rag/pipelines/graphrag/ingestion.py +135 -0
  78. deeptutor/services/rag/pipelines/graphrag/pandas_compat.py +68 -0
  79. deeptutor/services/rag/pipelines/graphrag/pipeline.py +284 -0
  80. deeptutor/services/rag/pipelines/graphrag/provider.py +134 -0
  81. deeptutor/services/rag/pipelines/graphrag/storage.py +127 -0
  82. deeptutor/services/rag/pipelines/ima/__init__.py +28 -0
  83. deeptutor/services/rag/pipelines/ima/client.py +364 -0
  84. deeptutor/services/rag/pipelines/ima/config.py +140 -0
  85. deeptutor/services/rag/pipelines/ima/envelope.py +109 -0
  86. deeptutor/services/rag/pipelines/ima/inventory.py +209 -0
  87. deeptutor/services/rag/pipelines/ima/media.py +190 -0
  88. deeptutor/services/rag/pipelines/ima/models.py +348 -0
  89. deeptutor/services/rag/pipelines/ima/notes.py +240 -0
  90. deeptutor/services/rag/pipelines/ima/pipeline.py +238 -0
  91. deeptutor/services/rag/pipelines/ima/probe.py +105 -0
  92. deeptutor/services/rag/pipelines/ima/sources.py +105 -0
  93. deeptutor/services/rag/pipelines/ima/transport.py +130 -0
  94. deeptutor/services/rag/pipelines/kiwix/__init__.py +1 -0
  95. deeptutor/services/rag/pipelines/kiwix/client.py +369 -0
  96. deeptutor/services/rag/pipelines/kiwix/pipeline.py +75 -0
  97. deeptutor/services/rag/pipelines/lightrag/README.md +78 -0
  98. deeptutor/services/rag/pipelines/lightrag/__init__.py +15 -0
  99. deeptutor/services/rag/pipelines/lightrag/block_policy.py +266 -0
  100. deeptutor/services/rag/pipelines/lightrag/cache_reuse.py +137 -0
  101. deeptutor/services/rag/pipelines/lightrag/config.py +453 -0
  102. deeptutor/services/rag/pipelines/lightrag/engine.py +536 -0
  103. deeptutor/services/rag/pipelines/lightrag/indexing_policy.py +696 -0
  104. deeptutor/services/rag/pipelines/lightrag/ingress.py +364 -0
  105. deeptutor/services/rag/pipelines/lightrag/parser.py +99 -0
  106. deeptutor/services/rag/pipelines/lightrag/pipeline.py +692 -0
  107. deeptutor/services/rag/pipelines/lightrag/roles.py +132 -0
  108. deeptutor/services/rag/pipelines/lightrag/sidecar.py +320 -0
  109. deeptutor/services/rag/pipelines/lightrag/storage.py +338 -0
  110. deeptutor/services/rag/pipelines/lightrag/worker.py +259 -0
  111. deeptutor/services/rag/pipelines/lightrag/write_lock.py +55 -0
  112. deeptutor/services/rag/pipelines/lightrag_server/__init__.py +15 -0
  113. deeptutor/services/rag/pipelines/lightrag_server/client.py +149 -0
  114. deeptutor/services/rag/pipelines/lightrag_server/config.py +68 -0
  115. deeptutor/services/rag/pipelines/lightrag_server/pipeline.py +120 -0
  116. deeptutor/services/rag/pipelines/lightrag_server/probe.py +110 -0
  117. deeptutor/services/rag/pipelines/llamaindex/__init__.py +3 -0
  118. deeptutor/services/rag/pipelines/llamaindex/config.py +199 -0
  119. deeptutor/services/rag/pipelines/llamaindex/document_loader.py +648 -0
  120. deeptutor/services/rag/pipelines/llamaindex/embedding_adapter.py +264 -0
  121. deeptutor/services/rag/pipelines/llamaindex/errors.py +76 -0
  122. deeptutor/services/rag/pipelines/llamaindex/exercise_lookup.py +373 -0
  123. deeptutor/services/rag/pipelines/llamaindex/ingestion.py +129 -0
  124. deeptutor/services/rag/pipelines/llamaindex/pipeline.py +556 -0
  125. deeptutor/services/rag/pipelines/llamaindex/rerank.py +130 -0
  126. deeptutor/services/rag/pipelines/llamaindex/retrievers.py +360 -0
  127. deeptutor/services/rag/pipelines/llamaindex/storage.py +655 -0
  128. deeptutor/services/rag/pipelines/llamaindex/vector_store.py +288 -0
  129. deeptutor/services/rag/pipelines/modes.py +48 -0
  130. deeptutor/services/rag/pipelines/pageindex/__init__.py +47 -0
  131. deeptutor/services/rag/pipelines/pageindex/client.py +146 -0
  132. deeptutor/services/rag/pipelines/pageindex/config.py +49 -0
  133. deeptutor/services/rag/pipelines/pageindex/pipeline.py +313 -0
  134. deeptutor/services/rag/pipelines/pageindex/reasoning.py +262 -0
  135. deeptutor/services/rag/pipelines/pageindex/storage.py +133 -0
  136. deeptutor/services/rag/pipelines/pageindex/tools.py +221 -0
  137. deeptutor/services/rag/pipelines/weknora/__init__.py +13 -0
  138. deeptutor/services/rag/pipelines/weknora/client.py +106 -0
  139. deeptutor/services/rag/pipelines/weknora/config.py +56 -0
  140. deeptutor/services/rag/pipelines/weknora/pipeline.py +74 -0
  141. deeptutor/services/rag/pipelines/weknora/probe.py +80 -0
  142. deeptutor/services/rag/preflight.py +274 -0
  143. deeptutor/services/rag/provider_binding.py +70 -0
  144. deeptutor/services/rag/query_rewrite.py +186 -0
  145. deeptutor/services/rag/service.py +379 -0
  146. deeptutor/services/rag/smart_retriever.py +81 -0
  147. deeptutor/services/rag/source_pages.py +292 -0
  148. deeptutor/services/rag/source_visuals.py +434 -0
  149. deeptutor/services/rag/visual_assets.py +430 -0
  150. deeptutor/services/rag/visual_coverage.py +165 -0
  151. deeptutor/services/web_source/__init__.py +1 -0
  152. deeptutor/services/web_source/bilingual.py +255 -0
  153. deeptutor/services/web_source/crawler.py +681 -0
  154. deeptutor/services/web_source/html_extractor.py +643 -0
  155. deeptutor/services/web_source/markdown.py +28 -0
  156. deeptutor/services/web_source/navigation.py +72 -0
  157. deeptutor/services/web_source/repository.py +640 -0
  158. deeptutor/services/web_source/robots.py +182 -0
  159. deeptutor/services/web_source/scheduler.py +336 -0
  160. deeptutor/services/web_source/snapshot_assets.py +171 -0
  161. deeptutor/services/web_source/sync.py +253 -0
  162. deeptutor/services/workspace/kb_move.py +446 -0
  163. deeptutor/tools/rag_tool.py +110 -0
  164. deeptutor_cli/kb.py +756 -0
  165. deeptutor_knowledge-1.7.0.dist-info/METADATA +15 -0
  166. deeptutor_knowledge-1.7.0.dist-info/RECORD +168 -0
  167. deeptutor_knowledge-1.7.0.dist-info/WHEEL +5 -0
  168. deeptutor_knowledge-1.7.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,360 @@
1
+ """Retriever composition for the LlamaIndex RAG pipeline."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from functools import lru_cache
6
+ import logging
7
+ from pathlib import Path
8
+ import shutil
9
+ from typing import Any
10
+
11
+ from llama_index.core.llms.mock import MockLLM
12
+ from llama_index.core.retrievers import QueryFusionRetriever
13
+ from llama_index.core.retrievers.fusion_retriever import FUSION_MODES
14
+
15
+ from .config import (
16
+ HYBRID_PROFILE,
17
+ VECTOR_PROFILE,
18
+ RetrievalConfig,
19
+ normalize_retrieval_profile,
20
+ retrieval_config_from_settings,
21
+ )
22
+ from .rerank import rerank_nodes
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ BM25_PERSIST_DIRNAME = "bm25_retriever"
27
+
28
+
29
+ def _import_bm25_retriever():
30
+ try:
31
+ from llama_index.retrievers.bm25 import BM25Retriever
32
+
33
+ return BM25Retriever
34
+ except ImportError:
35
+ return None
36
+
37
+
38
+ def _bm25_persist_dir(storage_dir: Path) -> Path:
39
+ return storage_dir / BM25_PERSIST_DIRNAME
40
+
41
+
42
+ def _set_similarity_top_k(retriever: Any, top_k: int) -> Any:
43
+ if hasattr(retriever, "similarity_top_k"):
44
+ retriever.similarity_top_k = top_k
45
+ return retriever
46
+
47
+
48
+ def _corpus_size(index: Any) -> int | None:
49
+ """Best-effort count of indexed nodes (the BM25 corpus size)."""
50
+ docstore = getattr(index, "docstore", None)
51
+ docs = getattr(docstore, "docs", None)
52
+ if isinstance(docs, dict):
53
+ return len(docs)
54
+ return None
55
+
56
+
57
+ def bm25_available() -> bool:
58
+ """Whether LlamaIndex's BM25 retriever package is importable.
59
+
60
+ Exposed so status surfaces can distinguish the *configured* retrieval
61
+ profile from the *effective* one: on Python 3.14 the BM25 dependency is
62
+ marker-excluded, and a deployment configured for hybrid then runs
63
+ vector-only without any signal otherwise (#1792).
64
+ """
65
+ return _import_bm25_retriever() is not None
66
+
67
+
68
+ def effective_retrieval_profile(profile: str | None) -> str:
69
+ """The retrieval profile that would actually run, given BM25 availability.
70
+
71
+ A hybrid configuration degrades to vector-only when the BM25 package is
72
+ missing; vector-only configurations are unaffected.
73
+ """
74
+ normalized = normalize_retrieval_profile(profile)
75
+ if normalized == HYBRID_PROFILE and not bm25_available():
76
+ return VECTOR_PROFILE
77
+ return normalized
78
+
79
+
80
+ def build_bm25_retriever(index: Any, storage_dir: Path, *, top_k: int) -> Any | None:
81
+ """Build or load LlamaIndex's official BM25 retriever if available."""
82
+ top_k = max(1, int(top_k))
83
+ # BM25 raises ("k of N is larger than the number of available scores") when
84
+ # similarity_top_k exceeds the corpus size — so a small knowledge base (e.g. a
85
+ # single short document) would crash hybrid retrieval at query time. Clamp to
86
+ # the node count so it returns what it has instead of erroring.
87
+ corpus_size = _corpus_size(index)
88
+ if corpus_size:
89
+ top_k = min(top_k, corpus_size)
90
+ bm25_cls = _import_bm25_retriever()
91
+ if bm25_cls is None:
92
+ # #1792: an INFO line was invisible in normal runs, so deployments on
93
+ # Python 3.14 (where the BM25 dependency is marker-excluded) ran
94
+ # vector-only without ever knowing. The configured profile is not the
95
+ # effective one — say so at warning level.
96
+ logger.warning(
97
+ "LlamaIndex BM25 retriever package is not installed; configured "
98
+ "retrieval profile will run vector-only. Install "
99
+ "'llama-index-retrievers-bm25' to restore hybrid retrieval."
100
+ )
101
+ return None
102
+
103
+ persist_dir = _bm25_persist_dir(storage_dir)
104
+ if persist_dir.exists():
105
+ try:
106
+ retriever = bm25_cls.from_persist_dir(str(persist_dir))
107
+ return _set_similarity_top_k(retriever, top_k)
108
+ except Exception as exc:
109
+ logger.warning("Failed to load persisted BM25 retriever from %s: %s", persist_dir, exc)
110
+
111
+ try:
112
+ return bm25_cls.from_defaults(index=index, similarity_top_k=top_k)
113
+ except Exception as exc:
114
+ logger.warning("Failed to build BM25 retriever; falling back to vector retrieval: %s", exc)
115
+ return None
116
+
117
+
118
+ def persist_bm25_retriever(index: Any, storage_dir: Path, *, top_k: int) -> bool:
119
+ """Persist BM25 sidecar index for faster hybrid retrieval.
120
+
121
+ Missing optional dependencies are non-fatal because hybrid retrieval can
122
+ still be enabled in deployments that install ``llama-index-retrievers-bm25``.
123
+ """
124
+ top_k = max(1, int(top_k))
125
+ bm25_cls = _import_bm25_retriever()
126
+ if bm25_cls is None:
127
+ return False
128
+
129
+ persist_dir = _bm25_persist_dir(storage_dir)
130
+ if persist_dir.exists():
131
+ shutil.rmtree(persist_dir, ignore_errors=True)
132
+
133
+ try:
134
+ retriever = bm25_cls.from_defaults(index=index, similarity_top_k=top_k)
135
+ except Exception as exc:
136
+ logger.warning("Failed to build BM25 retriever for persistence: %s", exc)
137
+ return False
138
+
139
+ if not hasattr(retriever, "persist"):
140
+ return False
141
+
142
+ persist_dir.mkdir(parents=True, exist_ok=True)
143
+ try:
144
+ retriever.persist(str(persist_dir))
145
+ return True
146
+ except Exception as exc:
147
+ logger.warning("Failed to persist BM25 retriever to %s: %s", persist_dir, exc)
148
+ return False
149
+
150
+
151
+ def build_retriever(
152
+ index: Any,
153
+ storage_dir: Path,
154
+ *,
155
+ top_k: int = 5,
156
+ config: RetrievalConfig | None = None,
157
+ ) -> Any:
158
+ """Compose the retrieval stack from official LlamaIndex retrievers."""
159
+ top_k = max(1, int(top_k))
160
+ retrieval_config = config or retrieval_config_from_settings()
161
+ if retrieval_config.profile == VECTOR_PROFILE:
162
+ return index.as_retriever(similarity_top_k=top_k)
163
+
164
+ bm25_top_k = retrieval_config.candidate_top_k(top_k, retrieval_config.bm25_top_k_multiplier)
165
+ bm25_retriever = build_bm25_retriever(index, storage_dir, top_k=bm25_top_k)
166
+ if bm25_retriever is None:
167
+ # Only reachable for non-vector profiles: the configured stack
168
+ # degrades to vector-only here, and the operator must know (#1792).
169
+ logger.warning(
170
+ "Retrieval profile %r requires BM25 but it is unavailable; "
171
+ "running vector-only. Install 'llama-index-retrievers-bm25' "
172
+ "or switch the profile to 'vector'.",
173
+ retrieval_config.profile,
174
+ )
175
+ return index.as_retriever(similarity_top_k=top_k)
176
+
177
+ if retrieval_config.profile == HYBRID_PROFILE:
178
+ vector_top_k = retrieval_config.candidate_top_k(
179
+ top_k, retrieval_config.vector_top_k_multiplier
180
+ )
181
+ vector_retriever = index.as_retriever(similarity_top_k=vector_top_k)
182
+ return QueryFusionRetriever(
183
+ [vector_retriever, bm25_retriever],
184
+ llm=MockLLM(),
185
+ mode=FUSION_MODES.RECIPROCAL_RANK,
186
+ similarity_top_k=top_k,
187
+ num_queries=retrieval_config.fusion_num_queries,
188
+ use_async=False,
189
+ )
190
+
191
+ return index.as_retriever(similarity_top_k=top_k)
192
+
193
+
194
+ def retrieve_nodes(
195
+ index: Any,
196
+ storage_dir: Path,
197
+ query: str,
198
+ *,
199
+ top_k: int = 5,
200
+ ) -> list[Any]:
201
+ """Run first-stage retrieval and the optional cross-encoder reranker."""
202
+ config = retrieval_config_from_settings()
203
+ candidate_top_k = config.rerank_candidate_top_k(top_k)
204
+ retriever = build_retriever(
205
+ index,
206
+ storage_dir,
207
+ top_k=candidate_top_k,
208
+ config=config,
209
+ )
210
+ candidates = retriever.retrieve(query)
211
+ if not config.reranker_model:
212
+ return candidates[: max(1, int(top_k))]
213
+
214
+ return rerank_nodes(
215
+ query,
216
+ candidates,
217
+ top_k=top_k,
218
+ model_name=config.reranker_model,
219
+ )
220
+
221
+
222
+ @lru_cache(maxsize=4)
223
+ def _scoped_index(index: Any, document_keys: tuple[str, ...]) -> Any:
224
+ """Reuse stored vectors in an isolated corpus; FAISS ignores node_ids filters.
225
+
226
+ This never embeds the documents again. The small LRU dies with the loaded
227
+ index identity, so a newly published index cannot reuse an old corpus.
228
+ """
229
+ from llama_index.core import VectorStoreIndex
230
+
231
+ from .vector_store import storage_context_for_nodes
232
+
233
+ allowed = set(document_keys)
234
+ # .docs reconstructs all nodes on every access; never call it per vector.
235
+ documents = index.docstore.docs
236
+ store = index.vector_store
237
+ data = getattr(getattr(store, "data", None), "embedding_dict", None)
238
+ faiss_index = getattr(store, "_faiss_index", None)
239
+ if isinstance(data, dict):
240
+ by_node = data
241
+ elif faiss_index is not None:
242
+ by_node = {
243
+ node_id: faiss_index.reconstruct(int(vector_id)).tolist()
244
+ for vector_id, node_id in index.index_struct.nodes_dict.items()
245
+ if documents[node_id].metadata.get("document_key") in allowed
246
+ }
247
+ else:
248
+ raise ValueError("This vector backend cannot enforce selected-document retrieval scope.")
249
+ nodes = []
250
+ for node in documents.values():
251
+ if (
252
+ node.metadata.get("document_key") not in allowed
253
+ or node.metadata.get("page_status") != "searchable"
254
+ or node.node_id not in by_node
255
+ ):
256
+ continue
257
+ copy = node.model_copy(deep=True)
258
+ copy.embedding = by_node[node.node_id]
259
+ nodes.append(copy)
260
+ if not nodes:
261
+ return None
262
+ return VectorStoreIndex(
263
+ nodes=nodes,
264
+ embed_model=index._embed_model,
265
+ storage_context=storage_context_for_nodes(nodes),
266
+ )
267
+
268
+
269
+ def retrieve_scoped_nodes(
270
+ index: Any, query: str, document_keys: list[str], *, top_k: int = 8
271
+ ) -> list[Any]:
272
+ """Restrict the corpus before retrieval, including the BM25 component."""
273
+ index = _scoped_index(index, tuple(sorted(set(document_keys))))
274
+ if index is None:
275
+ return []
276
+ nodes = list(index.docstore.docs.values())
277
+ ids = {node.node_id for node in nodes}
278
+ count = min(top_k, len(nodes))
279
+ vector = index.as_retriever(similarity_top_k=count)
280
+ bm25_cls = _import_bm25_retriever()
281
+ if bm25_cls is not None:
282
+ lexical = bm25_cls.from_defaults(nodes=nodes, similarity_top_k=count)
283
+ retriever = QueryFusionRetriever(
284
+ [vector, lexical],
285
+ llm=MockLLM(),
286
+ mode=FUSION_MODES.RECIPROCAL_RANK,
287
+ similarity_top_k=count,
288
+ num_queries=1,
289
+ use_async=False,
290
+ )
291
+ else:
292
+ retriever = vector
293
+ return [hit for hit in retriever.retrieve(query) if hit.node.node_id in ids][:count]
294
+
295
+
296
+ class _UnstemmedPageRetriever:
297
+ """Use BM25S directly when the optional stemming integration is unavailable.
298
+
299
+ Python 3.14 cannot install the PyStemmer version pinned by LlamaIndex's
300
+ adapter. Legacy page lookup still needs lexical retrieval without building
301
+ new embeddings; retain the same engine and skip optional stemming only.
302
+ """
303
+
304
+ def __init__(self, nodes: list[Any]) -> None:
305
+ import bm25s
306
+ from llama_index.core.schema import MetadataMode
307
+
308
+ self.nodes = nodes
309
+ self.engine = bm25s.BM25()
310
+ corpus = [node.get_content(metadata_mode=MetadataMode.EMBED) for node in nodes]
311
+ self.engine.index(self._tokenize(corpus), show_progress=False)
312
+
313
+ @staticmethod
314
+ def _tokenize(text):
315
+ import bm25s
316
+
317
+ return bm25s.tokenize(text, stopwords="en", stemmer=None, show_progress=False)
318
+
319
+ def retrieve(self, query: str) -> list[Any]:
320
+ from llama_index.core.schema import NodeWithScore
321
+
322
+ if not query.strip():
323
+ return []
324
+ indexes, scores = self.engine.retrieve(
325
+ self._tokenize(query), k=min(8, len(self.nodes)), show_progress=False
326
+ )
327
+ return [
328
+ NodeWithScore(node=self.nodes[int(index)], score=float(score))
329
+ for index, score in zip(indexes[0], scores[0])
330
+ ]
331
+
332
+
333
+ @lru_cache(maxsize=4)
334
+ def _legacy_page_retriever(directory: Path) -> Any:
335
+ from llama_index.core.storage.docstore import SimpleDocumentStore
336
+
337
+ nodes = list(
338
+ SimpleDocumentStore.from_persist_path(str(directory / "docstore.json")).docs.values()
339
+ )
340
+ cls = _import_bm25_retriever()
341
+ if cls is None:
342
+ return _UnstemmedPageRetriever(nodes)
343
+ return cls.from_defaults(nodes=nodes, similarity_top_k=min(8, len(nodes)))
344
+
345
+
346
+ def retrieve_legacy_pages(directory: Path, query: str, *, top_k: int = 8) -> list[Any]:
347
+ # The sidecar directory's content-addressed identity already includes the
348
+ # selected files. Never merge in nodes from another source or workspace.
349
+ return _legacy_page_retriever(directory).retrieve(query)[:top_k]
350
+
351
+
352
+ __all__ = [
353
+ "BM25_PERSIST_DIRNAME",
354
+ "bm25_available",
355
+ "build_bm25_retriever",
356
+ "build_retriever",
357
+ "effective_retrieval_profile",
358
+ "persist_bm25_retriever",
359
+ "retrieve_nodes",
360
+ ]