sahel-api / frontend-chat /chatbot /memory_builder.py
Mohamed-20's picture
Deploy backend to HF Spaces
1337ed3
Raw
History Blame Contribute Delete
8.12 kB
import argparse
import sys
from pathlib import Path
from bot.memory.document_registry import DocumentRegistry
from bot.memory.embedder import Embedder
from bot.memory.vector_database.chroma import Chroma
from bot.memory.vector_database.id_generator import generate_id
from database import create_db_engine
from document_loader.format import Format
from document_loader.loader import DirectoryLoader
from document_loader.text_splitter import create_recursive_text_splitter
from entities.document import Document
from helpers.log import get_logger
from sqlmodel import Session
logger = get_logger(__name__)
def load_documents(docs_path: Path) -> list[Document]:
"""
Loads Markdown documents from the specified path.
Args:
docs_path (Path): The path to the documents.
Returns:
List[Document]: A list of loaded documents.
"""
loader = DirectoryLoader(
path=docs_path,
glob="**/*.md",
show_progress=True,
)
return loader.load()
def split_chunks(sources: list, chunk_size: int = 1000, chunk_overlap: int = 50) -> list:
"""
Splits a list of sources into smaller chunks.
Args:
sources (List): The list of sources to be split into chunks.
chunk_size (int, optional): The maximum size of each chunk. Defaults to 1000.
chunk_overlap (int, optional): The amount of overlap between consecutive chunks. Defaults to 50.
Returns:
List: A list of smaller chunks obtained from the input sources.
"""
chunks = []
splitter = create_recursive_text_splitter(
format=Format.MARKDOWN.value, chunk_size=chunk_size, chunk_overlap=chunk_overlap
)
for chunk in splitter.split_documents(sources):
chunks.append(chunk)
return chunks
def build_memory_index(
docs_path: Path,
vector_store_path: str,
chunk_size: int,
chunk_overlap: int,
full_rebuild: bool = False,
model_name: str = "jinaai/jina-embeddings-v5-text-small-retrieval",
) -> dict:
"""
Build or incrementally update the vector memory index.
When `full_rebuild` is `True` the Chroma collection and the registry are
wiped before ingesting everything from scratch.
Otherwise, only new and changed documents are processed, and deleted documents are removed.
Returns a stats dict with counts of processed / deleted / skipped docs.
"""
# ------------------------------------------------------------------
# bootstrap vector DB + registry
# ------------------------------------------------------------------
embedding = Embedder(model_name=model_name)
vector_database = Chroma(is_persistent=True, persist_directory=str(vector_store_path), embedding=embedding)
session = Session(create_db_engine())
registry = DocumentRegistry(session)
# ------------------------------------------------------------------
# full-rebuild: wipe everything first
# ------------------------------------------------------------------
if full_rebuild:
logger.info("Full rebuild requested – wiping collection and registry.")
vector_database.reset_collection()
for record in registry.get_all():
registry.remove(record.document_id)
# ------------------------------------------------------------------
# (a) load source docs, compute document_id + version_hash
# ------------------------------------------------------------------
logger.info(f"Loading documents from: {docs_path}")
sources = load_documents(docs_path)
logger.info(f"Number of loaded documents: {len(sources)}")
current_docs: dict[str, str] = {} # {document_id: version_hash}
doc_map: dict[str, Document] = {} # {document_id: Document}
for doc in sources:
source_path = doc.metadata.get("source", "")
doc_id = generate_id(source_path)
ver_hash = generate_id(doc.page_content)
current_docs[doc_id] = ver_hash
doc_map[doc_id] = doc
# ------------------------------------------------------------------
# (b) diff against registry
# ------------------------------------------------------------------
new_ids, changed_ids, deleted_ids = registry.get_stale_documents(current_docs)
logger.info(
f"Diff result – new: {len(new_ids)}, changed: {len(changed_ids)}, deleted: {len(deleted_ids)}, "
f"unchanged: {len(current_docs) - len(new_ids) - len(changed_ids)}",
)
# ------------------------------------------------------------------
# (c) remove changed / deleted docs from Chroma + registry
# ------------------------------------------------------------------
for doc_id in changed_ids | deleted_ids:
record = registry.get(doc_id)
chunk_ids = record.chunk_ids if record else None
vector_database.delete_chunks_by_document_id(doc_id, chunk_ids=chunk_ids)
if doc_id in deleted_ids:
registry.remove(doc_id)
# ------------------------------------------------------------------
# (d) chunk & ingest new + changed docs
# ------------------------------------------------------------------
to_ingest = new_ids | changed_ids
for doc_id in to_ingest:
doc = doc_map[doc_id]
source_path = doc.metadata.get("source", "")
ver_hash = current_docs[doc_id]
chunks = split_chunks([doc], chunk_size=chunk_size, chunk_overlap=chunk_overlap)
# inject document_id + version_hash into every chunk's metadata
for chunk in chunks:
chunk.metadata["document_id"] = doc_id
chunk.metadata["version_hash"] = ver_hash
chunk_ids = vector_database.from_chunks(chunks)
# ------------------------------------------------------------------
# (e) upsert into registry
# ------------------------------------------------------------------
registry.upsert(
doc_id,
source=source_path,
filename=Path(source_path).name,
size=len(doc.page_content),
content_type="text/markdown",
version_hash=ver_hash,
chunk_ids=chunk_ids,
)
stats = {
"processed": len(to_ingest),
"deleted": len(deleted_ids),
"skipped": len(current_docs) - len(to_ingest),
}
logger.info("Memory Index updated – %s", stats)
session.close()
return stats
def get_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Memory Builder")
parser.add_argument(
"--chunk-size",
type=int,
help="The maximum size of each chunk. Defaults to 1000.",
required=False,
default=1000,
)
parser.add_argument(
"--chunk-overlap",
type=int,
help="The amount of overlap between consecutive chunks. Defaults to 50.",
required=False,
default=50,
)
parser.add_argument(
"--full-rebuild",
action="store_true",
default=False,
help="Wipe the vector store and registry and rebuild from scratch.",
)
parser.add_argument(
"--model-name",
type=str,
help="The name of the SentenceTransformer model to use for embedding. "
"Defaults to 'jinaai/jina-embeddings-v5-text-small-retrieval'.",
required=False,
default="jinaai/jina-embeddings-v5-text-small-retrieval",
)
return parser.parse_args()
def main(parameters):
root_folder = Path(__file__).resolve().parent.parent
doc_path = root_folder / "docs"
vector_store_path = root_folder / "vector_store" / "docs_index"
build_memory_index(
docs_path=doc_path,
vector_store_path=str(vector_store_path),
chunk_size=parameters.chunk_size,
chunk_overlap=parameters.chunk_overlap,
full_rebuild=parameters.full_rebuild,
model_name=parameters.model_name,
)
if __name__ == "__main__":
try:
args = get_args()
main(args)
except Exception as error:
logger.error(f"An error occurred: {str(error)}", exc_info=True, stack_info=True)
sys.exit(1)