opensolr-haystack 0.6.0__tar.gz → 0.7.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.6.0 → opensolr_haystack-0.7.0}/PKG-INFO +62 -3
  2. {opensolr_haystack-0.6.0 → opensolr_haystack-0.7.0}/README.md +61 -2
  3. opensolr_haystack-0.7.0/haystack_integrations/components/converters/opensolr/__init__.py +5 -0
  4. opensolr_haystack-0.7.0/haystack_integrations/components/converters/opensolr/converter.py +123 -0
  5. {opensolr_haystack-0.6.0 → opensolr_haystack-0.7.0}/haystack_integrations/components/retrievers/opensolr/retriever.py +19 -1
  6. {opensolr_haystack-0.6.0 → opensolr_haystack-0.7.0}/haystack_integrations/document_stores/opensolr/client.py +484 -4
  7. {opensolr_haystack-0.6.0 → opensolr_haystack-0.7.0}/haystack_integrations/document_stores/opensolr/store.py +66 -12
  8. {opensolr_haystack-0.6.0 → opensolr_haystack-0.7.0}/opensolr_haystack.egg-info/PKG-INFO +62 -3
  9. {opensolr_haystack-0.6.0 → opensolr_haystack-0.7.0}/opensolr_haystack.egg-info/SOURCES.txt +2 -0
  10. {opensolr_haystack-0.6.0 → opensolr_haystack-0.7.0}/pyproject.toml +1 -1
  11. {opensolr_haystack-0.6.0 → opensolr_haystack-0.7.0}/LICENSE +0 -0
  12. {opensolr_haystack-0.6.0 → opensolr_haystack-0.7.0}/haystack_integrations/components/retrievers/opensolr/__init__.py +0 -0
  13. {opensolr_haystack-0.6.0 → opensolr_haystack-0.7.0}/haystack_integrations/document_stores/opensolr/__init__.py +0 -0
  14. {opensolr_haystack-0.6.0 → opensolr_haystack-0.7.0}/opensolr_haystack.egg-info/dependency_links.txt +0 -0
  15. {opensolr_haystack-0.6.0 → opensolr_haystack-0.7.0}/opensolr_haystack.egg-info/requires.txt +0 -0
  16. {opensolr_haystack-0.6.0 → opensolr_haystack-0.7.0}/opensolr_haystack.egg-info/top_level.txt +0 -0
  17. {opensolr_haystack-0.6.0 → opensolr_haystack-0.7.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: opensolr-haystack
3
- Version: 0.6.0
3
+ Version: 0.7.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 everything in this
65
- README works immediately, with no signup:
64
+ There is a public demo account. Point the package at it and search, ingestion and
65
+ answers work immediately, with no signup:
66
66
 
67
67
  ```bash
68
68
  export OPENSOLR_EMAIL=mcp@opensolr.com
@@ -72,6 +72,8 @@ export OPENSOLR_API_KEY=420b8b23e7b12dc8ab838932145a5065
72
72
  `mcp_demo_d1__dense` is already loaded with 300 news articles, so search, filtering
73
73
  and grounded answers work the moment you connect. You also get the full write path:
74
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.
75
77
 
76
78
  Know what you are working with:
77
79
 
@@ -198,6 +200,63 @@ docs = store.search_by_image("photo.jpg", using="all") # labels + OCR + cod
198
200
  `alpha`, `fresh_bias`, `filters` — so an image query runs through the exact
199
201
  hybrid pipeline a text query does.
200
202
 
203
+ ## Local files: PDFs page by page, scans through OCR
204
+
205
+ `OpensolrConverter` turns files into Haystack `Document`s — one per PDF page
206
+ (`meta["page_number"]`), one per any other file. It takes file paths, folders and
207
+ `ByteStream`s:
208
+
209
+ ```python
210
+ from haystack_integrations.components.converters.opensolr import OpensolrConverter
211
+
212
+ converter = OpensolrConverter(document_store=store)
213
+ docs = converter.run(sources=["./contracts", "scan.png"], meta={"team": "legal"})["documents"]
214
+ # meta: file_path, page_number, is_ocr, language, content_type (+ yours)
215
+ ```
216
+
217
+ Or index them straight into the store — no converter, no splitter, PDFs page by page:
218
+
219
+ ```python
220
+ result = store.write_files("./contracts", pattern="*.pdf", metadata={"team": "legal"})
221
+ # {"documents": [{path, source, uri, id}], "pending": [], "failed": [], "jobs": [...]}
222
+ ```
223
+
224
+ - Each file is read on Opensolr's servers: its text, the text in its pictures (OCR,
225
+ more than 100 languages), a short description of the pictures without text. Word,
226
+ RTF, OpenDocument, text, HTML and picture files (scans) are one document each; a
227
+ **PDF is one document per page**.
228
+ - The bytes travel once: a file the account already sent (same content) is not
229
+ uploaded again. Pictures read by OCR count 0.5 of an AI request each; a file read
230
+ before is free.
231
+ - Files still being read when `timeout` runs out are under `result["pending"]` (the
232
+ converter logs them): run again to get them, nothing is sent twice.
233
+ - An indexed file's `uri` is `https://ingest.opensolr.com/<index>/<source>`, where
234
+ `source` is its path from the folder you sent (`meta["source"]`); sending it again
235
+ replaces it. Its date is the file's modification time.
236
+
237
+ ## PDF pages in search results
238
+
239
+ On an index where PDFs are stored page by page (files indexed as above, PDFs sent to
240
+ Data Ingestion with `rtf`, PDFs found by the Web Crawler), a search returns each PDF
241
+ **once**, as its best page. Its metadata carries `page_number` (the page number),
242
+ `page_url` (opens the PDF at that page, `...pdf#page=14`), `document_id`,
243
+ `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
245
+ as separate results.
246
+
247
+ ## Searching a period
248
+
249
+ `date_from` and `date_to` (`YYYY-MM-DD`, both included, either one can be left out)
250
+ keep only the documents dated in that period (`creation_date`):
251
+
252
+ ```python
253
+ docs = store.search("rate decision", top_k=5, date_from="2026-01-01", date_to="2026-03-31")
254
+ # or in a pipeline:
255
+ OpensolrHybridRetriever(document_store=store, date_from="2026-01-01")
256
+ ```
257
+
258
+ They combine with every other option — filters, `fresh_bias`, hybrid or lexical.
259
+
201
260
  ## Your index schema
202
261
 
203
262
  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 everything in this
47
- README works immediately, with no signup:
46
+ There is a public demo account. Point the package at it and search, ingestion and
47
+ answers work immediately, with no signup:
48
48
 
49
49
  ```bash
50
50
  export OPENSOLR_EMAIL=mcp@opensolr.com
@@ -54,6 +54,8 @@ export OPENSOLR_API_KEY=420b8b23e7b12dc8ab838932145a5065
54
54
  `mcp_demo_d1__dense` is already loaded with 300 news articles, so search, filtering
55
55
  and grounded answers work the moment you connect. You also get the full write path:
56
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.
57
59
 
58
60
  Know what you are working with:
59
61
 
@@ -180,6 +182,63 @@ docs = store.search_by_image("photo.jpg", using="all") # labels + OCR + cod
180
182
  `alpha`, `fresh_bias`, `filters` — so an image query runs through the exact
181
183
  hybrid pipeline a text query does.
182
184
 
185
+ ## Local files: PDFs page by page, scans through OCR
186
+
187
+ `OpensolrConverter` turns files into Haystack `Document`s — one per PDF page
188
+ (`meta["page_number"]`), one per any other file. It takes file paths, folders and
189
+ `ByteStream`s:
190
+
191
+ ```python
192
+ from haystack_integrations.components.converters.opensolr import OpensolrConverter
193
+
194
+ converter = OpensolrConverter(document_store=store)
195
+ docs = converter.run(sources=["./contracts", "scan.png"], meta={"team": "legal"})["documents"]
196
+ # meta: file_path, page_number, is_ocr, language, content_type (+ yours)
197
+ ```
198
+
199
+ Or index them straight into the store — no converter, no splitter, PDFs page by page:
200
+
201
+ ```python
202
+ result = store.write_files("./contracts", pattern="*.pdf", metadata={"team": "legal"})
203
+ # {"documents": [{path, source, uri, id}], "pending": [], "failed": [], "jobs": [...]}
204
+ ```
205
+
206
+ - Each file is read on Opensolr's servers: its text, the text in its pictures (OCR,
207
+ more than 100 languages), a short description of the pictures without text. Word,
208
+ RTF, OpenDocument, text, HTML and picture files (scans) are one document each; a
209
+ **PDF is one document per page**.
210
+ - The bytes travel once: a file the account already sent (same content) is not
211
+ uploaded again. Pictures read by OCR count 0.5 of an AI request each; a file read
212
+ before is free.
213
+ - Files still being read when `timeout` runs out are under `result["pending"]` (the
214
+ converter logs them): run again to get them, nothing is sent twice.
215
+ - An indexed file's `uri` is `https://ingest.opensolr.com/<index>/<source>`, where
216
+ `source` is its path from the folder you sent (`meta["source"]`); sending it again
217
+ replaces it. Its date is the file's modification time.
218
+
219
+ ## PDF pages in search results
220
+
221
+ On an index where PDFs are stored page by page (files indexed as above, PDFs sent to
222
+ Data Ingestion with `rtf`, PDFs found by the Web Crawler), a search returns each PDF
223
+ **once**, as its best page. Its metadata carries `page_number` (the page number),
224
+ `page_url` (opens the PDF at that page, `...pdf#page=14`), `document_id`,
225
+ `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
227
+ as separate results.
228
+
229
+ ## Searching a period
230
+
231
+ `date_from` and `date_to` (`YYYY-MM-DD`, both included, either one can be left out)
232
+ keep only the documents dated in that period (`creation_date`):
233
+
234
+ ```python
235
+ docs = store.search("rate decision", top_k=5, date_from="2026-01-01", date_to="2026-03-31")
236
+ # or in a pipeline:
237
+ OpensolrHybridRetriever(document_store=store, date_from="2026-01-01")
238
+ ```
239
+
240
+ They combine with every other option — filters, `fresh_bias`, hybrid or lexical.
241
+
183
242
  ## Your index schema
184
243
 
185
244
  Documents follow the Opensolr document model (`title`, `description`, `text`,
@@ -0,0 +1,5 @@
1
+ """Opensolr converter component for Haystack: files read on Opensolr, a PDF page by page."""
2
+
3
+ from haystack_integrations.components.converters.opensolr.converter import OpensolrConverter
4
+
5
+ __all__ = ["OpensolrConverter"]
@@ -0,0 +1,123 @@
1
+ """Converter component: local files read on Opensolr's servers (text, OCR, a PDF page by page)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import tempfile
8
+ from pathlib import Path
9
+ from typing import Any, Dict, List, Optional, Union
10
+
11
+ from haystack import Document, component, default_from_dict, default_to_dict
12
+ from haystack.dataclasses import ByteStream
13
+
14
+ from haystack_integrations.document_stores.opensolr import OpensolrDocumentStore
15
+ from haystack_integrations.document_stores.opensolr.client import list_files
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ @component
21
+ class OpensolrConverter:
22
+ """Turn files into Documents, read on Opensolr's servers.
23
+
24
+ Each file is read there: its text, the text in its pictures (OCR, more than 100
25
+ languages), a short description of the pictures that have no text, scanned pages
26
+ and picture files (JPG, PNG, TIFF...) included. A PDF gives one Document per page
27
+ (``meta["page_number"]``), any other file one Document. Nothing is indexed: put an
28
+ embedder and a writer after it, or use ``OpensolrDocumentStore.write_files`` to
29
+ index files directly.
30
+
31
+ Example:
32
+ ```python
33
+ from haystack_integrations.document_stores.opensolr import OpensolrDocumentStore
34
+ from haystack_integrations.components.converters.opensolr import OpensolrConverter
35
+
36
+ store = OpensolrDocumentStore(index="mysite__dense")
37
+ converter = OpensolrConverter(document_store=store)
38
+ docs = converter.run(sources=["./contracts"])["documents"]
39
+ ```
40
+
41
+ The work is counted on the store's index: each picture read costs 0.5 of an AI
42
+ request; a file this account sent before is read for free. ``sources`` takes file
43
+ paths, folders (every file in them, sub-folders included) and ``ByteStream``s.
44
+ Files that could not be read, or were still being read when ``timeout`` ran out,
45
+ are logged and left out; run again to get the late ones (they are not sent twice).
46
+ """
47
+
48
+ #: Files sent, and waited for, together.
49
+ BATCH = 20
50
+
51
+ def __init__(self, document_store: OpensolrDocumentStore, timeout: float = 600.0) -> None:
52
+ self.document_store = document_store
53
+ self.timeout = timeout
54
+
55
+ @component.output_types(documents=List[Document])
56
+ def run(
57
+ self,
58
+ sources: List[Union[str, Path, ByteStream]],
59
+ meta: Optional[Union[Dict[str, Any], List[Dict[str, Any]]]] = None,
60
+ ) -> Dict[str, List[Document]]:
61
+ """``meta``: added to every Document, one dict for all sources or one per source."""
62
+ if isinstance(meta, list) and len(meta) != len(sources):
63
+ raise ValueError("meta must be one dict, or one dict per source")
64
+ jobs: List[tuple] = []
65
+ temps: List[str] = []
66
+ try:
67
+ for i, source in enumerate(sources):
68
+ extra = dict((meta[i] if isinstance(meta, list) else meta) or {})
69
+ if isinstance(source, ByteStream):
70
+ name = Path(str(source.meta.get("file_path") or "document")).name
71
+ fd, tmp = tempfile.mkstemp(suffix="-" + name)
72
+ with os.fdopen(fd, "wb") as fh:
73
+ fh.write(source.data)
74
+ temps.append(tmp)
75
+ jobs.append((tmp, {**(source.meta or {}), "file_path": source.meta.get("file_path") or name, **extra}))
76
+ else:
77
+ for f in list_files(source):
78
+ jobs.append((str(f), {"file_path": str(f), **extra}))
79
+ documents: List[Document] = []
80
+ client = self.document_store.client
81
+ for i in range(0, len(jobs), self.BATCH):
82
+ batch = jobs[i : i + self.BATCH]
83
+ entries = client.extract_files(self.document_store.index, [p for p, _ in batch], pages=True, timeout=self.timeout)
84
+ for entry, (_, base) in zip(entries, batch):
85
+ documents.extend(self._documents(entry, base))
86
+ return {"documents": documents}
87
+ finally:
88
+ for tmp in temps:
89
+ try:
90
+ os.remove(tmp)
91
+ except OSError:
92
+ pass
93
+
94
+ @staticmethod
95
+ def _documents(entry: Dict[str, Any], base: Dict[str, Any]) -> List[Document]:
96
+ state = entry.get("state")
97
+ if state != "done":
98
+ logger.warning("Opensolr did not read %s (%s): %s", base.get("file_path"), state, entry.get("error") or "")
99
+ return []
100
+ base = {**base, "content_type": entry.get("mime") or ""}
101
+ pages = entry.get("pages") or []
102
+ if pages:
103
+ return [
104
+ Document(content=str(p.get("text") or ""), meta={
105
+ **base,
106
+ "page_number": int(p.get("page") or 0),
107
+ "is_ocr": bool(p.get("is_ocr")),
108
+ "language": str(p.get("lang") or ""),
109
+ })
110
+ for p in pages if str(p.get("text") or "").strip()
111
+ ]
112
+ text = str(entry.get("text") or "")
113
+ return [Document(content=text, meta=base)] if text.strip() else []
114
+
115
+ def to_dict(self) -> Dict[str, Any]:
116
+ return default_to_dict(self, document_store=self.document_store.to_dict(), timeout=self.timeout)
117
+
118
+ @classmethod
119
+ def from_dict(cls, data: Dict[str, Any]) -> "OpensolrConverter":
120
+ data["init_parameters"]["document_store"] = OpensolrDocumentStore.from_dict(
121
+ data["init_parameters"]["document_store"]
122
+ )
123
+ return default_from_dict(cls, data)
@@ -39,6 +39,9 @@ class OpensolrHybridRetriever:
39
39
  filters: Optional[Dict[str, Any]] = None,
40
40
  lexical: bool = False,
41
41
  fresh_bias: bool = False,
42
+ date_from: Optional[str] = None,
43
+ date_to: Optional[str] = None,
44
+ group_pages: bool = True,
42
45
  ) -> None:
43
46
  self.document_store = document_store
44
47
  self.top_k = top_k
@@ -51,6 +54,9 @@ class OpensolrHybridRetriever:
51
54
  # for a Haystack user, so an option the component does not carry is an option
52
55
  # that does not exist in a pipeline.
53
56
  self.fresh_bias = fresh_bias
57
+ self.date_from = date_from
58
+ self.date_to = date_to
59
+ self.group_pages = group_pages
54
60
 
55
61
  @component.output_types(documents=List[Document])
56
62
  def run(
@@ -62,13 +68,19 @@ class OpensolrHybridRetriever:
62
68
  filters: Optional[Dict[str, Any]] = None,
63
69
  lexical: Optional[bool] = None,
64
70
  fresh_bias: Optional[bool] = None,
71
+ date_from: Optional[str] = None,
72
+ date_to: Optional[str] = None,
73
+ group_pages: Optional[bool] = None,
65
74
  ) -> Dict[str, List[Document]]:
66
75
  """Run the retriever. ``alpha``: 0 = all semantic, 1 = all lexical.
67
76
  ``lexical=True`` = pure keyword search, no embedding call.
68
77
  ``fresh_bias=True`` biases the ranking toward recent documents by
69
78
  multiplying each score by a recency curve on ``creation_date``; it
70
79
  re-orders and never filters, so the hit count is unchanged and nothing
71
- old becomes unreachable. Off by default."""
80
+ old becomes unreachable. Off by default.
81
+ ``date_from`` / ``date_to`` (``YYYY-MM-DD``) keep only documents dated in that
82
+ period. ``group_pages`` (on by default) returns a PDF indexed page by page once,
83
+ as its best page, with ``page_number``, ``page_url`` and ``more_pages`` in meta."""
72
84
  docs = self.document_store.search(
73
85
  query=query,
74
86
  top_k=top_k if top_k is not None else self.top_k,
@@ -77,6 +89,9 @@ class OpensolrHybridRetriever:
77
89
  filters=filters if filters is not None else self.filters,
78
90
  lexical=lexical if lexical is not None else self.lexical,
79
91
  fresh_bias=fresh_bias if fresh_bias is not None else self.fresh_bias,
92
+ date_from=date_from if date_from is not None else self.date_from,
93
+ date_to=date_to if date_to is not None else self.date_to,
94
+ group_pages=group_pages if group_pages is not None else self.group_pages,
80
95
  )
81
96
  return {"documents": docs}
82
97
 
@@ -93,6 +108,9 @@ class OpensolrHybridRetriever:
93
108
  # the bias on comes back with it on. from_dict() tolerates its absence in
94
109
  # dicts written before this option existed — the constructor defaults it.
95
110
  fresh_bias=self.fresh_bias,
111
+ date_from=self.date_from,
112
+ date_to=self.date_to,
113
+ group_pages=self.group_pages,
96
114
  )
97
115
 
98
116
  @classmethod
@@ -13,7 +13,10 @@ from __future__ import annotations
13
13
  import re
14
14
  import json
15
15
  import time
16
- from typing import Any, Dict, List, Optional, Tuple
16
+ import hashlib
17
+ from pathlib import Path
18
+ from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
19
+ from urllib.parse import quote
17
20
 
18
21
  import httpx
19
22
 
@@ -600,6 +603,142 @@ def build_instruction(context: str, query: str) -> str:
600
603
  )
601
604
 
602
605
 
606
+ # --------------------------------------------------------------------------- #
607
+ # PDF pages and date ranges #
608
+ # --------------------------------------------------------------------------- #
609
+ # A PDF indexed from its file (Data Ingestion with rtf, the web crawler) is one Solr
610
+ # document per page: page_i is the page number, uri_id_s the id of the PDF it belongs to.
611
+ PAGE_FIELD = "page_i"
612
+ PARENT_FIELD = "uri_id_s"
613
+
614
+ #: Other matching pages returned under each PDF when the results are grouped.
615
+ GROUP_EXPAND_ROWS = 4
616
+
617
+ #: Most pages one read_document call returns.
618
+ READ_MAX_PAGES = 500
619
+
620
+ _DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
621
+ _META_FIELD_RE = re.compile(r"[^a-z0-9_]+")
622
+
623
+
624
+ def date_range_filter(date_from: Optional[str] = None, date_to: Optional[str] = None) -> Optional[str]:
625
+ """Solr filter on ``creation_date`` for a period, both ends included (``YYYY-MM-DD``).
626
+
627
+ Either end may be left out. Returns ``None`` when neither is given.
628
+ """
629
+ import datetime
630
+
631
+ bounds = []
632
+ for name, value, clock in (("date_from", date_from, "T00:00:00Z"), ("date_to", date_to, "T23:59:59Z")):
633
+ if value is None or str(value).strip() == "":
634
+ bounds.append("*")
635
+ continue
636
+ text = str(value).strip()
637
+ if not _DATE_RE.match(text):
638
+ raise ValueError(f"{name} must be YYYY-MM-DD, got {value!r}")
639
+ try:
640
+ datetime.date.fromisoformat(text)
641
+ except ValueError:
642
+ raise ValueError(f"{name} is not a real date: {value!r}") from None
643
+ bounds.append(text + clock)
644
+ if bounds == ["*", "*"]:
645
+ return None
646
+ if "*" not in bounds and bounds[0] > bounds[1]:
647
+ raise ValueError(f"date_from ({date_from}) is after date_to ({date_to})")
648
+ return f"creation_date:[{bounds[0]} TO {bounds[1]}]"
649
+
650
+
651
+ def add_filters(params: Dict[str, Any], *filters: Optional[str]) -> Dict[str, Any]:
652
+ """Append filter queries to ``params["fq"]``, keeping the ones already there."""
653
+ extra = [f for f in filters if f]
654
+ if not extra:
655
+ return params
656
+ existing = params.get("fq")
657
+ if existing is None:
658
+ params["fq"] = extra
659
+ elif isinstance(existing, list):
660
+ params["fq"] = existing + extra
661
+ else:
662
+ params["fq"] = [existing] + extra
663
+ return params
664
+
665
+
666
+ def apply_page_grouping(params: Dict[str, Any]) -> Dict[str, Any]:
667
+ """One result per PDF (its best page), with its other matching pages in Solr's expanded section.
668
+
669
+ Documents that are not pages of a PDF stay separate results (``nullPolicy=expand``). The
670
+ collapse sort is passed by reference so {!hybrid} ranks every page before the grouping.
671
+ """
672
+ add_filters(params, "{!collapse field=" + PARENT_FIELD + " nullPolicy=expand sort=$qgsort}")
673
+ params["qgsort"] = "score desc," + PAGE_FIELD + " asc"
674
+ params["expand"] = "true"
675
+ params["expand.rows"] = GROUP_EXPAND_ROWS
676
+ params["expand.sort"] = "score desc," + PAGE_FIELD + " asc"
677
+ return params
678
+
679
+
680
+ def page_url(uri: str, page: Optional[int]) -> str:
681
+ """The link that opens a PDF at one page."""
682
+ if uri and page and "#" not in uri:
683
+ return f"{uri}#page={int(page)}"
684
+ return uri
685
+
686
+
687
+ def _first(value: Any) -> Any:
688
+ return value[0] if isinstance(value, list) and value else value
689
+
690
+
691
+ def page_info(solr_doc: Dict[str, Any], expanded: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
692
+ """Where a hit lives: ``uri``; for a page of a PDF also ``page``, ``page_url``, ``document_id``
693
+ and, when the results were grouped, the other matching ``more_pages`` of that PDF."""
694
+ uri = str(_first(solr_doc.get("uri")) or "")
695
+ out: Dict[str, Any] = {"uri": uri}
696
+ try:
697
+ page = int(_first(solr_doc.get(PAGE_FIELD)) or 0)
698
+ except (TypeError, ValueError):
699
+ page = 0
700
+ if page < 1:
701
+ return out
702
+ parent = str(_first(solr_doc.get(PARENT_FIELD)) or "")
703
+ out.update(page=page, page_url=page_url(uri, page), document_id=parent)
704
+ group = (expanded or {}).get(parent) if parent else None
705
+ if isinstance(group, dict) and group.get("docs"):
706
+ more = []
707
+ for d in group["docs"]:
708
+ try:
709
+ n = int(_first(d.get(PAGE_FIELD)) or 0)
710
+ except (TypeError, ValueError):
711
+ continue
712
+ if n > 0:
713
+ more.append({"page": n, "page_url": page_url(str(_first(d.get("uri")) or uri), n)})
714
+ out["more_pages"] = more
715
+ out["matching_pages"] = 1 + int(group.get("numFound") or len(more))
716
+ return out
717
+
718
+
719
+ def list_files(path: Union[str, Path], pattern: str = "*", recursive: bool = True) -> List[Path]:
720
+ """The files under ``path`` (or ``path`` itself when it is a file), hidden ones left out."""
721
+ root = Path(path).expanduser()
722
+ if root.is_file():
723
+ return [root]
724
+ if not root.is_dir():
725
+ raise FileNotFoundError(f"No such file or folder: {path}")
726
+ found = root.rglob(pattern) if recursive else root.glob(pattern)
727
+ return sorted(
728
+ f for f in found
729
+ if f.is_file() and not any(part.startswith(".") for part in f.relative_to(root).parts)
730
+ )
731
+
732
+
733
+ def file_source(root: Union[str, Path], file: Union[str, Path]) -> str:
734
+ """A file's name in the index: its path from the folder that was sent, the folder's name first."""
735
+ root_p = Path(root).expanduser().resolve()
736
+ file_p = Path(file).expanduser().resolve()
737
+ if root_p.is_file() or root_p == file_p:
738
+ return file_p.name
739
+ return (Path(root_p.name) / file_p.relative_to(root_p)).as_posix()
740
+
741
+
603
742
  class OpensolrError(RuntimeError):
604
743
  """Raised when an Opensolr API call fails."""
605
744
 
@@ -619,6 +758,8 @@ class OpensolrClient:
619
758
  self.api_key = api_key
620
759
  self._http = httpx.Client(timeout=timeout, follow_redirects=True)
621
760
  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()
622
763
 
623
764
  # ------------------------------------------------------------------ #
624
765
  # low level #
@@ -641,14 +782,17 @@ class OpensolrClient:
641
782
  url = f"{base}/{method}"
642
783
  data = {**self._auth_params(), **params}
643
784
  resp = self._post_with_retry(url=url, data=data, label=method)
785
+ return self._answer(method, resp)
786
+
787
+ def _answer(self, label: str, resp: "httpx.Response") -> Any:
644
788
  if resp.status_code >= 500:
645
- raise OpensolrError(f"{method}: HTTP {resp.status_code}: {resp.text[:200]}")
789
+ raise OpensolrError(f"{label}: HTTP {resp.status_code}: {resp.text[:200]}")
646
790
  try:
647
791
  body = resp.json()
648
792
  except json.JSONDecodeError as exc:
649
- raise OpensolrError(f"{method}: non-JSON response: {resp.text[:200]}") from exc
793
+ raise OpensolrError(f"{label}: non-JSON response: {resp.text[:200]}") from exc
650
794
  if isinstance(body, dict) and body.get("status") is False:
651
- raise OpensolrError(f"{method}: {body.get('msg', body)}")
795
+ raise OpensolrError(f"{label}: {body.get('msg', body)}")
652
796
  return body
653
797
 
654
798
  def _post_with_retry(self, *, url: str, label: str, **kwargs: Any) -> "httpx.Response":
@@ -1091,5 +1235,341 @@ class OpensolrClient:
1091
1235
  resp.raise_for_status()
1092
1236
  return resp.json()
1093
1237
 
1238
+ # ------------------------------------------------------------------ #
1239
+ # PDF pages, documents, pictures #
1240
+ # ------------------------------------------------------------------ #
1241
+
1242
+ def select_grouped(self, index: str, params: Dict[str, Any], group_pages: bool = True) -> Dict[str, Any]:
1243
+ """``solr_select`` with the pages of each PDF grouped under its best page.
1244
+
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.
1247
+ """
1248
+ if not group_pages or index in self._flat_indexes:
1249
+ return self.solr_select(index, params)
1250
+ grouped = dict(params)
1251
+ if isinstance(grouped.get("fq"), list):
1252
+ grouped["fq"] = list(grouped["fq"])
1253
+ 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
1261
+
1262
+ def read_document(
1263
+ self,
1264
+ index: str,
1265
+ document: str,
1266
+ page_from: int = 1,
1267
+ page_to: Optional[int] = None,
1268
+ ) -> Dict[str, Any]:
1269
+ """The whole text of one indexed document.
1270
+
1271
+ ``document`` is its id, the id it was sent with (``meta_ext_id``) or its URL. A PDF
1272
+ indexed page by page comes back as its ``pages`` in order, ``page_from`` to
1273
+ ``page_to`` (at most :data:`READ_MAX_PAGES` per call), with ``pages_indexed``; any
1274
+ other document as one ``text``.
1275
+ """
1276
+ key = str(document or "").strip()
1277
+ if not key:
1278
+ raise ValueError("document is required")
1279
+ if key.startswith(("http://", "https://")):
1280
+ key = hashlib.md5(key.rstrip("/").encode("utf-8")).hexdigest()
1281
+ first = max(1, int(page_from or 1))
1282
+ last = int(page_to) if page_to else first + READ_MAX_PAGES - 1
1283
+ if last < first:
1284
+ raise ValueError(f"page_to ({page_to}) is before page_from ({page_from})")
1285
+ last = min(last, first + READ_MAX_PAGES - 1)
1286
+ esc = key.replace("\\", "\\\\").replace('"', '\\"')
1287
+ 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:
1304
+ 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", [])
1306
+ if not docs:
1307
+ raise OpensolrError(f"read_document: nothing in {index} for {document!r}")
1308
+
1309
+ def _text(d: Dict[str, Any]) -> str:
1310
+ t = d.get("text")
1311
+ return "\n".join(str(x) for x in t) if isinstance(t, list) else str(t or "")
1312
+
1313
+ head = docs[0]
1314
+ uri = str(_first(head.get("uri")) or "")
1315
+ out: Dict[str, Any] = {
1316
+ "id": str(_first(head.get(PARENT_FIELD)) or _first(head.get("id")) or ""),
1317
+ "uri": uri,
1318
+ "title": str(_first(head.get("title")) or ""),
1319
+ "content_type": str(_first(head.get("content_type")) or ""),
1320
+ "date": str(_first(head.get("creation_date")) or ""),
1321
+ }
1322
+ pages = []
1323
+ for d in docs:
1324
+ try:
1325
+ n = int(_first(d.get(PAGE_FIELD)) or 0)
1326
+ except (TypeError, ValueError):
1327
+ n = 0
1328
+ if n > 0:
1329
+ pages.append({"page": n, "page_url": page_url(uri, n), "text": _text(d)})
1330
+ if not pages:
1331
+ out["text"] = _text(head)
1332
+ return out
1333
+ esc_parent = out["id"].replace("\\", "\\\\").replace('"', '\\"')
1334
+ total = self.solr_select(index, {"q": f'{PARENT_FIELD}:"{esc_parent}"', "rows": 0})
1335
+ out.update(
1336
+ pages_indexed=int(total.get("response", {}).get("numFound") or len(pages)),
1337
+ page_from=pages[0]["page"],
1338
+ page_to=pages[-1]["page"],
1339
+ pages=pages,
1340
+ )
1341
+ return out
1342
+
1343
+ def describe_image(self, index: str, image_b64: str) -> Dict[str, Any]:
1344
+ """What a picture shows (``caption``), the text printed on it (``text``, read by OCR),
1345
+ its ``labels`` and any barcodes / QR ``codes``."""
1346
+ ans = self.image_to_text(index, image_b64, top_k=8)
1347
+ labels = [l["label"] for l in (ans.get("labels") or []) if isinstance(l, dict) and l.get("label")]
1348
+ codes: List[str] = []
1349
+ for c in ans.get("codes") or []:
1350
+ value = c.get("text") if isinstance(c, dict) else (c if isinstance(c, str) else None)
1351
+ if value:
1352
+ codes.append(value)
1353
+ mode = ans.get("mode") or ""
1354
+ ocr = str(ans.get("ocr_text") or "").strip() or (str(ans.get("text") or "").strip() if mode == "ocr" else "")
1355
+ caption = str(ans.get("caption") or "").strip() or (str(ans.get("text") or "").strip() if mode != "ocr" else "")
1356
+ return {"caption": caption, "text": ocr, "labels": labels, "codes": codes}
1357
+
1358
+ # ------------------------------------------------------------------ #
1359
+ # files: read on Opensolr (text, OCR, a PDF page by page), indexed #
1360
+ # ------------------------------------------------------------------ #
1361
+
1362
+ #: Seconds between two looks at files that are still being read.
1363
+ FILE_POLL_SECONDS = 3.0
1364
+
1365
+ #: Job ids one file_extract_status call accepts.
1366
+ FILE_STATUS_MAX = 20
1367
+
1368
+ @staticmethod
1369
+ def _file_hash(path: Path) -> Tuple[str, int]:
1370
+ digest = hashlib.sha1()
1371
+ size = 0
1372
+ with open(path, "rb") as fh:
1373
+ for block in iter(lambda: fh.read(4 * 1024 * 1024), b""):
1374
+ digest.update(block)
1375
+ size += len(block)
1376
+ return digest.hexdigest(), size
1377
+
1378
+ def upload_file(
1379
+ self,
1380
+ index: str,
1381
+ file: Union[str, Path],
1382
+ pages: bool = False,
1383
+ text: bool = True,
1384
+ ) -> Dict[str, Any]:
1385
+ """Send one local file to be read on Opensolr: its text, its pictures through OCR, a PDF page by page.
1386
+
1387
+ The bytes travel in parts, and only once: a file this account already sent (same
1388
+ content) is answered without sending it again. Returns ``job_id`` and ``state``
1389
+ (``queued``, ``processing``, ``done`` or ``failed``); once done, ``mime`` and the
1390
+ ``text``, or with ``pages=True`` the ``pages`` of a PDF (``page``, ``text``,
1391
+ ``is_ocr``, ``lang``). ``text=False`` answers the state alone. The pictures read are
1392
+ counted on the index owner's AI quota, 0.5 each.
1393
+ """
1394
+ path = Path(file).expanduser()
1395
+ sha1, size = self._file_hash(path)
1396
+ if size < 1:
1397
+ raise OpensolrError(f"file_extract: {path.name} is empty")
1398
+ form = {
1399
+ "index_name": index, "sha1": sha1, "size": size, "name": path.name,
1400
+ "pages": "1" if pages else "0", "text": "1" if text else "0",
1401
+ }
1402
+ ans = self.ai("file_extract", **form)
1403
+ stuck = 0
1404
+ with open(path, "rb") as fh:
1405
+ while ans.get("state") == "upload":
1406
+ offset = int(ans.get("received") or 0)
1407
+ part_max = int(ans.get("max_part_bytes") or 0)
1408
+ if part_max < 1 or offset >= size:
1409
+ raise OpensolrError(f"file_extract: {path.name}: unexpected upload state {str(ans)[:200]}")
1410
+ fh.seek(offset)
1411
+ part = fh.read(min(part_max, size - offset))
1412
+ resp = self._post_with_retry(
1413
+ label="file_extract",
1414
+ url=f"{AI_BASE}/file_extract",
1415
+ data={**self._auth_params(), **form, "offset": offset},
1416
+ files={"part": (path.name, part, "application/octet-stream")},
1417
+ )
1418
+ ans = self._answer("file_extract", resp)
1419
+ if ans.get("state") == "upload" and int(ans.get("received") or 0) <= offset:
1420
+ stuck += 1
1421
+ if stuck >= 3:
1422
+ raise OpensolrError(f"file_extract: {path.name}: the upload does not get past {offset} bytes")
1423
+ else:
1424
+ stuck = 0
1425
+ return ans
1426
+
1427
+ def file_status(
1428
+ self,
1429
+ index: str,
1430
+ job_ids: Sequence[str],
1431
+ pages: bool = False,
1432
+ text: bool = True,
1433
+ ) -> Dict[str, Dict[str, Any]]:
1434
+ """State of files sent with :meth:`upload_file`, keyed by job id, with their text (or pages) once read."""
1435
+ ids = list(dict.fromkeys(str(j) for j in job_ids if j))
1436
+ out: Dict[str, Dict[str, Any]] = {}
1437
+ for i in range(0, len(ids), self.FILE_STATUS_MAX):
1438
+ body = self.ai(
1439
+ "file_extract_status", index_name=index, job_ids=json.dumps(ids[i : i + self.FILE_STATUS_MAX]),
1440
+ pages="1" if pages else "0", text="1" if text else "0",
1441
+ )
1442
+ results = body.get("results") if isinstance(body, dict) else None
1443
+ if not isinstance(results, dict):
1444
+ raise OpensolrError(f"file_extract_status: unexpected response: {str(body)[:200]}")
1445
+ out.update(results)
1446
+ return out
1447
+
1448
+ def _wait_files(self, index: str, entries: List[Dict[str, Any]], pages: bool, text: bool, timeout: float) -> None:
1449
+ deadline = time.time() + max(0.0, float(timeout))
1450
+ while True:
1451
+ waiting: Dict[str, List[Dict[str, Any]]] = {}
1452
+ for e in entries:
1453
+ if e.get("job_id") and e.get("state") in ("queued", "processing"):
1454
+ waiting.setdefault(e["job_id"], []).append(e)
1455
+ if not waiting or time.time() >= deadline:
1456
+ return
1457
+ time.sleep(self.FILE_POLL_SECONDS)
1458
+ for job_id, ans in self.file_status(index, list(waiting), pages=pages, text=text).items():
1459
+ for e in waiting.get(job_id, []):
1460
+ e["state"] = ans.get("state") or "failed"
1461
+ for key in ("text", "pages", "mime", "error"):
1462
+ if key in ans:
1463
+ e[key] = ans[key]
1464
+
1465
+ def _send_files(self, index: str, files: Sequence[Union[str, Path]], pages: bool, text: bool) -> List[Dict[str, Any]]:
1466
+ entries = []
1467
+ for f in files:
1468
+ entry: Dict[str, Any] = {"path": str(f)}
1469
+ try:
1470
+ ans = self.upload_file(index, f, pages=pages, text=text)
1471
+ entry["job_id"] = ans.get("job_id")
1472
+ entry["state"] = ans.get("state")
1473
+ for key in ("text", "pages", "mime", "error"):
1474
+ if key in ans:
1475
+ entry[key] = ans[key]
1476
+ except (OpensolrError, OSError) as exc:
1477
+ entry.update(state="failed", error=str(exc))
1478
+ entries.append(entry)
1479
+ return entries
1480
+
1481
+ def extract_files(
1482
+ self,
1483
+ index: str,
1484
+ files: Sequence[Union[str, Path]],
1485
+ pages: bool = True,
1486
+ timeout: float = 600.0,
1487
+ ) -> List[Dict[str, Any]]:
1488
+ """Read local files on Opensolr without indexing them (see :meth:`upload_file`).
1489
+
1490
+ One entry per file: ``path``, ``job_id``, ``state`` and, once ``done``, ``mime`` plus
1491
+ the ``pages`` of a PDF (with ``pages=True``) or the ``text``; ``error`` when it could
1492
+ not be read. Files still ``queued``/``processing`` when ``timeout`` runs out can be
1493
+ asked for again: the bytes are not sent twice.
1494
+ """
1495
+ entries = self._send_files(index, files, pages=pages, text=True)
1496
+ self._wait_files(index, entries, pages=pages, text=True, timeout=timeout)
1497
+ return entries
1498
+
1499
+ def ingest_files(
1500
+ self,
1501
+ index: str,
1502
+ path: Union[str, Path],
1503
+ pattern: str = "*",
1504
+ recursive: bool = True,
1505
+ metadata: Optional[Dict[str, Any]] = None,
1506
+ wait: bool = False,
1507
+ timeout: float = 600.0,
1508
+ ) -> Dict[str, Any]:
1509
+ """Index local files through Data Ingestion: one file, or every file of a folder.
1510
+
1511
+ Each file is read on Opensolr (its text, its pictures through OCR) and a PDF is
1512
+ indexed one document per page, exactly like a PDF sent by URL with ``rtf``. Its
1513
+ ``uri`` is ``https://ingest.opensolr.com/<index>/<source>``, where ``source`` is the
1514
+ file's path from the folder sent (also in ``meta_source``), so sending it again
1515
+ replaces it; its date is the file's modification time. ``metadata`` is added to every
1516
+ document as ``meta_*`` fields. Files still being read when ``timeout`` runs out come
1517
+ back under ``pending``: call again to index them, the bytes are not sent twice.
1518
+
1519
+ Returns ``{"documents": [...], "pending": [...], "failed": [...], "jobs": [...]}``;
1520
+ ``jobs`` are the Data Ingestion job ids (``wait=True`` blocks until they are done).
1521
+ """
1522
+ meta = {str(k): v for k, v in (metadata or {}).items() if isinstance(v, (str, int, float, bool))}
1523
+ root = Path(path).expanduser()
1524
+ files = list_files(root, pattern, recursive)
1525
+ entries = self._send_files(index, files, pages=False, text=False)
1526
+ for e, f in zip(entries, files):
1527
+ e["source"] = file_source(root, f)
1528
+ self._wait_files(index, entries, pages=False, text=False, timeout=timeout)
1529
+
1530
+ documents: List[Dict[str, Any]] = []
1531
+ pending: List[Dict[str, Any]] = []
1532
+ failed: List[Dict[str, Any]] = []
1533
+ docs: List[Dict[str, Any]] = []
1534
+ for e in entries:
1535
+ if e.get("state") in ("queued", "processing"):
1536
+ pending.append({"path": e["path"], "state": e["state"]})
1537
+ continue
1538
+ if e.get("state") != "done":
1539
+ failed.append({"path": e["path"], "error": e.get("error") or e.get("state") or "not read"})
1540
+ continue
1541
+ src = e["source"]
1542
+ uri = f"https://ingest.opensolr.com/{index}/{quote(src, safe='')}"
1543
+ name = Path(e["path"])
1544
+ doc: Dict[str, Any] = {
1545
+ "uri": uri,
1546
+ "title": (name.stem or name.name)[:250],
1547
+ "description": src,
1548
+ "rtf": True,
1549
+ "file": e["job_id"],
1550
+ "meta_ext_id": src,
1551
+ "meta_source": src,
1552
+ "meta_lc_json": json.dumps({"source": src, **meta}, ensure_ascii=False),
1553
+ }
1554
+ try:
1555
+ doc["timestamp"] = int(name.stat().st_mtime)
1556
+ except OSError:
1557
+ pass
1558
+ for key, value in meta.items():
1559
+ field = "meta_" + _META_FIELD_RE.sub("_", key.lower()).strip("_")
1560
+ if field not in ("meta_", "meta_lc_json", "meta_ext_id", "meta_source"):
1561
+ doc[field] = str(value)
1562
+ docs.append(doc)
1563
+ documents.append({
1564
+ "path": e["path"], "source": src, "uri": uri,
1565
+ "id": hashlib.md5(uri.encode("utf-8")).hexdigest(),
1566
+ })
1567
+ jobs: List[str] = []
1568
+ for i in range(0, len(docs), 50):
1569
+ body = self.ingest(index, docs[i : i + 50], wait=wait, timeout=timeout)
1570
+ if isinstance(body, dict) and body.get("job_id"):
1571
+ jobs.append(str(body["job_id"]))
1572
+ return {"documents": documents, "pending": pending, "failed": failed, "jobs": jobs}
1573
+
1094
1574
  def close(self) -> None:
1095
1575
  self._http.close()
@@ -11,7 +11,7 @@ import base64
11
11
  from pathlib import Path
12
12
  import json
13
13
  import re
14
- from typing import Any, Dict, List, Optional
14
+ from typing import Any, Dict, List, Optional, Union
15
15
 
16
16
  from haystack import Document, default_from_dict, default_to_dict
17
17
  from haystack.document_stores.types import DuplicatePolicy
@@ -22,6 +22,8 @@ from haystack_integrations.document_stores.opensolr.client import (
22
22
  OpensolrError,
23
23
  apply_fresh_bias,
24
24
  apply_search_operators,
25
+ date_range_filter,
26
+ page_info,
25
27
  parse_operators,
26
28
  )
27
29
 
@@ -198,7 +200,7 @@ class OpensolrDocumentStore:
198
200
  continue
199
201
  self._checked = True
200
202
 
201
- def _doc_from_solr(self, solr_doc: Dict[str, Any]) -> Document:
203
+ def _doc_from_solr(self, solr_doc: Dict[str, Any], expanded: Optional[Dict[str, Any]] = None) -> Document:
202
204
  def _flat(v: Any) -> Any:
203
205
  return v[0] if isinstance(v, list) and len(v) == 1 else v
204
206
 
@@ -214,8 +216,18 @@ class OpensolrDocumentStore:
214
216
  content = " ".join(str(c) for c in content)
215
217
  score = solr_doc.get("score")
216
218
  ext = _flat(solr_doc.get("meta_ext_id"))
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
221
+ where = page_info(solr_doc, expanded)
222
+ if where.get("page"):
223
+ where["page_number"] = where.pop("page")
224
+ # every page of a PDF carries the PDF's meta_ext_id: the page number keeps ids apart
225
+ 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)
217
229
  return Document(
218
- id=str(ext) if ext else str(_flat(solr_doc.get("id", ""))),
230
+ id=doc_id,
219
231
  content=str(content),
220
232
  meta=meta,
221
233
  score=float(_flat(score)) if score is not None else None,
@@ -278,6 +290,34 @@ class OpensolrDocumentStore:
278
290
  self.client.ingest(self.index, docs[i : i + 50], wait=self.ingest_wait)
279
291
  return len(docs)
280
292
 
293
+ def write_files(
294
+ self,
295
+ path: Union[str, Path],
296
+ pattern: str = "*",
297
+ recursive: bool = True,
298
+ metadata: Optional[Dict[str, Any]] = None,
299
+ wait: Optional[bool] = None,
300
+ timeout: float = 600.0,
301
+ ) -> Dict[str, Any]:
302
+ """Index local files: one file, or every file of a folder.
303
+
304
+ Each file is read on Opensolr (its text, its pictures through OCR) and a PDF is
305
+ indexed one document per page, so a search finds the page that answers it.
306
+ Word, RTF, OpenDocument, text, HTML and picture files (scans) are one document
307
+ each. ``pattern`` picks a folder's files (``"*.pdf"``), ``metadata`` goes on
308
+ every document (filterable like any meta). Sending the same files again replaces
309
+ them. ``wait`` defaults to the store's ``ingest_wait``.
310
+
311
+ Returns ``{"documents", "pending", "failed", "jobs"}``: files still being read
312
+ when ``timeout`` ran out are under ``pending`` — call again to index them, the
313
+ bytes are not sent twice.
314
+ """
315
+ self._ensure_index()
316
+ return self.client.ingest_files(
317
+ self.index, path, pattern=pattern, recursive=recursive, metadata=metadata,
318
+ wait=self.ingest_wait if wait is None else wait, timeout=timeout,
319
+ )
320
+
281
321
  def ai_answer(
282
322
  self,
283
323
  query: str,
@@ -346,6 +386,9 @@ class OpensolrDocumentStore:
346
386
  filters: Optional[Dict[str, Any]] = None,
347
387
  lexical: bool = False,
348
388
  fresh_bias: bool = False,
389
+ date_from: Optional[str] = None,
390
+ date_to: Optional[str] = None,
391
+ group_pages: bool = True,
349
392
  ) -> List[Document]:
350
393
  """Retrieve the top_k documents for a query.
351
394
 
@@ -354,9 +397,17 @@ class OpensolrDocumentStore:
354
397
  filters — the hit count is unchanged, nothing old becomes unreachable, and
355
398
  a document with no ``creation_date`` is simply left unboosted. Applies to
356
399
  the lexical, hybrid and pure-kNN paths alike. Off by default.
400
+
401
+ ``date_from`` / ``date_to`` (``YYYY-MM-DD``, both included) keep only documents
402
+ dated in that period (``creation_date``). ``group_pages`` (on by default) returns
403
+ a PDF indexed page by page once, as its best page: ``meta`` carries
404
+ ``page_number``, ``page_url`` (opens the PDF at that page), ``document_id`` and
405
+ ``more_pages`` (its other matching pages). Every document carries ``uri``.
357
406
  """
358
407
  self._ensure_index()
359
408
  params: Dict[str, Any] = {"rows": top_k, "fl": "*,score"}
409
+ # Checked before anything is embedded: a malformed date is the caller's, not a GPU call's
410
+ period = date_range_filter(date_from, date_to)
360
411
  # Search operators (+word, -word, +"phrase", -"phrase") come out of the text once,
361
412
  # for every shape below. See parse_operators() for why they cannot stay inside the
362
413
  # query on any path that involves a vector.
@@ -378,9 +429,10 @@ class OpensolrDocumentStore:
378
429
  # top-level bf is not reliably its own.
379
430
  if fresh_bias:
380
431
  apply_fresh_bias(params)
381
- _extend_fq(params, _filters_to_fq(filters))
382
- body = self.client.solr_select(self.index, params)
383
- return [self._doc_from_solr(d) for d in body["response"]["docs"]]
432
+ _extend_fq(params, _filters_to_fq(filters) + ([period] if period else []))
433
+ body = self.client.select_grouped(self.index, params, group_pages)
434
+ expanded = body.get("expanded") or {}
435
+ return [self._doc_from_solr(d, expanded) for d in body["response"]["docs"]]
384
436
 
385
437
  # The embedder must never see an operator — it has no concept of negation, so a '-'
386
438
  # term reads as one more word and pulls results towards what the caller asked to drop.
@@ -407,10 +459,11 @@ class OpensolrDocumentStore:
407
459
  # including the vector-only ones an edismax bf never sees.
408
460
  if fresh_bias:
409
461
  apply_fresh_bias(params)
410
- _extend_fq(params, _filters_to_fq(filters))
462
+ _extend_fq(params, _filters_to_fq(filters) + ([period] if period else []))
411
463
 
412
- body = self.client.solr_select(self.index, params)
413
- return [self._doc_from_solr(d) for d in body["response"]["docs"]]
464
+ body = self.client.select_grouped(self.index, params, group_pages)
465
+ expanded = body.get("expanded") or {}
466
+ return [self._doc_from_solr(d, expanded) for d in body["response"]["docs"]]
414
467
 
415
468
  # ------------------------------------------------------------------ #
416
469
  # serialization #
@@ -426,7 +479,7 @@ class OpensolrDocumentStore:
426
479
  """
427
480
  raw = bytes(image) if isinstance(image, (bytes, bytearray)) else Path(image).read_bytes()
428
481
  b64 = base64.b64encode(raw).decode("ascii")
429
- ans = self.client.image_to_text(self._index, b64, top_k=top_k)
482
+ ans = self.client.image_to_text(self.index, b64, top_k=top_k)
430
483
  labels = [l["label"] for l in (ans.get("labels") or []) if isinstance(l, dict) and l.get("label")]
431
484
  codes = []
432
485
  for c in (ans.get("codes") or []):
@@ -447,8 +500,9 @@ class OpensolrDocumentStore:
447
500
  """Search the index with a photo instead of a text query.
448
501
 
449
502
  The image is turned into words by :meth:`image_to_words`, then those words
450
- run through the normal :meth:`search`, so ``hybrid``, ``lexical``, ``alpha``
451
- and ``fresh_bias`` apply via ``search_kwargs``. No image vector is stored.
503
+ run through the normal :meth:`search`, so ``hybrid``, ``lexical``, ``alpha``,
504
+ ``fresh_bias``, ``date_from``, ``date_to`` and ``group_pages`` apply via
505
+ ``search_kwargs``. No image vector is stored.
452
506
 
453
507
  ``using``: which reading to search with — ``"auto"`` (the engine's chosen
454
508
  text), ``"meaning"`` (CLIP visual labels), ``"text"`` (OCR text only), or
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: opensolr-haystack
3
- Version: 0.6.0
3
+ Version: 0.7.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 everything in this
65
- README works immediately, with no signup:
64
+ There is a public demo account. Point the package at it and search, ingestion and
65
+ answers work immediately, with no signup:
66
66
 
67
67
  ```bash
68
68
  export OPENSOLR_EMAIL=mcp@opensolr.com
@@ -72,6 +72,8 @@ export OPENSOLR_API_KEY=420b8b23e7b12dc8ab838932145a5065
72
72
  `mcp_demo_d1__dense` is already loaded with 300 news articles, so search, filtering
73
73
  and grounded answers work the moment you connect. You also get the full write path:
74
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.
75
77
 
76
78
  Know what you are working with:
77
79
 
@@ -198,6 +200,63 @@ docs = store.search_by_image("photo.jpg", using="all") # labels + OCR + cod
198
200
  `alpha`, `fresh_bias`, `filters` — so an image query runs through the exact
199
201
  hybrid pipeline a text query does.
200
202
 
203
+ ## Local files: PDFs page by page, scans through OCR
204
+
205
+ `OpensolrConverter` turns files into Haystack `Document`s — one per PDF page
206
+ (`meta["page_number"]`), one per any other file. It takes file paths, folders and
207
+ `ByteStream`s:
208
+
209
+ ```python
210
+ from haystack_integrations.components.converters.opensolr import OpensolrConverter
211
+
212
+ converter = OpensolrConverter(document_store=store)
213
+ docs = converter.run(sources=["./contracts", "scan.png"], meta={"team": "legal"})["documents"]
214
+ # meta: file_path, page_number, is_ocr, language, content_type (+ yours)
215
+ ```
216
+
217
+ Or index them straight into the store — no converter, no splitter, PDFs page by page:
218
+
219
+ ```python
220
+ result = store.write_files("./contracts", pattern="*.pdf", metadata={"team": "legal"})
221
+ # {"documents": [{path, source, uri, id}], "pending": [], "failed": [], "jobs": [...]}
222
+ ```
223
+
224
+ - Each file is read on Opensolr's servers: its text, the text in its pictures (OCR,
225
+ more than 100 languages), a short description of the pictures without text. Word,
226
+ RTF, OpenDocument, text, HTML and picture files (scans) are one document each; a
227
+ **PDF is one document per page**.
228
+ - The bytes travel once: a file the account already sent (same content) is not
229
+ uploaded again. Pictures read by OCR count 0.5 of an AI request each; a file read
230
+ before is free.
231
+ - Files still being read when `timeout` runs out are under `result["pending"]` (the
232
+ converter logs them): run again to get them, nothing is sent twice.
233
+ - An indexed file's `uri` is `https://ingest.opensolr.com/<index>/<source>`, where
234
+ `source` is its path from the folder you sent (`meta["source"]`); sending it again
235
+ replaces it. Its date is the file's modification time.
236
+
237
+ ## PDF pages in search results
238
+
239
+ On an index where PDFs are stored page by page (files indexed as above, PDFs sent to
240
+ Data Ingestion with `rtf`, PDFs found by the Web Crawler), a search returns each PDF
241
+ **once**, as its best page. Its metadata carries `page_number` (the page number),
242
+ `page_url` (opens the PDF at that page, `...pdf#page=14`), `document_id`,
243
+ `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
245
+ as separate results.
246
+
247
+ ## Searching a period
248
+
249
+ `date_from` and `date_to` (`YYYY-MM-DD`, both included, either one can be left out)
250
+ keep only the documents dated in that period (`creation_date`):
251
+
252
+ ```python
253
+ docs = store.search("rate decision", top_k=5, date_from="2026-01-01", date_to="2026-03-31")
254
+ # or in a pipeline:
255
+ OpensolrHybridRetriever(document_store=store, date_from="2026-01-01")
256
+ ```
257
+
258
+ They combine with every other option — filters, `fresh_bias`, hybrid or lexical.
259
+
201
260
  ## Your index schema
202
261
 
203
262
  Documents follow the Opensolr document model (`title`, `description`, `text`,
@@ -1,6 +1,8 @@
1
1
  LICENSE
2
2
  README.md
3
3
  pyproject.toml
4
+ haystack_integrations/components/converters/opensolr/__init__.py
5
+ haystack_integrations/components/converters/opensolr/converter.py
4
6
  haystack_integrations/components/retrievers/opensolr/__init__.py
5
7
  haystack_integrations/components/retrievers/opensolr/retriever.py
6
8
  haystack_integrations/document_stores/opensolr/__init__.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "opensolr-haystack"
7
- version = "0.6.0"
7
+ version = "0.7.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" }