opensolr-haystack 0.3.1__tar.gz → 0.5.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: opensolr-haystack
3
- Version: 0.3.1
3
+ Version: 0.5.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
@@ -145,10 +145,37 @@ Don't need vectors? Pure keyword search skips the embedding call entirely —
145
145
  zero AI quota, and it works on **any** Opensolr index, including non-vector
146
146
  ones and older Solr versions.
147
147
 
148
+ ## Search by image
149
+
150
+ Search your index with a **photo** instead of a text query. Opensolr reads the
151
+ picture three ways — visual labels (what it depicts), OCR text (words printed on
152
+ it), and any barcode / QR code — and turns that into an ordinary search. Nothing
153
+ new is stored in Solr; the picture simply becomes words.
154
+
155
+ ```python
156
+ # What the picture reads as (labels, OCR text, barcodes) — no search yet:
157
+ read = store.image_to_words("photo.jpg") # path, bytes, or base64
158
+ # {'text': 'red running shoe', 'mode': 'clip',
159
+ # 'labels': ['running shoe', 'sneaker'], 'codes': ['0123456789012']}
160
+
161
+ # Search the index with the picture:
162
+ docs = store.search_by_image("photo.jpg", top_k=4) # engine picks the best reading
163
+ docs = store.search_by_image("photo.jpg", using="meaning") # visual labels
164
+ docs = store.search_by_image("photo.jpg", using="text") # only OCR text
165
+ docs = store.search_by_image("photo.jpg", using="code") # exact barcode / QR match
166
+ docs = store.search_by_image("photo.jpg", using="all") # labels + OCR + codes
167
+ ```
168
+
169
+ `search_by_image` forwards the same tuning as `search` — `hybrid`, `lexical`,
170
+ `alpha`, `fresh_bias`, `filters` — so an image query runs through the exact
171
+ hybrid pipeline a text query does.
172
+
148
173
  ## Your index schema
149
174
 
150
175
  Documents follow the Opensolr document model (`title`, `description`, `text`,
151
- `meta_*` custom fields). To see the full schema: **Control Panel → click your
176
+ `meta_*` custom fields). The whole schema, every field and every type suffix,
177
+ is explained in the [Index Schema Reference](https://opensolr.com/opensolr-platform-user-documentation/schema-reference).
178
+ To see your own copy: **Control Panel → click your
152
179
  index → Configuration → Edit File → schema.xml**. Prefer zero-effort data
153
180
  entry? Configure the **Web Crawler** in the Control Panel (Index Tools →
154
181
  WebCrawler): add your site URL, validate it, and Opensolr indexes the whole
@@ -127,10 +127,37 @@ Don't need vectors? Pure keyword search skips the embedding call entirely —
127
127
  zero AI quota, and it works on **any** Opensolr index, including non-vector
128
128
  ones and older Solr versions.
129
129
 
130
+ ## Search by image
131
+
132
+ Search your index with a **photo** instead of a text query. Opensolr reads the
133
+ picture three ways — visual labels (what it depicts), OCR text (words printed on
134
+ it), and any barcode / QR code — and turns that into an ordinary search. Nothing
135
+ new is stored in Solr; the picture simply becomes words.
136
+
137
+ ```python
138
+ # What the picture reads as (labels, OCR text, barcodes) — no search yet:
139
+ read = store.image_to_words("photo.jpg") # path, bytes, or base64
140
+ # {'text': 'red running shoe', 'mode': 'clip',
141
+ # 'labels': ['running shoe', 'sneaker'], 'codes': ['0123456789012']}
142
+
143
+ # Search the index with the picture:
144
+ docs = store.search_by_image("photo.jpg", top_k=4) # engine picks the best reading
145
+ docs = store.search_by_image("photo.jpg", using="meaning") # visual labels
146
+ docs = store.search_by_image("photo.jpg", using="text") # only OCR text
147
+ docs = store.search_by_image("photo.jpg", using="code") # exact barcode / QR match
148
+ docs = store.search_by_image("photo.jpg", using="all") # labels + OCR + codes
149
+ ```
150
+
151
+ `search_by_image` forwards the same tuning as `search` — `hybrid`, `lexical`,
152
+ `alpha`, `fresh_bias`, `filters` — so an image query runs through the exact
153
+ hybrid pipeline a text query does.
154
+
130
155
  ## Your index schema
131
156
 
132
157
  Documents follow the Opensolr document model (`title`, `description`, `text`,
133
- `meta_*` custom fields). To see the full schema: **Control Panel → click your
158
+ `meta_*` custom fields). The whole schema, every field and every type suffix,
159
+ is explained in the [Index Schema Reference](https://opensolr.com/opensolr-platform-user-documentation/schema-reference).
160
+ To see your own copy: **Control Panel → click your
134
161
  index → Configuration → Edit File → schema.xml**. Prefer zero-effort data
135
162
  entry? Configure the **Web Crawler** in the Control Panel (Index Tools →
136
163
  WebCrawler): add your site URL, validate it, and Opensolr indexes the whole
@@ -66,6 +66,41 @@ BATCH_EMBED_MAX = 50
66
66
  # so it is applied at full strength with no hedging.
67
67
  FRESH_BIAS_FUNCTION = "recip(max(0,ms(NOW,creation_date)),3.16e-11,1,1)"
68
68
 
69
+ #: Default Fresh Results Bias strength when a caller does not pass one.
70
+ FRESH_BIAS_WEIGHT_DEFAULT = 0.5
71
+
72
+
73
+ def fresh_bias_function(weight: Optional[float] = None) -> str:
74
+ """Build the recency function for a 0.0-1.0 ``weight``.
75
+
76
+ Mirrors ``Hybrid_search::fresh_bias_function()`` on opensolr.com, the Drupal module and
77
+ the WordPress plugin. ``recip(ms, c, 1, 1)`` halves at ``ms = 1/c``, so the weight is a
78
+ HALF-LIFE on a geometric scale between 365 days at 0.0 and 6 hours at 1.0:
79
+
80
+ ========== ===========================================================
81
+ weight meaning
82
+ ========== ===========================================================
83
+ 0.0 365-day half-life: technically on, barely visible
84
+ 0.3 41 days
85
+ 0.5 9.6 days (the default)
86
+ 0.7 2.2 days
87
+ 1.0 6 hours: date all but replaces relevance
88
+ ========== ===========================================================
89
+
90
+ The fixed constant this replaces behaved like 0.0, which is why Fresh looked broken on a
91
+ news index: a 10-day-old article kept 97% of its multiplier, nowhere near enough to
92
+ outrank a better-matching older one.
93
+ """
94
+ w = FRESH_BIAS_WEIGHT_DEFAULT if weight is None else float(weight)
95
+ w = max(0.0, min(1.0, w))
96
+ half_life_days = 365.0 * (0.25 / 365.0) ** w
97
+ # PHP's %g writes "1.212e-9" where Python's writes "1.212e-09". Numerically identical,
98
+ # textually not — and this string is compared byte for byte against the platform, the
99
+ # Drupal module and the WordPress plugin by the prompt/query parity harness. Strip the
100
+ # padding zero so all five implementations emit the same characters.
101
+ c = ("%.4g" % (1.0 / (half_life_days * 86400000.0))).replace("e-0", "e-").replace("e+0", "e+")
102
+ return "recip(max(0,ms(NOW,creation_date))," + c + ",1,1)"
103
+
69
104
  #: The four candidate-selection modes the {!hybrid} parser understands.
70
105
  #:
71
106
  #: Validated rather than trusted, because the failure is silent: `mode` is interpolated into
@@ -77,7 +112,7 @@ FRESH_BIAS_FUNCTION = "recip(max(0,ms(NOW,creation_date)),3.16e-11,1,1)"
77
112
  HYBRID_MODES = ("union", "keywords_required", "meaning_required", "intersection")
78
113
 
79
114
 
80
- def apply_fresh_bias(params: Dict[str, Any]) -> Dict[str, Any]:
115
+ def apply_fresh_bias(params: Dict[str, Any], weight: Optional[float] = None) -> Dict[str, Any]:
81
116
  """Wrap an already-built ``params["q"]`` so the recency curve multiplies the
82
117
  FINAL score. Mutates ``params`` in place and returns it.
83
118
 
@@ -99,7 +134,21 @@ def apply_fresh_bias(params: Dict[str, Any]) -> Dict[str, Any]:
99
134
  rather than being inlined, so a ``}`` in the user's text cannot close the
100
135
  ``{!boost}`` block and leave the remainder to be parsed as query syntax.
101
136
  """
102
- params["freshBias"] = FRESH_BIAS_FUNCTION
137
+ # A document with no creation_date evaluates recip() at its MAXIMUM, 1.0 — Solr's
138
+ # ms(NOW, <missing>) is 0 — so an undated document is scored as if published this
139
+ # instant and floats to the top of a "newest first" ranking. Require a date instead
140
+ # of silently promoting the ones that have none.
141
+ fq = params.get("fq")
142
+ date_fq = "+creation_date:[* TO *]"
143
+ if fq is None:
144
+ params["fq"] = date_fq
145
+ elif isinstance(fq, list):
146
+ if date_fq not in fq:
147
+ params["fq"] = fq + [date_fq]
148
+ elif fq != date_fq:
149
+ params["fq"] = [fq, date_fq]
150
+
151
+ params["freshBias"] = fresh_bias_function(weight)
103
152
  params["freshBiasInner"] = params["q"]
104
153
  params["q"] = "{!boost b=$freshBias v=$freshBiasInner}"
105
154
  return params
@@ -491,6 +540,15 @@ class OpensolrClient:
491
540
  def ai(self, method: str, **params: Any) -> Any:
492
541
  return self._request(AI_BASE, method, params)
493
542
 
543
+ def image_to_text(self, index: str, image_b64: str, top_k: int = 8) -> Dict[str, Any]:
544
+ """Turn an image into search words via the Opensolr image_to_text API.
545
+
546
+ The answer carries ``text`` (the words to search with), ``mode`` ("clip"/"ocr"),
547
+ ``labels`` (CLIP visual labels, present even in OCR mode) and ``codes``
548
+ (barcodes / QR codes). No image vector is stored: the picture becomes words.
549
+ """
550
+ return self.ai("image_to_text", index_name=index, image=image_b64, top_k=top_k)
551
+
494
552
  # ------------------------------------------------------------------ #
495
553
  # management #
496
554
  # ------------------------------------------------------------------ #
@@ -706,6 +764,7 @@ class OpensolrClient:
706
764
  fl: str = "*,score",
707
765
  fq: Optional[str] = None,
708
766
  fresh_bias: bool = False,
767
+ fresh_bias_weight: Optional[float] = None,
709
768
  ) -> Dict[str, Any]:
710
769
  """Hybrid (BM25 + kNN) search via the native ``{!hybrid}`` parser.
711
770
 
@@ -745,7 +804,7 @@ class OpensolrClient:
745
804
  if fq:
746
805
  params["fq"] = fq
747
806
  if fresh_bias:
748
- apply_fresh_bias(params)
807
+ apply_fresh_bias(params, fresh_bias_weight)
749
808
  return self.solr_select(index, params)
750
809
 
751
810
  #: RAG context defaults — how many hybrid hits feed the LLM, and how many
@@ -7,6 +7,8 @@ local embedder component needed at indexing time.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ import base64
11
+ from pathlib import Path
10
12
  import json
11
13
  import re
12
14
  from typing import Any, Dict, List, Optional
@@ -376,6 +378,66 @@ class OpensolrDocumentStore:
376
378
  # serialization #
377
379
  # ------------------------------------------------------------------ #
378
380
 
381
+ def image_to_words(self, image, top_k: int = 8) -> Dict[str, Any]:
382
+ """Read an image and return what it was turned into, without searching yet.
383
+
384
+ Returns ``{"text", "mode", "labels", "codes"}``: the best words to search
385
+ with, whether it was read as visual labels ("clip") or OCR text ("ocr"),
386
+ the CLIP visual labels (present even in OCR mode), and any barcodes / QR codes.
387
+ ``image`` is a file path (str/Path) or raw image bytes.
388
+ """
389
+ raw = bytes(image) if isinstance(image, (bytes, bytearray)) else Path(image).read_bytes()
390
+ b64 = base64.b64encode(raw).decode("ascii")
391
+ ans = self.client.image_to_text(self._index, b64, top_k=top_k)
392
+ labels = [l["label"] for l in (ans.get("labels") or []) if isinstance(l, dict) and l.get("label")]
393
+ codes = []
394
+ for c in (ans.get("codes") or []):
395
+ text = c.get("text") if isinstance(c, dict) else (c if isinstance(c, str) else None)
396
+ if text:
397
+ codes.append(text)
398
+ return {"text": (ans.get("text") or "").strip(), "mode": ans.get("mode") or "clip", "labels": labels, "codes": codes}
399
+
400
+ def search_by_image(
401
+ self,
402
+ image,
403
+ top_k: int = 10,
404
+ using: str = "auto",
405
+ filters: Optional[Dict[str, Any]] = None,
406
+ image_top_k: int = 8,
407
+ **search_kwargs,
408
+ ) -> List[Document]:
409
+ """Search the index with a photo instead of a text query.
410
+
411
+ The image is turned into words by :meth:`image_to_words`, then those words
412
+ run through the normal :meth:`search`, so ``hybrid``, ``lexical``, ``alpha``
413
+ and ``fresh_bias`` apply via ``search_kwargs``. No image vector is stored.
414
+
415
+ ``using``: which reading to search with — ``"auto"`` (the engine's chosen
416
+ text), ``"meaning"`` (CLIP visual labels), ``"text"`` (OCR text only), or
417
+ ``"code"`` (the first barcode / QR code), or ``"all"`` (labels + OCR text + barcodes combined).
418
+ """
419
+ read = self.image_to_words(image, top_k=image_top_k)
420
+ if using == "meaning":
421
+ query = ", ".join(read["labels"])
422
+ elif using == "text":
423
+ query = read["text"] if read["mode"] == "ocr" else ""
424
+ elif using == "code":
425
+ query = read["codes"][0] if read["codes"] else ""
426
+ search_kwargs.setdefault("lexical", True)
427
+ elif using == "all":
428
+ parts = list(read["labels"])
429
+ if read["mode"] == "ocr" and read["text"]:
430
+ parts.append(read["text"])
431
+ parts.extend(read["codes"])
432
+ query = ", ".join(p for p in parts if p)
433
+ elif using == "auto":
434
+ query = read["text"]
435
+ else:
436
+ raise ValueError("using must be one of 'auto', 'meaning', 'text', 'code', 'all'")
437
+ if not query:
438
+ return []
439
+ return self.search(query, top_k=top_k, filters=filters, **search_kwargs)
440
+
379
441
  def to_dict(self) -> Dict[str, Any]:
380
442
  return default_to_dict(
381
443
  self,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: opensolr-haystack
3
- Version: 0.3.1
3
+ Version: 0.5.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
@@ -145,10 +145,37 @@ Don't need vectors? Pure keyword search skips the embedding call entirely —
145
145
  zero AI quota, and it works on **any** Opensolr index, including non-vector
146
146
  ones and older Solr versions.
147
147
 
148
+ ## Search by image
149
+
150
+ Search your index with a **photo** instead of a text query. Opensolr reads the
151
+ picture three ways — visual labels (what it depicts), OCR text (words printed on
152
+ it), and any barcode / QR code — and turns that into an ordinary search. Nothing
153
+ new is stored in Solr; the picture simply becomes words.
154
+
155
+ ```python
156
+ # What the picture reads as (labels, OCR text, barcodes) — no search yet:
157
+ read = store.image_to_words("photo.jpg") # path, bytes, or base64
158
+ # {'text': 'red running shoe', 'mode': 'clip',
159
+ # 'labels': ['running shoe', 'sneaker'], 'codes': ['0123456789012']}
160
+
161
+ # Search the index with the picture:
162
+ docs = store.search_by_image("photo.jpg", top_k=4) # engine picks the best reading
163
+ docs = store.search_by_image("photo.jpg", using="meaning") # visual labels
164
+ docs = store.search_by_image("photo.jpg", using="text") # only OCR text
165
+ docs = store.search_by_image("photo.jpg", using="code") # exact barcode / QR match
166
+ docs = store.search_by_image("photo.jpg", using="all") # labels + OCR + codes
167
+ ```
168
+
169
+ `search_by_image` forwards the same tuning as `search` — `hybrid`, `lexical`,
170
+ `alpha`, `fresh_bias`, `filters` — so an image query runs through the exact
171
+ hybrid pipeline a text query does.
172
+
148
173
  ## Your index schema
149
174
 
150
175
  Documents follow the Opensolr document model (`title`, `description`, `text`,
151
- `meta_*` custom fields). To see the full schema: **Control Panel → click your
176
+ `meta_*` custom fields). The whole schema, every field and every type suffix,
177
+ is explained in the [Index Schema Reference](https://opensolr.com/opensolr-platform-user-documentation/schema-reference).
178
+ To see your own copy: **Control Panel → click your
152
179
  index → Configuration → Edit File → schema.xml**. Prefer zero-effort data
153
180
  entry? Configure the **Web Crawler** in the Control Panel (Index Tools →
154
181
  WebCrawler): add your site URL, validate it, and Opensolr indexes the whole
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "opensolr-haystack"
7
- version = "0.3.1"
7
+ version = "0.5.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" }