CoolFace
Apppublic

aphilippov/python-server-api

sourceHugging Faceapache-2.0updated 3y agoView on Hugging Face
0likes
retrieve_utils.py380 linesDownload Raw Back to autogen
1from typing import List, Union, Callable2import os3import requests4from urllib.parse import urlparse5import glob6import chromadb7 8if chromadb.__version__ < "0.4.15":9    from chromadb.api import API10else:11    from chromadb.api import ClientAPI as API12from chromadb.api.types import QueryResult13import chromadb.utils.embedding_functions as ef14import logging15import pypdf16from autogen.token_count_utils import count_token17 18try:19    from unstructured.partition.auto import partition20 21    HAS_UNSTRUCTURED = True22except ImportError:23    HAS_UNSTRUCTURED = False24 25logger = logging.getLogger(__name__)26TEXT_FORMATS = [27    "txt",28    "json",29    "csv",30    "tsv",31    "md",32    "html",33    "htm",34    "rtf",35    "rst",36    "jsonl",37    "log",38    "xml",39    "yaml",40    "yml",41    "pdf",42]43UNSTRUCTURED_FORMATS = [44    "doc",45    "docx",46    "epub",47    "msg",48    "odt",49    "org",50    "pdf",51    "ppt",52    "pptx",53    "rtf",54    "rst",55    "xlsx",56]  # These formats will be parsed by the 'unstructured' library, if installed.57if HAS_UNSTRUCTURED:58    TEXT_FORMATS += UNSTRUCTURED_FORMATS59    TEXT_FORMATS = list(set(TEXT_FORMATS))60VALID_CHUNK_MODES = frozenset({"one_line", "multi_lines"})61 62 63def split_text_to_chunks(64    text: str,65    max_tokens: int = 4000,66    chunk_mode: str = "multi_lines",67    must_break_at_empty_line: bool = True,68    overlap: int = 10,69):70    """Split a long text into chunks of max_tokens."""71    if chunk_mode not in VALID_CHUNK_MODES:72        raise AssertionError73    if chunk_mode == "one_line":74        must_break_at_empty_line = False75    chunks = []76    lines = text.split("\n")77    lines_tokens = [count_token(line) for line in lines]78    sum_tokens = sum(lines_tokens)79    while sum_tokens > max_tokens:80        if chunk_mode == "one_line":81            estimated_line_cut = 282        else:83            estimated_line_cut = int(max_tokens / sum_tokens * len(lines)) + 184        cnt = 085        prev = ""86        for cnt in reversed(range(estimated_line_cut)):87            if must_break_at_empty_line and lines[cnt].strip() != "":88                continue89            if sum(lines_tokens[:cnt]) <= max_tokens:90                prev = "\n".join(lines[:cnt])91                break92        if cnt == 0:93            logger.warning(94                f"max_tokens is too small to fit a single line of text. Breaking this line:\n\t{lines[0][:100]} ..."95            )96            if not must_break_at_empty_line:97                split_len = int(max_tokens / lines_tokens[0] * 0.9 * len(lines[0]))98                prev = lines[0][:split_len]99                lines[0] = lines[0][split_len:]100                lines_tokens[0] = count_token(lines[0])101            else:102                logger.warning("Failed to split docs with must_break_at_empty_line being True, set to False.")103                must_break_at_empty_line = False104        chunks.append(prev) if len(prev) > 10 else None  # don't add chunks less than 10 characters105        lines = lines[cnt:]106        lines_tokens = lines_tokens[cnt:]107        sum_tokens = sum(lines_tokens)108    text_to_chunk = "\n".join(lines)109    chunks.append(text_to_chunk) if len(text_to_chunk) > 10 else None  # don't add chunks less than 10 characters110    return chunks111 112 113def extract_text_from_pdf(file: str) -> str:114    """Extract text from PDF files"""115    text = ""116    with open(file, "rb") as f:117        reader = pypdf.PdfReader(f)118        if reader.is_encrypted:  # Check if the PDF is encrypted119            try:120                reader.decrypt("")121            except pypdf.errors.FileNotDecryptedError as e:122                logger.warning(f"Could not decrypt PDF {file}, {e}")123                return text  # Return empty text if PDF could not be decrypted124 125        for page_num in range(len(reader.pages)):126            page = reader.pages[page_num]127            text += page.extract_text()128 129    if not text.strip():  # Debugging line to check if text is empty130        logger.warning(f"Could not decrypt PDF {file}")131 132    return text133 134 135def split_files_to_chunks(136    files: list,137    max_tokens: int = 4000,138    chunk_mode: str = "multi_lines",139    must_break_at_empty_line: bool = True,140    custom_text_split_function: Callable = None,141):142    """Split a list of files into chunks of max_tokens."""143 144    chunks = []145 146    for file in files:147        _, file_extension = os.path.splitext(file)148        file_extension = file_extension.lower()149 150        if HAS_UNSTRUCTURED and file_extension[1:] in UNSTRUCTURED_FORMATS:151            text = partition(file)152            text = "\n".join([t.text for t in text]) if len(text) > 0 else ""153        elif file_extension == ".pdf":154            text = extract_text_from_pdf(file)155        else:  # For non-PDF text-based files156            with open(file, "r", encoding="utf-8", errors="ignore") as f:157                text = f.read()158 159        if not text.strip():  # Debugging line to check if text is empty after reading160            logger.warning(f"No text available in file: {file}")161            continue  # Skip to the next file if no text is available162 163        if custom_text_split_function is not None:164            chunks += custom_text_split_function(text)165        else:166            chunks += split_text_to_chunks(text, max_tokens, chunk_mode, must_break_at_empty_line)167 168    return chunks169 170 171def get_files_from_dir(dir_path: Union[str, List[str]], types: list = TEXT_FORMATS, recursive: bool = True):172    """Return a list of all the files in a given directory, a url, a file path or a list of them."""173    if len(types) == 0:174        raise ValueError("types cannot be empty.")175    types = [t[1:].lower() if t.startswith(".") else t.lower() for t in set(types)]176    types += [t.upper() for t in types]177 178    files = []179    # If the path is a list of files or urls, process and return them180    if isinstance(dir_path, list):181        for item in dir_path:182            if os.path.isfile(item):183                files.append(item)184            elif is_url(item):185                files.append(get_file_from_url(item))186            elif os.path.exists(item):187                try:188                    files.extend(get_files_from_dir(item, types, recursive))189                except ValueError:190                    logger.warning(f"Directory {item} does not exist. Skipping.")191            else:192                logger.warning(f"File {item} does not exist. Skipping.")193        return files194 195    # If the path is a file, return it196    if os.path.isfile(dir_path):197        return [dir_path]198 199    # If the path is a url, download it and return the downloaded file200    if is_url(dir_path):201        return [get_file_from_url(dir_path)]202 203    if os.path.exists(dir_path):204        for type in types:205            if recursive:206                files += glob.glob(os.path.join(dir_path, f"**/*.{type}"), recursive=True)207            else:208                files += glob.glob(os.path.join(dir_path, f"*.{type}"), recursive=False)209    else:210        logger.error(f"Directory {dir_path} does not exist.")211        raise ValueError(f"Directory {dir_path} does not exist.")212    return files213 214 215def get_file_from_url(url: str, save_path: str = None):216    """Download a file from a URL."""217    if save_path is None:218        os.makedirs("/tmp/chromadb", exist_ok=True)219        save_path = os.path.join("/tmp/chromadb", os.path.basename(url))220    else:221        os.makedirs(os.path.dirname(save_path), exist_ok=True)222    with requests.get(url, stream=True) as r:223        r.raise_for_status()224        with open(save_path, "wb") as f:225            for chunk in r.iter_content(chunk_size=8192):226                f.write(chunk)227    return save_path228 229 230def is_url(string: str):231    """Return True if the string is a valid URL."""232    try:233        result = urlparse(string)234        return all([result.scheme, result.netloc])235    except ValueError:236        return False237 238 239def create_vector_db_from_dir(240    dir_path: Union[str, List[str]],241    max_tokens: int = 4000,242    client: API = None,243    db_path: str = "/tmp/chromadb.db",244    collection_name: str = "all-my-documents",245    get_or_create: bool = False,246    chunk_mode: str = "multi_lines",247    must_break_at_empty_line: bool = True,248    embedding_model: str = "all-MiniLM-L6-v2",249    embedding_function: Callable = None,250    custom_text_split_function: Callable = None,251    custom_text_types: List[str] = TEXT_FORMATS,252    recursive: bool = True,253    extra_docs: bool = False,254) -> API:255    """Create a vector db from all the files in a given directory, the directory can also be a single file or a url to256        a single file. We support chromadb compatible APIs to create the vector db, this function is not required if257        you prepared your own vector db.258 259    Args:260        dir_path (Union[str, List[str]]): the path to the directory, file, url or a list of them.261        max_tokens (Optional, int): the maximum number of tokens per chunk. Default is 4000.262        client (Optional, API): the chromadb client. Default is None.263        db_path (Optional, str): the path to the chromadb. Default is "/tmp/chromadb.db".264        collection_name (Optional, str): the name of the collection. Default is "all-my-documents".265        get_or_create (Optional, bool): Whether to get or create the collection. Default is False. If True, the collection266            will be returned if it already exists. Will raise ValueError if the collection already exists and get_or_create is False.267        chunk_mode (Optional, str): the chunk mode. Default is "multi_lines".268        must_break_at_empty_line (Optional, bool): Whether to break at empty line. Default is True.269        embedding_model (Optional, str): the embedding model to use. Default is "all-MiniLM-L6-v2". Will be ignored if270            embedding_function is not None.271        embedding_function (Optional, Callable): the embedding function to use. Default is None, SentenceTransformer with272            the given `embedding_model` will be used. If you want to use OpenAI, Cohere, HuggingFace or other embedding273            functions, you can pass it here, follow the examples in `https://docs.trychroma.com/embeddings`.274        custom_text_split_function (Optional, Callable): a custom function to split a string into a list of strings.275            Default is None, will use the default function in `autogen.retrieve_utils.split_text_to_chunks`.276        custom_text_types (Optional, List[str]): a list of file types to be processed. Default is TEXT_FORMATS.277        recursive (Optional, bool): whether to search documents recursively in the dir_path. Default is True.278        extra_docs (Optional, bool): whether to add more documents in the collection. Default is False279    Returns:280        API: the chromadb client.281    """282    if client is None:283        client = chromadb.PersistentClient(path=db_path)284    try:285        embedding_function = (286            ef.SentenceTransformerEmbeddingFunction(embedding_model)287            if embedding_function is None288            else embedding_function289        )290        collection = client.create_collection(291            collection_name,292            get_or_create=get_or_create,293            embedding_function=embedding_function,294            # https://github.com/nmslib/hnswlib#supported-distances295            # https://github.com/chroma-core/chroma/blob/566bc80f6c8ee29f7d99b6322654f32183c368c4/chromadb/segment/impl/vector/local_hnsw.py#L184296            # https://github.com/nmslib/hnswlib/blob/master/ALGO_PARAMS.md297            metadata={"hnsw:space": "ip", "hnsw:construction_ef": 30, "hnsw:M": 32},  # ip, l2, cosine298        )299 300        length = 0301        if extra_docs:302            length = len(collection.get()["ids"])303 304        if custom_text_split_function is not None:305            chunks = split_files_to_chunks(306                get_files_from_dir(dir_path, custom_text_types, recursive),307                custom_text_split_function=custom_text_split_function,308            )309        else:310            chunks = split_files_to_chunks(311                get_files_from_dir(dir_path, custom_text_types, recursive),312                max_tokens,313                chunk_mode,314                must_break_at_empty_line,315            )316        logger.info(f"Found {len(chunks)} chunks.")317        # Upsert in batch of 40000 or less if the total number of chunks is less than 40000318        for i in range(0, len(chunks), min(40000, len(chunks))):319            end_idx = i + min(40000, len(chunks) - i)320            collection.upsert(321                documents=chunks[i:end_idx],322                ids=[f"doc_{j+length}" for j in range(i, end_idx)],  # unique for each doc323            )324    except ValueError as e:325        logger.warning(f"{e}")326    return client327 328 329def query_vector_db(330    query_texts: List[str],331    n_results: int = 10,332    client: API = None,333    db_path: str = "/tmp/chromadb.db",334    collection_name: str = "all-my-documents",335    search_string: str = "",336    embedding_model: str = "all-MiniLM-L6-v2",337    embedding_function: Callable = None,338) -> QueryResult:339    """Query a vector db. We support chromadb compatible APIs, it's not required if you prepared your own vector db340        and query function.341 342    Args:343        query_texts (List[str]): the list of strings which will be used to query the vector db.344        n_results (Optional, int): the number of results to return. Default is 10.345        client (Optional, API): the chromadb compatible client. Default is None, a chromadb client will be used.346        db_path (Optional, str): the path to the vector db. Default is "/tmp/chromadb.db".347        collection_name (Optional, str): the name of the collection. Default is "all-my-documents".348        search_string (Optional, str): the search string. Only docs that contain an exact match of this string will be retrieved. Default is "".349        embedding_model (Optional, str): the embedding model to use. Default is "all-MiniLM-L6-v2". Will be ignored if350            embedding_function is not None.351        embedding_function (Optional, Callable): the embedding function to use. Default is None, SentenceTransformer with352            the given `embedding_model` will be used. If you want to use OpenAI, Cohere, HuggingFace or other embedding353            functions, you can pass it here, follow the examples in `https://docs.trychroma.com/embeddings`.354 355    Returns:356        QueryResult: the query result. The format is:357            class QueryResult(TypedDict):358                ids: List[IDs]359                embeddings: Optional[List[List[Embedding]]]360                documents: Optional[List[List[Document]]]361                metadatas: Optional[List[List[Metadata]]]362                distances: Optional[List[List[float]]]363    """364    if client is None:365        client = chromadb.PersistentClient(path=db_path)366    # the collection's embedding function is always the default one, but we want to use the one we used to create the367    # collection. So we compute the embeddings ourselves and pass it to the query function.368    collection = client.get_collection(collection_name)369    embedding_function = (370        ef.SentenceTransformerEmbeddingFunction(embedding_model) if embedding_function is None else embedding_function371    )372    query_embeddings = embedding_function(query_texts)373    # Query/search n most similar results. You can also .get by id374    results = collection.query(375        query_embeddings=query_embeddings,376        n_results=n_results,377        where_document={"$contains": search_string} if search_string else None,  # optional filter378    )379    return results380