opensolr-haystack 0.7.0__tar.gz → 0.8.0__tar.gz

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 (17) hide show
  1. {opensolr_haystack-0.7.0 → opensolr_haystack-0.8.0}/PKG-INFO +82 -12
  2. {opensolr_haystack-0.7.0 → opensolr_haystack-0.8.0}/README.md +81 -11
  3. {opensolr_haystack-0.7.0 → opensolr_haystack-0.8.0}/haystack_integrations/document_stores/opensolr/__init__.py +2 -1
  4. {opensolr_haystack-0.7.0 → opensolr_haystack-0.8.0}/haystack_integrations/document_stores/opensolr/client.py +146 -34
  5. {opensolr_haystack-0.7.0 → opensolr_haystack-0.8.0}/haystack_integrations/document_stores/opensolr/store.py +28 -5
  6. {opensolr_haystack-0.7.0 → opensolr_haystack-0.8.0}/opensolr_haystack.egg-info/PKG-INFO +82 -12
  7. {opensolr_haystack-0.7.0 → opensolr_haystack-0.8.0}/pyproject.toml +1 -1
  8. {opensolr_haystack-0.7.0 → opensolr_haystack-0.8.0}/LICENSE +0 -0
  9. {opensolr_haystack-0.7.0 → opensolr_haystack-0.8.0}/haystack_integrations/components/converters/opensolr/__init__.py +0 -0
  10. {opensolr_haystack-0.7.0 → opensolr_haystack-0.8.0}/haystack_integrations/components/converters/opensolr/converter.py +0 -0
  11. {opensolr_haystack-0.7.0 → opensolr_haystack-0.8.0}/haystack_integrations/components/retrievers/opensolr/__init__.py +0 -0
  12. {opensolr_haystack-0.7.0 → opensolr_haystack-0.8.0}/haystack_integrations/components/retrievers/opensolr/retriever.py +0 -0
  13. {opensolr_haystack-0.7.0 → opensolr_haystack-0.8.0}/opensolr_haystack.egg-info/SOURCES.txt +0 -0
  14. {opensolr_haystack-0.7.0 → opensolr_haystack-0.8.0}/opensolr_haystack.egg-info/dependency_links.txt +0 -0
  15. {opensolr_haystack-0.7.0 → opensolr_haystack-0.8.0}/opensolr_haystack.egg-info/requires.txt +0 -0
  16. {opensolr_haystack-0.7.0 → opensolr_haystack-0.8.0}/opensolr_haystack.egg-info/top_level.txt +0 -0
  17. {opensolr_haystack-0.7.0 → opensolr_haystack-0.8.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: opensolr-haystack
3
- Version: 0.7.0
3
+ Version: 0.8.0
4
4
  Summary: Haystack integration for Opensolr — managed Apache Solr DocumentStore with server-side embeddings and hybrid BM25+kNN retrieval
5
5
  Author-email: Opensolr <support@opensolr.com>
6
6
  License: MIT
@@ -61,8 +61,8 @@ the query. The store embeds server-side at both index and query time.
61
61
 
62
62
  ## Try it without an account
63
63
 
64
- There is a public demo account. Point the package at it and search, ingestion and
65
- answers work immediately, with no signup:
64
+ There is a public demo account. Point the package at it and everything in this
65
+ README works immediately, with no signup:
66
66
 
67
67
  ```bash
68
68
  export OPENSOLR_EMAIL=mcp@opensolr.com
@@ -70,17 +70,18 @@ export OPENSOLR_API_KEY=420b8b23e7b12dc8ab838932145a5065
70
70
  ```
71
71
 
72
72
  `mcp_demo_d1__dense` is already loaded with 300 news articles, so search, filtering
73
- and grounded answers work the moment you connect. You also get the full write path:
74
- create your own index on the account, ingest into it, query it, delete it.
75
- Reading files and pictures (the file loader and file indexing below, search by image)
76
- needs your own key: the shared demo key does not include them.
73
+ and grounded answers work the moment you connect. You also get the write path:
74
+ create your own index on the account, ingest into it and query it. Deletion is not
75
+ available on this shared key: no index here can be deleted or reconfigured by hand.
76
+ Whatever you create is removed automatically after 3 days.
77
77
 
78
78
  Know what you are working with:
79
79
 
80
80
  - **Anything you create there is deleted after 3 days.** Automatically, without warning
81
81
  or export. That includes indexes you created and every document in them.
82
- - **The account is shared with everyone reading this.** Your index is visible to them,
83
- they can change or delete it, and you can do the same to theirs. Never put anything
82
+ - **The account is shared with everyone reading this.** Your index is visible to them
83
+ and they can add documents to it, as you can to theirs. Nobody can delete or
84
+ reconfigure an index here (that is switched off for this key), but never put anything
84
85
  real, private or client-owned in it.
85
86
  - **The limits are per index, and deliberately small.** 200 MB of bandwidth and 50 MB
86
87
  of disk per index. Bandwidth is the one you will hit first: it covers a demo, a
@@ -172,8 +173,9 @@ text from PDF/DOCX/XLSX for you.
172
173
  ## Lexical-only mode
173
174
 
174
175
  Don't need vectors? Pure keyword search skips the embedding call entirely —
175
- zero AI quota, and it works on **any** Opensolr index, including non-vector
176
- ones and older Solr versions.
176
+ zero AI quota. It runs on any Opensolr index that has `title`, `description` and
177
+ `text` fields, including indexes outside the vector regions and older Solr
178
+ versions (see [Index requirements](#index-requirements)).
177
179
 
178
180
  ## Search by image
179
181
 
@@ -241,7 +243,7 @@ Data Ingestion with `rtf`, PDFs found by the Web Crawler), a search returns each
241
243
  **once**, as its best page. Its metadata carries `page_number` (the page number),
242
244
  `page_url` (opens the PDF at that page, `...pdf#page=14`), `document_id`,
243
245
  `more_pages` (its other matching pages, each with `page` and `page_url`) and
244
- `matching_pages`; every hit carries its `uri`. `group_pages=False` returns the pages
246
+ `matching_pages`, and its `uri`; any other hit keeps exactly the metadata it was sent with. `group_pages=False` returns the pages
245
247
  as separate results.
246
248
 
247
249
  ## Searching a period
@@ -257,6 +259,74 @@ OpensolrHybridRetriever(document_store=store, date_from="2026-01-01")
257
259
 
258
260
  They combine with every other option — filters, `fresh_bias`, hybrid or lexical.
259
261
 
262
+ ## Index requirements
263
+
264
+ Everything this package does runs on an Opensolr index that sits in a **vector
265
+ region** and carries the **Opensolr vector schema** — the configuration every
266
+ Opensolr vector index runs. An index created through the package (`create_index`,
267
+ `create_if_missing`) has both. An index made elsewhere may not: a server outside
268
+ the vector regions, an older Solr version, or a schema of its own.
269
+
270
+ **The check is automatic.** The first time the package touches an index, it asks
271
+ the platform once whether that index can do what is asked: one request per index
272
+ per client, kept for the client's life, never one per search. If the check itself
273
+ cannot run (a network error, the platform busy), nothing is blocked: the
274
+ operation runs exactly as it always did, and the check is tried again after 5
275
+ minutes. When an index differs from the Opensolr vector schema, a warning is logged
276
+ once, on the `opensolr` logger, with the reasons and the fix.
277
+
278
+ What the check gates — only what cannot work otherwise:
279
+
280
+ | Capability | Needed for | When it is missing |
281
+ |---|---|---|
282
+ | `ingestion` | Data Ingestion: adding documents and indexing files | The index is outside the vector regions: refused before a document or a byte is sent |
283
+ | `vector_search` | Vector and hybrid search | The index has no vector field of the right size: refused before any embedding is paid for |
284
+ | `pdf_pages` | Grouping the pages of a PDF in search results | Results come back ungrouped, as before |
285
+ | `keyword_search` | Keyword (lexical) search | Never blocked |
286
+
287
+ **The errors say exactly what is going on.** A blocked operation raises
288
+ `OpensolrSchemaError` (a subclass of `OpensolrError`) with `.index`, `.capability`
289
+ and `.report` (the full check), and a message with every reason and the fix:
290
+
291
+ ```
292
+ Index 'shop_it' cannot take documents or files through Data Ingestion. Index shop_it
293
+ is on IT-SOLR-9-6 (Milano, Italy), which is not a vector region: Data Ingestion,
294
+ server-side embeddings and hybrid search run only on the servers of a vector region
295
+ (DE-SOLR-9 (Germany, Solr 9.00), FINLAND9 (Finland, Solr 9.60), ...). This index
296
+ cannot be made ready: create a new index in one of the vector regions (...), for
297
+ example with create_index, and send your documents there.
298
+ ```
299
+
300
+ When Solr itself refuses a request, `OpensolrSolrError` carries Solr's own reason
301
+ and the index name; it is still an `httpx.HTTPStatusError`, so code that caught
302
+ that before keeps working.
303
+
304
+ ```python
305
+ from haystack_integrations.document_stores.opensolr import OpensolrDocumentStore, OpensolrSchemaError, OpensolrSolrError
306
+
307
+ try:
308
+ store.write_files("./contracts")
309
+ except OpensolrSchemaError as e:
310
+ print(e.capability, e.report["fix"]["action"]) # ingestion new_index
311
+ ```
312
+
313
+ **See the details, and fix what can be fixed:**
314
+
315
+ ```python
316
+ store.check_schema() # ready, capabilities, server, Solr version, missing / different fields, problems, fix
317
+ store.apply_schema(force=False) # installs the Opensolr vector schema; force=True when the index holds documents
318
+ ```
319
+
320
+ `apply_schema` exists so an index of a vector region whose configuration was
321
+ changed, or never set, can take everything this package does without being
322
+ recreated: it installs the same configuration every Opensolr vector index runs,
323
+ and **replaces the index configuration**. An index that already holds documents
324
+ needs `force=True`; those documents keep the fields they were written with and
325
+ must be sent again. An index outside the vector regions cannot take it — create
326
+ a new index in a region from `vector_regions` and send your documents there. The
327
+ package never installs the schema on its own.
328
+
329
+
260
330
  ## Your index schema
261
331
 
262
332
  Documents follow the Opensolr document model (`title`, `description`, `text`,
@@ -43,8 +43,8 @@ the query. The store embeds server-side at both index and query time.
43
43
 
44
44
  ## Try it without an account
45
45
 
46
- There is a public demo account. Point the package at it and search, ingestion and
47
- answers work immediately, with no signup:
46
+ There is a public demo account. Point the package at it and everything in this
47
+ README works immediately, with no signup:
48
48
 
49
49
  ```bash
50
50
  export OPENSOLR_EMAIL=mcp@opensolr.com
@@ -52,17 +52,18 @@ export OPENSOLR_API_KEY=420b8b23e7b12dc8ab838932145a5065
52
52
  ```
53
53
 
54
54
  `mcp_demo_d1__dense` is already loaded with 300 news articles, so search, filtering
55
- and grounded answers work the moment you connect. You also get the full write path:
56
- create your own index on the account, ingest into it, query it, delete it.
57
- Reading files and pictures (the file loader and file indexing below, search by image)
58
- needs your own key: the shared demo key does not include them.
55
+ and grounded answers work the moment you connect. You also get the write path:
56
+ create your own index on the account, ingest into it and query it. Deletion is not
57
+ available on this shared key: no index here can be deleted or reconfigured by hand.
58
+ Whatever you create is removed automatically after 3 days.
59
59
 
60
60
  Know what you are working with:
61
61
 
62
62
  - **Anything you create there is deleted after 3 days.** Automatically, without warning
63
63
  or export. That includes indexes you created and every document in them.
64
- - **The account is shared with everyone reading this.** Your index is visible to them,
65
- they can change or delete it, and you can do the same to theirs. Never put anything
64
+ - **The account is shared with everyone reading this.** Your index is visible to them
65
+ and they can add documents to it, as you can to theirs. Nobody can delete or
66
+ reconfigure an index here (that is switched off for this key), but never put anything
66
67
  real, private or client-owned in it.
67
68
  - **The limits are per index, and deliberately small.** 200 MB of bandwidth and 50 MB
68
69
  of disk per index. Bandwidth is the one you will hit first: it covers a demo, a
@@ -154,8 +155,9 @@ text from PDF/DOCX/XLSX for you.
154
155
  ## Lexical-only mode
155
156
 
156
157
  Don't need vectors? Pure keyword search skips the embedding call entirely —
157
- zero AI quota, and it works on **any** Opensolr index, including non-vector
158
- ones and older Solr versions.
158
+ zero AI quota. It runs on any Opensolr index that has `title`, `description` and
159
+ `text` fields, including indexes outside the vector regions and older Solr
160
+ versions (see [Index requirements](#index-requirements)).
159
161
 
160
162
  ## Search by image
161
163
 
@@ -223,7 +225,7 @@ Data Ingestion with `rtf`, PDFs found by the Web Crawler), a search returns each
223
225
  **once**, as its best page. Its metadata carries `page_number` (the page number),
224
226
  `page_url` (opens the PDF at that page, `...pdf#page=14`), `document_id`,
225
227
  `more_pages` (its other matching pages, each with `page` and `page_url`) and
226
- `matching_pages`; every hit carries its `uri`. `group_pages=False` returns the pages
228
+ `matching_pages`, and its `uri`; any other hit keeps exactly the metadata it was sent with. `group_pages=False` returns the pages
227
229
  as separate results.
228
230
 
229
231
  ## Searching a period
@@ -239,6 +241,74 @@ OpensolrHybridRetriever(document_store=store, date_from="2026-01-01")
239
241
 
240
242
  They combine with every other option — filters, `fresh_bias`, hybrid or lexical.
241
243
 
244
+ ## Index requirements
245
+
246
+ Everything this package does runs on an Opensolr index that sits in a **vector
247
+ region** and carries the **Opensolr vector schema** — the configuration every
248
+ Opensolr vector index runs. An index created through the package (`create_index`,
249
+ `create_if_missing`) has both. An index made elsewhere may not: a server outside
250
+ the vector regions, an older Solr version, or a schema of its own.
251
+
252
+ **The check is automatic.** The first time the package touches an index, it asks
253
+ the platform once whether that index can do what is asked: one request per index
254
+ per client, kept for the client's life, never one per search. If the check itself
255
+ cannot run (a network error, the platform busy), nothing is blocked: the
256
+ operation runs exactly as it always did, and the check is tried again after 5
257
+ minutes. When an index differs from the Opensolr vector schema, a warning is logged
258
+ once, on the `opensolr` logger, with the reasons and the fix.
259
+
260
+ What the check gates — only what cannot work otherwise:
261
+
262
+ | Capability | Needed for | When it is missing |
263
+ |---|---|---|
264
+ | `ingestion` | Data Ingestion: adding documents and indexing files | The index is outside the vector regions: refused before a document or a byte is sent |
265
+ | `vector_search` | Vector and hybrid search | The index has no vector field of the right size: refused before any embedding is paid for |
266
+ | `pdf_pages` | Grouping the pages of a PDF in search results | Results come back ungrouped, as before |
267
+ | `keyword_search` | Keyword (lexical) search | Never blocked |
268
+
269
+ **The errors say exactly what is going on.** A blocked operation raises
270
+ `OpensolrSchemaError` (a subclass of `OpensolrError`) with `.index`, `.capability`
271
+ and `.report` (the full check), and a message with every reason and the fix:
272
+
273
+ ```
274
+ Index 'shop_it' cannot take documents or files through Data Ingestion. Index shop_it
275
+ is on IT-SOLR-9-6 (Milano, Italy), which is not a vector region: Data Ingestion,
276
+ server-side embeddings and hybrid search run only on the servers of a vector region
277
+ (DE-SOLR-9 (Germany, Solr 9.00), FINLAND9 (Finland, Solr 9.60), ...). This index
278
+ cannot be made ready: create a new index in one of the vector regions (...), for
279
+ example with create_index, and send your documents there.
280
+ ```
281
+
282
+ When Solr itself refuses a request, `OpensolrSolrError` carries Solr's own reason
283
+ and the index name; it is still an `httpx.HTTPStatusError`, so code that caught
284
+ that before keeps working.
285
+
286
+ ```python
287
+ from haystack_integrations.document_stores.opensolr import OpensolrDocumentStore, OpensolrSchemaError, OpensolrSolrError
288
+
289
+ try:
290
+ store.write_files("./contracts")
291
+ except OpensolrSchemaError as e:
292
+ print(e.capability, e.report["fix"]["action"]) # ingestion new_index
293
+ ```
294
+
295
+ **See the details, and fix what can be fixed:**
296
+
297
+ ```python
298
+ store.check_schema() # ready, capabilities, server, Solr version, missing / different fields, problems, fix
299
+ store.apply_schema(force=False) # installs the Opensolr vector schema; force=True when the index holds documents
300
+ ```
301
+
302
+ `apply_schema` exists so an index of a vector region whose configuration was
303
+ changed, or never set, can take everything this package does without being
304
+ recreated: it installs the same configuration every Opensolr vector index runs,
305
+ and **replaces the index configuration**. An index that already holds documents
306
+ needs `force=True`; those documents keep the fields they were written with and
307
+ must be sent again. An index outside the vector regions cannot take it — create
308
+ a new index in a region from `vector_regions` and send your documents there. The
309
+ package never installs the schema on its own.
310
+
311
+
242
312
  ## Your index schema
243
313
 
244
314
  Documents follow the Opensolr document model (`title`, `description`, `text`,
@@ -1,6 +1,7 @@
1
1
  """Opensolr DocumentStore for Haystack — managed Apache Solr with server-side
2
2
  embeddings and native hybrid (BM25 + kNN) search."""
3
3
 
4
+ from haystack_integrations.document_stores.opensolr.client import OpensolrError, OpensolrSchemaError, OpensolrSolrError
4
5
  from haystack_integrations.document_stores.opensolr.store import OpensolrDocumentStore
5
6
 
6
- __all__ = ["OpensolrDocumentStore"]
7
+ __all__ = ["OpensolrDocumentStore", "OpensolrError", "OpensolrSchemaError", "OpensolrSolrError"]
@@ -14,12 +14,15 @@ import re
14
14
  import json
15
15
  import time
16
16
  import hashlib
17
+ import logging
17
18
  from pathlib import Path
18
19
  from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
19
20
  from urllib.parse import quote
20
21
 
21
22
  import httpx
22
23
 
24
+ _log = logging.getLogger("opensolr")
25
+
23
26
  MGMT_BASE = "https://opensolr.com/solr_manager/api"
24
27
  AI_BASE = "https://api.opensolr.com/solr_manager/api"
25
28
 
@@ -743,6 +746,29 @@ class OpensolrError(RuntimeError):
743
746
  """Raised when an Opensolr API call fails."""
744
747
 
745
748
 
749
+ class OpensolrSolrError(OpensolrError, httpx.HTTPStatusError):
750
+ """Solr refused a request on an index: the message carries Solr's own reason. Also an
751
+ ``httpx.HTTPStatusError``, so code that caught that before still catches it."""
752
+
753
+ def __init__(self, message: str, response: "httpx.Response") -> None:
754
+ httpx.HTTPStatusError.__init__(self, message, request=response.request, response=response)
755
+
756
+
757
+ class OpensolrSchemaError(OpensolrError):
758
+ """The index cannot do what was asked: it is outside the vector regions, or its schema
759
+ lacks what the operation needs. ``report`` is the full check (see ``check_schema``)."""
760
+
761
+ def __init__(self, index: str, capability: str, report: Dict[str, Any]) -> None:
762
+ self.index = index
763
+ self.capability = capability
764
+ self.report = report
765
+ what = OpensolrClient.CAPABILITY_TEXT.get(capability, capability)
766
+ problems = " ".join(report.get("problems") or []) or "The index check says it cannot."
767
+ fix = (report.get("fix") or {}).get("text") or ""
768
+ super().__init__(f"Index {index!r} cannot {what}. {problems} {fix} "
769
+ f"(client.check_schema({index!r}) shows the details.)".replace(" ", " "))
770
+
771
+
746
772
  class OpensolrClient:
747
773
  """Authenticated client for Opensolr management + AI endpoints.
748
774
 
@@ -758,8 +784,9 @@ class OpensolrClient:
758
784
  self.api_key = api_key
759
785
  self._http = httpx.Client(timeout=timeout, follow_redirects=True)
760
786
  self._core_info_cache: Dict[str, Dict[str, Any]] = {}
761
- # indexes whose schema has no PDF page fields: searched and read without them
762
- self._flat_indexes: set = set()
787
+ # the schema check of each index, made once (check_schema); when it could not run
788
+ self._schema_reports: Dict[str, Dict[str, Any]] = {}
789
+ self._schema_failed: Dict[str, float] = {}
763
790
 
764
791
  # ------------------------------------------------------------------ #
765
792
  # low level #
@@ -792,9 +819,21 @@ class OpensolrClient:
792
819
  except json.JSONDecodeError as exc:
793
820
  raise OpensolrError(f"{label}: non-JSON response: {resp.text[:200]}") from exc
794
821
  if isinstance(body, dict) and body.get("status") is False:
795
- raise OpensolrError(f"{label}: {body.get('msg', body)}")
822
+ detail = body.get("detail")
823
+ raise OpensolrError(f"{label}: {body.get('msg', body)}" + (f": {detail}" if detail else ""))
796
824
  return body
797
825
 
826
+ def _solr_answer(self, index: str, what: str, resp: "httpx.Response") -> Dict[str, Any]:
827
+ """Solr's JSON, or :class:`OpensolrSolrError` with Solr's own reason."""
828
+ if resp.status_code >= 400:
829
+ try:
830
+ why = (resp.json().get("error") or {}).get("msg") or ""
831
+ except (ValueError, AttributeError):
832
+ why = re.sub(r"<[^>]+>", " ", resp.text)[:300].strip()
833
+ raise OpensolrSolrError(
834
+ f"Solr refused the {what} on index {index} (HTTP {resp.status_code}): {why or resp.reason_phrase}", resp)
835
+ return resp.json()
836
+
798
837
  def _post_with_retry(self, *, url: str, label: str, **kwargs: Any) -> "httpx.Response":
799
838
  """POST, retrying only when the connection failed BEFORE a response was produced.
800
839
 
@@ -940,6 +979,7 @@ class OpensolrClient:
940
979
  ``wait=True``, polls ``ingest_status`` until the job completes
941
980
  (the queue is processed every minute).
942
981
  """
982
+ self.require_capability(index, "ingestion")
943
983
  resp = self._post_with_retry(label="ingest", url=
944
984
  f"{AI_BASE}/ingest",
945
985
  json={**self._auth_params(), "core_name": index, "documents": documents},
@@ -1036,8 +1076,7 @@ class OpensolrClient:
1036
1076
  def solr_select(self, index: str, params: Dict[str, Any]) -> Dict[str, Any]:
1037
1077
  base, auth = self.solr_endpoint(index)
1038
1078
  resp = self._post_with_retry(label="solr_select", url=f"{base}/select", data={"wt": "json", **params}, auth=auth)
1039
- resp.raise_for_status()
1040
- return resp.json()
1079
+ return self._solr_answer(index, "search", resp)
1041
1080
 
1042
1081
  def hybrid_search(
1043
1082
  self,
@@ -1073,6 +1112,7 @@ class OpensolrClient:
1073
1112
  if not 0.0 <= alpha_f <= 1.0:
1074
1113
  raise ValueError(f"alpha must be between 0 and 1, got {alpha_f}")
1075
1114
 
1115
+ self.require_capability(index, "vector_search")
1076
1116
  clean = query.replace("{", " ").replace("}", " ").replace('"', " ")
1077
1117
  vector = self.embed(index, query, is_query=True)
1078
1118
  compact = json.dumps(vector, separators=(",", ":"))
@@ -1232,8 +1272,91 @@ class OpensolrClient:
1232
1272
  json=payload,
1233
1273
  auth=auth,
1234
1274
  )
1235
- resp.raise_for_status()
1236
- return resp.json()
1275
+ return self._solr_answer(index, "update", resp)
1276
+
1277
+ # ------------------------------------------------------------------ #
1278
+ # the Opensolr vector schema: checked once per index, installed on request #
1279
+ # ------------------------------------------------------------------ #
1280
+
1281
+ #: Seconds before a schema check that could not run is tried again (never on every call).
1282
+ SCHEMA_RETRY_SECONDS = 300.0
1283
+
1284
+ #: What each capability lets a client do, for the error messages.
1285
+ CAPABILITY_TEXT = {
1286
+ "ingestion": "take documents or files through Data Ingestion",
1287
+ "vector_search": "be searched by meaning (vector or hybrid search)",
1288
+ "pdf_pages": "return PDF pages",
1289
+ "keyword_search": "be searched by keywords",
1290
+ }
1291
+
1292
+ def check_schema(self, index: str) -> Dict[str, Any]:
1293
+ """Whether an index is ready for the Opensolr packages, and every reason when it is not.
1294
+
1295
+ One request to the platform, which reads the index's schema and compares it with the
1296
+ Opensolr vector schema. The answer carries ``ready``, ``capabilities``
1297
+ (``ingestion``, ``vector_search``, ``pdf_pages``, ``keyword_search``), the server,
1298
+ the Solr version, whether the server is in a vector region, the fields and types
1299
+ that are missing or different, ``problems`` (sentences) and ``fix`` (``none``,
1300
+ ``apply_schema`` or ``new_index``, with what to do).
1301
+ """
1302
+ return self.mgmt("check_schema", core_name=index)
1303
+
1304
+ def apply_schema(self, index: str, force: bool = False) -> Dict[str, Any]:
1305
+ """Install the Opensolr vector schema on an index of a vector region.
1306
+
1307
+ It replaces the index configuration with the one every Opensolr vector index runs,
1308
+ so Data Ingestion, vector and hybrid search and PDF pages all work on it. An index
1309
+ that already holds documents needs ``force=True``: those documents keep the fields
1310
+ they were written with and must be sent again. An index outside the vector regions
1311
+ cannot take it: the error says where to create one. Returns the new check.
1312
+ """
1313
+ self._schema_reports.pop(index, None)
1314
+ self._schema_failed.pop(index, None)
1315
+ body = self.mgmt("apply_schema", core_name=index, force="1" if force else "0")
1316
+ check = body.get("check") if isinstance(body, dict) else None
1317
+ if isinstance(check, dict) and check.get("status") is True:
1318
+ self._schema_reports[index] = check
1319
+ return body
1320
+
1321
+ def schema_report(self, index: str) -> Optional[Dict[str, Any]]:
1322
+ """The check of an index, made once for this client (a check that could not run is
1323
+ tried again after :data:`SCHEMA_RETRY_SECONDS`, and nothing is concluded from it)."""
1324
+ if index in self._schema_reports:
1325
+ return self._schema_reports[index]
1326
+ failed_at = self._schema_failed.get(index)
1327
+ if failed_at is not None and time.time() - failed_at < self.SCHEMA_RETRY_SECONDS:
1328
+ return None
1329
+ try:
1330
+ report = self.check_schema(index)
1331
+ except OpensolrError as exc:
1332
+ self._schema_failed[index] = time.time()
1333
+ _log.warning("Opensolr: the schema of index %s could not be checked (%s); working as before, without the check", index, exc)
1334
+ return None
1335
+ self._schema_failed.pop(index, None)
1336
+ self._schema_reports[index] = report
1337
+ if not report.get("ready"):
1338
+ fix = (report.get("fix") or {}).get("text") or ""
1339
+ _log.warning("Opensolr: index %s is not on the Opensolr vector schema: %s %s", index,
1340
+ " ".join(report.get("problems") or []), fix)
1341
+ return report
1342
+
1343
+ def require_capability(self, index: str, capability: str) -> None:
1344
+ """Raise :class:`OpensolrSchemaError` when the index's check says it cannot do this.
1345
+
1346
+ Uses the check made once for this client; when the check could not run, nothing is
1347
+ blocked and the operation runs as it always did.
1348
+ """
1349
+ report = self.schema_report(index)
1350
+ if report is None:
1351
+ return
1352
+ caps = report.get("capabilities") or {}
1353
+ if caps.get(capability) is False:
1354
+ raise OpensolrSchemaError(index, capability, report)
1355
+
1356
+ def has_capability(self, index: str, capability: str) -> bool:
1357
+ """True only when the index's check says it can (an unknown answer is False)."""
1358
+ report = self.schema_report(index)
1359
+ return bool(report and (report.get("capabilities") or {}).get(capability))
1237
1360
 
1238
1361
  # ------------------------------------------------------------------ #
1239
1362
  # PDF pages, documents, pictures #
@@ -1242,22 +1365,16 @@ class OpensolrClient:
1242
1365
  def select_grouped(self, index: str, params: Dict[str, Any], group_pages: bool = True) -> Dict[str, Any]:
1243
1366
  """``solr_select`` with the pages of each PDF grouped under its best page.
1244
1367
 
1245
- See :func:`apply_page_grouping`. An index whose schema has no page fields is searched
1246
- as it is, and remembered, so it is asked once.
1368
+ See :func:`apply_page_grouping`. Grouped only when the index's check (made once)
1369
+ says it has the PDF page fields; otherwise searched as it is.
1247
1370
  """
1248
- if not group_pages or index in self._flat_indexes:
1371
+ if not group_pages or not self.has_capability(index, "pdf_pages"):
1249
1372
  return self.solr_select(index, params)
1250
1373
  grouped = dict(params)
1251
1374
  if isinstance(grouped.get("fq"), list):
1252
1375
  grouped["fq"] = list(grouped["fq"])
1253
1376
  apply_page_grouping(grouped)
1254
- try:
1255
- return self.solr_select(index, grouped)
1256
- except httpx.HTTPStatusError as exc:
1257
- if exc.response.status_code == 400 and PARENT_FIELD in exc.response.text:
1258
- self._flat_indexes.add(index)
1259
- return self.solr_select(index, params)
1260
- raise
1377
+ return self.solr_select(index, grouped)
1261
1378
 
1262
1379
  def read_document(
1263
1380
  self,
@@ -1285,24 +1402,17 @@ class OpensolrClient:
1285
1402
  last = min(last, first + READ_MAX_PAGES - 1)
1286
1403
  esc = key.replace("\\", "\\\\").replace('"', '\\"')
1287
1404
  fl = "id,uri,title,description,text,content_type,creation_date,meta_ext_id"
1288
- docs: List[Dict[str, Any]] = []
1289
- if index not in self._flat_indexes:
1290
- try:
1291
- body = self.solr_select(index, {
1292
- "q": f'id:"{esc}" OR {PARENT_FIELD}:"{esc}" OR meta_ext_id:"{esc}"',
1293
- "fq": f"(*:* -{PAGE_FIELD}:[* TO *]) OR {PAGE_FIELD}:[{first} TO {last}]",
1294
- "sort": f"{PAGE_FIELD} asc",
1295
- "rows": last - first + 1,
1296
- "fl": f"{fl},{PAGE_FIELD},{PARENT_FIELD}",
1297
- })
1298
- docs = body.get("response", {}).get("docs", [])
1299
- except httpx.HTTPStatusError as exc:
1300
- if exc.response.status_code != 400 or (PAGE_FIELD not in exc.response.text and PARENT_FIELD not in exc.response.text):
1301
- raise
1302
- self._flat_indexes.add(index)
1303
- if index in self._flat_indexes:
1405
+ if self.has_capability(index, "pdf_pages"):
1406
+ body = self.solr_select(index, {
1407
+ "q": f'id:"{esc}" OR {PARENT_FIELD}:"{esc}" OR meta_ext_id:"{esc}"',
1408
+ "fq": f"(*:* -{PAGE_FIELD}:[* TO *]) OR {PAGE_FIELD}:[{first} TO {last}]",
1409
+ "sort": f"{PAGE_FIELD} asc",
1410
+ "rows": last - first + 1,
1411
+ "fl": f"{fl},{PAGE_FIELD},{PARENT_FIELD}",
1412
+ })
1413
+ else:
1304
1414
  body = self.solr_select(index, {"q": f'id:"{esc}" OR meta_ext_id:"{esc}"', "rows": 1, "fl": fl})
1305
- docs = body.get("response", {}).get("docs", [])
1415
+ docs: List[Dict[str, Any]] = body.get("response", {}).get("docs", [])
1306
1416
  if not docs:
1307
1417
  raise OpensolrError(f"read_document: nothing in {index} for {document!r}")
1308
1418
 
@@ -1519,6 +1629,8 @@ class OpensolrClient:
1519
1629
  Returns ``{"documents": [...], "pending": [...], "failed": [...], "jobs": [...]}``;
1520
1630
  ``jobs`` are the Data Ingestion job ids (``wait=True`` blocks until they are done).
1521
1631
  """
1632
+ # before a byte is sent: an index that cannot take Data Ingestion says so now, not after the uploads
1633
+ self.require_capability(index, "ingestion")
1522
1634
  meta = {str(k): v for k, v in (metadata or {}).items() if isinstance(v, (str, int, float, bool))}
1523
1635
  root = Path(path).expanduser()
1524
1636
  files = list_files(root, pattern, recursive)
@@ -217,15 +217,16 @@ class OpensolrDocumentStore:
217
217
  score = solr_doc.get("score")
218
218
  ext = _flat(solr_doc.get("meta_ext_id"))
219
219
  doc_id = str(ext) if ext else str(_flat(solr_doc.get("id", "")))
220
- # Where the hit lives: uri; a page of a PDF adds page_number, page_url, document_id, more_pages
220
+ # A page of a PDF also says where it lives (uri, page_number, page_url, document_id, more_pages);
221
+ # any other document keeps exactly the meta it was sent with
221
222
  where = page_info(solr_doc, expanded)
222
223
  if where.get("page"):
223
224
  where["page_number"] = where.pop("page")
224
225
  # every page of a PDF carries the PDF's meta_ext_id: the page number keeps ids apart
225
226
  doc_id = f"{doc_id}#page={where['page_number']}"
226
- for key, value in where.items():
227
- if value not in ("", None):
228
- meta.setdefault(key, value)
227
+ for key, value in where.items():
228
+ if value not in ("", None):
229
+ meta.setdefault(key, value)
229
230
  return Document(
230
231
  id=doc_id,
231
232
  content=str(content),
@@ -290,6 +291,26 @@ class OpensolrDocumentStore:
290
291
  self.client.ingest(self.index, docs[i : i + 50], wait=self.ingest_wait)
291
292
  return len(docs)
292
293
 
294
+ def check_schema(self) -> Dict[str, Any]:
295
+ """Whether this index is ready for every feature of the store, and exactly why not.
296
+
297
+ Returns the platform's check: ``ready``, ``capabilities`` (``ingestion``,
298
+ ``vector_search``, ``pdf_pages``, ``keyword_search``), the server, its Solr
299
+ version, the fields and types that differ from the Opensolr vector schema,
300
+ ``problems`` and ``fix``. The store runs this check once on its own.
301
+ """
302
+ self._ensure_index()
303
+ return self.client.check_schema(self.index)
304
+
305
+ def apply_schema(self, force: bool = False) -> Dict[str, Any]:
306
+ """Install the Opensolr vector schema on this index (an index of a vector region).
307
+
308
+ It replaces the index configuration; an index that already holds documents needs
309
+ ``force=True``, and those documents must be sent again. Returns the new check.
310
+ """
311
+ self._ensure_index()
312
+ return self.client.apply_schema(self.index, force=force)
313
+
293
314
  def write_files(
294
315
  self,
295
316
  path: Union[str, Path],
@@ -402,7 +423,7 @@ class OpensolrDocumentStore:
402
423
  dated in that period (``creation_date``). ``group_pages`` (on by default) returns
403
424
  a PDF indexed page by page once, as its best page: ``meta`` carries
404
425
  ``page_number``, ``page_url`` (opens the PDF at that page), ``document_id`` and
405
- ``more_pages`` (its other matching pages). Every document carries ``uri``.
426
+ ``more_pages`` (its other matching pages) and its ``uri``.
406
427
  """
407
428
  self._ensure_index()
408
429
  params: Dict[str, Any] = {"rows": top_k, "fl": "*,score"}
@@ -434,6 +455,8 @@ class OpensolrDocumentStore:
434
455
  expanded = body.get("expanded") or {}
435
456
  return [self._doc_from_solr(d, expanded) for d in body["response"]["docs"]]
436
457
 
458
+ # An index without the vector fields says so before an embedding is paid for
459
+ self.client.require_capability(self.index, "vector_search")
437
460
  # The embedder must never see an operator — it has no concept of negation, so a '-'
438
461
  # term reads as one more word and pulls results towards what the caller asked to drop.
439
462
  vector = self.client.embed(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: opensolr-haystack
3
- Version: 0.7.0
3
+ Version: 0.8.0
4
4
  Summary: Haystack integration for Opensolr — managed Apache Solr DocumentStore with server-side embeddings and hybrid BM25+kNN retrieval
5
5
  Author-email: Opensolr <support@opensolr.com>
6
6
  License: MIT
@@ -61,8 +61,8 @@ the query. The store embeds server-side at both index and query time.
61
61
 
62
62
  ## Try it without an account
63
63
 
64
- There is a public demo account. Point the package at it and search, ingestion and
65
- answers work immediately, with no signup:
64
+ There is a public demo account. Point the package at it and everything in this
65
+ README works immediately, with no signup:
66
66
 
67
67
  ```bash
68
68
  export OPENSOLR_EMAIL=mcp@opensolr.com
@@ -70,17 +70,18 @@ export OPENSOLR_API_KEY=420b8b23e7b12dc8ab838932145a5065
70
70
  ```
71
71
 
72
72
  `mcp_demo_d1__dense` is already loaded with 300 news articles, so search, filtering
73
- and grounded answers work the moment you connect. You also get the full write path:
74
- create your own index on the account, ingest into it, query it, delete it.
75
- Reading files and pictures (the file loader and file indexing below, search by image)
76
- needs your own key: the shared demo key does not include them.
73
+ and grounded answers work the moment you connect. You also get the write path:
74
+ create your own index on the account, ingest into it and query it. Deletion is not
75
+ available on this shared key: no index here can be deleted or reconfigured by hand.
76
+ Whatever you create is removed automatically after 3 days.
77
77
 
78
78
  Know what you are working with:
79
79
 
80
80
  - **Anything you create there is deleted after 3 days.** Automatically, without warning
81
81
  or export. That includes indexes you created and every document in them.
82
- - **The account is shared with everyone reading this.** Your index is visible to them,
83
- they can change or delete it, and you can do the same to theirs. Never put anything
82
+ - **The account is shared with everyone reading this.** Your index is visible to them
83
+ and they can add documents to it, as you can to theirs. Nobody can delete or
84
+ reconfigure an index here (that is switched off for this key), but never put anything
84
85
  real, private or client-owned in it.
85
86
  - **The limits are per index, and deliberately small.** 200 MB of bandwidth and 50 MB
86
87
  of disk per index. Bandwidth is the one you will hit first: it covers a demo, a
@@ -172,8 +173,9 @@ text from PDF/DOCX/XLSX for you.
172
173
  ## Lexical-only mode
173
174
 
174
175
  Don't need vectors? Pure keyword search skips the embedding call entirely —
175
- zero AI quota, and it works on **any** Opensolr index, including non-vector
176
- ones and older Solr versions.
176
+ zero AI quota. It runs on any Opensolr index that has `title`, `description` and
177
+ `text` fields, including indexes outside the vector regions and older Solr
178
+ versions (see [Index requirements](#index-requirements)).
177
179
 
178
180
  ## Search by image
179
181
 
@@ -241,7 +243,7 @@ Data Ingestion with `rtf`, PDFs found by the Web Crawler), a search returns each
241
243
  **once**, as its best page. Its metadata carries `page_number` (the page number),
242
244
  `page_url` (opens the PDF at that page, `...pdf#page=14`), `document_id`,
243
245
  `more_pages` (its other matching pages, each with `page` and `page_url`) and
244
- `matching_pages`; every hit carries its `uri`. `group_pages=False` returns the pages
246
+ `matching_pages`, and its `uri`; any other hit keeps exactly the metadata it was sent with. `group_pages=False` returns the pages
245
247
  as separate results.
246
248
 
247
249
  ## Searching a period
@@ -257,6 +259,74 @@ OpensolrHybridRetriever(document_store=store, date_from="2026-01-01")
257
259
 
258
260
  They combine with every other option — filters, `fresh_bias`, hybrid or lexical.
259
261
 
262
+ ## Index requirements
263
+
264
+ Everything this package does runs on an Opensolr index that sits in a **vector
265
+ region** and carries the **Opensolr vector schema** — the configuration every
266
+ Opensolr vector index runs. An index created through the package (`create_index`,
267
+ `create_if_missing`) has both. An index made elsewhere may not: a server outside
268
+ the vector regions, an older Solr version, or a schema of its own.
269
+
270
+ **The check is automatic.** The first time the package touches an index, it asks
271
+ the platform once whether that index can do what is asked: one request per index
272
+ per client, kept for the client's life, never one per search. If the check itself
273
+ cannot run (a network error, the platform busy), nothing is blocked: the
274
+ operation runs exactly as it always did, and the check is tried again after 5
275
+ minutes. When an index differs from the Opensolr vector schema, a warning is logged
276
+ once, on the `opensolr` logger, with the reasons and the fix.
277
+
278
+ What the check gates — only what cannot work otherwise:
279
+
280
+ | Capability | Needed for | When it is missing |
281
+ |---|---|---|
282
+ | `ingestion` | Data Ingestion: adding documents and indexing files | The index is outside the vector regions: refused before a document or a byte is sent |
283
+ | `vector_search` | Vector and hybrid search | The index has no vector field of the right size: refused before any embedding is paid for |
284
+ | `pdf_pages` | Grouping the pages of a PDF in search results | Results come back ungrouped, as before |
285
+ | `keyword_search` | Keyword (lexical) search | Never blocked |
286
+
287
+ **The errors say exactly what is going on.** A blocked operation raises
288
+ `OpensolrSchemaError` (a subclass of `OpensolrError`) with `.index`, `.capability`
289
+ and `.report` (the full check), and a message with every reason and the fix:
290
+
291
+ ```
292
+ Index 'shop_it' cannot take documents or files through Data Ingestion. Index shop_it
293
+ is on IT-SOLR-9-6 (Milano, Italy), which is not a vector region: Data Ingestion,
294
+ server-side embeddings and hybrid search run only on the servers of a vector region
295
+ (DE-SOLR-9 (Germany, Solr 9.00), FINLAND9 (Finland, Solr 9.60), ...). This index
296
+ cannot be made ready: create a new index in one of the vector regions (...), for
297
+ example with create_index, and send your documents there.
298
+ ```
299
+
300
+ When Solr itself refuses a request, `OpensolrSolrError` carries Solr's own reason
301
+ and the index name; it is still an `httpx.HTTPStatusError`, so code that caught
302
+ that before keeps working.
303
+
304
+ ```python
305
+ from haystack_integrations.document_stores.opensolr import OpensolrDocumentStore, OpensolrSchemaError, OpensolrSolrError
306
+
307
+ try:
308
+ store.write_files("./contracts")
309
+ except OpensolrSchemaError as e:
310
+ print(e.capability, e.report["fix"]["action"]) # ingestion new_index
311
+ ```
312
+
313
+ **See the details, and fix what can be fixed:**
314
+
315
+ ```python
316
+ store.check_schema() # ready, capabilities, server, Solr version, missing / different fields, problems, fix
317
+ store.apply_schema(force=False) # installs the Opensolr vector schema; force=True when the index holds documents
318
+ ```
319
+
320
+ `apply_schema` exists so an index of a vector region whose configuration was
321
+ changed, or never set, can take everything this package does without being
322
+ recreated: it installs the same configuration every Opensolr vector index runs,
323
+ and **replaces the index configuration**. An index that already holds documents
324
+ needs `force=True`; those documents keep the fields they were written with and
325
+ must be sent again. An index outside the vector regions cannot take it — create
326
+ a new index in a region from `vector_regions` and send your documents there. The
327
+ package never installs the schema on its own.
328
+
329
+
260
330
  ## Your index schema
261
331
 
262
332
  Documents follow the Opensolr document model (`title`, `description`, `text`,
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "opensolr-haystack"
7
- version = "0.7.0"
7
+ version = "0.8.0"
8
8
  description = "Haystack integration for Opensolr — managed Apache Solr DocumentStore with server-side embeddings and hybrid BM25+kNN retrieval"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }