Created
July 12, 2026 01:02
-
-
Save mohashari/5cf01109a64803aa304677fa2a2c2df6 to your computer and use it in GitHub Desktop.
Implementing Dynamic Chunking and Sparse Vector Search in Qdrant for Multi-Tenant RAG — code snippets
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import re | |
| from typing import List, Dict, Any | |
| class MarkdownStructuralChunker: | |
| def __init__(self, max_chunk_size: int = 1000): | |
| self.max_chunk_size = max_chunk_size | |
| self.header_regex = re.compile(r'^(#{1,6})\s+(.+)$', re.MULTILINE) | |
| def chunk_document(self, text: str) -> List[Dict[str, Any]]: | |
| lines = text.split('\n') | |
| chunks = [] | |
| current_headers = {1: "", 2: "", 3: "", 4: "", 5: "", 6: ""} | |
| current_chunk_buffer = [] | |
| current_size = 0 | |
| for line in lines: | |
| header_match = self.header_regex.match(line) | |
| if header_match: | |
| # Flush the current buffer if it has content | |
| if current_chunk_buffer: | |
| chunks.append(self._create_chunk(current_chunk_buffer, current_headers)) | |
| current_chunk_buffer = [] | |
| current_size = 0 | |
| # Update current headers context | |
| level = len(header_match.group(1)) | |
| title = header_match.group(2).strip() | |
| current_headers[level] = title | |
| # Clear deeper header levels | |
| for l in range(level + 1, 7): | |
| current_headers[l] = "" | |
| line_len = len(line) | |
| if current_size + line_len > self.max_chunk_size and current_chunk_buffer: | |
| chunks.append(self._create_chunk(current_chunk_buffer, current_headers)) | |
| current_chunk_buffer = [line] | |
| current_size = line_len | |
| else: | |
| current_chunk_buffer.append(line) | |
| current_size += line_len + 1 # Include newline character | |
| if current_chunk_buffer: | |
| chunks.append(self._create_chunk(current_chunk_buffer, current_headers)) | |
| return chunks | |
| def _create_chunk(self, lines: List[str], headers: Dict[int, str]) -> Dict[str, Any]: | |
| header_path = [headers[i] for i in sorted(headers.keys()) if headers[i]] | |
| header_context = " > ".join(header_path) | |
| content = "\n".join(lines).strip() | |
| # Inject structural context directly into the text for better embedding alignment | |
| enriched_content = f"Context: {header_context}\n\nContent:\n{content}" if header_context else content | |
| return { | |
| "text": enriched_content, | |
| "metadata": { | |
| "header_path": header_path, | |
| "raw_length": len(content) | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| from typing import List, Dict, Any, Tuple | |
| from fastembed import TextEmbedding, SparseTextEmbedding | |
| class HybridEmbedder: | |
| def __init__(self): | |
| # Initialize standard lightweight production-grade models | |
| self.dense_model = TextEmbedding(model_name="BAAI/bge-small-en-v1.5") | |
| self.sparse_model = SparseTextEmbedding(model_name="prithivida/Splade_PP_en_v1") | |
| def embed_texts(self, texts: List[str]) -> Tuple[List[List[float]], List[Dict[int, float]]]: | |
| # Generate dense embeddings | |
| dense_embeddings = list(self.dense_model.embed(texts)) | |
| # Generate sparse embeddings | |
| sparse_embeddings_raw = list(self.sparse_model.embed(texts)) | |
| processed_sparse = [] | |
| for sparse_vec in sparse_embeddings_raw: | |
| # Map sparse vector lists to a dictionary of {index: value} for Qdrant compatibility | |
| sparse_dict = { | |
| int(idx): float(val) | |
| for idx, val in zip(sparse_vec.indices, sparse_vec.values) | |
| } | |
| processed_sparse.append(sparse_dict) | |
| return [list(d) for d in dense_embeddings], processed_sparse |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| from qdrant_client import QdrantClient | |
| from qdrant_client.models import ( | |
| Distance, | |
| VectorParams, | |
| SparseVectorParams, | |
| SparseIndexParams, | |
| PayloadSchemaType, | |
| HnswConfigDiff | |
| ) | |
| def configure_qdrant_collection(client: QdrantClient, collection_name: str): | |
| # Ensure collection is configured with both dense and sparse configurations | |
| client.recreate_collection( | |
| collection_name=collection_name, | |
| vectors_config={ | |
| "dense-vector": VectorParams( | |
| size=384, # Dimensionality of BAAI/bge-small-en-v1.5 | |
| distance=Distance.COSINE, | |
| hnsw_config=HnswConfigDiff( | |
| m=16, | |
| ef_construction=100, | |
| on_disk=True # Production tip: keep dense index on disk to save RAM | |
| ) | |
| ) | |
| }, | |
| sparse_vectors_config={ | |
| "sparse-vector": SparseVectorParams( | |
| index=SparseIndexParams( | |
| on_disk=True # Production tip: keep sparse index on disk to save RAM | |
| ) | |
| ) | |
| } | |
| ) | |
| # CRITICAL: Create keyword index for hard tenant isolation | |
| client.create_payload_index( | |
| collection_name=collection_name, | |
| field_name="tenant_id", | |
| field_schema=PayloadSchemaType.KEYWORD | |
| ) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import time | |
| import uuid | |
| from typing import List, Dict, Any | |
| from qdrant_client import QdrantClient | |
| from qdrant_client.models import PointStruct | |
| from qdrant_client.http.exceptions import UnexpectedResponse | |
| def ingest_tenant_chunks( | |
| client: QdrantClient, | |
| collection_name: str, | |
| tenant_id: str, | |
| chunks: List[Dict[str, Any]], | |
| embedder: HybridEmbedder, | |
| batch_size: int = 64 | |
| ): | |
| texts = [chunk["text"] for chunk in chunks] | |
| dense_vecs, sparse_vecs = embedder.embed_texts(texts) | |
| points = [] | |
| for i, chunk in enumerate(chunks): | |
| point_id = str(uuid.uuid4()) | |
| # Format sparse vector as Qdrant expects it | |
| sparse_vector_data = { | |
| "indices": list(sparse_vecs[i].keys()), | |
| "values": list(sparse_vecs[i].values()) | |
| } | |
| points.append( | |
| PointStruct( | |
| id=point_id, | |
| vector={ | |
| "dense-vector": dense_vecs[i], | |
| "sparse-vector": sparse_vector_data | |
| }, | |
| payload={ | |
| "tenant_id": tenant_id, | |
| "text": chunk["text"], | |
| "metadata": chunk["metadata"] | |
| } | |
| ) | |
| ) | |
| # Batch upload with retry logic | |
| for i in range(0, len(points), batch_size): | |
| batch = points[i : i + batch_size] | |
| retries = 3 | |
| while retries > 0: | |
| try: | |
| client.upsert( | |
| collection_name=collection_name, | |
| points=batch, | |
| wait=True | |
| ) | |
| break | |
| except UnexpectedResponse as e: | |
| retries -= 1 | |
| if retries == 0: | |
| raise RuntimeError(f"Failed to upsert batch to Qdrant after retries: {e}") | |
| time.sleep(2 ** (3 - retries)) # Exponential backoff |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| from typing import List, Dict, Any | |
| from qdrant_client import QdrantClient | |
| from qdrant_client import models | |
| def query_tenant_hybrid( | |
| client: QdrantClient, | |
| collection_name: str, | |
| tenant_id: str, | |
| query_text: str, | |
| embedder: HybridEmbedder, | |
| limit: int = 5 | |
| ) -> List[Dict[str, Any]]: | |
| # Generate query embeddings | |
| dense_queries, sparse_queries = embedder.embed_texts([query_text]) | |
| dense_query = dense_queries[0] | |
| sparse_query = sparse_queries[0] | |
| # Format sparse query vector for API | |
| sparse_vector_query = models.SparseVector( | |
| indices=list(sparse_query.keys()), | |
| values=list(sparse_query.values()) | |
| ) | |
| # Core filter to enforce hard tenant isolation | |
| tenant_filter = models.Filter( | |
| must=[ | |
| models.FieldCondition( | |
| key="tenant_id", | |
| match=models.MatchValue(value=tenant_id) | |
| ) | |
| ] | |
| ) | |
| # Execute Hybrid query using Reciprocal Rank Fusion (RRF) | |
| search_result = client.query_points( | |
| collection_name=collection_name, | |
| prefetch=[ | |
| models.Prefetch( | |
| query=dense_query, | |
| using="dense-vector", | |
| filter=tenant_filter, | |
| limit=limit * 3 | |
| ), | |
| models.Prefetch( | |
| query=sparse_vector_query, | |
| using="sparse-vector", | |
| filter=tenant_filter, | |
| limit=limit * 3 | |
| ) | |
| ], | |
| query=models.FusionQuery( | |
| fusion=models.Fusion.RRF | |
| ), | |
| limit=limit | |
| ) | |
| results = [] | |
| for point in search_result.points: | |
| results.append({ | |
| "id": point.id, | |
| "score": point.score, | |
| "text": point.payload.get("text"), | |
| "metadata": point.payload.get("metadata") | |
| }) | |
| return results |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # config.yaml (Qdrant Production Tunings) | |
| storage: | |
| performance: | |
| max_search_threads: 0 # Automatically scale to CPU cores | |
| mmap_threshold_kb: 20000 # Memory-map vector segments larger than 20MB to disk | |
| optimizers: | |
| indexing_threshold_kb: 20000 # Minimum segment size to build HNSW index | |
| default_vacuum_min_vector_number: 1000 | |
| service: | |
| grpc_port: 6334 | |
| enable_cors: false |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| from qdrant_client import QdrantClient | |
| from qdrant_client import models | |
| def hard_delete_tenant_data(client: QdrantClient, collection_name: str, tenant_id: str): | |
| # Perform a payload-filtered delete to ensure isolation | |
| client.delete( | |
| collection_name=collection_name, | |
| points_selector=models.FilterSelector( | |
| filter=models.Filter( | |
| must=[ | |
| models.FieldCondition( | |
| key="tenant_id", | |
| match=models.MatchValue(value=tenant_id) | |
| ) | |
| ] | |
| ) | |
| ) | |
| ) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment