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.
Files changed (15) hide show
  1. {opensolr_haystack-0.2.0/opensolr_haystack.egg-info → opensolr_haystack-0.2.2}/PKG-INFO +39 -1
  2. {opensolr_haystack-0.2.0 → opensolr_haystack-0.2.2}/README.md +38 -0
  3. {opensolr_haystack-0.2.0 → opensolr_haystack-0.2.2}/haystack_integrations/document_stores/opensolr/client.py +128 -2
  4. {opensolr_haystack-0.2.0 → opensolr_haystack-0.2.2}/haystack_integrations/document_stores/opensolr/store.py +26 -0
  5. {opensolr_haystack-0.2.0 → opensolr_haystack-0.2.2/opensolr_haystack.egg-info}/PKG-INFO +39 -1
  6. {opensolr_haystack-0.2.0 → opensolr_haystack-0.2.2}/pyproject.toml +1 -1
  7. {opensolr_haystack-0.2.0 → opensolr_haystack-0.2.2}/LICENSE +0 -0
  8. {opensolr_haystack-0.2.0 → opensolr_haystack-0.2.2}/haystack_integrations/components/retrievers/opensolr/__init__.py +0 -0
  9. {opensolr_haystack-0.2.0 → opensolr_haystack-0.2.2}/haystack_integrations/components/retrievers/opensolr/retriever.py +0 -0
  10. {opensolr_haystack-0.2.0 → opensolr_haystack-0.2.2}/haystack_integrations/document_stores/opensolr/__init__.py +0 -0
  11. {opensolr_haystack-0.2.0 → opensolr_haystack-0.2.2}/opensolr_haystack.egg-info/SOURCES.txt +0 -0
  12. {opensolr_haystack-0.2.0 → opensolr_haystack-0.2.2}/opensolr_haystack.egg-info/dependency_links.txt +0 -0
  13. {opensolr_haystack-0.2.0 → opensolr_haystack-0.2.2}/opensolr_haystack.egg-info/requires.txt +0 -0
  14. {opensolr_haystack-0.2.0 → opensolr_haystack-0.2.2}/opensolr_haystack.egg-info/top_level.txt +0 -0
  15. {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.0
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 &amp; 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 &amp; 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
- return self.mgmt(
142
- "create_index", index_name=index, core_type="generic", server_country=env
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.0
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 &amp; 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.0"
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" }