From d573a930d260a2f52e598df8d3271cd49e4968ef Mon Sep 17 00:00:00 2001 From: Divyendu Dutta Date: Tue, 30 Dec 2025 21:35:05 +0530 Subject: [PATCH 01/21] [FEAT] Add sentence-transformers dependency to env yaml file --- environment.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/environment.yml b/environment.yml index 8e1873c..7be7be0 100644 --- a/environment.yml +++ b/environment.yml @@ -20,3 +20,4 @@ dependencies: - types-PyYAML==6.0.12.20250809 - pytest==8.3.5 - pytest-cov==6.2.0 + - sentence-transformers From a35ddb623a0758617e27b3402d41ec4326a023b9 Mon Sep 17 00:00:00 2001 From: Divyendu Dutta Date: Tue, 30 Dec 2025 21:45:23 +0530 Subject: [PATCH 02/21] [DOC] Add information about chunker and embedding modules Added - python commands to run chunker and embedding modules - other relevant info about these modules in their sections in the README --- README.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/README.md b/README.md index 92eea6f..4404f4a 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,10 @@ RAG is good because: [Chunker Module](atlas/core/chunker/README.md) +#### Embedding and Indexing + +[Embedding Module](atlas/core/embedder/README.md) + #### Obsidian [Obsidian](https://obsidian.md/) is a light weight application used to take notes and create knowledge bases. It saves all the notes as markdown making it easy to load, process and render a huge amount of notes. @@ -124,6 +128,34 @@ This will generate the `obsidian_index.json` in `/Resources` folder. This json f See architecture section for structure of this json. +### Structural Chunker Module + +Run `python .\atlas\core\chunker\structural_chunker.py` + +This will generate the `chunked_data.json` in `/Resources` folder. This json file contains the chunks generated from the notes processed by the "Obsidian Vault Processor" module. + +See `README` in `atlas/core/chunker` for structure of this json. + +There is an option to set the `max_words` for `StructuralChunker`. This determines the size of chunks created and should be changed primarily based on the token limit of the encoding model and context size of the LLM used in later modules. + +### Embedding + +Run `python .\atlas\core\embedder\sentence_transformer\impl_embedder.py` + +This will generate the `embedded_chunks.json` in `/Resources` folder. This json is exactly similar to +`chunked_data.json` with the added `embedding` for each chunk. + +See `README` in `atlas/core/embedder` for structure of this json. + +See `altas/core/configs/sentence_transformer_config.yaml` for changing the encoder model used and its configuration. The following can be changed: + +```yaml +model_name: sentence-transformers/all-MiniLM-L6-v2 +batch_size: 32 +normalize_embeddings: true +device: cuda +``` + ### Tests Run unit tests via VS Code or `python -m unittest` to run all unit tests From e372e8c333b35ef225d1b8f23f304708dcac321c Mon Sep 17 00:00:00 2001 From: Divyendu Dutta Date: Tue, 30 Dec 2025 21:48:35 +0530 Subject: [PATCH 03/21] [CHORE] Modify package path for all files in the Ingest module Modified package path from `atlas.core.ingest` to `atlas.core.ingester` --- atlas/core/{ingest => ingester}/__init__.py | 0 .../base_file_processor.py | 22 +++++++++--- .../obsidian_vault_processor.py | 34 +++++-------------- 3 files changed, 27 insertions(+), 29 deletions(-) rename atlas/core/{ingest => ingester}/__init__.py (100%) rename atlas/core/{ingest => ingester}/base_file_processor.py (66%) rename atlas/core/{ingest => ingester}/obsidian_vault_processor.py (89%) diff --git a/atlas/core/ingest/__init__.py b/atlas/core/ingester/__init__.py similarity index 100% rename from atlas/core/ingest/__init__.py rename to atlas/core/ingester/__init__.py diff --git a/atlas/core/ingest/base_file_processor.py b/atlas/core/ingester/base_file_processor.py similarity index 66% rename from atlas/core/ingest/base_file_processor.py rename to atlas/core/ingester/base_file_processor.py index b1aeaf8..c0e71ba 100644 --- a/atlas/core/ingest/base_file_processor.py +++ b/atlas/core/ingester/base_file_processor.py @@ -2,6 +2,7 @@ from abc import abstractmethod from typing import List, Dict from pathlib import Path +import json from atlas.utils.logger import LoggerConfig @@ -11,8 +12,16 @@ class KnowledgeBaseProcessor(ABC): """ Abstract base class for processors that handle knowledge base files. + + Args: + vault_path (str): Path to the knowledge base. + output_path (str): Path to save the processed data. """ + def __init__(self, vault_path: str, output_path: str) -> None: + self.vault_path = Path(vault_path) + self.output_path = Path(output_path) + @abstractmethod def precheck(self) -> bool: """ @@ -32,15 +41,20 @@ def process(self) -> List[Dict]: """ pass - @abstractmethod def save_processed_data(self, processed_data: List[Dict]) -> None: """ - Save the processed data to a format suitable for later use. + Save the processed data to a JSON file atomically. + This ensures that the file is either fully written or not written at all. Args: - notes (list[dict]): The list of parsed metadata ie, processed data. + processed_data (list[dict]): The list of parsed notes metadata. """ - pass + tmp_path = self.output_path.with_suffix(".tmp") + + with tmp_path.open("w", encoding="utf-8") as f: + json.dump(processed_data, f, indent=2, ensure_ascii=False) + + tmp_path.replace(self.output_path) def ingest(self) -> None: """ diff --git a/atlas/core/ingest/obsidian_vault_processor.py b/atlas/core/ingester/obsidian_vault_processor.py similarity index 89% rename from atlas/core/ingest/obsidian_vault_processor.py rename to atlas/core/ingester/obsidian_vault_processor.py index 973f0fb..b3fba2b 100644 --- a/atlas/core/ingest/obsidian_vault_processor.py +++ b/atlas/core/ingester/obsidian_vault_processor.py @@ -1,6 +1,6 @@ from datetime import date from atlas.utils.logger import LoggerConfig -from atlas.core.ingest.base_file_processor import KnowledgeBaseProcessor +from atlas.core.ingester.base_file_processor import KnowledgeBaseProcessor from pathlib import Path from datetime import date, datetime @@ -13,13 +13,7 @@ class ObsidianVaultProcessor(KnowledgeBaseProcessor): - """ - Processor for Obsidian Vaults to extract notes metadata. - - Args: - vault_path (str): Path to the Obsidian vault. - output_path (str): Path to save the processed data (obsidian indexed data). - """ + """Processor for Obsidian Vaults to extract notes metadata.""" _OBSIDIAN_CONFIG_FILES = { "app.json", @@ -28,11 +22,10 @@ class ObsidianVaultProcessor(KnowledgeBaseProcessor): } def __init__(self, vault_path: str, output_path: str) -> None: + super().__init__(vault_path, output_path) LOGGER.info("-" * 20) LOGGER.info("ObsidianVaultProcessor initialized.") LOGGER.info(f"Obsidian Vault to be processed: {vault_path}") - self.vault_path = Path(vault_path) - self.output_path = Path(output_path) def _find_vault_root(self) -> Path | None: """ @@ -102,6 +95,12 @@ def precheck(self) -> bool: return is_valid_obsidian_vault def _normalize_yaml(self, obj): + """ + Recursively normalize YAML data by converting date and datetime objects to ISO format strings. + + Args: + obj: The YAML data to normalize. + """ if isinstance(obj, dict): return {k: self._normalize_yaml(v) for k, v in obj.items()} elif isinstance(obj, list): @@ -217,21 +216,6 @@ def _parse_markdown_note(self, note_path: Path, vault_path: Path) -> Dict[str, A "word_count": len(body.split()), } - def save_processed_data(self, processed_data: List[Dict]) -> None: - """ - Save the processed data to a JSON file atomically. - This ensures that the file is either fully written or not written at all. - - Args: - processed_data (list[dict]): The list of parsed notes metadata. - """ - tmp_path = self.output_path.with_suffix(".tmp") - - with tmp_path.open("w", encoding="utf-8") as f: - json.dump(processed_data, f, indent=2, ensure_ascii=False) - - tmp_path.replace(self.output_path) - def process(self) -> list[dict]: """ Process the Obsidian vault to extract notes metadata. From 799ff89363965ad27bd2f8da660d052d5b950515 Mon Sep 17 00:00:00 2001 From: Divyendu Dutta Date: Tue, 30 Dec 2025 21:50:58 +0530 Subject: [PATCH 04/21] [FEAT] Add encoder config file and associated code Added configuration file for the encoder. Allows to change the encoder used and its settings. Added associated dataclass and configuration loading function. --- .../configs/sentence_transformer_config.yaml | 4 ++ atlas/core/embedder/config.py | 39 +++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 atlas/core/configs/sentence_transformer_config.yaml create mode 100644 atlas/core/embedder/config.py diff --git a/atlas/core/configs/sentence_transformer_config.yaml b/atlas/core/configs/sentence_transformer_config.yaml new file mode 100644 index 0000000..8840c7d --- /dev/null +++ b/atlas/core/configs/sentence_transformer_config.yaml @@ -0,0 +1,4 @@ +model_name: sentence-transformers/all-MiniLM-L6-v2 +batch_size: 32 +normalize_embeddings: true +device: cuda diff --git a/atlas/core/embedder/config.py b/atlas/core/embedder/config.py new file mode 100644 index 0000000..c3720d9 --- /dev/null +++ b/atlas/core/embedder/config.py @@ -0,0 +1,39 @@ +from dataclasses import dataclass +import yaml +from pathlib import Path + +from atlas.utils.logger import LoggerConfig + +LOGGER = LoggerConfig().logger + + +@dataclass +class EncoderConfig: + """Configuration parameters for the encoder.""" + + model_name: str + batch_size: int + normalize_embeddings: bool + device: str + + +def load_encoder_config(path: Path) -> EncoderConfig: + """ + Load encoder configuration from a YAML file. + + Args: + path (Path): Path to the encoder YAML configuration file. + """ + try: + with path.open("r", encoding="utf-8") as f: + data = yaml.safe_load(f) + except FileNotFoundError as e: + LOGGER.error(f"Encoder configuration file not found: {path}") + raise FileNotFoundError(f"Encoder configuration file not found: {path}") + + if not data: + LOGGER.error(f"Encoder configuration file is empty: {path}") + raise ValueError(f"Encoder configuration file is empty: {path}") + + LOGGER.info(f"Encoder configuration loaded successfully from {path}") + return EncoderConfig(**data) From 38c2dd9edef7d2f82617fbc8bb0ffe0847b6699c Mon Sep 17 00:00:00 2001 From: Divyendu Dutta Date: Tue, 30 Dec 2025 21:54:40 +0530 Subject: [PATCH 05/21] [CHORE] Refactor abstract base class and impl class for chunker module Converted certain abstract methods to implementation methods in abstract base class and removed those methods from implementation class. --- atlas/core/chunker/base_chunker.py | 42 ++++++++++++++++++++---- atlas/core/chunker/structural_chunker.py | 40 +--------------------- 2 files changed, 36 insertions(+), 46 deletions(-) diff --git a/atlas/core/chunker/base_chunker.py b/atlas/core/chunker/base_chunker.py index 65019ba..5cfbb04 100644 --- a/atlas/core/chunker/base_chunker.py +++ b/atlas/core/chunker/base_chunker.py @@ -1,6 +1,8 @@ from abc import ABC from abc import abstractmethod from typing import List, Dict +from pathlib import Path +import json from atlas.utils.logger import LoggerConfig @@ -10,15 +12,35 @@ class BaseChunker(ABC): """ Abstract base class for chunkers that split processed data into smaller "retrieval units. + + Args: + processed_data_path (str): Path to the processed data file. + output_path (str): Path to save the chunked data. """ - @abstractmethod + def __init__(self, processed_data_path: str, output_path: str) -> None: + LOGGER.info("-" * 20) + LOGGER.info("StructuralChunker initialized.") + LOGGER.info(f"Chunking processed data at {processed_data_path}") + self.processed_data_path = Path(processed_data_path) + self.output_path = Path(output_path) + def read_processed_data(self) -> List[Dict] | None: """ Read the processed data which is the output of the previous module - ie,`KnowledgeBaseProcessor`. + ie, `KnowledgeBaseProcessor`. + + Returns: + List[Dict] | None: The processed data as a list of dictionaries or None if an error occurs. """ - pass + try: + with open(self.processed_data_path, "r", encoding="utf-8") as file: + data = json.load(file) + LOGGER.info("Processed data successfully read.") + return data + except Exception as e: + LOGGER.error(f"Error reading processed data: {e}") + return None @abstractmethod def create_chunks(self, processed_data: List[Dict]) -> List[Dict]: @@ -33,15 +55,21 @@ def create_chunks(self, processed_data: List[Dict]) -> List[Dict]: """ pass - @abstractmethod def save_chunked_data(self, chunked_data: List[Dict]) -> None: """ - Save the chunked data to a format suitable for later use. + Save the chunked data to the output path in JSON format. + This method writes to a temporary file first and then renames it to ensure atomicity. + This prevents data corruption in case of interruptions during the write process. Args: - chunked_data (list[dict]): The list of chunked data. + chunked_data (List[Dict]): The chunked data to be saved. """ - pass + tmp_path = self.output_path.with_suffix(".tmp") + + with tmp_path.open("w", encoding="utf-8") as f: + json.dump(chunked_data, f, indent=2, ensure_ascii=False) + + tmp_path.replace(self.output_path) def chunk(self) -> None: """ diff --git a/atlas/core/chunker/structural_chunker.py b/atlas/core/chunker/structural_chunker.py index b1a8edb..ddeb801 100644 --- a/atlas/core/chunker/structural_chunker.py +++ b/atlas/core/chunker/structural_chunker.py @@ -24,31 +24,9 @@ class StructuralChunker(BaseChunker): def __init__( self, processed_data_path: str, output_path: str, max_words: int ) -> None: - LOGGER.info("-" * 20) - LOGGER.info("StructuralChunker initialized.") - LOGGER.info(f"Chunking processed data at {processed_data_path}") - self.processed_data_path = Path(processed_data_path) - self.output_path = Path(output_path) + super().__init__(processed_data_path, output_path) self.max_words = max_words - def read_processed_data(self) -> List[Dict] | None: - """ - Read the obsidian indexed data which is the output of the previous module - ie, `ObsidianVaultProcessor`. - - Returns: - List[Dict] | None: The obsidian indexed data as a list of dictionaries or None if an error occurs. - """ - - try: - with open(self.processed_data_path, "r", encoding="utf-8") as file: - data = json.load(file) - LOGGER.info("Obsidian indexed data successfully read.") - return data - except Exception as e: - LOGGER.error(f"Error reading processed data: {e}") - return None - def _split_by_word_limit(self, text: str, max_words: int) -> list[str]: """ Split text into chunks based on a maximum word limit. @@ -213,22 +191,6 @@ def create_chunks(self, processed_data: List[Dict]) -> List[Dict]: return chunks - def save_chunked_data(self, chunked_data: List[Dict]) -> None: - """ - Save the chunked data to the output path in JSON format. - This method writes to a temporary file first and then renames it to ensure atomicity. - This prevents data corruption in case of interruptions during the write process. - - Args: - chunked_data (List[Dict]): The chunked data to be saved. - """ - tmp_path = self.output_path.with_suffix(".tmp") - - with tmp_path.open("w", encoding="utf-8") as f: - json.dump(chunked_data, f, indent=2, ensure_ascii=False) - - tmp_path.replace(self.output_path) - if __name__ == "__main__": processed_data_path = r"D:\\Deep learning\\Atlas\\Resources\\obsidian_index.json" From 10b65310d87e8efc399b879b7b720c3accc23a68 Mon Sep 17 00:00:00 2001 From: Divyendu Dutta Date: Tue, 30 Dec 2025 21:56:40 +0530 Subject: [PATCH 06/21] [DOC] Add README for embedding module --- atlas/core/embedder/README.md | 61 +++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 atlas/core/embedder/README.md diff --git a/atlas/core/embedder/README.md b/atlas/core/embedder/README.md new file mode 100644 index 0000000..7aba23a --- /dev/null +++ b/atlas/core/embedder/README.md @@ -0,0 +1,61 @@ +## Embedder Module + +LLM's dont really understand text. Hence, the text needs to be converted to a numeric representation, more specifically a vector called embedding. This is just a numeric representation in a low dimensional space. Two vectors close to each other in this space represent two texts which are close to each other semantically. + +### Encoder Model Choice + +`sentence-transformers/all-MiniLM-L6-v2` from [Sentence Transformers](https://www.sbert.net/) was chosen because its, +- fast and lightweight (super important for latency) +- provides really good [semantic search](https://www.sbert.net/examples/sentence_transformer/applications/semantic-search/README.html#background) performance + + +The encoder model is ultimately used for semantic search. + +#### What is Semantic Search? + +1. Take chunks → embed into vector space +2. Take query → embed into same space +3. Find nearest neighbors (cosine / dot / L2) +4. Return top-k chunks + +#### Why not use TinyLLama's encoder + +- There are three types of Transformer models + - Encoder only models + - eg, BERT, ROBERTa, MiniLM + - Decoder only models + - LLama/TinyLlama/GPT-2 + - they dont have an explicit encoder model in their architecture but they do encoding on text internally + - Encoder - Decoder models + - BART, T5, FLAN + +- TinyLlama being a decoder only model is specifically trained for next token prediction (the encoding is still done but its not the main focus and it does not have an encoder in the architectural sense). +- Whereas encoder only models are specifically trained generate embeddings and further use cases of embeddings (like retrieval, semantic search) + +#### Structure of embedding chunks json + +```json +[ + { + "chunk_id": "folder/sample note.md::Heading 1::0", + "note_id": "folder/sample note.md", + "title": "sample note", + "relative_path": "folder/sample note.md", + "heading": "Heading 1", + "chunk_index": 0, + "text": "lorem ipsum", + "word_count": 2, + "tags": [], + "frontmatter": {}, + "embedding": [ + 0.017203988507390022, + 0.06233978644013405, + -0.011157829314470291, + -0.012113398872315884, + ... + ] + }, + ... +] +``` +- This is same as the json output of the chunker module with the added `embedding` key. This represents the vector representation of the `text` as provided by the chosen encoder model. From 06bf5138911df5ae13d2355129404da42f451445 Mon Sep 17 00:00:00 2001 From: Divyendu Dutta Date: Tue, 30 Dec 2025 21:58:33 +0530 Subject: [PATCH 07/21] [FEAT] Add jupyter notebook to explore encoder model Exploring SentenceTransformer's `all-MiniLM-L6-v2` encoder model --- notebooks/explore_encoder_model.ipynb | 127 ++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 notebooks/explore_encoder_model.ipynb diff --git a/notebooks/explore_encoder_model.ipynb b/notebooks/explore_encoder_model.ipynb new file mode 100644 index 0000000..2a2221c --- /dev/null +++ b/notebooks/explore_encoder_model.ipynb @@ -0,0 +1,127 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "Based on [this](https://www.sbert.net/examples/sentence_transformer/applications/semantic-search/README.html#background) code from their offical website" + ] + }, + { + "cell_type": "markdown", + "id": "1", + "metadata": {}, + "source": [ + "This does semantic search (useful for retrieval) which is an application of generating embeddings" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2", + "metadata": {}, + "outputs": [], + "source": [ + "import torch\n", + "\n", + "from sentence_transformers import SentenceTransformer" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "embedder = SentenceTransformer(\"all-MiniLM-L6-v2\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4", + "metadata": {}, + "outputs": [], + "source": [ + "# Corpus with example documents\n", + "corpus = [\n", + " \"Machine learning is a field of study that gives computers the ability to learn without being explicitly programmed.\",\n", + " \"Deep learning is part of a broader family of machine learning methods based on artificial neural networks with representation learning.\",\n", + " \"Neural networks are computing systems vaguely inspired by the biological neural networks that constitute animal brains.\",\n", + " \"Mars rovers are robotic vehicles designed to travel on the surface of Mars to collect data and perform experiments.\",\n", + " \"The James Webb Space Telescope is the largest optical telescope in space, designed to conduct infrared astronomy.\",\n", + " \"SpaceX's Starship is designed to be a fully reusable transportation system capable of carrying humans to Mars and beyond.\",\n", + " \"Global warming is the long-term heating of Earth's climate system observed since the pre-industrial period due to human activities.\",\n", + " \"Renewable energy sources include solar, wind, hydro, and geothermal power that naturally replenish over time.\",\n", + " \"Carbon capture technologies aim to collect CO2 emissions before they enter the atmosphere and store them underground.\",\n", + "]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5", + "metadata": {}, + "outputs": [], + "source": [ + "# Use \"convert_to_tensor=True\" to keep the tensors on GPU (if available)\n", + "corpus_embeddings = embedder.encode_document(corpus, convert_to_tensor=True)\n", + "print(f\"Corpus embeddings shape: {corpus_embeddings.shape}\")\n", + "\n", + "# Query sentences:\n", + "queries = [\n", + " \"How do artificial neural networks work?\",\n", + " \"What technology is used for modern space exploration?\",\n", + " \"How can we address climate change challenges?\",\n", + "]\n", + "\n", + "# Find the closest 5 sentences of the corpus for each query sentence based on cosine similarity\n", + "top_k = min(5, len(corpus))\n", + "for query in queries:\n", + " query_embedding = embedder.encode_query(query, convert_to_tensor=True)\n", + " print(f\" Query embedding shape: {query_embedding.shape}\")\n", + "\n", + " # We use cosine-similarity and torch.topk to find the highest 5 scores\n", + " similarity_scores = embedder.similarity(query_embedding, corpus_embeddings)[0]\n", + " scores, indices = torch.topk(similarity_scores, k=top_k)\n", + "\n", + " print(\"\\nQuery:\", query)\n", + " print(\"Top 5 most similar sentences in corpus:\")\n", + "\n", + " for score, idx in zip(scores, indices):\n", + " print(f\"(Score: {score:.4f})\", corpus[idx])" + ] + }, + { + "cell_type": "markdown", + "id": "6", + "metadata": {}, + "source": [ + "So this encoder model encodes the text to a 384 dim space" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "atlas", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.14" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From cb3ab758c195490fc69bf8ae10a58e7ab9e3f307 Mon Sep 17 00:00:00 2001 From: Divyendu Dutta Date: Tue, 30 Dec 2025 22:10:32 +0530 Subject: [PATCH 08/21] [FEAT] Implement base and sentence transformer encoder wrapper classes --- atlas/core/embedder/__init__.py | 0 atlas/core/embedder/base/__init__.py | 0 atlas/core/embedder/base/base_encoder.py | 33 +++++++++++ .../embedder/sentence_transformer/__init__.py | 0 .../sentence_transformer/impl_encoder.py | 59 +++++++++++++++++++ 5 files changed, 92 insertions(+) create mode 100644 atlas/core/embedder/__init__.py create mode 100644 atlas/core/embedder/base/__init__.py create mode 100644 atlas/core/embedder/base/base_encoder.py create mode 100644 atlas/core/embedder/sentence_transformer/__init__.py create mode 100644 atlas/core/embedder/sentence_transformer/impl_encoder.py diff --git a/atlas/core/embedder/__init__.py b/atlas/core/embedder/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/atlas/core/embedder/base/__init__.py b/atlas/core/embedder/base/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/atlas/core/embedder/base/base_encoder.py b/atlas/core/embedder/base/base_encoder.py new file mode 100644 index 0000000..16a66c1 --- /dev/null +++ b/atlas/core/embedder/base/base_encoder.py @@ -0,0 +1,33 @@ +from abc import ABC +from abc import abstractmethod +from typing import List +import numpy as np + +from atlas.utils.logger import LoggerConfig + +LOGGER = LoggerConfig().logger + + +class BaseEncoder(ABC): + """Abstract base class for the encoder wrapper.""" + + @abstractmethod + def load(self) -> None: + """ + Loads the encoder model. + + """ + pass + + @abstractmethod + def encode(self, texts: List[str]) -> np.ndarray: + """ + Encodes a list of texts into their corresponding embeddings. + + Args: + texts (list[str]): A list of texts to be encoded. + + Returns: + np.ndarray: An array of embeddings corresponding to the input texts. + """ + pass diff --git a/atlas/core/embedder/sentence_transformer/__init__.py b/atlas/core/embedder/sentence_transformer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/atlas/core/embedder/sentence_transformer/impl_encoder.py b/atlas/core/embedder/sentence_transformer/impl_encoder.py new file mode 100644 index 0000000..8d0c301 --- /dev/null +++ b/atlas/core/embedder/sentence_transformer/impl_encoder.py @@ -0,0 +1,59 @@ +from sentence_transformers import SentenceTransformer +import numpy as np + +from atlas.core.embedder.base.base_encoder import BaseEncoder +from atlas.core.embedder.config import EncoderConfig +from atlas.utils.logger import LoggerConfig + +from typing import List + +LOGGER = LoggerConfig().logger + + +class SentenceTransformerEncoder(BaseEncoder): + """ + Sentence Transformer Encoder Wrapper. + + Args: + config (EncoderConfig): Configuration for the encoder. + """ + + def __init__(self, config: EncoderConfig): + LOGGER.info("-" * 20) + LOGGER.info("Initializing Sentence Transformer Encoder Wrapper") + self.config = config + self.model: SentenceTransformer | None = None + self.load() + + def load(self) -> None: + """Load the Sentence Transformer model.""" + if self.model is not None: + return + + self.model = SentenceTransformer( + self.config.model_name, device=self.config.device + ) + LOGGER.info(f"Loaded Sentence Transformer model: {self.config.model_name}") + + def encode(self, texts: List[str]) -> np.ndarray: + """ + Encode a List of texts into embeddings. + + Args: + texts (List[str]): List of texts to encode. + + Returns: + np.ndarray: Array of embeddings. + """ + LOGGER.info(f"Encoding {len(texts)} texts using Sentence Transformer model.") + + assert self.model is not None, "Model must be loaded before encoding" + + embeddings = self.model.encode( + texts, + batch_size=self.config.batch_size, + show_progress_bar=True, + normalize_embeddings=self.config.normalize_embeddings, + ) + + return embeddings From 3ed165195d8c833dc8e6b3a0e07212a131e9604b Mon Sep 17 00:00:00 2001 From: Divyendu Dutta Date: Tue, 30 Dec 2025 22:14:30 +0530 Subject: [PATCH 09/21] [FEAT] Implement BaseEmbedder and SentenceTransformerEmbedder classes Introduced a BaseEmbedder interface to standardize the embedding workflow and added a concrete SentenceTransformerEmbedder implementation. This provides a clean abstraction for embedding generation, simplifies future embedder extensions. --- atlas/core/embedder/base/base_embedder.py | 81 +++++++++++++++++++ .../sentence_transformer/impl_embedder.py | 72 +++++++++++++++++ 2 files changed, 153 insertions(+) create mode 100644 atlas/core/embedder/base/base_embedder.py create mode 100644 atlas/core/embedder/sentence_transformer/impl_embedder.py diff --git a/atlas/core/embedder/base/base_embedder.py b/atlas/core/embedder/base/base_embedder.py new file mode 100644 index 0000000..4765b68 --- /dev/null +++ b/atlas/core/embedder/base/base_embedder.py @@ -0,0 +1,81 @@ +from abc import ABC +from abc import abstractmethod +from typing import List, Dict +from pathlib import Path +import json + +from atlas.utils.logger import LoggerConfig + +LOGGER = LoggerConfig().logger + + +class BaseEmbedder(ABC): + """ + Abstract base class for all embedder implementations. + + Args: + chunk_data_path (str): Path to the chunk data file. + output_path (str): Path to save the embedded chunks. + encoder_config_path (str): Path to the encoder configuration file. + """ + + def __init__( + self, chunk_data_path: str, output_path: str, encoder_config_path: str + ): + LOGGER.info("-" * 20) + LOGGER.info("Initializing Embedder.") + self.chunk_data_path = Path(chunk_data_path) + self.output_path = Path(output_path) + self.encoder_config_path = Path(encoder_config_path) + self.load_encoder() + + def read_chunk_data(self) -> List[Dict] | None: + """Load chunk data to be embedded.""" + LOGGER.info(f"Loading chunk data from {self.chunk_data_path}") + try: + with self.chunk_data_path.open("r", encoding="utf-8") as f: + chunk_data = json.load(f) + LOGGER.info(f"Loaded {len(chunk_data)} chunks for embedding.") + return chunk_data + except Exception as e: + LOGGER.error(f"Error reading chunk data file: {e}") + return None + + @abstractmethod + def load_encoder(self) -> None: + """ + Load the encoder model. + """ + pass + + def embed(self) -> None: + """ + Main method to perform the embedding process. + """ + chunks = self.read_chunk_data() + assert chunks is not None, "Chunk data read should be present." + embedded_chunks = self.embed_chunks(chunks) + self.save_embedded_chunks(embedded_chunks) + LOGGER.info("Embedding process completed.") + + @abstractmethod + def embed_chunks(self, chunks: List[Dict]) -> List[Dict]: + """ + Embed the provided chunks using the loaded encoder. + + Args: + chunks (List[Dict]): List of chunk dictionaries to be embedded. + """ + pass + + def save_embedded_chunks(self, embedded_chunks: List[Dict]) -> None: + """ + Save the embedded chunks to a suitable format for later use. + """ + tmp_path = self.output_path.with_suffix(".tmp") + + with tmp_path.open("w", encoding="utf-8") as f: + json.dump(embedded_chunks, f, indent=2, ensure_ascii=False) + + tmp_path.replace(self.output_path) + LOGGER.info("Embedded chunks saved successfully.") diff --git a/atlas/core/embedder/sentence_transformer/impl_embedder.py b/atlas/core/embedder/sentence_transformer/impl_embedder.py new file mode 100644 index 0000000..008649e --- /dev/null +++ b/atlas/core/embedder/sentence_transformer/impl_embedder.py @@ -0,0 +1,72 @@ +import os +from typing import List, Dict + +from atlas.core.embedder.base.base_embedder import BaseEmbedder +from atlas.core.embedder.config import load_encoder_config +from atlas.core.embedder.sentence_transformer.impl_encoder import ( + SentenceTransformerEncoder, +) +from atlas.utils.logger import LoggerConfig + +LOGGER = LoggerConfig().logger + + +class SentenceTransformerEmbedder(BaseEmbedder): + """Embedder implementation using Sentence Transformers.""" + + def __init__( + self, chunk_data_path: str, output_path: str, encoder_config_path: str + ): + super().__init__(chunk_data_path, output_path, encoder_config_path) + + def load_encoder(self) -> None: + """Load the Sentence Transformer encoder model.""" + + embedding_config = load_encoder_config(self.encoder_config_path) + encoder = SentenceTransformerEncoder(embedding_config) + self.encoder = encoder + + def embed_chunks(self, chunks: List[Dict]) -> List[Dict]: + """ + Embed the provided chunks using the loaded encoder. + + Args: + chunks (List[Dict]): List of chunk dictionaries to be embedded. + + Returns: + List[Dict]: List of chunk dictionaries with added embeddings. + """ + if not chunks: + LOGGER.warning("No chunks provided for embedding.") + return [] + + texts = [chunk["text"] for chunk in chunks] + + embeddings = self.encoder.encode(texts) + + if len(embeddings) != len(chunks): + LOGGER.error("Embedding count does not match chunk count.") + raise ValueError("Embedding count does not match chunk count") + + embedded_chunks = [] + + for chunk, embedding in zip(chunks, embeddings): + embedded_chunk = { + **chunk, + "embedding": embedding.tolist(), # Convert numpy array to list for JSON serialization + } + embedded_chunks.append(embedded_chunk) + + return embedded_chunks + + +if __name__ == "__main__": + chunk_data_path = r"D:\\Deep learning\\Atlas\\Resources\\chunked_data.json" + output_path = r"D:\\Deep learning\\Atlas\\Resources\\embedded_chunks.json" + encoder_config_path = os.path.join( + os.getcwd(), "atlas", "core", "configs", "sentence_transformer_config.yaml" + ) + embedder = SentenceTransformerEmbedder( + chunk_data_path, output_path, encoder_config_path + ) + embedder.embed() From 769f14bdf45e8548a33be21535adceaafb73370c Mon Sep 17 00:00:00 2001 From: Divyendu Dutta Date: Tue, 30 Dec 2025 22:20:19 +0530 Subject: [PATCH 10/21] [TEST] Add unit tests for embedding module and modify existing unit tests Added conftest.py to save global pytest fixtures Updated existing unit tests appropriately Added unit tests for newly added embedding module --- tests/unittests/scripts/conftest.py | 133 +++++++++++ tests/unittests/scripts/test_config.py | 76 +++++++ .../scripts/test_obsidian_vault_processor.py | 2 +- .../test_sentence_transformer_embedder.py | 213 ++++++++++++++++++ .../test_sentence_transformer_encoder.py | 37 +++ .../scripts/test_structural_chunker.py | 72 +++--- 6 files changed, 486 insertions(+), 47 deletions(-) create mode 100644 tests/unittests/scripts/conftest.py create mode 100644 tests/unittests/scripts/test_config.py create mode 100644 tests/unittests/scripts/test_sentence_transformer_embedder.py create mode 100644 tests/unittests/scripts/test_sentence_transformer_encoder.py diff --git a/tests/unittests/scripts/conftest.py b/tests/unittests/scripts/conftest.py new file mode 100644 index 0000000..86adfd3 --- /dev/null +++ b/tests/unittests/scripts/conftest.py @@ -0,0 +1,133 @@ +from pathlib import Path +import yaml +import pytest +import json + +from atlas.core.embedder.config import load_encoder_config, EncoderConfig + + +@pytest.fixture +def dummy_processed_data_path(tmp_path: Path) -> Path: + """ + Create a dummy processed data file for testing. + + Args: + tmp_path (Path): Temporary directory provided by pytest. + + Returns: + Path: The path to the created dummy processed data file. + """ + dummy_data = [ + { + "note_id": "_learning about me/what I learnt about myself when dealing with ADHD.md", + "title": "what I learnt about myself when dealing with ADHD", + "relative_path": "_learning about me/what I learnt about myself when dealing with ADHD.md", + "raw_text": "\n- aim to do less, end up doing more\n- breakdown tasks into initial granular nested problems\n- work in iterations. Get 80% value from 20% of things, Pareto's principle\n- do long things, delay instant gratification. Avoid seeking novelty. Finish reading one book, watch one big video, play one game.\n- morning and night routines\n- just open things up, set things up, write in notepad ++ then take a break. Dont have to start immediately\n- do a little everyday in a robust consistent manner. Dont have to do it all in one day, in this one session. Takes time. Be patient but consistent\n- systems thinker - seeing the whole picture and its parts as early as possible - related to working in iterations", + "frontmatter": {}, + "headings": [], + "tags": [], + "wikilinks": [], + "word_count": 127, + }, + { + "note_id": "_Meditations/Games and Life.md", + "title": "Games and Life", + "relative_path": "_Meditations/Games and Life.md", + "raw_text": "\n### Roguelikes and Life\n- **Learning** more about the thing that's causing us _problems/scaring us/making us uncomfortable_ will help us deal with the problem better\n- For example, in Darkest Dungeon, stress is a huge problem for me but as I learn more about it, the fear changes into enthusiasm because I understand things better\n\t- So we try to apply the same in life as well\n---\n- Frame _questions_ regarding the problems Im currently facing\n\t- and _answer_ them\n\t\t- if you dont know the answer, search online or ask someone\n---\n- Learn from mistakes in life\n\t- For ex, we didnt know about traps in DD (Darkest Dungeon) and took deadly stress damage\n\t\t- but thats fine as long as we used that opportunity to learn about traps and how to avoid in future\n- Use experiences/problems/obstacles to acquire information\n\t- For ex, every run is an experience and helps us learn more\n- Write these things down\n---\n### Plateup and life\n- If something makes you uncomfortable (because you don't understand it or are not good at it)\n\t- and if that thing is good for you, its result benefits you\n\t\t- then embrace it and do more of that thing\n- This is the only way to get used to that uncomfortable feeling and learn more about it\n\t- and eventually it becomes less uncomfortable\n- For ex, in Plateup, we see a recipe we didn't understand before and we chose it so that we could embrace it and understand it\n\t- the game ended because I didn't know it but I was able to learn from it and next time I did better\n---\n- the real reason why the tasks of life evoke fear and anxiety is because I give utmost importance to just the outcome/result of the task\n\t- I need to focus on the doing part, the journey. To take control of my fate, to be in control\n\t- focusing on what I CAN DO, leads to excitement\n\t\t- it becomes opportunities to solve problems/ challenges/ obstacles\n\t\t\t- solving them leads to contentment and fulfillment and happiness\n---\n\n### Skyrim/Valorant and Life obstacles\n- When I was challenged to a fist fight by Uthgerd The Unbroken, it was not an easy fight considering her armour is much better than mine, the combat in the game is bad and its a fist fight (no weapon, magic or healing)\n- I lost multiple times and yet I didnt give up, I kept going back to it and wanted to beat her/ overcome the obstacle\n- similarly in life, similar problems will keep arising and the point is to not give up after the first failure but to learn from it and keep trying to overcome the obstacle\n- for ex, when applying for jobs, we fail an interview, instead of giving up we learn from it and keep trying to pass the interviews\n- the same thing happens in valorant too, where I keep trying to get better at aiming and getting headshots even though I have bad ping and am at a disadvantage\n\t- I drop off sometimes but always come back in order to try to best the obstacle and eventually I keep improving\n\n---\n\n### Spelunky\n- the game is a corollary for life. Sometimes things dont go our way like the shopkeeper is enraged due to something we didn't do, the key and chest and in locations unreachable without prior knowledge, its a dark level and rushing water and black market entrance level so we miss the black market. But thats fine. I dont give up the run immediately but rather take it as a challenge to see how far can I go with this hand I've been dealt.\n- The main reason I give up is because I think \"since I dont have the conditions I think I needed to succeed, there's no point pursuing things further. Whats the point of trying if I know I cant succeed\". Many problems with this way of thinking. \n\t- First, I make up the conditions to succeed. I dont know for sure if those are actually the conditions to succeed\n\t- Second, I dont know for sure that if those conditions to succeed arent available then I wont be able succeed for sure. It might be harder to succeed maybe but not impossible. Its impossible if I give up.\n\t\t- subpoint - even if I have all the conditions to succeed available, I might not be able to succeed. Eg, I have a jetpack and a shotgun and good amount of health, I die and the run ends\n\t- Third, if I try to succeed ie, work with what I have, there's always a chance to succeed. The chance may be low but its not 0. And even 1% is more than 0%. (this is related to the previous point)\n\t\t- eg, I lost 1 hp due to some pointless mistake. I immediately thought of abandoning the run. I remember all this that I've written here and kept progressing and found a jetpack and compass in the market in the next level. \n\t- Fourth, the point isnt always about succeeding but even failing is fine because I can learn from it and try again and try again and again and again. As long as I'm learning its good. And failure isnt the end of it all. Life goes on and opportunities come again. Learning from failure and improving makes me capable of being able to capitalize on those opportunities better.\n- I do think this mental distortion might have something to do with my ADHD. If things dont go properly/successfully (properly/successfully as defined by me or society) right from the very beginning ie, each small step needs to be perfect and successful, then I give up. I think its my ADHD kicking in which wants rewards in the short term ie, each small step going properly rather than considering things in the long term.\n- `important` - I beat spelunky finally. And prior to beating it today, I had a run where I did everything perfectly but some random explosion killed me. Another one - The shopkeeper got angered due to a snail spawning in the shop in the black market and I made no mistake in that run. Another one - There was a bug and I got stuck exiting and got killed by a tiki trap. But I didnt give up. I immediately did a quick restart. So why can I keep restarting and not give up in games but not do so in real life? All the times I got screwed in Spelunky where it wasnt my mistake has happened and will happen in life as well. I could do everything perfectly, be very good, follow all the rules and life will fuck me over. Life is procedurally generated just like Spelunky. But I do a quick restart and immediately try again because if I do so then eventually I will win the game just like how I eventually beat spelunky. ^f7912e\n- Life is hard just like Spelunky. Then does that mean I cant beat it? Nope, I learn and keep trying and keep getting better and eventually everything will align properly for me. Luck will be on my side and I will win at life.\n---\n\n", + "frontmatter": {}, + "headings": [ + {"level": 3, "title": "Roguelikes and Life"}, + {"level": 3, "title": "Plateup and life"}, + {"level": 3, "title": "Skyrim/Valorant and Life obstacles"}, + {"level": 3, "title": "Spelunky"}, + ], + "tags": [], + "wikilinks": [], + "word_count": 1233, + }, + ] + processed_data_path = tmp_path / "obsidian_index.json" + with processed_data_path.open("w", encoding="utf-8") as f: + json.dump(dummy_data, f, indent=2, ensure_ascii=False) + return processed_data_path + + +@pytest.fixture +def dummy_chunk_data_path(tmp_path: Path) -> Path: + """ + Create a dummy chunk data file for testing. + + Args: + tmp_path (Path): Temporary directory provided by pytest. + + Returns: + Path: The path to the created dummy chunk data file. + """ + dummy_chunk_data = [ + { + "chunk_id": "test Note.md::test_heading::chunk_0", + "note_id": "test Note.md", + "title": "Test Note", + "relative_path": "test_note.md", + "heading": "Test Heading", + "chunk_index": 0, + "text": "This is a test chunk.", + "word_count": 5, + "tags": [], + "frontmatter": {}, + } + ] + chunk_data_path = tmp_path / "chunked_data.json" + with chunk_data_path.open("w", encoding="utf-8") as f: + json.dump(dummy_chunk_data, f, indent=2, ensure_ascii=False) + return chunk_data_path + + +@pytest.fixture +def dummy_encoder_config(tmp_path: Path) -> EncoderConfig: + """ + Create dummy encoder configuration data for testing. + + Args: + tmp_path (Path): Temporary directory provided by pytest. + + Returns: + EncoderConfig: Instance of the encoder configuration dataclass. + """ + config_data = { + "model_name": "all-MiniLM-L6-v2", + "batch_size": 32, + "normalize_embeddings": True, + "device": "cuda", + } + config_path = tmp_path / "encoder_config.yaml" + with config_path.open("w", encoding="utf-8") as f: + yaml.dump(config_data, f) + + # Load the configuration + loaded_config = load_encoder_config(config_path) + return loaded_config + + +@pytest.fixture +def dummy_encoder_config_path(tmp_path: Path) -> Path: + """ + Create a dummy encoder configuration file for testing. + + Args: + tmp_path (Path): Temporary directory provided by pytest. + + Returns: + Path: The path to the created dummy encoder configuration file. + """ + config_data = { + "model_name": "all-MiniLM-L6-v2", + "batch_size": 32, + "normalize_embeddings": True, + "device": "cuda", + } + config_path = tmp_path / "encoder_config.yaml" + with config_path.open("w", encoding="utf-8") as f: + yaml.dump(config_data, f) + + return config_path diff --git a/tests/unittests/scripts/test_config.py b/tests/unittests/scripts/test_config.py new file mode 100644 index 0000000..0a974e2 --- /dev/null +++ b/tests/unittests/scripts/test_config.py @@ -0,0 +1,76 @@ +import pytest +import yaml +from pathlib import Path + +from atlas.core.embedder.config import load_encoder_config, EncoderConfig + + +@pytest.mark.unittest +@pytest.mark.runonci +def test_load_encoder_config_positive(tmp_path: Path): + """ + Test loading the encoder configuration file when file is available and not empty. + + Args: + tmp_path (Path): Temporary directory provided by pytest. + """ + + # Create a temporary YAML config file + config_data = { + "model_name": "all-MiniLM-L6-v2", + "batch_size": 32, + "normalize_embeddings": True, + "device": "cuda", + } + config_path = tmp_path / "encoder_config.yaml" + with config_path.open("w", encoding="utf-8") as f: + yaml.dump(config_data, f) + + # Load the configuration + loaded_config = load_encoder_config(config_path) + + # Assert that the loaded configuration matches the original data + assert isinstance(loaded_config, EncoderConfig) + assert loaded_config.model_name == config_data["model_name"] + assert loaded_config.batch_size == config_data["batch_size"] + assert loaded_config.normalize_embeddings == config_data["normalize_embeddings"] + assert loaded_config.device == config_data["device"] + + +@pytest.mark.unittest +@pytest.mark.runonci +def test_load_encoder_config_negative_empty_file(tmp_path): + """ + Test loading the encoder configuration file when file is empty. + + Args: + tmp_path (Path): Temporary directory provided by pytest. + """ + # Create an empty temporary YAML config file + config_path = tmp_path / "empty_encoder_config.yaml" + with config_path.open("w", encoding="utf-8") as f: + f.write("") + + # Attempt to load the configuration and expect a ValueError + with pytest.raises(ValueError) as exc_info: + load_encoder_config(config_path) + + assert "Encoder configuration file is empty" in str(exc_info.value) + + +@pytest.mark.unittest +@pytest.mark.runonci +def test_load_encoder_config_negative_file_not_found(tmp_path): + """ + Test loading the encoder configuration file when file is not found. + + Args: + tmp_path (Path): Temporary directory provided by pytest. + """ + config_path = tmp_path / "incorrect_config_file.yaml" + + # Attempt to load the configuration and expect a FileNotFoundError + with pytest.raises(FileNotFoundError) as exc_info: + load_encoder_config(config_path) + + assert "Encoder configuration file not found" in str(exc_info.value) diff --git a/tests/unittests/scripts/test_obsidian_vault_processor.py b/tests/unittests/scripts/test_obsidian_vault_processor.py index f9ef912..69c200f 100644 --- a/tests/unittests/scripts/test_obsidian_vault_processor.py +++ b/tests/unittests/scripts/test_obsidian_vault_processor.py @@ -3,7 +3,7 @@ import json from pathlib import Path -from atlas.core.ingest.obsidian_vault_processor import ObsidianVaultProcessor +from atlas.core.ingester.obsidian_vault_processor import ObsidianVaultProcessor @pytest.fixture diff --git a/tests/unittests/scripts/test_sentence_transformer_embedder.py b/tests/unittests/scripts/test_sentence_transformer_embedder.py new file mode 100644 index 0000000..4b7b5c4 --- /dev/null +++ b/tests/unittests/scripts/test_sentence_transformer_embedder.py @@ -0,0 +1,213 @@ +import pytest +import json +from pathlib import Path +from typing import List, Dict + +from atlas.core.embedder.sentence_transformer.impl_embedder import ( + SentenceTransformerEmbedder, +) +from atlas.core.embedder.sentence_transformer.impl_encoder import ( + SentenceTransformerEncoder, +) + + +@pytest.fixture +def dummy_chunks() -> List[Dict]: + """ + Create a dummy chunks for testing. + + Returns: + List[Dict]: The dummy chunk data. + """ + dummy_chunk_data = [ + { + "chunk_id": "test Note.md::test_heading::chunk_0", + "note_id": "test Note.md", + "title": "Test Note", + "relative_path": "test_note.md", + "heading": "Test Heading", + "chunk_index": 0, + "text": "This is a test chunk.", + "word_count": 5, + "tags": [], + "frontmatter": {}, + }, + { + "chunk_id": "test Note 2.md::test_heading::chunk_0", + "note_id": "test Note 2.md", + "title": "Test Note 2", + "relative_path": "test_note 2.md", + "heading": "Test Heading 2", + "chunk_index": 0, + "text": "This is a test chunk 2.", + "word_count": 6, + "tags": [], + "frontmatter": {}, + }, + ] + return dummy_chunk_data + + +@pytest.mark.unittest +@pytest.mark.runonci +def test_load_encoder(dummy_encoder_config_path: Path): + """ + Test SentenceTransformerEmbedder's encoder loading functionality. + + Args: + dummy_encoder_config_path (Path): The path to the dummy encoder configuration file. + """ + # Encoder is loaded when `SentenceTransformerEmbedder` is initialized. No need to call separately + embedder = SentenceTransformerEmbedder( + "dummy_chunked_data.json", "dummy.json", str(dummy_encoder_config_path) + ) + assert embedder.encoder is not None + assert isinstance(embedder.encoder, SentenceTransformerEncoder) + + +@pytest.mark.unittest +@pytest.mark.runonci +def test_embed_chunks_positive( + dummy_encoder_config_path: Path, dummy_chunks: List[Dict] +): + """ + Test SentenceTransformerEmbedder's functionality to generate embeddings for provided chunks. + + Args: + dummy_encoder_config_path (Path): The path to the dummy encoder configuration file. + dummy_chunks (List[Dict]): The provided dummy chunks. + """ + embedder = SentenceTransformerEmbedder( + "dummy_chunked_data.json", "dummy.json", str(dummy_encoder_config_path) + ) + embedded_chunks = embedder.embed_chunks(dummy_chunks) + assert len(embedded_chunks) == len(dummy_chunks) + assert "embedding" in embedded_chunks[0].keys() + assert len(embedded_chunks[0]["embedding"]) == 384 + + +@pytest.mark.unittest +@pytest.mark.runonci +def test_embed_chunks_negative(dummy_encoder_config_path: Path): + """ + Test SentenceTransformerEmbedder's functionality to fail in generating embeddings for + provided chunks when no chunk data is passed to `SentenceTransformerEmbedder.embed_chunks()`. + + Args: + dummy_encoder_config_path (Path): The path to the dummy encoder configuration file. + """ + embedder = SentenceTransformerEmbedder( + "dummy_chunked_data.json", "dummy.json", str(dummy_encoder_config_path) + ) + embedded_chunks = embedder.embed_chunks([]) + assert len(embedded_chunks) == 0 + + +@pytest.mark.unittest +@pytest.mark.runonci +def test_read_chunk_data_positive( + dummy_encoder_config_path: Path, dummy_chunk_data_path: Path +): + """ + Test SentenceTransformerEmbedder's functionality to read chunk data from the provided chunk data + path. + + Args: + dummy_encoder_config_path (Path): The path to the dummy encoder configuration file. + dummy_chunk_data_path (Path): The path to the provided chunk data path. + """ + embedder = SentenceTransformerEmbedder( + str(dummy_chunk_data_path), "dummy.json", str(dummy_encoder_config_path) + ) + chunks = embedder.read_chunk_data() + assert chunks is not None + assert len(chunks) == 1 + assert isinstance(chunks[0], dict) + assert chunks[0]["chunk_id"] == "test Note.md::test_heading::chunk_0" + + +@pytest.mark.unittest +@pytest.mark.runonci +def test_read_chunk_data_negative( + dummy_encoder_config_path: Path, dummy_chunk_data_path: Path +): + """ + Test SentenceTransformerEmbedder's failure to read chunk data from the provided chunk data + path when the provided chunk data path doesnt exist. + + Args: + dummy_encoder_config_path (Path): The path to the dummy encoder configuration file. + dummy_chunk_data_path (Path): The path to the provided chunk data path. + """ + dummy_chunk_data_path = ( + dummy_chunk_data_path.parent / "dummy_chunked_data.json" + ) # doesnt exist + embedder = SentenceTransformerEmbedder( + str(dummy_chunk_data_path), "dummy.json", str(dummy_encoder_config_path) + ) + chunks = embedder.read_chunk_data() + assert chunks is None + + +@pytest.mark.unittest +@pytest.mark.runonci +def test_save_embedded_chunks(tmp_path: Path, dummy_encoder_config_path: Path): + """ + Test SentenceTransformerEmbedder's functionality to save the generated embedded chunk data. + + Args: + tmp_path (Path): Temporary directory provided by pytest. + dummy_encoder_config_path (Path): The path to the dummy encoder configuration file. + """ + output_file_path = tmp_path / "embedded_chunks.json" + dummy_embedded_chunks = [ + { + "chunk_id": "test Note.md::test_heading::chunk_0", + "note_id": "test Note.md", + "title": "Test Note", + "relative_path": "test_note.md", + "heading": "Test Heading", + "chunk_index": 0, + "text": "This is a test chunk.", + "word_count": 5, + "tags": [], + "frontmatter": {}, + "embedding": [float(i) for i in range(384)], + } + ] + embedder = SentenceTransformerEmbedder( + "dummy_chunked_data.json", str(output_file_path), str(dummy_encoder_config_path) + ) + embedder.save_embedded_chunks(dummy_embedded_chunks) + assert output_file_path.exists() + with output_file_path.open("r", encoding="utf-8") as f: + saved_data = json.load(f) + assert saved_data == dummy_embedded_chunks + + +@pytest.mark.unittest +@pytest.mark.runonci +def test_embed( + tmp_path: Path, dummy_chunk_data_path: Path, dummy_encoder_config_path: Path +): + """ + Test SentenceTransformerEmbedder's overall functionality to generate the embedded chunk data + from the chunks in the provided chunk data path. + + Args: + tmp_path (Path): Temporary directory provided by pytest. + dummy_chunk_data_path (Path): The path to the provided chunk data path. + dummy_encoder_config_path (Path): The path to the dummy encoder configuration file. + """ + output_file_path = tmp_path / "embedded_chunks.json" + embedder = SentenceTransformerEmbedder( + str(dummy_chunk_data_path), + str(output_file_path), + str(dummy_encoder_config_path), + ) + embedder.embed() + assert output_file_path.exists() + with output_file_path.open("r", encoding="utf-8") as f: + saved_data = json.load(f) + + assert len(saved_data[0]["embedding"]) == 384 diff --git a/tests/unittests/scripts/test_sentence_transformer_encoder.py b/tests/unittests/scripts/test_sentence_transformer_encoder.py new file mode 100644 index 0000000..0a4818a --- /dev/null +++ b/tests/unittests/scripts/test_sentence_transformer_encoder.py @@ -0,0 +1,37 @@ +import pytest + +from atlas.core.embedder.config import EncoderConfig +from atlas.core.embedder.sentence_transformer.impl_encoder import ( + SentenceTransformerEncoder, +) +from sentence_transformers import SentenceTransformer + + +@pytest.mark.unittest +@pytest.mark.runonci +def test_load(dummy_encoder_config: EncoderConfig): + """ + Test the model loading functionality of the SentenceTransformerEncoder wrapper. + + Args: + dummy_encoder_config (EncoderConfig): Loaded encoder configuration data. + """ + encoder = SentenceTransformerEncoder(dummy_encoder_config) + assert encoder.model is not None + assert isinstance(encoder.model, SentenceTransformer) + + +@pytest.mark.unittest +@pytest.mark.runonci +def test_encode(dummy_encoder_config: EncoderConfig): + """ + Test the loaded encoder model's encoding functionality of the SentenceTransformerEncoder wrapper. + + Args: + dummy_encoder_config (EncoderConfig): Loaded encoder configuration data. + """ + encoder = SentenceTransformerEncoder(dummy_encoder_config) + encoder.load() + texts = ["lorem ipsum", "do re mi fa so la ti", "hello world"] + embeddings = encoder.encode(texts) + assert embeddings.shape == (3, 384) diff --git a/tests/unittests/scripts/test_structural_chunker.py b/tests/unittests/scripts/test_structural_chunker.py index b6f397e..e0708a3 100644 --- a/tests/unittests/scripts/test_structural_chunker.py +++ b/tests/unittests/scripts/test_structural_chunker.py @@ -5,52 +5,6 @@ from atlas.core.chunker.structural_chunker import StructuralChunker -@pytest.fixture -def dummy_processed_data_path(tmp_path: Path) -> Path: - """ - Create a dummy processed data file for testing. - - Args: - tmp_path (Path): Temporary directory provided by pytest. - - Returns: - Path: The path to the created dummy processed data file. - """ - dummy_data = [ - { - "note_id": "_learning about me/what I learnt about myself when dealing with ADHD.md", - "title": "what I learnt about myself when dealing with ADHD", - "relative_path": "_learning about me/what I learnt about myself when dealing with ADHD.md", - "raw_text": "\n- aim to do less, end up doing more\n- breakdown tasks into initial granular nested problems\n- work in iterations. Get 80% value from 20% of things, Pareto's principle\n- do long things, delay instant gratification. Avoid seeking novelty. Finish reading one book, watch one big video, play one game.\n- morning and night routines\n- just open things up, set things up, write in notepad ++ then take a break. Dont have to start immediately\n- do a little everyday in a robust consistent manner. Dont have to do it all in one day, in this one session. Takes time. Be patient but consistent\n- systems thinker - seeing the whole picture and its parts as early as possible - related to working in iterations", - "frontmatter": {}, - "headings": [], - "tags": [], - "wikilinks": [], - "word_count": 127, - }, - { - "note_id": "_Meditations/Games and Life.md", - "title": "Games and Life", - "relative_path": "_Meditations/Games and Life.md", - "raw_text": "\n### Roguelikes and Life\n- **Learning** more about the thing that's causing us _problems/scaring us/making us uncomfortable_ will help us deal with the problem better\n- For example, in Darkest Dungeon, stress is a huge problem for me but as I learn more about it, the fear changes into enthusiasm because I understand things better\n\t- So we try to apply the same in life as well\n---\n- Frame _questions_ regarding the problems Im currently facing\n\t- and _answer_ them\n\t\t- if you dont know the answer, search online or ask someone\n---\n- Learn from mistakes in life\n\t- For ex, we didnt know about traps in DD (Darkest Dungeon) and took deadly stress damage\n\t\t- but thats fine as long as we used that opportunity to learn about traps and how to avoid in future\n- Use experiences/problems/obstacles to acquire information\n\t- For ex, every run is an experience and helps us learn more\n- Write these things down\n---\n### Plateup and life\n- If something makes you uncomfortable (because you don't understand it or are not good at it)\n\t- and if that thing is good for you, its result benefits you\n\t\t- then embrace it and do more of that thing\n- This is the only way to get used to that uncomfortable feeling and learn more about it\n\t- and eventually it becomes less uncomfortable\n- For ex, in Plateup, we see a recipe we didn't understand before and we chose it so that we could embrace it and understand it\n\t- the game ended because I didn't know it but I was able to learn from it and next time I did better\n---\n- the real reason why the tasks of life evoke fear and anxiety is because I give utmost importance to just the outcome/result of the task\n\t- I need to focus on the doing part, the journey. To take control of my fate, to be in control\n\t- focusing on what I CAN DO, leads to excitement\n\t\t- it becomes opportunities to solve problems/ challenges/ obstacles\n\t\t\t- solving them leads to contentment and fulfillment and happiness\n---\n\n### Skyrim/Valorant and Life obstacles\n- When I was challenged to a fist fight by Uthgerd The Unbroken, it was not an easy fight considering her armour is much better than mine, the combat in the game is bad and its a fist fight (no weapon, magic or healing)\n- I lost multiple times and yet I didnt give up, I kept going back to it and wanted to beat her/ overcome the obstacle\n- similarly in life, similar problems will keep arising and the point is to not give up after the first failure but to learn from it and keep trying to overcome the obstacle\n- for ex, when applying for jobs, we fail an interview, instead of giving up we learn from it and keep trying to pass the interviews\n- the same thing happens in valorant too, where I keep trying to get better at aiming and getting headshots even though I have bad ping and am at a disadvantage\n\t- I drop off sometimes but always come back in order to try to best the obstacle and eventually I keep improving\n\n---\n\n### Spelunky\n- the game is a corollary for life. Sometimes things dont go our way like the shopkeeper is enraged due to something we didn't do, the key and chest and in locations unreachable without prior knowledge, its a dark level and rushing water and black market entrance level so we miss the black market. But thats fine. I dont give up the run immediately but rather take it as a challenge to see how far can I go with this hand I've been dealt.\n- The main reason I give up is because I think \"since I dont have the conditions I think I needed to succeed, there's no point pursuing things further. Whats the point of trying if I know I cant succeed\". Many problems with this way of thinking. \n\t- First, I make up the conditions to succeed. I dont know for sure if those are actually the conditions to succeed\n\t- Second, I dont know for sure that if those conditions to succeed arent available then I wont be able succeed for sure. It might be harder to succeed maybe but not impossible. Its impossible if I give up.\n\t\t- subpoint - even if I have all the conditions to succeed available, I might not be able to succeed. Eg, I have a jetpack and a shotgun and good amount of health, I die and the run ends\n\t- Third, if I try to succeed ie, work with what I have, there's always a chance to succeed. The chance may be low but its not 0. And even 1% is more than 0%. (this is related to the previous point)\n\t\t- eg, I lost 1 hp due to some pointless mistake. I immediately thought of abandoning the run. I remember all this that I've written here and kept progressing and found a jetpack and compass in the market in the next level. \n\t- Fourth, the point isnt always about succeeding but even failing is fine because I can learn from it and try again and try again and again and again. As long as I'm learning its good. And failure isnt the end of it all. Life goes on and opportunities come again. Learning from failure and improving makes me capable of being able to capitalize on those opportunities better.\n- I do think this mental distortion might have something to do with my ADHD. If things dont go properly/successfully (properly/successfully as defined by me or society) right from the very beginning ie, each small step needs to be perfect and successful, then I give up. I think its my ADHD kicking in which wants rewards in the short term ie, each small step going properly rather than considering things in the long term.\n- `important` - I beat spelunky finally. And prior to beating it today, I had a run where I did everything perfectly but some random explosion killed me. Another one - The shopkeeper got angered due to a snail spawning in the shop in the black market and I made no mistake in that run. Another one - There was a bug and I got stuck exiting and got killed by a tiki trap. But I didnt give up. I immediately did a quick restart. So why can I keep restarting and not give up in games but not do so in real life? All the times I got screwed in Spelunky where it wasnt my mistake has happened and will happen in life as well. I could do everything perfectly, be very good, follow all the rules and life will fuck me over. Life is procedurally generated just like Spelunky. But I do a quick restart and immediately try again because if I do so then eventually I will win the game just like how I eventually beat spelunky. ^f7912e\n- Life is hard just like Spelunky. Then does that mean I cant beat it? Nope, I learn and keep trying and keep getting better and eventually everything will align properly for me. Luck will be on my side and I will win at life.\n---\n\n", - "frontmatter": {}, - "headings": [ - {"level": 3, "title": "Roguelikes and Life"}, - {"level": 3, "title": "Plateup and life"}, - {"level": 3, "title": "Skyrim/Valorant and Life obstacles"}, - {"level": 3, "title": "Spelunky"}, - ], - "tags": [], - "wikilinks": [], - "word_count": 1233, - }, - ] - processed_data_path = tmp_path / "obsidian_index.json" - with processed_data_path.open("w", encoding="utf-8") as f: - json.dump(dummy_data, f, indent=2, ensure_ascii=False) - return processed_data_path - - @pytest.mark.unittest @pytest.mark.runonci def test_read_processed_data_positive(dummy_processed_data_path: Path): @@ -235,3 +189,29 @@ def test_save_chunked_data(tmp_path: Path): with output_file.open("r", encoding="utf-8") as f: saved_data = json.load(f) assert saved_data == chunked_data + + +@pytest.mark.unittest +@pytest.mark.runonci +def test_chunk(tmp_path: Path, dummy_processed_data_path: Path): + """ + Test the main chunking process of the chunker module. + + Args: + tmp_path (Path): Temporary directory provided by pytest. + dummy_processed_data_path (Path): Path to the dummy processed data file. + """ + chunker = StructuralChunker( + processed_data_path=str(dummy_processed_data_path), + output_path=str(tmp_path / "chunked_data.json"), + max_words=250, + ) + chunker.chunk() + output_file = tmp_path / "chunked_data.json" + assert output_file.exists() + with output_file.open("r", encoding="utf-8") as f: + saved_data = json.load(f) + assert ( + saved_data[0]["note_id"] + == "_learning about me/what I learnt about myself when dealing with ADHD.md" + ) From fe15b161281d830d98886468fd6e8a07d05894b5 Mon Sep 17 00:00:00 2001 From: Divyendu Dutta Date: Tue, 30 Dec 2025 22:37:11 +0530 Subject: [PATCH 11/21] [FIX] Add check for pytorch before setting device Set device to cuda or cpu based on presence of pytorch. Fixes CUDA related error on Github CI --- atlas/core/embedder/sentence_transformer/impl_encoder.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/atlas/core/embedder/sentence_transformer/impl_encoder.py b/atlas/core/embedder/sentence_transformer/impl_encoder.py index 8d0c301..699e442 100644 --- a/atlas/core/embedder/sentence_transformer/impl_encoder.py +++ b/atlas/core/embedder/sentence_transformer/impl_encoder.py @@ -1,5 +1,6 @@ from sentence_transformers import SentenceTransformer import numpy as np +import torch from atlas.core.embedder.base.base_encoder import BaseEncoder from atlas.core.embedder.config import EncoderConfig @@ -30,9 +31,10 @@ def load(self) -> None: if self.model is not None: return - self.model = SentenceTransformer( - self.config.model_name, device=self.config.device - ) + if self.config.device == "cuda": + device = "cuda" if torch.cuda.is_available() else "cpu" + + self.model = SentenceTransformer(self.config.model_name, device=device) LOGGER.info(f"Loaded Sentence Transformer model: {self.config.model_name}") def encode(self, texts: List[str]) -> np.ndarray: From 0d0a436ddbb3d60859a5bcf9d20cc70dd035ba44 Mon Sep 17 00:00:00 2001 From: Divyendu Dutta Date: Sat, 3 Jan 2026 18:06:08 +0530 Subject: [PATCH 12/21] [CHORE] Update architecture diagram Updated minor changes to architecture diagram --- Resources/Atlas_Architecture.drawio | 14 +++++++++++--- Resources/Atlas_Architecture.png | Bin 54356 -> 56271 bytes 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/Resources/Atlas_Architecture.drawio b/Resources/Atlas_Architecture.drawio index 103bab7..be9cd0e 100644 --- a/Resources/Atlas_Architecture.drawio +++ b/Resources/Atlas_Architecture.drawio @@ -80,8 +80,16 @@ - - + + + + + + + + + + @@ -95,7 +103,7 @@ - + diff --git a/Resources/Atlas_Architecture.png b/Resources/Atlas_Architecture.png index c5f047741ae722f608bdb1137841dd574a91475b..477759668e8ea050a56211dc4ab0928b08fe67ef 100644 GIT binary patch literal 56271 zcmdqJ1zeO{-Z&0JHwpquDk7*fNXGyYN(xE{(y71<-7%nY4MIgiDHR13B?Xig1q1{v zxyZ_(b-F4s3y&s%;&U4QBo^RKgYdTu0ROAQ9@$m4dPN|*L#ls_H z#>0adk`jX_oTgJUczAGCcV$C&CvRH^M;kmY5hctgE@44?gqu5;$Vo0?VGAUZ-`c^# z&eg)njo;bE9XtZ#7Irqy*cUVrz7CF#7F@zAC-?=y5RZX_vo*rY4Sdu(3;qZRg2y6? z;1d`S6UBV=785-Ro+u!Zjy481mg){*U6m6e;`}0F;4zQ7+F4BvE@36`+0o&W4fsdZ z#_AFRnxbUyif{r?RD?uD_`%ZXJ@qVXEnFRTFQII0_wzdt)Sfo3ZVm`%%)x{u`Gxr< zF~@PUvT(G)JQl({u|&99+qhyLfl~={i70akDu6Yh|3#Fr7y)0LEdVrVOz8lm@L{Jr zSYw{K2-(={>Yeve7PGMS(zMq`N?I8Fx*BdxCvR0(3#7d!!rI0WENtzKSwdJ)0^kG! z-`WTBZJ`Hx4!*df;9m1^u(ol-%*DRwjzBoNJ0O34XoYZgwz0yJ z75k!vt1CoH^Z~JtY!Qyw(_tSV!NGre1A3tMHzUU}ulp zSx68&;$(q)8T-J^-U6@|WV42Xu!yN%0*}FSAVn+dLTzw3&^U+_7pfCI$3YyBb8;kI?~hA4-T%3E_x|+3P=KEiWDKr zH~_V(fE{phu(pOo_ZO+el5vNw+Lmq(z+gE8`1LJ39D%77vv7jA!`ade`UiXm4@DHM z9X!EDJLsd1E5gdg%?&_^9RnP_YxrNoDLAU{W)z$$z%q*DFN`920t3qL!zfsQ{t^u> ziaDIy4u}0)HS~^BM(b$67Jq?;{(n^|zqy9SlIs76eh}2OaJ>XPEl6c(A>4uUh}M38 zwQ2ZA1Rg6-Sj!;#+ZhHSEJS~az+)Z#|CGSvBpfa7JJSEpxnvR$@BBab5Wl&=V@dU2 zDe!vkt{ztI9P==(rfUYm2#fpHwnL=<#7y{a+&RXyFhR{Pzm{zgi&w5n=2i{PShoPht=s%9Lq|9ce!RBrhXd2l$jk2BynuJ|qV#NSV+V(q|R zqbRWc;J??0LuXm5)aoJXJ7H?}kn{Oh=S*Y3!Mg6}-$Z^O0_qx>>E%m>)nFyl;i2r-SibMLJ zo8AA#$o|ebN?{z2;-u*JbB~3wiKjoO!EfpwD8jsRV~YHRZV@g&`|s75+iuaW+ygolfy+z$bJb%JwBz`HP}BZH zLGVAVX%(E^ywDMf>Q1Z*`Qd-HIBqxe?UqNJuEokD->+dOPF{bbFa;Y~{52j7HirE- z%cHl0JCqm1iX-?2Nh3N-2tMx!BlHC_f>3kqzgUJbis`>vhAllk&$$|?Adnh%I_Jd% zZTR?f^l-iY|IN&@-HpA|?eiBZZ=5^%OONrtk`w-swIQ$o+bb!!x>`WZ-#Gn)ggOz> z5#B$(3T`bY?_c^Dgm7gMNXUSd7r`ZR{?T>?%*y}c`og&K>p!+U)GHcTC1Iqz(ZC!<6#5cJ@{9cV_ci$ zZflIQwz#ArHiw8~ABfQ)P;kQVpU(k{U<0AQM!#Z9e*b1;jMK0GnkwD@0mJ%t>$8~j z%71Il5ElafD+K!=2z!6^vVXtg4ko7iH<9;Wqr`D>)qjY_e7{tAx0@z^BklbURoegR z3ZA8f8&nV!hxDx*<{~WMe-T9_!p*@QbM;q{Uqocr6VRIuVkHUi1Z#?S?oIu9^-n`X z^Iv1UagO3{;JdOER z)BkU1sAu7c?s9j43bs(?Rv%n~L|5$5wfTP^1#$irmV&>QbN(#^MEm3a!^LQvXTNg= zeYbCq9>raH#|&U6K-UE@*E=3|jhEoz(c+ytsc_aCf1-&~hwe&M@Q#l@6UQFmapLjX_n)jFZA7uOOzE(4` z*otb#oh3HY4UbpRB&t3}OBlX4bEGqPAg<2qgH+pArR3I@WJwS`}?MXv+LC))idg#-iwnLdfD&>DtuB^-s;;Ki;pD98RF~?s6?5N{D&fLxP=W6g* z6QSd&qa?Le6g(cnH9lx(2aBo}t4(mNJ?a>7R5}+`j;BCV8|AIZ<{|FXPH(3lrTx&< zkz=m!@j~E)B1cEW7f#JbhCB_(&(-R1=*hSf$13FTITg7& znnq%JC-#oDIF9>JpDCCec{UDu29xvXsNnN&UJ97onynL{i90H2TOXHYQx_vK*Y}>$ z?4}4iDRF{Vw~vru#Z6KeeXi}t1Lg0O*F%UX-f~9D>>a0WjWaUJwp6BMXlelOvrtP8 zMn%C65>k$Ec8EE57MjsswvA&|*l#P`Brr=V* zH>p>ri>irPk*AYH{~w&h^}gl=15tSm5~_(Et3{=VncDoLldrJN%f@bavW z$;4g2%uNgcX=E^K=7EmQOD|s^NeEbMTkA5tRqN6x?>bnMciu?9%sfWn&A0v}zbUf% zR5cq?Us?{e@r9Ura}L3ZNxJ&$=AMK;(Zh?R&dD4v&c8a=(9Y45x_#a#1f*r;`{#kcD5BqsB|5IYWsBvLH7XE$7tMiG!_Yv5m&o-P)-$t=#_orIh&St52eP%9A9 ziNRBT5l`u;KKhPMo3b=8=he#1l_Gn)UcktsjZ)^UjB{-@JINX~{t#H)tl$mx znfrdlW4!rX;Cqxg0sbE)_X!whWeQL&GR3!Mi;uYMX;hZaJVkh%^n8Nf)awl5q|qqY zKA}fd?DeN>8XVKlq&hQH+&OjU2$6G7Z~pc3M57dqD76mnkFBJ)X}wRa@Z+=;K9R2g z&`wL$_@gUz&!4(B31jr(GJF%~fd1=eTDCjx#9-qEfqCoWqo z-z2j|-l#&_?x*cc*)T8*I3dU6ikL$M3fEa|uCHX%kg&Szm`jU@E)P-Jp3Mp;F>&sC zbNATV<5*MQqfPQb;g<3TM7AX@f}F{5geNoncuKiB%=yxV;4qQdOEjsU9;uS>ztwwA z$V#e~;6MA}hZvzL-AQxlj&!AdPjdc=4{0UyaFzClnzacoRr`NfghxMezIda>k^4;S z$$&Af1PLKxO8Z#Xr_|%$u6d5CdhBn1==9mhE^*%)-1w_@$B0#X)v_*ojtx)Un;jNT zkp?XMW?;gUp67b-##x+}N5gs^vJ1iwsCb$j@H-5C8oJH}FmG60*ZmzBz+DCwVv(== zfE9|ZqEe>ID#O_ZEa$8MWf&DXpwnzB!N1G2f!$DY^k6eDk}mw?DeyCdG5i+}0Iu7U zYRrcAX6X)^rL;K$DM7<3!et|Wqy}RBW377q?a!(84o^rq5Y*efkn&93w@9hqhRDu{jj0i&OI4rf4_LBE;fuaLgG;KBd1v=MjN$g z_3%#uvued!yCtIixK66MlAo&cB>*~HfiNG@!*)a?j<)cRBYQ}fe?$hEjLI^$x_z%F zYcUH7U;-A;d!~Y@P+(lWzaH>5VAb(SwYKq^@$b}I{R25Q@_bqC|5%MEVYftvLV!v zZJi*odJs8iFkS7j%`2#8qVtqudENNNXb*?UBp5MD#)L#^KNJ-qGQ3?_egv=L_QDI} zf}HeJS--{0!^uIVISn_s)ME%kh;A_OS)QQv?+pp~c<+S8#_EC)X~k7KZ5bgXN=qli zKVL7RZQlepkwPJo8&5HCYygS7zqFXot-;NDC12=?aDL zF09_F%^GC5X%#6eCbQUTm2vf}Srwzm;)a5mmfXrqmR*f zpuLytRT}tnv zB&vE1bE8CR`SlrO&B3snc*ZDEW$3$sy28$0;1!n}06BHFz$ZzXUF|yYBBA~XqJ!Z5a zC|h7Aj&wS`9UG#LQuM=KlMee(y~;l~{Nh59SOW4b*^dVid8az+kJRKV;GyCv^G?Zi z_z)}kvJq7Lp{FIum34Tz{EA$`*(TG4&3i_63~z4;lrq_I=|tkaGv7#|ye20|aafko zaA*h(RVv=V`m`8viozr0Gu}lW_>FrtO%dlp&v9yO^d>+!gV;)C}gZeU616{q~jBj z9|b3l3j3%9yn?4hED2adLZ~4E0jDadKjNXX;44Aej^1Q&>L@AC1a6mFC zmYY}7!j)Sc*?%3XxdS+=frZL=I~vCk89}tl)7n%}-HO`Q!!W$tz zx!KLS`)KejG6_qfRV9GOAQn4SD7xN;Wr48X0FKHBOlvRwlxjEX;m=_}Qc;$WB3%5D zi%EZACc3MaZ$K+hLDS-DLw1%UAJi=0goajts)}kHu*4)((5SzD%)AUurH~3@ZP+#z zK`<==hrXevAYcDwfuA0ZHR-(#%H`2hPV)lZen-;sU^}q-NM;B*24LDEDS#sK(TnXf zCGN>OhgqJS2TThl`N)q&2OL4vS%f`3B|pGDk{zUw(D;B05l%eD4h=#vTLksh3mCej zn7|@%a4y=j5XdR4 zu>1$xL}-GhG0bE!&D}$H!K_lLPHKI8qCl?R;}}pek}(45V66}BFJa`O0_Ie^ zhj^}lAzc;~k_Yf%LPsMs-f@5I^}%krmN5n?OR&Xl%AvJ-4P-)0*x95FSn&PO=fmI{ zI$#uF)Wc-4Lv+ZKyWoRpC<`)kO>~Q!T~9f8ECt3&l!5hT*#{n|jCk$_4;pL-y6VA; zgvpEbo|xmHXJDqi1k+R^6Ni2s&`CLnQY2wMaNxm}za4rGo1_+Ve>FFyfu>mj48*4n zt@%#Ig(DAd5W8Dfgb)dB-B#z{A2od)N9Po{10gZ%A3MoU!MO5*(uEhuUKGhy;8aH41?5aSXvYrHByPMDkO|U}K#BMGi*)+WeeCNErJ+`3 z^*C$3QNtN|b9^w@v{0eV0*8f^L|8z3OvP0>*%@kM0Iz*)xk1_Lx0wd%>382rrDj4r zW__Htxo7r>d5{#8516Z(QUGuByut&4*ZY~p4n(y}j&dM!9=1H_;c2usMX5adNAN_$-m z)H2R@Bm-$Qa^AHzR5r~xVa{z>07rQbjb6}}@vVp;MlCNdAX~fAv_N_vsf3e1*%aex z**|GdZ(kNXeQRT#ZDp#pBS^pPA;^e97KSbI`cLA;44ZR#x{wAVU>Hb z(5?dz%(B@sl{#U&T~6B0Ar=h zlfkqAT7DciqJ+sMj$jt|1+LBm#<3|#6+oM<8kJ{bw3?1DSn#p@J8B*5&{c-4%UE#p z^uW=iPdKS#huXQ#)v@#fZfz*l6vXhWWYP_*pn^)6DUx`p=1V}iIMTj>E2yL#zq`n zkax)Eu(T^k0WhK?j>o{y(5bB)OWBPnOvHiG2kIm5n0>%*I4}_hN)f=p^R+K$yC4JB z!$lkm0Q|3agm!TNF5=)|gm~YDX#yu+fWfd4M^g~Q`_pmKmb-U@XeI?2rboqMI~>0q zanu6$?}1G9=Udy<--$S`f~=v1p`-q`(#A#{fQ@P(Gq}#q>4$R-cSRY3Al9qIqc*^z zBV|{V;R{7XUW-#0^Nk9mLl$GH(8LV^KM0rBzyN=dB5pel18%Ch(we5z88rW*nh7(u zeO!7IFjPU4(uFqDBN4%VJASM^G&Z0ggAV*^VuMR|hH9Py@DtdWiXcblFzW7cgR4Nb z>woU@>PZy40%})RHSddEachuFyDk2eqTO%oY1&(dv_!EKp7Fsio5=Qng6xg0R0K!| zQcs8%y)q5V*C=Ue+x*l6YG8$?XXd1Ng8ddd>)EXGT^!qAt$7w0bP23B_|*EucU4IR zRZ9%#tW5fLjk>&go}7=Z!R3PU-`kg442gs?K7VtA+}Z^4$9u-GGmliRNVHnAuRz?Q<$#?!VC&t*5oW z>Fw92dIPIaxhZz@3@?5|g%XXdXwsd8`%p$PP8`u4sPNb_ytW(XgBAhS%Txahc`&FW zga?k+ia+B{Kq?jv#TXxB+#n=<`TF}kAC& zj2>n-u@%fp^x!{mbnYV}P~;|)P)J5AanJY9B*N+H`*l6pzi#x=1TocE8RuUZdpzH2 z6?p~Z<2=hLDMM%o51NA96kK-fj<;ryTJe*zJSDRcy+!8lP!2C4aCOw;bLuHRB6Itm zuZ4KkSK*Z)k!ehJeou<;=E|^@&b7HKR&TTWPE;d7M&*Z0UtD+K+T@S8FVudIDTnuF zo+|!s!f_^c^4sJmEsuca4{2skoolTUPx?)SzIz?raG^nUmRSAi3&$;QP1g4Q_4UoA zva+5Y<;7EF>DoqBO0qF+F4-S35hdIR;3;}YO^O;cdQ}^bu`uvGKq1oqNN=+vT2(1WHSg_JjO0*kSLsFF@N9m~Gw_iA!q+>h{rbet ziRK@CPgk?JX+UMh{`zb4AKlwyVob8aL7^<>;4~=JSe`jmua$BEMxI%^dXYtC&0&MkN&;G^tJqMe8WX~RIdfTJa&||yB zPyH>w17jt4ZU9}9!*Z(n0la{)Q+tZgbGrM&+@XjY3Q~2-9M|@fXQa7e?N&(L{-+J!qobPz^3xa1KljY^R+oKu;LQE%a z7?Bc?ixuj21wPZCp0;d9XF7pI2SR2{TeTxQM!y_&S1%Z$dFi8WuVtb!EnCp~4~dyj zYAK#T^*ZvN=A4F0dqs!~X$P6@pFDE2(3Tthz@2=vKw!o<>)j-i*_+SY(jf`km7tsy;lKM!=oTu3AyM|c>u zVA1^yp!*@47qb7|N)^@l(ja_M@q?p%P{BBt&xi{ealCiF;M+Ob83_heu|e_Y~w`*9@Y3G~Dc% zX@U>qy%npfF?<@Uso{`3n0dzf)B)ds4_vXALuup+ALP6uf9w`zrPR;Tzrvo78_hjZ z?lKg5c)abMk81CwAN}u9Zr58J)}S7Z73KSm!^NCgF;0lA)u)Mh8P1VLqF=R#Tk;&f z^!KpZN>bYCE4S0AH)uQWdpNz`r5?oC#%^UT^KIZ`#0|0QN#|uVXGH_~BP2`{S{(E# zr!CC3R$G*Er-OVB#0HQ(xHEio+Gl;kPUYY*RUNQ9(gpGq^+YnmgwuMwm$>-b$@u5g z6>na9w{~H18a7~?a5HwY{_NmJRIoz6=B8#^iKFR@mUUK6y!v|4_JdT-)sD=?Wj&xE z(|0F%JvGQ6S5Vg530;*C=Im7mafohkkjYAz^ka7+Rb~FS-of4b?GpARm&)_caj2Gl zvyuJwh;=M$(Bjs*RlO8rc=Q9_$)>=Iv0`eD>NXAXTkFd=U|-YthaAE@+8+lrU*f9$ ztd&oh`GNBj+cnp(Y3z?wBO|OHA#UE4*_%0P{7hSREbai^@{O|P8#A45jYRxaVa)F3 z2?H^@QqJ?iErYp(OfJsIh^+AFF;{07M>`yqMde>jR^l2n)=X9cmd9=oq0HGRiTX{~9rO zOaQce9K`bg1u*?L(WW94$8GkebCtg=d?ouY6;yv*bemrw7B=vRuLP+x0#`eNd? zf30qqr$h3rzqAI&<(!GIHhFOB58_n(O;6{d2NY9K0%V_if?_h}G7cr(J=s`@{2u2b zBPm`pz+_jJJ>2@ls`gU;t#qXewR!^gd1CW_+{;Owki5g!`1%AW-<1=%=B|9p^r@o4 zZxhwKG5NKxxkgm!#jWJACsd`qy^E(7HKkwe?I5*YuR^S=aHy$A5l~c}y^thRrS_20 z<7lOy*jnA@1+Jd27eElzwZQ+bdm^rTKCBJg>T+zBV{rt%EN z#rId(@TvFq8XA_Rt5+NG|rNerSj@^%0 zSiPU0W^;gM*l6rH<6Jd+kiYpU>CTv#+Q+^!%whh{$JKp$RvNte>ncL<5~|Tf(MIIk zKjPi8b-%Hz$MT1>-K1j_n{%t?JyrkEy6SMICPwEh*)Co5!Fc-Z$I8RR17-V4rUMY> z&yIb${yIx|L{P?74k_tTcC8O@BMTto(rM_5Ec-bWr9T%4> z_xTAi7)3@}8^1|Sww6(h|09iH6bIqBzhB*Zu1w8vGWS^-8!<}8w$KIZtO2u3wn~Ho`Sq?jgAskoCZW22--vNwL9~`(xihp^dd*g6<8TnHy&7 zhi0f+G{w8v)kj zf=!mw*CgZhsIj^?DI6r)?_9d_z5L2PcD4BL_sZBh9vvc$9xXmy_RbBI_zgy0_x7iq ze_i;@y4j~fnSs&F1bHS+EBjvC#Qjy1)Et4*7n)0jU&4H8)}Ob<#jlY;!gwfln9~}u zr*}D5o^3EbsV&PaY|%#0>WbS{ilFYuB^&WPj>7U>mN>q0pUTR^ZC=mnMc|*u^Q=`g z-h2$kW3G5fKga>El3F38Af;?_epVNlURTdn)m5YIk@kjnQ~;le4fbIbxpzZqs&GJY zj1dKg9a;XwCZa+5hahpYX4jsYsrTke`jst^8mb^#Hm?ZNc|vDPw}r>(Z|_<55hi0k zRZc)dSwbp|l+zFLt0FqPsC0;(?KQVVVs)F-wGgq-o-uj1@*kH`OdKD|PSx;WTRA-I z5Qe}%m8UKLOg>Lf?EbQOU6^gH?cCZA4}r$#o_=ol?l$? zL1!Oxeez6_<7Oq{i#|*oGzo(tQEk*R%aL+3aE0chdKVfOstBcgl3btTdZa9@ZF|7M%`FeU27y!tUm^=oj3OfqCvs@$-I z2WeEcmR!IiKHHA0@RP46dWfp3Go5uJCOg~9*Lcd_DKWp=8(`V2^};E9@RPt|Wsk_> zspQXbZPXozC z7_p=FwWFnZvCZRr4zY+)?`M*eQN-g``K+ZUtjOoMfW@}&b@U6-ojB|A~8a8yR|MDZ1#Y3XXF404O$nMEe-~i}F zx+a)q+-dZj&7C}RHD3)ZWRr8Pn2K5KvQe(>d&e=iQr$4FahJ+&`4P?yOXjD(Yu$%_ zFv#RTKFMCg{Yl?5V=y~twaU=2@kw5IwvE{rn)U~>wYjpB0v+7lOFr6WxwcgAUE@|C zse@F{fQ0mEfLAO0&0F6U!=K(-AE-O4wO52=c`>$lp31dW-K5Q_4H5I&e9)Lsw`ASX zxNWx0pT9m`Tl#QqHeyko{Z!kmqsS}OUgn->U47i1LW9@^-=mrVnmupM zL`V^STqcf~i$P51^h~)X&4ugV^VMQ~(l|>iT*_+OGx+VaBWF~FyJ_h`&YB9J_=ajn z)>#RIQ_&CjJ67=OH$R{Cnq3uI=r0~qwX5#D7d$+gxn5{!7rrDGW206qK)23CT3<#* z75O2=394Xw>M}#Qrdh7?EBo~*zC>%V^}owgD#cGp>r}7(s#&_f_wCKm$H03op%5q* z6b!%ZM95p{6%WVjIW$KlsNUXde5iH~d2IG*W8Rsm(Tv#SvFubGeVLW3gi*T?zhy)O+7Vv8 z`W3O5uv^(l8;gajArUm38rKFDBgzRFeb+-9^s-i}d_ew2^vtPfu5-eRB^;3_;i0}? z(^#KK%_5Jtr`hKxXWYLo5M|4YEGA*5s^AK-AglmZ*r$GinSYVwxoT0u{WjtH?p~IG z7z&tKo72bA@13a|V{XZA@%hVs@AJCyw9x&&pjuRUCdC4{UsQTVP)&j-0dhFb?w-FXMlaf)i&V?wyMwfEHM`ubOp&N)!nlC&=R zK7pe8{K~|A-=`a66w0d#L7zQi-!n2zBs@DI)zINi?RJSE7kQOVkfW(8gVnbEYrD3H z&#Q)jh9TSbEU0MH1f}JFZa&_JUwX~PR;hiz2uLkjKwdt2pK*rbEubIIg(x; z{eB+rXp&;$lem^zyB9HVSy050_zV}6@^5W%^Nf=0~ z`h0W}t1tZ!bC9+|gn3yq&rvQj{#9k(dFS`$0fkSyU(0tcW`HaV*FK~nIS(zRnD<^N z=NrIVUXO1pmWuHAIplHgh?ZdRjV*VDYQ4=}u#N@FV zy2~bbd)-s`0;OiEm=QOJE)5YV`0=*U6j%wKOIrLOa#uTeKiSP=L|NW=gHW>w6 zdKho`%p0z-xofWKF}!3T((Gfn;Wu3(4va>EQvH*@wnL7YcO}?;NUgC(rrvnw7?@AZ7#z)IQ`b_LCR=+Z>15GFtlYV!JN|mvL=xT1 zVveNsDHewlE+xpb_{C)0(e7ta8@72d8Qte^VSGBlZt#x%4Yxl$4Y{*}8Vb(iU&{*@ z%gubMEuE*z?5Mp~x9Bta`7tV9V0ZMKM z+hJQX=trIeuVITDsE441gQxaZ4z`vI3bJyTLNJh#6JXj`xDcjPhdM;j>V<_!C#40z z=o>)TS%wbq^!>%J$Ac*A6f>Hh3BY|LJB=bWZCYW7gH3p`&0Z zx?fO=6fBhD2Fu@dxe30zNZ^O>hgl5J12}arL1J}qb*5DkQg*?zyIYP>;8xJVQ-x>Y zs97?JLpb<=4yPZv27;BOtRg+4RfFo^*E>#6}mR-fxfm~H6{o;dPsU*A`@k&^MluZDNO=lF;`O3 zV5of_ys=$k!W4~apd$1->z#*7HJdw7p^-QL8Vr?6;Ip7ZYV_H$f4U&+Bs-4sbKNd$ zONPkwO8bT$}bW5@H# z+bu4OpyDO821Q{oNGDCjt$mtVFd?n=7U2C!t+) zQsCS~j-Wr|9@NNrIPN_rtBN@Q2MufybP~*hPN##7s@0|20swT{Vf;0yeP#k6Ig94) zm0fVy&?7Y<4itd+U9l@*2g&of!B$XV?ljcsF(1~7zcWP1XHJG@BTqmY`?M!cJA376 zm&c%qKo0ooK~`8p#5N0bF(M-|2oQy?1@7Uh+dkRKzAS8Uc~A@R)fDwLR-9<}Q%GQY zy;hCEP!M~NDh9>yEm8?xEWt0fMteSsH-ud`r8={jDjZk^b_-qKsySqtKobX;(b^4h{A*To$P_~V%b;p|oVCx%# zD{>zsn*@f72hOG>1I02w_sMI5oL;+}ZToZ(bohYc^t~Ky%0#n#=F>RA!KeomP>jUB zg-_s;ge<$c1-L^>fSG0Ad9`eC_1f=dKTw{464C;y?JmuG1+{lX5iM5)U_q(ii+5g- z8sfU7u{)-WfT8Pwghii%mUf!ju$q<*jNL1*)jPBH%MH>LxG7YepRQ@Wy^Ud<;7#n~ z@yW87Cf(RofyaXL=2+8)xspNHaKlgMMT6J@6KZXmOnf81O-!hu8L}S1TrgK}B{&X}Q8GOGCI6t7;V8381TK;eV@?-{c>ZNQ!BHSb7_3oG5$lcEZ z!BO>b+GPOni4N?`0v*25s$j?`;xXqgm6p;4%ZCbnlpP+Vi?(LIXu12}ER62zjShPk_a6L*kR$2ax_$V)XL@Xw&E5SAB5tjRwOlOy_&Q>S*SvJTKN@dEuXt|{I>f*-`+b0dJhIQW=pm_t=J$8#s0?+fynAGu433R5 zB`@6FHFFi*^AaSCx(^Kvpq{&&dXM6YJPJ6%nVD~??<#SK! zJ&zANOA^dxZ{cd>OKQKVZn{999s!cnY156amo#%<*jL<_+s2ZKR--*PKdP7QPbwW9 z%{wq+ke``hAhxQwEt{xv(Eaf?f>pYIBCKe-R62rZO>+zMqcGUPQ}^$Es=-@09cf#q z7cS*cmX_d3ZglsZMp&M_tyvSv-pt(G6W)o64+FArxqxFf9nZzpLuT7H=kCdj+1j~A zB`<2Tw$Xt?=AYqmvqv5!l0 zC5YY>7p^P}#hS4}H=ylK3Mz&4zB4b77S&B!rC{JCjg*;Ha}=+u+0+aAULQ{<{=zW( z5dX?RlM)TppoqHF`tpzLvZo~-h%(vyC6HLV3a;9b@UK6;^BJ^}FpS8r90pgV^K%b3 zxqwcJ_iY^YlIfI2EQq2lLe9H86wYhf7RBx#U#Pi znNO&Aq9W&>eRlHe^F3Rl_3OR$aj~|HsWP)ZC4F1nv~@X&MkItV?~T8?8uZsk*p>d!Fi-R!Yx?fC)#&EZnQ28ygM`r@U>EWkH+JD+W|j3O;IonJg#EVh}5X!0b`ESy|4?S6*h!-COZX zB>5MRRYZN6Wos>cOPQ+yVquvZqb=YDeHE)Lf5O60ZgbtRd*)V9CUhHq z@A#m0qQnWCZzg+1h4=2X5#%fAVQ*ElG17vj&(lo7hm%qZyfIs?_?|NO(Q7kX=hF9_`xDDpuE*+ zx@A;2_dt4pXqiA;hPFHC-CAXRPyGG@`MEE4mU6j!NLl>XDiGBREn6+)E`F;6(@7ge z5=y85#{ip4y$|J_)YWG{U)d~tRP}fQUp)lxn-dLB7UD`i>1o=86zUDRi541)vNcps zI2Y;lnUJ=z$c`>X&Cmo~X82Z!29_&ZvN85U{rB?MGA~7o`%(#_By|l}}0Y-nt6E@ehgbaaM{8eDu*nVze=x&lIF- z5I-xampAe4z90dk7z{q(IxCh7bBrF(*H#j4&eWJg=a#pe(OIG?p8l zw__PWpbdUXOTE%C=gP+YSqfRnFR_&@lR0&VoK3hCNk&u>;sq}g2 zjJ&@D$pzgl!b3OUkj$)7@h3){OD4d;-1IbK!pc62zke?hL&5cBMI291F zshuk7JW`?U_L(|&k9X6+`pR4-IIxnwVZNi0N%LChW~XZ%-w%b@Jk_F%J7yQc@CX_& za|HjGT10VqM-ZYY76ZCGTCRZmy*%BwE1c-sZZFg(f|jf{%gR7tC0gC}p_KPkhTex; z>6H=W!wJ4x{`URV+N<8nqtE4E_in?jO{BqL;Z4^2&_OAX-qJg+QC;U zI8K3rzq^DeWoVF}TrcNimv5a9t>t4UBuiJP4Fwk}PYNEARlDuMX7VAFHA_fthp(RVB-m=|q3wKDk!e12iN4IMfBJoxx-GM8Uk)9t(bsU}$~TLl`6))&z ziYknGa*knt=LL?EA#07@xyOVGp+N2l94S^hE<*4utTLB*c^^63v$*_Q)lQ_TdEt}A zw#-BJOX(}B@5A6ZS}DDc%vN~>tokjJR#VS%zY=nts+mFEkfY5Rsw~~Ac|?89RbdZVjX9^|g18D~T_Dspnu433!MI{bseMr!BkhSrCI;mQ=X2 zNNZDi^bpP_qeH$^_&1I>$)XCneUF<2Z7kePP8hV~hVIfXU#p&4cq_{VqMTE2aksjl z;A4ZMmucUK5`Jk#Po9GwhtL|zh_?Q!8a?!D2sSE&HXnvN0!tUtWR4EyhQG;E*gTq+ zps3GvCc=O3fLCu=1iE2-@Ut0i!MuDUekOFQ(MuGo%VXz z-{<2s;tpDw9kM>Bn_rgqu|BM}in;{2*FR^9QqJJl0S9Q9C<6TQ3UX^>DGX{ghJG=l z{@#h>Q=%7YMCd}|_kcS?w#5sU@2~H3ksdMtKPUkGut1^xuwnqXg4B0QmF-$CkL81a zpsd8%yvo}XU#T`0h`n8j5 z^P$CKi(dk3%nF<@NDM|PS*|@TE9eRI>s*4ac9W{mCj3DpH6QlN9T@lnq6yF}O|N18 z?vFhdpbyN&7O!XOPF!}>)e53@sNcCc(~LdN0xrkcNq0npG*Whv>3JV$sG*|c)}jG*-@a! zrI9Fgf`61wU_?Ma@4au@ZQK<%OemTHzfa_d{*f2~o$+|8FKODDXL~J^D3(rFbu*eE z)Ar-}864j-7zRC77&{BL@;L;IW9?+#FD>Nb$w7D)(**jB76KPZ6+;t%K)n@v) zvN2b{+pJOVy-829c(z)&$PN5?1YwecFq&2j72kmbk{k#}U%-pVZF%%ag!r93r z&O+i@4r%~yx6EWOkNMle!H+FS=DVO8$Im+qRE7WWByz6f_z`tFWZ$b=vHJ$B8EU9V z&`B=)^?>TdWFzWMQ-vTksI@Ft;`5>wFHPZe>6)c`=+Prj63?IAI9rcwBx`uCm5`N> z)7?rG^d*GJoLATOzw=$$lBxT_w<>vYtBw;+8@!)l^k5|DA)*5Jt*#ZYMact;yqp^a zej$NcX08vI;K%|s!5jSm58<>jYX?8No38=7F(yxzt2htzJ_sKbBcDBgico5VWaA*7 z;+8jYWo+5{koP50aC0qjKf}b04(_-%INP|)qle5i*JPl;oR!w&V6DZ0#~;$0DJ3!` zZfRn1Lkl>H3|~%l*(dK+qovQ{z_OT&l;K&_RU&U{`~p(z(1`VW1o)RL4j2Ke>fepO zklx5gD^rB>AIol-q?Iwhs;r$Uf1&~pJj%E`YC`~sK55(>B1ihf}z5xuoXwJQqm7r@DERY zr562W=e9B%qHFmSnG>Tv7j8FWgry_&S}=2Ze%1l+3+%tl6?qcQ z2ja6@E)Yh}p)54~E#~}CGjCj{>n^n!My6+~RyMsA85P_BH%b>2OJ@ugTp5` zDO;RvZv+>8AJMd3@Q%F-lPaZ#w)9)$Xsd<~*^0UV@6QD62&aThfC@|?81I~2D8(%A zYSLy(r9kd~CQ&V^23aP*y+Aw=UqH+waJ0l|2+Yn`2)&+=Lxen`^;?3Tv>d)_T$44~ z<9o1dFM-wP>)p&QMY|yUm3NNO8qu)E#OdpV8jN$S@3$VZZt*-FTEw* z!>x)&>%uT$a6=h%S?%|=z`8pa11CsFalD9rw z2jkQtZ{7{Zm%uOtM8~u|GWHUMt})^b1t7?Wv>n6BAi-dlvWr9ruguAt7W6KLN6Fi@_3eIL z$DZJ@`NjZ&a5nae1uM^uKp6-YV0u2-;#sLq9hw3mgiY=1c;*?wIvrl4e)Ac zXGi<~rGw@N)}(Vk*OGF8>f_A^Vb{3 zsJfok&ejV4b=LWU+syVt6_2igKa{PFF?AzMW?(y`qHq*x3QYj;OF{_!+8 zDhPFl#pw|1p}E0Km_GZaEX%U9^6*i8KwV42qDeAHx|~Ln(Nms)pVH!}x=OmA@&J@K zPP33VeTTa$q?MW9PgO7rKgBsZ@~tw4a>n@_%Z2OMb}yb<--##g1LY^}dPwuX){DR-ZRY zS`{W#OpUsK&4*6--LYy-KXM^6qg|Rydb}mgsL0~LgXcOXqyP21*;(iyZxup`HYw)#%A zo8)*`fvnud+H7mmo0QS{j9f9o@2@zbufWqV!c|-zJitpZVL|jC0JSSTJz{`8cP-}k z%ahN=Exvh1U%qK&%aNE5ne8+JJI3&4Aa$FDf4h5p{Re0v4O~p_=E|Ouu%{rLk~EtoOzm zZsTE3hUT$`nn4U9sSAnw$i_{~$e4XjjHQx6u6NdHMC!812{B%*1*jYnc*V($qkjEI zBPD+sgcLc6Epa(Bl|m$wmpfaH?mOQ-)>1e8iRHL9ef%fmBHLsveR=)#%6;~BK_y26$%AZ>o!1Hl0I zfv|mOu6BWvr2~M)CGe`!G<3d__E)VrdxqM-gY=3x&%5JOUKJj<9e4|%I}*f z)R9}HahuQucGsM1A2Ujo{IeKS&~1NG$ZP#-hzCjWE1;iCJ23rI6GAtMf+(+Q|e zetx&UOY(MafqM)O`Dz^UIkLQQz3cEg*bZ*7C}*o%OPvXQBtO>qS-u6`I15Vrd>M?o zsz5b;z5`NcRn2|5 z+|x{y;oV$ZrF~o{1B1!MR^Xst%xBOHRX#aNO8uexiMrhMwX`GnZ_m?!V}W%wu@6g_lPzZ+i7bbA8lYD5f|H zu1`7{M{6$hc&z}XJD1y9KQ~3xEqoHif~p7BYIfPY+*EGH!3~kkGgWu7jW?`V%GbPT zx{O_r(JHNHwjYS&=$my3MhR{=vT-Y_*4XXxj)zBvr3mf-o7CrZ(k2(wBleN-w%WrO z@7>ntPjye$r(Ds%WgSq+Ho|yQI6_PDiKrOe<(#8GHW8)UuG{WKn}0Djf&WF*+gzzf z6CL=VPj4$@$AgZ<&S!#1QP&+uuiw=#DcVx~d|gEvL8N6osQ>nx@%fb>_HnV(ajNno z4RmLSM`Eu{V$a@PeWi|K%W>tKe@#TQ_$7Ay?spWqkX8yV(e2}96EQy4vWnwAwj8<^ z?YoIPfIQ3z{4GgQ*mKB_Oa62W^}w8t&>+rzec2EME3l{M{EpZ)m`ctlcO1OT$q44O zTb4YkBodMZZ%0`O#-7YPqmaJJ8{ial5fo0Sp;q5-{367tNdDwrInOx~X91+2_R6g` zKUv$3;&L-zRKBWR$W&uvB*a=|8JC}dvbfVi=;*g#X7Ty86?&eEOmuzNv!UDwt;0oK7%n9V>q6(H@TCFRD5{fTvm8GJX5W3#_w9_ zkz&e1g6YUXagOw#)>*H@(l4;{z57EdT#=&Y70jN5`!!DUD6*&SL^%gjSSMbaLe{^e z(`RpGJ#<_TG7VRbGx^!Y{<&&_lsr{Wa`@dj)u%5X*yy`DpS5H63o`XSV<+DPP-cQQ z`9~RA5+0@1E|~XaQLThseYR%ugMOdM1#~h6)fIojiO7IB3%1VxEH^>XJD)AW1GV0q4c9+UwDJc6}_KV;TGFNw8+`bB~K7K5z?M zyLB{q)`d!BxWTEw`2FC>FK#tACx?0pg{5MAn0JG>-D!U7Zr8=@6K9M=p5Kkh7SrBt-$%7i(`BaCo4-8L(%tf(0UG2J>#>n>rhOm4-EoP`!a6rg{+^OL;D?=c z!mEv3vj>a5@Np4v3rMnMtw%}3IdclIS>CfA>?WFZbjZfHxK;|pPZLRoVax()#;F`t z-aJ1$t`+4Xv!Q4&x@Q^WAFRrwH~fNrWZjyyYhzvIwQ(jlcBH&|5VYE_#Kt^nFr>DNG$tjB9?-ESOznC2%-v;Wm`aH9tFhU_{A4i zIAtZ{mSS>|GRG%8KN=6DWFVjSo;fKO=Iz@ucArb*6^bTqJkc-HJWFmq;Z>XbXi^3O zIbzt&x9}?KivNt)eCT-4Wjo+!BA-9bGJ6EP2vvFftJA<5?7nlLkHUlU(SunQ z?JOd>!|#YA^;0ots?=EvNhF(ePZSwr-*uy25P5T&E7Jne)b8;Cjit{SUjUAyxKD7MsM1w-^VU4+V zenxQi-#(h`e($h9`qRP;*ulNStqNs0K*4Tr!h3(JByPun7dDYDO%H$dGCn$5eYiJ` ziZxK=5YLeufJemc_5 zkOGjeJjQm>BTzlbL-^XfGCxXEwDg!LgL#R~){|x+1?(>7-Gz~=h9ixMHm}|Gn~%4B z#u_m7YgIWoNZCOmF zf|MO5!H9(!rIp3m$jw`_ze-O0`YlPtRilLQ#7Uco6MNsvvwsZ4@T7?S1LK!Pn9Y2N zG7f}Q_au2|Sxa^(w_4(qNu>UF=p?aiv|mTd@o_crH{%KGg#!!F<=gk&h{K9cpRTy=R|=F`MNPWw5U=1#C#KA3opC zo?T2Jt#kmt5c5C3Fp41Llt6cpZWWM^pJp&aj%H5HLpIAVgCSzDgv^4uAx@P#DPtPM zCTJdY+G*vx-UgArJ7k^=(~sRjO8yX8rPJ!U(+9}eU;cNQ?7>m%Ke3_S;9T?AS0vXA zUF&d3Vp)*tH0C`7q@(wdx@?RtpUEUCVGcvNI)%wTvP@D%V<6O28cn`zJ|vOB-cn}4 zcw8M2I?;|sHuVW`9q%e?zwOah=+`TH_%gUV0(=0OugVe^K7)$4fhjdbM3$ zfc|z)N#Gs2r~{0m-}Wd{O$l>#|6WekS3?GNUIq2`Ss6HwN%z$kVVmiF4Gh-p3;4mq z+ds(C)1D88oF}!)Mr!;GB9_S-UBKGoTDw@(#Z9rLSgqs2p4?bD_qmQ3DLZicoLbD_3GCGvMY|Xox>)0i}K6@s;P(A z)_7n28IHU_aa-h?4i@fa!|EwLm@Y}IY8pE zE%vtUf%TnJqU)talpDXh*TSuf$yooJbl+I*MQ%(X6^jT%Ro-s0eg4@niLp6*w*Ej< zkX~XpSH2-vyn%^W2i+D@uA9su z@TLye>E;S)7+uNYoPkvEw)=OA3XYXbTeN#vfKc26A&nIP zcuA1A{jz^J9HV#|v39c*W)~cIO}tCIY1t9QKA8pPmpy4~fFS&e>m#}Kov1U_9Ws$G z121!&U<@I4(+(6c59eVNzv_b|#Mn|d-7x@+4NCPDeV)vVpra*qd zdwF&!zWVX4lNpB|WnW>w(ePInyUYaK`Xs=hcaO{fH&~Yq0-Yb|)y%riOD{7(;NGRs*Z`XAN=C?PK+e|{muh=n2w%*Tau|zR?Lr zZ+C4N5nlTIOkw4{0u>-SQ;D0_&?v0+B1=qBv#*^>ABNV#k*(-7OjF$bjvt6F)K5q< zk496FJ>xbX=26S~10#l0_xFP^kmFK?5{fYjq+h1u-B;pU4}WnPukzO8Vv*hD7H0DG zj57&O>j2PDE>GPHj3VnwXT~k?aoqY`dvdlX*kC}dw$?5rDJYJ&U`jO3 zT5cyJiOHVvgD)wSHCfQcrc*VthZcJ~om3py=%G+}0^SU;9#9=NwdlUF*!#{`y+#&gGu$>7%3u5>~Le#-a0( z?U}xY5+zmS09>za`xn>r0N(!>Sw8!O3dBMIWX`r#{YbgK_8qr&F{j6q`YW6d-Z+4s zm|mu%;HYE2XCk?DpVC*z+!km+e{ z!)%)zj&N}-f2nm9aT(FXQ9;yD#2MfTg<$@ZS#G zbFxk?JoRVFHl=_`<}D=d@ER4;A7YbW2N4+P?o;uBoFza+N#K>0YN=zm!M-c8&pixA zHu;1u(9v%qt&|qzO0WX+{HW=tQ&^-6PmS$TA8XNpuGOx^tj|ha7k=JQW8bZQ$UUa< z6^z3OR1j}NcexkWph@n9<#}+QJ0NAL7x8Dx)P2FUx7nT0+`n*R!`kE@aIq6Fo>g<~ z%WM&(N##y2cn15R-Tl%Qh{x1?{kJLrTUULd1M6+!VeJ{OMr9Q6BL$Hky{<-2f-f0Z3shL_ zc-U*O{==OOHS;3iKd9HKv4vU|>9wg6_7R!Q-Q&

TQWAT9?#3W%0B(qo7ky z6#SMyW;FGo?rY`lG+@I3?(^+vtU(6mUMF~4g=g+P?y36QI0)@dDP8%M60Ac*x?s-h zXe{W9B1uCWlwB>FKXn!oG@;Mx;$H#_rSM)}Ve=XvQ%Kn(S~V`0$!$G`lTc6B{sa~< z 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 literal 54356 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 From 55c140786a3b12acb36eceae1a4c89cb5d766dc6 Mon Sep 17 00:00:00 2001 From: Divyendu Dutta Date: Sat, 3 Jan 2026 18:07:21 +0530 Subject: [PATCH 13/21] [CHORE] Add FAISS index file to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 39b8837..c8ab742 100644 --- a/.gitignore +++ b/.gitignore @@ -215,3 +215,4 @@ logs/ # Resources not needed to checkin Resources/*.json +Resources/*.faiss From cf2ef1059fecd9aba2819a1c2cfc134736c5bd7e Mon Sep 17 00:00:00 2001 From: Divyendu Dutta Date: Sat, 3 Jan 2026 18:08:58 +0530 Subject: [PATCH 14/21] [FEAT] Add FAISS dependency Added FAISS library as dependency to be installed via conda Changed numpy to be installed via conda instead of pip because otherwise numpy pip version does not play well with FAISS conda --- environment.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/environment.yml b/environment.yml index 7be7be0..12981f2 100644 --- a/environment.yml +++ b/environment.yml @@ -5,6 +5,8 @@ channels: dependencies: # Python version - python=3.11 + - faiss-gpu + - numpy # Hugging Face libraries and pytorch - pip - pip: From 21b92bb421f57e52f8424adf0b2725d510cc7af3 Mon Sep 17 00:00:00 2001 From: Divyendu Dutta Date: Sat, 3 Jan 2026 18:11:42 +0530 Subject: [PATCH 15/21] [CHORE] Minor changes Added missing docstrings and logging statements --- atlas/core/chunker/base_chunker.py | 2 ++ atlas/core/embedder/base/base_embedder.py | 18 ++++++++++++++++-- atlas/core/ingester/base_file_processor.py | 2 ++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/atlas/core/chunker/base_chunker.py b/atlas/core/chunker/base_chunker.py index 5cfbb04..b5b8b67 100644 --- a/atlas/core/chunker/base_chunker.py +++ b/atlas/core/chunker/base_chunker.py @@ -64,12 +64,14 @@ def save_chunked_data(self, chunked_data: List[Dict]) -> None: Args: chunked_data (List[Dict]): The chunked data to be saved. """ + self.output_path.parent.mkdir(parents=True, exist_ok=True) tmp_path = self.output_path.with_suffix(".tmp") with tmp_path.open("w", encoding="utf-8") as f: json.dump(chunked_data, f, indent=2, ensure_ascii=False) tmp_path.replace(self.output_path) + LOGGER.info(f"Chunks saved successfully to {str(self.output_path)}") def chunk(self) -> None: """ diff --git a/atlas/core/embedder/base/base_embedder.py b/atlas/core/embedder/base/base_embedder.py index 4765b68..ae9b21b 100644 --- a/atlas/core/embedder/base/base_embedder.py +++ b/atlas/core/embedder/base/base_embedder.py @@ -30,7 +30,13 @@ def __init__( self.load_encoder() def read_chunk_data(self) -> List[Dict] | None: - """Load chunk data to be embedded.""" + """ + Load chunk data to be embedded. + + Args: + List[Dict]: List of chunk dictionaries to be embedded. + """ + LOGGER.info(f"Loading chunk data from {self.chunk_data_path}") try: with self.chunk_data_path.open("r", encoding="utf-8") as f: @@ -65,17 +71,25 @@ def embed_chunks(self, chunks: List[Dict]) -> List[Dict]: Args: chunks (List[Dict]): List of chunk dictionaries to be embedded. + + Returns: + List[Dict]: List of chunk dictionaries with added embeddings. """ pass def save_embedded_chunks(self, embedded_chunks: List[Dict]) -> None: """ Save the embedded chunks to a suitable format for later use. + + Args: + embedded_chunks (List[Dict]): List of chunk dictionaries with added embeddings. """ + + self.output_path.parent.mkdir(parents=True, exist_ok=True) tmp_path = self.output_path.with_suffix(".tmp") with tmp_path.open("w", encoding="utf-8") as f: json.dump(embedded_chunks, f, indent=2, ensure_ascii=False) tmp_path.replace(self.output_path) - LOGGER.info("Embedded chunks saved successfully.") + LOGGER.info(f"Embedded chunks saved successfully to {str(self.output_path)}") diff --git a/atlas/core/ingester/base_file_processor.py b/atlas/core/ingester/base_file_processor.py index c0e71ba..da75748 100644 --- a/atlas/core/ingester/base_file_processor.py +++ b/atlas/core/ingester/base_file_processor.py @@ -49,12 +49,14 @@ def save_processed_data(self, processed_data: List[Dict]) -> None: Args: processed_data (list[dict]): The list of parsed notes metadata. """ + self.output_path.parent.mkdir(parents=True, exist_ok=True) tmp_path = self.output_path.with_suffix(".tmp") with tmp_path.open("w", encoding="utf-8") as f: json.dump(processed_data, f, indent=2, ensure_ascii=False) tmp_path.replace(self.output_path) + LOGGER.info(f"Processed data successfully to {str(self.output_path)}") def ingest(self) -> None: """ From 4a0bf03f83d582d307b430d5f08db13544e08354 Mon Sep 17 00:00:00 2001 From: Divyendu Dutta Date: Sat, 3 Jan 2026 18:13:04 +0530 Subject: [PATCH 16/21] [DOC] Update main readme and add readme for indexer module --- README.md | 52 +++++++++++++++++++++++------------- atlas/core/indexer/README.md | 29 ++++++++++++++++++++ 2 files changed, 63 insertions(+), 18 deletions(-) create mode 100644 atlas/core/indexer/README.md diff --git a/README.md b/README.md index 4404f4a..762d199 100644 --- a/README.md +++ b/README.md @@ -39,9 +39,7 @@ RAG or Retrieval Augmented Generation is a technique used to retrieve external k RAG is good because: - Reduces hallucinations - - Enables citations - - Keeps answers faithful to source material #### Chunking @@ -124,30 +122,28 @@ Before committing changes run `pre-commit run --all-files` or `pre-commit run -- Run `python .\atlas\core\ingest\obsidian_vault_processor.py` -This will generate the `obsidian_index.json` in `/Resources` folder. This json file contains the processed data after ingesting and processing the notes from the obsidian vault. - -See architecture section for structure of this json. +In the above script, modify +- `obsidian_vault_path` to point to your obsidian vault's root folder ie, the folder containing `.obsidian` folder +- `obsidian_index_path` to specify where the `obsidian_index.json` will be saved. This json file contains the processed data after ingesting and processing the notes from the obsidian vault. See [architecture](#architecture) section for the structure of this json. ### Structural Chunker Module Run `python .\atlas\core\chunker\structural_chunker.py` -This will generate the `chunked_data.json` in `/Resources` folder. This json file contains the chunks generated from the notes processed by the "Obsidian Vault Processor" module. - -See `README` in `atlas/core/chunker` for structure of this json. +In the above script, modify +- `processed_data_path` to specify where the `obsidian_index.json` is present +- `output_path` to specify where the `chunked_data.json` will be saved. This json file contains the chunks generated from the notes processed by the "Obsidian Vault Processor" module. See [`README` in `atlas/core/chunker`](atlas/core/chunker/README.md) for structure of this json. +- `max_words` to set what determines the size of chunks created. This should be changed primarily based on the token limit of the encoding model and context size of the LLM used in the later modules. -There is an option to set the `max_words` for `StructuralChunker`. This determines the size of chunks created and should be changed primarily based on the token limit of the encoding model and context size of the LLM used in later modules. - -### Embedding +### Embedder Module Run `python .\atlas\core\embedder\sentence_transformer\impl_embedder.py` -This will generate the `embedded_chunks.json` in `/Resources` folder. This json is exactly similar to -`chunked_data.json` with the added `embedding` for each chunk. - -See `README` in `atlas/core/embedder` for structure of this json. - -See `altas/core/configs/sentence_transformer_config.yaml` for changing the encoder model used and its configuration. The following can be changed: +In the above script modify, +- `chunk_data_path` to specify where the `chunked_data.json`is present +- `output_path` to specify where `embedded_chunks.json` will be saved. This json is exactly similar to +`chunked_data.json` with the added `embedding` for each chunk. See [`README` in `atlas/core/embedder`](atlas/core/embedder/README.md) for structure of this json. +- `encoder_config_path` to specify your own configuration settings for the encoder model used to generate the chunk embeddings. By default, see [`altas/core/configs/sentence_transformer_config.yaml`](atlas/core/configs/sentence_transformer_config.yaml) for changing the encoder model used and its configuration. The following can be changed: ```yaml model_name: sentence-transformers/all-MiniLM-L6-v2 @@ -156,6 +152,26 @@ normalize_embeddings: true device: cuda ``` +### Indexer Module + +Run `python .\atlas\core\indexer\run_indexer.py` + +In the above script modify, +- `results_save_path` to specify where the index and metadata file will be saved +- `embedded_chunks_json_file` to specify where the `embedded_chunks.json` is present + ### Tests -Run unit tests via VS Code or `python -m unittest` to run all unit tests +Run unit tests via VS Code + +or + +Run only unit tests - `pytest -m unittest` + +Run only integration tests - `pytest -m integration` + +Run only tests that can be run on CI - `pytest -m runonci` + +Run ALL tests - `pytest` + +Note : Anytime a pytest marker is added to a pytest, ensure it is registered in `pytest.ini` otherwise pytest will complain diff --git a/atlas/core/indexer/README.md b/atlas/core/indexer/README.md new file mode 100644 index 0000000..c7c50a9 --- /dev/null +++ b/atlas/core/indexer/README.md @@ -0,0 +1,29 @@ +## Indexer Module + +Indexing as a general concept is used to enhance speed of search and retrieval (ie, lookup) at the expense of storage space. And an index is a data structure that allows us to do this.A + +> We could scan all our data every time. +> An index exists so we don’t have to. + +Its used in various places like, +- databases +- search engines +- vector stores +- compilers + +### Why do indexing if I can just use the encoder to give me top K chunks? + +We can skip indexing when our data is small. We do indexing because computing similarity against everything doesn’t scale. Indexing exists to make nearest-neighbor search fast. + +When we use the encoder to get the top K chunks for a query, internally it ends up comparing against all the vector embeddings we have. + +```bash +for each chunk: + similarity(query_embedding, chunk_embedding) +``` + +Time Complexity : `O(N × d)` where `N` = number of chunks and `d` = embedding dimension + +### Vector indexing library + +[FAISS](https://faiss.ai/index.html), a vector indexing library was chosen instead of a vector DB to have more control when building the vector index. From adc16add64ba33b61e2eae8f492b2c005a2cb333 Mon Sep 17 00:00:00 2001 From: Divyendu Dutta Date: Sat, 3 Jan 2026 18:23:09 +0530 Subject: [PATCH 17/21] [FEAT] Implement base and FAISS vector store classes for vector indexing FAISS vector store class - build index - searches an input query in the index - saves and loads the index and metadata file --- atlas/core/indexer/__init__.py | 0 atlas/core/indexer/base_vector_store.py | 66 ++++++++++ atlas/core/indexer/faiss_vector_store.py | 160 +++++++++++++++++++++++ 3 files changed, 226 insertions(+) create mode 100644 atlas/core/indexer/__init__.py create mode 100644 atlas/core/indexer/base_vector_store.py create mode 100644 atlas/core/indexer/faiss_vector_store.py diff --git a/atlas/core/indexer/__init__.py b/atlas/core/indexer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/atlas/core/indexer/base_vector_store.py b/atlas/core/indexer/base_vector_store.py new file mode 100644 index 0000000..26ef269 --- /dev/null +++ b/atlas/core/indexer/base_vector_store.py @@ -0,0 +1,66 @@ +from abc import ABC +from abc import abstractmethod +from typing import List, Dict +import numpy as np + + +class BaseVectorStore(ABC): + """ + Abstract base class for a vector store. Used for vector indexing. + """ + + @abstractmethod + def add(self, vectors: np.ndarray, metadata: List[Dict]) -> None: + """ + Add the vector embeddings to the vector index and the corresponding + chunks to the metatdata. + + Args: + vectors (np.ndarray): Vector embeddings to add to the vector store. + metadata (List[Dict]): Corresponding list of chunk dictionaries. + """ + pass + + @abstractmethod + def search(self, query_vector: np.ndarray, k: int) -> List[Dict]: + """ + Search a query (via its embedding) in the vector store. + + Args: + query_vector (np.ndarray): Embedding of the query to search in the vector store. + k (int): Number of most similar embeddings (aka neighbors) to the query vector. + + Returns: + List[Dict]: List of dictionaries of the most similar embeddings to the query vector. + """ + pass + + @abstractmethod + def save(self, results_save_path: str): + """ + Save the following two files: + 1. index file + 2. chunk metadata + + Ensure that the elements in the two files are in sync + ie, `vector ID <-> metadata list index` + + Args: + results_save_path (str): Directory to save the above mentioned two result files. + """ + pass + + @abstractmethod + def load(self, results_load_path: str): + """ + Load the following two files: + 1. index file + 2. chunk metadata + + Use this in case we dont want to build the index and metadata from scratch and already + have both of them saved. + + Args: + results_load_path (str): Directory to load the above mentioned two result files from. + """ + pass diff --git a/atlas/core/indexer/faiss_vector_store.py b/atlas/core/indexer/faiss_vector_store.py new file mode 100644 index 0000000..a14a530 --- /dev/null +++ b/atlas/core/indexer/faiss_vector_store.py @@ -0,0 +1,160 @@ +import faiss +import numpy as np +from typing import List, Dict +from pathlib import Path +import json + +from atlas.core.indexer.base_vector_store import BaseVectorStore +from atlas.utils.logger import LoggerConfig + +LOGGER = LoggerConfig().logger + + +class FaissVectorStore(BaseVectorStore): + """ + Vector store using FAISS (Facebook AI Semantic Search) library. + Currently uses Flat Indexing but can be changed as needed. + + Args: + dim (int): Number of dimensions of the embeddings/vectors. + """ + + def __init__(self, dim: int): + LOGGER.info("-" * 20) + LOGGER.info("Initializing Indexer.") + self.dim = dim + self.index = faiss.IndexFlatIP(dim) + self.metadata: List[Dict] = [] + + def add(self, vectors: np.ndarray, metadata: List[Dict]) -> None: + """ + Add the vector embeddings to the FAISS vector index and the corresponding + chunks to the metatdata. + + Args: + vectors (np.ndarray): Vector embeddings to add to the FAISS vector store. + metadata (List[Dict]): Corresponding list of chunk dictionaries. + """ + + if vectors.ndim != 2 or vectors.shape[1] != self.dim: + LOGGER.error( + f"Invalid vector shape. Expected num of dim = 2 and size of vector = {self.dim}" + ) + raise ValueError( + f"Invalid vector shape. Expected num of dim = 2 and size of vector = {self.dim}" + ) + + if len(vectors) != len(metadata): + LOGGER.error("Vectors and metadata length mismatch") + raise ValueError("Vectors and metadata length mismatch") + + # we append metadata in the same order we add vectors + # this is to enforce the invariant `FAISS vector ID <-> metadata list index` + # this also means that when passing `vectors` and `metadata` to `add()`, + # they need to by synced + self.index.add(vectors) + self.metadata.extend(metadata) + + def search(self, query_vector: np.ndarray, k: int) -> List[Dict]: + """ + Search a query (via its embedding/vector) in the FAISS vector store. + + Args: + query_vector (np.ndarray): Embedding of the query to search in the FAISS vector store. + k (int): Number of most similar embeddings (aka neighbors) to the query vector. + + Returns: + List[Dict]: List of dictionaries of the most similar embeddings to the query vector. + Each dictionary contains the score (probability) for each similar embedding + match along with the full chunk metadata. + """ + + if k > self.index.ntotal: + LOGGER.error(f"k is more than maximum possible value : {self.index.ntotal}") + raise Exception( + f"k is more than maximum possible value : {self.index.ntotal}" + ) + + if query_vector.ndim == 1: + query_vector = query_vector.reshape( + 1, -1 + ) # add first dimension as batch == 1 + + scores, indices = self.index.search(query_vector, k) + + # search() returns two arrays: + # scores: shape (n_queries, k) + # indices: shape (n_queries, k) + + results = [] + for score, idx in zip( + scores[0], indices[0] + ): # use 0th element because we have 1 query vector + if idx == -1: # guard for neighbor not found for given query vector + continue + + result = {"score": float(score), **self.metadata[idx]} + results.append(result) + + LOGGER.info(f"Number of similar embeddings found : {len(results)}") + LOGGER.info( + f"Chunk with highest match : {results[0]['score']} is {results[0]['chunk_id']}" + ) + return results + + def save(self, results_save_path: str) -> None: + """ + Save the following two files: + 1. index file -> index.faiss + 2. chunk metadata -> metadata.json + + Ensure that the elements in the two files are in sync + ie, `FAISS vector ID <-> metadata list index` + + Args: + results_save_path (str): Directory to save the above mentioned two result files. + """ + + _results_save_path = Path(results_save_path) + _results_save_path.mkdir(parents=True, exist_ok=True) + + faiss.write_index(self.index, str(_results_save_path / "index.faiss")) + + metadata_save_path = _results_save_path / "metadata.json" + tmp_path = metadata_save_path.with_suffix(".tmp") + with (tmp_path).open("w", encoding="utf-8") as f: + json.dump(self.metadata, f, indent=2, ensure_ascii=False) + + tmp_path.replace(metadata_save_path) + LOGGER.info( + f"Index file and chunk metadata saved successfully to directory : {results_save_path}" + ) + + def load(self, results_load_path: str) -> None: + """ + Load the following two files: + 1. index file -> index.faiss + 2. chunk metadata -> metadata.json + + Use this in case we dont want to build the index and metadata from scratch and already + have both of them saved. + + Args: + results_load_path (str): Directory to load the above mentioned two result files from. + """ + + _results_load_path = Path(results_load_path) + try: + self.index = faiss.read_index(str(_results_load_path / "index.faiss")) + + with (_results_load_path / "metadata.json").open( + "r", encoding="utf-8" + ) as f: + self.metadata = json.load(f) + + LOGGER.info( + f"Index file and chunk metadata loaded successfully from directory : {results_load_path}" + ) + except Exception as e: + LOGGER.error(f"Error reading either index file or metadata file : {e}") + raise Exception(f"Error reading either index file or metadata file : {e}") From 6fb63ceb1eee706042b41e0e72e098cea63d5b36 Mon Sep 17 00:00:00 2001 From: Divyendu Dutta Date: Sat, 3 Jan 2026 18:29:36 +0530 Subject: [PATCH 18/21] [FEAT] Add script for embedding and vector indexing This just reads the json with all the chunk embedddings, builds the index and saves it --- atlas/core/indexer/run_indexer.py | 47 +++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 atlas/core/indexer/run_indexer.py diff --git a/atlas/core/indexer/run_indexer.py b/atlas/core/indexer/run_indexer.py new file mode 100644 index 0000000..f76811a --- /dev/null +++ b/atlas/core/indexer/run_indexer.py @@ -0,0 +1,47 @@ +import os +import numpy as np + +from atlas.utils.embedder_utils import load_embedded_chunks, generate_embedding +from atlas.core.indexer.faiss_vector_store import FaissVectorStore + +from atlas.utils.logger import LoggerConfig + +LOGGER = LoggerConfig().logger + +if __name__ == "__main__": + LOGGER.info("Running indexer to save the chunk embeddings to a vector index") + # this is the root folder which saves the following 2 files: + # 1. index file + # 2. metadata json + results_save_path = r"D:\\Deep learning\\Atlas\\Resources" + store = FaissVectorStore( + dim=384 + ) # the encoder model we used generated embeddings of size 384 + embedded_chunks_json_file = ( + r"D:\\Deep learning\\Atlas\\Resources\\embedded_chunks.json" + ) + embedded_chunks = load_embedded_chunks(embedded_chunks_json_file) + + store.add( + vectors=np.array([chunk["embedding"] for chunk in embedded_chunks]), + metadata=embedded_chunks, + ) + + store.save(results_save_path) + + # Sanity checks + # query_text = "Role of luck in life" # exact phrase query + # query_text = "Folks who inspire me" # paraphrasing + query_text = ( + "Journey is more important that the final result in life" # paraphrasing + ) + encoder_config_path = os.path.join( + os.getcwd(), "atlas", "core", "configs", "sentence_transformer_config.yaml" + ) + query_vector = generate_embedding(query_text, encoder_config_path) + results = store.search(query_vector, k=5) + LOGGER.info(len(results)) + for res in results: + LOGGER.info(f"score: {res['score']}") + LOGGER.info(f"Note title: {res['chunk_id']}") + LOGGER.info("===\n") From a43873f836c3ab5e33d2e95d012ffd99fa354c63 Mon Sep 17 00:00:00 2001 From: Divyendu Dutta Date: Sat, 3 Jan 2026 18:30:17 +0530 Subject: [PATCH 19/21] [FEAT] Add utility functions for loading embedded chunks and generating embeddings --- atlas/utils/embedder_utils.py | 56 +++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 atlas/utils/embedder_utils.py diff --git a/atlas/utils/embedder_utils.py b/atlas/utils/embedder_utils.py new file mode 100644 index 0000000..805903f --- /dev/null +++ b/atlas/utils/embedder_utils.py @@ -0,0 +1,56 @@ +import json +from pathlib import Path +from typing import List, Dict +import numpy as np + +from atlas.core.embedder.config import load_encoder_config +from atlas.core.embedder.sentence_transformer.impl_encoder import ( + SentenceTransformerEncoder, +) + +from atlas.utils.logger import LoggerConfig + +LOGGER = LoggerConfig().logger + + +def load_embedded_chunks(path: str) -> List[Dict]: + """ + Load and return the list of chunk dictionaries with added embeddings. + + Args: + path (str): Path to the list of chunk dictionaries json file. + + Returns: + List[Dict]: The list of chunk dictionaries with added embeddings. + """ + + _path = Path(path) + try: + with (_path).open("r", encoding="utf-8") as f: + metadata = json.load(f) + except Exception as e: + LOGGER.error(f"Error loading embedded chunks json file : {e}") + raise Exception(f"Error loading embedded chunks json file : {e}") + + LOGGER.info(f"Embedded chunks json successfully loaded from {str(path)}") + return metadata + + +def generate_embedding(text: str, encoder_config_path: str) -> np.ndarray: + """ + Generate the embedding/vector for a given text using the configuration settings + for a specific encoder. + + Args: + text (str): `text` for which to generate embeddings. + encoder_config_path (str): Path to the configuration settings file for the encoder. + + Returns: + np.ndarray: Embedding/vector for the provided `text`. + """ + texts = [text] + _encoder_config_path = Path(encoder_config_path) + embedding_config = load_encoder_config(_encoder_config_path) + encoder = SentenceTransformerEncoder(embedding_config) + embedding = encoder.encode(texts) + return embedding From 982dc3443f833efa650666c1e97fdfc3e9b7b855 Mon Sep 17 00:00:00 2001 From: Divyendu Dutta Date: Sat, 3 Jan 2026 18:43:50 +0530 Subject: [PATCH 20/21] [FEAT]] Add unit tests for FAISS vector store and embedder utility functions --- atlas/utils/embedder_utils.py | 2 +- tests/unittests/scripts/conftest.py | 32 +++ .../unittests/scripts/test_embedder_utils.py | 52 +++++ .../scripts/test_faiss_vector_store.py | 193 ++++++++++++++++++ 4 files changed, 278 insertions(+), 1 deletion(-) create mode 100644 tests/unittests/scripts/test_embedder_utils.py create mode 100644 tests/unittests/scripts/test_faiss_vector_store.py diff --git a/atlas/utils/embedder_utils.py b/atlas/utils/embedder_utils.py index 805903f..7428f9d 100644 --- a/atlas/utils/embedder_utils.py +++ b/atlas/utils/embedder_utils.py @@ -53,4 +53,4 @@ def generate_embedding(text: str, encoder_config_path: str) -> np.ndarray: embedding_config = load_encoder_config(_encoder_config_path) encoder = SentenceTransformerEncoder(embedding_config) embedding = encoder.encode(texts) - return embedding + return embedding[0] diff --git a/tests/unittests/scripts/conftest.py b/tests/unittests/scripts/conftest.py index 86adfd3..d8841da 100644 --- a/tests/unittests/scripts/conftest.py +++ b/tests/unittests/scripts/conftest.py @@ -83,6 +83,38 @@ def dummy_chunk_data_path(tmp_path: Path) -> Path: return chunk_data_path +@pytest.fixture +def dummy_embedded_chunk_data_path(tmp_path: Path) -> Path: + """ + Create a dummy embedded chunk data file for testing. + + Args: + tmp_path (Path): Temporary directory provided by pytest. + + Returns: + Path: The path to the created dummy embedded chunk data file. + """ + dummy_chunk_data = [ + { + "chunk_id": "test Note.md::test_heading::chunk_0", + "note_id": "test Note.md", + "title": "Test Note", + "relative_path": "test_note.md", + "heading": "Test Heading", + "chunk_index": 0, + "text": "This is a test chunk.", + "word_count": 5, + "tags": [], + "frontmatter": {}, + "embedding": [1.2, 2.4, 4.9], + } + ] + embedded_chunk_data_path = tmp_path / "embedded_chunks.json" + with embedded_chunk_data_path.open("w", encoding="utf-8") as f: + json.dump(dummy_chunk_data, f, indent=2, ensure_ascii=False) + return embedded_chunk_data_path + + @pytest.fixture def dummy_encoder_config(tmp_path: Path) -> EncoderConfig: """ diff --git a/tests/unittests/scripts/test_embedder_utils.py b/tests/unittests/scripts/test_embedder_utils.py new file mode 100644 index 0000000..dde17c4 --- /dev/null +++ b/tests/unittests/scripts/test_embedder_utils.py @@ -0,0 +1,52 @@ +import pytest +from pathlib import Path + +from atlas.utils.embedder_utils import load_embedded_chunks, generate_embedding + + +@pytest.mark.unittest +@pytest.mark.runonci +def test_load_embedded_chunks_positive(dummy_embedded_chunk_data_path: Path) -> None: + """ + Test if embedded chunks data can be successfully loaded from json file. + + Args: + dummy_embedded_chunk_data_path (Path): The path to the dummy embedded chunks json file. + """ + + metadata = load_embedded_chunks(str(dummy_embedded_chunk_data_path)) + assert len(metadata) == 1 + assert metadata[0]["chunk_id"] == "test Note.md::test_heading::chunk_0" + + +@pytest.mark.unittest +@pytest.mark.runonci +def test_load_embedded_chunks_negative(tmp_path: Path) -> None: + """ + Test if exception is raised if the embedded chunks json file isnt available. + + Args: + tmp_path (Path): Temporary path provided by pytest. + """ + + dummy_embedded_chunk_data_path = ( + tmp_path / "embedded_chunks.json" + ) # file on this path doesnt exist + with pytest.raises(Exception) as exc_info: + _ = load_embedded_chunks(str(dummy_embedded_chunk_data_path)) + assert "Error loading embedded chunks json file" in str(exc_info.value) + + +@pytest.mark.unittest +@pytest.mark.runonci +def test_generate_embedding(dummy_encoder_config_path: Path) -> None: + """ + Test encoder model generates proper embedding for a given text. + + Args: + dummy_encoder_config_path (Path): The path to the dummy encoder configuration file. + """ + + text = "Hi, my name is Bob Ross!" + embedding = generate_embedding(text, str(dummy_encoder_config_path)) + assert embedding.shape == (384,) diff --git a/tests/unittests/scripts/test_faiss_vector_store.py b/tests/unittests/scripts/test_faiss_vector_store.py new file mode 100644 index 0000000..fdffc0b --- /dev/null +++ b/tests/unittests/scripts/test_faiss_vector_store.py @@ -0,0 +1,193 @@ +import json +import pytest +import numpy as np +from pathlib import Path +import faiss + +from atlas.core.indexer.faiss_vector_store import FaissVectorStore +from atlas.utils.embedder_utils import load_embedded_chunks + + +@pytest.mark.unittest +@pytest.mark.runonci +def test_add_positive(dummy_embedded_chunk_data_path: Path) -> None: + """ + Test if embeddings/vectors can be added to the FAISS vector store. + + Args: + dummy_embedded_chunk_data_path (Path): The path to the dummy embedded chunks json file. + """ + vectors = np.array([[1, 2, 3]]) + embedded_chunks = load_embedded_chunks(str(dummy_embedded_chunk_data_path)) + store = FaissVectorStore(dim=3) + store.add(vectors, embedded_chunks) + assert store.index.ntotal == len(vectors) + assert len(store.metadata) == len(embedded_chunks) + + +@pytest.mark.unittest +@pytest.mark.runonci +def test_add_negative_invalid_embedding_shape( + dummy_embedded_chunk_data_path: Path, +) -> None: + """ + Test if exception is raised if the embedding(s) to be added to the FAISS vector + store has incorrect shape. + + Args: + dummy_embedded_chunk_data_path (Path): The path to the dummy embedded chunks json file. + """ + vectors = np.array([1, 2]) + embedded_chunks = load_embedded_chunks(str(dummy_embedded_chunk_data_path)) + store = FaissVectorStore(dim=3) + with pytest.raises(ValueError) as exc_info: + store.add(vectors, embedded_chunks) + assert "Invalid vector shape. Expected num of dim = 2 and size of vector" in str( + exc_info.value + ) + + +@pytest.mark.unittest +@pytest.mark.runonci +def test_add_negative_length_mismatch(dummy_embedded_chunk_data_path: Path) -> None: + """ + Test if exception is raised if the length of the embeddings/vectors and + associated metadata does not match when adding embedding(s) to the FAISS + vector store. + + Args: + dummy_embedded_chunk_data_path (Path): The path to the dummy embedded chunks json file. + """ + vectors = np.array([[1, 2, 3], [2, 3, 4]]) + embedded_chunks = load_embedded_chunks(str(dummy_embedded_chunk_data_path)) + store = FaissVectorStore(dim=3) + with pytest.raises(ValueError) as exc_info: + store.add(vectors, embedded_chunks) + assert "Vectors and metadata length mismatch" in str(exc_info.value) + + +@pytest.mark.unittest +@pytest.mark.runonci +def test_search_positive(dummy_embedded_chunk_data_path: Path) -> None: + """ + Test if the FAISS store can successfully search for neighbor embeddings for a + provided input embedding. + + Args: + dummy_embedded_chunk_data_path (Path): The path to the dummy embedded chunks json file. + """ + vectors = np.array([[1, 2, 3]]) + embedded_chunks = load_embedded_chunks(str(dummy_embedded_chunk_data_path)) + store = FaissVectorStore(dim=3) + store.add(vectors, embedded_chunks) + query_vector = np.array([1, 2, 2]) + k = 1 + results = store.search(query_vector, k) + assert len(results) == k + assert "score" in results[0].keys() + + +@pytest.mark.unittest +@pytest.mark.runonci +def test_search_negative_large_k(dummy_embedded_chunk_data_path: Path) -> None: + """ + Test if exeception is raised if the number of neighbors ie, `k` to be searched + in the vector store for an input embedding is more than the maximum embeddings + saved in the vector store. + + Args: + dummy_embedded_chunk_data_path (Path): The path to the dummy embedded chunks json file. + """ + vectors = np.array([[1, 2, 3]]) + embedded_chunks = load_embedded_chunks(str(dummy_embedded_chunk_data_path)) + store = FaissVectorStore(dim=3) + store.add(vectors, embedded_chunks) + query_vector = np.array([[1, 2, 2]]) + k = 3 + with pytest.raises(Exception) as exc_info: + _ = store.search(query_vector, k) + assert "k is more than maximum possible value" in str(exc_info.value) + + +@pytest.mark.unittest +@pytest.mark.runonci +def test_save(tmp_path: Path, dummy_embedded_chunk_data_path: Path) -> None: + """ + Test if generated index and metadata files can be saved. + + Args: + tmp_path (Path): Temporary path provided by pytest. + dummy_embedded_chunk_data_path (Path): The path to the dummy embedded chunks json file. + """ + vectors = np.array([[1, 2, 3]]) + embedded_chunks = load_embedded_chunks(str(dummy_embedded_chunk_data_path)) + store = FaissVectorStore(dim=3) + store.add(vectors, embedded_chunks) + results_save_path = tmp_path / "Results" + store.save(str(results_save_path)) + index_file_path = results_save_path / "index.faiss" + metadata_file_path = results_save_path / "metadata.json" + + assert index_file_path.exists() + index_data = faiss.read_index(str(index_file_path)) + assert index_data.ntotal == len(vectors) + + assert metadata_file_path.exists() + with metadata_file_path.open("r", encoding="utf-8") as f: + metadata_data = json.load(f) + assert len(metadata_data) == len(embedded_chunks) + + +@pytest.mark.unittest +@pytest.mark.runonci +def test_load_positive(tmp_path: Path, dummy_embedded_chunk_data_path: Path) -> None: + """ + Test if saved index and metadata files can be loaded. + + Args: + tmp_path (Path): Temporary path provided by pytest. + dummy_embedded_chunk_data_path (Path): The path to the dummy embedded chunks json file. + """ + vectors = np.array([[1, 2, 3]]) + embedded_chunks = load_embedded_chunks(str(dummy_embedded_chunk_data_path)) + store = FaissVectorStore(dim=3) + store.add(vectors, embedded_chunks) + results_save_path = tmp_path / "Results" + store.save(str(results_save_path)) + index_file_path = results_save_path / "index.faiss" + metadata_file_path = results_save_path / "metadata.json" + store.load(results_load_path=str(results_save_path)) + + assert index_file_path.exists() + index_data = faiss.read_index(str(index_file_path)) + assert index_data.ntotal == len(vectors) + + assert metadata_file_path.exists() + with metadata_file_path.open("r", encoding="utf-8") as f: + metadata_data = json.load(f) + assert len(metadata_data) == len(embedded_chunks) + + +@pytest.mark.unittest +@pytest.mark.runonci +def test_load_negative_file_not_found( + tmp_path: Path, dummy_embedded_chunk_data_path: Path +) -> None: + """ + Test if exception is raised if the index or metadata file is not found when trying to load them. + + Args: + tmp_path (Path): Temporary path provided by pytest. + dummy_embedded_chunk_data_path (Path): The path to the dummy embedded chunks json file. + """ + vectors = np.array([[1, 2, 3]]) + embedded_chunks = load_embedded_chunks(str(dummy_embedded_chunk_data_path)) + store = FaissVectorStore(dim=3) + store.add(vectors, embedded_chunks) + results_save_path = tmp_path / "Results" + store.save(str(results_save_path)) + results_load_path = tmp_path / "Res" # folder doesnt exist + + with pytest.raises(Exception) as exc_info: + store.load(results_load_path=str(results_load_path)) + assert "Error reading either index file or metadata file" in str(exc_info.value) From 8f32ad15c4180f3f8a4edd1122a57cd16c692878 Mon Sep 17 00:00:00 2001 From: Divyendu Dutta Date: Sat, 3 Jan 2026 18:49:29 +0530 Subject: [PATCH 21/21] [DOC] Fix architecture diagram path Made the architecture diagram path branch invariant --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 762d199..853a178 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ So it follows the scaling law that even a small LLM when trained on enough quali ## Architecture -Initial high level [architecture diagram](https://github.com/DivyenduDutta/Atlas/tree/master/Resources/Atlas_Architecture.png) +High level [architecture diagram](Resources/Atlas_Architecture.png) A sample of the `obsidian_index.json` is as below: