sharmaaryan commited on
Commit
61d5e4e
·
0 Parent(s):

Phase 0: shared search core + reproducible corpus

Browse files

Scaffold 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 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]