os-hybrid-kit 0.5.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. os_hybrid_kit-0.5.4/LICENSE +21 -0
  2. os_hybrid_kit-0.5.4/PKG-INFO +276 -0
  3. os_hybrid_kit-0.5.4/README.md +235 -0
  4. os_hybrid_kit-0.5.4/pyproject.toml +75 -0
  5. os_hybrid_kit-0.5.4/setup.cfg +4 -0
  6. os_hybrid_kit-0.5.4/src/os_hybrid_kit/__init__.py +42 -0
  7. os_hybrid_kit-0.5.4/src/os_hybrid_kit/adapters/__init__.py +5 -0
  8. os_hybrid_kit-0.5.4/src/os_hybrid_kit/adapters/dify/__init__.py +26 -0
  9. os_hybrid_kit-0.5.4/src/os_hybrid_kit/adapters/dify/retrieval.py +105 -0
  10. os_hybrid_kit-0.5.4/src/os_hybrid_kit/adapters/dify/vdb.py +91 -0
  11. os_hybrid_kit-0.5.4/src/os_hybrid_kit/client.py +313 -0
  12. os_hybrid_kit-0.5.4/src/os_hybrid_kit/config.py +148 -0
  13. os_hybrid_kit-0.5.4/src/os_hybrid_kit/integrations/__init__.py +16 -0
  14. os_hybrid_kit-0.5.4/src/os_hybrid_kit/integrations/_search.py +65 -0
  15. os_hybrid_kit-0.5.4/src/os_hybrid_kit/integrations/langchain.py +69 -0
  16. os_hybrid_kit-0.5.4/src/os_hybrid_kit/integrations/llama_index.py +59 -0
  17. os_hybrid_kit-0.5.4/src/os_hybrid_kit/mapping.py +65 -0
  18. os_hybrid_kit-0.5.4/src/os_hybrid_kit/pipeline.py +91 -0
  19. os_hybrid_kit-0.5.4/src/os_hybrid_kit/py.typed +0 -0
  20. os_hybrid_kit-0.5.4/src/os_hybrid_kit/query.py +124 -0
  21. os_hybrid_kit-0.5.4/src/os_hybrid_kit/results.py +70 -0
  22. os_hybrid_kit-0.5.4/src/os_hybrid_kit/types.py +17 -0
  23. os_hybrid_kit-0.5.4/src/os_hybrid_kit.egg-info/PKG-INFO +276 -0
  24. os_hybrid_kit-0.5.4/src/os_hybrid_kit.egg-info/SOURCES.txt +36 -0
  25. os_hybrid_kit-0.5.4/src/os_hybrid_kit.egg-info/dependency_links.txt +1 -0
  26. os_hybrid_kit-0.5.4/src/os_hybrid_kit.egg-info/requires.txt +15 -0
  27. os_hybrid_kit-0.5.4/src/os_hybrid_kit.egg-info/top_level.txt +1 -0
  28. os_hybrid_kit-0.5.4/tests/test_client.py +483 -0
  29. os_hybrid_kit-0.5.4/tests/test_config.py +117 -0
  30. os_hybrid_kit-0.5.4/tests/test_dify_adapter.py +156 -0
  31. os_hybrid_kit-0.5.4/tests/test_integrations_install_error.py +70 -0
  32. os_hybrid_kit-0.5.4/tests/test_integrations_search.py +129 -0
  33. os_hybrid_kit-0.5.4/tests/test_langchain_retriever.py +82 -0
  34. os_hybrid_kit-0.5.4/tests/test_llama_index_retriever.py +77 -0
  35. os_hybrid_kit-0.5.4/tests/test_mapping.py +49 -0
  36. os_hybrid_kit-0.5.4/tests/test_pipeline.py +71 -0
  37. os_hybrid_kit-0.5.4/tests/test_query.py +155 -0
  38. os_hybrid_kit-0.5.4/tests/test_types.py +12 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Zhu Lei
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,276 @@
1
+ Metadata-Version: 2.4
2
+ Name: os-hybrid-kit
3
+ Version: 0.5.4
4
+ Summary: Portable OpenSearch hybrid search (BM25 + kNN + RRF / weighted normalization) with a thin Dify adapter and optional LangChain / LlamaIndex retrievers.
5
+ Author-email: Zhu Lei <woicyou@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/kabishou11/os-hybrid-kit
8
+ Project-URL: Issues, https://github.com/kabishou11/os-hybrid-kit/issues
9
+ Project-URL: Changelog, https://github.com/kabishou11/os-hybrid-kit/blob/main/CHANGELOG.md
10
+ Project-URL: Documentation, https://github.com/kabishou11/os-hybrid-kit#readme
11
+ Project-URL: Source, https://github.com/kabishou11/os-hybrid-kit
12
+ Keywords: opensearch,hybrid-search,bm25,knn,rrf,dify,langchain,llama-index
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Typing :: Typed
22
+ Classifier: Topic :: Database
23
+ Classifier: Topic :: Scientific/Engineering :: Information Analysis
24
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
25
+ Classifier: Topic :: Text Processing :: Indexing
26
+ Requires-Python: >=3.10
27
+ Description-Content-Type: text/markdown
28
+ License-File: LICENSE
29
+ Requires-Dist: opensearch-py<4,>=2.6
30
+ Requires-Dist: pydantic>=2.0
31
+ Provides-Extra: dev
32
+ Requires-Dist: pytest>=8.0; extra == "dev"
33
+ Requires-Dist: pytest-cov>=5.0; extra == "dev"
34
+ Requires-Dist: ruff>=0.6; extra == "dev"
35
+ Provides-Extra: dify
36
+ Provides-Extra: langchain
37
+ Requires-Dist: langchain-core>=0.2; extra == "langchain"
38
+ Provides-Extra: llama-index
39
+ Requires-Dist: llama-index-core>=0.10; extra == "llama-index"
40
+ Dynamic: license-file
41
+
42
+ # os-hybrid-kit
43
+
44
+ Portable Python library for **OpenSearch hybrid search**: BM25 lexical match plus kNN vectors, fused with **RRF** or **weighted score normalization**.
45
+
46
+ [![CI](https://github.com/kabishou11/os-hybrid-kit/actions/workflows/ci.yml/badge.svg)](https://github.com/kabishou11/os-hybrid-kit/actions/workflows/ci.yml)
47
+ [![PyPI](https://img.shields.io/pypi/v/os-hybrid-kit.svg)](https://pypi.org/project/os-hybrid-kit/)
48
+ [![Python versions](https://img.shields.io/pypi/pyversions/os-hybrid-kit.svg)](https://pypi.org/project/os-hybrid-kit/)
49
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/kabishou11/os-hybrid-kit/blob/main/LICENSE)
50
+
51
+ **v0.5.4** — facade frozen; 0.5.x is polish and hardening. You bring the embedding model; the kit builds the mapping, upserts the search pipeline, and runs hybrid search.
52
+
53
+ [CHANGELOG](https://github.com/kabishou11/os-hybrid-kit/blob/main/CHANGELOG.md) · [Production checklist](https://github.com/kabishou11/os-hybrid-kit/blob/main/docs/PRODUCTION.md) · [Relevance and latency notes](https://github.com/kabishou11/os-hybrid-kit/blob/main/docs/NOTES.md) · [Integrations](https://github.com/kabishou11/os-hybrid-kit/blob/main/docs/INTEGRATIONS.md) · [Roadmap](https://github.com/kabishou11/os-hybrid-kit/blob/main/ROADMAP.md)
54
+
55
+ ## Why
56
+
57
+ Keyword scores (BM25) and vector similarity live on different scales. Adding them naively ranks the wrong documents. OpenSearch 2.x solves this with a `hybrid` query and a **search pipeline** that fuses the two lists after the query phase.
58
+
59
+ ## Install and 5-minute quickstart
60
+
61
+ Python 3.10+. Docker for the demo cluster.
62
+
63
+ ```bash
64
+ python -m pip install os-hybrid-kit
65
+ # from source:
66
+ python -m pip install git+https://github.com/kabishou11/os-hybrid-kit.git
67
+ # or from a clone:
68
+ python -m pip install -e .
69
+ docker compose up -d
70
+ python examples/seed_and_search.py
71
+ ```
72
+
73
+ The demo uses OpenSearch **2.19.6** (single node, security plugin off, `http://localhost:9200`) so both RRF and weighted fusion work. It hashes tokens into a tiny vector so you do not need an embedding model.
74
+
75
+ ```bash
76
+ python examples/seed_and_search.py --fusion weighted
77
+ python examples/seed_and_search.py --query "waterproof boots for muddy paths"
78
+ python examples/compare_retrievers.py # BM25 vs kNN vs hybrid ranking
79
+ python examples/benchmark_retrievers.py # same three modes, wall time (ms)
80
+ ```
81
+
82
+ If OpenSearch fails to boot on Linux: `sudo sysctl -w vm.max_map_count=262144`. Wait until `curl http://localhost:9200` returns cluster info. Tear down with `docker compose down` (or `make demo-down`). Example scripts exit with a `docker compose up -d` hint if the cluster is down.
83
+
84
+ ## Library usage
85
+
86
+ **Primary facade:** `HybridKit`, `HybridConfig`, `FusionMethod`, `Hit`, `SearchResult`.
87
+
88
+ **Methods:** `ensure_index`, `exists_index`, `delete_index`, `upsert_pipeline`, `index_documents`, `lexical_search`, `knn_search`, `hybrid_search`. Alias helpers: `put_alias`, `delete_alias`, `get_alias`, `swap_alias`, `with_index`.
89
+
90
+ ```python
91
+ from os_hybrid_kit import FusionMethod, HybridConfig, HybridKit
92
+
93
+ config = HybridConfig(
94
+ hosts=["http://localhost:9200"],
95
+ index="docs",
96
+ dimension=384, # must match your embedding model
97
+ text_field="content",
98
+ vector_field="embedding",
99
+ fusion=FusionMethod.RRF, # or FusionMethod.WEIGHTED
100
+ pipeline_name="hybrid-search-pipeline",
101
+ lexical_weight=0.3, # weighted fusion; clause order is lexical then kNN
102
+ vector_weight=0.7,
103
+ exclude_vector=True, # drop the vector field from _source on search
104
+ )
105
+ # Or: HybridConfig.from_env() # OPENSEARCH_URL / OPENSEARCH_HOSTS,
106
+ # # OPENSEARCH_INDEX, OPENSEARCH_DIM
107
+
108
+ kit = HybridKit(config)
109
+ kit.delete_index() # no-op if the index is missing
110
+ kit.ensure_index(extra_properties={"title": {"type": "text"}})
111
+ kit.upsert_pipeline()
112
+
113
+ kit.index_documents(
114
+ [
115
+ {
116
+ "_id": "1",
117
+ "title": "Trail shoes",
118
+ "content": "Lightweight trail running shoes.",
119
+ "embedding": query_vector, # list[float], length == dimension
120
+ }
121
+ ]
122
+ )
123
+
124
+ result = kit.hybrid_search("running shoes", query_vector, size=10)
125
+ for hit in result.hits: # Hit: id, score, source, index, raw
126
+ print(hit.score, hit.source["content"])
127
+ # result.texts("content") -> list[str]
128
+ ```
129
+
130
+ `hybrid_search` sends a `hybrid` query with two clauses — `match` on the text field, then `knn` on the vector field — and sets `search_pipeline` so OpenSearch fuses the lists. That is the main path. `lexical_search(query)` and `knn_search(embedding)` hit the same index without a pipeline (pure BM25 `match`, raw `knn`). All three accept `filter_query` and `_source` includes/excludes.
131
+
132
+ Embeddings are **yours**; the kit does not call a model or the Neural Search `neural` query. Pass an `EmbedFn` (`(text: str) -> Sequence[float]`) wherever you need to embed a query (the Dify adapter does). An empty embedding, or a vector whose length does not match `config.dimension`, raises `ValueError`. Empty or whitespace-only `query`, alias, and index names on `lexical_search` / `hybrid_search` / alias helpers / `with_index` also raise `ValueError` before OpenSearch is called.
133
+
134
+ `exists_index` / `ensure_index` / `delete_index` operate on `config.index`. `from_env()` reads `OPENSEARCH_HOSTS` (comma-separated, takes precedence) or `OPENSEARCH_URL`, plus `OPENSEARCH_INDEX` and `OPENSEARCH_DIM`. Keyword arguments override the environment.
135
+
136
+ **Builders** (advanced — use these if you already have an OpenSearch client):
137
+
138
+ ```python
139
+ from os_hybrid_kit import (
140
+ EmbedFn,
141
+ build_hybrid_query,
142
+ build_index_body,
143
+ build_knn_query,
144
+ build_knn_vector_property,
145
+ build_lexical_query,
146
+ build_opensearch_client,
147
+ build_pipeline_body,
148
+ build_rrf_pipeline_body,
149
+ build_weighted_pipeline_body,
150
+ parse_search_response,
151
+ upsert_search_pipeline,
152
+ )
153
+ ```
154
+
155
+ ## Aliases and cutover
156
+
157
+ `config.index` is the OpenSearch target for mapping, bulk, and search. It may be a **concrete index** or an **alias**. Pointing reads at an alias lets you rebuild a new index and cut over without changing query code.
158
+
159
+ Create the concrete index first (`ensure_index`). Attach an alias with `put_alias` / `swap_alias`, then read through it with `with_index`. Do not call `ensure_index` on an alias name: if the alias is missing, OpenSearch may create a concrete index of that name.
160
+
161
+ ```python
162
+ kit = HybridKit(config) # config.index == "docs-v1"
163
+ kit.ensure_index()
164
+ kit.put_alias("docs-read") # alias -> docs-v1
165
+ kit.with_index("docs-read").hybrid_search(query, embedding)
166
+
167
+ blue = kit.with_index("docs-v2")
168
+ blue.ensure_index()
169
+ blue.index_documents([...])
170
+ kit.swap_alias("docs-read", "docs-v2", old_index="docs-v1")
171
+ ```
172
+
173
+ `swap_alias` issues one `_aliases` request (remove old + add new) when `old_index` is passed. If `old_index` is omitted, the kit looks up current targets first, then updates. A missing alias raises `AliasNotFoundError` (`ValueError`) from `get_alias` / `delete_alias`; `swap_alias` still adds when the alias is missing. This is not a full index-management product: no `is_write_index` policy, no ILM.
174
+
175
+ ## Benchmark
176
+
177
+ `examples/benchmark_retrievers.py` seeds a fixed demo corpus and prints BM25 vs kNN vs hybrid hit ids, scores, and wall time in milliseconds. It is a stopwatch on your machine, not an eval harness and not a quality claim. `examples/compare_retrievers.py` is the ranking side-by-side (no timings).
178
+
179
+ ```bash
180
+ docker compose up -d
181
+ python examples/benchmark_retrievers.py
182
+ python examples/benchmark_retrievers.py --url http://localhost:9200 --n-queries 10 --fusion weighted
183
+ ```
184
+
185
+ ## Stable API (frozen since 0.3 / current 0.5.x)
186
+
187
+ These names have been frozen since 0.3. Additive extras (0.4 LangChain / LlamaIndex retrievers, 0.5 alias helpers) do not rename this facade. Breaking renames will not.
188
+
189
+ | Kind | Names |
190
+ | --- | --- |
191
+ | Types | `HybridKit`, `HybridConfig`, `FusionMethod`, `Hit`, `SearchResult` |
192
+ | Methods | `ensure_index`, `exists_index`, `delete_index`, `upsert_pipeline`, `index_documents`, `lexical_search`, `knn_search`, `hybrid_search` |
193
+ | Additive (0.5) | `put_alias`, `delete_alias`, `get_alias`, `swap_alias`, `with_index` |
194
+ | Fusion values | `FusionMethod.RRF` (`"rrf"`), `FusionMethod.WEIGHTED` (`"weighted"`) |
195
+ | Typing | `EmbedFn` (optional protocol for query embedders) |
196
+ | Advanced | `build_*` helpers, `parse_search_response`, `upsert_search_pipeline`, `build_opensearch_client` |
197
+
198
+ `Hit` fields: `id`, `score`, `source`, `index`, and `raw` (the original OpenSearch hit dict; optional if you construct a `Hit` yourself — `parse_search_response` always fills it). `SearchResult` fields: `hits`, `total`, `max_score`, `raw`, plus `texts(field)` which returns `str(hit.source.get(field, ""))` for each hit.
199
+
200
+ ## Fusion
201
+
202
+ | Fusion | Processor | Combines | OpenSearch |
203
+ | --- | --- | --- | --- |
204
+ | Reciprocal rank fusion (`FusionMethod.RRF`) | `score-ranker-processor` | ranks, not raw scores | 2.19+ |
205
+ | Weighted normalization (`FusionMethod.WEIGHTED`) | `normalization-processor` | min-max / L2 / z-score, then a weighted mean | 2.11+ |
206
+
207
+ Default weighted weights are `lexical_weight=0.3`, `vector_weight=0.7` and must sum to 1.0. Clause order is lexical then kNN, so those weights line up with the hybrid query.
208
+
209
+ ## Integrations (optional)
210
+
211
+ Thin retrievers around the frozen `HybridKit` facade. Core install does not pull these in.
212
+
213
+ ```bash
214
+ pip install "os-hybrid-kit[langchain]"
215
+ pip install "os-hybrid-kit[llama-index]"
216
+ ```
217
+
218
+ ```python
219
+ from os_hybrid_kit import HybridConfig, HybridKit
220
+ from os_hybrid_kit.integrations.langchain import HybridKitRetriever
221
+
222
+ kit = HybridKit(HybridConfig(dimension=384, index="docs"))
223
+ retriever = HybridKitRetriever(
224
+ kit,
225
+ embed_fn, # (text: str) -> Sequence[float]
226
+ search_kwargs={"size": 10, "mode": "hybrid"}, # hybrid | lexical | knn
227
+ )
228
+ docs = retriever.invoke("waterproof trail shoes")
229
+ ```
230
+
231
+ ```python
232
+ from os_hybrid_kit.integrations.llama_index import HybridKitRetriever
233
+
234
+ retriever = HybridKitRetriever(kit, embed_fn, search_kwargs={"size": 10})
235
+ nodes = retriever.retrieve("waterproof trail shoes")
236
+ ```
237
+
238
+ Importing a submodule without its extra raises `os_hybrid_kit.integrations.InstallError`. Details: [docs/INTEGRATIONS.md](https://github.com/kabishou11/os-hybrid-kit/blob/main/docs/INTEGRATIONS.md).
239
+
240
+ ## Dify adapter
241
+
242
+ `os_hybrid_kit.adapters.dify` (`src/os_hybrid_kit/adapters/dify/`) is a stub, not a marketplace plugin. Source checkouts can still import `adapters.dify`; that path warns and is not included in the wheel.
243
+
244
+ - **External knowledge:** `retrieve(kit, request, embed_fn)` accepts Dify's `POST /retrieval` JSON and returns `{ "records": [ { content, score, title, metadata } ] }`. Dify only sends text, so you pass an `EmbedFn`.
245
+ - **VDB stub:** `HybridVectorStore` exposes `add_texts`, `hybrid_search`, `search_by_vector`, and `search_by_full_text` for a plugin you maintain.
246
+
247
+ RRF scores are small (they are sums of `1 / (rank_constant + rank)`). If Dify's score threshold is `0.5`, RRF hits will be filtered out. Use threshold `0` with RRF, or switch to weighted fusion (scores are typically in `[0, 1]`). Details: [docs/PRODUCTION.md](https://github.com/kabishou11/os-hybrid-kit/blob/main/docs/PRODUCTION.md).
248
+
249
+ ## Tests
250
+
251
+ Unit tests run on every push. An `integration` job starts OpenSearch 2.19.6 with `docker compose` and runs `pytest -m integration`. You do not need Docker for unit tests. Retriever tests skip unless the matching extra is installed.
252
+
253
+ ```bash
254
+ python -m pip install -e ".[dev]"
255
+ pytest -m "not integration" # unit tests; no Docker / live cluster
256
+ # or: make test && make lint
257
+ pytest -m integration # live cluster; CI sets RUN_INTEGRATION=1
258
+ # retriever tests skip unless extras are installed:
259
+ make test-extras # pip install -e ".[dev,langchain,llama-index]" then pytest
260
+ ```
261
+
262
+ Live tests skip unless `RUN_INTEGRATION=1`. Default URL is `OPENSEARCH_URL` or `http://localhost:9200`.
263
+
264
+ ## What this is not
265
+
266
+ - A RAG UI, chat app, or document-ingestion product
267
+ - An MCP skills bridge
268
+ - An evaluation harness or RAGFlow eval kit
269
+ - A LiteLLM / model gateway
270
+ - ColQwen, late-interaction, or multimodal retrieval
271
+ - Guardrails or policy enforcement
272
+ - A packaged Dify marketplace plugin (the adapter is the retrieval contract plus a VDB stub)
273
+
274
+ ## License
275
+
276
+ MIT. See [LICENSE](https://github.com/kabishou11/os-hybrid-kit/blob/main/LICENSE).
@@ -0,0 +1,235 @@
1
+ # os-hybrid-kit
2
+
3
+ Portable Python library for **OpenSearch hybrid search**: BM25 lexical match plus kNN vectors, fused with **RRF** or **weighted score normalization**.
4
+
5
+ [![CI](https://github.com/kabishou11/os-hybrid-kit/actions/workflows/ci.yml/badge.svg)](https://github.com/kabishou11/os-hybrid-kit/actions/workflows/ci.yml)
6
+ [![PyPI](https://img.shields.io/pypi/v/os-hybrid-kit.svg)](https://pypi.org/project/os-hybrid-kit/)
7
+ [![Python versions](https://img.shields.io/pypi/pyversions/os-hybrid-kit.svg)](https://pypi.org/project/os-hybrid-kit/)
8
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/kabishou11/os-hybrid-kit/blob/main/LICENSE)
9
+
10
+ **v0.5.4** — facade frozen; 0.5.x is polish and hardening. You bring the embedding model; the kit builds the mapping, upserts the search pipeline, and runs hybrid search.
11
+
12
+ [CHANGELOG](https://github.com/kabishou11/os-hybrid-kit/blob/main/CHANGELOG.md) · [Production checklist](https://github.com/kabishou11/os-hybrid-kit/blob/main/docs/PRODUCTION.md) · [Relevance and latency notes](https://github.com/kabishou11/os-hybrid-kit/blob/main/docs/NOTES.md) · [Integrations](https://github.com/kabishou11/os-hybrid-kit/blob/main/docs/INTEGRATIONS.md) · [Roadmap](https://github.com/kabishou11/os-hybrid-kit/blob/main/ROADMAP.md)
13
+
14
+ ## Why
15
+
16
+ Keyword scores (BM25) and vector similarity live on different scales. Adding them naively ranks the wrong documents. OpenSearch 2.x solves this with a `hybrid` query and a **search pipeline** that fuses the two lists after the query phase.
17
+
18
+ ## Install and 5-minute quickstart
19
+
20
+ Python 3.10+. Docker for the demo cluster.
21
+
22
+ ```bash
23
+ python -m pip install os-hybrid-kit
24
+ # from source:
25
+ python -m pip install git+https://github.com/kabishou11/os-hybrid-kit.git
26
+ # or from a clone:
27
+ python -m pip install -e .
28
+ docker compose up -d
29
+ python examples/seed_and_search.py
30
+ ```
31
+
32
+ The demo uses OpenSearch **2.19.6** (single node, security plugin off, `http://localhost:9200`) so both RRF and weighted fusion work. It hashes tokens into a tiny vector so you do not need an embedding model.
33
+
34
+ ```bash
35
+ python examples/seed_and_search.py --fusion weighted
36
+ python examples/seed_and_search.py --query "waterproof boots for muddy paths"
37
+ python examples/compare_retrievers.py # BM25 vs kNN vs hybrid ranking
38
+ python examples/benchmark_retrievers.py # same three modes, wall time (ms)
39
+ ```
40
+
41
+ If OpenSearch fails to boot on Linux: `sudo sysctl -w vm.max_map_count=262144`. Wait until `curl http://localhost:9200` returns cluster info. Tear down with `docker compose down` (or `make demo-down`). Example scripts exit with a `docker compose up -d` hint if the cluster is down.
42
+
43
+ ## Library usage
44
+
45
+ **Primary facade:** `HybridKit`, `HybridConfig`, `FusionMethod`, `Hit`, `SearchResult`.
46
+
47
+ **Methods:** `ensure_index`, `exists_index`, `delete_index`, `upsert_pipeline`, `index_documents`, `lexical_search`, `knn_search`, `hybrid_search`. Alias helpers: `put_alias`, `delete_alias`, `get_alias`, `swap_alias`, `with_index`.
48
+
49
+ ```python
50
+ from os_hybrid_kit import FusionMethod, HybridConfig, HybridKit
51
+
52
+ config = HybridConfig(
53
+ hosts=["http://localhost:9200"],
54
+ index="docs",
55
+ dimension=384, # must match your embedding model
56
+ text_field="content",
57
+ vector_field="embedding",
58
+ fusion=FusionMethod.RRF, # or FusionMethod.WEIGHTED
59
+ pipeline_name="hybrid-search-pipeline",
60
+ lexical_weight=0.3, # weighted fusion; clause order is lexical then kNN
61
+ vector_weight=0.7,
62
+ exclude_vector=True, # drop the vector field from _source on search
63
+ )
64
+ # Or: HybridConfig.from_env() # OPENSEARCH_URL / OPENSEARCH_HOSTS,
65
+ # # OPENSEARCH_INDEX, OPENSEARCH_DIM
66
+
67
+ kit = HybridKit(config)
68
+ kit.delete_index() # no-op if the index is missing
69
+ kit.ensure_index(extra_properties={"title": {"type": "text"}})
70
+ kit.upsert_pipeline()
71
+
72
+ kit.index_documents(
73
+ [
74
+ {
75
+ "_id": "1",
76
+ "title": "Trail shoes",
77
+ "content": "Lightweight trail running shoes.",
78
+ "embedding": query_vector, # list[float], length == dimension
79
+ }
80
+ ]
81
+ )
82
+
83
+ result = kit.hybrid_search("running shoes", query_vector, size=10)
84
+ for hit in result.hits: # Hit: id, score, source, index, raw
85
+ print(hit.score, hit.source["content"])
86
+ # result.texts("content") -> list[str]
87
+ ```
88
+
89
+ `hybrid_search` sends a `hybrid` query with two clauses — `match` on the text field, then `knn` on the vector field — and sets `search_pipeline` so OpenSearch fuses the lists. That is the main path. `lexical_search(query)` and `knn_search(embedding)` hit the same index without a pipeline (pure BM25 `match`, raw `knn`). All three accept `filter_query` and `_source` includes/excludes.
90
+
91
+ Embeddings are **yours**; the kit does not call a model or the Neural Search `neural` query. Pass an `EmbedFn` (`(text: str) -> Sequence[float]`) wherever you need to embed a query (the Dify adapter does). An empty embedding, or a vector whose length does not match `config.dimension`, raises `ValueError`. Empty or whitespace-only `query`, alias, and index names on `lexical_search` / `hybrid_search` / alias helpers / `with_index` also raise `ValueError` before OpenSearch is called.
92
+
93
+ `exists_index` / `ensure_index` / `delete_index` operate on `config.index`. `from_env()` reads `OPENSEARCH_HOSTS` (comma-separated, takes precedence) or `OPENSEARCH_URL`, plus `OPENSEARCH_INDEX` and `OPENSEARCH_DIM`. Keyword arguments override the environment.
94
+
95
+ **Builders** (advanced — use these if you already have an OpenSearch client):
96
+
97
+ ```python
98
+ from os_hybrid_kit import (
99
+ EmbedFn,
100
+ build_hybrid_query,
101
+ build_index_body,
102
+ build_knn_query,
103
+ build_knn_vector_property,
104
+ build_lexical_query,
105
+ build_opensearch_client,
106
+ build_pipeline_body,
107
+ build_rrf_pipeline_body,
108
+ build_weighted_pipeline_body,
109
+ parse_search_response,
110
+ upsert_search_pipeline,
111
+ )
112
+ ```
113
+
114
+ ## Aliases and cutover
115
+
116
+ `config.index` is the OpenSearch target for mapping, bulk, and search. It may be a **concrete index** or an **alias**. Pointing reads at an alias lets you rebuild a new index and cut over without changing query code.
117
+
118
+ Create the concrete index first (`ensure_index`). Attach an alias with `put_alias` / `swap_alias`, then read through it with `with_index`. Do not call `ensure_index` on an alias name: if the alias is missing, OpenSearch may create a concrete index of that name.
119
+
120
+ ```python
121
+ kit = HybridKit(config) # config.index == "docs-v1"
122
+ kit.ensure_index()
123
+ kit.put_alias("docs-read") # alias -> docs-v1
124
+ kit.with_index("docs-read").hybrid_search(query, embedding)
125
+
126
+ blue = kit.with_index("docs-v2")
127
+ blue.ensure_index()
128
+ blue.index_documents([...])
129
+ kit.swap_alias("docs-read", "docs-v2", old_index="docs-v1")
130
+ ```
131
+
132
+ `swap_alias` issues one `_aliases` request (remove old + add new) when `old_index` is passed. If `old_index` is omitted, the kit looks up current targets first, then updates. A missing alias raises `AliasNotFoundError` (`ValueError`) from `get_alias` / `delete_alias`; `swap_alias` still adds when the alias is missing. This is not a full index-management product: no `is_write_index` policy, no ILM.
133
+
134
+ ## Benchmark
135
+
136
+ `examples/benchmark_retrievers.py` seeds a fixed demo corpus and prints BM25 vs kNN vs hybrid hit ids, scores, and wall time in milliseconds. It is a stopwatch on your machine, not an eval harness and not a quality claim. `examples/compare_retrievers.py` is the ranking side-by-side (no timings).
137
+
138
+ ```bash
139
+ docker compose up -d
140
+ python examples/benchmark_retrievers.py
141
+ python examples/benchmark_retrievers.py --url http://localhost:9200 --n-queries 10 --fusion weighted
142
+ ```
143
+
144
+ ## Stable API (frozen since 0.3 / current 0.5.x)
145
+
146
+ These names have been frozen since 0.3. Additive extras (0.4 LangChain / LlamaIndex retrievers, 0.5 alias helpers) do not rename this facade. Breaking renames will not.
147
+
148
+ | Kind | Names |
149
+ | --- | --- |
150
+ | Types | `HybridKit`, `HybridConfig`, `FusionMethod`, `Hit`, `SearchResult` |
151
+ | Methods | `ensure_index`, `exists_index`, `delete_index`, `upsert_pipeline`, `index_documents`, `lexical_search`, `knn_search`, `hybrid_search` |
152
+ | Additive (0.5) | `put_alias`, `delete_alias`, `get_alias`, `swap_alias`, `with_index` |
153
+ | Fusion values | `FusionMethod.RRF` (`"rrf"`), `FusionMethod.WEIGHTED` (`"weighted"`) |
154
+ | Typing | `EmbedFn` (optional protocol for query embedders) |
155
+ | Advanced | `build_*` helpers, `parse_search_response`, `upsert_search_pipeline`, `build_opensearch_client` |
156
+
157
+ `Hit` fields: `id`, `score`, `source`, `index`, and `raw` (the original OpenSearch hit dict; optional if you construct a `Hit` yourself — `parse_search_response` always fills it). `SearchResult` fields: `hits`, `total`, `max_score`, `raw`, plus `texts(field)` which returns `str(hit.source.get(field, ""))` for each hit.
158
+
159
+ ## Fusion
160
+
161
+ | Fusion | Processor | Combines | OpenSearch |
162
+ | --- | --- | --- | --- |
163
+ | Reciprocal rank fusion (`FusionMethod.RRF`) | `score-ranker-processor` | ranks, not raw scores | 2.19+ |
164
+ | Weighted normalization (`FusionMethod.WEIGHTED`) | `normalization-processor` | min-max / L2 / z-score, then a weighted mean | 2.11+ |
165
+
166
+ Default weighted weights are `lexical_weight=0.3`, `vector_weight=0.7` and must sum to 1.0. Clause order is lexical then kNN, so those weights line up with the hybrid query.
167
+
168
+ ## Integrations (optional)
169
+
170
+ Thin retrievers around the frozen `HybridKit` facade. Core install does not pull these in.
171
+
172
+ ```bash
173
+ pip install "os-hybrid-kit[langchain]"
174
+ pip install "os-hybrid-kit[llama-index]"
175
+ ```
176
+
177
+ ```python
178
+ from os_hybrid_kit import HybridConfig, HybridKit
179
+ from os_hybrid_kit.integrations.langchain import HybridKitRetriever
180
+
181
+ kit = HybridKit(HybridConfig(dimension=384, index="docs"))
182
+ retriever = HybridKitRetriever(
183
+ kit,
184
+ embed_fn, # (text: str) -> Sequence[float]
185
+ search_kwargs={"size": 10, "mode": "hybrid"}, # hybrid | lexical | knn
186
+ )
187
+ docs = retriever.invoke("waterproof trail shoes")
188
+ ```
189
+
190
+ ```python
191
+ from os_hybrid_kit.integrations.llama_index import HybridKitRetriever
192
+
193
+ retriever = HybridKitRetriever(kit, embed_fn, search_kwargs={"size": 10})
194
+ nodes = retriever.retrieve("waterproof trail shoes")
195
+ ```
196
+
197
+ Importing a submodule without its extra raises `os_hybrid_kit.integrations.InstallError`. Details: [docs/INTEGRATIONS.md](https://github.com/kabishou11/os-hybrid-kit/blob/main/docs/INTEGRATIONS.md).
198
+
199
+ ## Dify adapter
200
+
201
+ `os_hybrid_kit.adapters.dify` (`src/os_hybrid_kit/adapters/dify/`) is a stub, not a marketplace plugin. Source checkouts can still import `adapters.dify`; that path warns and is not included in the wheel.
202
+
203
+ - **External knowledge:** `retrieve(kit, request, embed_fn)` accepts Dify's `POST /retrieval` JSON and returns `{ "records": [ { content, score, title, metadata } ] }`. Dify only sends text, so you pass an `EmbedFn`.
204
+ - **VDB stub:** `HybridVectorStore` exposes `add_texts`, `hybrid_search`, `search_by_vector`, and `search_by_full_text` for a plugin you maintain.
205
+
206
+ RRF scores are small (they are sums of `1 / (rank_constant + rank)`). If Dify's score threshold is `0.5`, RRF hits will be filtered out. Use threshold `0` with RRF, or switch to weighted fusion (scores are typically in `[0, 1]`). Details: [docs/PRODUCTION.md](https://github.com/kabishou11/os-hybrid-kit/blob/main/docs/PRODUCTION.md).
207
+
208
+ ## Tests
209
+
210
+ Unit tests run on every push. An `integration` job starts OpenSearch 2.19.6 with `docker compose` and runs `pytest -m integration`. You do not need Docker for unit tests. Retriever tests skip unless the matching extra is installed.
211
+
212
+ ```bash
213
+ python -m pip install -e ".[dev]"
214
+ pytest -m "not integration" # unit tests; no Docker / live cluster
215
+ # or: make test && make lint
216
+ pytest -m integration # live cluster; CI sets RUN_INTEGRATION=1
217
+ # retriever tests skip unless extras are installed:
218
+ make test-extras # pip install -e ".[dev,langchain,llama-index]" then pytest
219
+ ```
220
+
221
+ Live tests skip unless `RUN_INTEGRATION=1`. Default URL is `OPENSEARCH_URL` or `http://localhost:9200`.
222
+
223
+ ## What this is not
224
+
225
+ - A RAG UI, chat app, or document-ingestion product
226
+ - An MCP skills bridge
227
+ - An evaluation harness or RAGFlow eval kit
228
+ - A LiteLLM / model gateway
229
+ - ColQwen, late-interaction, or multimodal retrieval
230
+ - Guardrails or policy enforcement
231
+ - A packaged Dify marketplace plugin (the adapter is the retrieval contract plus a VDB stub)
232
+
233
+ ## License
234
+
235
+ MIT. See [LICENSE](https://github.com/kabishou11/os-hybrid-kit/blob/main/LICENSE).
@@ -0,0 +1,75 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "os-hybrid-kit"
7
+ version = "0.5.4"
8
+ description = "Portable OpenSearch hybrid search (BM25 + kNN + RRF / weighted normalization) with a thin Dify adapter and optional LangChain / LlamaIndex retrievers."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ authors = [{ name = "Zhu Lei", email = "woicyou@gmail.com" }]
13
+ requires-python = ">=3.10"
14
+ keywords = ["opensearch", "hybrid-search", "bm25", "knn", "rrf", "dify", "langchain", "llama-index"]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Intended Audience :: Developers",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Typing :: Typed",
25
+ "Topic :: Database",
26
+ "Topic :: Scientific/Engineering :: Information Analysis",
27
+ "Topic :: Software Development :: Libraries :: Python Modules",
28
+ "Topic :: Text Processing :: Indexing",
29
+ ]
30
+ dependencies = [
31
+ "opensearch-py>=2.6,<4",
32
+ "pydantic>=2.0",
33
+ ]
34
+
35
+ [project.optional-dependencies]
36
+ dev = [
37
+ "pytest>=8.0",
38
+ "pytest-cov>=5.0",
39
+ "ruff>=0.6",
40
+ ]
41
+ dify = []
42
+ langchain = ["langchain-core>=0.2"]
43
+ llama-index = ["llama-index-core>=0.10"]
44
+
45
+ [project.urls]
46
+ Homepage = "https://github.com/kabishou11/os-hybrid-kit"
47
+ Issues = "https://github.com/kabishou11/os-hybrid-kit/issues"
48
+ Changelog = "https://github.com/kabishou11/os-hybrid-kit/blob/main/CHANGELOG.md"
49
+ Documentation = "https://github.com/kabishou11/os-hybrid-kit#readme"
50
+ Source = "https://github.com/kabishou11/os-hybrid-kit"
51
+
52
+ [tool.setuptools.packages.find]
53
+ where = ["src"]
54
+
55
+ [tool.setuptools.package-data]
56
+ os_hybrid_kit = ["py.typed"]
57
+
58
+ [tool.pytest.ini_options]
59
+ testpaths = ["tests"]
60
+ markers = [
61
+ "integration: tests that require a live OpenSearch cluster",
62
+ ]
63
+ addopts = "-q"
64
+
65
+ [tool.ruff]
66
+ line-length = 100
67
+ target-version = "py310"
68
+ src = ["src", "adapters", "tests", "examples"]
69
+
70
+ [tool.ruff.lint]
71
+ select = ["E", "F", "I", "UP", "W"]
72
+
73
+ [tool.ruff.lint.per-file-ignores]
74
+ "tests/*" = ["E501"]
75
+ "examples/*" = ["E501"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,42 @@
1
+ """Portable OpenSearch hybrid search kit (BM25 + kNN + RRF / weighted fusion).
2
+
3
+ Primary facade: ``HybridKit``, ``HybridConfig``, ``FusionMethod``, ``Hit``,
4
+ ``SearchResult``. Mapping, pipeline, and query ``build_*`` helpers are advanced.
5
+ """
6
+
7
+ from os_hybrid_kit.client import AliasNotFoundError, HybridKit, build_opensearch_client
8
+ from os_hybrid_kit.config import FusionMethod, HybridConfig
9
+ from os_hybrid_kit.mapping import build_index_body, build_knn_vector_property
10
+ from os_hybrid_kit.pipeline import (
11
+ build_pipeline_body,
12
+ build_rrf_pipeline_body,
13
+ build_weighted_pipeline_body,
14
+ upsert_search_pipeline,
15
+ )
16
+ from os_hybrid_kit.query import build_hybrid_query, build_knn_query, build_lexical_query
17
+ from os_hybrid_kit.results import Hit, SearchResult, parse_search_response
18
+ from os_hybrid_kit.types import EmbedFn
19
+
20
+ __version__ = "0.5.4"
21
+
22
+ __all__ = [
23
+ "AliasNotFoundError",
24
+ "EmbedFn",
25
+ "FusionMethod",
26
+ "Hit",
27
+ "HybridConfig",
28
+ "HybridKit",
29
+ "SearchResult",
30
+ "__version__",
31
+ "build_hybrid_query",
32
+ "build_index_body",
33
+ "build_knn_query",
34
+ "build_knn_vector_property",
35
+ "build_lexical_query",
36
+ "build_opensearch_client",
37
+ "build_pipeline_body",
38
+ "build_rrf_pipeline_body",
39
+ "build_weighted_pipeline_body",
40
+ "parse_search_response",
41
+ "upsert_search_pipeline",
42
+ ]
@@ -0,0 +1,5 @@
1
+ """Optional product adapters. Core search lives in ``os_hybrid_kit``.
2
+
3
+ The Dify external-knowledge adapter and VDB stub live in
4
+ ``os_hybrid_kit.adapters.dify``.
5
+ """