Spaces:
Sleeping
Sleeping
Commit ·
61d5e4e
0
Parent(s):
Phase 0: shared search core + reproducible corpus
Browse filesScaffold the StreamSearch repo and build the reusable heart with no infra:
- embed.py: singleton MiniLM (all-MiniLM-L6-v2, 384-d), batched embed_texts
- search.py: hybrid BM25 + dense (Qdrant) fused with RRF (k=60)
- schema.py: Document contract + Cassandra/Qdrant definitions
- data/corpus_sample.jsonl: 3000 arXiv abstracts (HF mirror, reproducible)
- tests: RRF, embedding determinism, single model-load, hybrid ranking (10 pass)
- pinned requirements.txt (Python 3.11), Makefile, MIT LICENSE, README
Deployment: unified live site over managed free tiers (Qdrant Cloud, Astra
Cassandra, Confluent/Redpanda Kafka); Spark shown via local compose + video.
- .gitattributes +5 -0
- .gitignore +27 -0
- LICENSE +21 -0
- Makefile +35 -0
- README.md +79 -0
- app/prebuilt/.gitkeep +0 -0
- data/corpus_sample.jsonl +0 -0
- requirements.txt +19 -0
- scripts/fetch_corpus.py +65 -0
- src/streamsearch/__init__.py +2 -0
- src/streamsearch/embed.py +47 -0
- src/streamsearch/py.typed +0 -0
- src/streamsearch/schema.py +77 -0
- src/streamsearch/search.py +162 -0
- tests/conftest.py +8 -0
- tests/fixtures/.gitkeep +0 -0
- tests/fixtures/mini_corpus.jsonl +6 -0
- tests/test_embed.py +33 -0
- tests/test_search.py +74 -0
.gitattributes
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Normalize line endings to LF in the repo (avoids CRLF churn on Windows).
|
| 2 |
+
* text=auto eol=lf
|
| 3 |
+
*.png binary
|
| 4 |
+
*.jpg binary
|
| 5 |
+
*.snapshot binary
|
.gitignore
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
.venv/
|
| 5 |
+
venv/
|
| 6 |
+
*.egg-info/
|
| 7 |
+
.pytest_cache/
|
| 8 |
+
|
| 9 |
+
# Model caches (large; downloaded at runtime)
|
| 10 |
+
.cache/
|
| 11 |
+
models/
|
| 12 |
+
*.safetensors
|
| 13 |
+
|
| 14 |
+
# Baked demo index snapshots (rebuilt via scripts/build_index.py)
|
| 15 |
+
app/prebuilt/*
|
| 16 |
+
!app/prebuilt/.gitkeep
|
| 17 |
+
|
| 18 |
+
# Local infra volumes / data
|
| 19 |
+
qdrant_storage/
|
| 20 |
+
cassandra_data/
|
| 21 |
+
redpanda_data/
|
| 22 |
+
*.log
|
| 23 |
+
|
| 24 |
+
# OS / editor
|
| 25 |
+
.DS_Store
|
| 26 |
+
.idea/
|
| 27 |
+
.vscode/
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 Aryan Sharma
|
| 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.
|
Makefile
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# StreamSearch — task runner.
|
| 2 |
+
# On Windows without `make`, run the underlying commands directly (see README),
|
| 3 |
+
# or install make (`winget install GnuWin32.Make`). The demo/proof hosts are
|
| 4 |
+
# Linux, where these targets run as-is.
|
| 5 |
+
|
| 6 |
+
PY ?= .venv/bin/python
|
| 7 |
+
ifeq ($(OS),Windows_NT)
|
| 8 |
+
PY = .venv/Scripts/python.exe
|
| 9 |
+
endif
|
| 10 |
+
|
| 11 |
+
.PHONY: setup test build-index demo produce stream record all
|
| 12 |
+
|
| 13 |
+
setup: ## create venv deps
|
| 14 |
+
$(PY) -m pip install --upgrade pip
|
| 15 |
+
$(PY) -m pip install -r requirements.txt
|
| 16 |
+
|
| 17 |
+
test: ## run the test suite
|
| 18 |
+
$(PY) -m pytest -q
|
| 19 |
+
|
| 20 |
+
build-index: ## build the baked-in demo index from the sample corpus
|
| 21 |
+
$(PY) scripts/build_index.py
|
| 22 |
+
|
| 23 |
+
demo: ## run the demo tier locally (FastAPI + Streamlit)
|
| 24 |
+
$(PY) -m streamlit run app/ui.py
|
| 25 |
+
|
| 26 |
+
produce: ## stream the corpus into the Kafka topic (proof tier)
|
| 27 |
+
$(PY) producer/produce.py
|
| 28 |
+
|
| 29 |
+
stream: ## run the Spark Structured Streaming job (proof tier)
|
| 30 |
+
$(PY) streaming/spark_job.py
|
| 31 |
+
|
| 32 |
+
record: ## print the proof-tier recording steps
|
| 33 |
+
@cat scripts/record_demo.md
|
| 34 |
+
|
| 35 |
+
all: build-index demo ## build the demo index then launch the local demo
|
README.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# StreamSearch — Real-Time Semantic Search over a Streaming Corpus
|
| 2 |
+
|
| 3 |
+
> **🔗 Live demo:** _coming in Phase 1_ · **📹 Pipeline video:** _coming in Phase 4_
|
| 4 |
+
|
| 5 |
+
Documents stream through **Kafka**, are processed and embedded by **Spark
|
| 6 |
+
Structured Streaming**, and land in **Cassandra** (metadata) and a **Qdrant**
|
| 7 |
+
vector database (embeddings) for continuous indexing. Hybrid search — BM25 +
|
| 8 |
+
dense embeddings fused with Reciprocal Rank Fusion — is served over the live
|
| 9 |
+
index through a FastAPI + Streamlit app.
|
| 10 |
+
|
| 11 |
+
## Deployment model — unified live site over managed free tiers
|
| 12 |
+
|
| 13 |
+
One clickable site a recruiter can actually use, backed by **real** managed
|
| 14 |
+
infrastructure on free tiers:
|
| 15 |
+
|
| 16 |
+
- **Live tier** — FastAPI + Streamlit app (Render / HF Spaces) over **Qdrant
|
| 17 |
+
Cloud** (vectors) and **DataStax Astra** (Cassandra metadata). The ingest box
|
| 18 |
+
publishes to **real Confluent/Redpanda Cloud Kafka**; a small always-on
|
| 19 |
+
consumer writes to Cassandra + Qdrant, so a pasted document genuinely flows
|
| 20 |
+
through Kafka → store → search results. *(Phases 1–3)*
|
| 21 |
+
- **Proof tier** — the full **Spark Structured Streaming** job (the one piece
|
| 22 |
+
with no free always-on host) runs against the same stack via local
|
| 23 |
+
`docker compose`, captured on video. This makes the Spark resume bullet
|
| 24 |
+
literally true. *(Phases 3–4)*
|
| 25 |
+
|
| 26 |
+
The shared embedding + search core (`src/streamsearch/`) is written once and
|
| 27 |
+
used by both the live consumer and the Spark job.
|
| 28 |
+
|
| 29 |
+
## Architecture
|
| 30 |
+
|
| 31 |
+
```
|
| 32 |
+
┌───────── shared core (src/streamsearch) ──────────┐
|
| 33 |
+
│ embed.py · search.py (BM25+dense+RRF k=60) · schema │
|
| 34 |
+
└───────────────┬───────────────────┬────────────────┘
|
| 35 |
+
ingest box ─┐ │ │
|
| 36 |
+
▼ ▼ ▼
|
| 37 |
+
Kafka (Confluent/Redpanda Cloud) │ LIVE: small consumer worker
|
| 38 |
+
│ │ ├─→ Astra (Cassandra) metadata
|
| 39 |
+
├──────────────────────► │ └─→ Qdrant Cloud vectors
|
| 40 |
+
│ │
|
| 41 |
+
PROOF: Spark Structured Streaming (docker compose, foreachBatch: embed once)
|
| 42 |
+
└─────────────────────────────► same Cassandra + Qdrant
|
| 43 |
+
│
|
| 44 |
+
FastAPI /search /ingest · Streamlit UI (Render/HF)
|
| 45 |
+
```
|
| 46 |
+
|
| 47 |
+
## Quickstart (local, Windows)
|
| 48 |
+
|
| 49 |
+
```powershell
|
| 50 |
+
py -3.11 -m venv .venv
|
| 51 |
+
.venv\Scripts\python.exe -m pip install -r requirements.txt
|
| 52 |
+
.venv\Scripts\python.exe -m pytest -q # Phase 0 tests
|
| 53 |
+
```
|
| 54 |
+
|
| 55 |
+
> **Note:** built and pinned for **Python 3.11**. `make` targets mirror these
|
| 56 |
+
> commands and run on the Linux demo/proof hosts; on Windows either install
|
| 57 |
+
> `make` or run the `.venv\Scripts\python.exe ...` commands directly.
|
| 58 |
+
|
| 59 |
+
## Corpus
|
| 60 |
+
|
| 61 |
+
`data/corpus_sample.jsonl` — a fixed set of **3,000 arXiv abstracts** (ML/CS)
|
| 62 |
+
built from the `CShorten/ML-ArXiv-Papers` Hugging Face dataset via
|
| 63 |
+
`scripts/fetch_corpus.py` and committed for reproducibility. (We use the HF
|
| 64 |
+
mirror because the live arXiv API rate-limits bursty fetches with HTTP 429.)
|
| 65 |
+
The demo is never empty. `datasets` is a build-time-only dependency.
|
| 66 |
+
|
| 67 |
+
## Scope (honest notes)
|
| 68 |
+
|
| 69 |
+
Single-broker Redpanda, single-node Cassandra, at-least-once micro-batch
|
| 70 |
+
delivery. This is a portfolio piece, not a production platform.
|
| 71 |
+
|
| 72 |
+
## Status
|
| 73 |
+
|
| 74 |
+
- [x] Phase 0 — shared search core + tests
|
| 75 |
+
- [ ] Phase 1 — demo tier deployed (live URL)
|
| 76 |
+
- [ ] Phase 2 — Kafka + producer
|
| 77 |
+
- [ ] Phase 3 — Spark → Cassandra + Qdrant
|
| 78 |
+
- [ ] Phase 4 — unify + record video
|
| 79 |
+
- [ ] Phase 5 — tests, README, polish
|
app/prebuilt/.gitkeep
ADDED
|
File without changes
|
data/corpus_sample.jsonl
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
requirements.txt
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# --- Core: shared embed + search (Phase 0), reused by both tiers ---
|
| 2 |
+
sentence-transformers==3.0.1
|
| 3 |
+
qdrant-client==1.9.2
|
| 4 |
+
rank-bm25==0.2.2
|
| 5 |
+
numpy==1.26.4
|
| 6 |
+
|
| 7 |
+
# --- Demo tier (Phase 1): FastAPI + Streamlit ---
|
| 8 |
+
fastapi==0.111.0
|
| 9 |
+
uvicorn[standard]==0.30.1
|
| 10 |
+
streamlit==1.36.0
|
| 11 |
+
pydantic==2.7.4
|
| 12 |
+
|
| 13 |
+
# --- Proof tier (Phases 2-3): Kafka producer, Spark, Cassandra ---
|
| 14 |
+
confluent-kafka==2.4.0
|
| 15 |
+
pyspark==3.5.1
|
| 16 |
+
cassandra-driver==3.29.1
|
| 17 |
+
|
| 18 |
+
# --- Tests (Phase 5) ---
|
| 19 |
+
pytest==8.2.2
|
scripts/fetch_corpus.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Build a fixed, reproducible corpus_sample.jsonl of arXiv abstracts.
|
| 2 |
+
|
| 3 |
+
Source: the `CShorten/ML-ArXiv-Papers` Hugging Face dataset (arXiv titles +
|
| 4 |
+
abstracts). We use a stable HF mirror rather than the live arXiv API because the
|
| 5 |
+
API rate-limits (HTTP 429) hard on bursty fetches. The output is checked into git
|
| 6 |
+
so runs are deterministic and the demo is never empty (spec, non-negotiable #3).
|
| 7 |
+
|
| 8 |
+
Requires: pip install datasets (build-time only; not a runtime dependency)
|
| 9 |
+
|
| 10 |
+
Usage:
|
| 11 |
+
python scripts/fetch_corpus.py --target 3000
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import argparse
|
| 17 |
+
import json
|
| 18 |
+
import re
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
|
| 21 |
+
HF_DATASET = "CShorten/ML-ArXiv-Papers"
|
| 22 |
+
_WS = re.compile(r"\s+")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def clean(text: str) -> str:
|
| 26 |
+
return _WS.sub(" ", (text or "")).strip()
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def main() -> None:
|
| 30 |
+
ap = argparse.ArgumentParser()
|
| 31 |
+
ap.add_argument("--target", type=int, default=3000, help="number of documents")
|
| 32 |
+
ap.add_argument("--out", type=Path, default=Path("data/corpus_sample.jsonl"))
|
| 33 |
+
args = ap.parse_args()
|
| 34 |
+
|
| 35 |
+
from datasets import load_dataset
|
| 36 |
+
|
| 37 |
+
print(f"Loading {HF_DATASET} ...")
|
| 38 |
+
ds = load_dataset(HF_DATASET, split="train")
|
| 39 |
+
|
| 40 |
+
args.out.parent.mkdir(parents=True, exist_ok=True)
|
| 41 |
+
written = 0
|
| 42 |
+
seen: set[str] = set()
|
| 43 |
+
with args.out.open("w", encoding="utf-8") as f:
|
| 44 |
+
for i, row in enumerate(ds):
|
| 45 |
+
title = clean(row.get("title"))
|
| 46 |
+
abstract = clean(row.get("abstract"))
|
| 47 |
+
if not abstract or title in seen:
|
| 48 |
+
continue
|
| 49 |
+
seen.add(title)
|
| 50 |
+
rec = {
|
| 51 |
+
"id": f"arxiv:{i}",
|
| 52 |
+
"title": title,
|
| 53 |
+
"text": abstract,
|
| 54 |
+
"source": "arxiv/ml",
|
| 55 |
+
}
|
| 56 |
+
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
| 57 |
+
written += 1
|
| 58 |
+
if written >= args.target:
|
| 59 |
+
break
|
| 60 |
+
|
| 61 |
+
print(f"Wrote {written} docs to {args.out}")
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
if __name__ == "__main__":
|
| 65 |
+
main()
|
src/streamsearch/__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .embed import embed_texts, get_model
|
| 2 |
+
from .search import HybridSearcher, reciprocal_rank_fusion
|
src/streamsearch/embed.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared embedding: load the small model ONCE, embed in batches.
|
| 2 |
+
|
| 3 |
+
The model is a module-level singleton. It must never be reloaded per document
|
| 4 |
+
(spec, Phase 0 and Phase 3 gotcha). Both the demo tier and the Spark foreachBatch
|
| 5 |
+
handler import `embed_texts` from here.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import logging
|
| 11 |
+
from functools import lru_cache
|
| 12 |
+
|
| 13 |
+
import numpy as np
|
| 14 |
+
|
| 15 |
+
from .schema import MODEL_NAME, EMBED_DIM
|
| 16 |
+
|
| 17 |
+
log = logging.getLogger(__name__)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@lru_cache(maxsize=1)
|
| 21 |
+
def get_model():
|
| 22 |
+
"""Load and cache the sentence-transformer. Logged so we can prove single-load."""
|
| 23 |
+
from sentence_transformers import SentenceTransformer
|
| 24 |
+
|
| 25 |
+
log.info("Loading embedding model %s (one-time)", MODEL_NAME)
|
| 26 |
+
model = SentenceTransformer(MODEL_NAME)
|
| 27 |
+
return model
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def embed_texts(texts: list[str], batch_size: int = 64) -> np.ndarray:
|
| 31 |
+
"""Batch-embed texts into an (n, EMBED_DIM) float32 array of unit vectors.
|
| 32 |
+
|
| 33 |
+
Normalized embeddings mean cosine similarity == dot product, which is what
|
| 34 |
+
Qdrant is configured for (Distance.COSINE).
|
| 35 |
+
"""
|
| 36 |
+
if not texts:
|
| 37 |
+
return np.empty((0, EMBED_DIM), dtype=np.float32)
|
| 38 |
+
|
| 39 |
+
model = get_model()
|
| 40 |
+
vectors = model.encode(
|
| 41 |
+
texts,
|
| 42 |
+
batch_size=batch_size,
|
| 43 |
+
normalize_embeddings=True,
|
| 44 |
+
convert_to_numpy=True,
|
| 45 |
+
show_progress_bar=False,
|
| 46 |
+
)
|
| 47 |
+
return vectors.astype(np.float32)
|
src/streamsearch/py.typed
ADDED
|
File without changes
|
src/streamsearch/schema.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared record schema and store definitions.
|
| 2 |
+
|
| 3 |
+
One record shape flows through the whole system: the producer emits it as JSON
|
| 4 |
+
onto Kafka, Spark parses it, Cassandra stores the metadata, and Qdrant stores the
|
| 5 |
+
embedding keyed by the same id. Keeping the contract here means the demo tier and
|
| 6 |
+
proof tier cannot drift apart.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from dataclasses import dataclass, asdict
|
| 12 |
+
from typing import Any
|
| 13 |
+
|
| 14 |
+
# Embedding model config. all-MiniLM-L6-v2 is 384-dim, ~90 MB, chosen so the
|
| 15 |
+
# free demo host does not run out of memory (see spec, non-negotiable #2).
|
| 16 |
+
MODEL_NAME = "sentence-transformers/all-MiniLM-L6-v2"
|
| 17 |
+
EMBED_DIM = 384
|
| 18 |
+
|
| 19 |
+
# Qdrant collection holding the dense vectors.
|
| 20 |
+
QDRANT_COLLECTION = "documents"
|
| 21 |
+
|
| 22 |
+
# Kafka topic the producer streams onto and Spark consumes from.
|
| 23 |
+
KAFKA_TOPIC = "documents"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@dataclass
|
| 27 |
+
class Document:
|
| 28 |
+
"""A single corpus document. `id` is the join key across Kafka/Cassandra/Qdrant."""
|
| 29 |
+
|
| 30 |
+
id: str
|
| 31 |
+
title: str
|
| 32 |
+
text: str
|
| 33 |
+
source: str = "corpus_sample"
|
| 34 |
+
|
| 35 |
+
def to_json(self) -> dict[str, Any]:
|
| 36 |
+
return asdict(self)
|
| 37 |
+
|
| 38 |
+
@classmethod
|
| 39 |
+
def from_json(cls, obj: dict[str, Any]) -> "Document":
|
| 40 |
+
return cls(
|
| 41 |
+
id=str(obj["id"]),
|
| 42 |
+
title=obj.get("title", ""),
|
| 43 |
+
text=obj["text"],
|
| 44 |
+
source=obj.get("source", "corpus_sample"),
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
@property
|
| 48 |
+
def embed_input(self) -> str:
|
| 49 |
+
"""Text fed to the embedder: title + body so titles carry weight."""
|
| 50 |
+
return f"{self.title}\n\n{self.text}".strip() if self.title else self.text
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
# --- Cassandra ---------------------------------------------------------------
|
| 54 |
+
# Single-node keyspace/table for document metadata. The vector lives in Qdrant;
|
| 55 |
+
# Cassandra is the system of record for the raw text + metadata.
|
| 56 |
+
CASSANDRA_KEYSPACE = "streamsearch"
|
| 57 |
+
CASSANDRA_TABLE = "documents"
|
| 58 |
+
|
| 59 |
+
CREATE_KEYSPACE_CQL = (
|
| 60 |
+
f"CREATE KEYSPACE IF NOT EXISTS {CASSANDRA_KEYSPACE} "
|
| 61 |
+
"WITH replication = {'class': 'SimpleStrategy', 'replication_factor': 1};"
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
CREATE_TABLE_CQL = f"""
|
| 65 |
+
CREATE TABLE IF NOT EXISTS {CASSANDRA_KEYSPACE}.{CASSANDRA_TABLE} (
|
| 66 |
+
id text PRIMARY KEY,
|
| 67 |
+
title text,
|
| 68 |
+
text text,
|
| 69 |
+
source text,
|
| 70 |
+
ingested_at timestamp
|
| 71 |
+
);
|
| 72 |
+
"""
|
| 73 |
+
|
| 74 |
+
INSERT_CQL = f"""
|
| 75 |
+
INSERT INTO {CASSANDRA_KEYSPACE}.{CASSANDRA_TABLE} (id, title, text, source, ingested_at)
|
| 76 |
+
VALUES (?, ?, ?, ?, toTimestamp(now()));
|
| 77 |
+
"""
|
src/streamsearch/search.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared hybrid search: BM25 (lexical) + dense (Qdrant) fused with RRF.
|
| 2 |
+
|
| 3 |
+
Written once here and reused by both tiers:
|
| 4 |
+
- demo tier serves it over a baked-in Qdrant snapshot,
|
| 5 |
+
- proof tier serves it over the Qdrant populated by the Spark job.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import logging
|
| 11 |
+
import re
|
| 12 |
+
from dataclasses import dataclass, field
|
| 13 |
+
from typing import Iterable
|
| 14 |
+
|
| 15 |
+
from qdrant_client import QdrantClient
|
| 16 |
+
from qdrant_client.http import models as qmodels
|
| 17 |
+
from rank_bm25 import BM25Okapi
|
| 18 |
+
|
| 19 |
+
from .embed import embed_texts
|
| 20 |
+
from .schema import EMBED_DIM, QDRANT_COLLECTION
|
| 21 |
+
|
| 22 |
+
log = logging.getLogger(__name__)
|
| 23 |
+
|
| 24 |
+
RRF_K = 60 # standard Reciprocal Rank Fusion constant (spec: k = 60)
|
| 25 |
+
|
| 26 |
+
_TOKEN_RE = re.compile(r"[a-z0-9]+")
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def tokenize(text: str) -> list[str]:
|
| 30 |
+
return _TOKEN_RE.findall(text.lower())
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@dataclass
|
| 34 |
+
class SearchResult:
|
| 35 |
+
id: str
|
| 36 |
+
title: str
|
| 37 |
+
text: str
|
| 38 |
+
score: float # fused RRF score
|
| 39 |
+
bm25_rank: int | None = None
|
| 40 |
+
dense_rank: int | None = None
|
| 41 |
+
payload: dict = field(default_factory=dict)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def reciprocal_rank_fusion(
|
| 45 |
+
ranked_lists: dict[str, list[str]], k: int = RRF_K
|
| 46 |
+
) -> dict[str, float]:
|
| 47 |
+
"""Fuse several ranked id-lists into one score map.
|
| 48 |
+
|
| 49 |
+
ranked_lists maps a stage name -> ids ordered best-first. RRF score for a
|
| 50 |
+
doc is sum over stages of 1 / (k + rank), rank being 1-based. Pure function,
|
| 51 |
+
unit-tested independently of any index.
|
| 52 |
+
"""
|
| 53 |
+
scores: dict[str, float] = {}
|
| 54 |
+
for ids in ranked_lists.values():
|
| 55 |
+
for rank, doc_id in enumerate(ids, start=1):
|
| 56 |
+
scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank)
|
| 57 |
+
return scores
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class HybridSearcher:
|
| 61 |
+
"""Holds a Qdrant client for dense search and an in-memory BM25 index.
|
| 62 |
+
|
| 63 |
+
BM25 is kept in memory (rank_bm25) built from the same docs indexed in
|
| 64 |
+
Qdrant. `add_documents` keeps both stages in sync so the demo ingest box
|
| 65 |
+
reflects new docs in both lexical and dense results.
|
| 66 |
+
"""
|
| 67 |
+
|
| 68 |
+
def __init__(self, client: QdrantClient, collection: str = QDRANT_COLLECTION):
|
| 69 |
+
self.client = client
|
| 70 |
+
self.collection = collection
|
| 71 |
+
self._ids: list[str] = []
|
| 72 |
+
self._corpus_tokens: list[list[str]] = []
|
| 73 |
+
self._bm25: BM25Okapi | None = None
|
| 74 |
+
self._meta: dict[str, dict] = {}
|
| 75 |
+
|
| 76 |
+
# --- index construction -------------------------------------------------
|
| 77 |
+
def ensure_collection(self) -> None:
|
| 78 |
+
existing = {c.name for c in self.client.get_collections().collections}
|
| 79 |
+
if self.collection not in existing:
|
| 80 |
+
self.client.create_collection(
|
| 81 |
+
collection_name=self.collection,
|
| 82 |
+
vectors_config=qmodels.VectorParams(
|
| 83 |
+
size=EMBED_DIM, distance=qmodels.Distance.COSINE
|
| 84 |
+
),
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
def add_documents(self, docs: Iterable) -> int:
|
| 88 |
+
"""Embed + upsert docs to Qdrant and (re)build the BM25 index.
|
| 89 |
+
|
| 90 |
+
`docs` are schema.Document instances. Returns count added.
|
| 91 |
+
"""
|
| 92 |
+
docs = list(docs)
|
| 93 |
+
if not docs:
|
| 94 |
+
return 0
|
| 95 |
+
|
| 96 |
+
vectors = embed_texts([d.embed_input for d in docs])
|
| 97 |
+
points = [
|
| 98 |
+
qmodels.PointStruct(
|
| 99 |
+
id=_qdrant_id(d.id),
|
| 100 |
+
vector=vectors[i].tolist(),
|
| 101 |
+
payload={"doc_id": d.id, "title": d.title, "text": d.text,
|
| 102 |
+
"source": d.source},
|
| 103 |
+
)
|
| 104 |
+
for i, d in enumerate(docs)
|
| 105 |
+
]
|
| 106 |
+
self.client.upsert(collection_name=self.collection, points=points)
|
| 107 |
+
|
| 108 |
+
for d in docs:
|
| 109 |
+
self._ids.append(d.id)
|
| 110 |
+
self._corpus_tokens.append(tokenize(d.embed_input))
|
| 111 |
+
self._meta[d.id] = {"title": d.title, "text": d.text, "source": d.source}
|
| 112 |
+
self._bm25 = BM25Okapi(self._corpus_tokens)
|
| 113 |
+
log.info("Indexed %d docs (total %d)", len(docs), len(self._ids))
|
| 114 |
+
return len(docs)
|
| 115 |
+
|
| 116 |
+
# --- query --------------------------------------------------------------
|
| 117 |
+
def search(self, query: str, top_k: int = 10, candidates: int = 50) -> list[SearchResult]:
|
| 118 |
+
dense_ids = self._dense_search(query, candidates)
|
| 119 |
+
bm25_ids = self._bm25_search(query, candidates)
|
| 120 |
+
|
| 121 |
+
fused = reciprocal_rank_fusion({"dense": dense_ids, "bm25": bm25_ids})
|
| 122 |
+
dense_rank = {doc_id: i + 1 for i, doc_id in enumerate(dense_ids)}
|
| 123 |
+
bm25_rank = {doc_id: i + 1 for i, doc_id in enumerate(bm25_ids)}
|
| 124 |
+
|
| 125 |
+
ordered = sorted(fused.items(), key=lambda kv: kv[1], reverse=True)[:top_k]
|
| 126 |
+
results = []
|
| 127 |
+
for doc_id, score in ordered:
|
| 128 |
+
meta = self._meta.get(doc_id, {})
|
| 129 |
+
results.append(
|
| 130 |
+
SearchResult(
|
| 131 |
+
id=doc_id,
|
| 132 |
+
title=meta.get("title", ""),
|
| 133 |
+
text=meta.get("text", ""),
|
| 134 |
+
score=score,
|
| 135 |
+
bm25_rank=bm25_rank.get(doc_id),
|
| 136 |
+
dense_rank=dense_rank.get(doc_id),
|
| 137 |
+
payload=meta,
|
| 138 |
+
)
|
| 139 |
+
)
|
| 140 |
+
return results
|
| 141 |
+
|
| 142 |
+
def _dense_search(self, query: str, limit: int) -> list[str]:
|
| 143 |
+
qvec = embed_texts([query])[0].tolist()
|
| 144 |
+
hits = self.client.search(
|
| 145 |
+
collection_name=self.collection, query_vector=qvec, limit=limit,
|
| 146 |
+
with_payload=True,
|
| 147 |
+
)
|
| 148 |
+
return [h.payload.get("doc_id", str(h.id)) for h in hits]
|
| 149 |
+
|
| 150 |
+
def _bm25_search(self, query: str, limit: int) -> list[str]:
|
| 151 |
+
if self._bm25 is None or not self._ids:
|
| 152 |
+
return []
|
| 153 |
+
scores = self._bm25.get_scores(tokenize(query))
|
| 154 |
+
top = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[:limit]
|
| 155 |
+
return [self._ids[i] for i in top if scores[i] > 0]
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def _qdrant_id(doc_id: str) -> int:
|
| 159 |
+
"""Qdrant point ids must be int or UUID. Hash string ids to a stable int."""
|
| 160 |
+
import hashlib
|
| 161 |
+
|
| 162 |
+
return int(hashlib.md5(doc_id.encode()).hexdigest()[:15], 16)
|
tests/conftest.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
# Make src/ importable without an editable install.
|
| 5 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 6 |
+
SRC = ROOT / "src"
|
| 7 |
+
if str(SRC) not in sys.path:
|
| 8 |
+
sys.path.insert(0, str(SRC))
|
tests/fixtures/.gitkeep
ADDED
|
File without changes
|
tests/fixtures/mini_corpus.jsonl
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{"id": "d1", "title": "Transformer models for language", "text": "Attention based neural networks have transformed natural language processing and machine translation.", "source": "test"}
|
| 2 |
+
{"id": "d2", "title": "Convolutional networks for vision", "text": "Deep convolutional neural networks achieve strong results on image classification benchmarks.", "source": "test"}
|
| 3 |
+
{"id": "d3", "title": "Distributed stream processing", "text": "Apache Kafka and Spark Structured Streaming enable scalable real time data pipelines.", "source": "test"}
|
| 4 |
+
{"id": "d4", "title": "Vector databases and search", "text": "Approximate nearest neighbor indexes like HNSW power fast semantic vector search at scale.", "source": "test"}
|
| 5 |
+
{"id": "d5", "title": "Reinforcement learning basics", "text": "An agent learns a policy by maximizing cumulative reward through interaction with an environment.", "source": "test"}
|
| 6 |
+
{"id": "d6", "title": "Cassandra wide column store", "text": "Cassandra is a distributed NoSQL database offering tunable consistency and high write throughput.", "source": "test"}
|
tests/test_embed.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
|
| 3 |
+
from streamsearch import embed
|
| 4 |
+
from streamsearch.schema import EMBED_DIM
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def test_embed_shape_and_norm():
|
| 8 |
+
vecs = embed.embed_texts(["hello world", "a second document"])
|
| 9 |
+
assert vecs.shape == (2, EMBED_DIM)
|
| 10 |
+
assert vecs.dtype == np.float32
|
| 11 |
+
# normalize_embeddings=True -> unit vectors
|
| 12 |
+
norms = np.linalg.norm(vecs, axis=1)
|
| 13 |
+
np.testing.assert_allclose(norms, 1.0, atol=1e-4)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def test_embed_is_deterministic():
|
| 17 |
+
a = embed.embed_texts(["reproducible embedding"])
|
| 18 |
+
b = embed.embed_texts(["reproducible embedding"])
|
| 19 |
+
np.testing.assert_allclose(a, b, atol=1e-6)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def test_empty_input_returns_empty():
|
| 23 |
+
vecs = embed.embed_texts([])
|
| 24 |
+
assert vecs.shape == (0, EMBED_DIM)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def test_model_loads_only_once():
|
| 28 |
+
# get_model is lru_cached: repeated calls return the same object.
|
| 29 |
+
embed.get_model.cache_clear()
|
| 30 |
+
m1 = embed.get_model()
|
| 31 |
+
m2 = embed.get_model()
|
| 32 |
+
assert m1 is m2
|
| 33 |
+
assert embed.get_model.cache_info().hits >= 1
|
tests/test_search.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
import pytest
|
| 5 |
+
from qdrant_client import QdrantClient
|
| 6 |
+
|
| 7 |
+
from streamsearch.schema import Document
|
| 8 |
+
from streamsearch.search import HybridSearcher, reciprocal_rank_fusion
|
| 9 |
+
|
| 10 |
+
FIXTURE = Path(__file__).parent / "fixtures" / "mini_corpus.jsonl"
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def load_fixture() -> list[Document]:
|
| 14 |
+
docs = []
|
| 15 |
+
with FIXTURE.open(encoding="utf-8") as f:
|
| 16 |
+
for line in f:
|
| 17 |
+
docs.append(Document.from_json(json.loads(line)))
|
| 18 |
+
return docs
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@pytest.fixture(scope="module")
|
| 22 |
+
def searcher() -> HybridSearcher:
|
| 23 |
+
client = QdrantClient(":memory:")
|
| 24 |
+
s = HybridSearcher(client)
|
| 25 |
+
s.ensure_collection()
|
| 26 |
+
s.add_documents(load_fixture())
|
| 27 |
+
return s
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
# --- RRF is a pure function; test it without any index -----------------------
|
| 31 |
+
def test_rrf_rewards_agreement_across_stages():
|
| 32 |
+
fused = reciprocal_rank_fusion(
|
| 33 |
+
{"dense": ["a", "b", "c"], "bm25": ["b", "a", "d"]}, k=60
|
| 34 |
+
)
|
| 35 |
+
# 'b' is rank1(bm25)+rank2(dense); 'a' is rank1(dense)+rank2(bm25) -> tie-ish,
|
| 36 |
+
# but both should top 'c' and 'd' which appear in only one list.
|
| 37 |
+
assert fused["a"] > fused["c"]
|
| 38 |
+
assert fused["b"] > fused["d"]
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def test_rrf_score_formula():
|
| 42 |
+
fused = reciprocal_rank_fusion({"only": ["x", "y"]}, k=60)
|
| 43 |
+
assert fused["x"] == pytest.approx(1 / 61)
|
| 44 |
+
assert fused["y"] == pytest.approx(1 / 62)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
# --- hybrid search over a real (in-memory) Qdrant index ----------------------
|
| 48 |
+
def test_semantic_query_returns_relevant_doc(searcher):
|
| 49 |
+
results = searcher.search("neural attention for translation", top_k=3)
|
| 50 |
+
ids = [r.id for r in results]
|
| 51 |
+
assert "d1" in ids # transformer/attention doc should surface
|
| 52 |
+
assert results[0].score > 0
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def test_lexical_query_matches_exact_terms(searcher):
|
| 56 |
+
results = searcher.search("Kafka Spark streaming pipeline", top_k=3)
|
| 57 |
+
assert results[0].id == "d3"
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def test_results_carry_per_stage_ranks(searcher):
|
| 61 |
+
results = searcher.search("vector search HNSW index", top_k=5)
|
| 62 |
+
top = results[0]
|
| 63 |
+
assert top.id == "d4"
|
| 64 |
+
# at least one stage should have ranked the top result
|
| 65 |
+
assert top.dense_rank is not None or top.bm25_rank is not None
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def test_ingested_doc_becomes_searchable(searcher):
|
| 69 |
+
searcher.add_documents([
|
| 70 |
+
Document(id="new1", title="Quantum computing",
|
| 71 |
+
text="Qubits leverage superposition and entanglement for computation.")
|
| 72 |
+
])
|
| 73 |
+
results = searcher.search("superposition entanglement qubits", top_k=3)
|
| 74 |
+
assert "new1" in [r.id for r in results]
|