Files
wehub-resource-sync 86db9aae8e
Documentation / build (push) Waiting to run
Documentation / deploy (push) Blocked by required conditions
chore: import upstream snapshot with attribution
2026-07-13 13:34:55 +08:00

1765 lines
67 KiB
Python
Executable File

# Copyright 2023-2026 llmware
# Licensed under the Apache License, Version 2.0 (the "License"); you
# may not use this file except in compliance with the License. You
# may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
# implied. See the License for the specific language governing
# permissions and limitations under the License.
"""The retrieval module implements the Query class. The Query class provides a high-level interface for executing
a variety of queries on a Library collection, whether instantiated on Mongo, Postgres, or SQLite.
The Query class includes both text retrieval strategies, which operate directly as queries on the text collection
database, as well as vector embedding semantic retrieval strategies, which require the use of o vector DB and that the
embeddings were previously created for the Library. There are also a number of convenience methods that provide
'hybrid' strategies combining elements of semantic and text querying."""
import logging
import os
from collections import Counter
from datetime import datetime
try:
from bson.objectid import ObjectId
except:
pass
from llmware.configs import LLMWareConfig, LLMWareException, ModelNotFoundException
from llmware.embeddings import EmbeddingHandler
from llmware.resources import CollectionRetrieval, QueryState
from llmware.util import Utilities, CorpTokenizer
from llmware.models import ModelCatalog
logger = logging.getLogger(__name__)
class Query:
"""Implements the query capabilities against a ``Library` object`.
Query is responsible for executing queries against an indexed library. The library can be semantic, text, custom,
or hybrid. A query object requires a library object as input, which will be the source of the query.
Parameters
----------
library : Library object
A ``library`` object.
embedding_model : object, default=None
An ``embedding_model`` object.
tokenizer : object, default=None
vector_db_api_key : str, default=None
The API key for the vector store.
query_id : int, default=None
The identifier for a query. This is used when a query state has to be loaded.
from_hf : bool, default=False
Sets whether the embedding model should be loaded from hugging face.
from_sentence_transformer: bool, default=False
Sets whether the embedding model should be loaded from ``LLMWareSemanticModel``.
embedding_model_name : str, default=None
The name of the embedding model. This has to be set if ``from_sentence_transformer=True``.
save_history : bool, default=True
Sets whether the history of queries should be saved.
query_mode : str, default=None
Sets the query mode that should be used. It has to be either 'text', 'semantic', or 'hybrid'.
vector_db : str, default=None
The name of the vector store to be queried against. If it is not set, then this is determined by the
given ``embedding_model``.
Examples
----------
>>> from llmware.library import Library
>>> from llmware.retrieval import Query
>>> library = Library().create_new_library('lib_semantic_query')
>>> library.add_website(url='https://en.wikipedia.org/wiki/Austria', get_links=False)
>>> library.install_new_embedding(embedding_model_name="industry-bert-sec", vector_db="milvus", batch_size=500)
>>> query = Query(library=library)
>>> results = query.semantic_query(query='the capital of austria is', result_count=3)
>>> len(results)
3
>>> results[0].keys()
dict_keys(['query', '_id', 'text', 'doc_ID', 'block_ID', 'page_num', 'content_type',
'author_or_speaker', 'special_field1', 'file_source', 'added_to_collection',
'table', 'coords_x', 'coords_y', 'coords_cx', 'coords_cy', 'external_files',
'score', 'similarity', 'distance', 'matches', 'account_name', 'library_name'])
>>> results[0]['query']
'the capital of austria is'
>>> results[0]['text']
'Austria is a parliamentary representative democracy with a popularly elected president as head of '
'state and a chancellor as head of government and chief executive. Major cities include Vienna , Graz, '
'Linz , Salzburg , and Innsbruck . Austria has the 17th highest nominal GDP per capita with high '
'standards of living; it was ranked 25th in the world for its Human Development Index in 2021. '
>>> results[2]['text']
"Austrian Parliament Building Vienna The Parliament of Austria is located in Vienna , the country's capital "
"and most populous city. Austria became a federal , representative democratic republic through the "
"Federal Constitutional Law of 1920. The political system of the Second Republic with its nine federal "
"states is based on the constitution of 1920, amended in 1929, which was re-enacted on 1 May 1945. [108] "
"""
def __init__(self, library, embedding_model=None, tokenizer=None, vector_db_api_key=None,
query_id=None, from_hf=False, from_sentence_transformer=False,embedding_model_name=None,
save_history=True, query_mode=None, vector_db=None, model_api_key=None):
# load user profile & instantiate core library assets linked to profile
self.library = library
if library:
self.library_name = library.library_name
self.account_name = library.account_name
else:
# throw error if library object does not have library_name and account_name attributes
raise LLMWareException(message= f"Query - init - library object not found - {library}")
# explicitly pass name of embedding model, if multiple embeddings on library
self.embedding_model_name = embedding_model_name
# added option to pass embedding_model and tokenizer
self.user_passed_model = embedding_model
self.user_passed_tokenizer = tokenizer
self.from_hf = from_hf
self.from_sentence_transformer = from_sentence_transformer
# edge case - if a user tries to load a sentence_transformer model but does not pass a model name
if from_sentence_transformer and not embedding_model_name:
raise LLMWareException(message=f"Query - init - to use sentence_transformers, please "
f"provide the model name directly to load")
# load default configs
# embedding initialization parameters
self.query_embedding = None
self.embedding_model = None
self.embedding_db = None
self.embeddings = None
self.model_api_key = model_api_key
if self.library:
self.embeddings = EmbeddingHandler(self.library)
self.semantic_distance_threshold = 1000 # basic shut off at such a high level
# keys that will be included in query results
# full list
self.query_result_standard_keys = ["_id", "text", "doc_ID", "block_ID","page_num","content_type",
"author_or_speaker", "special_field1", "file_source","added_to_collection",
"table", "coords_x", "coords_y", "coords_cx", "coords_cy", "external_files",
"score", "similarity", "distance", "matches"]
# short_list
self.query_result_short_keys = ["text", "file_source", "page_num", "score", "distance","matches"]
# minimum_list
self.query_result_min_required_keys = ["text", "file_source", "page_num"]
# default - set at 'full list'
self.query_result_return_keys = self.query_result_standard_keys
# default is semantic if embedding in place
embedding_record = self.library.get_embedding_status()
matched_lib_model = False
if embedding_model_name:
for emb in embedding_record:
logger.info(f"update: Query - embedding record lookup - {embedding_model_name} - {emb}")
if emb["embedding_model"] == embedding_model_name:
# if no vector_db name passed, then select based only on embedding_model
if not vector_db:
if emb["embedding_status"] == "yes":
self.embedding_db = emb["embedding_db"]
self.search_mode = "semantic"
matched_lib_model = True
break
else:
# confirm match of pair - embedding_model + vector_db
if emb["embedding_db"] == vector_db:
if emb["embedding_status"] == "yes":
self.embedding_db = emb["embedding_db"]
self.search_mode = "semantic"
matched_lib_model = True
break
else:
if len(embedding_record) > 0:
if not vector_db:
last_emb_record = embedding_record[-1]
if last_emb_record["embedding_status"] == "yes":
self.embedding_db = last_emb_record["embedding_db"]
self.search_mode = "semantic"
self.embedding_model_name = last_emb_record["embedding_model"]
matched_lib_model = True
else:
# look for match to passed vector_db and take most recent embedding
embedding_record.reverse()
for embs in embedding_record:
if embs["embedding_db"] == vector_db:
if embs["embedding_status"] == "yes":
self.embedding_db = vector_db
self.search_mode = "semantic"
self.embedding_model_name = embs["embedding_model"]
matched_lib_model = True
break
if matched_lib_model:
logger.info(f"update: Query - found matches in embedding record - "
f"{self.embedding_db} - {self.embedding_model_name}")
if not self.embedding_model:
self.load_embedding_model()
else:
self.search_mode = "text"
# passed for accessing api_based vector db
self.vector_db_api_key = vector_db_api_key
# if query_id passed, then load that state
if query_id:
self.query_id = query_id
self.load_query_state(query_id)
else:
self.query_id = QueryState().issue_new_query_id()
self.result_text_chunk_size = self.library.block_size_target_characters
# state variables
self.results = []
self.query_history = []
self.doc_id_list = []
self.doc_fn_list = []
self.save_history = save_history
if query_mode:
self.search_mode = query_mode
# confirm that 'query_history' path exists
query_history_path = LLMWareConfig().get_query_path()
if not os.path.exists(query_history_path):
os.mkdir(query_history_path)
os.chmod(query_history_path, 0o777)
def load_embedding_model(self):
""" Loads the embedding model pulled from the embedding_record of the library. """
# skip if already instantiated self.embedding_model
if not self.embedding_model:
if self.user_passed_model:
if self.from_hf:
self.embedding_model = ModelCatalog().load_hf_embedding_model(self.user_passed_model,
self.user_passed_tokenizer)
if self.from_sentence_transformer:
self.embedding_model = ModelCatalog().load_sentence_transformer_model(self.user_passed_model,
self.embedding_model_name)
else:
if ModelCatalog().lookup_model_card(self.embedding_model_name):
self.embedding_model = ModelCatalog().load_model(selected_model=self.embedding_model_name,
api_key=self.model_api_key)
else:
logger.info(f"update: Query - selected embedding model could not be found - "
f"{self.embedding_model_name}")
return self
def get_output_keys(self):
""" Returns list of keys that will be provided in each query_result. """
return self.query_result_return_keys
def set_output_keys(self, result_key_list):
""" Sets the list of keys that will be returned in each query_result. """
# set the output keys
validated_list = []
for key in result_key_list:
if key in self.library.default_keys:
validated_list.append(key)
# minimum required list
for rk in self.query_result_min_required_keys:
if rk not in validated_list:
validated_list.append(rk)
logger.info(f"warning: Query - adding required keys useful in downstream processing - {rk}")
# setting updated query_return_keys that is used in packaging query results
self.query_result_return_keys = validated_list
return validated_list
def start_query_session(self, query_id=None):
""" Initiates a query session and will capture potentially multiple related queries in single state. """
if query_id:
self.query_id = query_id
if self.query_id:
QueryState(self).load_state(self.query_id)
else:
query_id = QueryState(self).initiate_new_state_session()
return query_id
def register_query (self, retrieval_dict):
""" Registers a query to the query state. """
# qr_dict = ["query", "results", "doc_ID", "file_source"]
# add query results as new "column" in query state
self.results += retrieval_dict["results"]
if retrieval_dict["query"] not in self.query_history:
self.query_history.append(retrieval_dict["query"])
for doc_id in retrieval_dict["doc_ID"]:
if doc_id not in self.doc_id_list:
self.doc_id_list.append(doc_id)
for doc_fn in retrieval_dict["file_source"]:
if doc_fn not in self.doc_fn_list:
self.doc_fn_list.append(doc_fn)
# QueryState(self).save_state(self.query_id)
return self
def load_query_state(self, query_id):
""" Loads a query state of a previous query by query_id """
state = QueryState(self).load_state(query_id)
return self
def save_query_state(self):
""" Saves the current query state. """
QueryState(self).save_state()
return self
def clear_query_state(self):
""" Resets the query state. """
# need to reset state variables
QueryState(self).initiate_new_state_session()
return self
def dump_current_query_state(self):
""" Dumps the current query_state to a query_state_dict. """
query_state_dict = {"query_id": self.query_id,
"query_history": self.query_history,
"results": self.results,
"doc_ID": self.doc_id_list,
"file_source": self.doc_fn_list
}
return query_state_dict
def query(self, query, query_type="text", result_count=20, results_only=True):
""" Main method for executing a basic query - expects query as input, and optional parameters for
query_type, result_count and whether results_only. Output is a set of query results, which is a list of
dictionaries, with each dictionary representing a single matching retrieval from the collection. """
output_result = {"results": [], "doc_ID": [], "file_source": []}
if query_type not in ["text", "semantic"]:
logger.error("error: Query().query expects a query type of either 'text' or 'semantic'")
return output_result
if query_type == "text":
output_result = self.text_query(query,result_count=result_count,results_only=results_only)
if query_type == "semantic":
# check that embedding model is available, and if not, flip to text search
if not self.embedding_model:
self.load_embedding_model()
if self.search_mode == "text" or not self.embedding_model:
output_result = self.text_query(query, result_count=result_count,results_only=results_only)
else:
output_result = self.semantic_query(query, result_count=result_count,results_only=results_only)
return output_result
def text_query (self, query, exact_mode=False, result_count=20, exhaust_full_cursor=False, results_only=True):
""" Execute a basic text query. """
# prepare query if exact match required
if exact_mode:
query = self.exact_query_prep(query)
# query the text collection
cursor = CollectionRetrieval(self.library_name,account_name=self.account_name).basic_query(query)
# package results, with correct sample counts and output keys requested
results_dict = self._cursor_to_qr(query, cursor,result_count=result_count,exhaust_full_cursor=
exhaust_full_cursor)
if results_only:
return results_dict["results"]
return results_dict
def text_query_with_document_filter(self, query, doc_filter, result_count=20, exhaust_full_cursor=False,
results_only=True, exact_mode=False):
""" Execute a text query with a document filter applied. """
# prepare query if exact match required
if exact_mode:
query = self.exact_query_prep(query)
key = None
value_range = []
if "doc_ID" in doc_filter:
key = "doc_ID"
value_range = doc_filter["doc_ID"]
elif "file_source" in doc_filter:
key = "file_source"
value_range = doc_filter["file_source"]
else:
logger.warning("warning: Query - expected to receive document filter with keys of 'doc_ID' or "
"'file_source' - as a safe fall-back - will run the requested query without a filter.")
if key:
cursor = CollectionRetrieval(self.library_name, account_name=self.account_name). \
text_search_with_key_value_range(query, key, value_range)
else:
# as fallback, if no key found, then run query without filter
cursor = CollectionRetrieval(self.library_name, account_name=self.account_name).basic_query(query)
result_dict = self._cursor_to_qr(query, cursor, result_count=result_count,
exhaust_full_cursor=exhaust_full_cursor)
if results_only:
return result_dict["results"]
return result_dict
def text_query_by_content_type (self, query, content_type,results_only=True):
""" Execute a text query with additional constraint of content type, e.g., 'image', 'table', or 'text' """
filter_dict = {"content_type": content_type}
retrieval_dict = self.text_query_with_custom_filter(query,filter_dict,results_only=True)
return retrieval_dict
def image_query(self, query, results_only=True):
""" Execute a query with content_type == 'image'. """
filter_dict = {"content_type": "image"}
retrieval_dict = self.text_query_with_custom_filter(query, filter_dict,results_only=True)
return retrieval_dict
def table_query(self, query, export_tables_to_csv=False, results_only=True):
""" Execute a query with content_type == 'table'. """
filter_dict = {"content_type": "table"}
retrieval_dict = self.text_query_with_custom_filter(query, filter_dict,results_only=True)
# output and write tables to csv files
if export_tables_to_csv:
for i, entry in enumerate(retrieval_dict["results"]):
f = self.export_one_table_to_csv(entry,output_fp=LLMWareConfig.get_query_path(),
output_fn="table_{}.csv".format(i))
logger.warning(f"update: csv created - {LLMWareConfig.get_query_path()}- {f}")
return retrieval_dict
def text_search_by_page (self, query, page_num=1, results_only=True):
""" Execute a text search with page number constraint provided as page_num parameter. """
key = "master_index" # parsing uses "master_index" across multiple input sources, interpret as "page_num"
if not isinstance(page_num, list):
page_num = [page_num]
cursor_results = CollectionRetrieval(self.library_name, account_name=self.account_name).\
text_search_with_key_value_range(query, key, page_num)
retrieval_dict = self._cursor_to_qr(query, cursor_results)
if results_only:
return retrieval_dict["results"]
return retrieval_dict
def text_query_by_author_or_speaker(self, query, author_or_speaker, results_only=True):
""" Execute a text query with specific author_or_speaker constraint. """
filter_dict = {"author_or_speaker": author_or_speaker}
retrieval_dict = self.text_query_with_custom_filter(query,filter_dict,results_only=results_only)
return retrieval_dict
def text_query_with_custom_filter (self, query, filter_dict, result_count=20,
exhaust_full_cursor=False, results_only=True):
""" Execute a text query with additional custom filter dictionary. """
# filter_dict is a dict with indefinite number of key:value pairs - each key will be interpreted
# as "$and" in the query, requiring a match against all of the key:values in the filter_dict
# validate filter dict
validated_filter_dict = {}
for key, values in filter_dict.items():
for valid_keys in self.library.default_keys:
if key in valid_keys:
validated_filter_dict.update({key:values})
if validated_filter_dict:
cursor = CollectionRetrieval(self.library_name, account_name=self.account_name).\
text_search_with_key_value_dict_filter(query,validated_filter_dict)
else:
logger.error("error: Query text_query_with_custom_filter - keys in filter_dict are not"
"recognized as part of the library.collection default_keys list.")
return -1
result_dict = self._cursor_to_qr_with_secondary_filter(query, cursor,filter_dict,
result_count=result_count,
exhaust_full_cursor=exhaust_full_cursor)
if results_only:
return result_dict["results"]
return result_dict
def _cursor_to_qr_with_secondary_filter(self, query, cursor_results, filter_dict,
result_count=20, exhaust_full_cursor=False):
""" Internal helper method to package query results from cursor. """
qr = []
counter = 0
doc_id_list = []
doc_fn_list = []
for raw_qr in cursor_results:
# update to locate match and add to result
matches_found = self.locate_query_match(query, raw_qr["text"])
raw_qr.update({"matches": matches_found})
raw_qr.update({"page_num": raw_qr["master_index"]})
raw_qr.update({"_id": str(raw_qr["_id"])})
if "score" not in raw_qr:
raw_qr.update({"score": 0.0})
if "similarity" not in raw_qr:
raw_qr.update({"similarity": 0.0})
if "distance" not in raw_qr:
raw_qr.update({"distance": 0.0})
# apply secondary filter dict
match = -1
for key, value in filter_dict.items():
if key in raw_qr:
# support case in which filter_dict is a list, e.g., doc_id = {5,9,13}
if raw_qr[key] == value or (isinstance(value,list) and raw_qr[key] in value):
match = 1
else:
match = -1
break
if match == 1:
# output target keys
output_dict = {}
output_dict.update({"query": query})
for key in self.query_result_return_keys:
if key not in raw_qr:
logger.warning(f"warning: Query() - selected output key not found in result - {key}")
else:
output_dict.update({key: raw_qr[key]})
output_dict.update({"account_name": self.account_name})
output_dict.update({"library_name": self.library_name})
qr.append(output_dict)
if raw_qr["doc_ID"] not in doc_id_list:
doc_id_list.append(raw_qr["doc_ID"])
doc_fn_list.append(raw_qr["file_source"])
counter += 1
# will exhaust full cursor if .exhaust_full_cursor = True
if counter >= result_count and not exhaust_full_cursor:
break
qr_dict = {"query": query, "results": qr, "doc_ID": doc_id_list, "file_source": doc_fn_list}
if self.save_history:
self.register_query(qr_dict)
return qr_dict
def _cursor_to_qr (self, query, cursor_results, result_count=20, exhaust_full_cursor=False):
""" Internal helper method to package query results from cursor. """
qr = []
counter = 0
doc_id_list = []
doc_fn_list = []
for raw_qr in cursor_results:
# update to locate match and add to result
matches_found = self.locate_query_match(query, raw_qr["text"])
raw_qr.update({"matches": matches_found})
raw_qr.update({"page_num": raw_qr["master_index"]})
raw_qr.update({"_id": str(raw_qr["_id"])})
if "score" not in raw_qr:
raw_qr.update({"score": 0.0})
if "similarity" not in raw_qr:
raw_qr.update({"similarity": 0.0})
if "distance" not in raw_qr:
raw_qr.update({"distance": 0.0})
# output target keys
output_dict = {}
output_dict.update({"query": query})
for key in self.query_result_return_keys:
if key not in raw_qr:
logger.warning(f"warning: Query() - selected output key not found in result - {key}")
else:
output_dict.update({key: raw_qr[key]})
output_dict.update({"account_name": self.account_name})
output_dict.update({"library_name": self.library_name})
qr.append(output_dict)
if raw_qr["doc_ID"] not in doc_id_list:
doc_id_list.append(raw_qr["doc_ID"])
doc_fn_list.append(raw_qr["file_source"])
counter += 1
# will exhaust full cursor if .exhaust_full_cursor = True
if counter >= result_count and not exhaust_full_cursor:
break
qr_dict = {"query": query,"results": qr, "doc_ID": doc_id_list, "file_source": doc_fn_list}
if self.save_history:
self.register_query(qr_dict)
return qr_dict
def semantic_query(self, query, result_count=20, embedding_distance_threshold=None, custom_filter=None, results_only=True):
""" Main method to execute a semantic query - only required parameter is the query. """
if not embedding_distance_threshold:
embedding_distance_threshold = self.semantic_distance_threshold
self.load_embedding_model()
# confirm that embedding model exists, or catch and raise error
if self.embedding_model:
self.query_embedding = self.embedding_model.embedding(query)
else:
raise ModelNotFoundException(self.library_name)
if self.embedding_db and self.embedding_model:
semantic_block_results = self.embeddings.search_index(self.query_embedding,
embedding_db=self.embedding_db,
model=self.embedding_model,
sample_count=result_count)
else:
logger.error(f"error: Query - embedding record does not indicate embedding db - "
f"{self.embedding_db} and/or embedding model - {self.embedding_model}")
raise LLMWareException(message=f"Query - semantic query - selected "
f"embedding database is not supported - {self.embedding_db}")
qr_raw = []
# Collecting semantic results
for i, blocks in enumerate(semantic_block_results):
if blocks[1] < embedding_distance_threshold:
block_data = blocks[0]
block_data["distance"] = blocks[1]
block_data["semantic"] = "semantic"
block_data["score"] = 0.0
qr_raw.append(block_data)
# Applying custom filter if provided
if custom_filter:
qr_raw = self.apply_custom_filter(qr_raw, custom_filter)
# Processing results
results_dict = self._cursor_to_qr(query, qr_raw, result_count=result_count)
return results_dict["results"] if results_only else results_dict
def apply_custom_filter(self, results, custom_filter):
""" Apply custom filter to a set of results. """
filtered_results = []
for result in results:
if all(result.get(key) == value for key, value in custom_filter.items()):
filtered_results.append(result)
return filtered_results
def semantic_query_with_document_filter(self, query, filter_dict, embedding_distance_threshold=None,
result_count=100, results_only=True):
""" Execute semantic query with secondary constraint of document filter. """
# checks for filter to offer option to do semantic query in specific doc, page or content range
if not embedding_distance_threshold:
embedding_distance_threshold = self.semantic_distance_threshold
# note: by default, retrieves a much larger set of results to try to account for filter
th = self.semantic_distance_threshold
# confirm that embedding model exists, or catch and raise error
if self.embedding_model:
self.query_embedding = self.embedding_model.embedding(query)
else:
raise ModelNotFoundException(self.library_name)
if self.embedding_db and self.embedding_model:
semantic_block_results = self.embeddings.search_index(self.query_embedding,
embedding_db=self.embedding_db,
model=self.embedding_model,
sample_count=result_count)
else:
logger.error(f"error: Query - embedding record does not indicate embedding db- {self.embedding_db} "
f"and/or an embedding_model - {self.embedding_model}")
raise LLMWareException(message=f"Query - semantic query with document filter - selected "
f"embedding database is not supported - {self.embedding_db}")
qr_raw = []
# may need to conform the output structure of semantic_block_results
for i, blocks in enumerate(semantic_block_results):
# assume that each block has at least two components: [0] core mongo block, and [1] distance metric
if blocks[1] < embedding_distance_threshold:
blocks[0].update({"distance": blocks[1]})
blocks[0].update({"semantic": "semantic"})
blocks[0].update({"score": 0.0})
qr_raw.append(blocks[0])
result_output = self._cursor_to_qr_with_secondary_filter(query,qr_raw,filter_dict,result_count=result_count)
if results_only:
return result_output["results"]
return result_output
def similar_blocks_embedding(self, block, result_count=20, embedding_distance_threshold=10, results_only=True):
""" Application of semantic embedding space - takes a block of text as input and finds most similar comparable
text chunks. If you are comfortable with the normalization of the embedding vector space, then set a
specific embedding_distance_threshold, e.g., embedding_distance_threshold=1.1 to limit the results to
text blocks within a distance of 1.1 in the embedding space."""
# will use embedding to find similar blocks from a given block
# confirm that embedding model exists, or catch and raise error
if self.embedding_model:
self.query_embedding = self.embedding_model.embedding(block["text"])
else:
raise ModelNotFoundException(self.library_name)
if self.embedding_model and self.embedding_db:
semantic_block_results = self.embeddings.search_index(self.query_embedding,
embedding_db=self.embedding_db,
model=self.embedding_model,
sample_count=result_count)
else:
logger.error(f"error: Query - embedding record does not indicate embedding db- "
f"{self.embedding_db} and/or embedding model - {self.embedding_model}")
raise LLMWareException(message=f"Query - similar blocks embedding - selected "
f"embedding database is not supported - {self.embedding_db}")
qr_raw = []
# may need to conform the output structure of semantic_block_results
for i, blocks in enumerate(semantic_block_results):
# assume that each block has at least two components: [0] core mongo block, and [1] distance metric
if blocks[1] < embedding_distance_threshold:
blocks[0].update({"distance": blocks[1]})
blocks[0].update({"semantic": "semantic"})
blocks[0].update({"score": 0.0})
qr_raw.append(blocks[0])
# pick up with boilerplate
results_dict = self._cursor_to_qr("", qr_raw, result_count=result_count)
if results_only:
return results_dict["results"]
return results_dict
def dual_pass_query(self, query, result_count=20, primary="text",
safety_check=True, custom_filter=None, results_only=True):
""" Executes a combination of text and semantic queries and attempts to interweave and re-rank based on
correspondence between the two query attempts. """
# safety check
if safety_check and result_count > 100:
logger.info("warning: Query().dual_pass_query runs a comparison of output rankings using semantic "
"and text. This particular implementation is not optimized for sample lists longer "
"than ~100 X 100. To remove this warning, there are two options - (1) set the "
"safety_check to False in the method declaration, or (2) keep sample count below 100.")
result_count = 100
# following keys are required for dual pass query to work, add them if user has omitted them
keys_to_check = ['_id', 'doc_ID']
for key in keys_to_check:
if key not in self.query_result_return_keys:
self.query_result_return_keys.append(key)
# run dual pass - text + semantic
# Choose appropriate text query method based on custom_filter
if custom_filter:
retrieval_dict_text = self.text_query_with_custom_filter(query, custom_filter,
result_count=result_count, results_only=True)
else:
retrieval_dict_text = self.text_query(query, result_count=result_count, results_only=True)
# Semantic query with custom filter
retrieval_dict_semantic = self.semantic_query(query, result_count=result_count,
custom_filter=custom_filter, results_only=True)
if primary == "text":
first_list = retrieval_dict_text
second_list = retrieval_dict_semantic
else:
first_list = retrieval_dict_semantic
second_list = retrieval_dict_text
confirming_list = []
primary_only = []
secondary_only = []
matched_second_list = []
# this is the time intensive "n-squared" loop - probably OK up to 100+
for i, entry in enumerate(first_list):
match = -1
for j, entry2 in enumerate(second_list):
if entry["_id"] == entry2["_id"]:
entry["match_status"] = "matched" # Tagging as matched
confirming_list.append(entry)
match = 1
matched_second_list.append(entry2["_id"])
break
if match == -1:
entry["match_status"] = "primary_only" # Tagging as primary only
primary_only.append(entry)
for k, entry2 in enumerate(second_list):
if entry2["_id"] not in matched_second_list:
entry2["match_status"] = "secondary_only" # Tagging as secondary only
secondary_only.append(entry2)
# assemble merged top results
merged_results = []
merged_results += confirming_list
select_primary = min(len(primary_only), 5)
select_secondary = min(len(secondary_only), 5)
merged_results += primary_only[0:select_primary]
merged_results += secondary_only[0:select_secondary]
doc_id_list = []
doc_fn_list = []
for qr in merged_results:
if qr["doc_ID"] not in doc_id_list:
doc_id_list.append(qr["doc_ID"])
if qr["file_source"] not in doc_fn_list:
doc_fn_list.append(qr["file_source"])
retrieval_dict = {"results": merged_results,
"text_results": retrieval_dict_text,
"semantic_results": retrieval_dict_semantic,
"doc_ID": doc_id_list,
"file_source": doc_fn_list}
if results_only:
return merged_results
return retrieval_dict
def augment_qr (self, query_result, query_topic, augment_query="semantic"):
""" Augments the set of query results using alternative retrieval strategy. """
if augment_query == "semantic":
qr_aug = self.semantic_query(query_topic,result_count=20, results_only=True)
else:
qr_aug = self.text_query(query_topic,result_count=20, results_only=True)
# consolidate the qr lists
updated_qr = []
for qr in query_result:
updated_qr.append(qr) # start with original qr list
# add up to 10 entries from semantic list
semantic_return_max = 10
for j, sem_entries in enumerate(qr_aug):
if sem_entries not in updated_qr:
updated_qr.append(sem_entries)
if j > semantic_return_max:
break
return updated_qr
def apply_semantic_ranking(self, qr, issue_semantic):
""" Apply ranking of query results by semantic query ranking. """
# designed to take a set of query results, and re-rank the order of results by their semantic distance
# --note: possible to use a different query term for issue_semantic than the original query result
# heuristic - look for result targets of at least 20, but up to the exact len of the qr
result_target = max(len(qr),20)
semantic_qr = self.semantic_query(issue_semantic,result_count=result_target)
reranked_qr = []
for i, s in enumerate(semantic_qr):
for q in qr:
if s["_id"] == q["_id"]:
reranked_qr.append(q)
break
for q in qr:
if q not in reranked_qr:
reranked_qr.append(q)
return reranked_qr
def document_filter (self, filter_topic, query_mode="text", result_count=30,
exact_mode = False, exhaust_full_cursor=True):
""" Takes a filter_topic as input, along with query_mode, and generates a document filter as output. """
result_dict = None
if query_mode not in ["text", "semantic", "hybrid"]:
logger.error(f"error: Query document_filter supports query types - 'text', "
f"'semantic', and 'hybrid' - type selected not recognized - {query_mode}")
return result_dict
if query_mode == "text":
result_dict = self.text_query(filter_topic,exact_mode=exact_mode,result_count=result_count,
exhaust_full_cursor=exhaust_full_cursor,results_only=False)
if query_mode == "semantic":
result_dict = self.semantic_query(filter_topic,result_count=result_count, results_only=False)
if query_mode == "hybrid":
result_dict = self.dual_pass_query(filter_topic)
if not result_dict:
logger.error(f"error: Query file_selector_only could not find a result - unexpected error - "
f"{filter_topic}")
return result_dict
doc_filter_output = {"doc_ID": result_dict["doc_ID"], "file_source": result_dict["file_source"]}
return doc_filter_output
def page_lookup(self, page_list=None, doc_id_list=None, text_only=False):
""" Look up by specific pages, e.g, useful to retrieve the entire first page of a template document. """
if not doc_id_list:
doc_id_list = self.list_doc_id()
else:
if isinstance(doc_id_list,dict):
if "doc_ID" in doc_id_list:
doc_id_list = doc_id_list["doc_ID"]
else:
logger.warning("warning: could not recognize doc id list requested. by default, "
"will set to all documents in the library collection.")
doc_id_list = self.list_doc_id()
if not page_list:
logger.warning("warning: page lookup requested, but no value range identified. by default, will set "
"to retrieve the first page only.")
page_list = [1]
if text_only:
page_dict = {"doc_ID": {"$in":doc_id_list}, "master_index": {"$in": page_list}, "content_type":"text"}
else:
page_dict = {"doc_ID": {"$in":doc_id_list}, "master_index": {"$in": page_list}}
cursor_results = CollectionRetrieval(self.library_name,
account_name=self.account_name).filter_by_key_dict(page_dict)
output = []
for x in cursor_results:
x.update({"matches": []})
x.update({"page_num": x["master_index"]})
output.append(x)
return output
def get_whole_library(self, selected_keys=None):
""" Gets the whole library - and will return as a list in-memory. """
match_results_cursor = CollectionRetrieval(self.library_name,
account_name=self.account_name).get_whole_collection()
match_results = match_results_cursor.pull_all()
qr = []
# option to retrieve only user selected keys
if not selected_keys:
selected_keys = self.library.default_keys
for i, block in enumerate(match_results):
new_row = {}
new_row.update({"_id": str(block["_id"])})
new_row.update({"matches": []})
new_row.update({"page_num": block["master_index"]})
new_row.update({"score": 0.0})
new_row.update({"similarity": 0.0})
new_row.update({"distance": 0.0})
for keys in selected_keys:
if keys in block:
if keys not in new_row:
new_row.update({keys:block[keys]})
qr.append(new_row)
return qr
def export_all_tables(self, query="", output_fp=None):
""" Exports all tables, with query option to limit the list from a library. """
table_csv_files_created = []
if not output_fp:
output_fp = self.library.misc_path
if not query:
match_results = CollectionRetrieval(self.library_name,
account_name=self.account_name).filter_by_key("content_type","table")
else:
kv_dict = {"content_type": "table"}
match_results = CollectionRetrieval(self.library_name, account_name=self.account_name).\
text_search_with_key_value_dict_filter(query,kv_dict)
counter = 0
for i, entries in enumerate(match_results):
table = entries["table"]
output = []
table_raw = table
rows = table_raw.split("<tr>")
cols_tracker = []
coords_master = []
for row in rows:
new_row = []
if row.strip().endswith("</tr>"):
row = row.strip()[:-5]
cells = row.lstrip().split("<th>")
cols_count = 0
coords = []
for c in cells:
if c.strip().endswith("</th>"):
c = c.strip()[:-5]
clean_cell = ""
bracket_on = 0
fields = c.split("<")
if fields[0]:
index = fields[1].rstrip()[0:-1]
main_entry = fields[2].split(">")
value = main_entry[-1]
co = main_entry[0].split(" ")
if len(co) > 2:
x = co[1]
y = co[2]
coords.append((int(x), int(y)))
for c1 in c:
if bracket_on == 0 and c1 not in ("<", ">"):
clean_cell += c1
if c1 == "<":
bracket_on = 1
if c1 == ">":
bracket_on = 0
if c:
c_strip = c.split(">")[-1]
new_row.append(c_strip.strip())
cols_count += 1
coords_master.append(coords)
cols_tracker.append(cols_count)
output.append(new_row)
new_file = "table_{}.csv".format(counter)
counter += 1
f = Utilities().file_save(output, output_fp, new_file)
output = []
table_csv_files_created.append(new_file)
output_dict = {"library": self.library_name, "query": query, "tables_created": counter,
"file_names": table_csv_files_created, "output_fp": output_fp}
return output_dict
def export_one_table_to_csv(self, query_result, output_fp=None, output_fn=None):
""" Exports a single table query result into a csv file. """
table = query_result["table"]
output = []
table_raw = table
rows = table_raw.split("<tr>")
cols_tracker = []
coords_master = []
for row in rows:
new_row = []
if row.strip().endswith("</tr>"):
row = row.strip()[:-5]
cells = row.lstrip().split("<th>")
cols_count = 0
coords = []
for c in cells:
if c.strip().endswith("</th>"):
c = c.strip()[:-5]
clean_cell = ""
bracket_on = 0
fields = c.split("<")
if fields[0]:
index = fields[1].rstrip()[0:-1]
main_entry = fields[2].split(">")
value = main_entry[-1]
co = main_entry[0].split(" ")
if len(co) > 2:
x = co[1]
y = co[2]
coords.append((int(x), int(y)))
for c1 in c:
if bracket_on == 0 and c1 not in ("<", ">"):
clean_cell += c1
if c1 == "<":
bracket_on = 1
if c1 == ">":
bracket_on = 0
if c:
c_strip = c.split(">")[-1]
new_row.append(c_strip.strip())
cols_count += 1
coords_master.append(coords)
cols_tracker.append(cols_count)
output.append(new_row)
if not output_fn:
new_file = "table_0.csv"
else:
new_file = output_fn
f = Utilities().file_save(output, output_fp, new_file)
return new_file
def list_doc_id(self):
""" Utility function - returns list of all doc_ids in the library. """
doc_id_list = CollectionRetrieval(self.library_name, account_name=self.account_name).get_distinct_list("doc_ID")
return doc_id_list
def list_doc_fn(self):
""" Utility function - returns list of all document names in the library. """
doc_fn_raw_list = CollectionRetrieval(self.library_name,
account_name=self.account_name).get_distinct_list("file_source")
doc_fn_out = []
for i, file in enumerate(doc_fn_raw_list):
doc_fn_out.append(file.split(os.sep)[-1])
return doc_fn_out
def aggregate_text(self, qr_list):
""" Utility method that take a list of query result dictionaries as input (with all of the associated metadata
attributes) and repackages into two useful outputs:
-- text_agg, which is the aggregated text across all of the query results in a single unbroken string, and
-- meta_agg, which is a list of dictionaries with all of the 'start/stop' information in the text
string, which can be used to map back a snippet of text back to its original block entry in the DB.
"""
text_agg = ""
meta_agg = []
for i, entry in enumerate(qr_list):
t = entry["text"]
meta = {"start_char": len(text_agg), "stop_char": len(text_agg) + len(t), "block_id": entry["block_ID"],
"doc_ID": entry["doc_ID"], "page_num": entry["page_num"]}
meta_agg.append(meta)
text_agg += t
return text_agg, meta_agg
def document_lookup(self, doc_id="", file_source="", include_images=False):
"""
Takes as an input either a doc_id or file_source (e.g., filename) that is in a Library, and
returns all of the text and table blocks in the document. Images can be optionally included.
Parameters:
doc_id (str): Document ID.
file_source (str): Source file name.
include_images (bool): Whether to include images in the result. Defaults to False.
Returns:
list: Filtered list of document blocks.
"""
if doc_id:
kv_dict = {"doc_ID": doc_id}
elif file_source:
kv_dict = {"file_source": file_source}
else:
raise RuntimeError(
"Query document_lookup method requires as input either a document ID or "
"the name of a file already parsed in the library"
)
output = CollectionRetrieval(self.library_name, account_name=self.account_name).filter_by_key_dict(kv_dict)
if len(output) == 0:
logger.warning(f"update: Query - document_lookup - nothing found - {doc_id} - {file_source}")
return []
output_final = []
for entries in output:
# Filter out images if include_images is False
if include_images or entries["content_type"] != "image":
entries.update({"matches": []})
entries.update({"page_num": entries["master_index"]})
output_final.append(entries)
output_final = sorted(output_final, key=lambda x: x["block_ID"], reverse=False)
return output_final
def block_lookup(self, block_id, doc_id):
""" Look up by a specific pair of doc_id and block_id in a library. """
result = None
kv_dict = {"doc_ID": doc_id, "block_ID": block_id}
output = CollectionRetrieval(self.library_name, account_name=self.account_name).filter_by_key_dict(kv_dict)
if len(output) == 0:
logger.info(f"update: Query - Library - block_lookup - block not found: {block_id}")
result = None
return result
if len(output) > 1:
result = output[0]
if len(output) == 1:
result = output[0]
# if arrived this point, then positive result has been identified
result.update({"matches": []})
result.update({"page_num": result["master_index"]})
return result
def get_header_text_from_collection(self, text_field="header_text"):
""" Pulls the header_text from the collection, captured during parsing, useful to identify headlines. """
ds_folder = self.library.nlp_path
results = CollectionRetrieval(self.library_name, account_name=self.account_name).get_whole_collection()
f = open(ds_folder + "header_text.txt", "w", encoding='utf-8')
counter = 0
for elements in results:
text_sample = elements[text_field]
if text_sample:
f.write(text_sample)
f.write("\n")
f.write(elements["text"])
f.write("\n")
counter += 1
f.close()
results.close()
return counter
def get_core_text_from_collection(self, text_field="text"):
""" Returns all core text from a collection. """
ds_folder = self.library.nlp_path
results = CollectionRetrieval(self.library_name, account_name=self.account_name).get_whole_collection()
f = open(os.path.join(ds_folder,"core_text.txt"), "w", encoding='utf-8')
counter = 0
for elements in results:
text_sample = elements[text_field]
if text_sample:
f.write(text_sample)
f.write("\n")
counter += 1
f.close()
results.close()
return counter
def get_user_tags(self):
""" Returns user tags, if any, identified - note: this is experimental method and may change."""
# look for all non-empty user_tags
output = CollectionRetrieval(self.library_name,
account_name=self.account_name).filter_by_key_ne_value("user_tags", "")
counter = 0
user_tags_out = []
for elements in output:
counter += 1
user_tags_out.append((elements["block_ID"], elements["user_tags"]))
return user_tags_out
def filter_by_time_stamp (self, qr, first_date="", last_date=""):
""" Filters results by condition of time range. """
# apply filter dict to the qr results found
time_str = "%Y-%m-%d"
if first_date:
first_date = datetime.strptime(first_date,time_str)
if last_date:
last_date = datetime.strptime(last_date, time_str)
filtered_qr = []
for i, entry in enumerate(qr):
if entry["added_to_collection"]:
try:
# First try Linux format
time_str = "%a %b %d %H:%M:%S %Y"
doc_date = datetime.strptime(entry["added_to_collection"], time_str)
except ValueError:
# If it fails, try Windows format
time_str = "%m/%d/%y %H:%M:%S"
doc_date = datetime.strptime(entry["added_to_collection"], time_str)
time_accept = self._time_window_filter(first_date,last_date,doc_date)
if time_accept:
filtered_qr.append(entry)
return filtered_qr
def _time_window_filter(self, start_time,end_time, test_time, time_str="%a %b %d %H:%M:%S %Y"):
""" Internal helper function to evaluate and compare time stamps. """
if start_time and end_time:
if start_time <= test_time <= end_time:
return True
if start_time and not end_time:
if start_time <= test_time:
return True
if not start_time and end_time:
if test_time <= end_time:
return True
return False
def locate_query_match (self, query, core_text):
""" Utility function to locate the character-level match of a query inside a core_text. """
matches_found = []
# edge case - but return empty match if query is null
if not query:
return matches_found
b = CorpTokenizer(one_letter_removal=False, remove_stop_words=False, remove_punctuation=False,
remove_numbers=False)
query_tokens = b.tokenize(query)
for x in range(0, len(core_text)):
match = 0
for key_term in query_tokens:
if len(key_term) == 0:
continue
if key_term.startswith('"'):
key_term = key_term[1:-1]
if core_text[x].lower() == key_term[0].lower():
match += 1
if (x + len(key_term)) <= len(core_text):
for y in range(1, len(key_term)):
if key_term[y].lower() == core_text[x + y].lower():
match += 1
else:
match = -1
break
if match == len(key_term):
new_entry = [x, key_term]
matches_found.append(new_entry)
return matches_found
def exact_query_prep(self, query):
""" Prepares an exact query prep. """
if query.startswith('"') and query.endswith('"'):
prepared_query = '\"' + query[1:-1] + '\"'
else:
# even if user did not wrap in quotes, treat as exact search
prepared_query = '\"' + query + '\"'
return prepared_query
def bibliography_builder_from_qr(self, query_results):
""" Builds a bibliography from a query result. """
bibliography = []
doc_id_reviewed = []
doc_fn_reviewed = []
# first - assemble the list of docs in the query_results
for y in range(0,len(query_results)):
if "doc_ID" in query_results[y]:
if query_results[y]["doc_ID"] not in doc_id_reviewed:
doc_id_reviewed.append(query_results[y]["doc_ID"])
doc_fn_reviewed.append(query_results[y]["file_source"])
# second - identify and sort the key pages associated with the doc
for x in range(0,len(doc_id_reviewed)):
pages_reviewed = []
for z in range(0,len(query_results)):
if "doc_ID" in query_results[z]:
if query_results[z]["doc_ID"] == doc_id_reviewed[x]:
pages_reviewed.append(query_results[z]["page_num"])
pr = Counter(pages_reviewed)
mc = pr.most_common()
page_output_list = []
for m in mc:
page_output_list.append(m[0])
if len(doc_fn_reviewed) > x:
doc_fn_tmp = doc_fn_reviewed[x]
else:
doc_fn_tmp = "Doc# " + str(doc_id_reviewed[x])
bibliography.append({doc_fn_tmp:page_output_list})
return bibliography
def filter_cursor_list(self, cursor, filter_dict, sample_count=20, exhaust_full_cursor=None):
""" Applies filter to a cursor list. """
validated_filter_dict = self.prep_validated_filter_dict(filter_dict)
result_output = []
for i, entry in enumerate(cursor):
for key, value in validated_filter_dict.items():
if key not in entry:
logger.warning(f"warning: Query - retrieval cursor does not contain filter key - {key}")
else:
if entry[key] == value:
result_output.append(entry)
if len(result_output) > sample_count and not exhaust_full_cursor:
break
return result_output
def prep_validated_filter_dict(self, filter_dict):
""" Internal utility to prepare a validated filter dict. """
validated_filter_dict = {}
for key, values in filter_dict.items():
if key in self.library.default_keys:
validated_filter_dict.update({key:values})
else:
logger.warning(f"warning: Query - filter key not in library collection - {key}")
return validated_filter_dict
def block_lookup_by_collection_id(self, _id):
""" Block lookup using collection id key specifically. """
# specific to Mongo lookup - uses mongo '_id' which needs to be wrapped in ObjectId
return CollectionRetrieval(self.library_name,
account_name=self.account_name).filter_by_key("_id", ObjectId(_id))
def compare_text_blocks(self, t1, t2):
""" Token-by-token comparison of two text blocks. """
b = CorpTokenizer(one_letter_removal=True, remove_numbers=True, remove_stop_words=True)
tokens1 = b.tokenize(t1)
tokens2 = b.tokenize(t2)
match_per = 0
match = 0
for x in range(0, len(tokens1)):
for y in range(0, len(tokens2)):
if tokens1[x].lower() == tokens2[y].lower():
match += 1
break
if len(tokens1) > 0:
match_per = match / len(tokens1)
return match_per
def block_similarity_retrieval_more_like_this (self, target_text, qr, similarity_threshold=0.25):
""" Block similarity by token comparison. """
# will rank and order a list of query results using a target text as the reference point
output = []
for i, block in enumerate(qr):
compare_text = block["text"]
similarity = self.compare_text_blocks(target_text, compare_text)
if similarity > similarity_threshold:
block.update({"similarity": similarity})
output.append(block)
output = sorted(output, key=lambda x:x["similarity"], reverse=True)
return output
def build_doc_id_fn_list(self, qr):
""" Utility function to build a doc_id and filename list. """
doc_id_list = []
fn_list = []
for q in qr:
if q["doc_ID"] not in doc_id_list:
doc_id_list.append(q["doc_ID"])
fn_list.append(q["file_source"])
return doc_id_list, fn_list
def expand_text_result_before(self, block, window_size=400):
""" Expands text result before. """
block_id = block["block_ID"] -1
doc_id = block["doc_ID"]
before_text = ""
pre_blocks = []
while len(before_text) < window_size and block_id >= 0:
before_block = self.block_lookup(block_id, doc_id)
if before_block:
before_text += before_block["text"]
pre_blocks.append(before_block)
output = {"expanded_text": before_text, "results": pre_blocks}
return output
def expand_text_result_after(self, block, window_size=400):
""" Expands text result after. """
block_id = block["block_ID"] + 1
doc_id = block["doc_ID"]
after_text = ""
post_blocks = []
while len(after_text) < window_size:
after_block = self.block_lookup(block_id, doc_id)
if not after_block:
break # Break if no block is found
after_text += after_block["text"]
post_blocks.append(after_block)
block_id += 1 # Increment block_id for next iteration
output = {"expanded_text": after_text, "results": post_blocks}
return output
def generate_csv_report(self):
"""Generates a csv report from the current query status. """
output = QueryState(self).generate_query_report_current_state()
return output
def filter_by_key_value_range(self, key, value_range, results_only=True):
""" Executes a filter by key value range. """
cursor = CollectionRetrieval(self.library_name,
account_name=self.account_name).filter_by_key_value_range(key,value_range)
query= ""
result_dict = self._cursor_to_qr(query, cursor, exhaust_full_cursor=True)
if results_only:
return result_dict["results"]
return result_dict