import os from typing import Any, Dict, List, cast import google.auth import google.auth.transport.requests from llama_deploy import ControlPlaneConfig, WorkflowServiceConfig, deploy_workflow from llama_index.core import ( Settings, SimpleDirectoryReader, StorageContext, VectorStoreIndex, ) from llama_index.core.base.base_query_engine import BaseQueryEngine from llama_index.core.indices.query.query_transform.base import ( StepDecomposeQueryTransform, ) from llama_index.core.llms import LLM from llama_index.core.node_parser import SentenceSplitter from llama_index.core.postprocessor.llm_rerank import LLMRerank from llama_index.core.prompts import PromptTemplate from llama_index.core.response_synthesizers import ( ResponseMode, get_response_synthesizer, ) from llama_index.core.schema import MetadataMode, NodeWithScore, QueryBundle, TextNode from llama_index.core.workflow import ( Context, Event, StartEvent, StopEvent, Workflow, step, ) from llama_index.embeddings.vertex import VertexTextEmbedding from llama_index.llms.vertex import Vertex from llama_index.storage.docstore.firestore import FirestoreDocumentStore import vertexai from vertexai.generative_models import HarmBlockThreshold, HarmCategory, SafetySetting # credentials will now have an API token project_id = os.environ.get("PROJECT_ID") location = os.environ.get("LOCATION") vertexai.init(project=project_id, location=location) credentials = google.auth.default(quota_project_id=project_id)[0] request = google.auth.transport.requests.Request() credentials.refresh(request) safety_config = [ SafetySetting( category=HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, threshold=HarmBlockThreshold.BLOCK_NONE, ), SafetySetting( category=HarmCategory.HARM_CATEGORY_HARASSMENT, threshold=HarmBlockThreshold.BLOCK_NONE, ), SafetySetting( category=HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, threshold=HarmBlockThreshold.BLOCK_NONE, ), ] embedding_model = VertexTextEmbedding("text-embedding-005", credentials=credentials) llm = Vertex( model="gemini-2.0-flash", temperature=0.2, max_tokens=3000, safety_settings=safety_config, credentials=credentials, ) Settings.embed_model = embedding_model Settings.llm = llm class RetrieverEvent(Event): """Result of running retrieval""" nodes: list[NodeWithScore] class RerankEvent(Event): """Result of running reranking on retrieved nodes""" nodes: List[NodeWithScore] source_nodes: List[NodeWithScore] final_response_metadata: Dict[str, Any] class FirestoreIndexData(Event): """Result of indexing documents in Firestore""" status: str class QueryMultiStepEvent(Event): """ Event containing results of a multi-step query process. Attributes: nodes (List[NodeWithScore]): List of nodes with their associated scores. source_nodes (List[NodeWithScore]): List of source nodes with their scores. final_response_metadata (Dict[str, Any]): Metadata associated with the final response. """ nodes: List[NodeWithScore] source_nodes: List[NodeWithScore] final_response_metadata: Dict[str, Any] class CreateCitationsEvent(Event): """Add citations to the nodes.""" nodes: List[NodeWithScore] source_nodes: List[NodeWithScore] final_response_metadata: Dict[str, Any] CITATION_QA_TEMPLATE = PromptTemplate( "Your task is to answer the question based on the information given in the sources listed below." "Use only the provided sources to answer." "Cite the source number(s) for any information you use in your answer (e.g., [1])." "Always include at least one source citation in your answer." "Only cite a source if you directly use information from it." "If the sources don't contain the information needed to answer the question, state that." "For example:" "Source 1: Apples are red, green, or yellow." "Source 2: Bananas are yellow when ripe." "Source 3: Strawberries are red when ripe." "Query: Which fruits are red when ripe?" "Answer: Apples [1] and strawberries [3] can be red when ripe." "------" "Below are several numbered sources of information:" "------" "{context_str}" "------" "Query: {query_str}" "Answer: " ) CITATION_REFINE_TEMPLATE = PromptTemplate( "You have an initial answer to a query." "Your job is to improve this answer using the information provided in the numbered sources below. Here's how:" " - Read the existing answer and the sources carefully." " - Identify any information in the sources that can improve the answer by adding details, making it more accurate, or providing better support." " - If the sources provide new information, incorporate it into the answer." " - If the sources contradict the existing answer, correct the answer." " - If the sources aren't helpful, keep the original answer." "Cite the source number(s) for any information you use in your answer (e.g., [1])." "We have provided an existing answer: {existing_answer}" "Below are several numbered sources of information. " "Use them to refine the existing answer. " "If the provided sources are not helpful, you will repeat the existing answer." "------" "{context_msg}" "------" "Query: {query_str}" "Answer: " ) DEFAULT_CITATION_CHUNK_SIZE = 512 DEFAULT_CITATION_CHUNK_OVERLAP = 20 class RAGWorkflow(Workflow): """Defines Workflow class that architects complex Retrieval Augmented Generation (RAG) workflow using Gemini models and Firestore databases.""" def combine_queries( self, query_bundle: QueryBundle, prev_reasoning: str, llm_inner: LLM, ) -> QueryBundle: """Combine queries using StepDecomposeQueryTransform.""" transform_metadata = {"prev_reasoning": prev_reasoning} return StepDecomposeQueryTransform(llm=llm_inner)( query_bundle, metadata=transform_metadata ) def default_stop_fn(self, stop_dict: Dict) -> bool: """Stop function for multi-step query combiner.""" query_bundle = cast(QueryBundle, stop_dict.get("query_bundle")) if query_bundle is None: raise ValueError("Response must be provided to stop function.") return "none" in query_bundle.query_str.lower() def create_index(self, dirname: str | None) -> VectorStoreIndex: """Create Vector Store Index from documents in Firestore Database""" if not dirname: return None documents = SimpleDirectoryReader(dirname).load_data(show_progress=True) print(len(documents)) print("Data loaded into Documents.") # create (or load) docstore and add nodes docstore = FirestoreDocumentStore.from_database( project=os.environ.get("PROJECT_ID"), database=os.environ.get("FIRESTORE_DATABASE_ID"), ) docstore.add_documents(documents) print("Firestore document store created with documents") # create storage context storage_context = StorageContext.from_defaults(docstore=docstore) # setup index index = VectorStoreIndex.from_documents( documents=documents, storage_context=storage_context ) print("Vector Store Index created") return index async def multi_query_inner_loop( self, query_engine: BaseQueryEngine, query: str, num_steps: int, cur_steps: int ) -> tuple[list[str], list[NodeWithScore], Dict[str, Any]] | None: """Helper function to execute the query loop.""" # pylint: disable=too-many-locals prev_reasoning = "" cur_response = None should_stop = False final_response_metadata: Dict[str, Any] = {"sub_qa": []} text_chunks: list[str] = [] source_nodes: list[NodeWithScore] = [] stop_fn = self.default_stop_fn while not should_stop: if num_steps is not None and cur_steps >= num_steps: should_stop = True break print(llm) updated_query_bundle = self.combine_queries( QueryBundle(query_str=query), prev_reasoning, llm_inner=Settings.llm, ) print( f"Created query for the step - {cur_steps} is: {updated_query_bundle}" ) stop_dict = {"query_bundle": updated_query_bundle} if stop_fn(stop_dict): should_stop = True break cur_response = query_engine.query(updated_query_bundle) # append to response builder cur_qa_text = ( f"\nQuestion: {updated_query_bundle.query_str}\n" f"Answer: {cur_response!s}" ) text_chunks.append(cur_qa_text) for source_node in cur_response.source_nodes: print(source_node) source_nodes.append(source_node) # update metadata final_response_metadata["sub_qa"].append( (updated_query_bundle.query_str, cur_response) ) prev_reasoning += ( f"- {updated_query_bundle.query_str}\n" f"- {cur_response!s}\n" ) cur_steps += 1 return text_chunks, source_nodes, final_response_metadata @step(pass_context=True) async def query_multistep( self, ctx: Context, ev: StartEvent ) -> QueryMultiStepEvent | None: """Entry point for RAG, triggered by a StartEvent with `query`. Execute multi-step query process.""" query = ev.get("query") dirname = os.environ.get("DATA_DIRECTORY") index = self.create_index(dirname) cur_steps = 0 if not query: return None print(f"Query the database with: {query}") # store the query in the global context await ctx.set("query", query) # get the index from the global context if index is None: print("Index is empty, load some documents before querying!") return None num_steps = ev.get("num_steps") print(num_steps) query_engine = index.as_query_engine() result = await self.multi_query_inner_loop( query_engine, query, num_steps, cur_steps ) if result is None: return None text_chunks, source_nodes, final_response_metadata = result nodes = [ NodeWithScore(node=TextNode(text=text_chunk)) for text_chunk in text_chunks ] return QueryMultiStepEvent( nodes=nodes, source_nodes=source_nodes, final_response_metadata=final_response_metadata, ) @step() async def rerank(self, ctx: Context, ev: QueryMultiStepEvent) -> RerankEvent: """Reranking the nodes based on the initial query.""" print("Entered the rerank event") # Rerank the nodes ranker = LLMRerank(choice_batch_size=5, top_n=10, llm=Settings.llm) print(await ctx.get("query", default=None), flush=True) try: new_nodes = ranker.postprocess_nodes( ev.nodes, query_str=await ctx.get("query", default=None) ) except IndexError as ex: print(f"IndexError occurred during reranking: {ex}") print("Using previous nodes instead.") new_nodes = ev.nodes print(f"Reranked nodes to {len(new_nodes)}") return RerankEvent( nodes=new_nodes, source_nodes=ev.source_nodes, final_response_metadata=ev.final_response_metadata, ) @step() async def create_citation_nodes(self, ev: RerankEvent) -> CreateCitationsEvent: """ Modify retrieved nodes to create granular sources for citations. Takes a list of NodeWithScore objects and splits their content into smaller chunks, creating new NodeWithScore objects for each chunk. Each new node is labeled as a numbered source, allowing for more precise citation in query results. Args: nodes (List[NodeWithScore]): A list of NodeWithScore objects to be processed. Returns: List[NodeWithScore]: A new list of NodeWithScore objects, where each object represents a smaller chunk of the original nodes, labeled as a source. """ print("Entered create citation event") nodes = ev.nodes new_nodes: List[NodeWithScore] = [] text_splitter = SentenceSplitter( chunk_size=DEFAULT_CITATION_CHUNK_SIZE, chunk_overlap=DEFAULT_CITATION_CHUNK_OVERLAP, ) for node in nodes: print(node) text_chunks = text_splitter.split_text( node.node.get_content(metadata_mode=MetadataMode.NONE) ) for text_chunk in text_chunks: text = f"Source {len(new_nodes)+1}:\n{text_chunk}\n" new_node = NodeWithScore( node=TextNode.model_validate(node.node), score=node.score ) new_node.node.text = text new_nodes.append(new_node) return CreateCitationsEvent( nodes=new_nodes, source_nodes=ev.source_nodes, final_response_metadata=ev.final_response_metadata, ) @step() async def synthesize(self, ctx: Context, ev: CreateCitationsEvent) -> StopEvent: """Return a streaming response using reranked nodes.""" print("Synthesizing final result...") response_synthesizer = get_response_synthesizer( llm=Vertex(model="gemini-2.0-flash", temperature=0.1, max_tokens=5000), text_qa_template=CITATION_QA_TEMPLATE, refine_template=CITATION_REFINE_TEMPLATE, response_mode=ResponseMode.COMPACT, use_async=True, ) query = await ctx.get("query", default=None) response = await response_synthesizer.asynthesize( query, nodes=ev.nodes, additional_source_nodes=ev.source_nodes ) return StopEvent(result=response) async def main() -> None: """Deploys Workflow service.""" print("starting deploy workflow creation") await deploy_workflow( workflow=RAGWorkflow(timeout=200), workflow_config=WorkflowServiceConfig( host="0.0.0.0", port=8002, service_name="my_workflow", # This will make it accessible to all interfaces on the host ), control_plane_config=ControlPlaneConfig(), ) print("Created workflow successfully") if __name__ == "__main__": import asyncio asyncio.run(main())