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.
- langchain_opensolr-0.1.0/LICENSE +21 -0
- langchain_opensolr-0.1.0/PKG-INFO +118 -0
- langchain_opensolr-0.1.0/README.md +98 -0
- langchain_opensolr-0.1.0/langchain_opensolr/__init__.py +13 -0
- langchain_opensolr-0.1.0/langchain_opensolr/_client.py +197 -0
- langchain_opensolr-0.1.0/langchain_opensolr/embeddings.py +63 -0
- langchain_opensolr-0.1.0/langchain_opensolr/vectorstores.py +402 -0
- langchain_opensolr-0.1.0/langchain_opensolr.egg-info/PKG-INFO +118 -0
- langchain_opensolr-0.1.0/langchain_opensolr.egg-info/SOURCES.txt +12 -0
- langchain_opensolr-0.1.0/langchain_opensolr.egg-info/dependency_links.txt +1 -0
- langchain_opensolr-0.1.0/langchain_opensolr.egg-info/requires.txt +2 -0
- langchain_opensolr-0.1.0/langchain_opensolr.egg-info/top_level.txt +1 -0
- langchain_opensolr-0.1.0/pyproject.toml +33 -0
- langchain_opensolr-0.1.0/setup.cfg +4 -0
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -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"]
|