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.
- {opensolr_haystack-0.6.0 → opensolr_haystack-0.7.0}/PKG-INFO +62 -3
- {opensolr_haystack-0.6.0 → opensolr_haystack-0.7.0}/README.md +61 -2
- opensolr_haystack-0.7.0/haystack_integrations/components/converters/opensolr/__init__.py +5 -0
- opensolr_haystack-0.7.0/haystack_integrations/components/converters/opensolr/converter.py +123 -0
- {opensolr_haystack-0.6.0 → opensolr_haystack-0.7.0}/haystack_integrations/components/retrievers/opensolr/retriever.py +19 -1
- {opensolr_haystack-0.6.0 → opensolr_haystack-0.7.0}/haystack_integrations/document_stores/opensolr/client.py +484 -4
- {opensolr_haystack-0.6.0 → opensolr_haystack-0.7.0}/haystack_integrations/document_stores/opensolr/store.py +66 -12
- {opensolr_haystack-0.6.0 → opensolr_haystack-0.7.0}/opensolr_haystack.egg-info/PKG-INFO +62 -3
- {opensolr_haystack-0.6.0 → opensolr_haystack-0.7.0}/opensolr_haystack.egg-info/SOURCES.txt +2 -0
- {opensolr_haystack-0.6.0 → opensolr_haystack-0.7.0}/pyproject.toml +1 -1
- {opensolr_haystack-0.6.0 → opensolr_haystack-0.7.0}/LICENSE +0 -0
- {opensolr_haystack-0.6.0 → opensolr_haystack-0.7.0}/haystack_integrations/components/retrievers/opensolr/__init__.py +0 -0
- {opensolr_haystack-0.6.0 → opensolr_haystack-0.7.0}/haystack_integrations/document_stores/opensolr/__init__.py +0 -0
- {opensolr_haystack-0.6.0 → opensolr_haystack-0.7.0}/opensolr_haystack.egg-info/dependency_links.txt +0 -0
- {opensolr_haystack-0.6.0 → opensolr_haystack-0.7.0}/opensolr_haystack.egg-info/requires.txt +0 -0
- {opensolr_haystack-0.6.0 → opensolr_haystack-0.7.0}/opensolr_haystack.egg-info/top_level.txt +0 -0
- {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.
|
|
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
|
|
65
|
-
|
|
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
|
|
47
|
-
|
|
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,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
|
-
|
|
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"{
|
|
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"{
|
|
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"{
|
|
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=
|
|
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.
|
|
383
|
-
|
|
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.
|
|
413
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
|
65
|
-
|
|
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.
|
|
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" }
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{opensolr_haystack-0.6.0 → opensolr_haystack-0.7.0}/opensolr_haystack.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
{opensolr_haystack-0.6.0 → opensolr_haystack-0.7.0}/opensolr_haystack.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|