opensolr-haystack 0.2.0__tar.gz → 0.2.2__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.2.0/opensolr_haystack.egg-info → opensolr_haystack-0.2.2}/PKG-INFO +39 -1
- {opensolr_haystack-0.2.0 → opensolr_haystack-0.2.2}/README.md +38 -0
- {opensolr_haystack-0.2.0 → opensolr_haystack-0.2.2}/haystack_integrations/document_stores/opensolr/client.py +128 -2
- {opensolr_haystack-0.2.0 → opensolr_haystack-0.2.2}/haystack_integrations/document_stores/opensolr/store.py +26 -0
- {opensolr_haystack-0.2.0 → opensolr_haystack-0.2.2/opensolr_haystack.egg-info}/PKG-INFO +39 -1
- {opensolr_haystack-0.2.0 → opensolr_haystack-0.2.2}/pyproject.toml +1 -1
- {opensolr_haystack-0.2.0 → opensolr_haystack-0.2.2}/LICENSE +0 -0
- {opensolr_haystack-0.2.0 → opensolr_haystack-0.2.2}/haystack_integrations/components/retrievers/opensolr/__init__.py +0 -0
- {opensolr_haystack-0.2.0 → opensolr_haystack-0.2.2}/haystack_integrations/components/retrievers/opensolr/retriever.py +0 -0
- {opensolr_haystack-0.2.0 → opensolr_haystack-0.2.2}/haystack_integrations/document_stores/opensolr/__init__.py +0 -0
- {opensolr_haystack-0.2.0 → opensolr_haystack-0.2.2}/opensolr_haystack.egg-info/SOURCES.txt +0 -0
- {opensolr_haystack-0.2.0 → opensolr_haystack-0.2.2}/opensolr_haystack.egg-info/dependency_links.txt +0 -0
- {opensolr_haystack-0.2.0 → opensolr_haystack-0.2.2}/opensolr_haystack.egg-info/requires.txt +0 -0
- {opensolr_haystack-0.2.0 → opensolr_haystack-0.2.2}/opensolr_haystack.egg-info/top_level.txt +0 -0
- {opensolr_haystack-0.2.0 → opensolr_haystack-0.2.2}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: opensolr-haystack
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
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
|
|
@@ -121,4 +121,42 @@ entry? Configure the **Web Crawler** in the Control Panel (Index Tools →
|
|
|
121
121
|
WebCrawler): add your site URL, validate it, and Opensolr indexes the whole
|
|
122
122
|
site for you.
|
|
123
123
|
|
|
124
|
+
## Grounded RAG answers
|
|
125
|
+
|
|
126
|
+
One call: hybrid retrieval picks the top hits, whose content becomes the LLM
|
|
127
|
+
context, and Opensolr's server-side LLM answers — no generator component,
|
|
128
|
+
no LLM key:
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
answer = store.ai_answer(
|
|
132
|
+
"what does the refund policy say?",
|
|
133
|
+
rag_docs=3, # how many hybrid hits feed the LLM (default 3)
|
|
134
|
+
rag_words=1500, # words of text taken from each hit (default 1500)
|
|
135
|
+
# instruction="Answer in German, cite the exact titles you used", # optional
|
|
136
|
+
)
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## How it's tested
|
|
140
|
+
|
|
141
|
+
Every release is validated against **live Opensolr infrastructure** — no mocks:
|
|
142
|
+
|
|
143
|
+
- **Unit tests** (offline): location aliases, filter→fq mapping, query building, escaping.
|
|
144
|
+
- **End-to-end suite**: the full write path through the async Data Ingestion
|
|
145
|
+
queue (queued → server-side enrichment → searchable), semantic / hybrid /
|
|
146
|
+
lexical retrieval, metadata round-trip, filters, id round-trip (your ids
|
|
147
|
+
and the Solr `md5(uri)` ids), deletes by id and by query.
|
|
148
|
+
- **Real-corpus validation**: searches run against a 340-document replica of
|
|
149
|
+
opensolr.com's own production search index. Verified: pure-semantic hits
|
|
150
|
+
with zero keyword overlap ("how do I get my data back after a disaster" →
|
|
151
|
+
backup & restore docs), cross-lingual queries (Romanian query → English
|
|
152
|
+
content), exact-term surfacing in hybrid mode, all four hybrid modes, and
|
|
153
|
+
the full alpha range 0 → 1.
|
|
154
|
+
- **PDF ingestion**: a real PDF ingested via `rtf:true` — server-side text
|
|
155
|
+
extraction (13k+ chars), automatic content-type detection, then retrieved
|
|
156
|
+
with a purely semantic query against its contents.
|
|
157
|
+
- **Grounded RAG answers**: `ai_answer` verified end-to-end — a question answerable only from the ingested PDF returns the correct answer, sourced from the PDF's extracted text via hybrid retrieval.
|
|
158
|
+
|
|
159
|
+
The store is exercised live (write via ingestion, DuplicatePolicy SKIP/FAIL,
|
|
160
|
+
hybrid + lexical retrieval, filters, serde round-trip) before every release.
|
|
161
|
+
|
|
124
162
|
MIT license.
|
|
@@ -103,4 +103,42 @@ entry? Configure the **Web Crawler** in the Control Panel (Index Tools →
|
|
|
103
103
|
WebCrawler): add your site URL, validate it, and Opensolr indexes the whole
|
|
104
104
|
site for you.
|
|
105
105
|
|
|
106
|
+
## Grounded RAG answers
|
|
107
|
+
|
|
108
|
+
One call: hybrid retrieval picks the top hits, whose content becomes the LLM
|
|
109
|
+
context, and Opensolr's server-side LLM answers — no generator component,
|
|
110
|
+
no LLM key:
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
answer = store.ai_answer(
|
|
114
|
+
"what does the refund policy say?",
|
|
115
|
+
rag_docs=3, # how many hybrid hits feed the LLM (default 3)
|
|
116
|
+
rag_words=1500, # words of text taken from each hit (default 1500)
|
|
117
|
+
# instruction="Answer in German, cite the exact titles you used", # optional
|
|
118
|
+
)
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## How it's tested
|
|
122
|
+
|
|
123
|
+
Every release is validated against **live Opensolr infrastructure** — no mocks:
|
|
124
|
+
|
|
125
|
+
- **Unit tests** (offline): location aliases, filter→fq mapping, query building, escaping.
|
|
126
|
+
- **End-to-end suite**: the full write path through the async Data Ingestion
|
|
127
|
+
queue (queued → server-side enrichment → searchable), semantic / hybrid /
|
|
128
|
+
lexical retrieval, metadata round-trip, filters, id round-trip (your ids
|
|
129
|
+
and the Solr `md5(uri)` ids), deletes by id and by query.
|
|
130
|
+
- **Real-corpus validation**: searches run against a 340-document replica of
|
|
131
|
+
opensolr.com's own production search index. Verified: pure-semantic hits
|
|
132
|
+
with zero keyword overlap ("how do I get my data back after a disaster" →
|
|
133
|
+
backup & restore docs), cross-lingual queries (Romanian query → English
|
|
134
|
+
content), exact-term surfacing in hybrid mode, all four hybrid modes, and
|
|
135
|
+
the full alpha range 0 → 1.
|
|
136
|
+
- **PDF ingestion**: a real PDF ingested via `rtf:true` — server-side text
|
|
137
|
+
extraction (13k+ chars), automatic content-type detection, then retrieved
|
|
138
|
+
with a purely semantic query against its contents.
|
|
139
|
+
- **Grounded RAG answers**: `ai_answer` verified end-to-end — a question answerable only from the ingested PDF returns the correct answer, sourced from the PDF's extracted text via hybrid retrieval.
|
|
140
|
+
|
|
141
|
+
The store is exercised live (write via ingestion, DuplicatePolicy SKIP/FAIL,
|
|
142
|
+
hybrid + lexical retrieval, filters, serde round-trip) before every release.
|
|
143
|
+
|
|
106
144
|
MIT license.
|
|
@@ -138,9 +138,16 @@ class OpensolrClient:
|
|
|
138
138
|
f"Currently available: {sorted(live)}. Additional regions can "
|
|
139
139
|
f"be deployed on request — contact support@opensolr.com."
|
|
140
140
|
)
|
|
141
|
-
|
|
142
|
-
|
|
141
|
+
# create_index reads its params from the query string (GET) server-side
|
|
142
|
+
resp = self._http.post(
|
|
143
|
+
f"{MGMT_BASE}/create_index",
|
|
144
|
+
params={"index_name": index, "core_type": "generic", "server_country": env},
|
|
145
|
+
data=self._auth_params(),
|
|
143
146
|
)
|
|
147
|
+
body = resp.json()
|
|
148
|
+
if isinstance(body, dict) and body.get("status") is False:
|
|
149
|
+
raise OpensolrError(f"create_index: {body.get('msg', body)}")
|
|
150
|
+
return body
|
|
144
151
|
|
|
145
152
|
# ------------------------------------------------------------------ #
|
|
146
153
|
# AI #
|
|
@@ -257,6 +264,125 @@ class OpensolrClient:
|
|
|
257
264
|
resp.raise_for_status()
|
|
258
265
|
return resp.json()
|
|
259
266
|
|
|
267
|
+
def hybrid_search(
|
|
268
|
+
self,
|
|
269
|
+
index: str,
|
|
270
|
+
query: str,
|
|
271
|
+
rows: int = 5,
|
|
272
|
+
mode: str = "union",
|
|
273
|
+
alpha: float = 0.5,
|
|
274
|
+
fl: str = "*,score",
|
|
275
|
+
fq: Optional[str] = None,
|
|
276
|
+
) -> Dict[str, Any]:
|
|
277
|
+
"""Hybrid (BM25 + kNN) search via the native ``{!hybrid}`` parser.
|
|
278
|
+
|
|
279
|
+
The query is embedded server-side; lexical and vector scores are
|
|
280
|
+
fused per document on the Solr side.
|
|
281
|
+
"""
|
|
282
|
+
clean = query.replace("{", " ").replace("}", " ").replace('"', " ")
|
|
283
|
+
vector = self.embed(index, query, is_query=True)
|
|
284
|
+
compact = json.dumps(vector, separators=(",", ":"))
|
|
285
|
+
params: Dict[str, Any] = {
|
|
286
|
+
"q": (
|
|
287
|
+
f"{{!hybrid lexical=$lexicalRaw vector=$vectorQuery "
|
|
288
|
+
f"mode={mode} alpha={alpha} topN={max(rows, 10)}}}"
|
|
289
|
+
),
|
|
290
|
+
"lexicalRaw": f'{{!edismax qf="title^100 text^1"}}{clean}',
|
|
291
|
+
"vectorQuery": f"{{!knn f=embeddings topK={max(rows, 10)}}}{compact}",
|
|
292
|
+
"rows": rows,
|
|
293
|
+
"fl": fl,
|
|
294
|
+
}
|
|
295
|
+
if fq:
|
|
296
|
+
params["fq"] = fq
|
|
297
|
+
return self.solr_select(index, params)
|
|
298
|
+
|
|
299
|
+
#: RAG context defaults — how many hybrid hits feed the LLM, and how many
|
|
300
|
+
#: words of each hit's text are included. Both overridable per call.
|
|
301
|
+
RAG_DOCS = 3
|
|
302
|
+
RAG_WORDS = 1500
|
|
303
|
+
|
|
304
|
+
def _rag_context(
|
|
305
|
+
self,
|
|
306
|
+
index: str,
|
|
307
|
+
query: str,
|
|
308
|
+
fq: Optional[str] = None,
|
|
309
|
+
docs: Optional[int] = None,
|
|
310
|
+
words: Optional[int] = None,
|
|
311
|
+
) -> str:
|
|
312
|
+
"""Build the LLM context from the top hybrid search hits."""
|
|
313
|
+
|
|
314
|
+
def _flat(v: Any) -> str:
|
|
315
|
+
if isinstance(v, list):
|
|
316
|
+
v = " ".join(str(x) for x in v)
|
|
317
|
+
return str(v or "")
|
|
318
|
+
|
|
319
|
+
docs = docs or self.RAG_DOCS
|
|
320
|
+
words = words or self.RAG_WORDS
|
|
321
|
+
body = self.hybrid_search(
|
|
322
|
+
index, query, rows=docs, fl="title,description,text", fq=fq
|
|
323
|
+
)
|
|
324
|
+
parts: List[str] = []
|
|
325
|
+
for doc in body.get("response", {}).get("docs", []):
|
|
326
|
+
text_words = _flat(doc.get("text")).split()[:words]
|
|
327
|
+
parts.append(
|
|
328
|
+
_flat(doc.get("title")) + " - "
|
|
329
|
+
+ _flat(doc.get("description")) + " - "
|
|
330
|
+
+ " ".join(text_words) + " - "
|
|
331
|
+
)
|
|
332
|
+
return "".join(parts)
|
|
333
|
+
|
|
334
|
+
def ai_summary(
|
|
335
|
+
self,
|
|
336
|
+
index: str,
|
|
337
|
+
query: str,
|
|
338
|
+
filter_query: Optional[str] = None,
|
|
339
|
+
rag_docs: Optional[int] = None,
|
|
340
|
+
rag_words: Optional[int] = None,
|
|
341
|
+
instruction: Optional[str] = None,
|
|
342
|
+
**params: Any,
|
|
343
|
+
) -> str:
|
|
344
|
+
"""Grounded RAG answer: hybrid retrieval over the index feeds the LLM.
|
|
345
|
+
|
|
346
|
+
Retrieval runs client-side via ``hybrid_search`` (same pipeline as the
|
|
347
|
+
hosted search UI): the top ``rag_docs`` hits' title/description/text
|
|
348
|
+
(first ``rag_words`` words each) become the LLM context. Pass
|
|
349
|
+
``instruction`` to fully control the prompt (e.g. "Answer in German",
|
|
350
|
+
"Extract a list of people"). If retrieval fails or returns nothing,
|
|
351
|
+
the server falls back to its own retrieval. Returns plain text.
|
|
352
|
+
"""
|
|
353
|
+
data = {
|
|
354
|
+
**self._auth_params(),
|
|
355
|
+
"index_name": index,
|
|
356
|
+
"query": query,
|
|
357
|
+
"stream": "false",
|
|
358
|
+
**params,
|
|
359
|
+
}
|
|
360
|
+
if instruction:
|
|
361
|
+
data["instruction"] = instruction
|
|
362
|
+
if "context" not in data:
|
|
363
|
+
try:
|
|
364
|
+
context = self._rag_context(
|
|
365
|
+
index, query, fq=filter_query, docs=rag_docs, words=rag_words
|
|
366
|
+
)
|
|
367
|
+
except (OpensolrError, httpx.HTTPError):
|
|
368
|
+
context = ""
|
|
369
|
+
if context:
|
|
370
|
+
data["context"] = context
|
|
371
|
+
data.setdefault(
|
|
372
|
+
"instruction",
|
|
373
|
+
"Read and understand the full context below, and formulate "
|
|
374
|
+
f"a clear, concise and factual answer to: '{query}'.\n"
|
|
375
|
+
"Answer ONLY from the context. Format the answer in "
|
|
376
|
+
"Markdown, use bold section headers where they help, and "
|
|
377
|
+
"cite exact titles or names from the context when "
|
|
378
|
+
"referring to them.\n",
|
|
379
|
+
)
|
|
380
|
+
resp = self._http.post(f"{AI_BASE}/ai_summary", data=data)
|
|
381
|
+
if resp.status_code >= 400:
|
|
382
|
+
raise OpensolrError(f"ai_summary: HTTP {resp.status_code}: {resp.text[:200]}")
|
|
383
|
+
# The stream is prefixed with flush-padding whitespace — strip it.
|
|
384
|
+
return resp.text.strip()
|
|
385
|
+
|
|
260
386
|
def solr_update(self, index: str, payload: Any, commit: bool = True) -> Dict[str, Any]:
|
|
261
387
|
base, auth = self.solr_endpoint(index)
|
|
262
388
|
params = {"commit": "true"} if commit else {"commitWithin": "10000"}
|
|
@@ -248,6 +248,32 @@ class OpensolrDocumentStore:
|
|
|
248
248
|
self.client.ingest(self.index, docs[i : i + 50], wait=self.ingest_wait)
|
|
249
249
|
return len(docs)
|
|
250
250
|
|
|
251
|
+
def ai_answer(
|
|
252
|
+
self,
|
|
253
|
+
query: str,
|
|
254
|
+
filters: Optional[Dict[str, Any]] = None,
|
|
255
|
+
rag_docs: int = 3,
|
|
256
|
+
rag_words: int = 1500,
|
|
257
|
+
instruction: Optional[str] = None,
|
|
258
|
+
**kwargs: Any,
|
|
259
|
+
) -> str:
|
|
260
|
+
"""Grounded RAG answer generated only from this index's content.
|
|
261
|
+
|
|
262
|
+
Two-step pattern: hybrid (BM25 + kNN) retrieval picks the top
|
|
263
|
+
``rag_docs`` hits (first ``rag_words`` words of text each), whose
|
|
264
|
+
title/description/text become the LLM context — the same pipeline as
|
|
265
|
+
Opensolr's hosted search UI. Pass ``instruction`` to fully control
|
|
266
|
+
the prompt (e.g. "Answer in German, cite the sources you used").
|
|
267
|
+
Returns plain text.
|
|
268
|
+
"""
|
|
269
|
+
fqs = _filters_to_fq(filters)
|
|
270
|
+
fq = " AND ".join(f"({f})" for f in fqs) if fqs else None
|
|
271
|
+
return self.client.ai_summary(
|
|
272
|
+
self.index, query, filter_query=fq,
|
|
273
|
+
rag_docs=rag_docs, rag_words=rag_words, instruction=instruction,
|
|
274
|
+
**kwargs,
|
|
275
|
+
)
|
|
276
|
+
|
|
251
277
|
def delete_documents(self, document_ids: List[str]) -> None:
|
|
252
278
|
if not document_ids:
|
|
253
279
|
return
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: opensolr-haystack
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
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
|
|
@@ -121,4 +121,42 @@ entry? Configure the **Web Crawler** in the Control Panel (Index Tools →
|
|
|
121
121
|
WebCrawler): add your site URL, validate it, and Opensolr indexes the whole
|
|
122
122
|
site for you.
|
|
123
123
|
|
|
124
|
+
## Grounded RAG answers
|
|
125
|
+
|
|
126
|
+
One call: hybrid retrieval picks the top hits, whose content becomes the LLM
|
|
127
|
+
context, and Opensolr's server-side LLM answers — no generator component,
|
|
128
|
+
no LLM key:
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
answer = store.ai_answer(
|
|
132
|
+
"what does the refund policy say?",
|
|
133
|
+
rag_docs=3, # how many hybrid hits feed the LLM (default 3)
|
|
134
|
+
rag_words=1500, # words of text taken from each hit (default 1500)
|
|
135
|
+
# instruction="Answer in German, cite the exact titles you used", # optional
|
|
136
|
+
)
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## How it's tested
|
|
140
|
+
|
|
141
|
+
Every release is validated against **live Opensolr infrastructure** — no mocks:
|
|
142
|
+
|
|
143
|
+
- **Unit tests** (offline): location aliases, filter→fq mapping, query building, escaping.
|
|
144
|
+
- **End-to-end suite**: the full write path through the async Data Ingestion
|
|
145
|
+
queue (queued → server-side enrichment → searchable), semantic / hybrid /
|
|
146
|
+
lexical retrieval, metadata round-trip, filters, id round-trip (your ids
|
|
147
|
+
and the Solr `md5(uri)` ids), deletes by id and by query.
|
|
148
|
+
- **Real-corpus validation**: searches run against a 340-document replica of
|
|
149
|
+
opensolr.com's own production search index. Verified: pure-semantic hits
|
|
150
|
+
with zero keyword overlap ("how do I get my data back after a disaster" →
|
|
151
|
+
backup & restore docs), cross-lingual queries (Romanian query → English
|
|
152
|
+
content), exact-term surfacing in hybrid mode, all four hybrid modes, and
|
|
153
|
+
the full alpha range 0 → 1.
|
|
154
|
+
- **PDF ingestion**: a real PDF ingested via `rtf:true` — server-side text
|
|
155
|
+
extraction (13k+ chars), automatic content-type detection, then retrieved
|
|
156
|
+
with a purely semantic query against its contents.
|
|
157
|
+
- **Grounded RAG answers**: `ai_answer` verified end-to-end — a question answerable only from the ingested PDF returns the correct answer, sourced from the PDF's extracted text via hybrid retrieval.
|
|
158
|
+
|
|
159
|
+
The store is exercised live (write via ingestion, DuplicatePolicy SKIP/FAIL,
|
|
160
|
+
hybrid + lexical retrieval, filters, serde round-trip) before every release.
|
|
161
|
+
|
|
124
162
|
MIT license.
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "opensolr-haystack"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.2"
|
|
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
|
|
File without changes
|
|
File without changes
|
{opensolr_haystack-0.2.0 → opensolr_haystack-0.2.2}/opensolr_haystack.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
{opensolr_haystack-0.2.0 → opensolr_haystack-0.2.2}/opensolr_haystack.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|