langchain-opensolr 0.1.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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Opensolr SRL
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,118 @@
1
+ Metadata-Version: 2.4
2
+ Name: langchain-opensolr
3
+ Version: 0.1.0
4
+ Summary: LangChain integration for Opensolr — managed Apache Solr with server-side embeddings and hybrid (BM25 + kNN) search
5
+ Author-email: Opensolr <support@opensolr.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://opensolr.com
8
+ Project-URL: Documentation, https://opensolr.com/opensolr-platform-user-documentation/ai-vector
9
+ Project-URL: Repository, https://github.com/opensolr/langchain-opensolr
10
+ Keywords: langchain,opensolr,solr,vector,hybrid-search,rag,embeddings
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: langchain-core>=0.3.0
18
+ Requires-Dist: httpx>=0.25.0
19
+ Dynamic: license-file
20
+
21
+ # langchain-opensolr
22
+
23
+ LangChain integration for [Opensolr](https://opensolr.com) — managed Apache Solr
24
+ with **server-side embeddings** and native **hybrid (BM25 + kNN) search**.
25
+
26
+ No local embedding model. No third-party embedding API key. One set of
27
+ credentials, and the vectors are computed on Opensolr's GPU infrastructure
28
+ (multilingual E5-large-instruct, 1024 dimensions, cosine).
29
+
30
+ ```bash
31
+ pip install langchain-opensolr
32
+ ```
33
+
34
+ ## The whole tutorial
35
+
36
+ ```python
37
+ from langchain_opensolr import OpensolrVectorStore
38
+
39
+ vs = OpensolrVectorStore(
40
+ index="mysite__dense", # vector-enabled Opensolr index
41
+ email="you@example.com",
42
+ api_key="YOUR_OPENSOLR_API_KEY",
43
+ create_if_missing=True, # provisions the index on first use
44
+ )
45
+
46
+ vs.add_texts(
47
+ ["Hybrid search fuses BM25 with vector similarity",
48
+ "Cats sleep sixteen hours a day"],
49
+ metadatas=[{"category": "search"}, {"category": "animals"}],
50
+ )
51
+
52
+ docs = vs.similarity_search("how do lexical and semantic search combine?", k=1)
53
+ print(docs[0].page_content)
54
+ ```
55
+
56
+ That's it — no embedding model was configured, because embedding happens on
57
+ the server at both index and query time.
58
+
59
+ ## Hybrid search
60
+
61
+ Pure vector search fails on exact identifiers; pure BM25 fails on meaning.
62
+ Opensolr's `{!hybrid}` query parser fuses both scores **per document**:
63
+
64
+ ```python
65
+ docs = vs.similarity_search(
66
+ "affordable restaurants",
67
+ k=5,
68
+ hybrid=True,
69
+ mode="union", # union | keywords_required | meaning_required | intersection
70
+ alpha=0.5, # 0 = all semantic … 1 = all lexical
71
+ )
72
+ ```
73
+
74
+ ## Metadata filters
75
+
76
+ ```python
77
+ vs.similarity_search("search engines", k=5, filter={"category": "search"})
78
+ vs.similarity_search("anything", k=5, filter='meta_rank:[2 TO *]') # raw Solr fq
79
+ ```
80
+
81
+ Metadata round-trips losslessly (stored as JSON alongside filterable
82
+ `meta_*` fields).
83
+
84
+ ## As a retriever, in any chain
85
+
86
+ ```python
87
+ retriever = vs.as_retriever(search_kwargs={"k": 5, "hybrid": True})
88
+ ```
89
+
90
+ ## Standalone embeddings
91
+
92
+ Use Opensolr's embedding endpoint with any other LangChain component:
93
+
94
+ ```python
95
+ from langchain_opensolr import OpensolrEmbeddings
96
+
97
+ emb = OpensolrEmbeddings(email="you@example.com", api_key="...", index="mysite__dense")
98
+ emb.embed_query("budget-friendly dining") # -> 1024 floats
99
+ ```
100
+
101
+ ## Notes
102
+
103
+ - Vector-enabled indexes run on Opensolr's Solr 9.x environments — locations
104
+ `us` (Chicago), `de` (Germany), `fi` (Finland). Pass `location=` to choose.
105
+ - A free Opensolr account (15-day trial, no card) includes an AI quota that
106
+ comfortably covers this README end to end:
107
+ [opensolr.com](https://opensolr.com).
108
+ - Full platform docs: [AI & Vector Search](https://opensolr.com/opensolr-platform-user-documentation/ai-vector).
109
+
110
+ ## Development
111
+
112
+ ```bash
113
+ pip install -e . pytest
114
+ pytest tests/unit_tests
115
+ OPENSOLR_EMAIL=... OPENSOLR_API_KEY=... OPENSOLR_INDEX=... pytest tests/integration_tests
116
+ ```
117
+
118
+ MIT license.
@@ -0,0 +1,98 @@
1
+ # langchain-opensolr
2
+
3
+ LangChain integration for [Opensolr](https://opensolr.com) — managed Apache Solr
4
+ with **server-side embeddings** and native **hybrid (BM25 + kNN) search**.
5
+
6
+ No local embedding model. No third-party embedding API key. One set of
7
+ credentials, and the vectors are computed on Opensolr's GPU infrastructure
8
+ (multilingual E5-large-instruct, 1024 dimensions, cosine).
9
+
10
+ ```bash
11
+ pip install langchain-opensolr
12
+ ```
13
+
14
+ ## The whole tutorial
15
+
16
+ ```python
17
+ from langchain_opensolr import OpensolrVectorStore
18
+
19
+ vs = OpensolrVectorStore(
20
+ index="mysite__dense", # vector-enabled Opensolr index
21
+ email="you@example.com",
22
+ api_key="YOUR_OPENSOLR_API_KEY",
23
+ create_if_missing=True, # provisions the index on first use
24
+ )
25
+
26
+ vs.add_texts(
27
+ ["Hybrid search fuses BM25 with vector similarity",
28
+ "Cats sleep sixteen hours a day"],
29
+ metadatas=[{"category": "search"}, {"category": "animals"}],
30
+ )
31
+
32
+ docs = vs.similarity_search("how do lexical and semantic search combine?", k=1)
33
+ print(docs[0].page_content)
34
+ ```
35
+
36
+ That's it — no embedding model was configured, because embedding happens on
37
+ the server at both index and query time.
38
+
39
+ ## Hybrid search
40
+
41
+ Pure vector search fails on exact identifiers; pure BM25 fails on meaning.
42
+ Opensolr's `{!hybrid}` query parser fuses both scores **per document**:
43
+
44
+ ```python
45
+ docs = vs.similarity_search(
46
+ "affordable restaurants",
47
+ k=5,
48
+ hybrid=True,
49
+ mode="union", # union | keywords_required | meaning_required | intersection
50
+ alpha=0.5, # 0 = all semantic … 1 = all lexical
51
+ )
52
+ ```
53
+
54
+ ## Metadata filters
55
+
56
+ ```python
57
+ vs.similarity_search("search engines", k=5, filter={"category": "search"})
58
+ vs.similarity_search("anything", k=5, filter='meta_rank:[2 TO *]') # raw Solr fq
59
+ ```
60
+
61
+ Metadata round-trips losslessly (stored as JSON alongside filterable
62
+ `meta_*` fields).
63
+
64
+ ## As a retriever, in any chain
65
+
66
+ ```python
67
+ retriever = vs.as_retriever(search_kwargs={"k": 5, "hybrid": True})
68
+ ```
69
+
70
+ ## Standalone embeddings
71
+
72
+ Use Opensolr's embedding endpoint with any other LangChain component:
73
+
74
+ ```python
75
+ from langchain_opensolr import OpensolrEmbeddings
76
+
77
+ emb = OpensolrEmbeddings(email="you@example.com", api_key="...", index="mysite__dense")
78
+ emb.embed_query("budget-friendly dining") # -> 1024 floats
79
+ ```
80
+
81
+ ## Notes
82
+
83
+ - Vector-enabled indexes run on Opensolr's Solr 9.x environments — locations
84
+ `us` (Chicago), `de` (Germany), `fi` (Finland). Pass `location=` to choose.
85
+ - A free Opensolr account (15-day trial, no card) includes an AI quota that
86
+ comfortably covers this README end to end:
87
+ [opensolr.com](https://opensolr.com).
88
+ - Full platform docs: [AI & Vector Search](https://opensolr.com/opensolr-platform-user-documentation/ai-vector).
89
+
90
+ ## Development
91
+
92
+ ```bash
93
+ pip install -e . pytest
94
+ pytest tests/unit_tests
95
+ OPENSOLR_EMAIL=... OPENSOLR_API_KEY=... OPENSOLR_INDEX=... pytest tests/integration_tests
96
+ ```
97
+
98
+ MIT license.
@@ -0,0 +1,13 @@
1
+ """LangChain integration for Opensolr — managed Apache Solr with server-side
2
+ embeddings and native hybrid (BM25 + kNN) search."""
3
+
4
+ from langchain_opensolr._client import OpensolrClient, OpensolrError
5
+ from langchain_opensolr.embeddings import OpensolrEmbeddings
6
+ from langchain_opensolr.vectorstores import OpensolrVectorStore
7
+
8
+ __all__ = [
9
+ "OpensolrClient",
10
+ "OpensolrError",
11
+ "OpensolrEmbeddings",
12
+ "OpensolrVectorStore",
13
+ ]
@@ -0,0 +1,197 @@
1
+ """Thin REST client for the Opensolr platform APIs.
2
+
3
+ Two base URLs, by platform design:
4
+ - Management API (index list/info/create): https://opensolr.com/solr_manager/api
5
+ - AI API (embed, batch_embed, embed_and_search, ai_summary): https://api.opensolr.com/solr_manager/api
6
+
7
+ Direct Solr access (select/update) goes to the index's own host, resolved via
8
+ ``get_core_info`` (``connection_url`` + HTTP basic auth).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from typing import Any, Dict, List, Optional, Tuple
15
+
16
+ import httpx
17
+
18
+ MGMT_BASE = "https://opensolr.com/solr_manager/api"
19
+ AI_BASE = "https://api.opensolr.com/solr_manager/api"
20
+
21
+ #: Only these Opensolr environments run vector-enabled (Solr 9.x + knn_vector
22
+ #: schema) servers. ``create_index`` for a vector index must target one of them.
23
+ VECTOR_LOCATIONS: Dict[str, str] = {
24
+ "us": "CHICAGO-96",
25
+ "de": "DE-SOLR-9",
26
+ "fi": "FINLAND9",
27
+ }
28
+
29
+ #: Server-side limit for one batch_embed call.
30
+ BATCH_EMBED_MAX = 50
31
+
32
+
33
+ class OpensolrError(RuntimeError):
34
+ """Raised when an Opensolr API call fails."""
35
+
36
+
37
+ class OpensolrClient:
38
+ """Authenticated client for Opensolr management + AI endpoints.
39
+
40
+ Args:
41
+ email: Opensolr account email.
42
+ api_key: Opensolr API key (Account > API in the control panel).
43
+ timeout: Per-request timeout in seconds. Embedding calls run on GPU
44
+ infrastructure and are usually fast, but cold starts happen.
45
+ """
46
+
47
+ def __init__(self, email: str, api_key: str, timeout: float = 120.0) -> None:
48
+ self.email = email
49
+ self.api_key = api_key
50
+ self._http = httpx.Client(timeout=timeout, follow_redirects=True)
51
+ self._core_info_cache: Dict[str, Dict[str, Any]] = {}
52
+
53
+ # ------------------------------------------------------------------ #
54
+ # low level #
55
+ # ------------------------------------------------------------------ #
56
+
57
+ def _auth_params(self) -> Dict[str, str]:
58
+ return {"email": self.email, "api_key": self.api_key}
59
+
60
+ def _request(self, base: str, method: str, params: Dict[str, Any]) -> Any:
61
+ url = f"{base}/{method}"
62
+ data = {**self._auth_params(), **params}
63
+ resp = self._http.post(url, data=data)
64
+ if resp.status_code >= 500:
65
+ raise OpensolrError(f"{method}: HTTP {resp.status_code}: {resp.text[:200]}")
66
+ try:
67
+ body = resp.json()
68
+ except json.JSONDecodeError as exc:
69
+ raise OpensolrError(f"{method}: non-JSON response: {resp.text[:200]}") from exc
70
+ if isinstance(body, dict) and body.get("status") is False:
71
+ raise OpensolrError(f"{method}: {body.get('msg', body)}")
72
+ return body
73
+
74
+ def mgmt(self, method: str, **params: Any) -> Any:
75
+ return self._request(MGMT_BASE, method, params)
76
+
77
+ def ai(self, method: str, **params: Any) -> Any:
78
+ return self._request(AI_BASE, method, params)
79
+
80
+ # ------------------------------------------------------------------ #
81
+ # management #
82
+ # ------------------------------------------------------------------ #
83
+
84
+ def get_index_list(self) -> List[Dict[str, str]]:
85
+ return self.mgmt("get_index_list")
86
+
87
+ def get_core_info(self, index: str, refresh: bool = False) -> Dict[str, Any]:
88
+ """Resolve an index's Solr endpoint + HTTP auth. Cached per client."""
89
+ if not refresh and index in self._core_info_cache:
90
+ return self._core_info_cache[index]
91
+ body = self.mgmt("get_core_info", core_name=index)
92
+ msg = body.get("msg") if isinstance(body, dict) else None
93
+ if not isinstance(msg, dict) or "info" not in msg:
94
+ raise OpensolrError(f"get_core_info({index}): unexpected response: {str(body)[:200]}")
95
+ info = msg["info"]
96
+ self._core_info_cache[index] = info
97
+ return info
98
+
99
+ def create_index(self, index: str, location: str = "us") -> Dict[str, Any]:
100
+ """Create a vector-enabled index on one of the vector locations.
101
+
102
+ ``location`` is one of :data:`VECTOR_LOCATIONS` keys ("us", "de", "fi")
103
+ or a raw Opensolr environment identifier.
104
+ """
105
+ env = VECTOR_LOCATIONS.get(location.lower(), location)
106
+ if env not in VECTOR_LOCATIONS.values():
107
+ raise ValueError(
108
+ f"Vector-enabled indexes are only available in these locations: "
109
+ f"{sorted(VECTOR_LOCATIONS)} (got {location!r})"
110
+ )
111
+ return self.mgmt(
112
+ "create_index", index_name=index, core_type="generic", server_country=env
113
+ )
114
+
115
+ # ------------------------------------------------------------------ #
116
+ # AI #
117
+ # ------------------------------------------------------------------ #
118
+
119
+ def embed(self, index: str, text: str, is_query: bool = False) -> List[float]:
120
+ body = self.ai(
121
+ "embed", index_name=index, payload=text, is_query="1" if is_query else "0"
122
+ )
123
+ if not isinstance(body, list) or not body:
124
+ raise OpensolrError(f"embed: unexpected response: {str(body)[:200]}")
125
+ return body
126
+
127
+ def batch_embed(self, index: str, texts: List[str]) -> List[List[float]]:
128
+ """Embed many texts. Chunks transparently at the server's batch limit."""
129
+ out: List[List[float]] = []
130
+ for i in range(0, len(texts), BATCH_EMBED_MAX):
131
+ chunk = texts[i : i + BATCH_EMBED_MAX]
132
+ resp = self._http.post(
133
+ f"{AI_BASE}/batch_embed",
134
+ json={
135
+ **self._auth_params(),
136
+ "index_name": index,
137
+ "payloads": chunk,
138
+ },
139
+ )
140
+ try:
141
+ body = resp.json()
142
+ except json.JSONDecodeError as exc:
143
+ raise OpensolrError(f"batch_embed: non-JSON response: {resp.text[:200]}") from exc
144
+ if isinstance(body, dict) and body.get("status") is False:
145
+ raise OpensolrError(f"batch_embed: {body.get('msg', body)}")
146
+ embeddings = body.get("embeddings") if isinstance(body, dict) else None
147
+ if not isinstance(embeddings, list) or len(embeddings) != len(chunk):
148
+ raise OpensolrError(f"batch_embed: unexpected response: {str(body)[:200]}")
149
+ out.extend(embeddings)
150
+ return out
151
+
152
+ def embed_and_search(self, index: str, query: str, rows: int = 10, **params: Any) -> Dict[str, Any]:
153
+ """Server-side one-shot: embed the query, run hybrid search, return docs."""
154
+ body = self.ai(
155
+ "embed_and_search",
156
+ index_name=index,
157
+ q=query,
158
+ rows=rows,
159
+ **{"in": "all", "fresh": "no", **params},
160
+ )
161
+ return body
162
+
163
+ # ------------------------------------------------------------------ #
164
+ # direct Solr #
165
+ # ------------------------------------------------------------------ #
166
+
167
+ def solr_endpoint(self, index: str) -> Tuple[str, Optional[Tuple[str, str]]]:
168
+ """Return (base_url, basic_auth) for the index's native Solr API."""
169
+ info = self.get_core_info(index)
170
+ url = info.get("connection_url")
171
+ if not url:
172
+ raise OpensolrError(f"No connection_url for index {index!r}")
173
+ auth = None
174
+ if info.get("auth_username"):
175
+ auth = (info["auth_username"], info.get("auth_password") or "")
176
+ return url, auth
177
+
178
+ def solr_select(self, index: str, params: Dict[str, Any]) -> Dict[str, Any]:
179
+ base, auth = self.solr_endpoint(index)
180
+ resp = self._http.post(f"{base}/select", data={"wt": "json", **params}, auth=auth)
181
+ resp.raise_for_status()
182
+ return resp.json()
183
+
184
+ def solr_update(self, index: str, payload: Any, commit: bool = True) -> Dict[str, Any]:
185
+ base, auth = self.solr_endpoint(index)
186
+ params = {"commit": "true"} if commit else {"commitWithin": "10000"}
187
+ resp = self._http.post(
188
+ f"{base}/update",
189
+ params=params,
190
+ json=payload,
191
+ auth=auth,
192
+ )
193
+ resp.raise_for_status()
194
+ return resp.json()
195
+
196
+ def close(self) -> None:
197
+ self._http.close()
@@ -0,0 +1,63 @@
1
+ """Opensolr server-side embeddings for LangChain.
2
+
3
+ Texts are embedded by Opensolr's GPU-backed multilingual model
4
+ (E5-large-instruct, 1024 dimensions). No local model, no extra API keys —
5
+ the same credentials as your Opensolr account.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import List, Optional
11
+
12
+ from langchain_core.embeddings import Embeddings
13
+
14
+ from ._client import OpensolrClient
15
+
16
+
17
+ class OpensolrEmbeddings(Embeddings):
18
+ """Embeddings backed by the Opensolr AI API.
19
+
20
+ Example:
21
+ .. code-block:: python
22
+
23
+ from langchain_opensolr import OpensolrEmbeddings
24
+
25
+ embeddings = OpensolrEmbeddings(
26
+ email="you@example.com",
27
+ api_key="...",
28
+ index="mysite__dense",
29
+ )
30
+ vec = embeddings.embed_query("budget-friendly dining")
31
+
32
+ Args:
33
+ email: Opensolr account email.
34
+ api_key: Opensolr API key.
35
+ index: A vector-enabled Opensolr index name. Embedding requests are
36
+ accounted against this index's plan.
37
+ client: Optional pre-configured :class:`OpensolrClient` to reuse
38
+ (e.g. shared with an :class:`OpensolrVectorStore`).
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ email: str = "",
44
+ api_key: str = "",
45
+ index: str = "",
46
+ client: Optional[OpensolrClient] = None,
47
+ ) -> None:
48
+ if client is None:
49
+ if not (email and api_key):
50
+ raise ValueError("Provide either a client or email + api_key")
51
+ client = OpensolrClient(email, api_key)
52
+ if not index:
53
+ raise ValueError("index is required (embedding is accounted per index)")
54
+ self._client = client
55
+ self._index = index
56
+
57
+ def embed_documents(self, texts: List[str]) -> List[List[float]]:
58
+ if not texts:
59
+ return []
60
+ return self._client.batch_embed(self._index, texts)
61
+
62
+ def embed_query(self, text: str) -> List[float]:
63
+ return self._client.embed(self._index, text, is_query=True)
@@ -0,0 +1,402 @@
1
+ """Opensolr vector store for LangChain.
2
+
3
+ A :class:`~langchain_core.vectorstores.VectorStore` backed by a managed,
4
+ vector-enabled Opensolr index (Apache Solr 9.x, ``knn_vector`` 1024-dim,
5
+ cosine). Embedding happens **server-side** on Opensolr's GPU infrastructure —
6
+ no local embedding model or third-party API key is needed.
7
+
8
+ Highlights:
9
+
10
+ - Zero-config constructor: ``OpensolrVectorStore(index=..., email=..., api_key=...)``.
11
+ Host, port and HTTP auth of the underlying Solr core are resolved
12
+ automatically through the Opensolr management API.
13
+ - ``hybrid=True`` search uses Opensolr's native ``{!hybrid}`` query parser,
14
+ fusing BM25 and kNN per document with a tunable ``alpha`` balance and four
15
+ modes: ``union``, ``keywords_required``, ``meaning_required``, ``intersection``.
16
+ - Metadata round-trips losslessly (stored as JSON alongside filterable
17
+ ``meta_*`` string fields).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import re
24
+ import time
25
+ import uuid
26
+ from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
27
+
28
+ from langchain_core.documents import Document
29
+ from langchain_core.embeddings import Embeddings
30
+ from langchain_core.vectorstores import VectorStore
31
+
32
+ from ._client import VECTOR_LOCATIONS, OpensolrClient, OpensolrError
33
+ from .embeddings import OpensolrEmbeddings
34
+
35
+ _HYBRID_MODES = ("union", "keywords_required", "meaning_required", "intersection")
36
+
37
+ #: Solr fields managed by this integration or by the Opensolr schema that
38
+ #: should not leak into Document.metadata.
39
+ _INTERNAL_FIELDS = {
40
+ "_version_",
41
+ "_root_",
42
+ "score",
43
+ "embeddings",
44
+ "meta_lc_json",
45
+ }
46
+
47
+ _META_KEY_RE = re.compile(r"[^a-z0-9_]+")
48
+
49
+
50
+ def _sanitize_meta_key(key: str) -> str:
51
+ return _META_KEY_RE.sub("_", key.lower()).strip("_")
52
+
53
+
54
+ def _escape_fq_value(value: str) -> str:
55
+ return value.replace("\\", "\\\\").replace('"', '\\"')
56
+
57
+
58
+ def _scalar(value: Any) -> bool:
59
+ return isinstance(value, (str, int, float, bool))
60
+
61
+
62
+ class OpensolrVectorStore(VectorStore):
63
+ """Managed hybrid vector store on Opensolr.
64
+
65
+ Example:
66
+ .. code-block:: python
67
+
68
+ from langchain_opensolr import OpensolrVectorStore
69
+
70
+ vs = OpensolrVectorStore(
71
+ index="mysite__dense",
72
+ email="you@example.com",
73
+ api_key="...",
74
+ )
75
+ vs.add_texts(["Solr is a search platform", "Cats sleep a lot"])
76
+ docs = vs.similarity_search("search engines", k=2)
77
+
78
+ # hybrid BM25 + kNN with filters
79
+ docs = vs.similarity_search(
80
+ "search engines", k=2, hybrid=True, filter={"category": "docs"}
81
+ )
82
+
83
+ Args:
84
+ index: Name of the vector-enabled Opensolr index. Vector indexes live
85
+ on Opensolr's Solr 9.x environments (locations: ``us``, ``de``, ``fi``).
86
+ email: Opensolr account email.
87
+ api_key: Opensolr API key.
88
+ client: Optional pre-configured :class:`OpensolrClient` (overrides
89
+ email/api_key).
90
+ location: Where to create the index if ``create_if_missing`` is set:
91
+ ``us`` (Chicago), ``de`` (Germany) or ``fi`` (Finland) — the
92
+ Opensolr environments with vector-enabled servers.
93
+ create_if_missing: Create the index automatically on first use.
94
+ text_field: Solr field holding page content (default ``text``).
95
+ vector_field: Solr ``knn_vector`` field (default ``embeddings``).
96
+ """
97
+
98
+ def __init__(
99
+ self,
100
+ index: str,
101
+ email: str = "",
102
+ api_key: str = "",
103
+ client: Optional[OpensolrClient] = None,
104
+ location: str = "us",
105
+ create_if_missing: bool = False,
106
+ text_field: str = "text",
107
+ vector_field: str = "embeddings",
108
+ ) -> None:
109
+ if client is None:
110
+ if not (email and api_key):
111
+ raise ValueError("Provide either a client or email + api_key")
112
+ client = OpensolrClient(email, api_key)
113
+ if location.lower() not in VECTOR_LOCATIONS and location not in VECTOR_LOCATIONS.values():
114
+ raise ValueError(
115
+ f"Vector-enabled Opensolr locations are {sorted(VECTOR_LOCATIONS)}; got {location!r}"
116
+ )
117
+ self._client = client
118
+ self._index = index
119
+ self._location = location
120
+ self._create_if_missing = create_if_missing
121
+ self._text_field = text_field
122
+ self._vector_field = vector_field
123
+ self._checked = False
124
+
125
+ # ------------------------------------------------------------------ #
126
+ # plumbing #
127
+ # ------------------------------------------------------------------ #
128
+
129
+ @property
130
+ def embeddings(self) -> Embeddings:
131
+ """Server-side embeddings bound to this index."""
132
+ return OpensolrEmbeddings(client=self._client, index=self._index)
133
+
134
+ def _ensure_index(self) -> None:
135
+ if self._checked:
136
+ return
137
+ try:
138
+ info = self._client.get_core_info(self._index)
139
+ except OpensolrError:
140
+ if not self._create_if_missing:
141
+ raise
142
+ self._client.create_index(self._index, self._location)
143
+ info = None
144
+ for _ in range(5):
145
+ time.sleep(2)
146
+ try:
147
+ info = self._client.get_core_info(self._index, refresh=True)
148
+ break
149
+ except OpensolrError:
150
+ continue
151
+ if info is None:
152
+ raise
153
+ version = str(info.get("solr_version", ""))
154
+ if version and not version.startswith("9"):
155
+ raise OpensolrError(
156
+ f"Index {self._index!r} runs Solr {version}, but vector search "
157
+ f"requires Solr 9.x. Create the index in one of the vector-enabled "
158
+ f"locations: {sorted(VECTOR_LOCATIONS)}."
159
+ )
160
+ self._checked = True
161
+
162
+ def _doc_from_solr(self, solr_doc: Dict[str, Any]) -> Document:
163
+ def _flat(v: Any) -> Any:
164
+ if isinstance(v, list):
165
+ return v[0] if len(v) == 1 else v
166
+ return v
167
+
168
+ content = _flat(solr_doc.get(self._text_field, "")) or ""
169
+ if isinstance(content, list):
170
+ content = " ".join(str(c) for c in content)
171
+
172
+ metadata: Dict[str, Any] = {}
173
+ raw_json = _flat(solr_doc.get("meta_lc_json"))
174
+ if raw_json:
175
+ try:
176
+ metadata = json.loads(raw_json)
177
+ except (TypeError, json.JSONDecodeError):
178
+ metadata = {}
179
+ if not metadata:
180
+ for key, value in solr_doc.items():
181
+ if key.startswith("meta_") and key not in _INTERNAL_FIELDS:
182
+ metadata[key[5:]] = _flat(value)
183
+ return Document(
184
+ id=str(_flat(solr_doc.get("id", ""))),
185
+ page_content=str(content),
186
+ metadata=metadata,
187
+ )
188
+
189
+ def _filter_to_fq(self, filter: Any) -> List[str]:
190
+ if filter is None:
191
+ return []
192
+ if isinstance(filter, str):
193
+ return [filter]
194
+ if isinstance(filter, list):
195
+ return [str(f) for f in filter]
196
+ if isinstance(filter, dict):
197
+ fq = []
198
+ for key, value in filter.items():
199
+ field = f"meta_{_sanitize_meta_key(key)}"
200
+ if isinstance(value, list):
201
+ joined = " OR ".join(f'"{_escape_fq_value(str(v))}"' for v in value)
202
+ fq.append(f"{field}:({joined})")
203
+ else:
204
+ fq.append(f'{field}:"{_escape_fq_value(str(value))}"')
205
+ return fq
206
+ raise ValueError(f"Unsupported filter type: {type(filter)}")
207
+
208
+ # ------------------------------------------------------------------ #
209
+ # write path #
210
+ # ------------------------------------------------------------------ #
211
+
212
+ def add_texts(
213
+ self,
214
+ texts: Iterable[str],
215
+ metadatas: Optional[List[dict]] = None,
216
+ ids: Optional[List[str]] = None,
217
+ **kwargs: Any,
218
+ ) -> List[str]:
219
+ texts = list(texts)
220
+ if not texts:
221
+ return []
222
+ self._ensure_index()
223
+ metadatas = metadatas or [{} for _ in texts]
224
+ ids = ids or [str(uuid.uuid4()) for _ in texts]
225
+ if not (len(texts) == len(metadatas) == len(ids)):
226
+ raise ValueError("texts, metadatas and ids must have the same length")
227
+
228
+ vectors = self._client.batch_embed(self._index, texts)
229
+
230
+ docs = []
231
+ for text, meta, doc_id, vector in zip(texts, metadatas, ids, vectors):
232
+ doc: Dict[str, Any] = {
233
+ "id": doc_id,
234
+ self._text_field: text,
235
+ self._vector_field: vector,
236
+ "meta_lc_json": json.dumps(meta, ensure_ascii=False),
237
+ }
238
+ title = meta.get("title") if isinstance(meta, dict) else None
239
+ doc["title"] = str(title) if title else text[:100]
240
+ for key, value in (meta or {}).items():
241
+ if _scalar(value):
242
+ field = f"meta_{_sanitize_meta_key(key)}"
243
+ if field not in ("meta_lc_json",):
244
+ doc[field] = str(value)
245
+ docs.append(doc)
246
+
247
+ self._client.solr_update(self._index, docs)
248
+ return ids
249
+
250
+ def delete(self, ids: Optional[List[str]] = None, **kwargs: Any) -> Optional[bool]:
251
+ self._ensure_index()
252
+ if ids is None:
253
+ if kwargs.get("delete_all"):
254
+ self._client.solr_update(self._index, {"delete": {"query": "*:*"}})
255
+ return True
256
+ raise ValueError("Provide ids, or delete_all=True to clear the index")
257
+ self._client.solr_update(self._index, {"delete": list(ids)})
258
+ return True
259
+
260
+ def get_by_ids(self, ids: Sequence[str], /) -> List[Document]:
261
+ self._ensure_index()
262
+ joined = " OR ".join(f'"{_escape_fq_value(str(i))}"' for i in ids)
263
+ body = self._client.solr_select(
264
+ self._index, {"q": f"id:({joined})", "rows": len(ids), "fl": "*"}
265
+ )
266
+ found = {d.id: d for d in map(self._doc_from_solr, body["response"]["docs"])}
267
+ return [found[i] for i in ids if i in found]
268
+
269
+ # ------------------------------------------------------------------ #
270
+ # read path #
271
+ # ------------------------------------------------------------------ #
272
+
273
+ def _knn_query(self, vector: List[float], k: int) -> str:
274
+ compact = json.dumps(vector, separators=(",", ":"))
275
+ return f"{{!knn f={self._vector_field} topK={k}}}{compact}"
276
+
277
+ def similarity_search_with_score(
278
+ self,
279
+ query: str,
280
+ k: int = 4,
281
+ filter: Optional[Any] = None,
282
+ hybrid: bool = False,
283
+ mode: str = "union",
284
+ alpha: float = 0.5,
285
+ **kwargs: Any,
286
+ ) -> List[Tuple[Document, float]]:
287
+ """Return documents most similar to ``query`` with their scores.
288
+
289
+ Args:
290
+ query: Natural language query. Embedded server-side.
291
+ k: Number of documents to return.
292
+ filter: Metadata filter — a dict (``{"key": "value"}`` matches the
293
+ ``meta_key`` field), a raw Solr ``fq`` string, or a list of them.
294
+ hybrid: Fuse BM25 (lexical) and kNN (semantic) scores per document
295
+ using Opensolr's ``{!hybrid}`` query parser instead of pure kNN.
296
+ mode: Hybrid mode — ``union`` (default), ``keywords_required``,
297
+ ``meaning_required`` or ``intersection``.
298
+ alpha: Hybrid semantic↔lexical balance, 0 = all semantic,
299
+ 1 = all lexical.
300
+ """
301
+ self._ensure_index()
302
+ vector = self._client.embed(self._index, query, is_query=True)
303
+ knn = self._knn_query(vector, max(k, 10))
304
+
305
+ params: Dict[str, Any] = {
306
+ "rows": k,
307
+ "fl": "*,score",
308
+ }
309
+ if hybrid:
310
+ if mode not in _HYBRID_MODES:
311
+ raise ValueError(f"mode must be one of {_HYBRID_MODES}, got {mode!r}")
312
+ clean = query.replace("{", " ").replace("}", " ").replace('"', " ")
313
+ params["q"] = (
314
+ f"{{!hybrid lexical=$lexicalRaw vector=$vectorQuery "
315
+ f"mode={mode} alpha={alpha} topN={max(k, 10)}}}"
316
+ )
317
+ params["lexicalRaw"] = (
318
+ f'{{!edismax qf="title^100 {self._text_field}^1"}}{clean}'
319
+ )
320
+ params["vectorQuery"] = knn
321
+ else:
322
+ params["q"] = knn
323
+
324
+ for i, fq in enumerate(self._filter_to_fq(filter)):
325
+ params.setdefault("fq", [])
326
+ params["fq"].append(fq)
327
+
328
+ body = self._client.solr_select(self._index, params)
329
+ docs = body["response"]["docs"]
330
+ return [
331
+ (self._doc_from_solr(d), float(d.get("score", 0.0)))
332
+ for d in docs
333
+ ]
334
+
335
+ def similarity_search(
336
+ self,
337
+ query: str,
338
+ k: int = 4,
339
+ filter: Optional[Any] = None,
340
+ **kwargs: Any,
341
+ ) -> List[Document]:
342
+ return [
343
+ doc
344
+ for doc, _ in self.similarity_search_with_score(
345
+ query, k=k, filter=filter, **kwargs
346
+ )
347
+ ]
348
+
349
+ def similarity_search_by_vector(
350
+ self,
351
+ embedding: List[float],
352
+ k: int = 4,
353
+ filter: Optional[Any] = None,
354
+ **kwargs: Any,
355
+ ) -> List[Document]:
356
+ self._ensure_index()
357
+ params: Dict[str, Any] = {
358
+ "q": self._knn_query(embedding, max(k, 10)),
359
+ "rows": k,
360
+ "fl": "*,score",
361
+ }
362
+ fq = self._filter_to_fq(filter)
363
+ if fq:
364
+ params["fq"] = fq
365
+ body = self._client.solr_select(self._index, params)
366
+ return [self._doc_from_solr(d) for d in body["response"]["docs"]]
367
+
368
+ # ------------------------------------------------------------------ #
369
+ # constructors #
370
+ # ------------------------------------------------------------------ #
371
+
372
+ @classmethod
373
+ def from_texts(
374
+ cls,
375
+ texts: List[str],
376
+ embedding: Optional[Embeddings] = None,
377
+ metadatas: Optional[List[dict]] = None,
378
+ *,
379
+ index: str = "",
380
+ email: str = "",
381
+ api_key: str = "",
382
+ location: str = "us",
383
+ ids: Optional[List[str]] = None,
384
+ **kwargs: Any,
385
+ ) -> "OpensolrVectorStore":
386
+ """Build a store from texts.
387
+
388
+ ``embedding`` is accepted for interface compatibility but ignored —
389
+ Opensolr embeds server-side with its own multilingual model.
390
+ """
391
+ if not index:
392
+ raise ValueError("index is required")
393
+ store = cls(
394
+ index=index,
395
+ email=email,
396
+ api_key=api_key,
397
+ location=location,
398
+ create_if_missing=True,
399
+ **kwargs,
400
+ )
401
+ store.add_texts(texts, metadatas=metadatas, ids=ids)
402
+ return store
@@ -0,0 +1,118 @@
1
+ Metadata-Version: 2.4
2
+ Name: langchain-opensolr
3
+ Version: 0.1.0
4
+ Summary: LangChain integration for Opensolr — managed Apache Solr with server-side embeddings and hybrid (BM25 + kNN) search
5
+ Author-email: Opensolr <support@opensolr.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://opensolr.com
8
+ Project-URL: Documentation, https://opensolr.com/opensolr-platform-user-documentation/ai-vector
9
+ Project-URL: Repository, https://github.com/opensolr/langchain-opensolr
10
+ Keywords: langchain,opensolr,solr,vector,hybrid-search,rag,embeddings
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: langchain-core>=0.3.0
18
+ Requires-Dist: httpx>=0.25.0
19
+ Dynamic: license-file
20
+
21
+ # langchain-opensolr
22
+
23
+ LangChain integration for [Opensolr](https://opensolr.com) — managed Apache Solr
24
+ with **server-side embeddings** and native **hybrid (BM25 + kNN) search**.
25
+
26
+ No local embedding model. No third-party embedding API key. One set of
27
+ credentials, and the vectors are computed on Opensolr's GPU infrastructure
28
+ (multilingual E5-large-instruct, 1024 dimensions, cosine).
29
+
30
+ ```bash
31
+ pip install langchain-opensolr
32
+ ```
33
+
34
+ ## The whole tutorial
35
+
36
+ ```python
37
+ from langchain_opensolr import OpensolrVectorStore
38
+
39
+ vs = OpensolrVectorStore(
40
+ index="mysite__dense", # vector-enabled Opensolr index
41
+ email="you@example.com",
42
+ api_key="YOUR_OPENSOLR_API_KEY",
43
+ create_if_missing=True, # provisions the index on first use
44
+ )
45
+
46
+ vs.add_texts(
47
+ ["Hybrid search fuses BM25 with vector similarity",
48
+ "Cats sleep sixteen hours a day"],
49
+ metadatas=[{"category": "search"}, {"category": "animals"}],
50
+ )
51
+
52
+ docs = vs.similarity_search("how do lexical and semantic search combine?", k=1)
53
+ print(docs[0].page_content)
54
+ ```
55
+
56
+ That's it — no embedding model was configured, because embedding happens on
57
+ the server at both index and query time.
58
+
59
+ ## Hybrid search
60
+
61
+ Pure vector search fails on exact identifiers; pure BM25 fails on meaning.
62
+ Opensolr's `{!hybrid}` query parser fuses both scores **per document**:
63
+
64
+ ```python
65
+ docs = vs.similarity_search(
66
+ "affordable restaurants",
67
+ k=5,
68
+ hybrid=True,
69
+ mode="union", # union | keywords_required | meaning_required | intersection
70
+ alpha=0.5, # 0 = all semantic … 1 = all lexical
71
+ )
72
+ ```
73
+
74
+ ## Metadata filters
75
+
76
+ ```python
77
+ vs.similarity_search("search engines", k=5, filter={"category": "search"})
78
+ vs.similarity_search("anything", k=5, filter='meta_rank:[2 TO *]') # raw Solr fq
79
+ ```
80
+
81
+ Metadata round-trips losslessly (stored as JSON alongside filterable
82
+ `meta_*` fields).
83
+
84
+ ## As a retriever, in any chain
85
+
86
+ ```python
87
+ retriever = vs.as_retriever(search_kwargs={"k": 5, "hybrid": True})
88
+ ```
89
+
90
+ ## Standalone embeddings
91
+
92
+ Use Opensolr's embedding endpoint with any other LangChain component:
93
+
94
+ ```python
95
+ from langchain_opensolr import OpensolrEmbeddings
96
+
97
+ emb = OpensolrEmbeddings(email="you@example.com", api_key="...", index="mysite__dense")
98
+ emb.embed_query("budget-friendly dining") # -> 1024 floats
99
+ ```
100
+
101
+ ## Notes
102
+
103
+ - Vector-enabled indexes run on Opensolr's Solr 9.x environments — locations
104
+ `us` (Chicago), `de` (Germany), `fi` (Finland). Pass `location=` to choose.
105
+ - A free Opensolr account (15-day trial, no card) includes an AI quota that
106
+ comfortably covers this README end to end:
107
+ [opensolr.com](https://opensolr.com).
108
+ - Full platform docs: [AI & Vector Search](https://opensolr.com/opensolr-platform-user-documentation/ai-vector).
109
+
110
+ ## Development
111
+
112
+ ```bash
113
+ pip install -e . pytest
114
+ pytest tests/unit_tests
115
+ OPENSOLR_EMAIL=... OPENSOLR_API_KEY=... OPENSOLR_INDEX=... pytest tests/integration_tests
116
+ ```
117
+
118
+ MIT license.
@@ -0,0 +1,12 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ langchain_opensolr/__init__.py
5
+ langchain_opensolr/_client.py
6
+ langchain_opensolr/embeddings.py
7
+ langchain_opensolr/vectorstores.py
8
+ langchain_opensolr.egg-info/PKG-INFO
9
+ langchain_opensolr.egg-info/SOURCES.txt
10
+ langchain_opensolr.egg-info/dependency_links.txt
11
+ langchain_opensolr.egg-info/requires.txt
12
+ langchain_opensolr.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ langchain-core>=0.3.0
2
+ httpx>=0.25.0
@@ -0,0 +1 @@
1
+ langchain_opensolr
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "langchain-opensolr"
7
+ version = "0.1.0"
8
+ description = "LangChain integration for Opensolr — managed Apache Solr with server-side embeddings and hybrid (BM25 + kNN) search"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.9"
12
+ authors = [{ name = "Opensolr", email = "support@opensolr.com" }]
13
+ dependencies = [
14
+ "langchain-core>=0.3.0",
15
+ "httpx>=0.25.0",
16
+ ]
17
+ keywords = ["langchain", "opensolr", "solr", "vector", "hybrid-search", "rag", "embeddings"]
18
+ classifiers = [
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3",
21
+ "Topic :: Software Development :: Libraries :: Python Modules",
22
+ ]
23
+
24
+ [project.urls]
25
+ Homepage = "https://opensolr.com"
26
+ Documentation = "https://opensolr.com/opensolr-platform-user-documentation/ai-vector"
27
+ Repository = "https://github.com/opensolr/langchain-opensolr"
28
+
29
+ [tool.setuptools]
30
+ packages = ["langchain_opensolr"]
31
+
32
+ [tool.pytest.ini_options]
33
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+