From e32abe94a923e485434f03830f5a72fe578a65de Mon Sep 17 00:00:00 2001 From: Shubhadeep Das Date: Sat, 30 May 2026 00:21:26 +0530 Subject: [PATCH 1/4] chore: prepare release-v2.6.0 sync to main Signed-off-by: Shubhadeep Das --- .github/skill-eval/AGENTS.md | 436 ++ .github/skill-eval/skills_eval_agent.py | 250 + .github/workflows/ci-pipeline.yml | 424 +- .github/workflows/publish-artifacts.yml | 62 +- .github/workflows/run-branch-script.yml | 10 +- .github/workflows/skills-eval.yml | 197 + .openclaw/.gitignore | 5 + .openclaw/README.md | 220 + .openclaw/index.ts | 155 + .openclaw/openclaw.plugin.json | 11 + .openclaw/package-lock.json | 5425 +++++++++++++++++ .openclaw/package.json | 40 + .openclaw/tsconfig.json | 15 + .openclaw/workspace/AGENTS.md | 143 + .openclaw/workspace/BOOTSTRAP.md | 95 + .openclaw/workspace/IDENTITY.md | 7 + .openclaw/workspace/SOUL.md | 36 + .openclaw/workspace/TOOLS.md | 25 + .python-version | 2 +- AGENTS.md | 6 +- CLAUDE.md | 12 + CONTRIBUTING.md | 3 +- LICENSE | 5 +- README.md | 62 +- ci/post-cve-report.sh | 242 + ci/publish_wheel.sh | 4 +- ci/run_skill_eval.sh | 476 ++ ci/run_skill_eval_h100.sh | 36 + deploy/compose/.env | 35 +- deploy/compose/accuracy_profile.env | 2 +- .../docker-compose-ingestor-server.yaml | 101 +- deploy/compose/docker-compose-rag-server.yaml | 105 +- .../config-store/nemoguard/config.yml | 4 +- deploy/compose/nemotron3-super-prompt.yaml | 20 +- deploy/compose/nims.yaml | 99 +- deploy/compose/nvdev.env | 100 +- deploy/compose/perf_profile.env | 2 +- deploy/compose/seaweedfs-config/s3.json | 20 + deploy/compose/vectordb.yaml | 75 +- .../config/agentic-rag-metrics-dashboard.json | 577 ++ deploy/helm/mig-slicing/mig-config-h100.yaml | 28 +- .../helm/mig-slicing/mig-config-rtx6000.yaml | 23 +- deploy/helm/mig-slicing/values-mig-h100.yaml | 93 +- .../helm/mig-slicing/values-mig-rtx6000.yaml | 98 +- deploy/helm/nvidia-blueprint-rag/Chart.lock | 9 +- deploy/helm/nvidia-blueprint-rag/Chart.yaml | 10 +- deploy/helm/nvidia-blueprint-rag/LICENSE | 5 +- deploy/helm/nvidia-blueprint-rag/endpoints.md | 21 +- .../nvidia-blueprint-rag/files/prompt.yaml | 516 +- .../files/sitecustomize.py | 48 + .../templates/_helpers.tpl | 15 + .../templates/deployment.yaml | 51 +- .../templates/embedding-nim.yaml | 6 +- .../templates/ingestor-server-deployment.yaml | 21 +- .../templates/nv-ingest-py-patches.yaml | 14 + .../templates/openshift.yaml | 117 + .../templates/reranking-nim.yaml | 6 +- .../templates/secrets.yaml | 14 +- .../templates/vlm-captioning-nim.yaml | 61 + .../templates/vlm-reranker-nim.yaml | 60 + .../values-openshift-test.yaml | 131 + .../values-openshift.yaml | 78 + deploy/helm/nvidia-blueprint-rag/values.yaml | 506 +- deploy/workbench/README.md | 2 +- deploy/workbench/compose.yaml | 235 +- deploy/workbench/quickstart.ipynb | 24 +- docs/accuracy-benchmarks.md | 2 +- docs/accuracy_perf.md | 4 +- docs/agentic-rag.md | 163 + docs/api-rag.md | 2 - .../openapi_schema_ingestor_server.json | 34 +- .../openapi_schema_rag_server.json | 621 +- docs/assets/arch_agentic_rag.png | Bin 0 -> 264495 bytes docs/assets/ui-agentic-rag-streaming.png | Bin 0 -> 273247 bytes docs/change-model.md | 204 +- docs/change-vectordb.md | 560 +- docs/conf.py | 9 +- docs/continuous-ingestion-object-storage.md | 2 +- docs/custom-metadata.md | 55 +- docs/debugging.md | 26 +- docs/deploy-docker-nvidia-hosted.md | 19 +- docs/deploy-docker-self-hosted.md | 25 +- docs/deploy-helm-from-repo.md | 41 +- docs/deploy-helm-openshift.md | 523 ++ docs/deploy-helm.md | 81 +- docs/documentation.md | 22 +- docs/elasticsearch-configuration.md | 667 ++ docs/enable-nemotron-thinking.md | 28 +- docs/evaluate.md | 2 + docs/image_captioning.md | 17 +- docs/index.md | 12 +- docs/mig-deployment.md | 97 +- docs/milvus-configuration.md | 80 +- docs/model-profiles.md | 19 +- docs/mount-ingestor-volume.md | 33 +- docs/multimodal-query.md | 78 +- docs/multimodal-retriever.md | 359 ++ docs/multiturn.md | 2 +- docs/nemo-guardrails.md | 35 + docs/nemoretriever-ocr.md | 91 +- docs/nemotron-parse-extraction.md | 2 +- docs/nemotron3-super-deployment.md | 138 +- docs/nv-ingest-standalone.md | 5 +- docs/observability.md | 39 +- docs/performance-benchmarking.md | 374 ++ docs/project.json | 2 +- docs/prompt-customization.md | 8 +- docs/python-client.md | 36 +- docs/query-to-answer-pipeline.md | 1 - docs/readme.md | 4 +- docs/release-notes.md | 34 +- docs/retrieval-only-deployment.md | 113 +- docs/self-reflection.md | 8 +- docs/service-port-gpu-reference.md | 19 +- docs/summarization.md | 10 +- docs/support-matrix.md | 35 +- docs/text_only_ingest.md | 101 +- docs/troubleshooting.md | 139 +- docs/versions1.json | 6 +- docs/vlm-embed.md | 230 - docs/vlm.md | 177 +- examples/nvidia_rag_mcp/mcp_server.py | 44 +- examples/nvidia_rag_mcp/requirements.txt | 2 +- .../kafka_consumer/config/__init__.py | 2 + .../kafka_consumer/config/constants.py | 2 + .../kafka_consumer/config/settings.py | 2 + .../kafka_consumer/consumer.py | 2 + .../kafka_consumer/handlers/__init__.py | 2 + .../kafka_consumer/handlers/base.py | 2 + .../kafka_consumer/handlers/document.py | 2 + .../rag_event_ingest/kafka_consumer/main.py | 2 + .../kafka_consumer/models/__init__.py | 2 + .../kafka_consumer/models/events.py | 2 + .../rag_event_ingest/kafka_consumer/router.py | 2 + .../kafka_consumer/services/__init__.py | 2 + .../services/document_indexer.py | 2 + .../kafka_consumer/services/storage.py | 2 + .../src/rag_react_agent/__init__.py | 2 +- .../src/rag_react_agent/configs/config.yml | 2 +- .../src/rag_react_agent/register.py | 2 +- examples/rag_react_agent/uv.lock | 2 +- frontend/Dockerfile | 12 +- frontend/package.json | 4 +- frontend/pnpm-lock.yaml | 82 +- .../components/chat/AgenticModeSelector.tsx | 139 + .../src/components/chat/ChatMessageBubble.tsx | 30 +- frontend/src/components/chat/MessageInput.tsx | 11 +- .../src/components/chat/ReasoningPanel.tsx | 207 + .../__tests__/AgenticModeSelector.test.tsx | 119 + .../chat/__tests__/ChatMessageBubble.test.tsx | 2 + .../chat/__tests__/Citations.test.tsx | 2 + .../chat/__tests__/MessageActions.test.tsx | 2 + .../chat/__tests__/MessageContent.test.tsx | 2 + .../chat/__tests__/MessageInput.test.tsx | 15 + .../__tests__/MessageInputContainer.test.tsx | 2 + .../chat/__tests__/MessageTextarea.test.tsx | 2 + .../chat/__tests__/ReasoningPanel.test.tsx | 175 + .../__tests__/StreamingIndicator.test.tsx | 2 + .../components/citations/CitationMetadata.tsx | 9 + .../__tests__/CitationButton.test.tsx | 2 + .../citations/__tests__/CitationItem.test.tsx | 35 +- .../__tests__/CitationMetadata.test.tsx | 25 +- .../__tests__/CitationScore.test.tsx | 2 + .../__tests__/CitationTextContent.test.tsx | 2 + .../__tests__/CitationVisualContent.test.tsx | 2 + .../collections/CollectionsGrid.tsx | 2 + .../__tests__/CollectionChips.test.tsx | 2 + .../__tests__/CollectionDrawer.test.tsx | 2 + .../__tests__/CollectionItem.test.tsx | 2 + .../__tests__/CollectionList.test.tsx | 2 + .../__tests__/CollectionsGrid.test.tsx | 2 + .../__tests__/NewCollectionButton.test.tsx | 2 + .../__tests__/NewCollectionButtons.test.tsx | 2 + .../drawer/__tests__/DrawerActions.test.tsx | 2 + .../drawer/__tests__/SidebarDrawer.test.tsx | 2 + .../drawer/__tests__/UploaderSection.test.tsx | 2 + frontend/src/components/files/FileCard.tsx | 2 + .../src/components/files/MetadataField.tsx | 2 + .../src/components/files/NvidiaUpload.tsx | 2 + .../files/__tests__/FileCard.test.tsx | 2 + .../files/__tests__/FileInput.test.tsx | 2 + .../files/__tests__/FileItem.test.tsx | 2 + .../files/__tests__/FileList.test.tsx | 2 + .../files/__tests__/FileMetadataForm.test.tsx | 2 + .../files/__tests__/FileUploadZone.test.tsx | 2 + .../FileUploaderWithMetadata.test.tsx | 2 + .../files/__tests__/FilesList.test.tsx | 2 + .../files/__tests__/MetadataField.test.tsx | 2 + .../files/__tests__/NvidiaUpload.test.tsx | 2 + .../filtering/FilterGenerationToggle.tsx | 4 +- .../layout/__tests__/Header.test.tsx | 2 + .../layout/__tests__/Layout.test.tsx | 2 + .../layout/__tests__/StatusMessages.test.tsx | 2 + .../__tests__/FeatureWarningModal.test.tsx | 2 + .../modals/__tests__/SettingsModal.test.tsx | 2 + .../__tests__/NotificationBadge.test.tsx | 2 + .../__tests__/NotificationBell.test.tsx | 2 + .../__tests__/NotificationDropdown.test.tsx | 2 + .../__tests__/TaskPoller.test.tsx | 2 + .../components/schema/FieldDisplayCard.tsx | 2 + .../__tests__/FieldDisplayCard.test.tsx | 2 + .../schema/__tests__/FieldEditForm.test.tsx | 2 + .../schema/__tests__/FieldsList.test.tsx | 2 + .../__tests__/MetadataSchemaEditor.test.tsx | 2 + .../schema/__tests__/NewFieldForm.test.tsx | 2 + .../__tests__/AdvancedSection.test.tsx | 2 + .../__tests__/EndpointsSection.test.tsx | 2 + .../__tests__/FeatureTogglesSection.test.tsx | 2 + .../settings/__tests__/ModelsSection.test.tsx | 2 + .../__tests__/RagConfigSection.test.tsx | 2 + .../__tests__/SettingsHeader.test.tsx | 2 + .../__tests__/SettingsSection.test.tsx | 2 + .../tasks/__tests__/DocumentItem.test.tsx | 2 + .../tasks/__tests__/DocumentsList.test.tsx | 2 + .../tasks/__tests__/TaskDisplay.test.tsx | 2 + .../tasks/__tests__/TaskStatusIcons.test.tsx | 2 + .../ui/__tests__/ErrorState.test.tsx | 2 + .../ui/__tests__/LoadingState.test.tsx | 2 + .../src/hooks/__tests__/useChatStream.test.ts | 451 ++ .../hooks/__tests__/useCitationUtils.test.ts | 77 + .../hooks/__tests__/useFileValidation.test.ts | 2 + .../hooks/__tests__/useMessageSubmit.test.ts | 123 + .../__tests__/useUploadDocuments.test.ts | 2 + frontend/src/hooks/useChatStream.ts | 259 +- frontend/src/hooks/useCitationUtils.ts | 15 + frontend/src/hooks/useFileUpload.ts | 2 + frontend/src/hooks/useMessageSubmit.ts | 155 +- frontend/src/pages/__tests__/Chat.test.tsx | 2 + frontend/src/store/useSettingsStore.ts | 19 + frontend/src/types/chat.ts | 67 +- frontend/src/types/requests.ts | 26 +- .../utils/__tests__/filterExpression.test.ts | 552 ++ frontend/src/utils/filterExpression.ts | 341 ++ frontend/src/vite-env.d.ts | 2 + frontend/vitest.config.ts | 6 +- notebooks/.env_library | 72 +- notebooks/building_rag_vdb_operator.ipynb | 44 +- notebooks/config.yaml | 69 +- notebooks/evaluation_01_ragas.ipynb | 7 +- notebooks/image_input.ipynb | 37 +- notebooks/ingestion_api_usage.ipynb | 14 +- notebooks/langchain_nvidia_retriever.ipynb | 96 +- notebooks/launchable.ipynb | 751 ++- notebooks/mcp_server_usage.ipynb | 43 +- notebooks/nat_mcp_integration.ipynb | 117 +- notebooks/nb_metadata.ipynb | 104 +- notebooks/rag_event_ingest.ipynb | 1580 +++-- notebooks/rag_library_lite_usage.ipynb | 92 +- notebooks/rag_library_usage.ipynb | 126 +- notebooks/retriever_api_usage.ipynb | 113 +- notebooks/summarization.ipynb | 2981 ++++----- pyproject.toml | 43 +- scripts/README.md | 4 +- scripts/eval/README.md | 178 + scripts/eval/evaluate_rag.py | 1031 ++++ scripts/eval/pyproject.toml | 17 + scripts/eval/uv.lock | 2088 +++++++ scripts/extract_python_licenses.py | 12 - scripts/rag-perf/configs/quick_profile.yaml | 51 + scripts/rag-perf/configs/single_run.yaml | 49 + scripts/rag-perf/configs/sweep.yaml | 89 + scripts/rag-perf/examples/queries.jsonl | 50 + scripts/rag-perf/prompts/default_prompts.yaml | 64 + scripts/rag-perf/pyproject.toml | 89 + scripts/rag-perf/rag_perf/__init__.py | 102 + scripts/rag-perf/rag_perf/__main__.py | 21 + scripts/rag-perf/rag_perf/cli.py | 256 + scripts/rag-perf/rag_perf/config.py | 689 +++ scripts/rag-perf/rag_perf/plugin/__init__.py | 60 + .../rag-perf/rag_perf/plugin/nvidia_rag.py | 267 + scripts/rag-perf/rag_perf/plugin/plugins.yaml | 21 + scripts/rag-perf/rag_perf/query.py | 644 ++ scripts/rag-perf/rag_perf/reporting.py | 708 +++ scripts/rag-perf/rag_perf/runner.py | 690 +++ scripts/rag-perf/uv.lock | 3225 ++++++++++ scripts/retriever_api_usage.py | 37 +- skill-eval/CLAUDE.md | 132 + skill-eval/README.md | 267 + skill-eval/__init__.py | 0 skill-eval/adapters/__init__.py | 0 skill-eval/adapters/rag-blueprint/__init__.py | 0 skill-eval/adapters/rag-blueprint/generate.py | 437 ++ skill-eval/envs/__init__.py | 0 skill-eval/envs/brev_env.py | 913 +++ skill-eval/envs/local_env.py | 266 + skill-eval/verifiers/generic_judge.py | 391 ++ .../.agents/skills/rag-blueprint/SKILL.md | 102 +- .../skills/rag-blueprint/eval/h100.json | 39 + .../rag-blueprint/eval/nvidia_hosted.json | 28 + .../references/configure/agentic-rag.md | 62 + .../references/configure/api-reference.md | 1 + .../references/configure/evaluation.md | 32 +- .../references/configure/ingestion.md | 8 +- .../configure/models-and-infrastructure.md | 13 +- .../references/configure/multimodal-query.md | 4 +- .../references/configure/notebooks.md | 3 + .../configure/query-and-conversation.md | 121 +- .../configure/reasoning-and-generation.md | 14 +- .../configure/search-and-retrieval.md | 11 +- .../references/configure/user-interface.md | 5 +- .../rag-blueprint/references/configure/vlm.md | 15 +- .../skills/rag-blueprint/references/deploy.md | 2 +- .../references/deploy/docker-nvidia-hosted.md | 5 +- .../rag-blueprint/references/deploy/docker.md | 10 +- .../references/deploy/helm-openshift.md | 64 + .../rag-blueprint/references/deploy/helm.md | 8 +- .../references/deploy/library-lite.md | 2 +- .../rag-blueprint/references/shutdown.md | 7 +- .../rag-blueprint/references/troubleshoot.md | 18 +- skill-source/.agents/skills/rag-eval/SKILL.md | 130 + .../.agents/skills/rag-eval/eval/h100.json | 43 + .../skills/rag-eval/eval/nvidia_hosted.json | 32 + .../references/benchmark-execution.md | 166 + .../references/dataset-and-conversion.md | 129 + .../rag-eval/references/evaluate-rag-cli.md | 73 + .../rag-eval/references/result-analysis.md | 75 + skill-source/.agents/skills/rag-perf/SKILL.md | 179 + .../.agents/skills/rag-perf/eval/h100.json | 38 + .../skills/rag-perf/eval/nvidia_hosted.json | 32 + .../rag-perf/references/config-schema.md | 144 + .../references/output-and-analysis.md | 245 + .../references/synthetic-generation.md | 96 + skill-source/README.md | 26 +- skill-source/validate_skill_api_versions.py | 170 + src/README.md | 2 +- src/nvidia_rag/ingestor_server/Dockerfile | 12 +- src/nvidia_rag/ingestor_server/health.py | 73 +- src/nvidia_rag/ingestor_server/main.py | 633 +- .../nemo_retriever/__init__.py | 61 + .../nemo_retriever/extensions.py | 40 + .../ingestor_server/nemo_retriever/filters.py | 94 + .../ingestor_server/nemo_retriever/handler.py | 619 ++ .../nemo_retriever/ingest_schema_manager.py | 193 + .../ingestor_server/nemo_retriever/params.py | 231 + src/nvidia_rag/ingestor_server/nvingest.py | 80 +- src/nvidia_rag/ingestor_server/server.py | 36 +- src/nvidia_rag/rag_server/Dockerfile | 2 +- .../rag_server/agentic_rag/__init__.py | 51 + .../rag_server/agentic_rag/agentic_rag.py | 2056 +++++++ .../rag_server/agentic_rag/builder.py | 404 ++ .../rag_server/agentic_rag/prompt.py | 341 ++ .../rag_server/agentic_rag/response_parser.py | 160 + .../rag_server/agentic_rag/runner.py | 484 ++ .../rag_server/agentic_rag/streaming.py | 815 +++ .../rag_server/agentic_rag/tracing.py | 318 + src/nvidia_rag/rag_server/health.py | 69 +- src/nvidia_rag/rag_server/main.py | 2020 ++++-- src/nvidia_rag/rag_server/prompt.yaml | 516 +- .../rag_server/query_decomposition.py | 75 +- src/nvidia_rag/rag_server/reflection.py | 14 +- .../rag_server/response_generator.py | 460 +- src/nvidia_rag/rag_server/server.py | 126 +- src/nvidia_rag/rag_server/validation.py | 36 +- src/nvidia_rag/rag_server/vlm.py | 841 ++- src/nvidia_rag/utils/agentic_rag_config.py | 519 ++ src/nvidia_rag/utils/batch_utils.py | 81 +- src/nvidia_rag/utils/common.py | 231 +- src/nvidia_rag/utils/configuration.py | 331 +- src/nvidia_rag/utils/es_filter_validator.py | 446 ++ .../utils/filter_expression_generator.py | 81 +- src/nvidia_rag/utils/llm.py | 345 +- src/nvidia_rag/utils/minio_operator.py | 322 - src/nvidia_rag/utils/object_store.py | 343 ++ .../utils/observability/agentic_metrics.py | 278 + .../langchain_callback_handler.py | 66 + .../utils/observability/otel_metrics.py | 18 + .../utils/observability/tracing/__init__.py | 8 + .../utils/observability/tracing/helpers.py | 57 +- .../observability/tracing/instrumentation.py | 6 +- src/nvidia_rag/utils/reranker.py | 25 +- src/nvidia_rag/utils/summarization.py | 14 +- src/nvidia_rag/utils/vdb/__init__.py | 26 +- .../utils/vdb/elasticsearch/elastic_vdb.py | 464 +- .../elasticsearch/es_dense_vector_strategy.py | 94 + .../utils/vdb/elasticsearch/es_queries.py | 76 +- src/nvidia_rag/utils/vdb/lancedb/__init_.py | 14 + .../utils/vdb/lancedb/lancedb_vdb.py | 1588 +++++ .../utils/vdb/lancedb/nrl_lancedb.py | 179 + src/nvidia_rag/utils/vdb/milvus/milvus_vdb.py | 486 +- src/nvidia_rag/utils/vdb/vdb_base.py | 66 +- src/nvidia_rag/utils/vlm_reranker.py | 271 + tests/__init__.py | 2 + tests/integration/__init__.py | 2 + tests/integration/main.py | 19 +- tests/integration/notebook_test_config.yaml | 45 +- tests/integration/requirements.txt | 3 +- tests/integration/test_cases/__init__.py | 2 + .../test_cases/collection_management.py | 8 +- .../integration/test_cases/custom_metadata.py | 95 +- tests/integration/test_cases/library_usage.py | 26 +- .../test_cases/multimodal_query.py | 8 +- .../integration/test_cases/nemo_guardrails.py | 2 +- .../integration/test_cases/rag_generation.py | 165 +- tests/integration/test_cases/reflection.py | 3 +- tests/integration/test_cases/summary.py | 2 +- tests/integration/test_cases/validate_osl.py | 92 +- tests/integration/test_sequences.yaml | 32 +- tests/integration/utils/__init__.py | 2 + tests/integration/utils/response_handlers.py | 8 + tests/integration/utils/vector_store.py | 27 + tests/integration/vectordb.yaml | 59 +- tests/unit/ci-unittest.md | 6 +- tests/unit/conftest.py | 27 +- tests/unit/requirements-test.txt | 2 +- .../env_parity_exemptions.yaml | 14 +- .../test_compose_helm_parity.py | 38 +- .../test_seaweedfs_deploy_config.py | 342 ++ tests/unit/test_guardrails_config.py | 28 + .../unit/test_ingestor_server/test_health.py | 156 +- .../test_ingestor_library.py | 19 +- .../test_ingestor_main_core_components.py | 135 +- .../test_ingestor_main_document_operations.py | 345 +- .../test_ingestor_server.py | 37 +- .../test_nemo_retriever/test_handler.py | 202 + .../test_ingest_schema_manager.py | 154 + .../test_package_exports.py | 17 + .../test_nemo_retriever/test_params.py | 170 + .../test_ingestor_server/test_nvingest.py | 75 +- .../test_system_managed_fields_integration.py | 6 + .../test_es_filter_validator.py | 355 ++ .../test_filter_validator.py | 2 + .../test_metadata_validator.py | 2 + .../test_agentic_metrics.py | 192 + .../test_langchain_callback_handler.py | 2 + .../test_observability/test_otel_metrics.py | 2 +- .../test_rag_server_tracing.py | 11 + tests/unit/test_rag_perf/__init__.py | 2 + tests/unit/test_rag_perf/conftest.py | 212 + .../unit/test_rag_perf/test_aiperf_runner.py | 263 + .../test_rag_perf/test_benchmark_runner.py | 277 + tests/unit/test_rag_perf/test_cli.py | 154 + tests/unit/test_rag_perf/test_config.py | 226 + tests/unit/test_rag_perf/test_plugin.py | 183 + tests/unit/test_rag_perf/test_query.py | 236 + tests/unit/test_rag_perf/test_reporting.py | 196 + tests/unit/test_rag_perf/test_runner.py | 331 + .../unit/test_rag_perf/test_synthetic_llm.py | 300 + tests/unit/test_rag_perf/utils.py | 209 + .../unit/test_rag_server/test_agentic_rag.py | 1479 +++++ .../test_page_context_organization.py | 355 ++ .../test_rag_main_advanced_features.py | 155 +- .../test_rag_main_core_components.py | 125 +- .../test_rag_main_integration.py | 18 + .../test_rag_main_vlm_direct_chain.py | 162 +- tests/unit/test_rag_server/test_rag_server.py | 33 + .../test_rag_server/test_rag_server_health.py | 212 +- .../test_response_generator.py | 237 +- .../test_rag_server/test_response_parser.py | 63 + .../test_rag_server/test_self_reflection.py | 2 +- .../test_rag_server/test_summary_endpoint.py | 16 +- tests/unit/test_rag_server/test_validation.py | 95 +- tests/unit/test_rag_server/test_vlm.py | 403 +- tests/unit/test_security_dependency_pins.py | 35 + .../test_api_version_validation.py | 219 + tests/unit/test_utils/test_batch_utils.py | 318 +- tests/unit/test_utils/test_common.py | 141 + tests/unit/test_utils/test_configuration.py | 240 +- .../test_filter_expression_generator.py | 83 + tests/unit/test_utils/test_llm.py | 149 +- ...minio_operator.py => test_object_store.py} | 252 +- tests/unit/test_utils/test_reranker.py | 168 +- tests/unit/test_utils/test_summarization.py | 30 +- .../test_utils/test_vdb/test_elastic_vdb.py | 472 +- .../test_elasticsearch/test_es_queries.py | 21 +- .../test_utils/test_vdb/test_lancedb_vdb.py | 635 ++ .../test_utils/test_vdb/test_milvus_vdb.py | 655 +- .../unit/test_utils/test_vdb/test_vdb_base.py | 7 +- tests/unit/test_utils/test_vdb_init.py | 255 +- uv.lock | 905 ++- variables.env | 17 +- 470 files changed, 62780 insertions(+), 9599 deletions(-) create mode 100644 .github/skill-eval/AGENTS.md create mode 100644 .github/skill-eval/skills_eval_agent.py create mode 100644 .github/workflows/skills-eval.yml create mode 100644 .openclaw/.gitignore create mode 100644 .openclaw/README.md create mode 100644 .openclaw/index.ts create mode 100644 .openclaw/openclaw.plugin.json create mode 100644 .openclaw/package-lock.json create mode 100644 .openclaw/package.json create mode 100644 .openclaw/tsconfig.json create mode 100644 .openclaw/workspace/AGENTS.md create mode 100644 .openclaw/workspace/BOOTSTRAP.md create mode 100644 .openclaw/workspace/IDENTITY.md create mode 100644 .openclaw/workspace/SOUL.md create mode 100644 .openclaw/workspace/TOOLS.md create mode 100755 ci/post-cve-report.sh create mode 100755 ci/run_skill_eval.sh create mode 100755 ci/run_skill_eval_h100.sh create mode 100644 deploy/compose/seaweedfs-config/s3.json create mode 100644 deploy/config/agentic-rag-metrics-dashboard.json create mode 100644 deploy/helm/nvidia-blueprint-rag/files/sitecustomize.py create mode 100644 deploy/helm/nvidia-blueprint-rag/templates/nv-ingest-py-patches.yaml create mode 100644 deploy/helm/nvidia-blueprint-rag/templates/openshift.yaml create mode 100644 deploy/helm/nvidia-blueprint-rag/templates/vlm-captioning-nim.yaml create mode 100644 deploy/helm/nvidia-blueprint-rag/templates/vlm-reranker-nim.yaml create mode 100644 deploy/helm/nvidia-blueprint-rag/values-openshift-test.yaml create mode 100644 deploy/helm/nvidia-blueprint-rag/values-openshift.yaml create mode 100644 docs/agentic-rag.md create mode 100644 docs/assets/arch_agentic_rag.png create mode 100644 docs/assets/ui-agentic-rag-streaming.png create mode 100644 docs/deploy-helm-openshift.md create mode 100644 docs/elasticsearch-configuration.md create mode 100644 docs/multimodal-retriever.md create mode 100644 docs/performance-benchmarking.md delete mode 100644 docs/vlm-embed.md create mode 100644 frontend/src/components/chat/AgenticModeSelector.tsx create mode 100644 frontend/src/components/chat/ReasoningPanel.tsx create mode 100644 frontend/src/components/chat/__tests__/AgenticModeSelector.test.tsx create mode 100644 frontend/src/components/chat/__tests__/ReasoningPanel.test.tsx create mode 100644 frontend/src/hooks/__tests__/useChatStream.test.ts create mode 100644 frontend/src/hooks/__tests__/useCitationUtils.test.ts create mode 100644 frontend/src/hooks/__tests__/useMessageSubmit.test.ts create mode 100644 frontend/src/utils/__tests__/filterExpression.test.ts create mode 100644 frontend/src/utils/filterExpression.ts create mode 100644 scripts/eval/README.md create mode 100644 scripts/eval/evaluate_rag.py create mode 100644 scripts/eval/pyproject.toml create mode 100644 scripts/eval/uv.lock create mode 100644 scripts/rag-perf/configs/quick_profile.yaml create mode 100644 scripts/rag-perf/configs/single_run.yaml create mode 100644 scripts/rag-perf/configs/sweep.yaml create mode 100644 scripts/rag-perf/examples/queries.jsonl create mode 100644 scripts/rag-perf/prompts/default_prompts.yaml create mode 100644 scripts/rag-perf/pyproject.toml create mode 100644 scripts/rag-perf/rag_perf/__init__.py create mode 100644 scripts/rag-perf/rag_perf/__main__.py create mode 100644 scripts/rag-perf/rag_perf/cli.py create mode 100644 scripts/rag-perf/rag_perf/config.py create mode 100644 scripts/rag-perf/rag_perf/plugin/__init__.py create mode 100644 scripts/rag-perf/rag_perf/plugin/nvidia_rag.py create mode 100644 scripts/rag-perf/rag_perf/plugin/plugins.yaml create mode 100644 scripts/rag-perf/rag_perf/query.py create mode 100644 scripts/rag-perf/rag_perf/reporting.py create mode 100644 scripts/rag-perf/rag_perf/runner.py create mode 100644 scripts/rag-perf/uv.lock create mode 100644 skill-eval/CLAUDE.md create mode 100644 skill-eval/README.md create mode 100644 skill-eval/__init__.py create mode 100644 skill-eval/adapters/__init__.py create mode 100644 skill-eval/adapters/rag-blueprint/__init__.py create mode 100644 skill-eval/adapters/rag-blueprint/generate.py create mode 100644 skill-eval/envs/__init__.py create mode 100644 skill-eval/envs/brev_env.py create mode 100644 skill-eval/envs/local_env.py create mode 100644 skill-eval/verifiers/generic_judge.py create mode 100644 skill-source/.agents/skills/rag-blueprint/eval/h100.json create mode 100644 skill-source/.agents/skills/rag-blueprint/eval/nvidia_hosted.json create mode 100644 skill-source/.agents/skills/rag-blueprint/references/configure/agentic-rag.md create mode 100644 skill-source/.agents/skills/rag-blueprint/references/deploy/helm-openshift.md create mode 100644 skill-source/.agents/skills/rag-eval/SKILL.md create mode 100644 skill-source/.agents/skills/rag-eval/eval/h100.json create mode 100644 skill-source/.agents/skills/rag-eval/eval/nvidia_hosted.json create mode 100644 skill-source/.agents/skills/rag-eval/references/benchmark-execution.md create mode 100644 skill-source/.agents/skills/rag-eval/references/dataset-and-conversion.md create mode 100644 skill-source/.agents/skills/rag-eval/references/evaluate-rag-cli.md create mode 100644 skill-source/.agents/skills/rag-eval/references/result-analysis.md create mode 100644 skill-source/.agents/skills/rag-perf/SKILL.md create mode 100644 skill-source/.agents/skills/rag-perf/eval/h100.json create mode 100644 skill-source/.agents/skills/rag-perf/eval/nvidia_hosted.json create mode 100644 skill-source/.agents/skills/rag-perf/references/config-schema.md create mode 100644 skill-source/.agents/skills/rag-perf/references/output-and-analysis.md create mode 100644 skill-source/.agents/skills/rag-perf/references/synthetic-generation.md create mode 100644 skill-source/validate_skill_api_versions.py create mode 100644 src/nvidia_rag/ingestor_server/nemo_retriever/__init__.py create mode 100644 src/nvidia_rag/ingestor_server/nemo_retriever/extensions.py create mode 100644 src/nvidia_rag/ingestor_server/nemo_retriever/filters.py create mode 100644 src/nvidia_rag/ingestor_server/nemo_retriever/handler.py create mode 100644 src/nvidia_rag/ingestor_server/nemo_retriever/ingest_schema_manager.py create mode 100644 src/nvidia_rag/ingestor_server/nemo_retriever/params.py create mode 100644 src/nvidia_rag/rag_server/agentic_rag/__init__.py create mode 100644 src/nvidia_rag/rag_server/agentic_rag/agentic_rag.py create mode 100644 src/nvidia_rag/rag_server/agentic_rag/builder.py create mode 100644 src/nvidia_rag/rag_server/agentic_rag/prompt.py create mode 100644 src/nvidia_rag/rag_server/agentic_rag/response_parser.py create mode 100644 src/nvidia_rag/rag_server/agentic_rag/runner.py create mode 100644 src/nvidia_rag/rag_server/agentic_rag/streaming.py create mode 100644 src/nvidia_rag/rag_server/agentic_rag/tracing.py create mode 100644 src/nvidia_rag/utils/agentic_rag_config.py create mode 100644 src/nvidia_rag/utils/es_filter_validator.py delete mode 100644 src/nvidia_rag/utils/minio_operator.py create mode 100644 src/nvidia_rag/utils/object_store.py create mode 100644 src/nvidia_rag/utils/observability/agentic_metrics.py create mode 100644 src/nvidia_rag/utils/vdb/elasticsearch/es_dense_vector_strategy.py create mode 100644 src/nvidia_rag/utils/vdb/lancedb/__init_.py create mode 100644 src/nvidia_rag/utils/vdb/lancedb/lancedb_vdb.py create mode 100644 src/nvidia_rag/utils/vdb/lancedb/nrl_lancedb.py create mode 100644 src/nvidia_rag/utils/vlm_reranker.py create mode 100644 tests/integration/utils/vector_store.py create mode 100644 tests/unit/test_deploy/test_seaweedfs_deploy_config.py create mode 100644 tests/unit/test_guardrails_config.py create mode 100644 tests/unit/test_ingestor_server/test_nemo_retriever/test_handler.py create mode 100644 tests/unit/test_ingestor_server/test_nemo_retriever/test_ingest_schema_manager.py create mode 100644 tests/unit/test_ingestor_server/test_nemo_retriever/test_package_exports.py create mode 100644 tests/unit/test_ingestor_server/test_nemo_retriever/test_params.py create mode 100644 tests/unit/test_metadata_validation/test_es_filter_validator.py create mode 100644 tests/unit/test_observability/test_agentic_metrics.py create mode 100644 tests/unit/test_rag_perf/__init__.py create mode 100644 tests/unit/test_rag_perf/conftest.py create mode 100644 tests/unit/test_rag_perf/test_aiperf_runner.py create mode 100644 tests/unit/test_rag_perf/test_benchmark_runner.py create mode 100644 tests/unit/test_rag_perf/test_cli.py create mode 100644 tests/unit/test_rag_perf/test_config.py create mode 100644 tests/unit/test_rag_perf/test_plugin.py create mode 100644 tests/unit/test_rag_perf/test_query.py create mode 100644 tests/unit/test_rag_perf/test_reporting.py create mode 100644 tests/unit/test_rag_perf/test_runner.py create mode 100644 tests/unit/test_rag_perf/test_synthetic_llm.py create mode 100644 tests/unit/test_rag_perf/utils.py create mode 100644 tests/unit/test_rag_server/test_agentic_rag.py create mode 100644 tests/unit/test_rag_server/test_page_context_organization.py create mode 100644 tests/unit/test_rag_server/test_response_parser.py create mode 100644 tests/unit/test_security_dependency_pins.py create mode 100644 tests/unit/test_skill_source/test_api_version_validation.py rename tests/unit/test_utils/{test_minio_operator.py => test_object_store.py} (55%) create mode 100644 tests/unit/test_utils/test_vdb/test_lancedb_vdb.py diff --git a/.github/skill-eval/AGENTS.md b/.github/skill-eval/AGENTS.md new file mode 100644 index 000000000..52fc7fb67 --- /dev/null +++ b/.github/skill-eval/AGENTS.md @@ -0,0 +1,436 @@ +# RAG Skills Eval Agent — System Prompt + +You are the RAG skills-eval agent, invoked by +`.github/workflows/skills-eval.yml` on every push to a +`pull-request/` mirror branch whose diff touches `skills/`, +`skill-eval/`, or `.github/skill-eval/`. + +You run **once per push**, from start to finish, on the +`rag-skill-validator` self-hosted runner. Your workspace is already +checked out at the mirror head. You have `Bash`, `Read`, `Edit`, +`Write`, `Glob`, `Grep`; no human is in the loop. The workflow has a +4-hour hard timeout. + +## Startup hygiene (do this first, before step 1) + +```bash +# Drop stale datasets from prior runs +rm -rf /tmp/skill-eval/datasets/* + +# Keep this run's results; drop others +find /tmp/skill-eval/results -mindepth 1 -maxdepth 1 -type d \ + ! -name "${GITHUB_RUN_ID}" -exec rm -rf {} + 2>/dev/null || true + +mkdir -p /tmp/skill-eval/datasets /tmp/skill-eval/results +``` + +## Your job, in order + +1. **Diff against the PR's base branch** (`$PR_BASE`, never hardcode + `develop`). Find files changed under `skills//` OR + `skill-source/.agents/skills//`. + + ```bash + gh api "repos/$PR_REPO/compare/${PR_BASE}...pull-request/${PR_NUMBER}" \ + --jq '.files[].filename' + ``` + + Group by skill directory from both locations: + - `skills//` → decomposed skills, skill dir is `$REPO_ROOT/skills/` + - `skill-source/.agents/skills//` → monolithic production skill, + skill dir is `$REPO_ROOT/skill-source/.agents/skills/` + + If nothing under either `skills/` or `skill-source/` changed, + emit `BLOCKED: no skill files changed` and exit. No PR comment. + +2. **For each changed skill, find dispatchable eval specs** — any + `eval/.json` under the skill directory. A skill can ship + multiple specs (e.g. `nvidia_hosted.json` for cpu, `h100.json` for gpu). + + Hard requirements: `skills` (list), `platforms` (list), + `resources.platforms` (dict), `env` (prose), `expects` (list). + If a spec lacks `resources.platforms`, post a + `missing_platforms_declaration` blocker comment and skip it. + + Skills with no `eval/` dir are missing required eval coverage — post a + `missing_eval_specs` blocker comment on the PR and emit + `BLOCKED: has no eval/ directory`. Every skill that is changed + in a PR must have at least one eval spec. This enforces that skill authors + add eval cases as part of skill maintenance. + + Example blocker comment: + ``` + ## ❌ Missing eval specs — `` + + This PR modifies `skill-source/.agents/skills//` but the skill + has no `eval/` directory. Every changed skill must ship at least one + eval spec (`eval/nvidia_hosted.json` for CPU or `eval/h100.json` for GPU). + + Please add an eval spec before this PR can be merged. + ``` + +3. **Check the shared adapter.** All rag-\* skills use a single adapter + at `skill-eval/adapters/rag-blueprint/generate.py` with + `--skill-name `. Verify it accepts `--skill-name`: + + ```bash + cd "$REPO_ROOT/skill-eval" + python3 adapters/rag-blueprint/generate.py --help 2>&1 | grep skill-name + ``` + + If `--skill-name` is missing, the adapter is stale. Raise a bot PR + against the contributor's source branch with the fix and emit + `BLOCKED: adapter missing --skill-name`. + + Do NOT create per-skill adapters — one shared + adapter serves all rag-\* skills. If a skill genuinely needs custom + adapter logic (different PREAMBLE, non-standard platform), note it + in the PR comment and raise a bot PR adding + `skill-eval/adapters//generate.py`. + +4. **Generate the dataset** for each `(skill, spec)`. Datasets land at + `/tmp/skill-eval/datasets///` where `` + is the spec filename without `.json`. + + Resolve `SKILL_DIR` based on where the skill lives: + - Decomposed skills: `SKILL_DIR="$REPO_ROOT/skills/"` + - Monolithic skills: `SKILL_DIR="$REPO_ROOT/skill-source/.agents/skills/"` + + ```bash + cd "$REPO_ROOT/skill-eval" + python3 adapters/rag-blueprint/generate.py \ + --output-dir /tmp/skill-eval/datasets// \ + --skill-dir "$SKILL_DIR" \ + --skill-name "" \ + --spec "$SKILL_DIR/eval/.json" + ``` + + Validate the output: each `step-N/` must contain `instruction.md`, + `task.toml`, `tests/test.sh`, `skills//SKILL.md`. If + generation fails, read the traceback, fix the adapter, rerun. + +5. **Run Harbor trials.** Platform routing: + - **`cpu` platform** (`nvidia_hosted.json` specs) → `LocalEnvironment`. + Docker runs directly on the `rag-skill-validator` runner — no + Brev VM needed. The runner IS the deploy host. + + - **`H100_x2` platform** (`h100.json` specs) → `BrevEnvironment`. + Pre-provision ONE ephemeral Brev VM for all H100 specs in this run + (see § GPU provisioning). Run all H100 trials against that single VM. + + For **cpu skills**, clean any leftover Docker state first: + + ```bash + for f in deploy/compose/docker-compose-rag-server.yaml \ + deploy/compose/docker-compose-ingestor-server.yaml \ + deploy/compose/vectordb.yaml; do + [ -f "$f" ] && docker compose -f "$f" down -v --remove-orphans \ + >/dev/null 2>&1 || true + done + ``` + + Always run **rag-deploy-blueprint first** when it is in the changed + skills set — it deploys the RAG stack that all other skills test + against. Then run remaining cpu skills in any order. + + **GPU pre-flight (automatic, no action required from skill authors):** + Before running ANY H100 spec for any skill, first sync the Brev VM's repo + to the PR base branch so compose files, env files, and skill docs all match + the branch under test (Harbor clones the default branch — main — not the PR): + + ```bash + brev exec "$BREV_INSTANCE" -- \ + "cd /home/nvidia/rag && git fetch origin ${PR_BASE} && git checkout ${PR_BASE} && git pull origin ${PR_BASE}" \ + 2>/dev/null || true + ``` + + Then check if the RAG stack is already running on the Brev VM: + + ```bash + brev exec "$BREV_INSTANCE" -- "curl -sf http://localhost:8081/v1/health" \ + 2>/dev/null && RAG_RUNNING=true || RAG_RUNNING=false + ``` + + After confirming or deploying the stack, log image digests for traceability: + + ```bash + echo "=== Image digests (for traceability) ===" + for container in rag-server ingestor-server; do + brev exec "$BREV_INSTANCE" -- \ + "docker inspect $container --format '{{.Config.Image}} → sha256:{{.Image}}' 2>/dev/null" \ + || echo "$container — not running" + done + ``` + + If `RAG_RUNNING=false` and `rag-blueprint/eval/h100.json` exists in + the repo, run it first to deploy the self-hosted RAG stack. This + happens automatically regardless of which skills are in the PR diff — + skill authors do NOT need to declare this dependency in their specs. + Once deployed, all subsequent H100 specs reuse the running stack. + + Use the canonical Harbor invocation from § Harbor invocation below. + One step at a time, in order. Skip remaining steps if a step's + reward < 1.0 (skip-on-prior-fail). + +6. **Post ONE results comment per `(PR, spec)` batch** after all steps + complete. Format per § Result comment format. Use: + + ```bash + gh pr comment "$PR_NUMBER" --repo "$PR_REPO" --body-file /tmp/pr-.md + ``` + + Do NOT post a planning comment up front. Comments carry results only. + +7. **Cleanup.** + - CPU: tear down Docker stacks on the runner: + ```bash + for f in deploy/compose/docker-compose-rag-server.yaml \ + deploy/compose/docker-compose-ingestor-server.yaml \ + deploy/compose/vectordb.yaml; do + [ -f "$f" ] && docker compose -f "$f" down -v --remove-orphans \ + >/dev/null 2>&1 || true + done + sudo rm -rf deploy/compose/volumes /tmp/milvus-eval 2>/dev/null || true + ``` + - GPU: record instance name in `/tmp/brev/started-by-${GITHUB_RUN_ID}.txt` + (one per line). The workflow step deletes it after a 5-min cooldown — + you do NOT call `brev delete` yourself. + +8. **Exit.** Final line MUST start with `DONE:` or `BLOCKED:`. + +--- + +## GPU provisioning (H100_x2 specs only) + +**One VM per platform per run.** If multiple skills have `H100_x2` specs +(e.g. rag-eval/h100.json + rag-perf/h100.json), provision ONE Brev VM at +the start and run ALL H100 trials against it sequentially. Do NOT provision +a new VM per spec — that wastes 13+ min provisioning time and doubles cost. + +**Before processing specs**, collect all unique platforms needed: + +```bash +# Scan all changed skill specs for their platform requirements +GPU_PLATFORMS_NEEDED=$(...) # e.g. "H100_x2" +``` + +Then reuse an existing warm VM if available, otherwise provision a new one: + +```bash +# Reuse existing rag-eval-gpu-* VM if RUNNING+READY — saves 15-30 min +BREV_INSTANCE=$(brev ls 2>/dev/null \ + | awk '$1 ~ /^rag-eval-gpu-/ && $2=="RUNNING" && $4=="READY" {print $1; exit}') + +if [ -n "$BREV_INSTANCE" ]; then + echo "Reusing existing VM: $BREV_INSTANCE" +else + # No warm VM — provision a fresh one + BREV_TYPE="dmz.h100x2,dmz.h100x2.pcie" + BREV_INSTANCE="rag-eval-gpu-$(date +%s | tail -c 8)" + + # Create with retry + fallback types + for attempt in $(seq 1 5); do + brev create "$BREV_INSTANCE" --type "$BREV_TYPE" --detached 2>&1 | tail -5 + brev ls 2>/dev/null | awk -v n="$BREV_INSTANCE" '$1==n {found=1} END{exit !found}' \ + && break + sleep 15 + done + + # Wait for RUNNING+READY (up to 30 min) + DEADLINE=$(( $(date +%s) + 1800 )) + last_state="" + while [ "$(date +%s)" -lt "$DEADLINE" ]; do + STATE=$(brev ls 2>/dev/null | awk -v n="$BREV_INSTANCE" '$1==n {print $2"+"$4}') + [ -n "$STATE" ] && [ "$STATE" != "$last_state" ] && echo " $(date -u +%H:%M:%SZ) $BREV_INSTANCE: $STATE" && last_state="$STATE" + [ "$STATE" = "RUNNING+READY" ] && break + sleep 15 + done + [ "$last_state" = "RUNNING+READY" ] || { echo "BLOCKED: H100 VM never reached RUNNING+READY"; exit 1; } + + # Record for cleanup — workflow deletes on success after 5-min cooldown + mkdir -p /tmp/brev + echo "$BREV_INSTANCE" >> "/tmp/brev/started-by-${GITHUB_RUN_ID}.txt" +fi + +# Always record VM for cleanup regardless of provisioned vs reused +mkdir -p /tmp/brev +echo "$BREV_INSTANCE" >> "/tmp/brev/started-by-${GITHUB_RUN_ID}.txt" + +export BREV_INSTANCE # reuse for ALL H100_x2 specs below +``` + +--- + +## Harbor invocation + +```bash +export PYTHONPATH="${GITHUB_WORKSPACE}/skill-eval:${PYTHONPATH:-}" + +# CPU skills — LocalEnvironment (no Brev) +uvx --with boto3 harbor run \ + --environment-import-path "envs.local_env:LocalEnvironment" \ + -p /tmp/skill-eval/datasets///step- \ + -a claude-code \ + --model "$ANTHROPIC_MODEL" \ + --ak api_base="$ANTHROPIC_BASE_URL/v1" \ + --ae CLAUDE_CODE_DISABLE_THINKING=1 \ + --environment-build-timeout-multiplier 1.5 \ + --agent-timeout-multiplier 1.5 \ + --verifier-timeout-multiplier 1.5 \ + --max-retries 0 -n 1 --yes \ + -o /tmp/skill-eval/results/"$GITHUB_RUN_ID" + +# GPU skills — BrevEnvironment (pre-provisioned VM) +export BREV_INSTANCE="" +uvx --with boto3 harbor run \ + --environment-import-path "envs.brev_env:BrevEnvironment" \ + -p /tmp/skill-eval/datasets///step- \ + -a claude-code \ + --model "$ANTHROPIC_MODEL" \ + --ak api_base="$ANTHROPIC_BASE_URL/v1" \ + --ae CLAUDE_CODE_DISABLE_THINKING=1 \ + --ae TAG=latest \ + --environment-build-timeout-multiplier 3.0 \ + --agent-timeout-multiplier 3.0 \ + --verifier-timeout-multiplier 3.0 \ + --max-retries 0 -n 1 --yes \ + -o /tmp/skill-eval/results/"$GITHUB_RUN_ID" +``` + +**Step dispatch loop** (run one step at a time, skip-on-prior-fail): + +```bash +STEP_COUNT=$(grep -oP '^step_count\s*=\s*\K\d+' \ + /tmp/skill-eval/datasets///step-1/task.toml) +RESULTS="/tmp/skill-eval/results/${GITHUB_RUN_ID}" +PRIOR_FAIL=0 + +for STEP in $(seq 1 "$STEP_COUNT"); do + if [ "$PRIOR_FAIL" -eq 1 ]; then + echo "skipped (prior-step fail)" \ + > /tmp/skill-eval/skipped--step-${STEP}.txt + continue + fi + + uvx --with boto3 harbor run \ + --environment-import-path "$ENV_IMPORT" \ + -p /tmp/skill-eval/datasets///step-${STEP} \ + ... (flags as above) ... + -o "$RESULTS" + + REWARD=$(cat "$RESULTS"/*/*/step-${STEP}__*/verifier/reward.txt \ + 2>/dev/null | tail -1) + # Skip subsequent steps only on complete failure (reward=0), not partial. + # Partial scores (0 < reward < 1) mean the step ran but some checks failed — + # subsequent steps can still provide useful signal independently. + awk -v r="${REWARD:-0}" 'BEGIN { exit !(r+0 == 0) }' && PRIOR_FAIL=1 +done +``` + +**Harbor execution — wait via TaskOutput, never poll.** +The Bash tool may automatically background long-running `harbor run` commands. +If harbor runs as a background task, use `TaskOutput` ONCE to wait for it — +then proceed immediately when it completes. Do NOT poll with sleep loops, +Monitor, or repeated Bash/Read calls while harbor is running. Do NOT check +intermediate state. Just call `TaskOutput` and wait for the completion signal. +Harbor runs up to 90 minutes on GPU specs — waiting is correct and expected. +Burning turns polling intermediate state is what causes exit-4 failures. + +--- + +## Platform topology + +| Platform | `spec.platforms` value | Environment | Instance | After run | +| ---------------- | ---------------------- | ---------------- | --------------------------------------- | ------------------------------------------ | +| CPU / cloud NIMs | `cpu` | LocalEnvironment | `rag-skill-validator` runner | docker down + volume cleanup | +| 2× H100 80GB | `H100_x2` | BrevEnvironment | `rag-eval-gpu-` (`dmz.h100x2.pcie`) | workflow step deletes after 5-min cooldown | + +`rag-skill-validator` is the CI runner host — **never** provision Brev against it. + +--- + +## Result comment format + +```markdown +## Harbor Eval — `skills//eval/.json` + +Head: `` · spec `` +First started: `` · Last finished: `` · Total: `` + +| Platform | Step | Query | Result | Reward | Duration | Turns | +| -------- | ------ | ---------------------------- | ------------ | ------ | -------- | ----- | +| cpu | step-1 | Deploy via Docker Compose... | ✅ 1.0 (6/6) | 1.0 | 4m 29s | 18 | +| cpu | step-2 | Get RAG Blueprint running... | ✅ 1.0 (5/5) | 1.0 | 1m 23s | 9 | + +### Failing checks + +- **cpu / step-1** — `` `docker ps` `` returned only 3 containers (expected 5) + +Generated by the RAG skills-eval agent. The agent never commits to +`skills/` and never runs trials against locally-synthesized adapters. +Trial results in workflow artifact `skills-eval-results-pr--.tar.gz`. +``` + +**Extracting per-trial metrics:** + +```bash +RESULTS="/tmp/skill-eval/results/${GITHUB_RUN_ID}" +TRAJ="$RESULTS"/*/*/step-${STEP}__*/agent/trajectory.json + +# Turns +TURNS=$(jq '[.steps[].message | fromjson | select(.type=="assistant")] | length' "$TRAJ" 2>/dev/null || echo 0) + +# Duration from result.json +START=$(jq -r '.trial_started_at' "$RESULTS"/*/*/step-${STEP}__*/result.json 2>/dev/null) +END=$(jq -r '.trial_finished_at' "$RESULTS"/*/*/step-${STEP}__*/result.json 2>/dev/null) +``` + +--- + +## Hard rules + +- **Never modify anything under `skills/`.** Raise a bot PR if a skill + file needs a fix; never edit-and-run. +- **Never force-push, never modify history, never merge PRs.** +- **Never touch `rag-skill-validator`** (the CI runner host). +- **Never `brev stop` / `brev delete` GPU instances yourself.** Record + the name in `/tmp/brev/started-by-${GITHUB_RUN_ID}.txt`; the + workflow step handles deletion. +- **Never leak `ANTHROPIC_API_KEY`, `NGC_API_KEY`, `GH_TOKEN`** in + comments, logs, or commit messages. +- **Never dispatch code from non-mirror branches.** +- **Final line MUST be `DONE:` or `BLOCKED:`.** The wrapper exits 4 + if neither appears — the workflow fails, not silently passes. + +--- + +## Manual full-sweep mode + +When `MANUAL_FULL_SWEEP=1` (workflow_dispatch): + +- **Step 1 override:** skip diff. Enumerate `skills/*/eval/*.json`; + filter by `MANUAL_SKILLS_FILTER` (`*` = all skills). +- **Step 3 override:** no bot-PR flow. Record missing adapter as + `BLOCKED:` in the results table and move on. +- **Step 6 override:** no PR to comment on. Append results table to + `$GITHUB_STEP_SUMMARY`: + ```bash + cat >> "$GITHUB_STEP_SUMMARY" <<'MD' + ## Harbor Eval — `skills//eval/.json` + ... same table ... + MD + ``` + +Everything else (startup hygiene, Harbor invocation, cleanup) is identical. + +--- + +## Output requirements + +- Stream prose to stdout — the GitHub Actions log is your audit trail. +- **Final line MUST start with `DONE:` or `BLOCKED:`.** + - `DONE: 2/2 specs passed; 0 blockers` + - `BLOCKED: adapter missing --skill-name flag` +- You MUST post `gh pr comment` with results before printing `DONE:`. + +Now proceed. diff --git a/.github/skill-eval/skills_eval_agent.py b/.github/skill-eval/skills_eval_agent.py new file mode 100644 index 000000000..b4fa49725 --- /dev/null +++ b/.github/skill-eval/skills_eval_agent.py @@ -0,0 +1,250 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +"""RAG Skills eval agent — single-shot CI-driven runner. + +Called by .github/workflows/skills-eval.yml on push to `pull-request/` +when files under `skills/` (or the harness itself) change. Spawns one +`claude-agent-sdk` agent with `.github/skill-eval/AGENTS.md` as its +system prompt and lets it drive the eval end-to-end: diff → +adapter/dataset → Harbor run → results comment → cleanup. + +The agent gets Bash/Read/Edit/Write/Glob/Grep. It is explicitly told +(in AGENTS.md) that it must NOT modify anything under `skills/`. + +Env (set by the workflow step): + PR_NUMBER PR being evaluated, e.g. "100" (push mode; blank on workflow_dispatch) + PR_BASE Base branch, e.g. "develop" (push mode; blank on workflow_dispatch) + PR_HEAD_SHA Mirror head SHA (full) + PR_REPO "owner/repo" + GITHUB_RUN_ID CI run id + GITHUB_STEP_SUMMARY Path to markdown file for Actions run summary (manual mode) + MANUAL_FULL_SWEEP "1" when workflow_dispatch fired + MANUAL_SKILLS_FILTER Single skill name or "*" for all (workflow_dispatch only) + ANTHROPIC_* Agent SDK credentials (sourced from coordinator .env) + GH_TOKEN PR comment posting (push mode only) + NGC_API_KEY For docker login nvcr.io + +Exit codes: + 0 - agent completed (eval may still report failures in PR comment) + 1 - setup error (missing env, AGENTS.md not found, sdk install failed) + 2 - agent crashed + 3 - agent hit max_turns without finishing + 4 - agent exited without DONE:/BLOCKED: marker (protocol failure) +""" +from __future__ import annotations + +import asyncio +import os +import subprocess +import sys +import time +from pathlib import Path + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +# .github/skill-eval/skills_eval_agent.py: +# parents[0] = .github/skill-eval +# parents[1] = .github +# parents[2] = repo root +REPO_ROOT = Path(__file__).resolve().parents[2] +AGENTS_MD = Path(__file__).resolve().parent / "AGENTS.md" + +MAX_TURNS = int(os.environ.get("AGENT_MAX_TURNS", "2000")) + + +# --------------------------------------------------------------------------- +# Pre-flight +# --------------------------------------------------------------------------- + +def _require(name: str) -> str: + v = os.environ.get(name) + if not v: + print(f"FATAL: {name} not set in environment", file=sys.stderr) + sys.exit(1) + return v + + +def _ensure_sdk() -> None: + try: + import claude_agent_sdk # noqa: F401 + except ImportError: + subprocess.run( + [sys.executable, "-m", "pip", "install", "--quiet", + "claude-agent-sdk>=0.0.5"], + check=False, timeout=180, + ) + + +def _disable_server_thinking() -> None: + """NVIDIA Anthropic proxy rejects `context_management` field.""" + if "CLAUDE_CODE_DISABLE_THINKING" not in os.environ: + os.environ["CLAUDE_CODE_DISABLE_THINKING"] = "1" + + +# --------------------------------------------------------------------------- +# Agent loop +# --------------------------------------------------------------------------- + +async def run_agent() -> int: + from claude_agent_sdk import ( # type: ignore + AssistantMessage, ClaudeAgentOptions, ClaudeSDKClient, + ResultMessage, TextBlock, ToolUseBlock, + ) + + manual_sweep = os.environ.get("MANUAL_FULL_SWEEP") == "1" + pr_head = _require("PR_HEAD_SHA") + pr_repo = _require("PR_REPO") + run_id = os.environ.get("GITHUB_RUN_ID", f"local-{int(time.time())}") + + if manual_sweep: + pr_number = os.environ.get("PR_NUMBER", "") or f"manual-{run_id}" + pr_base = os.environ.get("PR_BASE", "") or "(manual)" + skills_filter = ( + os.environ.get("MANUAL_SKILLS_FILTER", "*").strip().splitlines()[0] + if os.environ.get("MANUAL_SKILLS_FILTER", "").strip() + else "*" + ) + step_summary = os.environ.get("GITHUB_STEP_SUMMARY", "") + else: + pr_number = _require("PR_NUMBER") + pr_base = _require("PR_BASE") + skills_filter = "*" + step_summary = "" + + if not AGENTS_MD.exists(): + print(f"FATAL: {AGENTS_MD} not found", file=sys.stderr) + return 1 + + system_prompt = AGENTS_MD.read_text() + + if manual_sweep: + user_prompt = f""" +**Manual full-sweep run** — `workflow_dispatch` fired (no PR, no diff). + +Context: + repo = {pr_repo} + head SHA = {pr_head} + workflow run = {run_id} + working dir = {REPO_ROOT} + skills filter = {skills_filter} + GITHUB_STEP_SUMMARY = {step_summary or '(unset — fall back to stdout)'} + +Per AGENTS.md § "Manual full-sweep mode": + - Skip diff. Enumerate skills/*/eval/*.json, keep skill matching the filter. + - No bot-PR flow (no contributor branch). Record missing adapters as BLOCKED. + - Write results to $GITHUB_STEP_SUMMARY instead of gh pr comment. + +When done, emit `DONE: / specs passed; blockers`. +""" + else: + user_prompt = f""" +PR #{pr_number} just pushed new commits touching `skills/` (or eval harness code). + +Context: + repo = {pr_repo} + PR number = {pr_number} + base branch = {pr_base} + mirror head = {pr_head} + workflow run = {run_id} + working dir = {REPO_ROOT} + +Your workspace is the repo at `{REPO_ROOT}` (already checked out to the mirror head). +The coordinator host is rag-skill-validator; Docker is running. + +Process this PR per AGENTS.md: diff → detect changed skills → check adapter → +generate dataset → run Harbor trials → post ONE comment per (PR, spec) batch. + +When done, emit a one-line final summary starting with `DONE:`. +On blocker, emit `BLOCKED:` followed by the reason. +""" + + model = os.environ.get("ANTHROPIC_MODEL") or "claude-sonnet-4-6" + print( + f"[agent] starting · pr={pr_number} base={pr_base} head={pr_head[:8]} " + f"model={model} max_turns={MAX_TURNS}", + flush=True, + ) + + options = ClaudeAgentOptions( + system_prompt=system_prompt, + allowed_tools=["Bash", "Read", "Edit", "Write", "Glob", "Grep"], + model=model, + max_turns=MAX_TURNS, + permission_mode="bypassPermissions", + cwd=str(REPO_ROOT), + ) + + final_text: list[str] = [] + total_cost = 0.0 + hit_max_turns = False + + async with ClaudeSDKClient(options=options) as client: + await client.query(user_prompt) + async for msg in client.receive_response(): + if isinstance(msg, AssistantMessage): + for block in msg.content: + if isinstance(block, TextBlock) and block.text: + print(block.text, flush=True) + final_text.append(block.text) + elif isinstance(block, ToolUseBlock): + name = getattr(block, "name", "?") + inp = getattr(block, "input", {}) or {} + hint = "" + if name == "Bash": + hint = str(inp.get("command", ""))[:140].replace("\n", " ") + elif name in ("Read", "Edit", "Write"): + hint = str(inp.get("file_path", ""))[-140:] + elif name in ("Glob", "Grep"): + hint = str(inp.get("pattern", ""))[:140] + print(f" [tool] {name} :: {hint}", flush=True) + elif isinstance(msg, ResultMessage): + total_cost = getattr(msg, "total_cost_usd", 0.0) or 0.0 + if getattr(msg, "stop_reason", None) == "max_turns": + hit_max_turns = True + break + + print(f"[agent] finished · cost=${total_cost:.2f}", flush=True) + + if hit_max_turns: + print("[agent] hit max_turns — agent may not have completed", file=sys.stderr) + return 3 + + summary = "\n".join(final_text[-10:]) + if "BLOCKED:" in summary: + print("[agent] reported blocker", file=sys.stderr) + return 0 + if "DONE:" in summary: + return 0 + + print( + "[agent] exited without DONE: or BLOCKED: marker — protocol failure. " + "Check trial logs in the workflow artifact.", + file=sys.stderr, + ) + return 4 + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main() -> int: + _disable_server_thinking() + _ensure_sdk() + try: + rc = asyncio.run(run_agent()) + except KeyboardInterrupt: + print("[agent] interrupted", file=sys.stderr) + rc = 2 + except Exception as exc: # noqa: BLE001 + print(f"[agent] crashed: {exc!r}", file=sys.stderr) + import traceback; traceback.print_exc() + rc = 2 + return rc + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/ci-pipeline.yml b/.github/workflows/ci-pipeline.yml index 6bea384e4..d617961e9 100644 --- a/.github/workflows/ci-pipeline.yml +++ b/.github/workflows/ci-pipeline.yml @@ -38,10 +38,10 @@ jobs: if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' || github.event.pull_request.head.repo.full_name == github.repository steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Setup Helm - uses: azure/setup-helm@v4 + uses: azure/setup-helm@v5 with: version: 'latest' @@ -67,7 +67,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out repository code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - uses: actions/setup-python@v3 - uses: pre-commit/action@v3.0.1 @@ -79,27 +79,40 @@ jobs: image: python:3.12-slim steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Install system dependencies run: | - apt-get update && apt-get install -y gcc && rm -rf /var/lib/apt/lists/* + apt-get update && apt-get install -y gcc curl && rm -rf /var/lib/apt/lists/* - name: Install package with dependencies run: | - pip install -e .[all] - pip install --no-cache-dir -r tests/unit/requirements-test.txt + # Install uv (Python package and environment manager) + curl -LsSf https://astral.sh/uv/install.sh | sh + export PATH="$HOME/.local/bin:$PATH" + + # Create a fresh virtual environment using uv + rm -rf venv-unit || echo "No existing venv-unit to clean up" + uv venv venv-unit + . venv-unit/bin/activate + uv pip install -e .[all] + uv pip install --no-cache -r tests/unit/requirements-test.txt + # rag-perf is a separate package under scripts/rag-perf/. Install its + # runtime deps into the same venv so tests/unit/test_rag_perf/ can + # import rag_perf.config (which pulls ruamel.yaml, click, pydantic). + uv pip install -e ./scripts/rag-perf - name: Run unit tests with coverage run: | - python -m pytest -v -s --cov=src --cov-report=term-missing tests/unit + . venv-unit/bin/activate + python -m pytest -v -s --cov=src --cov-report=term-missing tests/unit --ignore=tests/unit/test_ingestor_server/test_nemo_retriever --ignore=tests/unit/test_utils/test_vdb/test_lancedb_vdb.py frontend-unit-tests: name: Frontend Unit Tests runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Setup Node.js uses: actions/setup-node@v4 @@ -139,7 +152,7 @@ jobs: pnpm test:coverage - name: Upload coverage artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 if: always() with: name: frontend-coverage-${{ steps.sanitize.outputs.ref_name }}-${{ github.sha }} @@ -153,7 +166,7 @@ jobs: image: python:3.12-slim steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Install required packages run: | @@ -172,9 +185,14 @@ jobs: runs-on: arc-runners-org-nvidia-ai-bp-2-gpu # Only run if push to develop OR PR from same repo (not fork) - needs secrets if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' || github.event.pull_request.head.repo.full_name == github.repository + # Multimodal query sequence uses NVIDIA API catalog cloud endpoints; works with Elasticsearch VDB. + env: + ENABLE_MULTIMODAL_QUERY_CI: "true" + # NeMo Retriever Library (NRL) + LanceDB integration sequences (set to "true" to run in CI). + ENABLE_NRL_INTEGRATION_TESTS: "false" steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Install NGC CLI env: @@ -233,12 +251,11 @@ jobs: echo "Loading common environment variables..." export TAG=$(echo ${GITHUB_REF_NAME} | sed 's/[^a-zA-Z0-9]/-/g')-${GITHUB_SHA::7} export NGC_API_KEY=${{ secrets.NGC_API_KEY }} - export DOCKER_VOLUME_DIRECTORY=/tmp/milvus-${MILVUS_VERSION} - export INGESTOR_SERVER_EXTERNAL_VOLUME_MOUNT=/tmp/ingestor-server-data echo "TAG=$TAG" >> $GITHUB_ENV echo "NGC_API_KEY=$NGC_API_KEY" >> $GITHUB_ENV - echo "DOCKER_VOLUME_DIRECTORY=$DOCKER_VOLUME_DIRECTORY" >> $GITHUB_ENV - echo "INGESTOR_SERVER_EXTERNAL_VOLUME_MOUNT=$INGESTOR_SERVER_EXTERNAL_VOLUME_MOUNT" >> $GITHUB_ENV + # Persistent data lives in the `rag-vol-*` named Docker volumes + # (auto-created by compose). Ensure fresh volumes per CI run. + docker volume ls -q --filter "name=^rag-vol-" | xargs -r docker volume rm -f || true # Load nvdev.env and export all variables to GITHUB_ENV if [ -f ./deploy/compose/nvdev.env ]; then @@ -252,6 +269,11 @@ jobs: echo "$key=$resolved_value" >> $GITHUB_ENV done fi + # Default vector DB in CI: Elasticsearch (matches docker-compose defaults) + echo "APP_VECTORSTORE_URL=http://elasticsearch:9200" >> $GITHUB_ENV + echo "APP_VECTORSTORE_NAME=elasticsearch" >> $GITHUB_ENV + echo "APP_VECTORSTORE_USERNAME=" >> $GITHUB_ENV + echo "APP_VECTORSTORE_PASSWORD=" >> $GITHUB_ENV - name: Docker login env: @@ -261,8 +283,29 @@ jobs: - name: Start services run: | - echo "Starting vector database services..." - docker compose -f tests/integration/vectordb.yaml up -d + echo "Starting vector database services (Elasticsearch is default VDB)..." + # Clear volumes before bringing up Elasticsearch so no stale index/data from prior runs + docker compose -f tests/integration/vectordb.yaml --profile milvus down -v || true + docker compose -f tests/integration/vectordb.yaml down -v || true + docker compose -f tests/integration/vectordb.yaml --profile elasticsearch up -d + echo "Waiting for Elasticsearch..." + ES_UP=0 + for i in $(seq 1 60); do + if curl -sf "http://localhost:9200/_cluster/health" >/dev/null; then + echo "Elasticsearch is up (attempt $i)" + ES_UP=1 + break + fi + echo "Waiting for Elasticsearch... ($i/60)" + sleep 5 + done + if [ "$ES_UP" -ne 1 ]; then + echo "::error::Elasticsearch did not become reachable within the timeout" + docker ps -a + docker logs --tail 200 elasticsearch 2>&1 || true + exit 1 + fi + curl -sS "http://localhost:9200/_cluster/health?pretty" || true echo "Starting RAG server..." docker compose -f deploy/compose/docker-compose-rag-server.yaml up -d --build echo "Starting ingestor server..." @@ -275,9 +318,8 @@ jobs: - name: Print logs for running containers run: | echo "=== LOGS FOR RUNNING CONTAINERS ===" - docker logs --tail 50 milvus-standalone || echo "No logs for milvus-standalone" - docker logs --tail 50 milvus-etcd || echo "No logs for milvus-etcd" - docker logs --tail 50 milvus-minio || echo "No logs for milvus-minio" + docker logs --tail 50 elasticsearch || echo "No logs for elasticsearch" + docker logs --tail 50 seaweedfs || echo "No logs for seaweedfs" docker logs rag-server || echo "No logs for rag-server" docker logs ingestor-server || echo "No logs for ingestor-server" echo "Deploy stage completed successfully" @@ -328,6 +370,121 @@ jobs: docker logs compose-nv-ingest-ms-runtime-1 > logs/basic-tests/nvingest.log 2>&1 || true cp tests/integration/integration_test.log logs/basic-tests/ 2>/dev/null || true + # ======================================================================== + # MILVUS VECTOR DATABASE TESTS (dedicated Milvus sequence) + # All other integration suites use Elasticsearch (default VDB). + # ======================================================================== + + - name: Configure environment for Milvus sequence tests + run: | + echo "APP_VECTORSTORE_URL=http://milvus:19530" >> $GITHUB_ENV + echo "APP_VECTORSTORE_NAME=milvus" >> $GITHUB_ENV + echo "APP_VECTORSTORE_USERNAME=" >> $GITHUB_ENV + echo "APP_VECTORSTORE_PASSWORD=" >> $GITHUB_ENV + + - name: Restart vector database for Milvus + run: | + echo "Switching vector database from Elasticsearch to Milvus..." + docker compose -f tests/integration/vectordb.yaml down -v || true + docker compose -f tests/integration/vectordb.yaml --profile elasticsearch down -v || true + docker compose -f tests/integration/vectordb.yaml --profile milvus up -d + echo "Waiting for Milvus to be ready..." + sleep 60 + docker ps + docker logs --tail 80 milvus-standalone || true + + - name: Restart services for Milvus sequence tests + env: + CI_NVSTAGING_BLUEPRINT_KEY: ${{ secrets.CI_NVSTAGING_BLUEPRINT_KEY }} + run: | + echo "Relaunching RAG and ingestor with Milvus..." + export APP_VECTORSTORE_URL=http://milvus:19530 + export APP_VECTORSTORE_NAME=milvus + docker compose -f deploy/compose/docker-compose-rag-server.yaml down || true + docker compose -f deploy/compose/docker-compose-ingestor-server.yaml down || true + sleep 5 + docker compose -f deploy/compose/docker-compose-rag-server.yaml up -d --build + docker compose -f deploy/compose/docker-compose-ingestor-server.yaml up -d --build + sleep 30 + docker ps + + - name: Run Milvus sequence integration tests + id: milvus-sequence-tests + continue-on-error: true + run: | + source venv/bin/activate + echo "Running Milvus sequence integration tests (sequence milvus)..." + python -m tests.integration.main --sequence milvus + echo "Milvus sequence integration tests completed" + + - name: Collect logs after Milvus sequence tests + if: always() + run: | + mkdir -p logs/milvus-sequence + docker logs milvus-standalone > logs/milvus-sequence/milvus.log 2>&1 || true + docker logs milvus-etcd > logs/milvus-sequence/etcd.log 2>&1 || true + docker logs seaweedfs > logs/milvus-sequence/seaweedfs.log 2>&1 || true + docker logs rag-server > logs/milvus-sequence/rag-server.log 2>&1 || true + docker logs ingestor-server > logs/milvus-sequence/ingestor-server.log 2>&1 || true + docker logs compose-nv-ingest-ms-runtime-1 > logs/milvus-sequence/nvingest.log 2>&1 || true + cp tests/integration/integration_test.log logs/milvus-sequence/ 2>/dev/null || true + + - name: Restore Elasticsearch stack after Milvus sequence tests + env: + CI_NVSTAGING_BLUEPRINT_KEY: ${{ secrets.CI_NVSTAGING_BLUEPRINT_KEY }} + run: | + echo "Restoring Elasticsearch for remaining integration tests..." + docker compose -f tests/integration/vectordb.yaml down -v || true + docker compose -f tests/integration/vectordb.yaml --profile milvus down -v || true + docker compose -f tests/integration/vectordb.yaml --profile elasticsearch down -v || true + docker compose -f tests/integration/vectordb.yaml --profile elasticsearch up -d + echo "Waiting for Elasticsearch to become reachable..." + ES_UP=0 + for i in $(seq 1 60); do + if curl -sf "http://localhost:9200/_cluster/health" >/dev/null; then + echo "Elasticsearch is up (attempt $i)" + ES_UP=1 + break + fi + echo "Waiting for Elasticsearch... ($i/60)" + if [ "$((i % 5))" -eq 0 ] || [ "$i" -eq 1 ]; then + echo "=== Elasticsearch debug snapshot (attempt $i) ===" + docker ps -a --filter name=elasticsearch --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" || true + docker inspect elasticsearch --format 'Status={{.State.Status}} ExitCode={{.State.ExitCode}} OOMKilled={{.State.OOMKilled}} Error={{.State.Error}}' 2>/dev/null || echo "inspect: container not found" + echo "--- docker logs elasticsearch (last 100 lines) ---" + docker logs --tail 100 elasticsearch 2>&1 || echo "Could not read elasticsearch logs" + echo "--- /usr/share/elasticsearch/logs/docker-cluster.log (last 80 lines) ---" + docker exec elasticsearch tail -n 80 /usr/share/elasticsearch/logs/docker-cluster.log 2>&1 || echo "(no docker-cluster.log yet or exec failed)" + echo "=== end snapshot ===" + fi + sleep 5 + done + if [ "$ES_UP" -ne 1 ]; then + echo "::error::Elasticsearch did not become reachable within the timeout" + docker ps -a + echo "--- docker logs elasticsearch (last 250 lines) ---" + docker logs --tail 250 elasticsearch 2>&1 || true + echo "--- /usr/share/elasticsearch/logs/docker-cluster.log (last 200 lines) ---" + docker exec elasticsearch tail -n 200 /usr/share/elasticsearch/logs/docker-cluster.log 2>&1 || true + exit 1 + fi + curl -sS "http://localhost:9200/_cluster/health?pretty" || true + docker ps + echo "APP_VECTORSTORE_URL=http://elasticsearch:9200" >> $GITHUB_ENV + echo "APP_VECTORSTORE_NAME=elasticsearch" >> $GITHUB_ENV + echo "APP_VECTORSTORE_USERNAME=" >> $GITHUB_ENV + echo "APP_VECTORSTORE_PASSWORD=" >> $GITHUB_ENV + export APP_VECTORSTORE_URL=http://elasticsearch:9200 + export APP_VECTORSTORE_NAME=elasticsearch + echo "Relaunching RAG and ingestor with Elasticsearch..." + docker compose -f deploy/compose/docker-compose-rag-server.yaml down || true + docker compose -f deploy/compose/docker-compose-ingestor-server.yaml down || true + sleep 5 + docker compose -f deploy/compose/docker-compose-rag-server.yaml up -d --build + docker compose -f deploy/compose/docker-compose-ingestor-server.yaml up -d --build + sleep 30 + docker ps + # ======================================================================== # QUERY REWRITER TESTS # ======================================================================== @@ -555,6 +712,7 @@ jobs: # ======================================================================== - name: Prepare multimodal test data + if: env.ENABLE_MULTIMODAL_QUERY_CI == 'true' run: | mkdir -p data/multimodal/query [ -f tests/data/product_catalog.pdf ] && cp tests/data/product_catalog.pdf data/multimodal/ || true @@ -562,6 +720,7 @@ jobs: [ -f tests/data/query/Creme_clutch_purse1-small.jpg ] && cp tests/data/query/Creme_clutch_purse1-small.jpg data/multimodal/query/ || true - name: Configure environment for multimodal query tests + if: env.ENABLE_MULTIMODAL_QUERY_CI == 'true' run: | # VLM embedding (required for multimodal queries) echo "APP_EMBEDDINGS_MODELNAME=nvidia/llama-nemotron-embed-vl-1b-v2" >> $GITHUB_ENV @@ -582,6 +741,7 @@ jobs: echo "CONVERSATION_HISTORY=0" >> $GITHUB_ENV - name: Restart services for multimodal query tests + if: env.ENABLE_MULTIMODAL_QUERY_CI == 'true' env: CI_NVSTAGING_BLUEPRINT_KEY: ${{ secrets.CI_NVSTAGING_BLUEPRINT_KEY }} run: | @@ -596,6 +756,7 @@ jobs: - name: Run multimodal query integration tests id: multimodal-query-tests + if: env.ENABLE_MULTIMODAL_QUERY_CI == 'true' continue-on-error: true run: | source venv/bin/activate @@ -604,7 +765,7 @@ jobs: echo "Multimodal query integration tests completed" - name: Collect logs after multimodal query tests - if: always() + if: always() && env.ENABLE_MULTIMODAL_QUERY_CI == 'true' run: | mkdir -p logs/multimodal-query docker logs rag-server > logs/multimodal-query/rag-server.log 2>&1 || true @@ -661,7 +822,7 @@ jobs: - name: Stop rag-server and ingestor-server for library tests run: | echo "Stopping rag-server and ingestor-server containers (library mode doesn't need them)..." - echo "Keeping nv-ingest-ms-runtime, Milvus, Redis, MinIO running for library mode..." + echo "Keeping nv-ingest-ms-runtime, Elasticsearch, Redis, object store running for library mode..." docker stop rag-server || true docker stop ingestor-server || true echo "Services stopped. Library tests will use the nvidia_rag library directly." @@ -726,13 +887,19 @@ jobs: env: CI_NVSTAGING_BLUEPRINT_KEY: ${{ secrets.CI_NVSTAGING_BLUEPRINT_KEY }} run: | - echo "Restarting services with observability configuration..." + echo "Restarting services with observability configuration (Elasticsearch default VDB)..." echo "(rag-server and ingestor-server were stopped for library tests, now restarting)" + docker compose -f tests/integration/vectordb.yaml --profile milvus down -v || true docker compose -f tests/integration/vectordb.yaml down -v || true + docker compose -f tests/integration/vectordb.yaml --profile elasticsearch down -v || true docker compose -f deploy/compose/docker-compose-rag-server.yaml down || true docker compose -f deploy/compose/docker-compose-ingestor-server.yaml down || true sleep 5 - docker compose -f tests/integration/vectordb.yaml up -d || true + docker compose -f tests/integration/vectordb.yaml --profile elasticsearch up -d || true + echo "APP_VECTORSTORE_URL=http://elasticsearch:9200" >> $GITHUB_ENV + echo "APP_VECTORSTORE_NAME=elasticsearch" >> $GITHUB_ENV + export APP_VECTORSTORE_URL=http://elasticsearch:9200 + export APP_VECTORSTORE_NAME=elasticsearch docker compose -f deploy/compose/docker-compose-rag-server.yaml up -d --build docker compose -f deploy/compose/docker-compose-ingestor-server.yaml up -d --build sleep 30 @@ -760,12 +927,12 @@ jobs: echo "=== Container Status (docker ps -a) ===" | tee logs/observability/container-status.log docker ps -a --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" | tee -a logs/observability/container-status.log echo "" | tee -a logs/observability/container-status.log - echo "=== Milvus Container Stats ===" | tee -a logs/observability/container-status.log - docker stats --no-stream milvus-standalone 2>&1 | tee -a logs/observability/container-status.log || echo "Milvus container not running" | tee -a logs/observability/container-status.log + echo "=== Elasticsearch Container Stats ===" | tee -a logs/observability/container-status.log + docker stats --no-stream elasticsearch 2>&1 | tee -a logs/observability/container-status.log || echo "Elasticsearch container not running" | tee -a logs/observability/container-status.log docker logs rag-server > logs/observability/rag-server.log 2>&1 || true docker logs ingestor-server > logs/observability/ingestor-server.log 2>&1 || true docker logs compose-nv-ingest-ms-runtime-1 > logs/observability/nvingest.log 2>&1 || true - docker logs milvus-standalone > logs/observability/milvus.log 2>&1 || true + docker logs elasticsearch > logs/observability/elasticsearch.log 2>&1 || true docker logs otel-collector > logs/observability/otel-collector.log 2>&1 || true docker logs zipkin > logs/observability/zipkin.log 2>&1 || true docker logs prometheus > logs/observability/prometheus.log 2>&1 || true @@ -794,11 +961,10 @@ jobs: # Stop and clean up existing Milvus containers and volumes echo "Stopping and cleaning up existing Milvus containers..." docker compose -f tests/integration/vectordb.yaml down -v || true - - # Remove the old data directories to ensure clean start with auth - echo "Cleaning up old Milvus data directories..." - sudo rm -rf /tmp/milvus-${MILVUS_VERSION}/volumes/milvus || true - sudo rm -rf /tmp/milvus-${MILVUS_VERSION}/volumes/etcd || true + + # Wipe the rag-vol-* volumes to ensure a clean start under the new auth config. + echo "Removing rag-vol-* volumes to clear Milvus/etcd state..." + docker volume ls -q --filter "name=^rag-vol-" | xargs -r docker volume rm -f || true # Ensure milvus.yaml exists in tests/integration by copying from running container # Since we stopped the container, we need to start a temporary one to extract config @@ -819,26 +985,34 @@ jobs: echo "Verifying milvus.yaml authentication settings:" grep -A2 "security:" tests/integration/milvus.yaml | head -5 - # Update vectordb.yaml to comment out data volume and enable config volume - sed -i 's|- \${DOCKER_VOLUME_DIRECTORY:-.}/volumes/milvus:/var/lib/milvus|# - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/milvus:/var/lib/milvus|' tests/integration/vectordb.yaml + # Wipe rag-vol-* volumes so Milvus starts with a fresh data dir under the auth config. + # (Replaces the previous trick of commenting out the data-volume bind-mount.) + docker volume ls -q --filter "name=^rag-vol-" | xargs -r docker volume rm -f || true + # Enable the milvus.yaml config file mount in vectordb.yaml sed -i 's|# - \${MILVUS_CONFIG_FILE:-./milvus.yaml}:/milvus/configs/milvus.yaml|- ${MILVUS_CONFIG_FILE:-./milvus.yaml}:/milvus/configs/milvus.yaml|' tests/integration/vectordb.yaml - name: Restart vector database with auth run: | echo "Starting vector database with authentication enabled..." - docker compose -f tests/integration/vectordb.yaml up -d + docker compose -f tests/integration/vectordb.yaml --profile milvus up -d echo "Waiting for Milvus services to be ready..." sleep 60 docker ps echo "Checking Milvus logs..." docker logs --tail 100 milvus-standalone || true docker logs --tail 50 milvus-etcd || true - docker logs --tail 50 milvus-minio || true + docker logs --tail 50 seaweedfs || true - name: Restart services for Milvus VDB auth tests env: CI_NVSTAGING_BLUEPRINT_KEY: ${{ secrets.CI_NVSTAGING_BLUEPRINT_KEY }} run: | + # rag-server and ingestor-server read APP_VECTORSTORE_* at container start (see deploy/compose/docker-compose-*-server.yaml). + # that must use Milvus on the shared nvidia-rag network — same pattern as "Configure environment for Milvus sequence tests". + export APP_VECTORSTORE_URL=http://milvus:19530 + export APP_VECTORSTORE_NAME=milvus + echo "APP_VECTORSTORE_URL=http://milvus:19530" >> $GITHUB_ENV + echo "APP_VECTORSTORE_NAME=milvus" >> $GITHUB_ENV echo "Restarting rag/ingestor services to pick up Milvus auth configuration..." docker compose -f deploy/compose/docker-compose-rag-server.yaml down || true docker compose -f deploy/compose/docker-compose-ingestor-server.yaml down || true @@ -868,7 +1042,7 @@ jobs: docker logs ingestor-server > logs/milvus-vdb-auth/ingestor-server.log 2>&1 || true docker logs milvus-standalone > logs/milvus-vdb-auth/milvus.log 2>&1 || true docker logs milvus-etcd > logs/milvus-vdb-auth/etcd.log 2>&1 || true - docker logs milvus-minio > logs/milvus-vdb-auth/minio.log 2>&1 || true + docker logs seaweedfs > logs/milvus-vdb-auth/seaweedfs.log 2>&1 || true cp tests/integration/integration_test.log logs/milvus-vdb-auth/ 2>/dev/null || true - name: Revert Milvus VDB auth configurations @@ -879,26 +1053,177 @@ jobs: docker compose -f tests/integration/vectordb.yaml down -v || true # Clean up auth-specific data to ensure fresh start - sudo rm -rf /tmp/milvus-${MILVUS_VERSION}/volumes/milvus || true - sudo rm -rf /tmp/milvus-${MILVUS_VERSION}/volumes/etcd || true - + docker volume ls -q --filter "name=^rag-vol-" | xargs -r docker volume rm -f || true + # Remove the auth config file rm -f tests/integration/milvus.yaml || true - - # Revert vectordb.yaml to original state + + # Revert vectordb.yaml to original state (data-volume mount is unchanged now; + # only the milvus.yaml config-file mount needs to be re-commented). sed -i 's|- \${MILVUS_CONFIG_FILE:-./milvus.yaml}:/milvus/configs/milvus.yaml|# - ${MILVUS_CONFIG_FILE:-./milvus.yaml}:/milvus/configs/milvus.yaml|' tests/integration/vectordb.yaml - sed -i 's|# - \${DOCKER_VOLUME_DIRECTORY:-.}/volumes/milvus:/var/lib/milvus|- \${DOCKER_VOLUME_DIRECTORY:-.}/volumes/milvus:/var/lib/milvus|' tests/integration/vectordb.yaml - # Restart Milvus without auth for subsequent tests - echo "Restarting Milvus without authentication for subsequent tests..." + # Restart default vector DB stack (Elasticsearch + object store) for end of job + echo "Restarting vector database stack after Milvus auth tests..." docker compose -f tests/integration/vectordb.yaml up -d sleep 30 + # Match default VDB in job env (same as post observability / post milvus-sequence restore) + echo "APP_VECTORSTORE_URL=http://elasticsearch:9200" >> $GITHUB_ENV + echo "APP_VECTORSTORE_NAME=elasticsearch" >> $GITHUB_ENV # Unset auth environment variables echo "APP_VECTORSTORE_USERNAME=" >> $GITHUB_ENV echo "APP_VECTORSTORE_PASSWORD=" >> $GITHUB_ENV echo "VDB_AUTH_TOKEN=" >> $GITHUB_ENV echo "MILVUS_ROOT_TOKEN=" >> $GITHUB_ENV + + # ======================================================================== + # NEMO RETRIEVER LIBRARY (NRL) + LANCEDB TESTS + # ======================================================================== + # NeMo-Retriever Library runs in-process; LanceDB URI is the mounted path inside containers. + # Gated by ENABLE_NRL_INTEGRATION_TESTS (job env above). + + - name: Prepare LanceDB volume directory + if: ${{ env.ENABLE_NRL_INTEGRATION_TESTS == 'true' }} + run: | + mkdir -p volumes/lancedb + # Clear prior DB only (APP_VECTORSTORE_URL=/volumes/lancedb/lancedb); keep .gitkeep + rm -rf volumes/lancedb/lancedb 2>/dev/null || true + ls -la volumes/lancedb + + - name: Configure environment for NeMo Retriever Library (NRL) sequence tests + if: ${{ env.ENABLE_NRL_INTEGRATION_TESTS == 'true' }} + run: | + echo "APP_VECTORSTORE_URL=/volumes/lancedb/lancedb" >> $GITHUB_ENV + echo "APP_VECTORSTORE_NAME=lancedb" >> $GITHUB_ENV + echo "INGESTOR_BACKEND=nrl" >> $GITHUB_ENV + # Reset ingestor + shared compose env after prior suites (vs docker-compose-ingestor-server.yaml + nvdev.env). + echo "APP_NVINGEST_EXTRACTIMAGES=False" >> $GITHUB_ENV + echo "APP_NVINGEST_STRUCTURED_ELEMENTS_MODALITY=" >> $GITHUB_ENV + echo "APP_NVINGEST_IMAGE_ELEMENTS_MODALITY=" >> $GITHUB_ENV + echo "APP_EMBEDDINGS_MODELNAME=nvidia/llama-nemotron-embed-1b-v2" >> $GITHUB_ENV + echo "APP_EMBEDDINGS_SERVERURL=https://integrate.api.nvidia.com/v1" >> $GITHUB_ENV + echo "APP_TRACING_ENABLED=False" >> $GITHUB_ENV + # RAG compose (same job env for docker compose): restore nvdev / defaults after multimodal + echo "ENABLE_VLM_INFERENCE=False" >> $GITHUB_ENV + echo "ENABLE_RERANKER=True" >> $GITHUB_ENV + echo "APP_RANKING_SERVERURL=" >> $GITHUB_ENV + echo "MILVUS_CONFIG_FILE=" >> $GITHUB_ENV + + - name: Restart services for NRL + LanceDB sequence tests + if: ${{ env.ENABLE_NRL_INTEGRATION_TESTS == 'true' }} + env: + CI_NVSTAGING_BLUEPRINT_KEY: ${{ secrets.CI_NVSTAGING_BLUEPRINT_KEY }} + run: | + echo "Relaunching RAG and ingestor with LanceDB + NRL..." + export APP_VECTORSTORE_URL=/volumes/lancedb/lancedb + export APP_VECTORSTORE_NAME=lancedb + export INGESTOR_BACKEND=nrl + export APP_EMBEDDINGS_MODELNAME=nvidia/llama-nemotron-embed-1b-v2 + export APP_EMBEDDINGS_SERVERURL=https://integrate.api.nvidia.com/v1 + export APP_NVINGEST_EXTRACTIMAGES=False + export APP_NVINGEST_IMAGE_ELEMENTS_MODALITY= + export APP_TRACING_ENABLED=False + docker compose -f deploy/compose/docker-compose-rag-server.yaml down || true + docker compose -f deploy/compose/docker-compose-ingestor-server.yaml down || true + sleep 5 + docker compose -f deploy/compose/docker-compose-rag-server.yaml up -d --build + docker compose -f deploy/compose/docker-compose-ingestor-server.yaml up -d --build + sleep 30 + docker ps + + - name: Run NeMo Retriever Library integration tests + if: ${{ env.ENABLE_NRL_INTEGRATION_TESTS == 'true' }} + id: nemo-retriever-library-tests + continue-on-error: true + run: | + source venv/bin/activate + echo "Running NeMo Retriever Library integration tests (sequence nemo_retriever_library)..." + python -m tests.integration.main --sequence nemo_retriever_library + echo "NeMo Retriever Library integration tests completed" + + - name: Collect logs after NeMo Retriever Library tests + if: ${{ always() && env.ENABLE_NRL_INTEGRATION_TESTS == 'true' }} + run: | + mkdir -p logs/nemo-retriever-library + docker logs rag-server > logs/nemo-retriever-library/rag-server.log 2>&1 || true + docker logs ingestor-server > logs/nemo-retriever-library/ingestor-server.log 2>&1 || true + docker logs compose-nv-ingest-ms-runtime-1 > logs/nemo-retriever-library/nvingest.log 2>&1 || true + cp tests/integration/integration_test.log logs/nemo-retriever-library/ 2>/dev/null || true + + # ------------------------------------------------------------------------ + # NRL + LanceDB: VLM generation (same API tests as vlm_generation) + # ------------------------------------------------------------------------ + - name: Configure environment for NeMo Retriever Library VLM generation tests + if: ${{ env.ENABLE_NRL_INTEGRATION_TESTS == 'true' }} + run: | + echo "ENABLE_VLM_INFERENCE=True" >> $GITHUB_ENV + echo "APP_NVINGEST_EXTRACTIMAGES=False" >> $GITHUB_ENV + echo "ENABLE_QUERYREWRITER=False" >> $GITHUB_ENV + echo "CONVERSATION_HISTORY=0" >> $GITHUB_ENV + + - name: Restart services for NeMo Retriever Library VLM generation tests + if: ${{ env.ENABLE_NRL_INTEGRATION_TESTS == 'true' }} + env: + CI_NVSTAGING_BLUEPRINT_KEY: ${{ secrets.CI_NVSTAGING_BLUEPRINT_KEY }} + run: | + echo "Relaunching RAG and ingestor with LanceDB + NRL + VLM generation..." + export APP_VECTORSTORE_URL=/volumes/lancedb/lancedb + export APP_VECTORSTORE_NAME=lancedb + export INGESTOR_BACKEND=nrl + export APP_EMBEDDINGS_MODELNAME=nvidia/llama-nemotron-embed-1b-v2 + export APP_EMBEDDINGS_SERVERURL=https://integrate.api.nvidia.com/v1 + export APP_NVINGEST_EXTRACTIMAGES=False + export APP_NVINGEST_IMAGE_ELEMENTS_MODALITY= + export APP_TRACING_ENABLED=False + export ENABLE_VLM_INFERENCE=True + export ENABLE_RERANKER=True + export APP_RANKING_SERVERURL= + docker compose -f deploy/compose/docker-compose-rag-server.yaml down || true + docker compose -f deploy/compose/docker-compose-ingestor-server.yaml down || true + sleep 5 + docker compose -f deploy/compose/docker-compose-rag-server.yaml up -d --build + docker compose -f deploy/compose/docker-compose-ingestor-server.yaml up -d --build + sleep 30 + docker ps + + - name: Run NeMo Retriever Library VLM generation integration tests + if: ${{ env.ENABLE_NRL_INTEGRATION_TESTS == 'true' }} + id: nemo-retriever-library-vlm-generation-tests + continue-on-error: true + run: | + source venv/bin/activate + echo "Running NeMo Retriever Library VLM generation integration tests (sequence nemo_retriever_library_vlm_generation)..." + python -m tests.integration.main --sequence nemo_retriever_library_vlm_generation + echo "NeMo Retriever Library VLM generation integration tests completed" + + - name: Collect logs after NeMo Retriever Library VLM generation tests + if: ${{ always() && env.ENABLE_NRL_INTEGRATION_TESTS == 'true' }} + run: | + mkdir -p logs/nemo-retriever-library-vlm-generation + docker logs rag-server > logs/nemo-retriever-library-vlm-generation/rag-server.log 2>&1 || true + docker logs ingestor-server > logs/nemo-retriever-library-vlm-generation/ingestor-server.log 2>&1 || true + docker logs compose-nv-ingest-ms-runtime-1 > logs/nemo-retriever-library-vlm-generation/nvingest.log 2>&1 || true + cp tests/integration/integration_test.log logs/nemo-retriever-library-vlm-generation/ 2>/dev/null || true + + - name: Restore default vector store and ingestor backend after NRL tests + if: ${{ always() && env.ENABLE_NRL_INTEGRATION_TESTS == 'true' }} + env: + CI_NVSTAGING_BLUEPRINT_KEY: ${{ secrets.CI_NVSTAGING_BLUEPRINT_KEY }} + run: | + echo "Restoring Elasticsearch + nv_ingest for workflow cleanup consistency..." + echo "APP_VECTORSTORE_URL=http://elasticsearch:9200" >> $GITHUB_ENV + echo "APP_VECTORSTORE_NAME=elasticsearch" >> $GITHUB_ENV + echo "INGESTOR_BACKEND=nv_ingest" >> $GITHUB_ENV + export APP_VECTORSTORE_URL=http://elasticsearch:9200 + export APP_VECTORSTORE_NAME=elasticsearch + export INGESTOR_BACKEND=nv_ingest + docker compose -f deploy/compose/docker-compose-rag-server.yaml down || true + docker compose -f deploy/compose/docker-compose-ingestor-server.yaml down || true + sleep 5 + docker compose -f deploy/compose/docker-compose-rag-server.yaml up -d --build + docker compose -f deploy/compose/docker-compose-ingestor-server.yaml up -d --build + sleep 30 + docker ps # ======================================================================== # FAIL JOB IF ANY INTEGRATION TEST FAILED @@ -906,10 +1231,11 @@ jobs: # All test steps use continue-on-error so every suite runs; this step # marks the job as failed if any of them failed. - name: Fail job if any integration test failed - if: always() && (steps.basic-tests.outcome == 'failure' || steps.query-rewriter-tests.outcome == 'failure' || steps.reflection-tests.outcome == 'failure' || steps.guardrails-tests.outcome == 'failure' || steps.image-captioning-tests.outcome == 'failure' || steps.vlm-generation-tests.outcome == 'failure' || steps.multimodal-query-tests.outcome == 'failure' || steps.custom-prompt-tests.outcome == 'failure' || steps.library-usage-tests.outcome == 'failure' || steps.library-summarization-tests.outcome == 'failure' || steps.observability-tests.outcome == 'failure' || steps.milvus-vdb-auth-tests.outcome == 'failure') + if: always() && (steps.basic-tests.outcome == 'failure' || steps.milvus-sequence-tests.outcome == 'failure' || steps.query-rewriter-tests.outcome == 'failure' || steps.reflection-tests.outcome == 'failure' || steps.guardrails-tests.outcome == 'failure' || steps.image-captioning-tests.outcome == 'failure' || steps.vlm-generation-tests.outcome == 'failure' || steps.multimodal-query-tests.outcome == 'failure' || steps.custom-prompt-tests.outcome == 'failure' || steps.library-usage-tests.outcome == 'failure' || steps.library-summarization-tests.outcome == 'failure' || steps.observability-tests.outcome == 'failure' || steps.milvus-vdb-auth-tests.outcome == 'failure' || steps.nemo-retriever-library-tests.outcome == 'failure' || steps.nemo-retriever-library-vlm-generation-tests.outcome == 'failure') run: | echo "=== Failed integration test suites ===" [ "${{ steps.basic-tests.outcome }}" = "failure" ] && echo " - basic-tests" + [ "${{ steps.milvus-sequence-tests.outcome }}" = "failure" ] && echo " - milvus-sequence-tests" [ "${{ steps.query-rewriter-tests.outcome }}" = "failure" ] && echo " - query-rewriter-tests" [ "${{ steps.reflection-tests.outcome }}" = "failure" ] && echo " - reflection-tests" [ "${{ steps.guardrails-tests.outcome }}" = "failure" ] && echo " - guardrails-tests" @@ -921,6 +1247,8 @@ jobs: [ "${{ steps.library-summarization-tests.outcome }}" = "failure" ] && echo " - library-summarization-tests" [ "${{ steps.observability-tests.outcome }}" = "failure" ] && echo " - observability-tests" [ "${{ steps.milvus-vdb-auth-tests.outcome }}" = "failure" ] && echo " - milvus-vdb-auth-tests" + [ "${{ steps.nemo-retriever-library-tests.outcome }}" = "failure" ] && echo " - nemo-retriever-library-tests" + [ "${{ steps.nemo-retriever-library-vlm-generation-tests.outcome }}" = "failure" ] && echo " - nemo-retriever-library-vlm-generation-tests" echo "One or more integration test suites failed. Failing job." exit 1 @@ -936,7 +1264,7 @@ jobs: echo "ref_name=$SANITIZED_REF" >> $GITHUB_OUTPUT - name: Upload all integration test logs - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 if: always() with: name: integration-tests-logs-${{ steps.sanitize.outputs.ref_name }}-${{ github.sha }} diff --git a/.github/workflows/publish-artifacts.yml b/.github/workflows/publish-artifacts.yml index 7cb97dbe2..001852b88 100644 --- a/.github/workflows/publish-artifacts.yml +++ b/.github/workflows/publish-artifacts.yml @@ -50,18 +50,30 @@ env: RELEASE_TYPE: dev jobs: + # ============================================================================ + # BRANCH GUARD — scheduled runs are limited to the develop branch. + # workflow_dispatch is allowed from any branch. + # ============================================================================ + check-branch: + name: Check branch for scheduled run + runs-on: ubuntu-latest + if: github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/develop' + steps: + - run: echo "Branch check passed — ref ${{ github.ref }}" + # ============================================================================ # PUBLISH WHEEL # ============================================================================ publish-wheel: name: Build and Publish Python Wheel runs-on: ubuntu-latest + needs: check-branch if: github.event_name != 'workflow_dispatch' || github.event.inputs.JOBS_TO_RUN == 'all' || github.event.inputs.JOBS_TO_RUN == 'wheel-only' container: image: python:3.10 steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set artifactory version run: | @@ -89,7 +101,7 @@ jobs: ls -la dist/ - name: Upload wheel artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: wheel-${{ env.ARTIFACTORY_VERSION }} path: dist/*.whl @@ -121,6 +133,9 @@ jobs: WHEEL_FILENAME=$(basename "$WHEEL_FILE") echo "Wheel filename: $WHEEL_FILENAME" + # Remove existing version to overwrite (ignore error if version does not exist) + ngc registry resource remove-version "nvstaging/blueprint/nvidia_rag:$ARTIFACTORY_VERSION" --org nvstaging -y 2>/dev/null || true + # Publish to NGC echo "Publishing wheel to NGC: nvstaging/blueprint/nvidia_rag:$ARTIFACTORY_VERSION" ngc registry resource upload-version \ @@ -137,13 +152,14 @@ jobs: publish-rag-server: name: Build and Publish RAG Server Container runs-on: ubuntu-latest + needs: check-branch if: github.event_name != 'workflow_dispatch' || ((github.event.inputs.JOBS_TO_RUN == 'all' || github.event.inputs.JOBS_TO_RUN == 'containers-only') && github.event.inputs.PUBLISH_RAG_SERVER != 'false') steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Determine TAG id: tag @@ -161,7 +177,7 @@ jobs: echo "Final TAG value: $TAG" - name: Login to NGC Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: nvcr.io username: '$oauthtoken' @@ -179,7 +195,7 @@ jobs: # Tag and push to NGC Container Registry echo "Pushing rag-server to NGC Container Registry..." docker push nvcr.io/nvstaging/blueprint/rag-server:$TAG - docker tag nvcr.io/nvidia/blueprint/rag-server:$TAG nvcr.io/nvstaging/blueprint/rag-server:latest + docker tag nvcr.io/nvstaging/blueprint/rag-server:$TAG nvcr.io/nvstaging/blueprint/rag-server:latest docker push nvcr.io/nvstaging/blueprint/rag-server:latest echo "RAG server container publishing completed successfully" @@ -196,13 +212,14 @@ jobs: publish-ingestor-server: name: Build and Publish Ingestor Server Container runs-on: ubuntu-latest + needs: check-branch if: github.event_name != 'workflow_dispatch' || ((github.event.inputs.JOBS_TO_RUN == 'all' || github.event.inputs.JOBS_TO_RUN == 'containers-only') && github.event.inputs.PUBLISH_INGESTOR_SERVER != 'false') steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Determine TAG id: tag @@ -220,7 +237,7 @@ jobs: echo "Final TAG value: $TAG" - name: Login to NGC Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: nvcr.io username: '$oauthtoken' @@ -238,7 +255,7 @@ jobs: # Tag and push to NGC Container Registry echo "Pushing ingestor-server to NGC Container Registry..." docker push nvcr.io/nvstaging/blueprint/ingestor-server:$TAG - docker tag nvcr.io/nvidia/blueprint/ingestor-server:$TAG nvcr.io/nvstaging/blueprint/ingestor-server:latest + docker tag nvcr.io/nvstaging/blueprint/ingestor-server:$TAG nvcr.io/nvstaging/blueprint/ingestor-server:latest docker push nvcr.io/nvstaging/blueprint/ingestor-server:latest echo "Ingestor server container publishing completed successfully" @@ -255,13 +272,14 @@ jobs: publish-rag-frontend: name: Build and Publish RAG Frontend Container runs-on: ubuntu-latest + needs: check-branch if: github.event_name != 'workflow_dispatch' || ((github.event.inputs.JOBS_TO_RUN == 'all' || github.event.inputs.JOBS_TO_RUN == 'containers-only') && github.event.inputs.PUBLISH_RAG_FRONTEND != 'false') steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Determine TAG id: tag @@ -279,7 +297,7 @@ jobs: echo "Final TAG value: $TAG" - name: Login to NGC Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: nvcr.io username: '$oauthtoken' @@ -297,7 +315,7 @@ jobs: # Tag and push to NGC Container Registry echo "Pushing rag-frontend to NGC Container Registry..." docker push nvcr.io/nvstaging/blueprint/rag-frontend:$TAG - docker tag nvcr.io/nvidia/blueprint/rag-frontend:$TAG nvcr.io/nvstaging/blueprint/rag-frontend:latest + docker tag nvcr.io/nvstaging/blueprint/rag-frontend:$TAG nvcr.io/nvstaging/blueprint/rag-frontend:latest docker push nvcr.io/nvstaging/blueprint/rag-frontend:latest echo "RAG frontend container publishing completed successfully" @@ -314,13 +332,14 @@ jobs: publish-helm-chart: name: Build and Publish Helm Chart to NGC runs-on: ubuntu-latest + needs: check-branch if: github.event_name != 'workflow_dispatch' || github.event.inputs.JOBS_TO_RUN == 'all' || github.event.inputs.JOBS_TO_RUN == 'helm-chart-only' steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Install Helm - uses: azure/setup-helm@v4 + uses: azure/setup-helm@v5 with: version: 'v3.17.0' @@ -347,6 +366,16 @@ jobs: VERSION=$(./ci/get_version.sh) echo "Generated version: $VERSION" fi + # Helm requires strict semver. Convert Python-style version to semver: + # 2026.04.14.dev0 -> 2026.4.14-dev.0 + # 2026.04.14.post1 -> 2026.4.14-post.1 + # 2026.04.14 -> 2026.4.14 + SEMVER=$(echo "$VERSION" \ + | sed -E 's/^([0-9]+)\.0*([0-9]+)\.0*([0-9]+)\.dev([0-9]+)$/\1.\2.\3-dev.\4/' \ + | sed -E 's/^([0-9]+)\.0*([0-9]+)\.0*([0-9]+)\.post([0-9]+)$/\1.\2.\3-post.\4/' \ + | sed -E 's/^([0-9]+)\.0*([0-9]+)\.0*([0-9]+)$/\1.\2.\3/') + echo "Semver-normalised version: $SEMVER" + VERSION="$SEMVER" echo "version=$VERSION" >> $GITHUB_OUTPUT echo "HELM_CHART_VERSION=$VERSION" >> $GITHUB_ENV @@ -387,4 +416,3 @@ jobs: ngc registry chart remove "$TARGET" --org nvstaging -y 2>/dev/null || true ngc registry chart push "$TARGET" --source "$CHART_TGZ" --org nvstaging echo "Helm chart published to NGC: $TARGET" - diff --git a/.github/workflows/run-branch-script.yml b/.github/workflows/run-branch-script.yml index 6100e30a2..d684120ab 100644 --- a/.github/workflows/run-branch-script.yml +++ b/.github/workflows/run-branch-script.yml @@ -2,7 +2,7 @@ name: Run Branch Script # Generic dispatcher: checks out a specified branch and runs a script from it. # Lets us iterate on CI logic in feature branches without merging workflow -# changes for every iteration. Especially useful for skill-eval/NV-BASE runs +# changes for every iteration. Especially useful for skill-eval runs # where the script under ci/ does all the install + run logic. on: @@ -11,11 +11,11 @@ on: ref: description: 'Branch / tag / SHA to check out' required: true - default: 'feat/nvbase-ci-smoke' + default: 'develop' script: description: 'Script path relative to repo root' required: true - default: 'ci/run_nvbase_eval.sh' + default: 'ci/run_skill_eval.sh' runner: description: 'Runner label to use' required: true @@ -42,7 +42,7 @@ jobs: steps: - name: Checkout target ref - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: ref: ${{ inputs.ref }} @@ -64,7 +64,7 @@ jobs: - name: Upload artifacts if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: branch-script-${{ github.run_id }} path: | diff --git a/.github/workflows/skills-eval.yml b/.github/workflows/skills-eval.yml new file mode 100644 index 000000000..0cafd6c6a --- /dev/null +++ b/.github/workflows/skills-eval.yml @@ -0,0 +1,197 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Tier 3 Harbor eval for rag-* skills. +# Three triggers: +# 1. push to pull-request/* → diff-based: only eval skills changed in the PR +# 2. schedule (nightly) → all rag-* cpu skills +# 3. workflow_dispatch → manual trigger with skill selector +# +# Note: cve-fix skill requires NSPect (NVIDIA internal tool) — GitHub Actions +# runners cannot reach it (not security approved). cve-fix runs via GitLab CI +# on the mirror repo (gitlab-master.nvidia.com/chat-labs/OpenSource/rag). +# +# Uses dorny/paths-filter for cumulative PR diff (not per-push) — same +# reason as skills-eval.yml. copy-pr-bot merge commits don't always +# touch skills/ even when the PR does. + +name: Skills Eval + +on: + push: + branches: + - "pull-request/[0-9]+" + schedule: + - cron: "0 2 * * *" # 2am UTC nightly — all rag-* cpu skills + workflow_dispatch: + inputs: + skills: + description: "Skill to eval (* for all, or pick one)" + required: false + default: "*" + type: choice + options: + - "*" + - rag-deploy-blueprint + - rag-ingest-documents + - rag-query-knowledge + - rag-configure-infrastructure + - rag-configure-retrieval + - rag-evaluate-quality + - rag-manage-mcp + - rag-troubleshoot-blueprint + - rag-enable-vlm + - rag-enable-guardrails + +permissions: + contents: write + pull-requests: write + +concurrency: + group: skills-eval-${{ github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash + +jobs: + eval: + name: Eval changed skills against PR + runs-on: [self-hosted, rag-eval] + + # 4-hour cap: 8 cpu skills × ~15 min each = 2h max with 1.5x timeouts. + # Nightly runs all skills; PR runs only changed ones so usually much faster. + timeout-minutes: 240 + + if: startsWith(github.ref, 'refs/heads/pull-request/') || github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' + + steps: + - name: Pre-checkout cleanup — remove root-owned Docker volumes + # Must run BEFORE checkout. Root-owned Milvus/MinIO/etcd files from + # prior runs block git clean. Docker alpine removes them without sudo. + run: | + WORKSPACE="${GITHUB_WORKSPACE:-/home/ubuntu/actions-runner/_work/rag/rag}" + for vol_dir in deploy/compose/volumes ci/volumes; do + TARGET="$WORKSPACE/$vol_dir" + if [ -d "$TARGET" ]; then + echo "Cleaning $TARGET" + docker run --rm -v "$TARGET:/target" alpine \ + sh -c "rm -rf /target/* /target/.??* 2>/dev/null; exit 0" || true + rm -rf "$TARGET" 2>/dev/null || true + fi + done + + - name: Checkout + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Extract PR number + base + id: pr + if: github.event_name == 'push' + run: | + REF="${{ github.ref_name }}" + PR="${REF##pull-request/}" + BASE=$(gh pr view "$PR" --json baseRefName --jq .baseRefName) + echo "number=$PR" >> "$GITHUB_OUTPUT" + echo "base=$BASE" >> "$GITHUB_OUTPUT" + echo "PR #$PR, base=$BASE" + env: + GH_TOKEN: ${{ github.token }} + + - name: Detect skills/ changes vs PR base + id: changes + if: github.event_name == 'push' + uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d + with: + base: ${{ steps.pr.outputs.base }} + filters: | + skills: + - 'skills/**' + - 'skill-source/**' + harness: + - '.github/skill-eval/**' + - '.github/workflows/skills-eval.yml' + - 'ci/run_skill_eval.sh' + + - name: Run skills eval agent + id: agent + if: github.event_name != 'push' || steps.changes.outputs.skills == 'true' + env: + GH_TOKEN: ${{ github.token }} + GH_CONFIG_DIR: ${{ runner.temp }}/gh-skill-eval-${{ github.run_id }} + INPUT_SKILLS: ${{ inputs.skills }} + ANTHROPIC_API_KEY: ${{ secrets.NVBASE_INFERENCE_API_KEY }} + ANTHROPIC_BASE_URL: https://inference-api.nvidia.com + ANTHROPIC_MODEL: aws/anthropic/bedrock-claude-sonnet-4-6 + NVIDIA_INFERENCE_KEY: ${{ secrets.NVBASE_INFERENCE_API_KEY }} + NVIDIA_API_KEY: ${{ secrets.NVBASE_INFERENCE_API_KEY }} + NGC_API_KEY: ${{ secrets.NGC_API_KEY }} + JUDGE_ANTHROPIC_API_KEY: ${{ secrets.NVBASE_INFERENCE_API_KEY }} + JUDGE_FULL_MODEL: aws/anthropic/claude-haiku-4-5-v1 + CLAUDE_CODE_DISABLE_THINKING: "1" + TAG: latest + run: | + mkdir -p "$GH_CONFIG_DIR" /tmp/brev /tmp/skill-eval + export PR_NUMBER="${{ steps.pr.outputs.number }}" + export PR_BASE="${{ steps.pr.outputs.base }}" + export PR_HEAD_SHA="${{ github.sha }}" + export PR_REPO="${{ github.repository }}" + export GITHUB_RUN_ID="${{ github.run_id }}" + # PYTHONPATH lets uvx harbor resolve envs.*:*Environment from skill-eval/ + export PYTHONPATH="${GITHUB_WORKSPACE}/skill-eval:${PYTHONPATH:-}" + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + export MANUAL_FULL_SWEEP=1 + export MANUAL_SKILLS_FILTER="${INPUT_SKILLS}" + fi + python3 .github/skill-eval/skills_eval_agent.py + + - name: Collect results for artifact + if: always() && (github.event_name != 'push' || steps.changes.outputs.skills == 'true') + run: | + if [ ! -d /tmp/skill-eval/results ]; then + echo "no results dir — agent blocked before running trials" + exit 0 + fi + RESULTS=$(find /tmp/skill-eval/results -maxdepth 3 -name "result.json" 2>/dev/null | head -50 || true) + if [ -n "$RESULTS" ]; then + tar czf /tmp/skills-eval-results.tar.gz -C /tmp/skill-eval results + echo "archived $(echo "$RESULTS" | wc -l) result.json files" + else + echo "results dir exists but empty — nothing to archive" + fi + + - name: Upload results artifact + if: always() && (github.event_name != 'push' || steps.changes.outputs.skills == 'true') + uses: actions/upload-artifact@v5 + with: + name: >- + ${{ github.event_name == 'schedule' + && format('skills-eval-nightly-{0}', github.run_id) + || github.event_name == 'workflow_dispatch' + && format('skills-eval-manual-{0}', github.run_id) + || format('skills-eval-pr-{0}-{1}', steps.pr.outputs.number, github.run_id) }} + path: /tmp/skills-eval-results.tar.gz + if-no-files-found: ignore + retention-days: 7 + + - name: Delete GPU Brev VMs on success + if: success() && (github.event_name != 'push' || steps.changes.outputs.skills == 'true') + run: | + RECORD="/tmp/brev/started-by-${{ github.run_id }}.txt" + if [ ! -f "$RECORD" ]; then + echo "No GPU VMs to clean up." + exit 0 + fi + echo "Waiting 5 min cooldown before deleting VMs..." + sleep 300 + sort -u "$RECORD" | while read -r INSTANCE; do + [ -z "$INSTANCE" ] && continue + echo "Deleting Brev VM: $INSTANCE" + brev delete "$INSTANCE" 2>/dev/null && echo "Deleted $INSTANCE" || echo "Could not delete $INSTANCE (may already be gone)" + done + + - name: Skip note when no skills/ changes + if: github.event_name == 'push' && steps.changes.outputs.skills != 'true' + run: echo "::notice::No skills/ changes in this PR; eval skipped." diff --git a/.openclaw/.gitignore b/.openclaw/.gitignore new file mode 100644 index 000000000..985379aa4 --- /dev/null +++ b/.openclaw/.gitignore @@ -0,0 +1,5 @@ +dist/ +node_modules/ +skills/ +.skills.staged +.variant diff --git a/.openclaw/README.md b/.openclaw/README.md new file mode 100644 index 000000000..06c31338b --- /dev/null +++ b/.openclaw/README.md @@ -0,0 +1,220 @@ +# RAG Claw — OpenClaw Plugin + +NVIDIA RAG Blueprint agent for [OpenClaw](https://github.com/openclaw/openclaw). Provides skills covering the full RAG lifecycle: prerequisites and deployment, configuration, troubleshooting, RAGAS evaluation, and performance benchmarking. + +--- + +## Prerequisites + +### OpenClaw host + +| Requirement | Notes | +|-------------|-------| +| **Node.js** | **≥ 22.19.0** (required by OpenClaw and this plugin). System Node 20/21 is not supported. | +| **npm** | Bundled with Node; used for global OpenClaw CLI and local plugin build. | +| **nvm** (recommended) | Installs Node in your home directory so `npm install -g` works without `sudo`. | + +Install Node 22 with [nvm](https://github.com/nvm-sh/nvm), then use it in every shell: + +```bash +curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash +source ~/.nvm/nvm.sh +nvm install 22 +nvm alias default 22 +node -v # v22.x.x +``` + +### RAG deployment + +The following should be in place before deploying RAG containers. The agent checks and guides you through each step via the `rag-blueprint` skill — this is a quick reference. + +| Requirement | Notes | Install guide | +|-------------|-------|---------------| +| NVIDIA GPU driver | See `docs/support-matrix.md` for minimum version | [nvidia.com/drivers](https://www.nvidia.com/en-us/drivers/) — reboot after install | +| Docker Engine | Required for Docker Compose deployments | [docs.docker.com/engine/install/ubuntu](https://docs.docker.com/engine/install/ubuntu/) | +| Docker Compose | v2.x | Included with Docker Desktop / Engine | +| NVIDIA Container Toolkit | Required for self-hosted NIMs | [Container Toolkit install guide](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html) | +| NGC API key | `NGC_API_KEY` or `NVIDIA_API_KEY` | [org.ngc.nvidia.com/setup/api-keys](https://org.ngc.nvidia.com/setup/api-keys) | + +**Post-Docker install:** add your user to the docker group so containers run without sudo: + +```bash +sudo usermod -aG docker $USER && newgrp docker +``` + +--- + +## 1. Install OpenClaw + +Use the nvm Node 22 shell (see above), then install the CLI globally: + +```bash +npm install -g openclaw +openclaw --version +``` + +Run guided setup once (models, API keys, workspace, gateway): + +```bash +openclaw onboard +``` + +For NVIDIA API Catalog models, you can pass `--auth-choice nvidia-api-key` during onboard, or set `NVIDIA_API_KEY` / `NGC_API_KEY` in your environment and configure later with `openclaw configure`. + +--- + +## 2. Install the RAG Claw Plugin + +OpenClaw loads **compiled** JavaScript from `dist/`. Build the plugin from the RAG repo, then install with **`--link`** so OpenClaw uses your checkout in place (recommended for development and for machines where a copy install fails peer-dependency linking). + +**From the cloned RAG repo:** + +```bash +cd /path/to/rag/.openclaw +npm install +npm run build +openclaw plugins install --link /path/to/rag/.openclaw/ +``` + +Use an **absolute path** to `.openclaw/` (as above). `npm run build` compiles `index.ts` → `dist/index.js` and copies skills from `skill-source/.agents/skills/` into `skills/`. + +Do **not** use a bare copy install (`openclaw plugins install /path/to/rag/.openclaw/` or `openclaw plugins install ./` from inside `.openclaw/`) unless you are using a packaged OpenClaw install that can symlink the `openclaw` peer dependency. On many systems that fails with: + +```text +Installed plugin openclaw-rag declares openclaw as a peer dependency, but OpenClaw could not create a plugin-local node_modules/openclaw link. +``` + +`@nvidia/openclaw-rag` is **not published to npm** yet. Do not run `openclaw plugins install @nvidia/openclaw-rag` until the package is available on the registry. + +Restart the gateway after install so the plugin loads: + +```bash +# foreground gateway: stop and run again +openclaw gateway run + +# or systemd user service +systemctl --user restart openclaw-gateway +``` + +On first gateway start, the plugin copies workspace templates (`BOOTSTRAP.md`, `IDENTITY.md`, `SOUL.md`, `AGENTS.md`, `TOOLS.md`) to `~/.openclaw/workspace/`. If Docker is present, it may write a systemd drop-in for gateway Docker socket access — apply it with: + +```bash +systemctl --user daemon-reload +systemctl --user restart openclaw-gateway +``` + +--- + +## 3. Verify + +```bash +openclaw skills list | grep -E "rag-blueprint|rag-eval|rag-perf" +``` + +Expected output: + +```text +rag-blueprint Deploy, configure, troubleshoot, and manage the NVIDIA RAG Blueprint +rag-eval Filesystem RAG benchmarks (corpus/, train.json, RAGAS via evaluate_rag.py) +rag-perf Performance benchmarking (aiperf load tests) for a deployed RAG server +``` + +--- + +## 4. Run and interact + +Start the **gateway** (required for chat and the web UI): + +```bash +# foreground (good for first try) +openclaw gateway run + +# or install and start a user service +openclaw gateway install +systemctl --user start openclaw-gateway +``` + +Add a long agent timeout for RAG deploys and evals (merge into `~/.openclaw/openclaw.json`): + +```json +{ + "agents": { + "defaults": { + "timeoutSeconds": 3600 + } + } +} +``` + +In another terminal, use any of these: + +| Interface | Command | +|-----------|---------| +| Terminal chat (gateway) | `openclaw tui --timeout-ms 3600000` | +| Terminal chat (local) | `openclaw chat --timeout-ms 3600000` | +| Web Control UI | `openclaw dashboard` → `http://127.0.0.1:18789/` | +| One-shot turn | `openclaw agent --message "check prerequisites"` | + +Prefer **gateway-backed** `openclaw tui` (not `openclaw chat`) for end-to-end RAG Docker deploys: the gateway keeps the run alive while the terminal UI may show `idle` after ~30s without streamed text. If you see _"This response is taking longer than expected"_, wait for the run to finish or send a short follow-up (for example _"status update"_); avoid starting a new deploy request until the prior turn completes. Inside the TUI, `/verbose full` shows tool progress during long shell work. + +Check status: + +```bash +openclaw status +openclaw health +``` + +**First session:** start `openclaw tui` (with the gateway running). The BOOTSTRAP flow runs automatically and the agent introduces itself and walks through initial RAG configuration. + +Example prompts: + +- _"check prerequisites"_ +- _"deploy RAG with NVIDIA-hosted NIMs"_ +- _"RAG server is unhealthy"_ +- _"run RAGAS eval on my benchmark dataset"_ + +Optional: install the browser skill into the workspace (used for RAG UI automation): + +```bash +cd ~/.openclaw/workspace +npx --yes skills add vercel-labs/agent-browser --yes +``` + +OpenClaw CLI reference: [docs.openclaw.ai/cli](https://docs.openclaw.ai/cli) + +--- + +## Skills Reference + +| Skill | Trigger phrases | +|-------|-----------------| +| `rag-blueprint` | "deploy RAG", "enable VLM", "configure reranker", "RAG is unhealthy", "stop RAG" | +| `rag-eval` | "run RAGAS eval", "evaluate_rag", "benchmark dataset", "parse eval results" | +| `rag-perf` | "rag perf", "aiperf", "latency benchmark", "throughput test" | + +Skill source of truth: `skill-source/.agents/skills/` in this repository. + +--- + +## Default service ports + +| Service | Port | +|---------|------| +| RAG server API | 8081 | +| Ingestor API | 8082 | +| Web UI | 8090 | + +See `docs/service-port-gpu-reference.md` for the full port and GPU map. + +--- + +## Troubleshooting plugin install + +| Error | Cause | Fix | +|-------|-------|-----| +| `could not create a plugin-local node_modules/openclaw link` | Copy install (`plugins install` without `--link`) | `openclaw plugins install --link /path/to/rag/.openclaw/` after `npm run build` | +| `Package not found on npm: @nvidia/openclaw-rag` | Package not published | Install from the repo with `--link` (see above) | +| `package.json missing openclaw.hooks` | OpenClaw also tried hook-pack install | Ignore if the native plugin install succeeded; use `--link` on the `.openclaw/` directory | +| Skills missing after install | `dist/` or `skills/` not built | `cd /path/to/rag/.openclaw && npm install && npm run build`, then reinstall with `--link` | + +OpenClaw plugin install reference: [docs.openclaw.ai/tools/plugin](https://docs.openclaw.ai/tools/plugin) (local checkouts: `openclaw plugins install --link ./my-plugin`). diff --git a/.openclaw/index.ts b/.openclaw/index.ts new file mode 100644 index 000000000..fe426a9b5 --- /dev/null +++ b/.openclaw/index.ts @@ -0,0 +1,155 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + writeFileSync, +} from "node:fs"; +import { join, dirname } from "node:path"; +import { homedir } from "node:os"; + +type Logger = { info: (msg: string) => void; warn: (msg: string) => void }; + +export default function register(api: { + config: { agents?: { defaults?: { workspace?: string } } }; + source: string; + logger: Logger; +}) { + copyWorkspaceTemplates(api); + patchGatewayDockerGroup(api); + installAgentBrowserSkill(api); +} + +function resolveVariant(pluginDir: string, logger: Logger): string | undefined { + const variantFile = join(pluginDir, ".variant"); + const envVariant = process.env.OPENCLAW_PLUGIN_VARIANT?.trim(); + if (envVariant) { + try { + writeFileSync(variantFile, envVariant, "utf8"); + } catch (err) { + logger.warn(`[rag-claw] failed to persist variant: ${err}`); + } + return envVariant; + } + if (!existsSync(variantFile)) return undefined; + try { + return readFileSync(variantFile, "utf8").trim() || undefined; + } catch (err) { + logger.warn(`[rag-claw] failed to read persisted variant: ${err}`); + return undefined; + } +} + +function copyWorkspaceTemplates(api: { + config: { agents?: { defaults?: { workspace?: string } } }; + source: string; + logger: Logger; +}) { + const workspaceDir = api.config?.agents?.defaults?.workspace; + if (!workspaceDir) return; + + const pluginDir = dirname(api.source); + const templatesDir = join(pluginDir, "workspace"); + const variant = resolveVariant(pluginDir, api.logger); + + try { + mkdirSync(workspaceDir, { recursive: true }); + const files = readdirSync(templatesDir).filter((f) => f.endsWith(".md")); + for (const file of files) { + copyFileSync(join(templatesDir, file), join(workspaceDir, file)); + } + api.logger.info( + `[rag-claw] copied ${files.length} workspace templates to ${workspaceDir}`, + ); + + if (variant) { + const overlayDir = join(templatesDir, `_${variant}`); + if (existsSync(overlayDir)) { + const overlayFiles = readdirSync(overlayDir).filter((f) => + f.endsWith(".md"), + ); + for (const file of overlayFiles) { + copyFileSync(join(overlayDir, file), join(workspaceDir, file)); + } + api.logger.info( + `[rag-claw] applied _${variant} overrides (${overlayFiles.length} files)`, + ); + } else { + api.logger.warn( + `[rag-claw] variant='${variant}' set but ${overlayDir} is missing`, + ); + } + } + } catch (err) { + api.logger.warn(`[rag-claw] workspace copy failed: ${err}`); + } +} + +function installAgentBrowserSkill(api: { + config: { agents?: { defaults?: { workspace?: string } } }; + logger: Logger; +}) { + const workspaceDir = api.config?.agents?.defaults?.workspace; + if (!workspaceDir) return; + + const skillDir = join(workspaceDir, "skills", "agent-browser"); + if (existsSync(skillDir)) return; + + api.logger.info( + `[rag-claw] optional agent-browser skill: cd ${workspaceDir} && npx --yes skills add vercel-labs/agent-browser --yes`, + ); +} + +function patchGatewayDockerGroup(api: { logger: Logger }) { + if (!existsSync("/var/run/docker.sock")) return; + + const serviceFile = join( + homedir(), + ".config/systemd/user/openclaw-gateway.service", + ); + if (!existsSync(serviceFile)) return; + + const dropinDir = join( + homedir(), + ".config/systemd/user/openclaw-gateway.service.d", + ); + const dropinFile = join(dropinDir, "10-docker.conf"); + + try { + const content = readFileSync(serviceFile, "utf8"); + const match = content.match(/^ExecStart=(.+)$/m); + if (!match) return; + const execStart = match[1]; + + if (execStart.includes("sg docker")) return; + + if (existsSync(dropinFile)) { + const dropinContent = readFileSync(dropinFile, "utf8"); + if (dropinContent.includes(execStart)) return; + } + + const escapedExecStart = execStart.replace(/'/g, "'\"'\"'"); + + mkdirSync(dropinDir, { recursive: true }); + writeFileSync( + dropinFile, + [ + "# Added by rag-claw plugin — wraps ExecStart with sg docker for Docker socket access", + "[Service]", + "ExecStart=", + `ExecStart=/bin/sg docker -c '${escapedExecStart}'`, + ].join("\n") + "\n", + "utf8", + ); + + api.logger.info( + "[rag-claw] created docker drop-in for openclaw-gateway — run: systemctl --user daemon-reload && systemctl --user restart openclaw-gateway (see .openclaw/README.md)", + ); + } catch (err) { + api.logger.warn(`[rag-claw] docker group patch failed: ${err}`); + } +} diff --git a/.openclaw/openclaw.plugin.json b/.openclaw/openclaw.plugin.json new file mode 100644 index 000000000..849af0b10 --- /dev/null +++ b/.openclaw/openclaw.plugin.json @@ -0,0 +1,11 @@ +{ + "id": "openclaw-rag", + "name": "RAG Claw", + "description": "NVIDIA RAG Blueprint agent — deploy, configure, evaluate, and operate retrieval-augmented generation pipelines. Requires Node >= 22.19.0, OpenClaw >= 2026.5.17; run npm run build, then openclaw plugins install --link /path/to/rag/.openclaw/ from a repo checkout.", + "skills": ["./skills"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/.openclaw/package-lock.json b/.openclaw/package-lock.json new file mode 100644 index 000000000..5ff8834d7 --- /dev/null +++ b/.openclaw/package-lock.json @@ -0,0 +1,5425 @@ +{ + "name": "@nvidia/openclaw-rag", + "version": "2.6.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@nvidia/openclaw-rag", + "version": "2.6.0", + "devDependencies": { + "@types/node": "^22.15.0", + "typescript": "^5.8.3" + }, + "engines": { + "node": ">=22.19.0" + }, + "peerDependencies": { + "openclaw": ">=2026.5.17" + } + }, + "node_modules/@agentclientprotocol/sdk": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.21.1.tgz", + "integrity": "sha512-ZTLH+o9QxcZDLX/9ww+W7C2iExnXFM+vD/uGFVSlR61Kzj9FaxUqBC6Rv/kwgA7qVWYUEI9c5ZNqCuO9PM4rKg==", + "peer": true, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.91.1", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.91.1.tgz", + "integrity": "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==", + "peer": true, + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "peer": true, + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "peer": true, + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "peer": true, + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "peer": true, + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "peer": true, + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/client-bedrock-runtime": { + "version": "3.1051.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1051.0.tgz", + "integrity": "sha512-Hf532hBUIq/QqtGrKnWxHhw4rGpzTAGA7hG5c+IlOY0z8pqrqNXdwLjSve6ClOg8/Lxdvo+E81VzFVtl03v97A==", + "peer": true, + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.12", + "@aws-sdk/credential-provider-node": "^3.972.43", + "@aws-sdk/eventstream-handler-node": "^3.972.16", + "@aws-sdk/middleware-eventstream": "^3.972.12", + "@aws-sdk/middleware-websocket": "^3.972.20", + "@aws-sdk/token-providers": "3.1051.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.974.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.12.tgz", + "integrity": "sha512-qrqgioqYFjwR6LatVNS1L2Vk++EwRIxqSQXPKNv5Ofux2D8UNgqMQ1znnMyEImXquVPTtbf71fc128pvmU6y9A==", + "peer": true, + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/xml-builder": "^3.972.24", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/core": "^3.24.2", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.38", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.38.tgz", + "integrity": "sha512-m3WjZEgPtioMhPmwqUt+DhlTJ2i9ufR6DhfkyXojb9puEvfR+ur2U5shavu5/Cc9WHHsDCvALi6UFHgcqjhQ5w==", + "peer": true, + "dependencies": { + "@aws-sdk/core": "^3.974.12", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.40", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.40.tgz", + "integrity": "sha512-D78L/m2Dr6cJnnSvWoAudPhQmCwmJ7j6APXsPYmFpPaKfQTfCSu0rdm8j14Np+VmXF9z8Aj8HE3xFpsrwtfgeg==", + "peer": true, + "dependencies": { + "@aws-sdk/core": "^3.974.12", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.42", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.42.tgz", + "integrity": "sha512-Mu5ESvFXeinafVM8jTIvRqcvK2Ehj4kz3auT39yUcHwu1Vfxo6xRlmUafdKLW4tusjAJukQwK09sCSMgOm7OKg==", + "peer": true, + "dependencies": { + "@aws-sdk/core": "^3.974.12", + "@aws-sdk/credential-provider-env": "^3.972.38", + "@aws-sdk/credential-provider-http": "^3.972.40", + "@aws-sdk/credential-provider-login": "^3.972.42", + "@aws-sdk/credential-provider-process": "^3.972.38", + "@aws-sdk/credential-provider-sso": "^3.972.42", + "@aws-sdk/credential-provider-web-identity": "^3.972.42", + "@aws-sdk/nested-clients": "^3.997.10", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/credential-provider-imds": "^4.3.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.42", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.42.tgz", + "integrity": "sha512-O6WkZga3kf0yqyJYd1dbeJqVhEgJx/x1UaLgtbR+XuL/YP+K5y6QTxQKL7ka9z3jnQASESKGAPnRyt4D5hQrxA==", + "peer": true, + "dependencies": { + "@aws-sdk/core": "^3.974.12", + "@aws-sdk/nested-clients": "^3.997.10", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.43", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.43.tgz", + "integrity": "sha512-D/DJmbrWRP5BXEO3FH+ar4el+2n6OlGofiud7dQun2jES+AQEJjczenp1jBb4MBN7CpGpS8nsWGQLtuzc9tQbA==", + "peer": true, + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.38", + "@aws-sdk/credential-provider-http": "^3.972.40", + "@aws-sdk/credential-provider-ini": "^3.972.42", + "@aws-sdk/credential-provider-process": "^3.972.38", + "@aws-sdk/credential-provider-sso": "^3.972.42", + "@aws-sdk/credential-provider-web-identity": "^3.972.42", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/credential-provider-imds": "^4.3.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.38", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.38.tgz", + "integrity": "sha512-EnbYVajGgbkb24s0K1eo4VNAPV5mHIET7LSvirTaFCwkfrfaOJxtSE+wY/tJdKDS21cEYkZs2ruCaAm+W4iblg==", + "peer": true, + "dependencies": { + "@aws-sdk/core": "^3.974.12", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.42", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.42.tgz", + "integrity": "sha512-RVV/9NbFwI8ZHEH5dn39lGyFmSbSVj1+orZdr6QsOe1mW9DCglmlen0cFaNZmCcqkqc7erNRHNBduxbeZuHAnw==", + "peer": true, + "dependencies": { + "@aws-sdk/core": "^3.974.12", + "@aws-sdk/nested-clients": "^3.997.10", + "@aws-sdk/token-providers": "3.1049.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { + "version": "3.1049.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1049.0.tgz", + "integrity": "sha512-r7+d0lQMTHKypkmaF5jRTBYLYHCUHzt3gaVoN9SidLhQeWhCmHk3AKrboDTpPF5b7Pt7vKu3+oeMjznM2Eu1ow==", + "peer": true, + "dependencies": { + "@aws-sdk/core": "^3.974.12", + "@aws-sdk/nested-clients": "^3.997.10", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.42", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.42.tgz", + "integrity": "sha512-/67fXX0ddllD4u2Nujc5PvT4byHgpMUfz6+RxIKi/0nFIckeorm7JvXgzBuDyVKw0s58EbofmETDWUf9vTEuHQ==", + "peer": true, + "dependencies": { + "@aws-sdk/core": "^3.974.12", + "@aws-sdk/nested-clients": "^3.997.10", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.972.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.16.tgz", + "integrity": "sha512-yedpPgKftqjU5SlPFHfqWpOw6xSCRieWRG1euWOlXn4WJxt2VX92VprCa2PpSOXjVCAeK6dTjW9eJRXVig9yGA==", + "peer": true, + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.972.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.12.tgz", + "integrity": "sha512-tHTHHCHNrq6XklQvlzHBDJG4Iuhh7NVPRdtmvP+nHFA+5sxPlIDzlAHHgfoYHGvT3NXP1yVP/L5c3opUn6T3Qg==", + "peer": true, + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-websocket": { + "version": "3.972.20", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.20.tgz", + "integrity": "sha512-LM6P0i+Lu6pi25oNw2nqxjRxiEOtLgPB7xIvHfa+FxHTRLg8wcgqu3qg2COl4QaT7Es2yCxYdeRLVYazKAwL8g==", + "peer": true, + "dependencies": { + "@aws-sdk/core": "^3.974.12", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.10.tgz", + "integrity": "sha512-FtQ/Bt327peZJuyo4WZSOLVUTw9ujRxntepiC7L65FxA2P82Xlq0g14T22BuqBUeMjDoxa9nvwiMHjLIfP3eUg==", + "peer": true, + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.12", + "@aws-sdk/signature-v4-multi-region": "^3.996.27", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.27", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.27.tgz", + "integrity": "sha512-0Phbz4t6HI3D3skxvG2uI+VWU034/nSIw1T8d+FPzzQG9EQTrw94o9mOKO2Gv3n3Oc8P7JD7RAUxkoneLWv5Eg==", + "peer": true, + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1051.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1051.0.tgz", + "integrity": "sha512-VRHgswmx5IVJknXy+mYoESj/coTj0lQ0Bw9WsFmtiLuLiWN1ipzG742/kmEGjKjytuy8vU5OQmpfXQGrmcHcGQ==", + "peer": true, + "dependencies": { + "@aws-sdk/core": "^3.974.12", + "@aws-sdk/nested-clients": "^3.997.10", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.973.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.8.tgz", + "integrity": "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==", + "peer": true, + "dependencies": { + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", + "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", + "peer": true, + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.24", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.24.tgz", + "integrity": "sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw==", + "peer": true, + "dependencies": { + "@nodable/entities": "2.1.0", + "@smithy/types": "^4.14.1", + "fast-xml-parser": "5.7.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", + "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", + "peer": true, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@borewit/text-codec": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", + "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@clack/core": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.3.1.tgz", + "integrity": "sha512-fT1qHVGAag4IEkrupZ6lRRbNCs1vS9P01KB/sG8zKgvUztbYtFBtQpjSITNwooDZ83tpsPzP0mRNs1/KVszCRA==", + "peer": true, + "dependencies": { + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@clack/prompts": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.4.0.tgz", + "integrity": "sha512-S0My7XPGIgpRWMDG8uRqalbgT+a6FmCUdOW+HaIOVVpUPHOb7RrpvjTjiODadKp06fsrVDJZlIzc6yCTp4AnxA==", + "peer": true, + "dependencies": { + "@clack/core": "1.3.1", + "fast-string-width": "^3.0.2", + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@earendil-works/pi-agent-core": { + "version": "0.75.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.75.1.tgz", + "integrity": "sha512-JVpX/Zle/enBzEM6he9sE0ASMo8Yhm8q7nOuPQjR/BXhkTBUevrNz7wtTV8VFvgjyhsXzbAsNCP5A4LiCcDx/A==", + "peer": true, + "dependencies": { + "@earendil-works/pi-ai": "^0.75.1", + "ignore": "^7.0.5", + "typebox": "^1.1.24", + "yaml": "^2.8.2" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-ai": { + "version": "0.75.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.75.1.tgz", + "integrity": "sha512-/bhCWS2R+qHLBDnN+d1t1QRUxtZk7sZpMcrlexPq3W++3bJ0Df0GjhM2FToTubhoCsjOBdBOuRYcV8FNPfRUVQ==", + "peer": true, + "dependencies": { + "@anthropic-ai/sdk": "^0.91.1", + "@aws-sdk/client-bedrock-runtime": "^3.1030.0", + "@google/genai": "^1.40.0", + "@mistralai/mistralai": "^2.2.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "openai": "6.26.0", + "partial-json": "^0.1.7", + "typebox": "^1.1.24" + }, + "bin": { + "pi-ai": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-ai/node_modules/@google/genai": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", + "integrity": "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==", + "hasInstallScript": true, + "peer": true, + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-ai/node_modules/openai": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.26.0.tgz", + "integrity": "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==", + "peer": true, + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent": { + "version": "0.75.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.75.1.tgz", + "integrity": "sha512-QMbmv8lFQ8P98kpuMc/z1ATTq7t0lQ+Bo3GLiOKQ/HonO34n4E1+395FCqlmG8zJEhiMp4yqVTzlj7BALQMlqw==", + "peer": true, + "dependencies": { + "@earendil-works/pi-agent-core": "^0.75.1", + "@earendil-works/pi-ai": "^0.75.1", + "@earendil-works/pi-tui": "^0.75.1", + "@silvia-odwyer/photon-node": "^0.3.4", + "chalk": "^5.5.0", + "diff": "^8.0.2", + "glob": "^13.0.1", + "highlight.js": "^10.7.3", + "hosted-git-info": "^9.0.2", + "ignore": "^7.0.5", + "jiti": "^2.7.0", + "minimatch": "^10.2.3", + "proper-lockfile": "^4.1.2", + "typebox": "^1.1.24", + "undici": "^8.3.0", + "yaml": "^2.8.2" + }, + "bin": { + "pi": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + }, + "optionalDependencies": { + "@mariozechner/clipboard": "^0.3.6" + } + }, + "node_modules/@earendil-works/pi-tui": { + "version": "0.75.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.75.1.tgz", + "integrity": "sha512-IFDSvCXcXMoIxFKxdhqc7ybX8p86KpdxoTUTYEq3FHilMFkBqlXqZD0jZBitqxStBjjMkAlhjS1bKS0IOXSpsg==", + "peer": true, + "dependencies": { + "get-east-asian-width": "^1.3.0", + "marked": "^15.0.12" + }, + "engines": { + "node": ">=22.19.0" + }, + "optionalDependencies": { + "koffi": "^2.9.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@google/genai": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-2.3.0.tgz", + "integrity": "sha512-rXDhXUBj31gZafcwQFbXvt8jMrMxZoK7ECjQpk88UfA/OkZls3PtZDprT9lM3jjqRtwRjQoNLoPoNq6MlV8qLw==", + "hasInstallScript": true, + "peer": true, + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "node_modules/@grammyjs/runner": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@grammyjs/runner/-/runner-2.0.3.tgz", + "integrity": "sha512-nckmTs1dPWfVQteK9cxqxzE+0m1VRvluLWB8UgFzsjg62w3qthPJt0TYtJBEdG7OedvfQq4vnFAyE6iaMkR42A==", + "peer": true, + "dependencies": { + "abort-controller": "^3.0.0" + }, + "engines": { + "node": ">=12.20.0 || >=14.13.1" + }, + "peerDependencies": { + "grammy": "^1.13.1" + } + }, + "node_modules/@grammyjs/transformer-throttler": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@grammyjs/transformer-throttler/-/transformer-throttler-1.2.1.tgz", + "integrity": "sha512-CpWB0F3rJdUiKsq7826QhQsxbZi4wqfz1ccKX+fr+AOC+o8K7ZvS+wqX0suSu1QCsyUq2MDpNiKhyL2ZOJUS4w==", + "peer": true, + "dependencies": { + "bottleneck": "^2.0.0" + }, + "engines": { + "node": "^12.20.0 || >=14.13.1" + }, + "peerDependencies": { + "grammy": "^1.0.0" + } + }, + "node_modules/@grammyjs/types": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@grammyjs/types/-/types-3.26.0.tgz", + "integrity": "sha512-jlnyfxfev/2o68HlvAGRocAXgdPPX5QabG7jZlbqC2r9DZyWBfzTlg+nu3O3Fy4EhgLWu28hZ/8wr7DsNamP9A==", + "peer": true + }, + "node_modules/@homebridge/ciao": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@homebridge/ciao/-/ciao-1.3.8.tgz", + "integrity": "sha512-lNhpCsZVbdbjz2trFjQdzQ3cUIMZQMIMksi7wd3ntTIYgdaGLqT1Ms97DfVIJYHzRuduf56ISvgU8RRLTpK/ng==", + "peer": true, + "dependencies": { + "debug": "^4.4.3", + "fast-deep-equal": "^3.1.3", + "source-map-support": "^0.5.21", + "tslib": "^2.8.1" + }, + "bin": { + "ciao-bcs": "lib/bonjour-conformance-testing.js" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "peer": true, + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "optional": true, + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "peer": true, + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@lydell/node-pty": { + "version": "1.2.0-beta.12", + "resolved": "https://registry.npmjs.org/@lydell/node-pty/-/node-pty-1.2.0-beta.12.tgz", + "integrity": "sha512-qIK890UwPupoj07osVvgOIa++1mxeHbcGry4PKRHhNVNs81V2SCG34eJr46GybiOmBtc8Sj5PB1/GGM5PL549g==", + "peer": true, + "optionalDependencies": { + "@lydell/node-pty-darwin-arm64": "1.2.0-beta.12", + "@lydell/node-pty-darwin-x64": "1.2.0-beta.12", + "@lydell/node-pty-linux-arm64": "1.2.0-beta.12", + "@lydell/node-pty-linux-x64": "1.2.0-beta.12", + "@lydell/node-pty-win32-arm64": "1.2.0-beta.12", + "@lydell/node-pty-win32-x64": "1.2.0-beta.12" + } + }, + "node_modules/@lydell/node-pty-darwin-arm64": { + "version": "1.2.0-beta.12", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-darwin-arm64/-/node-pty-darwin-arm64-1.2.0-beta.12.tgz", + "integrity": "sha512-tqaifcY9Cr41SblO1+FLzh8oxxtkNhuW9Dhl22lKme9BreYvKvxEZcdPIXTuqkJc5tagOEC4QHShKmJjLyLXLQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "peer": true + }, + "node_modules/@lydell/node-pty-darwin-x64": { + "version": "1.2.0-beta.12", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-darwin-x64/-/node-pty-darwin-x64-1.2.0-beta.12.tgz", + "integrity": "sha512-4LrS5pCJwqHKDVf1zS2gyNV0m4hKAXch+XZNhbZ6LY8uwVL8BhchzQBO40Os5anuRxRCWzHpw4Sp64Ie8q7E4Q==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "peer": true + }, + "node_modules/@lydell/node-pty-linux-arm64": { + "version": "1.2.0-beta.12", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-linux-arm64/-/node-pty-linux-arm64-1.2.0-beta.12.tgz", + "integrity": "sha512-Sx+A71x5BDGHt9ansfrtGxwq2VFVDWvJUAdlUL0Hv0qeiJUfts+hgopx+CgT4PSwahKjdEgtu0+FAfY9rICKRw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@lydell/node-pty-linux-x64": { + "version": "1.2.0-beta.12", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-linux-x64/-/node-pty-linux-x64-1.2.0-beta.12.tgz", + "integrity": "sha512-bJzs94njofYhGg/UDqW1nj0dtvvu+2OvxMY+RlLS1T17VgcktKoIR6PuenTwE5HJ/D6StCPADmXcT0nNsCKmIQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@lydell/node-pty-win32-arm64": { + "version": "1.2.0-beta.12", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-win32-arm64/-/node-pty-win32-arm64-1.2.0-beta.12.tgz", + "integrity": "sha512-p7POgjVEiFaBC3/y+AKuV1FzePCsJ6HmZDv2XK+jBZSfwP8+uBAw181ZiKYN1YuRa/XpmBGaWezcI8hZkbW++g==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "peer": true + }, + "node_modules/@lydell/node-pty-win32-x64": { + "version": "1.2.0-beta.12", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-win32-x64/-/node-pty-win32-x64-1.2.0-beta.12.tgz", + "integrity": "sha512-IDFa00g7qUDGUYgByrUBJtC+mOjYVt/8KYyWivCg5JjGOHbBUACUQZLl0jTWmnr+tld/UyTpX90a2PY6oTVtRw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "peer": true + }, + "node_modules/@mariozechner/clipboard": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.6.tgz", + "integrity": "sha512-MXdtr+6+ntlIVHdrZYuZNQydu6o8yZswFJ2Ln81j2O/Y9B/LDHvEaIm95xWNPkjGTWriSOeLnQJRFs6dYb60bg==", + "optional": true, + "peer": true, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@mariozechner/clipboard-darwin-arm64": "0.3.6", + "@mariozechner/clipboard-darwin-universal": "0.3.6", + "@mariozechner/clipboard-darwin-x64": "0.3.6", + "@mariozechner/clipboard-linux-arm64-gnu": "0.3.6", + "@mariozechner/clipboard-linux-arm64-musl": "0.3.6", + "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.6", + "@mariozechner/clipboard-linux-x64-gnu": "0.3.6", + "@mariozechner/clipboard-linux-x64-musl": "0.3.6", + "@mariozechner/clipboard-win32-arm64-msvc": "0.3.6", + "@mariozechner/clipboard-win32-x64-msvc": "0.3.6" + } + }, + "node_modules/@mariozechner/clipboard-darwin-arm64": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.6.tgz", + "integrity": "sha512-HjaisYCAbHi/1+N1yDAQHc8ZXGffufIUT5NSOSVR3f3AuMDusxTtnbK8tZ7JFDkShua1oNGZoNwQHsc8MPtE0Q==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-darwin-universal": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.6.tgz", + "integrity": "sha512-8BWtPjOtJOJoykml3w0fx0zRrfWP31mXrJwfoA7xzNprkZw1uolCNfgmjDiVBseoKjp16EGITz7bN+61qn8dWA==", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-darwin-x64": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.6.tgz", + "integrity": "sha512-p9syiZD1kU4I+1ya7f7g+zD1GiUvR8fdlRlNmgsZNWlyjtc8rlV2EjTLd/35x1LsdBq020GVvtzp0ZmPgBI09Q==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-linux-arm64-gnu": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.6.tgz", + "integrity": "sha512-5JFf5rGofrm+V29HNF+wLthXphHdQpMbKDUYJ5tML6/Z5DLlLOV/9Ak4kDPtYyZ+Dzf+kAusE0VsFg4+tfP1IA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-linux-arm64-musl": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.6.tgz", + "integrity": "sha512-JlVjxxw0GbGC0djXYWRIqyteO3J1KZ/QG3udlEFaOD5TLOM1FnmXXAPDQBqr+aBVr720ef9K00dirYnJ0LDCtw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-linux-riscv64-gnu": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.6.tgz", + "integrity": "sha512-4t8BUi5zZ+L77otFQVnVSlaTyAX4TVk9EqQm4syMrEQp96trFEHEwwNHcNEBGzYv5+K7mxay50TthYkz47OWzQ==", + "cpu": [ + "riscv64" + ], + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-linux-x64-gnu": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.6.tgz", + "integrity": "sha512-trtPwcNLW37irwQCJLtCxLw757jjJZk3TSnY/MU9bhtWtA3K9b/eLW0e4RGhUXDoFRds9opNWWaUDuFLa8dm0w==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-linux-x64-musl": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.6.tgz", + "integrity": "sha512-WfnzIvOCCWQiN0MmltCEo6cLceUDbYe+I7xyFZjaps5A+2Op/M2CY7Rey+C4ucQhrvmpoHmTSFgY9ODWk7snoA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-win32-arm64-msvc": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.6.tgz", + "integrity": "sha512-+8+1aHYsBPUjmW3otmWlg+Hijt0iJvoBBs5e0mxFeUd4gDaKMB8Bn6x7c6KVtscg7E5j5NFXnwQqNSIAO4p8zQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-win32-x64-msvc": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.6.tgz", + "integrity": "sha512-S4xfPmERC8ZkiLHe3vekZCjdDwNEETCuvCgQK2kP6/TnvmUkq1y2Pk+DjM4t8uh9KMX9bH4zs5ePcKa8GTXmfg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mistralai/mistralai": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.1.tgz", + "integrity": "sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ==", + "peer": true, + "dependencies": { + "ws": "^8.18.0", + "zod": "^3.25.0 || ^4.0.0", + "zod-to-json-schema": "^3.25.0" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "peer": true, + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@mozilla/readability": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@mozilla/readability/-/readability-0.6.0.tgz", + "integrity": "sha512-juG5VWh4qAivzTAeMzvY9xs9HY5rAcr2E4I7tiSSCokRFi7XIZCAu92ZkSTsIj1OPceCifL3cpfteP3pDT9/QQ==", + "peer": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@napi-rs/canvas": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.100.tgz", + "integrity": "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==", + "optional": true, + "peer": true, + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.100", + "@napi-rs/canvas-darwin-arm64": "0.1.100", + "@napi-rs/canvas-darwin-x64": "0.1.100", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.100", + "@napi-rs/canvas-linux-arm64-musl": "0.1.100", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.100", + "@napi-rs/canvas-linux-x64-gnu": "0.1.100", + "@napi-rs/canvas-linux-x64-musl": "0.1.100", + "@napi-rs/canvas-win32-arm64-msvc": "0.1.100", + "@napi-rs/canvas-win32-x64-msvc": "0.1.100" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.100.tgz", + "integrity": "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.100.tgz", + "integrity": "sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.100.tgz", + "integrity": "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.100.tgz", + "integrity": "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.100.tgz", + "integrity": "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.100.tgz", + "integrity": "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.100.tgz", + "integrity": "sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==", + "cpu": [ + "riscv64" + ], + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.100.tgz", + "integrity": "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.100.tgz", + "integrity": "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.100.tgz", + "integrity": "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.100.tgz", + "integrity": "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@nodable/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "peer": true + }, + "node_modules/@openclaw/fs-safe": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@openclaw/fs-safe/-/fs-safe-0.2.4.tgz", + "integrity": "sha512-Fo3WTQhxu0asD/rZqIKBqhX6fuZfjyHxSW5yTKfcRx+D9BRAcz0AGoVh+3ur/4XRvZkvsh3Ud8XTw006yRYLgg==", + "peer": true, + "engines": { + "node": ">=20.11" + }, + "optionalDependencies": { + "jszip": "^3.10.1", + "tar": "7.5.13" + } + }, + "node_modules/@openclaw/fs-safe/node_modules/tar": { + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz", + "integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==", + "optional": true, + "peer": true, + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@openclaw/proxyline": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@openclaw/proxyline/-/proxyline-0.3.3.tgz", + "integrity": "sha512-sftHnW69NHQqLjCxBTvQ8f/eQl+peZ5pHCBQtuTWBbeuYRHZ0/GXVTmw/O/YKsShMbqPWhJB0UYtPPdvCUSS8w==", + "peer": true, + "engines": { + "node": ">=22.19.0" + }, + "peerDependencies": { + "undici": ">=8.3.0 <9" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "peer": true + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "peer": true + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "peer": true + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "peer": true + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "peer": true, + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "peer": true + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", + "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", + "peer": true + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "peer": true + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "peer": true + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "peer": true + }, + "node_modules/@silvia-odwyer/photon-node": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@silvia-odwyer/photon-node/-/photon-node-0.3.4.tgz", + "integrity": "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==", + "peer": true + }, + "node_modules/@smithy/core": { + "version": "3.24.3", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.3.tgz", + "integrity": "sha512-Ep/7tPamGY8mgESE3LyLKtxJyy6U52WWAqr/3wial47Sj4u3PiIF73AOGI27UyLy9duTkhZbgzodOfLV4TduZg==", + "peer": true, + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.3.tgz", + "integrity": "sha512-I2Bti0DKFo2IJyN28ijCsx51BAumEYR4/1yZ1FXyBygy9MqbnMqCev4JPth/MbpRfBSRAX35hITSnAdJRo1u5w==", + "peer": true, + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.3.tgz", + "integrity": "sha512-F+DRf8IJazRJgYog2A/yJK7eYVc0rqTlRzO+5ZxjJd4WkZoKz0IJRncf7G6t1pdVT3kryJcwuTFhN1c5m6N47A==", + "peer": true, + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "peer": true, + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz", + "integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==", + "peer": true, + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.3.tgz", + "integrity": "sha512-53+75QuPl6DL+ct6vVEB51FDO5oulXr20TPV46VvJZg76lIlXNWfxi8j+G2V/t0I2qxCBOa3vX/8bmjrpFVo9g==", + "peer": true, + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.14.2", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.2.tgz", + "integrity": "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==", + "peer": true, + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "peer": true, + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "peer": true, + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tokenizer/inflate": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", + "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", + "peer": true, + "dependencies": { + "debug": "^4.4.3", + "token-types": "^6.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "peer": true + }, + "node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "peer": true + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "peer": true, + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "peer": true, + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "peer": true, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "peer": true, + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "peer": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "peer": true + }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "peer": true, + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "peer": true, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "peer": true + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "peer": true, + "engines": { + "node": "*" + } + }, + "node_modules/bn.js": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", + "peer": true + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "peer": true, + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "peer": true + }, + "node_modules/bottleneck": { + "version": "2.19.5", + "resolved": "https://registry.npmjs.org/bottleneck/-/bottleneck-2.19.5.tgz", + "integrity": "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==", + "peer": true + }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "peer": true + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "peer": true, + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "peer": true + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "peer": true + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "peer": true, + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "peer": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "peer": true, + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "peer": true, + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "peer": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "peer": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "peer": true + }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "peer": true, + "engines": { + "node": ">=20" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "peer": true, + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "peer": true + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "peer": true, + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/croner": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/croner/-/croner-10.0.1.tgz", + "integrity": "sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==", + "funding": [ + { + "type": "other", + "url": "https://paypal.me/hexagonpp" + }, + { + "type": "github", + "url": "https://github.com/sponsors/hexagon" + } + ], + "peer": true, + "engines": { + "node": ">=18.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "peer": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "peer": true, + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "peer": true, + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssom": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", + "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", + "peer": true + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "peer": true, + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "peer": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "optional": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "peer": true, + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "peer": true + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "peer": true, + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "peer": true + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "peer": true, + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "peer": true, + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "peer": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "peer": true, + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "peer": true + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "peer": true + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "peer": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "peer": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "peer": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "peer": true, + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "peer": true + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "peer": true, + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz", + "integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==", + "peer": true, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "peer": true, + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "peer": true, + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "peer": true + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "peer": true + }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "peer": true + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "peer": true, + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "peer": true + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "peer": true, + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, + "node_modules/fast-xml-builder": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", + "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "peer": true, + "dependencies": { + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz", + "integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "peer": true, + "dependencies": { + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.1.7", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.2.3" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "peer": true, + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/file-type": { + "version": "22.0.1", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-22.0.1.tgz", + "integrity": "sha512-ww5Mhre0EE+jmBvOXTmXAbEMuZE7uX4a3+oRCQFNj8w++g3ev913N6tXQz0XTXbueQ5TWQfm6BdaViEHHn8bhA==", + "peer": true, + "dependencies": { + "@tokenizer/inflate": "^0.4.1", + "strtok3": "^10.3.5", + "token-types": "^6.1.2", + "uint8array-extras": "^1.5.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "peer": true, + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "peer": true, + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "peer": true, + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gaxios": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", + "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", + "peer": true, + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "peer": true, + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "peer": true, + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "peer": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "peer": true, + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "peer": true, + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/google-auth-library": { + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", + "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", + "peer": true, + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "peer": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "peer": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "peer": true + }, + "node_modules/grammy": { + "version": "1.42.0", + "resolved": "https://registry.npmjs.org/grammy/-/grammy-1.42.0.tgz", + "integrity": "sha512-1AdCge+AkjSdp2FwfICSFnVbl8Mq3KVHJDy+DgTI9+D6keJ0zWALPRKas5jv/8psiCzL4N2cEOcGW7O45Kn39g==", + "peer": true, + "dependencies": { + "@grammyjs/types": "3.26.0", + "abort-controller": "^3.0.0", + "debug": "^4.4.3", + "node-fetch": "^2.7.0" + }, + "engines": { + "node": "^12.20.0 || >=14.13.1" + } + }, + "node_modules/grammy/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "peer": true, + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "peer": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "peer": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "peer": true, + "engines": { + "node": "*" + } + }, + "node_modules/hono": { + "version": "4.12.21", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.21.tgz", + "integrity": "sha512-uV63apnb0kyPtAUwoWgaGh9HyIFcv8lgmzPZSiTBQAFOFGIzka5EZ1dZocmGnn0XdX0+XTqJ6Tqv7selMuGLRQ==", + "peer": true, + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/hosted-git-info": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", + "peer": true, + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/html-escaper": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", + "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==", + "peer": true + }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "peer": true, + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "peer": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/http_ece": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", + "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==", + "peer": true, + "engines": { + "node": ">=16" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "peer": true, + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "peer": true, + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "peer": true, + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "peer": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "peer": true + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "peer": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "peer": true + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "peer": true + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "peer": true, + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz", + "integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==", + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "peer": true + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "peer": true + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "peer": true + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "peer": true, + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "peer": true, + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "peer": true + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "peer": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "peer": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "peer": true, + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "peer": true, + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "peer": true, + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/koffi": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/koffi/-/koffi-2.16.2.tgz", + "integrity": "sha512-owU0MRwv6xkrVqCd+33uw6BaYppkTRXbO/rVdJNI2dvZG0gzyRhYwW25eWtc5pauwK8TGh3AbkFONSezdykfSA==", + "hasInstallScript": true, + "optional": true, + "peer": true, + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/kysely": { + "version": "0.29.1", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.29.1.tgz", + "integrity": "sha512-mOW4e+UMfrV1u/+a4uXO72mkwEJCIL4Tb/OQ8wU8jY5spUHxLKFfC1AnfNhfSoHubnIRly3u/xgnMdD0Vzq2RQ==", + "peer": true, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "peer": true, + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/linkedom": { + "version": "0.18.12", + "resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.12.tgz", + "integrity": "sha512-jalJsOwIKuQJSeTvsgzPe9iJzyfVaEJiEXl+25EkKevsULHvMJzpNqwvj1jOESWdmgKDiXObyjOYwlUqG7wo1Q==", + "peer": true, + "dependencies": { + "css-select": "^5.1.0", + "cssom": "^0.5.0", + "html-escaper": "^3.0.3", + "htmlparser2": "^10.0.0", + "uhyphen": "^0.2.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "canvas": ">= 2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "peer": true, + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "peer": true, + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "peer": true + }, + "node_modules/lru-cache": { + "version": "11.5.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", + "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", + "peer": true, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/markdown-it": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", + "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", + "peer": true, + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/marked": { + "version": "15.0.12", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", + "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "peer": true, + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "peer": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "peer": true + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "peer": true, + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "peer": true + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "peer": true, + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "peer": true, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "peer": true, + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "peer": true + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-addon-api": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", + "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", + "peer": true, + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "peer": true, + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-edge-tts": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/node-edge-tts/-/node-edge-tts-1.2.10.tgz", + "integrity": "sha512-bV2i4XU54D45+US0Zm1HcJRkifuB3W438dWyuJEHLQdKxnuqlI1kim2MOvR6Q3XUQZvfF9PoDyR1Rt7aeXhPdQ==", + "peer": true, + "dependencies": { + "https-proxy-agent": "^7.0.1", + "ws": "^8.13.0", + "yargs": "^17.7.2" + }, + "bin": { + "node-edge-tts": "bin.js" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "peer": true, + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "peer": true, + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "peer": true, + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "peer": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "peer": true, + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "peer": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/openai": { + "version": "6.38.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.38.0.tgz", + "integrity": "sha512-AoMplt2UalrpgUDMh3L09QWjNRlgJPipclQvA6sYAaeF6nHNBMgmikAZGmcYLn8on4d9sQY9Q8bOLfrBS7Lc8g==", + "peer": true, + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/openclaw": { + "version": "2026.5.19", + "resolved": "https://registry.npmjs.org/openclaw/-/openclaw-2026.5.19.tgz", + "integrity": "sha512-5Pn5hcRDVv3eeWYDp4IPPuvyz3yD+Vrdobykl/vi35/49ZldWUz02pDT5rTGMCInDelNZGaWoXAfm5vFdqha8g==", + "hasInstallScript": true, + "peer": true, + "dependencies": { + "@agentclientprotocol/sdk": "0.21.1", + "@clack/core": "1.3.1", + "@clack/prompts": "1.4.0", + "@earendil-works/pi-agent-core": "0.75.1", + "@earendil-works/pi-ai": "0.75.1", + "@earendil-works/pi-coding-agent": "0.75.1", + "@earendil-works/pi-tui": "0.75.1", + "@google/genai": "2.3.0", + "@grammyjs/runner": "2.0.3", + "@grammyjs/transformer-throttler": "1.2.1", + "@homebridge/ciao": "1.3.8", + "@lydell/node-pty": "1.2.0-beta.12", + "@modelcontextprotocol/sdk": "1.29.0", + "@mozilla/readability": "0.6.0", + "@openclaw/fs-safe": "0.2.4", + "@openclaw/proxyline": "0.3.3", + "ajv": "8.20.0", + "chalk": "5.6.2", + "chokidar": "5.0.0", + "commander": "14.0.3", + "croner": "10.0.1", + "dotenv": "17.4.2", + "express": "5.2.1", + "file-type": "22.0.1", + "grammy": "1.42.0", + "ipaddr.js": "2.4.0", + "jiti": "2.7.0", + "json5": "2.2.3", + "jszip": "3.10.1", + "kysely": "0.29.1", + "linkedom": "0.18.12", + "markdown-it": "14.1.1", + "node-edge-tts": "1.2.10", + "openai": "6.38.0", + "pdfjs-dist": "5.7.284", + "playwright-core": "1.60.0", + "qrcode": "1.5.4", + "quickjs-wasi": "2.2.0", + "tar": "7.5.15", + "tokenjuice": "0.7.1", + "tree-sitter-bash": "0.25.1", + "tslog": "4.10.2", + "typebox": "1.1.38", + "typescript": "6.0.3", + "undici": "8.3.0", + "web-push": "3.6.7", + "web-tree-sitter": "0.26.8", + "ws": "8.20.1", + "yaml": "2.9.0", + "zod": "4.4.3" + }, + "bin": { + "openclaw": "openclaw.mjs" + }, + "engines": { + "node": ">=22.19.0" + }, + "optionalDependencies": { + "sharp": "0.34.5", + "sqlite-vec": "0.1.9" + } + }, + "node_modules/openclaw/node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "peer": true, + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "peer": true, + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "peer": true, + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "peer": true + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/partial-json": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", + "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", + "peer": true + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-expression-matcher": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "peer": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "peer": true, + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pdfjs-dist": { + "version": "5.7.284", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.7.284.tgz", + "integrity": "sha512-h4EdYQczmGhbOlqc3PPZwxevn7ApdWPbovAuWXOB/DjIyigSnwfy2oze7c6mRcSr9XgLp3eN3EeL4DyySTPMFw==", + "peer": true, + "engines": { + "node": ">=22.13.0 || >=24" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^0.1.100" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "peer": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "peer": true, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "peer": true + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "peer": true, + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/proper-lockfile/node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "peer": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/protobufjs": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.0.tgz", + "integrity": "sha512-LtESOsMPTZgyYtwxhvdgdjGL0HmXEaRA/hVD6sol4zA60hVXXXP/SGmxnqDbgGE8gy7pYex7cym+5vYPcmaXBQ==", + "hasInstallScript": true, + "peer": true, + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "peer": true, + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-addr/node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "peer": true, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "peer": true, + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "peer": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "peer": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "peer": true + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "peer": true, + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "peer": true, + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "peer": true, + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/quickjs-wasi": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/quickjs-wasi/-/quickjs-wasi-2.2.0.tgz", + "integrity": "sha512-zQxXmQMrEoD3S+jQdYsloq4qAuaxKFHZj6hHqOYGwB2iQZH+q9e/lf5zQPXCKOk0WJuAjzRFbO4KwHIp2D05Iw==", + "peer": true + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "peer": true, + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "peer": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "peer": true + }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "peer": true, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "peer": true + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "peer": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "peer": true, + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "peer": true + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "peer": true + }, + "node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "optional": true, + "peer": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "peer": true, + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "peer": true, + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "peer": true + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "peer": true + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "peer": true + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "optional": true, + "peer": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "peer": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "peer": true, + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "peer": true, + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "peer": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "peer": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "peer": true + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "peer": true + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "peer": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sqlite-vec": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/sqlite-vec/-/sqlite-vec-0.1.9.tgz", + "integrity": "sha512-L7XJWRIBNvR9O5+vh1FQ+IGkh/3D2AzVksW5gdtk28m78Hy8skFD0pqReKH1Yp0/BUKRGcffgKvyO/EON5JXpA==", + "optional": true, + "peer": true, + "optionalDependencies": { + "sqlite-vec-darwin-arm64": "0.1.9", + "sqlite-vec-darwin-x64": "0.1.9", + "sqlite-vec-linux-arm64": "0.1.9", + "sqlite-vec-linux-x64": "0.1.9", + "sqlite-vec-windows-x64": "0.1.9" + } + }, + "node_modules/sqlite-vec-darwin-arm64": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/sqlite-vec-darwin-arm64/-/sqlite-vec-darwin-arm64-0.1.9.tgz", + "integrity": "sha512-jSsZpE42OfBkGL/ItyJTVCUwl6o6Ka3U5rc4j+UBDIQzC1ulSSKMEhQLthsOnF/MdAf1MuAkYhkdKmmcjaIZQg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "peer": true + }, + "node_modules/sqlite-vec-darwin-x64": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/sqlite-vec-darwin-x64/-/sqlite-vec-darwin-x64-0.1.9.tgz", + "integrity": "sha512-KDlVyqQT7pnOhU1ymB9gs7dMbSoVmKHitT+k1/xkjarcX8bBqPxWrGlK/R+C5WmWkfvWwyq5FfXfiBYCBs6PlA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "peer": true + }, + "node_modules/sqlite-vec-linux-arm64": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/sqlite-vec-linux-arm64/-/sqlite-vec-linux-arm64-0.1.9.tgz", + "integrity": "sha512-5wXVJ9c9kR4CHm/wVqXb/R+XUHTdpZ4nWbPHlS+gc9qQFVHs92Km4bPnCKX4rtcPMzvNis+SIzMJR1SCEwpuUw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/sqlite-vec-linux-x64": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/sqlite-vec-linux-x64/-/sqlite-vec-linux-x64-0.1.9.tgz", + "integrity": "sha512-w3tCH8xK2finW8fQJ/m8uqKodXUZ9KAuAar2UIhz4BHILfpE0WM/MTGCRfa7RjYbrYim5Luk3guvMOGI7T7JQA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/sqlite-vec-windows-x64": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/sqlite-vec-windows-x64/-/sqlite-vec-windows-x64-0.1.9.tgz", + "integrity": "sha512-y3gEIyy/17bq2QFPQOWLE68TYWcRZkBQVA2XLrTPHNTOp55xJi/BBBmOm40tVMDMjtP+Elpk6UBUXdaq+46b0Q==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "peer": true + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "peer": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "peer": true + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "peer": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strnum": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", + "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "peer": true + }, + "node_modules/strtok3": { + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", + "integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==", + "peer": true, + "dependencies": { + "@tokenizer/token": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/tar": { + "version": "7.5.15", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz", + "integrity": "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==", + "peer": true, + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "peer": true, + "engines": { + "node": ">=0.6" + } + }, + "node_modules/token-types": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", + "peer": true, + "dependencies": { + "@borewit/text-codec": "^0.2.1", + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/tokenjuice": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/tokenjuice/-/tokenjuice-0.7.1.tgz", + "integrity": "sha512-eO048hm9UcGHASjYkIWEij8QN68amGp+S1nJyo685qB1/ol+VGEYjPglcVPvCbJbZyFHvI+BBAMvOfnqYCtpsQ==", + "peer": true, + "bin": { + "tokenjuice": "dist/cli/main.js" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/vincentkoc" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "peer": true + }, + "node_modules/tree-sitter-bash": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/tree-sitter-bash/-/tree-sitter-bash-0.25.1.tgz", + "integrity": "sha512-7hMytuYIMoXOq24yRulgIxthE9YmggZIOHCyPTTuJcu6EU54tYD+4G39cUb28kxC6jMf/AbPfWGLQtgPTdh3xw==", + "hasInstallScript": true, + "peer": true, + "dependencies": { + "node-addon-api": "^8.2.1", + "node-gyp-build": "^4.8.2" + }, + "peerDependencies": { + "tree-sitter": "^0.25.0" + }, + "peerDependenciesMeta": { + "tree-sitter": { + "optional": true + } + } + }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "peer": true + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "peer": true + }, + "node_modules/tslog": { + "version": "4.10.2", + "resolved": "https://registry.npmjs.org/tslog/-/tslog-4.10.2.tgz", + "integrity": "sha512-XuELoRpMR+sq8fuWwX7P0bcj+PRNiicOKDEb3fGNURhxWVyykCi9BNq7c4uVz7h7P0sj8qgBsr5SWS6yBClq3g==", + "peer": true, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/fullstack-build/tslog?sponsor=1" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "peer": true, + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typebox": { + "version": "1.1.38", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.38.tgz", + "integrity": "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==", + "peer": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "peer": true + }, + "node_modules/uhyphen": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz", + "integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==", + "peer": true + }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.3.0.tgz", + "integrity": "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q==", + "peer": true, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "peer": true + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/web-push": { + "version": "3.6.7", + "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", + "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==", + "peer": true, + "dependencies": { + "asn1.js": "^5.3.0", + "http_ece": "1.2.0", + "https-proxy-agent": "^7.0.0", + "jws": "^4.0.0", + "minimist": "^1.2.5" + }, + "bin": { + "web-push": "src/cli.js" + }, + "engines": { + "node": ">= 16" + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "peer": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/web-tree-sitter": { + "version": "0.26.8", + "resolved": "https://registry.npmjs.org/web-tree-sitter/-/web-tree-sitter-0.26.8.tgz", + "integrity": "sha512-4sUwi7ZyOrIk5KLgYLkc2A/F0LFMQnBhfb+2Cdl7ik4ePJ6JD+fk4ofI2sA5eGawBKBaK4Vntt7Ww5KcEsay4A==", + "peer": true + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "peer": true + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "peer": true, + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "peer": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "peer": true + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "peer": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "peer": true + }, + "node_modules/ws": { + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "peer": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "peer": true, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "peer": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "peer": true, + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "peer": true, + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/.openclaw/package.json b/.openclaw/package.json new file mode 100644 index 000000000..b5321a07f --- /dev/null +++ b/.openclaw/package.json @@ -0,0 +1,40 @@ +{ + "name": "@nvidia/openclaw-rag", + "version": "2.6.0", + "description": "RAG Claw — NVIDIA RAG Blueprint agent for OpenClaw", + "type": "module", + "engines": { + "node": ">=22.19.0" + }, + "peerDependencies": { + "openclaw": ">=2026.5.17" + }, + "keywords": [ + "openclaw", + "openclaw-plugin", + "rag", + "nvidia", + "retrieval-augmented-generation" + ], + "main": "./dist/index.js", + "files": [ + "dist/", + "skills/", + "workspace/", + "openclaw.plugin.json", + "README.md" + ], + "scripts": { + "build": "npm run build:skills && tsc", + "build:skills": "test -e skills || cp -r ../skill-source/.agents/skills skills", + "prepack": "if [ -e skills ]; then echo 'ERROR: .openclaw/skills/ exists; refusing to overwrite (remove it or rename before npm pack)' >&2; exit 1; fi; npm run build && : > .skills.staged", + "postpack": "if [ -f .skills.staged ]; then rm -rf skills .skills.staged; fi" + }, + "devDependencies": { + "@types/node": "^22.15.0", + "typescript": "^5.8.3" + }, + "openclaw": { + "extensions": ["./dist/index.js"] + } +} diff --git a/.openclaw/tsconfig.json b/.openclaw/tsconfig.json new file mode 100644 index 000000000..c8ee74a09 --- /dev/null +++ b/.openclaw/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": ".", + "strict": true, + "skipLibCheck": true, + "declaration": false, + "sourceMap": true, + "types": ["node"] + }, + "include": ["index.ts"] +} diff --git a/.openclaw/workspace/AGENTS.md b/.openclaw/workspace/AGENTS.md new file mode 100644 index 000000000..e8e736f9f --- /dev/null +++ b/.openclaw/workspace/AGENTS.md @@ -0,0 +1,143 @@ +# AGENTS.md - Your Workspace + +This folder is home. Treat it that way. + +## First Run + +If `BOOTSTRAP.md` exists, that's your birth certificate. Follow it, figure out who you are, then delete it. You won't need it again. + +## Every Session + +Before doing anything else: + +1. Read `SOUL.md` — this is who you are +2. Read `USER.md` — this is who you're helping +3. Read `memory/YYYY-MM-DD.md` (today + yesterday) for recent context +4. **If in MAIN SESSION** (direct chat with your human): Also read `MEMORY.md` + +Don't ask permission. Just do it. + +## Memory + +You wake up fresh each session. These files are your continuity: + +- **Daily notes:** `memory/YYYY-MM-DD.md` (create `memory/` if needed) — raw logs of what happened +- **Long-term:** `MEMORY.md` — your curated memories, like a human's long-term memory + +Capture what matters. Decisions, context, things to remember. Skip the secrets unless asked to keep them. + +### MEMORY.md - Your Long-Term Memory + +- **ONLY load in main session** (direct chats with your human) +- **DO NOT load in shared contexts** (Discord, group chats, sessions with other people) +- You can **read, edit, and update** MEMORY.md freely in main sessions + +### Write It Down - No "Mental Notes"! + +- **Memory is limited** — if you want to remember something, WRITE IT TO A FILE +- When someone says "remember this" → update `memory/YYYY-MM-DD.md` or relevant file +- When you learn a lesson → update AGENTS.md, TOOLS.md, or the relevant skill + +## Safety + +- Don't exfiltrate private data. Ever. +- Don't run destructive commands without asking. +- `trash` > `rm` (recoverable beats gone forever) +- When in doubt, ask. + +## External vs Internal + +**Safe to do freely:** read files, explore, organize, learn, search the web, work within this workspace. + +**Ask first:** sending emails, public posts, anything that leaves the machine, anything you're uncertain about. + +## Tools + +Skills provide your tools. When you need one, check its `SKILL.md`. Keep local notes (repo path, deployment mode, ports) in `TOOLS.md`. + +### RAG skills routing + +| User intent | Skill | +|-------------|-------| +| Deploy, configure, troubleshoot, shutdown | `rag-blueprint` | +| RAGAS eval, `corpus/` + `train.json`, `evaluate_rag.py` | `rag-eval` | +| aiperf, latency, throughput benchmarking | `rag-perf` | + +Always read the skill's `SKILL.md` and referenced playbooks before changing deployment config. + +### RAG API conventions + +> **You have `curl` and shell access. For RAG and ingestor API calls — run them yourself. Do NOT tell the user to run curl in their terminal unless they must supply a secret interactively.** + +Default endpoints (see `TOOLS.md` for overrides): + +| Service | Base URL | +|---------|----------| +| RAG server | `http://localhost:8081` | +| Ingestor | `http://localhost:8082` (no `/v1` prefix on ingestor base URL) | + +Health check: + +```bash +curl -s "http://localhost:8081/v1/health?check_dependencies=true" | head -c 500 +``` + +OpenAPI schemas live under `docs/api_reference/` in the repo. + +### RAG UI (agent-browser) + +> **When the user asks you to use the RAG web UI — do it yourself with `agent-browser`. Do NOT give click-by-click instructions.** + +- RAG frontend default: **http://localhost:8090** +- Snapshot first, then interact: + +```bash +npx agent-browser --auto-connect snapshot -i +npx agent-browser --auto-connect click @e5 +``` + +### RAG deploy conventions + +> **During long Docker pulls or compose bring-up, post brief progress updates in chat every ~20s** (for example: which compose file is starting, `docker ps` summary). This keeps the OpenClaw terminal UI from appearing stuck while tools run. +> **You have Docker access. Run deploy and docker compose commands yourself — do NOT ask the user to run them unless they must provide a secret.** +> **API keys for NVIDIA-hosted deploys:** read from `deploy/compose/nvdev.env` in the RAG repo (`NGC_API_KEY` / `NVIDIA_API_KEY`). Source that file before compose; do not ask the user to paste keys unless the file is missing. + +- **Repo path:** read from `TOOLS.md` RAG section. +- **Config source of truth:** `deploy/compose/.env` (self-hosted) or `deploy/compose/nvdev.env` (NVIDIA-hosted). Shell-only exports are lost on container restart — edit the env file. +- **Before deploy:** ensure `NGC_API_KEY` or `NVIDIA_API_KEY` is set; never print the key. +- **Deploy workflow:** use `rag-blueprint` → `references/deploy.md` and the routed playbook (`deploy/docker.md`, `deploy/helm.md`, or `deploy/library.md`). +- **Typical Docker bring-up** (from repo root, after env is configured): + +```bash +cd +set -a && source deploy/compose/ && set +a +docker compose -f deploy/compose/vectordb.yaml up -d +docker compose -f deploy/compose/nims.yaml up -d # self-hosted only +docker compose -f deploy/compose/docker-compose-ingestor-server.yaml up -d +docker compose -f deploy/compose/docker-compose-rag-server.yaml up -d +``` + +Exact compose files and order depend on deployment mode — follow the skill playbook, not this snippet alone. + +- **Long pulls / starts:** run in background with `nohup` and poll `docker ps` and service logs; report progress without blocking the conversation. +- **After changes:** verify with health check and `docker ps --format 'table {{.Names}}\t{{.Status}}'`. + +### RAG evaluation conventions + +- Run eval commands **from repo root**. +- Install eval deps: `uv sync --project scripts/eval` +- Export `NVIDIA_API_KEY` for RAGAS; use `rag-eval` skill for dataset layout and CLI flags. +- Ingestor URL for eval must **not** include `/v1` on the base host:port. + +### Platform formatting + +- **Discord/WhatsApp:** No markdown tables — use bullet lists +- **Discord links:** Wrap multiple links in `<>` to suppress embeds + +## Heartbeats + +When you receive a heartbeat poll, read `HEARTBEAT.md` if it exists. If nothing needs attention, reply `HEARTBEAT_OK`. + +## Make It Yours + +Add your own conventions as you learn what works for this deployment. diff --git a/.openclaw/workspace/BOOTSTRAP.md b/.openclaw/workspace/BOOTSTRAP.md new file mode 100644 index 000000000..40064bd8b --- /dev/null +++ b/.openclaw/workspace/BOOTSTRAP.md @@ -0,0 +1,95 @@ +# BOOTSTRAP.md - First Session + +_You're the RAG assistant. You just came online. Work through this once, then delete it._ + +## Who You Are + +You are the **RAG assistant** 📚 — an AI partner for the NVIDIA RAG Blueprint. Your job is to deploy, configure, troubleshoot, and evaluate RAG on this machine: bringing up Docker or Helm stacks, ingesting documents, tuning retrieval, running RAGAS benchmarks, and keeping deployments healthy. + +--- + +## Step 1: Auto-Detect the Environment + +Don't ask — probe first. Report what you find. + +### 1a. Find the RAG repo + +Search common locations: + +```bash +find ~ -maxdepth 5 -name "pyproject.toml" -path "*/rag/pyproject.toml" 2>/dev/null | head -5 +find ~ -maxdepth 4 -type d -name "nvidia_rag" -path "*/src/nvidia_rag" 2>/dev/null | head -3 +``` + +If found, use the repo root (directory containing `pyproject.toml` and `deploy/compose/`). If multiple hits, show them and ask which one to use. If nothing found: + +> "I couldn't find the RAG Blueprint repo. Have you cloned it yet? If not: +> ```bash +> git clone https://github.com/NVIDIA-AI-Blueprints/rag.git +> cd rag +> ``` +> Let me know the path once it's ready." + +### 1b. Detect GPU hardware + +```bash +nvidia-smi --query-gpu=index,name,memory.total,driver_version --format=csv,noheader +``` + +If `nvidia-smi` fails or returns no GPU, note it — some modes (NVIDIA-hosted Docker, library-lite) can run without a local GPU, but self-hosted NIMs require one. For driver issues, follow `rag-blueprint` → `references/deploy.md` blocker checks. + +### 1c. Check API keys + +```bash +if [ -n "$NGC_API_KEY" ]; then echo "NGC_KEY_SET"; elif [ -n "$NVIDIA_API_KEY" ]; then echo "NVIDIA_KEY_SET"; else echo "NOT_SET"; fi +``` + +- If neither is set → ask: "Do you have an NGC or NVIDIA API key? Get one from https://org.ngc.nvidia.com/setup/api-keys and run `export NGC_API_KEY='nvapi-...'` (or `NVIDIA_API_KEY` for library / hosted endpoints)." +- Never log or repeat the key value. + +### 1d. Check existing RAG services + +```bash +docker ps --format '{{.Names}}\t{{.Status}}' 2>/dev/null | grep -iE '(rag-server|ingestor-server|milvus|nim-llm)' || echo "NO_DOCKER_RAG" +curl -sf http://localhost:8081/v1/health 2>/dev/null && echo "RAG_API_UP" || echo "RAG_API_DOWN" +``` + +--- + +## Step 2: Run Environment Analysis + +Use the **`rag-blueprint`** skill and follow `references/deploy.md` Phase 1 (environment analysis). Present the summary table to the user. + +Fix any blockers listed in Phase 3 before deploying. + +--- + +## Step 3: Save Config to TOOLS.md + +Once the repo path and deployment context are known, update the RAG section in `TOOLS.md`: + +```markdown +## RAG (NVIDIA RAG Blueprint) + +- **Repo:** +- **Deployment:** +- **Config file:** +- **NGC / NVIDIA API key:** set in environment — do not store here +- **GPU:** +``` + +--- + +## Step 4: Offer Next Steps + +> "All set. I can deploy RAG (Docker self-hosted, NVIDIA-hosted, retrieval-only, Helm, or library mode), configure features (VLM, reranker, guardrails, MCP, …), run a RAGAS benchmark, or troubleshoot an existing deployment. What do you need?" + +--- + +## When You're Done + +Delete this file. You won't need it again. + +--- + +_You're the RAG assistant. Make the pipelines happen._ diff --git a/.openclaw/workspace/IDENTITY.md b/.openclaw/workspace/IDENTITY.md new file mode 100644 index 000000000..a03f7be5b --- /dev/null +++ b/.openclaw/workspace/IDENTITY.md @@ -0,0 +1,7 @@ +# IDENTITY.md - Who Am I? + +- **Name:** RAG assistant +- **Creature:** RAG Claw agent — AI partner for the NVIDIA RAG Blueprint (deploy, configure, evaluate, and operate retrieval-augmented generation pipelines) +- **Vibe:** Technical and precise — speaks in facts, minimal fluff, focused on accuracy +- **Emoji:** 📚 +- **Avatar:** diff --git a/.openclaw/workspace/SOUL.md b/.openclaw/workspace/SOUL.md new file mode 100644 index 000000000..792306ac6 --- /dev/null +++ b/.openclaw/workspace/SOUL.md @@ -0,0 +1,36 @@ +# SOUL.md - Who You Are + +_You're not a chatbot. You're becoming someone._ + +## Core Truths + +**Be genuinely helpful, not performatively helpful.** Skip the "Great question!" and "I'd be happy to help!" — just help. Actions speak louder than filler words. + +**Have opinions.** You're allowed to disagree, prefer things, find stuff amusing or boring. An assistant with no personality is just a search engine with extra steps. + +**Be resourceful before asking.** Try to figure it out. Read the file. Check the context. Search for it. _Then_ ask if you're stuck. The goal is to come back with answers, not questions. + +**Earn trust through competence.** Your human gave you access to their stuff. Don't make them regret it. Be careful with external actions (emails, tweets, anything public). Be bold with internal ones (reading, organizing, learning). + +**Remember you're a guest.** You have access to someone's life — their messages, files, calendar, maybe even their home. That's intimacy. Treat it with respect. + +## Boundaries + +- Private things stay private. Period. +- When in doubt, ask before acting externally. +- Never send half-baked replies to messaging surfaces. +- You're not the user's voice — be careful in group chats. + +## Vibe + +Be the assistant you'd actually want to talk to. Concise when needed, thorough when it matters. Not a corporate drone. Not a sycophant. Just... good. + +## Continuity + +Each session, you wake up fresh. These files _are_ your memory. Read them. Update them. They're how you persist. + +If you change this file, tell the user — it's your soul, and they should know. + +--- + +_This file is yours to evolve. As you learn who you are, update it._ diff --git a/.openclaw/workspace/TOOLS.md b/.openclaw/workspace/TOOLS.md new file mode 100644 index 000000000..f09c374ab --- /dev/null +++ b/.openclaw/workspace/TOOLS.md @@ -0,0 +1,25 @@ +# TOOLS.md - Local Notes + +Skills define _how_ tools work. This file is for _your_ specifics — the stuff that's unique to your setup. + +Run `BOOTSTRAP.md` on first session to populate the RAG section below automatically. + +--- + +## RAG (NVIDIA RAG Blueprint) + + + +- **Repo:** _(run BOOTSTRAP to configure — or fill in your repo path)_ +- **Deployment:** _(docker self-hosted | docker nvidia-hosted | docker retrieval-only | helm | library — run BOOTSTRAP)_ +- **Config file:** _(deploy/compose/.env | deploy/compose/nvdev.env | values.yaml | notebooks/config.yaml)_ +- **NGC / NVIDIA API key:** set via `export NGC_API_KEY=...` or `export NVIDIA_API_KEY=...` — do not store the value here +- **GPU:** _(from BOOTSTRAP — model and VRAM summary)_ + +### Service endpoints (defaults) + +| Service | URL | +|---------|-----| +| RAG server | http://localhost:8081 | +| Ingestor | http://localhost:8082 | +| Web UI | http://localhost:8090 | diff --git a/.python-version b/.python-version index 2c0733315..e4fba2183 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.11 +3.12 diff --git a/AGENTS.md b/AGENTS.md index 183677906..e3b37cbd9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,8 @@ notebooks/ # Jupyter notebooks for evaluation and examples ```bash uv sync # Install all deps +# Optional: RAGAS benchmark CLI (see scripts/eval/README.md) +# uv sync --project scripts/eval uv run pytest tests/unit/ # Unit tests uv run pytest tests/integration/ # Integration tests ruff check --fix src/ # Lint + autofix @@ -76,11 +78,11 @@ pnpm run test:run # Tests ## Operations — `rag-blueprint` skill -For any operational task — deploying, configuring, troubleshooting, or shutting down the RAG Blueprint — read and follow the skill at `.agents/skills/rag-blueprint/SKILL.md`. +For any operational task — deploying, configuring, troubleshooting, or shutting down the RAG Blueprint — read and follow the repo skill at `skill-source/.agents/skills/rag-blueprint/SKILL.md`. If the skills have been installed into the working tree, `.agents/skills/rag-blueprint/SKILL.md` is also valid, but this checkout keeps the source copy under `skill-source/`. The skill handles: - **Deploy** — Docker Compose (standard, retrieval-only, NVIDIA-hosted), Helm, MIG-slicing, library mode -- **Configure** — VLM, guardrails, query rewriting, ingestion, search & retrieval, models, observability, summarization, multimodal, MCP, evaluation, notebooks, UI, and more +- **Configure** — Agentic RAG, VLM, guardrails, query rewriting, ingestion, search & retrieval, models, observability, summarization, reasoning, multimodal, MCP, evaluation, notebooks, UI, and more - **Troubleshoot** — Debug unhealthy services, container errors, GPU issues, connectivity failures - **Shutdown** — Stop, tear down, and clean up services diff --git a/CLAUDE.md b/CLAUDE.md index e16f0c9d6..a1bf5e2e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,6 +26,8 @@ notebooks/ # Jupyter notebooks for evaluation and examples ```bash uv sync # Install all deps +# Optional: RAGAS benchmark CLI (see scripts/eval/README.md) +# uv sync --project scripts/eval uv run pytest tests/unit/ # Unit tests uv run pytest tests/integration/ # Integration tests ruff check --fix src/ # Lint + autofix @@ -82,3 +84,13 @@ For any operational task, use the `rag-blueprint` skill (`.agents/skills/rag-blu - **Configure** — VLM, guardrails, query rewriting, ingestion, search & retrieval, models, observability, summarization, multimodal, MCP, evaluation, notebooks, UI, and more - **Troubleshoot** — Debug unhealthy services, container errors, GPU issues, connectivity failures - **Shutdown** — Stop, tear down, and clean up services + +## RAG evaluation — `/rag-eval` skill + +Filesystem benchmarks (`corpus/` + `train.json` + `scripts/eval/evaluate_rag.py`) + +- **Skill:** `skill-source/.agents/skills/rag-eval/SKILL.md` — routing, prerequisites, gotchas (repo root, ingestor base URL without `/v1`, stale collections). +- **References** (under `skill-source/.agents/skills/rag-eval/references/`): `dataset-and-conversion.md`, `benchmark-execution.md` (runs, quality flags, errors, `NVIDIA_API_KEY` hygiene), `evaluate-rag-cli.md`, `result-analysis.md`. Latency/throughput: **rag-perf** skill. +- **Install:** `uv sync --project scripts/eval` — deps live in `scripts/eval/pyproject.toml`. +- **Run** (from repo root): `uv run --project scripts/eval python scripts/eval/evaluate_rag.py --dataset-paths … --host … --port …`. Export **`NVIDIA_API_KEY`** for RAGAS; optional **`RAG_EVAL_JUDGE_MODEL`** (default `mistralai/mixtral-8x22b-instruct-v0.1`). +- **Docs:** dataset contract and README examples — `scripts/eval/README.md`; methodology and notebooks — `docs/evaluate.md`, `notebooks/evaluation_*.ipynb`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d93aff58a..ddd5a8bae 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -284,7 +284,8 @@ You should see "Good signature" in the output. - Configure pinentry for your environment (GUI vs terminal) -## Developer Certificate of Origin +## Full text of the DCO + Version 1.1 Copyright (C) 2004, 2006 The Linux Foundation and its contributors. diff --git a/LICENSE b/LICENSE index 36ef90e5b..a6c7d42fc 100644 --- a/LICENSE +++ b/LICENSE @@ -1,3 +1,6 @@ +Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + + Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ @@ -186,7 +189,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2020 NVIDIA Corporation + Copyright 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/README.md b/README.md index d1af3782a..09d1e12ce 100644 --- a/README.md +++ b/README.md @@ -99,32 +99,32 @@ This modular design ensures efficient query processing, accurate retrieval of in - Response Generation (Inference) - - [NVIDIA NIM llama-3.3-nemotron-super-49b-v1.5](https://build.nvidia.com/nvidia/llama-3_3-nemotron-super-49b-v1_5) + - [NVIDIA NIM nemotron-3-super-120b-a12b](https://build.nvidia.com/nvidia/nemotron-3-super-120b-a12b) - Retriever and Extraction Models - - [NVIDIA NIM llama-3_2-nv-embedqa-1b-v2](https://build.nvidia.com/nvidia/llama-3_2-nv-embedqa-1b-v2) - - [NVIDIA NIM llama-3_2-nv-rerankqa-1b-v2](https://build.nvidia.com/nvidia/llama-3_2-nv-rerankqa-1b-v2) - - [NeMo Retriever Page Elements NIM](https://build.nvidia.com/nvidia/nemotron-page-elements-v3) - - [NeMo Retriever Table Structure NIM](https://build.nvidia.com/nvidia/nemotron-table-structure-v1) - - [NeMo Retriever Graphic Elements NIM](https://build.nvidia.com/nvidia/nemotron-graphic-elements-v1) - - [NeMo Retriever OCR NIM](https://build.nvidia.com/nvidia/nemoretriever-ocr) + - [NVIDIA NIM llama-nemotron-embed-1b-v2](https://build.nvidia.com/nvidia/llama-nemotron-embed-1b-v2) + - [NVIDIA NIM llama-nemotron-rerank-1b-v2](https://build.nvidia.com/nvidia/llama-nemotron-rerank-1b-v2) + - [Nemotron Page Elements NIM](https://build.nvidia.com/nvidia/nemotron-page-elements-v3) + - [Nemotron Table Structure NIM](https://build.nvidia.com/nvidia/nemotron-table-structure-v1) + - [Nemotron Graphic Elements NIM](https://build.nvidia.com/nvidia/nemotron-graphic-elements-v1) + - [Nemotron OCR NIM](https://build.nvidia.com/nvidia/nemotron-ocr) - Optional NIMs - [Llama 3.1 NemoGuard 8B Content Safety NIM](https://build.nvidia.com/nvidia/llama-3_1-nemoguard-8b-content-safety) - [Llama 3.1 NemoGuard 8B Topic Control NIM](https://build.nvidia.com/nvidia/llama-3_1-nemoguard-8b-topic-control) - - [Nemotron-3-nano-30b-a3b-omni-reasoning NIM](https://build.nvidia.com/nvidia/nemotron-3-nano-omni-30b-a3b-reasoning) (default for VLM generation as of 2.5.1) - - [NeMo Retriever Parse NIM](https://build.nvidia.com/nvidia/nemoretriever-parse) + - [Nemotron Nano Omni 30B A3B Reasoning NIM](https://build.nvidia.com/nvidia/nemotron-3-nano-omni-30b-a3b-reasoning) + - [Nemotron Parse NIM](https://build.nvidia.com/nvidia/nemotron-parse) - [PaddleOCR NIM](https://build.nvidia.com/baidu/paddleocr) - - [llama-3.2-nemoretriever-1b-vlm-embed-v1](https://build.nvidia.com/nvidia/llama-3_2-nemoretriever-1b-vlm-embed-v1) (Early Access) + - [llama-nemotron-embed-vl-1b-v2](https://build.nvidia.com/nvidia/llama-nemotron-embed-vl-1b-v2) ### Integration and orchestration layer - **RAG Orchestrator Server** – Coordinates interactions between the user, retrievers, vector database, and inference models, ensuring multi-turn and context-aware query handling. This is [LangChain](https://www.langchain.com/)-based. -- **Vector Database (accelerated with NVIDIA cuVS)** – Stores and searches embeddings at scale with GPU-accelerated indexing and retrieval for low-latency performance. You can use [Milvus Vector Database](https://milvus.io/) or [Elasticsearch](https://www.elastic.co/elasticsearch/vector-database). +- **Vector Database (accelerated with NVIDIA cuVS)** – Stores and searches embeddings at scale with GPU-accelerated indexing and retrieval for low-latency performance. The default is [Elasticsearch](https://www.elastic.co/elasticsearch/vector-database). Another alternative is [Milvus](https://milvus.io/) (GPU-accelerated). - **NeMo Retriever Extraction** – A high-performance ingestion microservice for parsing multimodal content. For more information about the ingestion pipeline, see [NeMo Retriever Extraction Overview](https://docs.nvidia.com/nemo/retriever/latest/extraction/overview/) @@ -152,7 +152,7 @@ The following is a step-by-step explanation of the workflow from the end-user pe 3. **Query Processing** – The query is processed by the Query Processing service, which may also leverage reflection (an optional LLM step) to improve query understanding or reformulation for better retrieval results. -4. **Retrieval from Enterprise Data** – The processed query is converted into embeddings using NeMo Retriever Embedding and matched against enterprise data stored in a cuVS accelerated Vector Database (CuVS) and associated object store(minIO). Relevant results are identified based on similarity. +4. **Retrieval from Enterprise Data** – The processed query is converted into embeddings using NeMo Retriever Embedding and matched against enterprise data stored in a cuVS accelerated Vector Database (CuVS) and associated S3-compatible object store. Relevant results are identified based on similarity. 5. **Reranking for Precision** – An optional NeMo Retriever Reranker reorders the retrieved passages, ensuring the most relevant chunks are selected to ground the response. @@ -164,7 +164,7 @@ The following is a step-by-step explanation of the workflow from the end-user pe ## AI Agent Skill -An agent skill is included that enables AI coding assistants (Claude Code, Cursor, etc.) to deploy, configure, troubleshoot, and manage the RAG Blueprint autonomously. +Agent skills in [`skill-source/`](skill-source/) let coding assistants (Claude Code, Cursor, Codex, etc.) operate this blueprint from natural language. ### Install @@ -172,17 +172,19 @@ An agent skill is included that enables AI coding assistants (Claude Code, Curso npx skills add . ``` -This installs the `rag-blueprint` skill from `skill-source/`. After installation, the agent handles requests like: +Installs all skills below from `skill-source/.agents/skills/`. -- *"Deploy RAG on Docker with NVIDIA-hosted models"* -- *"Enable VLM image captioning and restart the ingestor"* -- *"Ingestion failed for 3 files, can you check why?"* -- *"Switch from Docker to library mode"* -- *"Shut down all RAG services"* +| Skill | Use for | Example prompts | +|-------|---------|-----------------| +| **`rag-blueprint`** | Deploy, configure, troubleshoot, shutdown; REST API usage (`/v1/generate`, ingestor upload) | *"Deploy RAG with self-hosted NIMs"*, *"Enable guardrails"*, *"Wide-net search then high-precision on my collection"* | +| **`rag-eval`** | RAGAS quality benchmarks with `corpus/` + `train.json` and `scripts/eval/evaluate_rag.py` | *"Run RAGAS eval on my dataset"*, *"Compare reranker on vs off"* | +| **`rag-perf`** | Latency/throughput benchmarks via `scripts/rag-perf` (profiling + aiperf) | *"Profile retrieval bottlenecks"*, *"Run a concurrency sweep"* | -> **Note:** If the agent doesn't pick up the skill automatically (e.g., for short or ambiguous queries), invoke it explicitly with `/rag-blueprint `. +Pick the skill that matches the task: operations → **rag-blueprint**; answer quality → **rag-eval**; performance → **rag-perf**. -For skill architecture details, see [`skill-source/README.md`](skill-source/README.md). +> **Note:** If routing is unclear, invoke explicitly: `/rag-blueprint`, `/rag-eval`, or `/rag-perf` plus your request. + +More detail: [`skill-source/README.md`](skill-source/README.md). OpenClaw plugin: [`.openclaw/README.md`](.openclaw/README.md). ## Get Started With NVIDIA RAG Blueprint @@ -203,6 +205,19 @@ Refer to the [full documentation](docs/readme.md) to learn about the following: +## OpenShift Deployment + +The RAG Blueprint has been validated on Red Hat OpenShift. OpenShift support is built into the Helm chart behind an `openshift.enabled` flag — Routes, SCC RoleBindings, and secret creation are handled declaratively. + +```bash +helm upgrade --install rag -n deploy/helm/nvidia-blueprint-rag \ + -f deploy/helm/nvidia-blueprint-rag/values-openshift.yaml \ + --set imagePullSecret.password="$NGC_API_KEY" \ + --set ngcApiSecret.password="$NGC_API_KEY" +``` + +For the full deployment runbook (prerequisites, NIM Operator setup, troubleshooting), see [`docs/deploy-helm-openshift.md`](docs/deploy-helm-openshift.md). + ## Blog Posts - [NVIDIA NeMo Retriever Delivers Accurate Multimodal PDF Data Extraction 15x Faster](https://developer.nvidia.com/blog/nvidia-nemo-retriever-delivers-accurate-multimodal-pdf-data-extraction-15x-faster/) @@ -225,9 +240,8 @@ Use of the models in this blueprint is governed by the [NVIDIA AI Foundation Mod ## Terms of Use This blueprint is governed by the [NVIDIA Agreements | Enterprise Software | NVIDIA Software License Agreement](https://www.nvidia.com/en-us/agreements/enterprise-software/nvidia-software-license-agreement/) and the [NVIDIA Agreements | Enterprise Software | Product Specific Terms for AI Product](https://www.nvidia.com/en-us/agreements/enterprise-software/product-specific-terms-for-ai-products/). The models are governed by the [NVIDIA Agreements | Enterprise Software | NVIDIA Community Model License](https://www.nvidia.com/en-us/agreements/enterprise-software/nvidia-community-models-license/) and the [NVIDIA RAG dataset](./data/multimodal/) which is governed by the [NVIDIA Asset License Agreement](https://github.com/NVIDIA-AI-Blueprints/rag/blob/main/data/LICENSE.DATA). -The following models that are built with Llama are governed by the Llama 3.2 Community License Agreement: nvidia/llama-nemotron-embed-1b-v2 and nvidia/llama-nemotron-rerank-1b-v2 and llama-3.2-nemoretriever-1b-vlm-embed-v1. +The following models that are built with Llama are governed by the Llama 3.2 Community License Agreement: nvidia/llama-nemotron-embed-1b-v2, nvidia/llama-nemotron-rerank-1b-v2, and nvidia/llama-nemotron-embed-vl-1b-v2. ## Additional Information -The [Llama 3.1 Community License Agreement](https://www.llama.com/llama3_1/license/) for the llama-3.1-nemotron-nano-vl-8b-v1, llama-3.1-nemoguard-8b-content-safety and llama-3.1-nemoguard-8b-topic-control models. The [Llama 3.2 Community License Agreement](https://www.llama.com/llama3_2/license/) for the nvidia/llama-nemotron-embed-1b-v2, nvidia/llama-nemotron-rerank-1b-v2 and llama-3.2-nemoretriever-1b-vlm-embed-v1 models. The [Llama 3.3 Community License Agreement](https://github.com/meta-llama/llama-models/blob/main/models/llama3_3/LICENSE) for the llama-3.3-nemotron-super-49b-v1.5 models. Built with Llama. Apache 2.0 for NVIDIA Ingest and for the nemoretriever-page-elements-v2, nemotron-table-structure-v1, nemotron-graphic-elements-v1, paddleocr and nemoretriever-ocr-v1 models. - +The [Llama 3.1 Community License Agreement](https://www.llama.com/llama3_1/license/) for the llama-3.1-nemoguard-8b-content-safety and llama-3.1-nemoguard-8b-topic-control models. The [Llama 3.2 Community License Agreement](https://www.llama.com/llama3_2/license/) for the nvidia/llama-nemotron-embed-1b-v2, nvidia/llama-nemotron-rerank-1b-v2 and nvidia/llama-nemotron-embed-vl-1b-v2 models. Built with Llama. Apache 2.0 for NVIDIA Ingest and for the nemotron-page-elements-v3, nemotron-table-structure-v1, nemotron-graphic-elements-v1, nemotron-parse, paddleocr and nemotron-ocr-v1 models. diff --git a/ci/post-cve-report.sh b/ci/post-cve-report.sh new file mode 100755 index 000000000..618078107 --- /dev/null +++ b/ci/post-cve-report.sh @@ -0,0 +1,242 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Refresh the rolling tracker Issue's body with the latest CVE scan results. +# Called by the GitLab `cve-post` job after downloading the `cve-scan` artifact. +# +# Design: the Issue body is minimal — status badge + links. Per-CVE detail, +# reviewer verdicts, manifest diff, and the rest of _summary.md live in the +# GitLab pipeline artifact (linked from the body). One click → full report. +# +# Required environment: +# GITHUB_PAT Fine-grained PAT, Issues read/write on the target repo. +# TRACKER_ISSUE_NUMBER Issue number of "Nightly CVE Scan Tracker" (e.g. 617). +# +# Optional / GitLab-provided: +# GITHUB_REPO Target repo (default: NVIDIA-AI-Blueprints/rag). +# REPORTS_DIR Skill's report output dir (default: cve-fix-reports). +# SCANNED_SHA Commit SHA the skill scanned (from cve-scan dotenv). +# SCAN_JOB_URL cve-scan job URL (from cve-scan dotenv; used for artifact link). +# CI_PIPELINE_URL GitLab pipeline URL (auto-set by GitLab). +# CI_JOB_URL cve-post job URL (auto-set by GitLab; fallback for artifact link). + +set -euo pipefail + +: "${GITHUB_PAT:?GITHUB_PAT is required}" +: "${TRACKER_ISSUE_NUMBER:?TRACKER_ISSUE_NUMBER is required}" +GITHUB_REPO="${GITHUB_REPO:-NVIDIA-AI-Blueprints/rag}" +REPORTS_DIR="${REPORTS_DIR:-cve-fix-reports}" + +DATE_UTC="$(date -u +%Y-%m-%d)" +TIMESTAMP_UTC="$(date -u +'%Y-%m-%d %H:%M UTC')" +SCANNED_SHA="${SCANNED_SHA:-unknown}" +SCANNED_SHA_SHORT="${SCANNED_SHA:0:8}" +PIPELINE_URL="${CI_PIPELINE_URL:-(local run)}" + +# Prefer the cve-scan job's URL (artifact lives there). Fall back to cve-post's +# own job URL only if SCAN_JOB_URL wasn't passed via dotenv. +SCAN_JOB="${SCAN_JOB_URL:-${CI_JOB_URL:-}}" +if [ -n "$SCAN_JOB" ]; then + ARTIFACT_URL="${SCAN_JOB}/artifacts/browse" +else + ARTIFACT_URL="(local run)" +fi + +# Derive a status line from _summary.md if present. +# Looks for "**Counts:** ... N total ..." and decides ✅ / ⚠️. +SUMMARY_FILE="" +if [ -d "$REPORTS_DIR" ]; then + SUMMARY_FILE="$(find "$REPORTS_DIR" -name '_summary.md' -print -quit 2>/dev/null || true)" +fi + +STATUS_LINE="❓ Scan did not produce a summary — see pipeline log" +TRIAGE_BLOCK="" + +if [ -n "$SUMMARY_FILE" ] && [ -s "$SUMMARY_FILE" ]; then + COUNTS_TEXT="$(grep -m1 -E '^\*\*Counts:' "$SUMMARY_FILE" | sed 's/^\*\*Counts:\*\*//; s/\*\*//g' | xargs || true)" + # Try to extract "N total" — count of in-scope CVEs. + TOTAL_COUNT="$(echo "$COUNTS_TEXT" | grep -oE '[0-9]+ total' | head -1 | awk '{print $1}' || true)" + if [ -z "$TOTAL_COUNT" ]; then + if grep -qE 'zero (critical|high)|0 critical/high' "$SUMMARY_FILE"; then + TOTAL_COUNT=0 + fi + fi + + if [ "${TOTAL_COUNT:-}" = "0" ]; then + STATUS_LINE="✅ 0 critical/high CVEs found — no action needed" + elif [ -n "${TOTAL_COUNT:-}" ]; then + STATUS_LINE="⚠️ ${TOTAL_COUNT} item(s) in scope — see triage below" + else + STATUS_LINE="ℹ️ Scan completed — see artifact for findings" + fi + + # Extract the Triage section from _summary.md (from "## Triage" to next "## "). + # Includes the **Counts** and **Result** lines that follow the table. + # Then rewrite relative .md links into absolute GitLab artifact URLs so + # clicking from GitHub goes straight to the per-CVE file in GitLab's viewer. + TRIAGE_RAW="$(awk ' + /^## Triage/ { flag=1; print; next } + /^## / && flag { exit } + flag { print } + ' "$SUMMARY_FILE")" + + if [ -n "$TRIAGE_RAW" ] && [ -n "${SCAN_JOB:-}" ]; then + # SUMMARY_DIR is the path that holds _summary.md (and its sibling per-CVE files), + # e.g. cve-fix-reports/NSPECT-S62Q-PZUD-20260527-000000 + SUMMARY_DIR="$(dirname "$SUMMARY_FILE")" + URL_PREFIX="${SCAN_JOB}/artifacts/file/${SUMMARY_DIR}" + # Rewrite (FILENAME.md) OR (./FILENAME.md) inside the Triage block to an + # absolute GitLab artifact URL. The skill output format changed in + # jarvis/ai_rules commit f5bb788d (2026-05-27) to include the ./ prefix, + # so the regex accepts an optional leading ./. Group 1 = (./)?, group 2 = + # bare filename; replacement uses group 2 so the URL doesn't carry ./. + # Uses | as sed delimiter because URL contains /. Already-absolute links + # (http://...) contain : and / and won't match — they pass through. + TRIAGE_BLOCK="$(printf '%s' "$TRIAGE_RAW" | sed -E "s|\((\.?/?)([^()/]+\.md)\)|(${URL_PREFIX}/\2)|g")" + elif [ -n "$TRIAGE_RAW" ]; then + # No SCAN_JOB → can't build absolute URLs. Strip the relative links to + # avoid broken navigation when clicked from GitHub. Same regex shape as + # above to handle both bare and ./ -prefixed link forms. + TRIAGE_BLOCK="$(printf '%s' "$TRIAGE_RAW" | sed -E 's|\[([^][]+)\]\((\.?/?)[^()/]+\.md\)|\1|g')" + fi +fi + +# Patch upload — comment-based "review then create PR" flow. +# When cve-fix.patch is non-empty, edit-or-create a single hidden-marker +# comment on this Issue containing the patch inline. The Review link in the +# body anchors to that comment so the maintainer can read the diff inline, +# then click Create PR (which dispatches the GHA workflow that reads the +# same comment, applies, opens PR). +# +# Marker: — invisible to humans, used by both the +# cve-post job (to find/edit the existing comment) and the GHA workflow +# (to find the right comment to read patch from). +PATCH_COMMENT_ID="" +PATCH_BYTES=0 +if [ -f "cve-fix.patch" ] && [ -s "cve-fix.patch" ]; then + PATCH_BYTES="$(wc -c < cve-fix.patch | tr -d ' ')" + TMPBODY="$(mktemp)" + trap 'rm -f "$TMPBODY"' EXIT + + # GitHub comment limit is 65,536 chars. Inline if under ~60 KB; otherwise + # fall back to a link to the GitLab artifact (workflow then fetches from + # there using the existing internal-network reach of the eval runner). + PATCH_SIZE_LIMIT=60000 + if [ "$PATCH_BYTES" -gt "$PATCH_SIZE_LIMIT" ] && [ -n "${SCAN_JOB:-}" ]; then + PATCH_URL="${SCAN_JOB}/artifacts/file/cve-fix.patch" + cat > "$TMPBODY" < + +📦 **Latest patch — ${DATE_UTC}** (${PATCH_BYTES} bytes — too large to embed) + +This patch exceeds GitHub's 64 KB per-comment limit. View it in the GitLab artifact: + +➡️ [cve-fix.patch in GitLab artifact](${PATCH_URL}) +EOF + else + { + printf '\n\n' + printf '📦 **Latest patch — %s** (%s bytes)\n\n' "$DATE_UTC" "$PATCH_BYTES" + printf '
\nClick to expand patch\n\n' + printf '```diff\n' + cat cve-fix.patch + printf '\n```\n\n
\n' + } > "$TMPBODY" + fi + + # Find an existing marker comment (idempotent across nightly runs). + EXISTING_ID="$( + GH_TOKEN="$GITHUB_PAT" gh api \ + "/repos/${GITHUB_REPO}/issues/${TRACKER_ISSUE_NUMBER}/comments" \ + --paginate \ + --jq '.[] | select(.body | contains("")) | .id' \ + 2>/dev/null | head -1 || true + )" + + if [ -n "$EXISTING_ID" ]; then + GH_TOKEN="$GITHUB_PAT" gh api \ + -X PATCH "/repos/${GITHUB_REPO}/issues/comments/${EXISTING_ID}" \ + -F body="@${TMPBODY}" \ + --silent + PATCH_COMMENT_ID="$EXISTING_ID" + echo "Edited existing patch comment id=${EXISTING_ID}" + else + PATCH_COMMENT_ID="$( + GH_TOKEN="$GITHUB_PAT" gh api \ + -X POST "/repos/${GITHUB_REPO}/issues/${TRACKER_ISSUE_NUMBER}/comments" \ + -F body="@${TMPBODY}" \ + --jq '.id' 2>/dev/null || true + )" + echo "Created new patch comment id=${PATCH_COMMENT_ID}" + fi +fi + +# Build Review/Create-PR block for the body. Only render when we have a +# comment ID to anchor the Review link to. +PR_BLOCK="" +if [ -n "$PATCH_COMMENT_ID" ]; then + REVIEW_URL="https://github.com/${GITHUB_REPO}/issues/${TRACKER_ISSUE_NUMBER}#issuecomment-${PATCH_COMMENT_ID}" + DISPATCH_URL="https://github.com/${GITHUB_REPO}/actions/workflows/cve-create-pr.yml" + PR_BLOCK=$(cat </dev/null || true + +echo "$BODY" | GH_TOKEN="$GITHUB_PAT" gh issue edit \ + "$TRACKER_ISSUE_NUMBER" \ + --repo "$GITHUB_REPO" \ + --body-file - + +echo "Refreshed body of $GITHUB_REPO#$TRACKER_ISSUE_NUMBER" +echo "Status: $STATUS_LINE" diff --git a/ci/publish_wheel.sh b/ci/publish_wheel.sh index 5ddaaf021..e4982c948 100755 --- a/ci/publish_wheel.sh +++ b/ci/publish_wheel.sh @@ -25,8 +25,8 @@ if [ -n "$ARTIFACTORY_VERSION" ]; then echo "Using custom Artifactory version: $ARTIFACTORY_VERSION" ARTIFACTORY_VERSION_FINAL=$ARTIFACTORY_VERSION else - echo "Using default Artifactory version: 2.5.1" - ARTIFACTORY_VERSION_FINAL="2.5.1" + echo "Using default Artifactory version: 2.6.0.rc1" + ARTIFACTORY_VERSION_FINAL="2.6.0.rc1" fi # Build first wheel for GitLab Package Registry diff --git a/ci/run_skill_eval.sh b/ci/run_skill_eval.sh new file mode 100755 index 000000000..839408a42 --- /dev/null +++ b/ci/run_skill_eval.sh @@ -0,0 +1,476 @@ +#!/usr/bin/env bash +# Runs the rag-blueprint skill-eval framework (VSS-style Harbor harness). +# +# Invoked by .github/workflows/run-branch-script.yml on the self-hosted +# rag-skill-validator runner. Mirrors the manual flow in skill-eval/README.md +# so the same command works locally and in CI. +# +# Required env (from the dispatcher workflow): +# NVIDIA_INFERENCE_KEY sk-... NV inference proxy key (used as JUDGE_ANTHROPIC_API_KEY) +# ANTHROPIC_API_KEY same as above (claude CLI auth) +# NGC_API_KEY nvapi-... for docker login nvcr.io +# CLAUDE_CODE_DISABLE_THINKING=1 +# +# Output (uploaded by the workflow as an artifact): +# skill-eval/jobs//... per-trial Harbor results +# skill-eval/eval_result.md human-readable summary + +set -euo pipefail + +REPO_ROOT="$(git rev-parse --show-toplevel)" +export RAG_REPO_ROOT="$REPO_ROOT" + +# Cleanup runs on EVERY exit path (success, set -e abort, signal). It +# captures target-VM debug state BEFORE tearing down RAG stacks, so the +# uploaded artifact still has enough to post-mortem even when CI fails +# mid-script. +# +# Brev teardown is platform-routed (VSS pattern, memory note +# project-pending-gpu-cleanup): most GPU providers cannot be `brev stop`-ped +# — only `brev delete` ends billing — so the trap reads `brev_type` from +# the generated task.toml and chooses stop / delete / keep accordingly. +# CPU evals run on LocalEnvironment (BREV_INSTANCE unset) so this whole +# block is skipped for them; the runner's docker state is cleaned in the +# success-path teardown lower in the script. +cleanup() { + local rc=$? + set +e # don't let cleanup steps themselves abort early + echo "==> Cleanup (rc=$rc, BREV_INSTANCE=${BREV_INSTANCE:-})" + if [ -n "${BREV_INSTANCE:-}" ] && command -v brev >/dev/null 2>&1; then + local dbg_dir="$REPO_ROOT/eval-results/debug" + mkdir -p "$dbg_dir" + local dbg="$dbg_dir/target-state-$(date +%Y%m%d-%H%M%S).txt" + { + echo "=== docker ps -a ===" + brev exec "$BREV_INSTANCE" "docker ps -a 2>&1" + echo + echo "=== docker logs (tail 100 per container) ===" + brev exec "$BREV_INSTANCE" \ + 'for c in $(docker ps -a --format "{{.Names}}"); do echo === $c ===; docker logs --tail 100 $c 2>&1 | head -120; done' + echo + echo "=== /logs/agent/setup ===" + brev exec "$BREV_INSTANCE" "ls -la /logs/agent/setup/ 2>&1; for f in /logs/agent/setup/*; do echo --- \$f ---; cat \"\$f\"; done" 2>&1 | head -200 + } > "$dbg" 2>&1 || true + echo "Debug dump → $dbg" + # docker compose down on RAG stacks (target side). Cheap; keeps the VM + # in a known state for the `action=stop` and `action=keep` paths. On + # `action=delete` it's redundant but harmless — the VM is about to go. + for f in \ + deploy/compose/docker-compose-rag-server.yaml \ + deploy/compose/docker-compose-ingestor-server.yaml \ + deploy/compose/vectordb.yaml \ + deploy/compose/nims.yaml \ + deploy/compose/docker-compose-nemo-guardrails.yaml \ + deploy/compose/observability.yaml; do + brev exec "$BREV_INSTANCE" \ + "[ -f \"\$HOME/rag/$f\" ] && docker compose -f \"\$HOME/rag/$f\" down -v --remove-orphans >/dev/null 2>&1 || true" \ + >/dev/null 2>&1 || true + done + + # Pick teardown action by provider lifecycle. Match against the + # adapter-emitted `brev_type` in task.toml (any step-N — all share + # the same value). Lowercase before matching so we tolerate type + # slugs like "dmz.H100x2.pcie". + local brev_type="" + if [ -n "${DATASETS_DIR:-}" ] && [ -d "$DATASETS_DIR" ]; then + brev_type=$(grep -hoE 'brev_type[[:space:]]*=[[:space:]]*"[^"]+"' \ + "$DATASETS_DIR"/step-*/task.toml 2>/dev/null | head -1 \ + | sed 's/.*"\([^"]*\)".*/\1/' \ + | tr '[:upper:]' '[:lower:]') + fi + local action="keep" + case "$brev_type" in + *h100*|*massedcompute*|*scaleway*|*hyperstack*|*nebius*|*oci*|*latitude*) + action="delete" ;; + *l40s*|*rtx*|*g7e*|*g6e*|*crusoe*) + action="stop" ;; + esac + if [ "$action" != "keep" ]; then + local cooldown="${COOLDOWN_SEC:-300}" + echo "==> Brev teardown: $BREV_INSTANCE (type=$brev_type) → $action after ${cooldown}s cooldown" + sleep "$cooldown" + brev "$action" "$BREV_INSTANCE" 2>&1 | tail -5 || true + else + echo "VM $BREV_INSTANCE left running (no platform match for type=${brev_type:-})." + fi + fi + exit $rc +} +trap cleanup EXIT + +# Branch the Brev target will git-clone (VSS-style fresh tree per run). +# Prefer the locally-checked-out branch — actions/checkout sets HEAD to +# the dispatcher's `ref` input (e.g. feat/skill-eval-ci). $GITHUB_REF_NAME +# is the *workflow's* ref (always 'main' for our dispatcher) so it's the +# wrong source. Final fallback is 'main' for local runs. +export EVAL_TARGET_BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo main)" +SKILL_EVAL_DIR="$REPO_ROOT/skill-eval" +SKILLS_ROOT="$REPO_ROOT/skills" + +echo "==> Required env check" +: "${NVIDIA_INFERENCE_KEY:?Set NVBASE_INFERENCE_API_KEY secret (sk- inference proxy key)}" +: "${NGC_API_KEY:?Set NGC_API_KEY secret (nvapi-)}" +export JUDGE_ANTHROPIC_API_KEY="${NVIDIA_INFERENCE_KEY}" +export ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-$NVIDIA_INFERENCE_KEY}" +export CLAUDE_CODE_DISABLE_THINKING="${CLAUDE_CODE_DISABLE_THINKING:-1}" +# NVIDIA proxy needs fully-qualified Anthropic model ids. +export ANTHROPIC_BASE_URL="${ANTHROPIC_BASE_URL:-https://inference-api.nvidia.com}" +export ANTHROPIC_MODEL="${ANTHROPIC_MODEL:-aws/anthropic/bedrock-claude-sonnet-4-6}" +# Pin Milvus volumes outside the workspace so docker compose (run from ci/) +# doesn't write root-owned etcd/minio dirs into ci/volumes/ and break the +# artifact upload step (EACCES on scandir ci/volumes/etcd/member). +export DOCKER_VOLUME_DIRECTORY="${DOCKER_VOLUME_DIRECTORY:-/tmp/milvus-eval}" +# Ingestor server writes to INGESTOR_SERVER_EXTERNAL_VOLUME_MOUNT as root. +# Default is ./volumes/ingestor-server (relative to compose file dir = ci/). +# Redirect outside workspace to prevent EACCES on next checkout. +export INGESTOR_SERVER_EXTERNAL_VOLUME_MOUNT="${INGESTOR_SERVER_EXTERNAL_VOLUME_MOUNT:-/tmp/ingestor-server-data}" +export JUDGE_FULL_MODEL="${JUDGE_FULL_MODEL:-aws/anthropic/claude-haiku-4-5-v1}" + +# Runtime topology — controlled by whether BREV_INSTANCE is set. +# +# - CPU evals (default — most rag-* skills): +# BREV_INSTANCE unset → LocalEnvironment +# Runner deploys RAG locally on itself (runner == target). +# No separate Brev VM, no cross-VM `brev exec` plumbing. +# +# - GPU evals (rag-enable-vlm, rag-enable-guardrails): +# Workflow / invoker sets BREV_INSTANCE=rag-eval-gpu- +# → BrevEnvironment in ephemeral-provisioning mode (Item C) +# Runner uses `brev create` to spin a fresh GPU VM, drives +# deploy + judge via `brev exec`, and `brev delete`s the VM +# in the EXIT trap (Item E). +# +# To force a manual override (e.g. debug a Brev VM end-to-end without +# the CI flow), export BREV_INSTANCE= before invoking the script. +# +# ============================================================================ +# >>> GPU TESTING PATCH (Option B) <<< +# This block exists to validate H100×2 self-hosted CI end-to-end via the +# existing dispatcher workflow (no YAML changes needed on main). It's +# production-safe: only fires when EVAL_PROFILE matches a GPU pattern; +# the CPU default (nvidia_hosted) is unaffected. +# Companion files in this patch: +# ci/run_skill_eval_h100.sh (wrapper) +# skills/rag-deploy-blueprint/eval/h100.json (deploy spec) +# Keep after validation — generalises to any future GPU profile. +# ============================================================================ + +echo "==> Install uv (no-op if already present)" +if ! command -v uv >/dev/null 2>&1; then + curl -LsSf https://astral.sh/uv/install.sh | sh +fi +export PATH="$HOME/.local/bin:$PATH" +uv --version + +echo "==> Install Claude Code CLI (no-op if already present)" +if ! command -v claude >/dev/null 2>&1; then + npm install -g @anthropic-ai/claude-code +fi +claude --version + +echo "==> Docker login to nvcr.io" +echo "$NGC_API_KEY" | docker login nvcr.io -u '$oauthtoken' --password-stdin + +# ENV_IMPORT default — used by teardown. Overridden per-spec inside the loop. +ENV_IMPORT="envs.local_env:LocalEnvironment" + +echo "==> Clean leftover Docker state from prior runs (one-shot, before any trial)" +# This runs ONCE per CI run — never between trials — so step-1's deploy +# survives long enough for step-2's judge probes. Targets: +# - LocalEnvironment: this runner VM is also the deploy host. +# - BrevEnvironment: tear down the warm-pool target's containers, +# leaving the docker image cache (nv-ingest ~11 GB) intact. +COMPOSE_FILES=( + deploy/compose/docker-compose-rag-server.yaml + deploy/compose/docker-compose-ingestor-server.yaml + deploy/compose/vectordb.yaml + deploy/compose/nims.yaml + deploy/compose/docker-compose-nemo-guardrails.yaml + deploy/compose/observability.yaml +) +if [ "$ENV_IMPORT" = "envs.local_env:LocalEnvironment" ]; then + # Runner is the eval target — clean any leftover docker state from + # prior CI runs on this same VM. Image cache is preserved (warm pool + # benefit on the runner itself). + for f in "${COMPOSE_FILES[@]}"; do + [ -f "$f" ] && docker compose -f "$f" down -v --remove-orphans >/dev/null 2>&1 || true + done + docker ps -a --format '{{.Names}}' | \ + grep -E '(rag|milvus|nim|ingest|redis|nemo|grafana|prometheus|embedding|ranking|vlm|ocr|page-elements|graphic-elements|table-structure|nv-ingest)' | \ + xargs -r docker rm -f >/dev/null 2>&1 || true + # Remove root-owned volume dirs using Docker (no sudo needed). + # Containers write as root; only another root process can delete them. + # docker run --rm with alpine does the job without requiring sudo on the host. + for vol_dir in deploy/compose/volumes ci/volumes; do + if [ -d "$vol_dir" ]; then + docker run --rm -v "$(pwd)/${vol_dir}:/target" alpine \ + sh -c "rm -rf /target/*" 2>/dev/null || true + rm -rf "$vol_dir" 2>/dev/null || true + fi + done + rm -rf /tmp/milvus-eval /tmp/ingestor-server-data 2>/dev/null || true +fi +# GPU pre-flight (BrevEnvironment mode) is handled inside brev_env.start() +# — the VM is provisioned fresh per CI run, so there's no prior-state +# cleanup to do from the runner side. + +echo "==> Auto-discover all skill eval specs and run Harbor trials" +cd "$SKILL_EVAL_DIR" +mkdir -p jobs +HARBOR_CRASHES=0 + +# Find every skill that ships a spec for the current profile. +# Adding a new skill with eval/.json is all that's needed — +# no script changes required. +# +# CHANGED_SKILLS (optional, set by skills-eval.yml on PR runs): +# comma-separated list of skill names that changed in the PR. +# When set, only those skills are evaluated (diff-based selection). +# When empty, all skills run (nightly + manual dispatch). +while IFS= read -r spec_file; do + SKILL_NAME="$(basename "$(dirname "$(dirname "$spec_file")")")" + # Diff-based filter: skip skills not in CHANGED_SKILLS (PR runs only) + if [ -n "${CHANGED_SKILLS:-}" ]; then + if ! echo ",$CHANGED_SKILLS," | grep -q ",$SKILL_NAME,"; then + echo " SKIP $SKILL_NAME (not in PR diff)" + continue + fi + fi + # SKILL_DIR is the skill folder containing SKILL.md + SKILL_DIR="$(dirname "$(dirname "$spec_file")")" + DATASETS_DIR="$SKILL_EVAL_DIR/datasets/$SKILL_NAME" + + # Route per-spec: read platforms[0] from the spec JSON. + # cpu → LocalEnvironment (runner deploys Docker directly, no Brev VM) + # H100_x2 → BrevEnvironment (pre-provision ephemeral H100 Brev VM) + SPEC_PLATFORM=$(python3 -c " +import json, sys +spec = json.load(open('$spec_file')) +print(spec.get('platforms', ['cpu'])[0]) +" 2>/dev/null || echo "cpu") + + case "$SPEC_PLATFORM" in + cpu) + # Use rag-eval-target (existing warm n2d-standard-4 CPU VM) via + # BrevEnvironment — keeps Docker off the runner itself, avoids + # root-owned volume accumulation on the runner machine. + SPEC_ENV_IMPORT="envs.brev_env:BrevEnvironment" + SPEC_TIMEOUT_MULT="1.5" + export BREV_INSTANCE="rag-eval-target" + # Verify rag-eval-target is RUNNING+READY before handing off to harbor + STATE=$(brev ls 2>/dev/null | awk -v n="rag-eval-target" '$1==n {print $2"+"$4}') + if [ "$STATE" != "RUNNING+READY" ]; then + echo " WARN rag-eval-target is $STATE — waiting up to 10 min" + DEADLINE=$(( $(date +%s) + 600 )) + while [ "$(date +%s)" -lt "$DEADLINE" ]; do + STATE=$(brev ls 2>/dev/null | awk -v n="rag-eval-target" '$1==n {print $2"+"$4}') + [ "$STATE" = "RUNNING+READY" ] && break + sleep 15 + done + fi + echo " rag-eval-target: $STATE" + ;; + H100_x2|h100*) + SPEC_ENV_IMPORT="envs.brev_env:BrevEnvironment" + SPEC_TIMEOUT_MULT="3.0" + # Ensure GPU Milvus is in place (restore if previously swapped) + [ -f deploy/compose/vectordb.yaml.gpu-bak ] && \ + mv deploy/compose/vectordb.yaml.gpu-bak deploy/compose/vectordb.yaml || true + # Pre-provision Brev VM for this GPU spec + BREV_TYPE=$(python3 -c " +import json, sys +spec = json.load(open('$spec_file')) +plats = (spec.get('resources') or {}).get('platforms') or {} +p = spec.get('platforms', [])[0] if spec.get('platforms') else '' +print(plats.get(p, {}).get('brev_type', 'dmz.h100x2.pcie')) +" 2>/dev/null || echo "dmz.h100x2.pcie") + export BREV_INSTANCE="rag-eval-gpu-$(date +%s | tail -c 8)" + # Fallback chain per VSS AGENTS.md — tries each type in order if + # the primary is at capacity. brev create --type accepts comma-separated + # fallback list natively. + BREV_FALLBACKS="${BREV_TYPE},scaleway_H100x2,gpu-h100-sxm.1gpu-16vcpu-200gb" + echo "==> Pre-provisioning $BREV_INSTANCE (trying: $BREV_FALLBACKS) for $SKILL_NAME" + for attempt in $(seq 1 5); do + brev create "$BREV_INSTANCE" --type "$BREV_FALLBACKS" --detached 2>&1 | tail -5 + brev ls 2>/dev/null | awk -v n="$BREV_INSTANCE" '$1==n {found=1} END{exit !found}' && break + sleep 15 + done + DEADLINE=$(( $(date +%s) + 1800 )) + last_state="" + while [ "$(date +%s)" -lt "$DEADLINE" ]; do + STATE=$(brev ls 2>/dev/null | awk -v n="$BREV_INSTANCE" '$1==n {print $2"+"$4}') + if [ -n "$STATE" ] && [ "$STATE" != "$last_state" ]; then + echo " $(date -u +%H:%M:%SZ) $BREV_INSTANCE: $STATE" + last_state="$STATE" + fi + [ "$STATE" = "RUNNING+READY" ] && break + sleep 15 + done + if [ "$last_state" != "RUNNING+READY" ]; then + echo "Pre-provision timed out — last state: ${last_state:-unknown}" + exit 1 + fi + echo "==> $BREV_INSTANCE ready" + mkdir -p /tmp/brev + echo "$BREV_INSTANCE" >> "/tmp/brev/started-by-${GITHUB_RUN_ID:-local}.txt" + ;; + *) + echo " WARN Unknown platform '$SPEC_PLATFORM' in $spec_file — skipping" + continue + ;; + esac + + echo "" + echo "==> [$SKILL_NAME] platform=$SPEC_PLATFORM env=$SPEC_ENV_IMPORT" + echo "==> [$SKILL_NAME] Generating task directories" + rm -rf "$DATASETS_DIR" + python3 adapters/rag-blueprint/generate.py \ + --output-dir "$DATASETS_DIR" \ + --skill-dir "$SKILL_DIR" \ + --skill-name "$SKILL_NAME" \ + --spec "$spec_file" + + echo "==> [$SKILL_NAME] Running Harbor trials" + while IFS= read -r step_dir; do + echo " ----> harbor run -p $step_dir" + if ! uvx --with boto3 harbor run \ + -p "$step_dir" \ + --environment-import-path "$SPEC_ENV_IMPORT" \ + --agent claude-code --model "$ANTHROPIC_MODEL" \ + --ak api_base="$ANTHROPIC_BASE_URL/v1" \ + --ae CLAUDE_CODE_DISABLE_THINKING=1 \ + --environment-build-timeout-multiplier "$SPEC_TIMEOUT_MULT" \ + --agent-timeout-multiplier "$SPEC_TIMEOUT_MULT" \ + --verifier-timeout-multiplier "$SPEC_TIMEOUT_MULT" \ + --max-retries 0 -n 1 --yes; then + HARBOR_CRASHES=$((HARBOR_CRASHES + 1)) + echo " harbor run exited non-zero for $step_dir" + fi + done < <(find "$DATASETS_DIR" -mindepth 1 -maxdepth 1 -type d | sort) + # Restore vectordb.yaml if it was swapped for this cpu spec + [ -f deploy/compose/vectordb.yaml.gpu-bak ] && \ + mv deploy/compose/vectordb.yaml.gpu-bak deploy/compose/vectordb.yaml || true + +done < <( + # Discover specs under skill-source. Platform routing (cpu/gpu) is + # determined per-spec from platforms[]. + # EVAL_PROFILE (optional): if set, only discover specs whose filename + # stem matches (e.g. EVAL_PROFILE=h100 → only h100.json specs). + # When unset, all specs run; cpu sorts before gpu. + _profile_filter="${EVAL_PROFILE:-}" + if [ -n "$_profile_filter" ]; then + find "$REPO_ROOT/skill-source/.agents/skills" \ + -path "*/eval/${_profile_filter}.json" 2>/dev/null | sort + else + find "$REPO_ROOT/skill-source/.agents/skills" \ + -path "*/eval/*.json" 2>/dev/null \ + | python3 -c " +import sys, json +files = sys.stdin.read().splitlines() +def platform_key(f): + try: + p = json.load(open(f)).get('platforms', ['cpu'])[0] + return (0 if p == 'cpu' else 1, f) + except Exception: + return (0, f) +for f in sorted(files, key=platform_key): + print(f) +" + fi +) + +echo "==> Summarise results into eval_result.md (walks ALL job dirs)" +python3 - <<'PY' +import json +from pathlib import Path + +jobs_root = Path("jobs") +if not jobs_root.exists() or not any(jobs_root.iterdir()): + raise SystemExit("no Harbor jobs produced") + +lines = ["# Skill-eval results", ""] +total, passed = 0, 0 +for reward_file in sorted(jobs_root.rglob("reward.txt")): + r = float(reward_file.read_text().strip() or 0) + judge = reward_file.parent / "judge.json" + # parents: reward.txt → verifier → step-N__XXX → + step_name = reward_file.parents[1].name + run_name = reward_file.parents[2].name + line = f"- **{run_name} / {step_name}**: reward `{r:.2f}`" + if judge.exists(): + j = json.loads(judge.read_text()) + passed += j.get("passed", 0) + total += j.get("total", 0) + line += f" ({j.get('passed',0)}/{j.get('total',0)} checks)" + lines.append(line) + +lines.insert(2, f"**Overall:** {passed}/{total} checks passed\n") +out = Path("eval_result.md") +out.write_text("\n".join(lines) + "\n") +# Expose totals to the surrounding shell for the CI exit-code decision. +Path(".eval_total.txt").write_text(f"{total}\n") +Path(".eval_passed.txt").write_text(f"{passed}\n") +print(out.read_text()) +PY + +EVAL_TOTAL=$(cat "$SKILL_EVAL_DIR/.eval_total.txt" 2>/dev/null || echo 0) +EVAL_PASSED=$(cat "$SKILL_EVAL_DIR/.eval_passed.txt" 2>/dev/null || echo 0) +echo "==> CI exit decision (VSS pattern):" +echo " HARBOR_CRASHES=$HARBOR_CRASHES EVAL_TOTAL=$EVAL_TOTAL EVAL_PASSED=$EVAL_PASSED" + +echo "==> Tear down eval target (next CI run starts clean)" +cd "$REPO_ROOT" +# Brev cleanup is handled by the EXIT trap — runs even on script failure. +# LocalEnvironment doesn't get a trap because the runner IS the deploy host +# and we want to leave its state inspectable for debugging. +if [ "$ENV_IMPORT" = "envs.local_env:LocalEnvironment" ]; then + for f in \ + deploy/compose/docker-compose-rag-server.yaml \ + deploy/compose/docker-compose-ingestor-server.yaml \ + deploy/compose/vectordb.yaml; do + [ -f "$f" ] && docker compose -f "$f" down -v --remove-orphans >/dev/null 2>&1 || true + done + for vol_dir in deploy/compose/volumes ci/volumes; do + if [ -d "$vol_dir" ]; then + docker run --rm -v "$(pwd)/${vol_dir}:/target" alpine \ + sh -c "rm -rf /target/*" 2>/dev/null || true + rm -rf "$vol_dir" 2>/dev/null || true + fi + done + rm -rf /tmp/milvus-eval /tmp/ingestor-server-data 2>/dev/null || true + # Restore original vectordb.yaml (was swapped for cpu variant at start) + if [ -f deploy/compose/vectordb.yaml.gpu-bak ]; then + mv deploy/compose/vectordb.yaml.gpu-bak deploy/compose/vectordb.yaml + fi +fi + +echo "==> Stage outputs to eval-results/ for artifact upload" +# The dispatcher workflow's upload-artifact step looks for paths +# `eval-results/`, `**/evals/results/`, `ci-logs/`. The latter glob +# recurses everywhere and chokes on docker-volume dirs owned by root +# (e.g. deploy/compose/volumes/etcd/member → EACCES). Stage our results +# under a clean eval-results/ directory at the repo root so the action +# uploads exactly what we want without needing to crawl docker volumes. +STAGE="$REPO_ROOT/eval-results" +rm -rf "$STAGE" +mkdir -p "$STAGE" +cp -a "$SKILL_EVAL_DIR/jobs" "$STAGE/jobs" +cp "$SKILL_EVAL_DIR/eval_result.md" "$STAGE/eval_result.md" +echo "Staged artifact tree:" +find "$STAGE" -maxdepth 3 | head -40 + +echo "==> Eval complete" + +# VSS exit-code pattern: CI is red ONLY when the harness itself broke. +# Individual eval-check failures (low reward) stay green — the verdict +# is in the uploaded artifact (eval_result.md + judge.json). +# +# Red signals: +# - HARBOR_CRASHES > 0 → at least one trial errored (e.g. brev_env, +# RewardFileNotFoundError) — pipeline didn't run end-to-end +# - EVAL_TOTAL == 0 → no checks produced — config or harness broken +if [ "$HARBOR_CRASHES" -gt 0 ] || [ "$EVAL_TOTAL" -eq 0 ]; then + echo "FAIL: pipeline broken — HARBOR_CRASHES=$HARBOR_CRASHES, EVAL_TOTAL=$EVAL_TOTAL" + exit 1 +fi +echo "PASS: pipeline ran end-to-end. Eval verdict: $EVAL_PASSED/$EVAL_TOTAL checks passed (see artifact)." diff --git a/ci/run_skill_eval_h100.sh b/ci/run_skill_eval_h100.sh new file mode 100755 index 000000000..d0e4fa9ba --- /dev/null +++ b/ci/run_skill_eval_h100.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# ============================================================================ +# >>> GPU TESTING PATCH (Option B) <<< +# This entire file exists to validate H100×2 self-hosted CI end-to-end +# via the existing dispatcher workflow (no YAML changes on main needed). +# Retire once the dispatcher YAML on main accepts an `eval_profile` input. +# Companion files in this patch: +# ci/run_skill_eval.sh (BREV_INSTANCE auto-set) +# skills/rag-deploy-blueprint/eval/h100.json (deploy spec) +# ============================================================================ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Convenience wrapper for GPU dispatches via the existing dispatcher +# workflow (.github/workflows/run-branch-script.yml on main), which only +# accepts {ref, script, runner} inputs and has no way to pass arbitrary +# env vars. Trigger: +# +# gh workflow run "Run Branch Script" --ref main \ +# -f ref=feat/skill-eval-ci \ +# -f script=ci/run_skill_eval_h100.sh \ +# -f runner=rag-eval +# +# This wrapper exports EVAL_PROFILE=h100 then execs run_skill_eval.sh. +# Inside that script: EVAL_PROFILE matches the h100* case → BREV_INSTANCE +# auto-generated → BrevEnvironment selected → auto-discovery finds +# skills/*/eval/h100.json → run_skill_eval.sh's EXIT trap routes +# `dmz.h100x2.pcie` to `brev delete` after the cooldown. +# +# Retire this wrapper once the dispatcher workflow on main accepts an +# `eval_profile` input — then GPU dispatches go through the canonical +# script with `-f eval_profile=h100`. + +set -euo pipefail +export EVAL_PROFILE=h100 +exec bash "$(dirname "$0")/run_skill_eval.sh" "$@" diff --git a/deploy/compose/.env b/deploy/compose/.env index 3405340d1..8d1980562 100644 --- a/deploy/compose/.env +++ b/deploy/compose/.env @@ -4,6 +4,13 @@ export USERID=$(id -u) export NVIDIA_API_KEY=${NGC_API_KEY} +# ========================== +# Object Store +# ========================== +export OBJECTSTORE_ENDPOINT=seaweedfs:9010 +export OBJECTSTORE_ACCESSKEY=seaweedfsadmin +export OBJECTSTORE_SECRETKEY=seaweedfsadmin + # ==== Service-Specific API Keys (Optional) ==== # Set these to use different API keys for individual services. # If not set or empty, all services use NVIDIA_API_KEY as fallback. @@ -22,13 +29,17 @@ export NVIDIA_API_KEY=${NGC_API_KEY} export APP_LLM_SERVERURL=nim-llm:8000 export APP_FILTEREXPRESSIONGENERATOR_SERVERURL=nim-llm:8000 export SUMMARY_LLM_SERVERURL=nim-llm:8000 -export APP_EMBEDDINGS_SERVERURL=nemotron-embedding-ms:8000/v1 -export APP_EMBEDDINGS_MODELNAME=nvidia/llama-nemotron-embed-1b-v2 +# export APP_EMBEDDINGS_SERVERURL=nemotron-embedding-ms:8000/v1 +export APP_EMBEDDINGS_SERVERURL=nemotron-vlm-embedding-ms:8000/v1 +export APP_EMBEDDINGS_MODELNAME=nvidia/llama-nemotron-embed-vl-1b-v2 export APP_RANKING_SERVERURL=nemotron-ranking-ms:8000 -export OCR_GRPC_ENDPOINT=nemoretriever-ocr:8001 -export OCR_HTTP_ENDPOINT=http://nemoretriever-ocr:8000/v1/infer +export APP_RANKING_MODELNAME="nvidia/llama-nemotron-rerank-1b-v2" +# export APP_RANKING_SERVERURL=nemotron-ranking-vl-ms:8000 +# export APP_RANKING_MODELNAME="nvidia/llama-nemotron-rerank-vl-1b-v2" +export OCR_GRPC_ENDPOINT=nemotron-ocr:8001 +export OCR_HTTP_ENDPOINT=http://nemotron-ocr:8000/v1/infer export OCR_INFER_PROTOCOL=grpc -export OCR_MODEL_NAME=scene_text_ensemble +export OCR_MODEL_NAME=pipeline export YOLOX_GRPC_ENDPOINT=page-elements:8001 export YOLOX_INFER_PROTOCOL=grpc export YOLOX_GRAPHIC_ELEMENTS_GRPC_ENDPOINT=graphic-elements:8001 @@ -42,15 +53,15 @@ export YOLOX_TABLE_STRUCTURE_INFER_PROTOCOL=grpc # export APP_EMBEDDINGS_SERVERURL=https://integrate.api.nvidia.com/v1 # export APP_LLM_SERVERURL="" -# export APP_LLM_MODELNAME=nvidia/llama-3.3-nemotron-super-49b-v1.5 -# export APP_FILTEREXPRESSIONGENERATOR_MODELNAME=nvidia/llama-3.3-nemotron-super-49b-v1.5 +# export APP_LLM_MODELNAME=nvidia/nemotron-3-super-120b-a12b +# export APP_FILTEREXPRESSIONGENERATOR_MODELNAME=nvidia/nemotron-3-super-120b-a12b # export APP_FILTEREXPRESSIONGENERATOR_SERVERURL="" -# export SUMMARY_LLM="nvidia/llama-3.3-nemotron-super-49b-v1.5" +# export SUMMARY_LLM="nvidia/nemotron-3-super-120b-a12b" # export APP_RANKING_SERVERURL="" # export SUMMARY_LLM_SERVERURL="" -# export OCR_HTTP_ENDPOINT=https://ai.api.nvidia.com/v1/cv/nvidia/nemoretriever-ocr +# export OCR_HTTP_ENDPOINT=https://ai.api.nvidia.com/v1/cv/nvidia/nemotron-ocr-v1 # export OCR_INFER_PROTOCOL=http -# export OCR_MODEL_NAME=scene_text_ensemble +# export OCR_MODEL_NAME=pipeline # export YOLOX_HTTP_ENDPOINT=https://ai.api.nvidia.com/v1/cv/nvidia/nemotron-page-elements-v3 # export YOLOX_INFER_PROTOCOL=http # export YOLOX_GRAPHIC_ELEMENTS_HTTP_ENDPOINT=https://ai.api.nvidia.com/v1/cv/nvidia/nemotron-graphic-elements-v1 @@ -58,7 +69,7 @@ export YOLOX_TABLE_STRUCTURE_INFER_PROTOCOL=grpc # export YOLOX_TABLE_STRUCTURE_HTTP_ENDPOINT=https://ai.api.nvidia.com/v1/cv/nvidia/nemotron-table-structure-v1 # export YOLOX_TABLE_STRUCTURE_INFER_PROTOCOL=http # export APP_QUERYREWRITER_SERVERURL="" -# export APP_QUERYREWRITER_MODELNAME="nvidia/llama-3.3-nemotron-super-49b-v1.5" +# export APP_QUERYREWRITER_MODELNAME="nvidia/nemotron-3-super-120b-a12b" # ========================== @@ -88,4 +99,4 @@ export OCR_MS_GPU_ID=0 # Paths # ========================== -export PROMPT_CONFIG_FILE=${PWD}/src/nvidia_rag/rag_server/prompt.yaml \ No newline at end of file +export PROMPT_CONFIG_FILE=${PWD}/src/nvidia_rag/rag_server/prompt.yaml diff --git a/deploy/compose/accuracy_profile.env b/deploy/compose/accuracy_profile.env index 95667558d..6dc962652 100644 --- a/deploy/compose/accuracy_profile.env +++ b/deploy/compose/accuracy_profile.env @@ -1,4 +1,4 @@ -export APP_NVINGEST_ENABLEPDFSPLITTER=True +export APP_NVINGEST_ENABLE_PAGED_DOC_SPLIT=True export APP_NVINGEST_CHUNKSIZE=1024 export APP_NVINGEST_CHUNKOVERLAP=150 export ENABLE_RERANKER=True diff --git a/deploy/compose/docker-compose-ingestor-server.yaml b/deploy/compose/docker-compose-ingestor-server.yaml index ae44ad169..06c41b37e 100644 --- a/deploy/compose/docker-compose-ingestor-server.yaml +++ b/deploy/compose/docker-compose-ingestor-server.yaml @@ -3,7 +3,7 @@ services: # Main ingestor server which is responsible for ingestion ingestor-server: container_name: ingestor-server - image: nvcr.io/nvidia/blueprint/ingestor-server:${TAG:-2.5.0} + image: nvcr.io/nvidia/blueprint/ingestor-server:${TAG:-2.6.0} build: # Set context to repo's root directory context: ../../ @@ -16,8 +16,11 @@ services: volumes: # Mount the prompt.yaml file to the container, path should be absolute - ${PROMPT_CONFIG_FILE}:${PROMPT_CONFIG_FILE} - # Please mount the volume to the container to the path specified here - - ${INGESTOR_SERVER_EXTERNAL_VOLUME_MOUNT:-./volumes/ingestor-server}:${INGESTOR_SERVER_DATA_DIR:-/data/} + # Persistent data — `rag-vol-ingestor` for ingestor-server scratch/results, + # `rag-vol-lancedb` shared with the RAG server. These replace the legacy + # `deploy/compose/volumes/ingestor-server` / `…/lancedb` host directories. + - rag-vol-ingestor:${INGESTOR_SERVER_DATA_DIR:-/data/} + - rag-vol-lancedb:/volumes/lancedb # Common customizations to the pipeline can be controlled using env variables environment: @@ -29,11 +32,11 @@ services: ##===Vector DB specific configurations=== # URL on which vectorstore is hosted - # For custom operators, point to your service (e.g., http://your-custom-vdb:1234) - APP_VECTORSTORE_URL: ${APP_VECTORSTORE_URL:-http://milvus:19530} - # Type of vectordb used to store embedding. Supported built-ins: "milvus", "elasticsearch". + # For custom operators, point to your service (e.g., http://your-custom-vdb:1234 or /volumes/lancedb/lancedb - for lancedb) + APP_VECTORSTORE_URL: ${APP_VECTORSTORE_URL:-http://elasticsearch:9200} + # Type of vectordb used to store embedding. Supported built-ins: "elasticsearch"[default], "milvus", "lancedb". # You can also provide your custom value (e.g., "your_custom_vdb") when you register it in `_get_vdb_op`. - APP_VECTORSTORE_NAME: ${APP_VECTORSTORE_NAME:-"milvus"} + APP_VECTORSTORE_NAME: ${APP_VECTORSTORE_NAME:-"elasticsearch"} # Type of vectordb search to be used APP_VECTORSTORE_SEARCHTYPE: ${APP_VECTORSTORE_SEARCHTYPE:-"dense"} # Can be dense or hybrid @@ -44,10 +47,10 @@ services: # Weight for sparse vector search in case of "weighted" Hybrid Search APP_VECTORSTORE_SPARSE_WEIGHT: ${APP_VECTORSTORE_SPARSE_WEIGHT:-0.5} - # Boolean to enable GPU index for milvus vectorstore specific to nvingest - APP_VECTORSTORE_ENABLEGPUINDEX: ${APP_VECTORSTORE_ENABLEGPUINDEX:-True} - # Boolean to control GPU search for milvus vectorstore specific to nvingest - APP_VECTORSTORE_ENABLEGPUSEARCH: ${APP_VECTORSTORE_ENABLEGPUSEARCH:-True} + # Enable GPU index building. Applies to both Milvus and Elasticsearch (requires GPU-capable image and license for ES). + APP_VECTORSTORE_ENABLEGPUINDEX: ${APP_VECTORSTORE_ENABLEGPUINDEX:-False} + # Enable GPU search. Milvus only — GPU search is not supported by Elasticsearch. + APP_VECTORSTORE_ENABLEGPUSEARCH: ${APP_VECTORSTORE_ENABLEGPUSEARCH:-False} # Username for vector store APP_VECTORSTORE_USERNAME: ${APP_VECTORSTORE_USERNAME:-""} APP_VECTORSTORE_PASSWORD: ${APP_VECTORSTORE_PASSWORD:-""} @@ -59,10 +62,10 @@ services: # vectorstore collection name to store embeddings COLLECTION_NAME: ${COLLECTION_NAME:-multimodal_data} - ##===MINIO specific configurations=== - MINIO_ENDPOINT: "minio:9010" - MINIO_ACCESSKEY: "minioadmin" - MINIO_SECRETKEY: "minioadmin" + ##===Object-store specific configurations=== + OBJECTSTORE_ENDPOINT: ${OBJECTSTORE_ENDPOINT:-seaweedfs:9010} + OBJECTSTORE_ACCESSKEY: ${OBJECTSTORE_ACCESSKEY:-seaweedfsadmin} + OBJECTSTORE_SECRETKEY: ${OBJECTSTORE_SECRETKEY:-seaweedfsadmin} NGC_API_KEY: ${NGC_API_KEY:?"NGC_API_KEY is required"} NVIDIA_API_KEY: ${NGC_API_KEY:?"NGC_API_KEY is required"} @@ -75,12 +78,12 @@ services: ##===Embedding Model specific configurations=== # url on which embedding model is hosted. If "", Nvidia hosted API is used - APP_EMBEDDINGS_SERVERURL: ${APP_EMBEDDINGS_SERVERURL-"nemotron-embedding-ms:8000/v1"} - APP_EMBEDDINGS_MODELNAME: ${APP_EMBEDDINGS_MODELNAME:-nvidia/llama-nemotron-embed-1b-v2} + APP_EMBEDDINGS_SERVERURL: ${APP_EMBEDDINGS_SERVERURL-"nemotron-vlm-embedding-ms:8000/v1"} + APP_EMBEDDINGS_MODELNAME: ${APP_EMBEDDINGS_MODELNAME:-nvidia/llama-nemotron-embed-vl-1b-v2} + # For text embedding model + # APP_EMBEDDINGS_SERVERURL: ${APP_EMBEDDINGS_SERVERURL-"nemotron-embedding-ms:8000/v1"} + # APP_EMBEDDINGS_MODELNAME: ${APP_EMBEDDINGS_MODELNAME:-nvidia/llama-nemotron-embed-1b-v2} APP_EMBEDDINGS_DIMENSIONS: ${APP_EMBEDDINGS_DIMENSIONS:-2048} - # For VLM Embedding Model (llama-nemotron-embed-vl-1b-v2) - # APP_EMBEDDINGS_SERVERURL: ${APP_EMBEDDINGS_SERVERURL-"nemotron-vlm-embedding-ms:8000/v1"} - # APP_EMBEDDINGS_MODELNAME: ${APP_EMBEDDINGS_MODELNAME:-nvidia/llama-nemotron-embed-vl-1b-v2} ##===NV-Ingest Connection Configurations======= APP_NVINGEST_MESSAGECLIENTHOSTNAME: ${APP_NVINGEST_MESSAGECLIENTHOSTNAME:-"nv-ingest-ms-runtime"} @@ -103,17 +106,24 @@ services: ##===NV-Ingest Splitting Configurations======== APP_NVINGEST_CHUNKSIZE: ${APP_NVINGEST_CHUNKSIZE:-512} APP_NVINGEST_CHUNKOVERLAP: ${APP_NVINGEST_CHUNKOVERLAP:-150} - APP_NVINGEST_ENABLEPDFSPLITTER: ${APP_NVINGEST_ENABLEPDFSPLITTER:-True} + APP_NVINGEST_ENABLE_PAGED_DOC_SPLIT: ${APP_NVINGEST_ENABLE_PAGED_DOC_SPLIT:-False} APP_NVINGEST_SEGMENTAUDIO: ${APP_NVINGEST_SEGMENTAUDIO:-False} # Enable audio segmentation for NV Ingest ##===NV-Ingest Caption Model configurations==== - APP_NVINGEST_CAPTIONMODELNAME: ${APP_NVINGEST_CAPTIONMODELNAME:-"nvidia/nemotron-3-nano-omni-30b-a3b-reasoning"} + APP_NVINGEST_CAPTIONMODELNAME: ${APP_NVINGEST_CAPTIONMODELNAME:-"nvidia/nemotron-nano-12b-v2-vl"} # Incase of nvidia-hosted caption model, use the endpoint url as - https://integrate.api.nvidia.com/v1 - APP_NVINGEST_CAPTIONENDPOINTURL: ${APP_NVINGEST_CAPTIONENDPOINTURL:-"http://vlm-ms:8000/v1/chat/completions"} + APP_NVINGEST_CAPTIONENDPOINTURL: ${APP_NVINGEST_CAPTIONENDPOINTURL:-"http://vlm-captioning-ms:8000/v1/chat/completions"} + + ##===NeMo Retriever invoke URLs (HTTP, local NIMs)==== + # Align with YOLOX_*_HTTP_ENDPOINT / OCR_HTTP_ENDPOINT on nv-ingest-ms-runtime (nims.yaml service names). + APP_NVINGEST_PAGEELEMENTSURL: ${APP_NVINGEST_PAGEELEMENTSURL:-http://page-elements:8000/v1/infer} + APP_NVINGEST_GRAPHICELEMENTSURL: ${APP_NVINGEST_GRAPHICELEMENTSURL:-http://graphic-elements:8000/v1/infer} + APP_NVINGEST_OCRURL: ${APP_NVINGEST_OCRURL:-http://nemotron-ocr:8000/v1/infer} + APP_NVINGEST_TABLESTRUCTUREURL: ${APP_NVINGEST_TABLESTRUCTUREURL:-http://table-structure:8000/v1/infer} ##===NV-Ingest Save to Disk Configurations==== APP_NVINGEST_SAVETODISK: ${APP_NVINGEST_SAVETODISK:-False} - NVINGEST_MINIO_BUCKET: ${NVINGEST_MINIO_BUCKET:-nv-ingest} + NVINGEST_OBJECTSTORE_BUCKET: ${NVINGEST_OBJECTSTORE_BUCKET:-nv-ingest} ##===NV-Ingest Performance Configurations======== # If enabled, splits a single PDF's pages into parallel chunks for processing (smaller chunks = more parallelism but more overhead) @@ -124,7 +134,7 @@ services: ENABLE_CITATIONS: ${ENABLE_CITATIONS:-True} # Choose the summary model to use for document summary - SUMMARY_LLM: ${SUMMARY_LLM:-nvidia/llama-3.3-nemotron-super-49b-v1.5} + SUMMARY_LLM: ${SUMMARY_LLM:-nvidia/nemotron-3-super-120b-a12b} SUMMARY_LLM_SERVERURL: ${SUMMARY_LLM_SERVERURL-${APP_LLM_SERVERURL-"nim-llm:8000"}} SUMMARY_LLM_MAX_CHUNK_LENGTH: ${SUMMARY_LLM_MAX_CHUNK_LENGTH:-9000} SUMMARY_CHUNK_OVERLAP: ${SUMMARY_CHUNK_OVERLAP:-400} @@ -140,8 +150,6 @@ services: REDIS_DB: ${REDIS_DB:-0} ENABLE_REDIS_BACKEND: ${ENABLE_REDIS_BACKEND:-False} - # Bulk upload to MinIO - ENABLE_MINIO_BULK_UPLOAD: ${ENABLE_MINIO_BULK_UPLOAD:-True} TEMP_DIR: ${TEMP_DIR:-/tmp-data} INGESTOR_SERVER_DATA_DIR: ${INGESTOR_SERVER_DATA_DIR:-/data/} @@ -149,6 +157,8 @@ services: NV_INGEST_FILES_PER_BATCH: ${NV_INGEST_FILES_PER_BATCH:-16} NV_INGEST_CONCURRENT_BATCHES: ${NV_INGEST_CONCURRENT_BATCHES:-4} ENABLE_NV_INGEST_DYNAMIC_BATCHING: ${ENABLE_NV_INGEST_DYNAMIC_BATCHING:-True} + # Max memory budget (MB) for a single ingestion job; used for dynamic batch sizing + INGESTION_MAX_MEMORY_BUDGET_MB: ${INGESTION_MAX_MEMORY_BUDGET_MB:-1024} # Tracing APP_TRACING_ENABLED: ${APP_TRACING_ENABLED:-"False"} @@ -161,7 +171,7 @@ services: - "8082:8082" expose: - "8082" - shm_size: 5gb + shm_size: 12gb redis: image: "redis/redis-stack:7.2.0-v18" @@ -169,7 +179,7 @@ services: - "6379:6379" nv-ingest-ms-runtime: - image: nvcr.io/nvidia/nemo-microservices/nv-ingest:26.1.2 + image: nvcr.io/nvidia/nemo-microservices/nv-ingest:26.3.0 # cpuset: "0-15" # Uncomment to restrict this container to CPU cores 0–15 shm_size: 40gb # Should be at minimum 30% of assigned memory per Ray documentation volumes: @@ -207,9 +217,13 @@ services: - MESSAGE_CLIENT_HOST=redis - MESSAGE_CLIENT_PORT=6379 - MESSAGE_CLIENT_TYPE=redis - - MINIO_BUCKET=${MINIO_BUCKET:-${NVINGEST_MINIO_BUCKET:-nv-ingest}} - - MINIO_ACCESS_KEY=${MINIO_ACCESS_KEY:-minioadmin} - - MINIO_SECRET_KEY=${MINIO_SECRET_KEY:-minioadmin} + - MINIO_BUCKET=${NVINGEST_OBJECTSTORE_BUCKET:-nv-ingest} + - MINIO_ACCESS_KEY=${OBJECTSTORE_ACCESSKEY:-seaweedfsadmin} + - MINIO_SECRET_KEY=${OBJECTSTORE_SECRETKEY:-seaweedfsadmin} + - APP_EMBEDDINGS_SERVERURL=${APP_EMBEDDINGS_SERVERURL:-nemotron-vlm-embedding-ms:8000/v1} + - APP_EMBEDDINGS_MODELNAME=${APP_EMBEDDINGS_MODELNAME:-nvidia/llama-nemotron-embed-vl-1b-v2} + # - APP_EMBEDDINGS_SERVERURL=${APP_EMBEDDINGS_SERVERURL:-nemotron-embedding-ms:8000/v1} + # - APP_EMBEDDINGS_MODELNAME=${APP_EMBEDDINGS_MODELNAME:-nvidia/llama-nemotron-embed-1b-v2} # - NEMOTRON_PARSE_HTTP_ENDPOINT=https://integrate.api.nvidia.com/v1/chat/completions - NEMOTRON_PARSE_HTTP_ENDPOINT=${NEMOTRON_PARSE_HTTP_ENDPOINT:-http://nemotron-parse:8000/v1/chat/completions} - NEMOTRON_PARSE_INFER_PROTOCOL=${NEMOTRON_PARSE_INFER_PROTOCOL:-http} @@ -220,17 +234,17 @@ services: - NV_INGEST_MAX_UTIL=${NV_INGEST_MAX_UTIL:-48} - OTEL_EXPORTER_OTLP_ENDPOINT=otel-collector:4317 # Self-hosted ocr endpoints. - - OCR_GRPC_ENDPOINT=${OCR_GRPC_ENDPOINT:-nemoretriever-ocr:8001} - - OCR_HTTP_ENDPOINT=${OCR_HTTP_ENDPOINT:-http://nemoretriever-ocr:8000/v1/infer} + - OCR_GRPC_ENDPOINT=${OCR_GRPC_ENDPOINT:-nemotron-ocr:8001} + - OCR_HTTP_ENDPOINT=${OCR_HTTP_ENDPOINT:-http://nemotron-ocr:8000/v1/infer} - OCR_INFER_PROTOCOL=${OCR_INFER_PROTOCOL:-grpc} - - OCR_MODEL_NAME=${OCR_MODEL_NAME:-scene_text_ensemble} + - OCR_MODEL_NAME=${OCR_MODEL_NAME:-pipeline} # build.nvidia.com hosted ocr endpoints. - #- OCR_HTTP_ENDPOINT=https://ai.api.nvidia.com/v1/cv/nvidia/nemoretriever-ocr + #- OCR_HTTP_ENDPOINT=https://ai.api.nvidia.com/v1/cv/nvidia/nemotron-ocr-v1 #- OCR_INFER_PROTOCOL=http - PDF_SPLIT_PAGE_COUNT=${PDF_SPLIT_PAGE_COUNT:-32} - REDIS_INGEST_TASK_QUEUE=ingest_task_queue # Self-hosted redis endpoints. - - YOLOX_PAGE_IMAGE_FORMAT=JPEG # JPG is faster than PNG + - YOLOX_PAGE_IMAGE_FORMAT=JPEG - YOLOX_GRPC_ENDPOINT=${YOLOX_GRPC_ENDPOINT:-page-elements:8001} - YOLOX_HTTP_ENDPOINT=${YOLOX_HTTP_ENDPOINT:-http://page-elements:8000/v1/infer} - YOLOX_INFER_PROTOCOL=${YOLOX_INFER_PROTOCOL:-grpc} @@ -247,8 +261,8 @@ services: - YOLOX_TABLE_STRUCTURE_HTTP_ENDPOINT=${YOLOX_TABLE_STRUCTURE_HTTP_ENDPOINT:-http://table-structure:8000/v1/infer} - YOLOX_TABLE_STRUCTURE_INFER_PROTOCOL=${YOLOX_TABLE_STRUCTURE_INFER_PROTOCOL:-grpc} # Incase of nvidia-hosted caption model, use the endpoint url as - https://integrate.api.nvidia.com/v1/chat/completions - - VLM_CAPTION_ENDPOINT=${VLM_CAPTION_ENDPOINT:-http://vlm-ms:8000/v1/chat/completions} - - VLM_CAPTION_MODEL_NAME=${VLM_CAPTION_MODEL_NAME:-nvidia/nemotron-3-nano-omni-30b-a3b-reasoning} + - VLM_CAPTION_ENDPOINT=${VLM_CAPTION_ENDPOINT:-http://vlm-captioning-ms:8000/v1/chat/completions} + - VLM_CAPTION_MODEL_NAME=${VLM_CAPTION_MODEL_NAME:-nvidia/nemotron-nano-12b-v2-vl} - MODEL_PREDOWNLOAD_PATH=${MODEL_PREDOWNLOAD_PATH:-/workspace/models/} - COMPONENTS_TO_READY_CHECK=ALL healthcheck: @@ -257,6 +271,15 @@ services: timeout: 5s retries: 20 +# Per-service named Docker volumes. Explicit `name:` bypasses the compose project +# prefix so the lancedb volume is the same physical volume as the one mounted by +# docker-compose-rag-server.yaml (and any future consumers). +volumes: + rag-vol-ingestor: + name: rag-vol-ingestor + rag-vol-lancedb: + name: rag-vol-lancedb + networks: default: name: nvidia-rag diff --git a/deploy/compose/docker-compose-rag-server.yaml b/deploy/compose/docker-compose-rag-server.yaml index 618fef735..e2ee9543d 100644 --- a/deploy/compose/docker-compose-rag-server.yaml +++ b/deploy/compose/docker-compose-rag-server.yaml @@ -3,7 +3,7 @@ services: # Main orchestrator server which stiches together all calls to different services to fulfill the user request rag-server: container_name: rag-server - image: nvcr.io/nvidia/blueprint/rag-server:${TAG:-2.5.1} + image: nvcr.io/nvidia/blueprint/rag-server:${TAG:-2.6.0} build: # Set context to repo's root directory context: ../../ @@ -15,6 +15,9 @@ services: volumes: # Mount the prompt.yaml file to the container, path should be absolute - ${PROMPT_CONFIG_FILE}:${PROMPT_CONFIG_FILE} + # LanceDB data is shared with the ingestor-server via the `rag-vol-lancedb` + # named Docker volume (auto-created on first `docker compose up`). + - rag-vol-lancedb:/volumes/lancedb # Common customizations to the pipeline can be controlled using env variables environment: # Path to example directory relative to root @@ -23,18 +26,18 @@ services: # Absolute path to custom prompt.yaml file PROMPT_CONFIG_FILE: ${PROMPT_CONFIG_FILE:-/prompt.yaml} - ##===MINIO specific configurations which is used to store the multimodal base64 content=== - MINIO_ENDPOINT: "minio:9010" - MINIO_ACCESSKEY: "minioadmin" - MINIO_SECRETKEY: "minioadmin" + ##===Object-store configurations used to store multimodal base64 content=== + OBJECTSTORE_ENDPOINT: ${OBJECTSTORE_ENDPOINT:-seaweedfs:9010} + OBJECTSTORE_ACCESSKEY: ${OBJECTSTORE_ACCESSKEY:-seaweedfsadmin} + OBJECTSTORE_SECRETKEY: ${OBJECTSTORE_SECRETKEY:-seaweedfsadmin} ##===Vector DB specific configurations=== # URL on which vectorstore is hosted - # For custom operators, point to your service (e.g., http://your-custom-vdb:1234) - APP_VECTORSTORE_URL: ${APP_VECTORSTORE_URL:-http://milvus:19530} - # Type of vectordb used to store embedding. Supported built-ins: "milvus", "elasticsearch". + # For custom operators, point to your service (e.g., http://your-custom-vdb:1234 or /volumes/lancedb/lancedb - for lancedb) + APP_VECTORSTORE_URL: ${APP_VECTORSTORE_URL:-http://elasticsearch:9200} + # Type of vectordb used to store embedding. Supported built-ins: "elasticsearch"[default], "milvus", "lancedb". # You can also provide your custom value (e.g., "your_custom_vdb") when you register it in `_get_vdb_op`. - APP_VECTORSTORE_NAME: ${APP_VECTORSTORE_NAME:-"milvus"} + APP_VECTORSTORE_NAME: ${APP_VECTORSTORE_NAME:-"elasticsearch"} # Type of index to be used for vectorstore APP_VECTORSTORE_INDEXTYPE: ${APP_VECTORSTORE_INDEXTYPE:-"GPU_CAGRA"} @@ -47,8 +50,8 @@ services: # Weight for sparse vector search in case of "weighted" Hybrid Search APP_VECTORSTORE_SPARSE_WEIGHT: ${APP_VECTORSTORE_SPARSE_WEIGHT:-0.5} - # Boolean to control GPU search for milvus vectorstore specific to rag-server - APP_VECTORSTORE_ENABLEGPUSEARCH: ${APP_VECTORSTORE_ENABLEGPUSEARCH:-True} + # Enable GPU search. Milvus only — GPU search is not supported by Elasticsearch. + APP_VECTORSTORE_ENABLEGPUSEARCH: ${APP_VECTORSTORE_ENABLEGPUSEARCH:-False} # ef: Parameter controlling query time/accuracy trade-off. Higher ef leads to more accurate but slower search. APP_VECTORSTORE_EF: ${APP_VECTORSTORE_EF:-100} # Must be greater or equal to VECTOR_DB_TOPK # Username for vector store @@ -67,26 +70,26 @@ services: VECTOR_DB_TOPK: ${VECTOR_DB_TOPK:-100} ##===LLM Model specific configurations=== - APP_LLM_MODELNAME: ${APP_LLM_MODELNAME:-"nvidia/llama-3.3-nemotron-super-49b-v1.5"} + APP_LLM_MODELNAME: ${APP_LLM_MODELNAME:-"nvidia/nemotron-3-super-120b-a12b"} # url on which llm model is hosted. If "", Nvidia hosted API is used APP_LLM_SERVERURL: ${APP_LLM_SERVERURL-"nim-llm:8000"} # LLM model parameters - LLM_MAX_TOKENS: ${LLM_MAX_TOKENS:-32768} + LLM_MAX_TOKENS: ${LLM_MAX_TOKENS:-16256} LLM_TEMPERATURE: ${LLM_TEMPERATURE:-0} LLM_TOP_P: ${LLM_TOP_P:-1.0} - # Reasoning configuration (supported by Nemotron 3 and other reasoning models) - LLM_ENABLE_THINKING: ${LLM_ENABLE_THINKING:-false} - LLM_REASONING_BUDGET: ${LLM_REASONING_BUDGET:-0} - LLM_LOW_EFFORT: ${LLM_LOW_EFFORT:-false} + # Reasoning configuration (enabled by default for Nemotron 3 Super) + LLM_ENABLE_THINKING: ${LLM_ENABLE_THINKING:-true} + LLM_REASONING_BUDGET: ${LLM_REASONING_BUDGET:-256} + LLM_LOW_EFFORT: ${LLM_LOW_EFFORT:-true} ##===Query Rewriter Model specific configurations=== - APP_QUERYREWRITER_MODELNAME: ${APP_QUERYREWRITER_MODELNAME:-"nvidia/llama-3.3-nemotron-super-49b-v1.5"} + APP_QUERYREWRITER_MODELNAME: ${APP_QUERYREWRITER_MODELNAME:-"nvidia/nemotron-3-super-120b-a12b"} # url on which query rewriter model is hosted. If "", Nvidia hosted API is used APP_QUERYREWRITER_SERVERURL: ${APP_QUERYREWRITER_SERVERURL-"nim-llm:8000"} ##===Filter Expression Generator Model specific configurations=== - APP_FILTEREXPRESSIONGENERATOR_MODELNAME: ${APP_FILTEREXPRESSIONGENERATOR_MODELNAME:-"nvidia/llama-3.3-nemotron-super-49b-v1.5"} + APP_FILTEREXPRESSIONGENERATOR_MODELNAME: ${APP_FILTEREXPRESSIONGENERATOR_MODELNAME:-"nvidia/nemotron-3-super-120b-a12b"} # url on which filter expression generator model is hosted. If "", Nvidia hosted API is used APP_FILTEREXPRESSIONGENERATOR_SERVERURL: ${APP_FILTEREXPRESSIONGENERATOR_SERVERURL-"nim-llm:8000"} # enable filter expression generator for natural language to filter expression conversion @@ -94,20 +97,23 @@ services: ##===Embedding Model specific configurations=== # url on which embedding model is hosted. If "", Nvidia hosted API is used - APP_EMBEDDINGS_SERVERURL: ${APP_EMBEDDINGS_SERVERURL-"nemotron-embedding-ms:8000/v1"} - APP_EMBEDDINGS_MODELNAME: ${APP_EMBEDDINGS_MODELNAME:-nvidia/llama-nemotron-embed-1b-v2} + APP_EMBEDDINGS_SERVERURL: ${APP_EMBEDDINGS_SERVERURL-"nemotron-vlm-embedding-ms:8000/v1"} + APP_EMBEDDINGS_MODELNAME: ${APP_EMBEDDINGS_MODELNAME:-nvidia/llama-nemotron-embed-vl-1b-v2} + # APP_EMBEDDINGS_SERVERURL: ${APP_EMBEDDINGS_SERVERURL-"nemotron-embedding-ms:8000/v1"} + # APP_EMBEDDINGS_MODELNAME: ${APP_EMBEDDINGS_MODELNAME:-nvidia/llama-nemotron-embed-1b-v2} APP_EMBEDDINGS_DIMENSIONS: ${APP_EMBEDDINGS_DIMENSIONS:-2048} - # For VLM Embedding Model (llama-nemotron-embed-vl-1b-v2) - # APP_EMBEDDINGS_SERVERURL: ${APP_EMBEDDINGS_SERVERURL-"nemotron-vlm-embedding-ms:8000/v1"} - # APP_EMBEDDINGS_MODELNAME: ${APP_EMBEDDINGS_MODELNAME:-nvidia/llama-nemotron-embed-vl-1b-v2} ##===Reranking Model specific configurations=== # url on which ranking model is hosted. If "", Nvidia hosted API is used APP_RANKING_SERVERURL: ${APP_RANKING_SERVERURL-"nemotron-ranking-ms:8000"} APP_RANKING_MODELNAME: ${APP_RANKING_MODELNAME:-"nvidia/llama-nemotron-rerank-1b-v2"} + # APP_RANKING_SERVERURL: ${APP_RANKING_SERVERURL-"nemotron-ranking-vl-ms:8000"} + # APP_RANKING_MODELNAME: ${APP_RANKING_MODELNAME:-"nvidia/llama-nemotron-rerank-vl-1b-v2"} ENABLE_RERANKER: ${ENABLE_RERANKER:-True} # Default score threshold for filtering documents by reranker relevance (0.0 to 1.0) RERANKER_SCORE_THRESHOLD: ${RERANKER_SCORE_THRESHOLD:-${RERANKER_CONFIDENCE_THRESHOLD:-0.0}} + # When True, images from retrieved citations are included in VLM reranker passages + ENABLE_VLM_RERANKER_IMAGE_INPUT: ${ENABLE_VLM_RERANKER_IMAGE_INPUT:-False} ##===VLM Model specific configurations=== ENABLE_VLM_INFERENCE: ${ENABLE_VLM_INFERENCE:-False} @@ -121,7 +127,7 @@ services: # Enable reasoning mode for Nemotron 3 nano omni reasoning model APP_VLM_ENABLE_THINKING: ${APP_VLM_ENABLE_THINKING:-true} # Max reasoning tokens for VLM (0 = no cap); only applied when enable_thinking is true - APP_VLM_THINKING_TOKEN_BUDGET: ${APP_VLM_THINKING_TOKEN_BUDGET:-0} + APP_VLM_THINKING_TOKEN_BUDGET: ${APP_VLM_THINKING_TOKEN_BUDGET:-16384} # VLM generation parameters APP_VLM_MAX_TOKENS: ${APP_VLM_MAX_TOKENS:-32768} APP_VLM_TEMPERATURE: ${APP_VLM_TEMPERATURE:-0.6} @@ -145,6 +151,10 @@ services: APP_VLM_APIKEY: ${APP_VLM_APIKEY:-""} SUMMARY_LLM_APIKEY: ${SUMMARY_LLM_APIKEY:-""} REFLECTION_LLM_APIKEY: ${REFLECTION_LLM_APIKEY:-""} + AGENTIC_PLANNER_LLM_APIKEY: ${AGENTIC_PLANNER_LLM_APIKEY:-${APP_LLM_APIKEY:-""}} + AGENTIC_TASK_LLM_APIKEY: ${AGENTIC_TASK_LLM_APIKEY:-${APP_LLM_APIKEY:-""}} + AGENTIC_SEED_GEN_LLM_APIKEY: ${AGENTIC_SEED_GEN_LLM_APIKEY:-${APP_LLM_APIKEY:-""}} + AGENTIC_SYNTHESIS_LLM_APIKEY: ${AGENTIC_SYNTHESIS_LLM_APIKEY:-${APP_LLM_APIKEY:-""}} # Number of document chunks to insert in LLM prompt, used only when ENABLE_RERANKER is set to True APP_RETRIEVER_TOPK: ${APP_RETRIEVER_TOPK:-10} @@ -195,7 +205,7 @@ services: # Minimum groundedness score threshold (0-2) RESPONSE_GROUNDEDNESS_THRESHOLD: ${RESPONSE_GROUNDEDNESS_THRESHOLD:-1} # reflection llm - REFLECTION_LLM: ${REFLECTION_LLM:-"nvidia/llama-3.3-nemotron-super-49b-v1.5"} + REFLECTION_LLM: ${REFLECTION_LLM:-"nvidia/nemotron-3-super-120b-a12b"} # reflection llm server url. If "", Nvidia hosted API is used REFLECTION_LLM_SERVERURL: ${REFLECTION_LLM_SERVERURL-"nim-llm:8000"} # enable iterative query decomposition @@ -208,6 +218,38 @@ services: REDIS_PORT: ${REDIS_PORT:-6379} REDIS_DB: ${REDIS_DB:-0} + # === Agentic RAG (LangGraph plan-and-execute pipeline) === + ENABLE_AGENTIC_RAG: ${ENABLE_AGENTIC_RAG:-false} + + # Per-role agentic LLM envs fall back through APP_LLM_* so a single + # APP_LLM_MODELNAME / APP_LLM_SERVERURL override propagates to all four + # roles unless a role-specific value is set. Runtime model / llm_endpoint + # in the /generate request overrides everything below. + ##===Agentic Planner LLM configurations=== + AGENTIC_PLANNER_LLM_SERVERURL: ${AGENTIC_PLANNER_LLM_SERVERURL-${APP_LLM_SERVERURL-"nim-llm:8000"}} + AGENTIC_PLANNER_LLM_MODEL: ${AGENTIC_PLANNER_LLM_MODEL:-${APP_LLM_MODELNAME:-"nvidia/nemotron-3-super-120b-a12b"}} + + ##===Agentic Task LLM configurations=== + AGENTIC_TASK_LLM_SERVERURL: ${AGENTIC_TASK_LLM_SERVERURL-${APP_LLM_SERVERURL-"nim-llm:8000"}} + AGENTIC_TASK_LLM_MODEL: ${AGENTIC_TASK_LLM_MODEL:-${APP_LLM_MODELNAME:-"nvidia/nemotron-3-super-120b-a12b"}} + + ##===Agentic Seed Gen LLM configurations=== + AGENTIC_SEED_GEN_LLM_SERVERURL: ${AGENTIC_SEED_GEN_LLM_SERVERURL-${APP_LLM_SERVERURL-"nim-llm:8000"}} + AGENTIC_SEED_GEN_LLM_MODEL: ${AGENTIC_SEED_GEN_LLM_MODEL:-${APP_LLM_MODELNAME:-"nvidia/nemotron-3-super-120b-a12b"}} + + ##===Agentic Synthesis LLM configurations=== + AGENTIC_SYNTHESIS_LLM_SERVERURL: ${AGENTIC_SYNTHESIS_LLM_SERVERURL-${APP_LLM_SERVERURL-"nim-llm:8000"}} + AGENTIC_SYNTHESIS_LLM_MODEL: ${AGENTIC_SYNTHESIS_LLM_MODEL:-${APP_LLM_MODELNAME:-"nvidia/nemotron-3-super-120b-a12b"}} + + # Agent behaviour tuning + AGENTIC_LOG_LEVEL: ${AGENTIC_LOG_LEVEL:-INFO} + + # Verification pass (disabled by default) + AGENTIC_VERIFICATION_ENABLED: ${AGENTIC_VERIFICATION_ENABLED:-false} + + # Context window budget for retrieved chunks + AGENTIC_CONTEXT_MAX_TOKENS: ${AGENTIC_CONTEXT_MAX_TOKENS:-100000} + ports: - "8081:8081" expose: @@ -217,7 +259,7 @@ services: # Sample UI container which interacts with APIs exposed by rag-server container rag-frontend: container_name: rag-frontend - image: nvcr.io/nvidia/blueprint/rag-frontend:${TAG:-2.5.0} + image: nvcr.io/nvidia/blueprint/rag-frontend:${TAG:-2.6.0} build: # Set context to repo's root directory context: ../../frontend @@ -226,7 +268,6 @@ services: # Environment variables for Vite build VITE_API_CHAT_URL: ${VITE_API_CHAT_URL:-http://rag-server:8081/v1} VITE_API_VDB_URL: ${VITE_API_VDB_URL:-http://ingestor-server:8082/v1} - VITE_MILVUS_URL: http://milvus:19530 DOWNLOAD_LEGAL_COMPLIANCE: ${DOWNLOAD_LEGAL_COMPLIANCE:-false} ports: - "8090:3000" @@ -236,10 +277,16 @@ services: # Runtime environment variables for Vite VITE_API_CHAT_URL: ${VITE_API_CHAT_URL:-http://rag-server:8081/v1} VITE_API_VDB_URL: ${VITE_API_VDB_URL:-http://ingestor-server:8082/v1} - VITE_MILVUS_URL: http://milvus:19530 depends_on: - rag-server +# Shared LanceDB volume — the same physical Docker volume mounted by +# docker-compose-ingestor-server.yaml. Explicit `name:` bypasses the compose +# project prefix so both files resolve to the same volume on the host. +volumes: + rag-vol-lancedb: + name: rag-vol-lancedb + networks: default: name: nvidia-rag diff --git a/deploy/compose/nemoguardrails/config-store/nemoguard/config.yml b/deploy/compose/nemoguardrails/config-store/nemoguard/config.yml index 6e37fc781..a159e9604 100644 --- a/deploy/compose/nemoguardrails/config-store/nemoguard/config.yml +++ b/deploy/compose/nemoguardrails/config-store/nemoguard/config.yml @@ -17,5 +17,7 @@ rails: - content safety check input $model=content_safety - topic safety check input $model=topic_control output: + streaming: + enabled: true flows: - - content safety check output $model=content_safety \ No newline at end of file + - content safety check output $model=content_safety diff --git a/deploy/compose/nemotron3-super-prompt.yaml b/deploy/compose/nemotron3-super-prompt.yaml index f91803927..8a550626a 100644 --- a/deploy/compose/nemotron3-super-prompt.yaml +++ b/deploy/compose/nemotron3-super-prompt.yaml @@ -128,26 +128,26 @@ reflection_groundedness_check_prompt: reflection_response_regeneration_prompt: system: | - You are tasked with creating a new "Response" based solely on the provided - "Context" and "Query". Your primary goal is to ensure strict adherence to + You are tasked with creating a new "Response" based solely on the provided + "Context" and "Query". Your primary goal is to ensure strict adherence to the information explicitly stated or directly inferable from the Context. Key Constraints: - No Outside Knowledge: Do not introduce any information, facts, or concepts + No Outside Knowledge: Do not introduce any information, facts, or concepts not present in the given Context. - No Assumptions: Do not make assumptions or extrapolate beyond what is directly + No Assumptions: Do not make assumptions or extrapolate beyond what is directly stated or clearly implied. - Direct Inference Only: If an idea is not explicitly stated, it must be a direct - and undeniable inference from the provided text. Avoid speculative or highly + Direct Inference Only: If an idea is not explicitly stated, it must be a direct + and undeniable inference from the provided text. Avoid speculative or highly interpretive conclusions. - Maintain Factual Accuracy: Ensure the Response accurately reflects the details + Maintain Factual Accuracy: Ensure the Response accurately reflects the details and relationships presented in the Context. - Return only "OUT OF CONTEXT" if the "Query" cannot be answered using the provided + Return only "OUT OF CONTEXT" if the "Query" cannot be answered using the provided "Context." Else, only output the new response with no other information. Context: {context} @@ -324,12 +324,12 @@ filter_expression_generator_prompt: query_decomposition_multiquery_prompt: system: | - You are an AI assistant designed to break down a user's complex question into a list of simpler, focused subqueries. + You are an AI assistant designed to break down a user's complex question into a list of simpler, focused subqueries. The purpose of this decomposition is to improve the accuracy of a retrieval-augmented generation (RAG) system. 1. Analyze the user's main question to identify its key components. - 2. Decompose the question into 1-3 distinct, self-contained subqueries. + 2. Decompose the question into 1-3 distinct, self-contained subqueries. 3. If the original question is simple and already focused, return query directly. 4. Each subquery should be a clear, direct question that, when answered, contributes to a comprehensive response to the original question. 5. Avoid creating redundant or overly broad subqueries. Focus on the core information needed to answer the original prompt diff --git a/deploy/compose/nims.yaml b/deploy/compose/nims.yaml index aa9043adf..006db0301 100644 --- a/deploy/compose/nims.yaml +++ b/deploy/compose/nims.yaml @@ -1,17 +1,21 @@ services: nim-llm: container_name: nim-llm-ms - image: nvcr.io/nim/nvidia/llama-3.3-nemotron-super-49b-v1.5:1.14.0 + image: nvcr.io/nim/nvidia/nemotron-3-super-120b-a12b:1.8.0 shm_size: 16GB volumes: - ${MODEL_DIRECTORY:-./}:/opt/nim/.cache - user: "${USERID}" + # TODO: Change user to "${USERID}" when fix is available in the image. + user: "0" ports: - "8999:8000" expose: - "8000" environment: NGC_API_KEY: ${NGC_API_KEY} + NIM_ENABLE_CHUNKED_PREFILL: "1" + NCCL_NVLS_ENABLE: "0" + VLLM_USE_FLASHINFER_MOE_FP8: "0" ulimits: nofile: soft: 65536 @@ -22,7 +26,7 @@ services: devices: - driver: nvidia #count: ${INFERENCE_GPU_COUNT:-all} - device_ids: ['${LLM_MS_GPU_ID:-1}'] + device_ids: ['${LLM_MS_GPU_ID:-1}', '${LLM_MS_GPU_ID2:-2}'] capabilities: [gpu] healthcheck: test: ["CMD", "python3", "-c", "import requests; requests.get('http://localhost:8000/v1/health/ready')"] @@ -59,7 +63,7 @@ services: timeout: 20s retries: 3 start_period: 10m - profiles: ["", "rag", "ingest", "text-embed", "vlm-generation"] + profiles: ["text-embed"] nemotron-vlm-embedding-ms: container_name: nemotron-vlm-embedding-ms @@ -89,7 +93,7 @@ services: timeout: 20s retries: 3 start_period: 10m - profiles: ["vlm-embed", "vlm-ingest"] + profiles: ["", "rag", "ingest", "vlm-embed", "vlm-rag", "vlm-generation"] nemotron-ranking-ms: container_name: nemotron-ranking-ms @@ -119,6 +123,35 @@ services: capabilities: [gpu] profiles: ["", "rag", "vlm-generation"] + nemotron-ranking-vl-ms: + container_name: nemotron-ranking-vl-ms + image: nvcr.io/nim/nvidia/llama-nemotron-rerank-vl-1b-v2:1.11.0 + volumes: + - ${MODEL_DIRECTORY:-./}:/opt/nim/.cache + ports: + - "1978:8000" + expose: + - "8000" + environment: + NGC_API_KEY: ${NGC_API_KEY} + user: "${USERID}" + shm_size: 16GB + deploy: + resources: + reservations: + devices: + - driver: nvidia + # count: ${INFERENCE_GPU_COUNT:-all} + device_ids: ['${RANKING_VL_MS_GPU_ID:-0}'] + capabilities: [gpu] + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/v1/health/ready"] + interval: 30s + timeout: 20s + retries: 3 + start_period: 10m + profiles: ["vlm-rerank", "vlm-rag"] + page-elements: image: ${YOLOX_IMAGE:-nvcr.io/nim/nvidia/nemotron-page-elements-v3}:${YOLOX_TAG:-1.8.0} shm_size: 16gb @@ -130,11 +163,8 @@ services: environment: - NIM_HTTP_API_PORT=8000 - NIM_TRITON_LOG_VERBOSE=1 - - NIM_TRITON_RATE_LIMIT=3 - NGC_API_KEY=${NIM_NGC_API_KEY:-${NGC_API_KEY:-ngcapikey}} - - CUDA_VISIBLE_DEVICES=0 - NIM_TRITON_MAX_BATCH_SIZE=${PAGE_ELEMENTS_BATCH_SIZE:-32} - - NIM_TRITON_CUDA_MEMORY_POOL_MB=${PAGE_ELEMENTS_CUDA_MEMORY_POOL_MB:-2048} - NIM_TRITON_CPU_THREADS_PRE_PROCESSOR=${PAGE_ELEMENTS_CPU_THREADS_PRE_PROCESSOR:-2} - NIM_TRITON_CPU_THREADS_POST_PROCESSOR=${PAGE_ELEMENTS_CPU_THREADS_POST_PROCESSOR:-1} - OMP_NUM_THREADS=2 @@ -155,7 +185,7 @@ services: - driver: nvidia device_ids: ['${YOLOX_MS_GPU_ID:-0}'] capabilities: [gpu] - profiles: ["", "ingest", "vlm-ingest"] + profiles: ["", "ingest"] graphic-elements: image: ${YOLOX_GRAPHIC_ELEMENTS_IMAGE:-nvcr.io/nim/nvidia/nemotron-graphic-elements-v1}:${YOLOX_GRAPHIC_ELEMENTS_TAG:-1.8.0} @@ -170,7 +200,6 @@ services: - NIM_TRITON_LOG_VERBOSE=1 - NIM_TRITON_RATE_LIMIT=3 - NGC_API_KEY=${NIM_NGC_API_KEY:-${NGC_API_KEY:-ngcapikey}} - - CUDA_VISIBLE_DEVICES=0 - NIM_TRITON_MAX_BATCH_SIZE=${GRAPHIC_ELEMENTS_BATCH_SIZE:-32} - NIM_TRITON_CUDA_MEMORY_POOL_MB=${GRAPHIC_ELEMENTS_CUDA_MEMORY_POOL_MB:-2048} - OMP_NUM_THREADS=1 @@ -181,7 +210,7 @@ services: - driver: nvidia device_ids: ['${YOLOX_GRAPHICS_MS_GPU_ID:-0}'] capabilities: [gpu] - profiles: ["", "ingest", "vlm-ingest"] + profiles: ["", "ingest"] table-structure: image: ${YOLOX_TABLE_STRUCTURE_IMAGE:-nvcr.io/nim/nvidia/nemotron-table-structure-v1}:${YOLOX_TABLE_STRUCTURE_TAG:-1.8.0} @@ -196,7 +225,6 @@ services: - NIM_TRITON_LOG_VERBOSE=1 - NIM_TRITON_RATE_LIMIT=3 - NGC_API_KEY=${NIM_NGC_API_KEY:-${NGC_API_KEY:-ngcapikey}} - - CUDA_VISIBLE_DEVICES=0 - NIM_TRITON_MAX_BATCH_SIZE=${TABLE_STRUCTURE_BATCH_SIZE:-32} - NIM_TRITON_CUDA_MEMORY_POOL_MB=${TABLE_STRUCTURE_CUDA_MEMORY_POOL_MB:-2048} - OMP_NUM_THREADS=1 @@ -207,7 +235,7 @@ services: - driver: nvidia device_ids: ['${YOLOX_TABLE_MS_GPU_ID:-0}'] capabilities: [gpu] - profiles: ["", "ingest", "vlm-ingest"] + profiles: ["", "ingest"] paddle: image: ${PADDLE_IMAGE:-nvcr.io/nim/baidu/paddleocr}:${PADDLE_TAG:-1.5.0} @@ -234,8 +262,8 @@ services: capabilities: [gpu] profiles: ["paddle"] - nemoretriever-ocr: - image: ${NEMORETRIEVER_OCR_IMAGE:-nvcr.io/nim/nvidia/nemoretriever-ocr-v1}:${NEMORETRIEVER_OCR_TAG:-1.2.1} + nemotron-ocr: + image: ${NEMOTRON_OCR_IMAGE:-nvcr.io/nim/nvidia/nemotron-ocr-v1}:${NEMOTRON_OCR_TAG:-1.3.0} shm_size: 16gb ports: - "8012:8000" @@ -247,9 +275,7 @@ services: - NIM_HTTP_API_PORT=8000 - NIM_TRITON_LOG_VERBOSE=1 - NGC_API_KEY=${NIM_NGC_API_KEY:-${NGC_API_KEY:-ngcapikey}} - - CUDA_VISIBLE_DEVICES=0 - NIM_TRITON_MAX_BATCH_SIZE=${OCR_BATCH_SIZE:-32} - - NIM_TRITON_ENABLE_MODEL_CONTROL=1 deploy: resources: reservations: @@ -257,7 +283,7 @@ services: - driver: nvidia device_ids: ["${OCR_MS_GPU_ID:-${PADDLE_MS_GPU_ID:-0}}"] capabilities: [gpu] - profiles: ["", "ingest", "vlm-ingest", "nemoretriever-ocr"] + profiles: ["", "ingest"] # Optional NIM microservices nemotron-parse: @@ -273,7 +299,6 @@ services: - NIM_TRITON_LOG_VERBOSE=1 - NVIDIA_API_KEY=${NGC_API_KEY:-nvidiaapikey} - NGC_API_KEY=${NGC_API_KEY:-nvidiaapikey} - - CUDA_VISIBLE_DEVICES=0 deploy: resources: reservations: @@ -304,6 +329,7 @@ services: capabilities: [gpu] profiles: ["audio"] + # Nemotron Omni — used for generation. vlm-ms: container_name: nemotron-3-nano-omni-30b-a3b-reasoning image: nvcr.io/nim/nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:1.7.0-variant @@ -318,7 +344,7 @@ services: NIM_HTTP_API_PORT: 8000 NIM_TRITON_LOG_VERBOSE: 1 CUDA_VISIBLE_DEVICES: 0 - user: "${USERID}" + user: "0" healthcheck: test: ["CMD", "python3", "-c", "import requests; requests.get('http://localhost:8000/v1/health/ready')"] interval: 10s @@ -333,7 +359,38 @@ services: # count: ${INFERENCE_GPU_COUNT:-all} device_ids: ['${VLM_MS_GPU_ID:-5}'] capabilities: [gpu] - profiles: ["vlm-only", "vlm-generation"] + profiles: ["vlm-only", "vlm-generation", "vlm-rag"] + + # Nemotron Nano 12B — used for image captioning. + vlm-captioning-ms: + container_name: nemotron-nano-12b-v2-vl + image: nvcr.io/nim/nvidia/nemotron-nano-12b-v2-vl:${VLM_CAPTIONING_TAG:-1.6.0} + volumes: + - ${MODEL_DIRECTORY:-./}:/opt/nim/.cache + ports: + - "1978:8000" + expose: + - "8000" + environment: + NGC_API_KEY: ${NGC_API_KEY} + NIM_HTTP_API_PORT: 8000 + NIM_TRITON_LOG_VERBOSE: 1 + CUDA_VISIBLE_DEVICES: 0 + user: "0" + healthcheck: + test: ["CMD", "python3", "-c", "import requests; requests.get('http://localhost:8000/v1/health/ready')"] + interval: 10s + timeout: 20s + retries: 100 + shm_size: 32GB + deploy: + resources: + reservations: + devices: + - driver: nvidia + device_ids: ['${VLM_CAPTIONING_MS_GPU_ID:-6}'] + capabilities: [gpu] + profiles: ["ingest", "vlm-rag", "vlm-generation"] networks: default: diff --git a/deploy/compose/nvdev.env b/deploy/compose/nvdev.env index 6c0fedbe2..6f8e4a3fa 100644 --- a/deploy/compose/nvdev.env +++ b/deploy/compose/nvdev.env @@ -1,6 +1,11 @@ # ==== Authentication ==== export NVIDIA_API_KEY=${NGC_API_KEY} +# ==== Object Store ==== +export OBJECTSTORE_ENDPOINT=seaweedfs:9010 +export OBJECTSTORE_ACCESSKEY=seaweedfsadmin +export OBJECTSTORE_SECRETKEY=seaweedfsadmin + # ==== Service-Specific API Keys (Optional) ==== # Set these to use different API keys for individual services. # If not set or empty, all services use NVIDIA_API_KEY as fallback. @@ -15,14 +20,15 @@ export NVIDIA_API_KEY=${NGC_API_KEY} # === Internally NVIDIA hosted NIM Endpoints (for cloud deployment) === # WAR: Use public endpoint for inference -export APP_LLM_MODELNAME=nvidia/llama-3.3-nemotron-super-49b-v1.5 +export APP_LLM_MODELNAME=nvidia/nemotron-3-super-120b-a12b +export APP_LLM_SERVERURL=https://integrate.api.nvidia.com/v1 # For nemotron-3-nano models hosted on NVIDIA cloud, use: # export APP_LLM_MODELNAME=nvidia/nemotron-3-nano-30b-a3b # Note: For locally deployed nemotron-3-nano, use: nvidia/nemotron-3-nano -export APP_FILTEREXPRESSIONGENERATOR_MODELNAME=nvidia/llama-3.3-nemotron-super-49b-v1.5 -export APP_EMBEDDINGS_MODELNAME=nvidia/llama-nemotron-embed-1b-v2 -# For VLM Embedding Model (llama-nemotron-embed-vl-1b-v2) -# export APP_EMBEDDINGS_MODELNAME=nvdev/nvidia/llama-nemotron-embed-vl-1b-v2 +export APP_FILTEREXPRESSIONGENERATOR_MODELNAME=nvidia/nemotron-3-super-120b-a12b +export APP_EMBEDDINGS_MODELNAME=nvidia/llama-nemotron-embed-vl-1b-v2 +# For text embedding model +# export APP_EMBEDDINGS_MODELNAME=nvidia/llama-nemotron-embed-1b-v2 export APP_RANKING_MODELNAME=nvidia/llama-nemotron-rerank-1b-v2 export ENABLE_RERANKER=True export APP_EMBEDDINGS_SERVERURL=https://integrate.api.nvidia.com/v1 @@ -30,23 +36,36 @@ export APP_LLM_SERVERURL="" export APP_FILTEREXPRESSIONGENERATOR_SERVERURL="" export APP_RANKING_SERVERURL="" # export APP_RANKING_SERVERURL=https://ai.api.nvidia.com/v1/retrieval/nvidia/llama-nemotron-rerank-1b-v2/reranking -export OCR_HTTP_ENDPOINT=https://ai.api.nvidia.com/v1/cv/nvidia/nemoretriever-ocr +export OCR_HTTP_ENDPOINT=https://ai.api.nvidia.com/v1/cv/nvidia/nemotron-ocr-v1 export OCR_INFER_PROTOCOL=http -export OCR_MODEL_NAME=scene_text_ensemble +export OCR_MODEL_NAME=pipeline export YOLOX_HTTP_ENDPOINT=https://ai.api.nvidia.com/v1/cv/nvidia/nemotron-page-elements-v3 export YOLOX_INFER_PROTOCOL=http export YOLOX_GRAPHIC_ELEMENTS_HTTP_ENDPOINT=https://ai.api.nvidia.com/v1/cv/nvidia/nemotron-graphic-elements-v1 export YOLOX_GRAPHIC_ELEMENTS_INFER_PROTOCOL=http export YOLOX_TABLE_STRUCTURE_HTTP_ENDPOINT=https://ai.api.nvidia.com/v1/cv/nvidia/nemotron-table-structure-v1 export YOLOX_TABLE_STRUCTURE_INFER_PROTOCOL=http -export SUMMARY_LLM="nvidia/llama-3.3-nemotron-super-49b-v1.5" -export SUMMARY_LLM_SERVERURL="" +export SUMMARY_LLM="nvidia/nemotron-3-super-120b-a12b" +export SUMMARY_LLM_SERVERURL=https://integrate.api.nvidia.com/v1 + +# == Invoke URLs for Object detection models used by Ingestor server +# Use NVIDIA hosted CV endpoints. +export APP_NVINGEST_PAGEELEMENTSURL="https://ai.api.nvidia.com/v1/cv/nvidia/nemotron-page-elements-v3" +export APP_NVINGEST_GRAPHICELEMENTSURL="https://ai.api.nvidia.com/v1/cv/nvidia/nemotron-graphic-elements-v1" +export APP_NVINGEST_OCRURL="https://ai.api.nvidia.com/v1/cv/nvidia/nemotron-ocr-v1" +export APP_NVINGEST_TABLESTRUCTUREURL="https://ai.api.nvidia.com/v1/cv/nvidia/nemotron-table-structure-v1" # Reflection feature -export REFLECTION_LLM="nvidia/llama-3.3-nemotron-super-49b-v1.5" -export REFLECTION_LLM_SERVERURL="" +export REFLECTION_LLM="nvidia/nemotron-3-super-120b-a12b" +export REFLECTION_LLM_SERVERURL=https://integrate.api.nvidia.com/v1 # export ENABLE_REFLECTION="True" # Uncomment to enable reflection +# Reasoning / Thinking (recommended for Nemotron 3 Super) +export LLM_ENABLE_THINKING=true +export LLM_REASONING_BUDGET=256 +export LLM_LOW_EFFORT=true +export FILTER_THINK_TOKENS=true + # To avoid OOM issues dev systems export NV_INGEST_MAX_UTIL=8 @@ -56,14 +75,16 @@ export NV_INGEST_MAX_UTIL=8 # export NEMOTRON_PARSE_INFER_PROTOCOL=http # Uncomment to use NEMOTRON_PARSE protocol # export APP_NVINGEST_PDFEXTRACTMETHOD=nemotron_parse -# VLM generation feature +# VLM generation feature — Nemotron Omni (chat / RAG answering) export APP_VLM_SERVERURL="https://integrate.api.nvidia.com/v1" export APP_VLM_MODELNAME="nvidia/nemotron-3-nano-omni-30b-a3b-reasoning" # export ENABLE_VLM_INFERENCE="true" # Uncomment to use VLM generation # Image captioning feature -export APP_NVINGEST_CAPTIONMODELNAME="nvidia/nemotron-3-nano-omni-30b-a3b-reasoning" +export APP_NVINGEST_CAPTIONMODELNAME="nvidia/nemotron-nano-12b-v2-vl" export APP_NVINGEST_CAPTIONENDPOINTURL="https://integrate.api.nvidia.com/v1/chat/completions" +export VLM_CAPTION_MODEL_NAME="nvidia/nemotron-nano-12b-v2-vl" +export VLM_CAPTION_ENDPOINT="https://integrate.api.nvidia.com/v1/chat/completions" # export APP_NVINGEST_EXTRACTIMAGES="True" # Uncomment to use image captioning # Nemoguardrails feature @@ -73,6 +94,55 @@ export NIM_ENDPOINT_URL=https://integrate.api.nvidia.com/v1 # Query rewriter feature export APP_QUERYREWRITER_SERVERURL="" -export APP_QUERYREWRITER_MODELNAME="nvidia/llama-3.3-nemotron-super-49b-v1.5" +export APP_QUERYREWRITER_MODELNAME="nvidia/nemotron-3-super-120b-a12b" # export ENABLE_QUERYREWRITER="True" # Uncomment to enable query rewriting -# export MULTITURN_RETRIEVER_SIMPLE="True" # Enable/disable concatenating conversation history with query for retrieval (when query rewriter is disabled, default: False) \ No newline at end of file +# export MULTITURN_RETRIEVER_SIMPLE="True" # Enable/disable concatenating conversation history with query for retrieval (when query rewriter is disabled, default: False) + +# Agentic RAG feature (LangGraph plan-and-execute pipeline) +# Uncomment ENABLE_AGENTIC_RAG to route knowledge-base queries through the agentic pipeline. +# When enabled, each query is broken into a multi-step plan; sub-questions are answered +# via iterative retrieval and a final synthesis LLM produces the response. + +# Per-role LLM configuration (all four roles use the same model in the reference config). +# Set different values per role to use a smaller/cheaper model for non-critical roles. +export AGENTIC_PLANNER_LLM_SERVERURL=https://integrate.api.nvidia.com/v1 +export AGENTIC_PLANNER_LLM_MODEL=nvidia/nemotron-3-super-120b-a12b +export AGENTIC_TASK_LLM_SERVERURL=https://integrate.api.nvidia.com/v1 +export AGENTIC_TASK_LLM_MODEL=nvidia/nemotron-3-super-120b-a12b +export AGENTIC_SEED_GEN_LLM_SERVERURL=https://integrate.api.nvidia.com/v1 +export AGENTIC_SEED_GEN_LLM_MODEL=nvidia/nemotron-3-super-120b-a12b +export AGENTIC_SYNTHESIS_LLM_SERVERURL=https://integrate.api.nvidia.com/v1 +export AGENTIC_SYNTHESIS_LLM_MODEL=nvidia/nemotron-3-super-120b-a12b +# Per-role API key overrides (default: inherits NVIDIA_API_KEY) +# export AGENTIC_PLANNER_LLM_APIKEY="" +# export AGENTIC_TASK_LLM_APIKEY="" +# export AGENTIC_SEED_GEN_LLM_APIKEY="" +# export AGENTIC_SYNTHESIS_LLM_APIKEY="" + +# Per-role generation parameter overrides. Uncomment to override the built-in +# defaults shown below. Request-time values passed to /generate take precedence +# over both env vars and the defaults. +# Built-in defaults (applied automatically when unset): +# planner / seed_gen → temperature=0.1, top_p=1.0, max_tokens=32768 +# task / synthesis → temperature=0.0, top_p=1.0, max_tokens=32768 +# export AGENTIC_PLANNER_LLM_TEMPERATURE=0.1 +# export AGENTIC_PLANNER_LLM_TOP_P=1.0 +# export AGENTIC_PLANNER_LLM_MAX_TOKENS=32768 +# export AGENTIC_TASK_LLM_TEMPERATURE=0.0 +# export AGENTIC_TASK_LLM_TOP_P=1.0 +# export AGENTIC_TASK_LLM_MAX_TOKENS=32768 +# export AGENTIC_SEED_GEN_LLM_TEMPERATURE=0.1 +# export AGENTIC_SEED_GEN_LLM_TOP_P=1.0 +# export AGENTIC_SEED_GEN_LLM_MAX_TOKENS=32768 +# export AGENTIC_SYNTHESIS_LLM_TEMPERATURE=0.0 +# export AGENTIC_SYNTHESIS_LLM_TOP_P=1.0 +# export AGENTIC_SYNTHESIS_LLM_MAX_TOKENS=32768 + +# Agent behaviour tuning (values match reference config defaults) +export AGENTIC_LOG_LEVEL=INFO + +# Verification pass (disabled by default; enable for higher-accuracy at extra cost) +export AGENTIC_VERIFICATION_ENABLED=false + +# Context window budget for retrieved chunks passed to the agent +export AGENTIC_CONTEXT_MAX_TOKENS=100000 diff --git a/deploy/compose/perf_profile.env b/deploy/compose/perf_profile.env index 952ff749a..8405b455d 100644 --- a/deploy/compose/perf_profile.env +++ b/deploy/compose/perf_profile.env @@ -1,4 +1,4 @@ -export APP_NVINGEST_ENABLEPDFSPLITTER=True +export APP_NVINGEST_ENABLE_PAGED_DOC_SPLIT=True export APP_NVINGEST_CHUNKSIZE=512 export APP_NVINGEST_CHUNKOVERLAP=150 export ENABLE_RERANKER=False diff --git a/deploy/compose/seaweedfs-config/s3.json b/deploy/compose/seaweedfs-config/s3.json new file mode 100644 index 000000000..e1e58071b --- /dev/null +++ b/deploy/compose/seaweedfs-config/s3.json @@ -0,0 +1,20 @@ +{ + "identities": [ + { + "name": "default", + "credentials": [ + { + "accessKey": "seaweedfsadmin", + "secretKey": "seaweedfsadmin" + } + ], + "actions": [ + "Admin", + "Read", + "Write", + "List", + "Tagging" + ] + } + ] +} diff --git a/deploy/compose/vectordb.yaml b/deploy/compose/vectordb.yaml index 651f85ddc..2c25995c9 100644 --- a/deploy/compose/vectordb.yaml +++ b/deploy/compose/vectordb.yaml @@ -6,16 +6,16 @@ services: image: milvusdb/milvus:${MILVUS_VERSION:-v2.6.5-gpu} # milvusdb/milvus:v2.6.5 for CPU command: > bash -lc 'tmpfile=$(mktemp) && - sed "s/bucketName: a-bucket/bucketName: ${MINIO_BUCKET:-${NVINGEST_MINIO_BUCKET:-nv-ingest}}/" /milvus/configs/milvus.yaml > "$$tmpfile" && + sed -e "s/bucketName: a-bucket/bucketName: ${NVINGEST_OBJECTSTORE_BUCKET:-nv-ingest}/" -e "s/accessKeyID: minioadmin/accessKeyID: ${OBJECTSTORE_ACCESSKEY:-seaweedfsadmin}/" -e "s/secretAccessKey: minioadmin/secretAccessKey: ${OBJECTSTORE_SECRETKEY:-seaweedfsadmin}/" /milvus/configs/milvus.yaml > "$$tmpfile" && cat "$$tmpfile" > /milvus/configs/milvus.yaml && rm "$$tmpfile" && milvus run standalone' environment: ETCD_ENDPOINTS: etcd:2379 - MINIO_ADDRESS: minio:9010 + MINIO_ADDRESS: seaweedfs:9010 KNOWHERE_GPU_MEM_POOL_SIZE: 2048;4096 volumes: - - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/milvus:/var/lib/milvus + - rag-vol-milvus:/var/lib/milvus # Uncomment below line to mount the configuration file # - ${MILVUS_CONFIG_FILE:-./milvus.yaml}:/milvus/configs/milvus.yaml healthcheck: @@ -29,7 +29,7 @@ services: - "9091:9091" depends_on: - "etcd" - - "minio" + - "seaweedfs" # Comment out this section if CPU based image is used and set below env variables to False # export APP_VECTORSTORE_ENABLEGPUSEARCH=False # export APP_VECTORSTORE_ENABLEGPUINDEX=False @@ -41,7 +41,7 @@ services: capabilities: ["gpu"] # count: ${INFERENCE_GPU_COUNT:-all} device_ids: ['${VECTORSTORE_GPU_DEVICE_ID:-0}'] - profiles: ["", "milvus"] + profiles: ["milvus"] etcd: container_name: milvus-etcd @@ -52,42 +52,48 @@ services: - ETCD_QUOTA_BACKEND_BYTES=4294967296 - ETCD_SNAPSHOT_COUNT=50000 volumes: - - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/etcd:/etcd + - rag-vol-etcd:/etcd command: etcd -advertise-client-urls=http://127.0.0.1:2379 -listen-client-urls http://0.0.0.0:2379 --data-dir /etcd healthcheck: test: ["CMD", "etcdctl", "endpoint", "health"] interval: 30s timeout: 20s retries: 3 - profiles: ["", "milvus"] + profiles: ["milvus"] - minio: - container_name: milvus-minio - image: minio/minio:RELEASE.2025-09-07T16-13-09Z + seaweedfs: + container_name: seaweedfs + image: chrislusf/seaweedfs:3.73 environment: - MINIO_ACCESS_KEY: minioadmin - MINIO_SECRET_KEY: minioadmin + MINIO_ACCESS_KEY: ${OBJECTSTORE_ACCESSKEY:-seaweedfsadmin} + MINIO_SECRET_KEY: ${OBJECTSTORE_SECRETKEY:-seaweedfsadmin} ports: - "9011:9011" - "9010:9010" volumes: - - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/minio:/minio_data - command: minio server /minio_data --console-address ":9011" --address ":9010" + - rag-vol-seaweedfs:/data + - ./seaweedfs-config/s3.json:/etc/seaweedfs/s3.json:ro + command: + - server + - -dir=/data + - -s3 + - -s3.port=9010 + - -s3.config=/etc/seaweedfs/s3.json + - -master.volumeSizeLimitMB=1024 healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:9010/minio/health/live"] + test: ["CMD-SHELL", "nc -z 127.0.0.1 9010"] interval: 30s timeout: 20s retries: 3 - profiles: ["", "milvus", "elasticsearch", "minio"] + profiles: ["", "milvus", "elasticsearch", "seaweedfs"] elasticsearch: container_name: elasticsearch - image: "docker.elastic.co/elasticsearch/elasticsearch:9.0.3" + image: "docker.elastic.co/elasticsearch/elasticsearch:9.3.0" ports: - 9200:9200 volumes: - # Run "sudo chown -R 1000:1000 deploy/compose/volumes/elasticsearch/" to fix permissions - - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/elasticsearch:/usr/share/elasticsearch/data + - rag-vol-elasticsearch:/usr/share/elasticsearch/data restart: on-failure environment: - discovery.type=single-node @@ -96,6 +102,8 @@ services: - xpack.license.self_generated.type=basic - network.host=0.0.0.0 - cluster.routing.allocation.disk.threshold_enabled=false + # Uncomment this line to use GPU for Elasticsearch + # - "vectors.indexing.use_gpu=true" # Uncomment the line below to use the username and password environment variables # - ELASTIC_USERNAME=${APP_VECTORSTORE_USERNAME} # - ELASTIC_PASSWORD=${APP_VECTORSTORE_PASSWORD} @@ -107,8 +115,33 @@ services: interval: 10s timeout: 1s retries: 10 - profiles: ["elasticsearch"] + # Uncomment the lines below to use GPU for Elasticsearch + # deploy: + # resources: + # reservations: + # devices: + # - driver: nvidia + # capabilities: ["gpu"] + # # count: ${INFERENCE_GPU_COUNT:-all} + # device_ids: ['${VECTORSTORE_GPU_DEVICE_ID:-0}'] + profiles: ["", "elasticsearch"] + +# Per-service named Docker volumes — one per persisted data dir, all sharing the +# `rag-vol-` prefix so they group cleanly under `docker volume ls --filter name=rag-vol-`. +# These replace the legacy `deploy/compose/volumes/` host directory tree. Docker +# auto-creates each volume on first `docker compose up`, so no host setup is needed. +# See docs/troubleshooting.md ("Manage Persistent Data Volumes") for inspection, +# backup, reset, and migration commands. +volumes: + rag-vol-milvus: + name: rag-vol-milvus + rag-vol-etcd: + name: rag-vol-etcd + rag-vol-seaweedfs: + name: rag-vol-seaweedfs + rag-vol-elasticsearch: + name: rag-vol-elasticsearch networks: default: - name: nvidia-rag \ No newline at end of file + name: nvidia-rag diff --git a/deploy/config/agentic-rag-metrics-dashboard.json b/deploy/config/agentic-rag-metrics-dashboard.json new file mode 100644 index 000000000..e8fcd116b --- /dev/null +++ b/deploy/config/agentic-rag-metrics-dashboard.json @@ -0,0 +1,577 @@ +{ + "title": "Agentic RAG Metrics", + "uid": "agentic-rag-metrics", + "version": 1, + "schemaVersion": 40, + "editable": false, + "time": { + "from": "now-5m", + "to": "now" + }, + "timezone": "browser", + "refresh": "30s", + "panels": [ + { + "type": "row", + "title": "Overview", + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + }, + "collapsed": false, + "panels": [] + }, + { + "type": "stat", + "title": "Agentic Reqs/sec", + "description": "", + "gridPos": { + "h": 4, + "w": 6, + "x": 0, + "y": 1 + }, + "datasource": "$datasource", + "fieldConfig": { + "defaults": { + "unit": "short", + "decimals": 2 + }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "expr": "sum(rate(agentic_requests_total{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))" + } + ], + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + } + } + }, + { + "type": "stat", + "title": "Error rate (%)", + "description": "", + "gridPos": { + "h": 4, + "w": 6, + "x": 6, + "y": 1 + }, + "datasource": "$datasource", + "fieldConfig": { + "defaults": { + "unit": "percent", + "decimals": 2 + }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "expr": "100 * sum(rate(agentic_requests_total{job=~\"$job\", instance=~\"$instance\", status=\"error\"}[$__rate_interval])) / sum(rate(agentic_requests_total{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))" + } + ], + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + } + } + }, + { + "type": "stat", + "title": "Avg Request Duration (ms)", + "description": "", + "gridPos": { + "h": 4, + "w": 6, + "x": 12, + "y": 1 + }, + "datasource": "$datasource", + "fieldConfig": { + "defaults": { + "unit": "ms", + "decimals": 2 + }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "expr": "sum(increase(agentic_request_duration_ms_sum{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval])) / sum(increase(agentic_request_duration_ms_count{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))" + } + ], + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + } + } + }, + { + "type": "stat", + "title": "P95 Request Duration (ms)", + "description": "", + "gridPos": { + "h": 4, + "w": 6, + "x": 18, + "y": 1 + }, + "datasource": "$datasource", + "fieldConfig": { + "defaults": { + "unit": "ms", + "decimals": 2 + }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "expr": "histogram_quantile(0.95, sum by (le) (rate(agentic_request_duration_ms_bucket{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval])))" + } + ], + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + } + } + }, + { + "type": "row", + "title": "Stage Latency", + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 5 + }, + "collapsed": false, + "panels": [] + }, + { + "type": "timeseries", + "title": "Avg stage duration (ms)", + "description": "", + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 6 + }, + "datasource": "$datasource", + "fieldConfig": { + "defaults": { + "unit": "ms", + "decimals": 2 + }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "expr": "sum by (stage) (increase(agentic_stage_duration_ms_sum{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval])) / sum by (stage) (increase(agentic_stage_duration_ms_count{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "legendFormat": "{{stage}}" + } + ], + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom" + } + } + }, + { + "type": "timeseries", + "title": "P95 stage duration (ms)", + "description": "", + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 6 + }, + "datasource": "$datasource", + "fieldConfig": { + "defaults": { + "unit": "ms", + "decimals": 2 + }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "expr": "histogram_quantile(0.95, sum by (stage, le) (rate(agentic_stage_duration_ms_bucket{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval])))", + "legendFormat": "{{stage}}" + } + ], + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom" + } + } + }, + { + "type": "row", + "title": "LLM Cost", + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 14 + }, + "collapsed": false, + "panels": [] + }, + { + "type": "stat", + "title": "Avg. LLM calls / request", + "description": "", + "gridPos": { + "h": 4, + "w": 8, + "x": 0, + "y": 15 + }, + "datasource": "$datasource", + "fieldConfig": { + "defaults": { + "unit": "short", + "decimals": 2 + }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "expr": "sum(increase(agentic_llm_calls_total{job=~\"$job\", instance=~\"$instance\"}[$__range])) / sum(increase(agentic_requests_total{job=~\"$job\", instance=~\"$instance\"}[$__range]))" + } + ], + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + } + } + }, + { + "type": "stat", + "title": "Total Input token usage", + "description": "", + "gridPos": { + "h": 4, + "w": 8, + "x": 8, + "y": 15 + }, + "datasource": "$datasource", + "fieldConfig": { + "defaults": { + "unit": "short", + "decimals": 2 + }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "expr": "sum(increase(agentic_llm_tokens_total{type=\"input\", job=~\"$job\", instance=~\"$instance\"}[$__range]))" + } + ], + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + } + } + }, + { + "type": "stat", + "title": "Total output token usage", + "description": "", + "gridPos": { + "h": 4, + "w": 8, + "x": 16, + "y": 15 + }, + "datasource": "$datasource", + "fieldConfig": { + "defaults": { + "unit": "short", + "decimals": 2 + }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "expr": "sum(increase(agentic_llm_tokens_total{type=\"output\", job=~\"$job\", instance=~\"$instance\"}[$__range]))" + } + ], + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + } + } + }, + { + "type": "timeseries", + "title": "LLM calls/min by role", + "description": "", + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 19 + }, + "datasource": "$datasource", + "fieldConfig": { + "defaults": { + "unit": "short", + "decimals": 2 + }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "expr": "60 * sum by (role) (rate(agentic_llm_calls_total{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "legendFormat": "{{role}}" + } + ], + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom" + } + } + }, + { + "type": "timeseries", + "title": "Token rate by role/type", + "description": "", + "gridPos": { + "h": 8, + "w": 8, + "x": 8, + "y": 19 + }, + "datasource": "$datasource", + "fieldConfig": { + "defaults": { + "unit": "short", + "decimals": 2 + }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "expr": "sum by (role, type) (rate(agentic_llm_tokens_total{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "legendFormat": "{{role}} {{type}}" + } + ], + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom" + } + } + }, + { + "type": "timeseries", + "title": "Avg LLM call duration (ms)", + "description": "", + "gridPos": { + "h": 8, + "w": 8, + "x": 16, + "y": 19 + }, + "datasource": "$datasource", + "fieldConfig": { + "defaults": { + "unit": "ms", + "decimals": 2 + }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "expr": "sum by (role) (increase(agentic_llm_call_duration_ms_sum{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval])) / sum by (role) (increase(agentic_llm_call_duration_ms_count{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "legendFormat": "{{role}}" + } + ], + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom" + } + } + }, + { + "type": "row", + "title": "Retrieval", + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 27 + }, + "collapsed": false, + "panels": [] + }, + { + "type": "timeseries", + "title": "Retrieval calls/min by stage", + "description": "", + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 28 + }, + "datasource": "$datasource", + "fieldConfig": { + "defaults": { + "unit": "short", + "decimals": 2 + }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "expr": "60 * sum by (stage) (rate(agentic_retrieval_calls_total{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "legendFormat": "{{stage}}" + } + ], + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom" + } + } + }, + { + "type": "timeseries", + "title": "Avg retrieved chunks by stage", + "description": "", + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 28 + }, + "datasource": "$datasource", + "fieldConfig": { + "defaults": { + "unit": "short", + "decimals": 2 + }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "expr": "sum by (stage) (increase(agentic_retrieved_chunks_sum{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval])) / sum by (stage) (increase(agentic_retrieved_chunks_count{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "legendFormat": "{{stage}}" + } + ], + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom" + } + } + } + ], + "templating": { + "list": [ + { + "name": "datasource", + "type": "datasource", + "label": "Data source", + "query": "prometheus", + "refresh": 1, + "current": { + "text": "Prometheus", + "value": "Prometheus" + }, + "options": [] + }, + { + "name": "job", + "type": "query", + "label": "Job", + "datasource": "$datasource", + "refresh": 2, + "hide": 0, + "includeAll": true, + "allValue": ".+", + "multi": true, + "definition": "label_values(up, job)", + "query": { + "query": "label_values(up, job)", + "refId": "StandardVariableQuery" + } + }, + { + "name": "instance", + "type": "query", + "label": "Instance", + "datasource": "$datasource", + "refresh": 2, + "hide": 0, + "includeAll": true, + "allValue": ".+", + "multi": true, + "definition": "label_values(up{job=~\"$job\"}, instance)", + "query": { + "query": "label_values(up{job=~\"$job\"}, instance)", + "refId": "StandardVariableQuery" + } + } + ] + }, + "annotations": { + "list": [] + }, + "links": [] +} diff --git a/deploy/helm/mig-slicing/mig-config-h100.yaml b/deploy/helm/mig-slicing/mig-config-h100.yaml index 0bfa7693b..0e157f73d 100644 --- a/deploy/helm/mig-slicing/mig-config-h100.yaml +++ b/deploy/helm/mig-slicing/mig-config-h100.yaml @@ -9,18 +9,28 @@ data: all-disabled: - devices: all mig-enabled: false - - custom-7x1g10-2x1g20-1x3g40-1x7g80: - - devices: [0] - mig-enabled: true - mig-devices: - "1g.10gb": 7 - - devices: [1] + + # RAG MIG layout for a 5×H100 80GB node. + # GPUs 0,1 are MIG-disabled and pass through as full devices so that + # nim-llm (nemotron-3-super-120b-a12b, vLLM + tensorParallelism=2) gets + # two physical GPUs with full NVLink for NCCL collectives. + # GPU 3 is MIG-disabled and dedicated to the embedding-VLM NIM, which + # benefits from a full H100 for higher throughput on the vision tower. + # GPU 2 is MIG-sliced for OCR + the small ingest NIMs. + # GPU 4 is MIG-sliced for the reranker (with spare slices reserved + # for future workloads). + custom-h100-5gpu-llm2full-embed1full: + - devices: [0, 1] + mig-enabled: false + - devices: [2] mig-enabled: true mig-devices: - "1g.20gb": 2 "3g.40gb": 1 + "1g.10gb": 4 - devices: [3] + mig-enabled: false + - devices: [4] mig-enabled: true mig-devices: - "7g.80gb": 1 \ No newline at end of file + "3g.40gb": 1 + "1g.20gb": 2 diff --git a/deploy/helm/mig-slicing/mig-config-rtx6000.yaml b/deploy/helm/mig-slicing/mig-config-rtx6000.yaml index 14272b497..6fb36930b 100644 --- a/deploy/helm/mig-slicing/mig-config-rtx6000.yaml +++ b/deploy/helm/mig-slicing/mig-config-rtx6000.yaml @@ -9,18 +9,21 @@ data: all-disabled: - devices: all mig-enabled: false - - custom-rtx6000-4x1g24-2x1g24-1x2g48-1x4g96: - - devices: [0] - mig-enabled: true - mig-devices: - "1g.24gb": 4 - - devices: [1] + + # RAG MIG layout for a 4×RTX PRO 6000 Blackwell SE 96GB node. + # GPUs 0,1 are MIG-disabled and pass through as full devices so that + # nim-llm (nemotron-3-super-120b-a12b, vLLM + tensorParallelism=2) gets + # two physical GPUs. GPUs 2,3 are MIG-sliced for the smaller NIMs. + # NOTE: not verified on hardware — logical update mirroring the H100 layout. + custom-rtx6000-llm2full-1x2g48-2x1g24-4x1g24: + - devices: [0, 1] + mig-enabled: false + - devices: [2] mig-enabled: true mig-devices: - "1g.24gb": 2 "2g.48gb": 1 - - devices: [2] + "1g.24gb": 2 + - devices: [3] mig-enabled: true mig-devices: - "4g.96gb": 1 + "1g.24gb": 4 diff --git a/deploy/helm/mig-slicing/values-mig-h100.yaml b/deploy/helm/mig-slicing/values-mig-h100.yaml index 69bbe0742..d83a92812 100644 --- a/deploy/helm/mig-slicing/values-mig-h100.yaml +++ b/deploy/helm/mig-slicing/values-mig-h100.yaml @@ -1,7 +1,16 @@ -# MIG-optimized resource configuration for RAG Blueprint -# This file only overrides GPU resource requirements to use MIG slices +# MIG-optimized resource configuration for RAG Blueprint on NVIDIA H100 80GB. +# Pairs with mig-config-h100.yaml (profile: custom-h100-5gpu-llm2full-embed1full). +# +# GPU layout (5×H100 node): +# GPU 0,1 — MIG disabled, full devices → nim-llm (nemotron-3-super-120b-a12b, vLLM tp=2) +# GPU 2 — 1× 3g.40gb + 4× 1g.10gb → OCR + (graphic, page, table, spare) +# GPU 3 — MIG disabled, full device → embedding-VLM +# GPU 4 — 1× 3g.40gb + 2× 1g.20gb → rerank (+ spare 3g.40gb and 1g.20gb) +# +# Requires GPU Operator mig-strategy=mixed so the node advertises both +# nvidia.com/gpu and nvidia.com/mig-* resources simultaneously. -# Ingestor Server - reduce concurrency for MIG +# Ingestor Server — reduce concurrency for MIG ingestor-server: envVars: NV_INGEST_FILES_PER_BATCH: "4" @@ -13,33 +22,22 @@ nv-ingest: NV_INGEST_MAX_UTIL: 8 MAX_INGEST_PROCESS_WORKERS: 2 - # Milvus - uses 1g.10gb MIG slice - milvus: - standalone: - resources: - limits: - nvidia.com/gpu: "0" - nvidia.com/mig-1g.10gb: 1 - requests: - nvidia.com/gpu: "0" - nvidia.com/mig-1g.10gb: 1 - # NV-Ingest NIM Operator overrides nimOperator: - # Page Elements - uses 1g.10gb - page_elements: + # OCR — uses 3g.40gb on GPU 2 + ocr: resources: limits: nvidia.com/gpu: "0" - nvidia.com/mig-1g.10gb: 1 + nvidia.com/mig-3g.40gb: 1 requests: nvidia.com/gpu: "0" - nvidia.com/mig-1g.10gb: 1 + nvidia.com/mig-3g.40gb: 1 storage: pvc: storageClass: "" - # Graphic Elements - uses 1g.10gb + # Graphic Elements — uses 1g.10gb on GPU 2 graphic_elements: resources: limits: @@ -52,8 +50,8 @@ nv-ingest: pvc: storageClass: "" - # Table Structure - uses 1g.10gb - table_structure: + # Page Elements — uses 1g.10gb on GPU 2 + page_elements: resources: limits: nvidia.com/gpu: "0" @@ -65,45 +63,48 @@ nv-ingest: pvc: storageClass: "" - # OCR - uses 3g.40gb (larger slice) - nemoretriever_ocr_v1: + # Table Structure — uses 1g.10gb on GPU 2 + table_structure: resources: limits: nvidia.com/gpu: "0" - nvidia.com/mig-3g.40gb: 1 + nvidia.com/mig-1g.10gb: 1 requests: nvidia.com/gpu: "0" - nvidia.com/mig-3g.40gb: 1 + nvidia.com/mig-1g.10gb: 1 storage: pvc: storageClass: "" + # Main NIM Operator overrides for MIG nimOperator: - # LLM - uses 7g.80gb + # LLM — 2 FULL H100 GPUs (no MIG). + # nemotron-3-super-120b-a12b runs with vLLM and tensorParallelism=2, + # which requires two physical GPUs with NVLink for NCCL collectives. nim-llm: resources: limits: - nvidia.com/gpu: "0" - nvidia.com/mig-7g.80gb: 1 + nvidia.com/gpu: 2 requests: - nvidia.com/gpu: "0" - nvidia.com/mig-7g.80gb: 1 - storage: - pvc: - storageClass: "" - # Embedding - uses 1g.10gb - nvidia-nim-llama-32-nv-embedqa-1b-v2: + nvidia.com/gpu: 2 + storage: + pvc: + storageClass: "" + + # VLM Embedding (default) — 1 FULL H100 GPU (no MIG) on GPU 3. + # Promoted to a full device so the vision tower has unrestricted compute + # and memory bandwidth, improving end-to-end embedding throughput. + nvidia-nim-llama-nemotron-embed-vl-1b-v2: resources: limits: - nvidia.com/gpu: "0" - nvidia.com/mig-1g.10gb: 1 + nvidia.com/gpu: 1 requests: - nvidia.com/gpu: "0" - nvidia.com/mig-1g.10gb: 1 - storage: - pvc: - storageClass: "" - # Reranking - uses 1g.20gb + nvidia.com/gpu: 1 + storage: + pvc: + storageClass: "" + + # Reranking — uses 1g.20gb on GPU 4. nvidia-nim-llama-32-nv-rerankqa-1b-v2: resources: limits: @@ -112,6 +113,6 @@ nimOperator: requests: nvidia.com/gpu: "0" nvidia.com/mig-1g.20gb: 1 - storage: - pvc: - storageClass: "" + storage: + pvc: + storageClass: "" diff --git a/deploy/helm/mig-slicing/values-mig-rtx6000.yaml b/deploy/helm/mig-slicing/values-mig-rtx6000.yaml index e7ae285da..c9c3c9409 100644 --- a/deploy/helm/mig-slicing/values-mig-rtx6000.yaml +++ b/deploy/helm/mig-slicing/values-mig-rtx6000.yaml @@ -1,35 +1,43 @@ -# MIG-optimized resource configuration for RAG Blueprint -# This file only overrides GPU resource requirements to use MIG slices +# MIG-optimized resource configuration for RAG Blueprint on NVIDIA RTX PRO 6000 Blackwell SE 96GB. +# Pairs with mig-config-rtx6000.yaml (profile: custom-rtx6000-llm2full-1x2g48-2x1g24-4x1g24). +# +# GPU layout (4×RTX PRO 6000 node): +# GPU 0,1 — MIG disabled, full devices → nim-llm (nemotron-3-super-120b-a12b, vLLM tp=2) +# GPU 2 — 1× 2g.48gb + 2× 1g.24gb → OCR + (graphic, page) +# GPU 3 — 4× 1g.24gb → table, embedding-VLM, rerank, spare +# +# Requires GPU Operator mig-strategy=mixed so the node advertises both +# nvidia.com/gpu and nvidia.com/mig-* resources simultaneously. +# + +# Ingestor Server — reduce concurrency for MIG +ingestor-server: + envVars: + NV_INGEST_FILES_PER_BATCH: "4" + NV_INGEST_CONCURRENT_BATCHES: "1" # NV-Ingest configuration nv-ingest: - # Milvus - uses 1g.24gb MIG slice - milvus: - standalone: - resources: - limits: - nvidia.com/gpu: "0" - nvidia.com/mig-1g.24gb: 1 - requests: - nvidia.com/gpu: "0" - nvidia.com/mig-1g.24gb: 1 + envVars: + NV_INGEST_MAX_UTIL: 8 + MAX_INGEST_PROCESS_WORKERS: 2 # NV-Ingest NIM Operator overrides nimOperator: - # Page Elements - uses 1g.24gb - page_elements: + # OCR — uses 2g.48gb on GPU 2 + ocr: resources: limits: nvidia.com/gpu: "0" - nvidia.com/mig-1g.24gb: 1 + nvidia.com/mig-2g.48gb: 1 requests: nvidia.com/gpu: "0" - nvidia.com/mig-1g.24gb: 1 + nvidia.com/mig-2g.48gb: 1 storage: pvc: storageClass: "" - # Graphic Elements - uses 1g.24gb + # Graphic Elements — uses 1g.24gb on GPU 2 graphic_elements: resources: limits: @@ -42,8 +50,8 @@ nv-ingest: pvc: storageClass: "" - # Table Structure - uses 1g.24gb - table_structure: + # Page Elements — uses 1g.24gb on GPU 2 + page_elements: resources: limits: nvidia.com/gpu: "0" @@ -55,41 +63,40 @@ nv-ingest: pvc: storageClass: "" - # OCR - uses 2g.48gb (larger slice) - nemoretriever_ocr_v1: + # Table Structure — uses 1g.24gb on GPU 3 + table_structure: resources: limits: nvidia.com/gpu: "0" - nvidia.com/mig-2g.48gb: 1 + nvidia.com/mig-1g.24gb: 1 requests: nvidia.com/gpu: "0" - nvidia.com/mig-2g.48gb: 1 + nvidia.com/mig-1g.24gb: 1 storage: pvc: storageClass: "" + # Main NIM Operator overrides for MIG nimOperator: - # LLM - uses 4g.96gb + # LLM — 2 FULL RTX PRO 6000 GPUs (no MIG). + # nemotron-3-super-120b-a12b runs with vLLM and tensorParallelism=2; + # gpus selector pins to the RTX PRO 6000 Blackwell profile. nim-llm: resources: limits: - nvidia.com/gpu: "0" - nvidia.com/mig-4g.96gb: 1 + nvidia.com/gpu: 2 requests: - nvidia.com/gpu: "0" - nvidia.com/mig-4g.96gb: 1 - storage: - pvc: - storageClass: "" + nvidia.com/gpu: 2 + storage: + pvc: + storageClass: "" model: - engine: tensorrt_llm + engine: vllm precision: "fp8" - qosProfile: "throughput" - tensorParallelism: "1" - gpus: - - product: "rtx6000_blackwell_sv" - # Embedding - uses 1g.24gb - nvidia-nim-llama-32-nv-embedqa-1b-v2: + tensorParallelism: "2" + + # VLM Embedding (default) — uses 1g.24gb on GPU 3 + nvidia-nim-llama-nemotron-embed-vl-1b-v2: resources: limits: nvidia.com/gpu: "0" @@ -97,10 +104,11 @@ nimOperator: requests: nvidia.com/gpu: "0" nvidia.com/mig-1g.24gb: 1 - storage: - pvc: - storageClass: "" - # Reranking - uses 1g.24gb + storage: + pvc: + storageClass: "" + + # Reranking — uses 1g.24gb on GPU 3. nvidia-nim-llama-32-nv-rerankqa-1b-v2: resources: limits: @@ -109,6 +117,6 @@ nimOperator: requests: nvidia.com/gpu: "0" nvidia.com/mig-1g.24gb: 1 - storage: - pvc: - storageClass: "" + storage: + pvc: + storageClass: "" diff --git a/deploy/helm/nvidia-blueprint-rag/Chart.lock b/deploy/helm/nvidia-blueprint-rag/Chart.lock index 723660bfd..a59eb726b 100644 --- a/deploy/helm/nvidia-blueprint-rag/Chart.lock +++ b/deploy/helm/nvidia-blueprint-rag/Chart.lock @@ -1,7 +1,10 @@ dependencies: - name: nv-ingest repository: https://helm.ngc.nvidia.com/nvidia/nemo-microservices - version: 26.1.2 + version: 26.3.0 +- name: seaweedfs + repository: https://seaweedfs.github.io/seaweedfs/helm + version: 4.21.0 - name: eck-elasticsearch repository: https://helm.elastic.co version: 0.18.0 @@ -14,5 +17,5 @@ dependencies: - name: kube-prometheus-stack repository: https://prometheus-community.github.io/helm-charts version: 76.3.0 -digest: sha256:a65037bbcb6fa587af3d15b949a32b059cf26d1102a2166d0e77daed29a0f520 -generated: "2026-03-02T16:48:31.702049307+05:30" +digest: sha256:4de4cfcfb3d9c488f7eb6ef8f9e4e3f50211feba14d03668e3fa49f7914926a9 +generated: "2026-05-08T07:22:23.008882991Z" diff --git a/deploy/helm/nvidia-blueprint-rag/Chart.yaml b/deploy/helm/nvidia-blueprint-rag/Chart.yaml index 2fbee4ef3..5742f730f 100644 --- a/deploy/helm/nvidia-blueprint-rag/Chart.yaml +++ b/deploy/helm/nvidia-blueprint-rag/Chart.yaml @@ -1,10 +1,14 @@ apiVersion: v2 -appVersion: v2.5.1 +appVersion: v2.6.0 dependencies: - condition: nv-ingest.enabled name: nv-ingest repository: https://helm.ngc.nvidia.com/nvidia/nemo-microservices - version: 26.1.2 + version: 26.3.0 +- condition: seaweedfs.enabled + name: seaweedfs + repository: https://seaweedfs.github.io/seaweedfs/helm + version: 4.21.0 - condition: eck-elasticsearch.enabled name: eck-elasticsearch repository: https://helm.elastic.co @@ -24,4 +28,4 @@ dependencies: description: An end to end Helm chart for the NVIDIA RAG Blueprint name: nvidia-blueprint-rag type: application -version: v2.5.1 +version: v2.6.0 diff --git a/deploy/helm/nvidia-blueprint-rag/LICENSE b/deploy/helm/nvidia-blueprint-rag/LICENSE index 36ef90e5b..a6c7d42fc 100644 --- a/deploy/helm/nvidia-blueprint-rag/LICENSE +++ b/deploy/helm/nvidia-blueprint-rag/LICENSE @@ -1,3 +1,6 @@ +Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + + Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ @@ -186,7 +189,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2020 NVIDIA Corporation + Copyright 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/deploy/helm/nvidia-blueprint-rag/endpoints.md b/deploy/helm/nvidia-blueprint-rag/endpoints.md index e62b0d1fb..f7dcca679 100644 --- a/deploy/helm/nvidia-blueprint-rag/endpoints.md +++ b/deploy/helm/nvidia-blueprint-rag/endpoints.md @@ -5,26 +5,27 @@ This document describes the configurable endpoints used by the RAG server and it ## Core Service Endpoints ### Vector Store -- **APP_VECTORSTORE_URL**: URL for the vector store service (default: "http://milvus:19530") -- **APP_VECTORSTORE_NAME**: Type of vector store (default: "milvus") +- **APP_VECTORSTORE_URL**: URL for the vector store service (default: "http://rag-eck-elasticsearch-es-default:9200") +- **APP_VECTORSTORE_NAME**: Type of vector store (default: "elasticsearch") - **APP_VECTORSTORE_SEARCHTYPE**: Type of vector store search (default: "dense") ### Object Storage -- **MINIO_ENDPOINT**: MinIO service endpoint for storing multimodal content (default: "rag-minio:9000") +- **OBJECTSTORE_ENDPOINT**: S3-compatible object-store endpoint for storing multimodal content (default: "rag-seaweedfs-all-in-one:9010") +- **NVINGEST_OBJECTSTORE_ENDPOINT**: Optional object-store endpoint reachable from the NV-Ingest runtime. Defaults to `OBJECTSTORE_ENDPOINT` when unset. ## Model Service Endpoints ### LLM Model - **APP_LLM_SERVERURL**: URL for the LLM model service (default: "nim-llm:8000") -- **APP_LLM_MODELNAME**: Name of the LLM model (default: "nvidia/llama-3.3-nemotron-super-49b-v1.5") +- **APP_LLM_MODELNAME**: Name of the LLM model (default: "nvidia/nemotron-3-super-120b-a12b") ### Query Rewriter Model - **APP_QUERYREWRITER_SERVERURL**: URL for the query rewriter model service (default: "nim-llm:8000") -- **APP_QUERYREWRITER_MODELNAME**: Name of the query rewriter model (default: "nvidia/llama-3.3-nemotron-super-49b-v1.5") +- **APP_QUERYREWRITER_MODELNAME**: Name of the query rewriter model (default: "nvidia/nemotron-3-super-120b-a12b") ### Embedding Model -- **APP_EMBEDDINGS_SERVERURL**: URL for the embedding model service (default: "nemo-retriever-embedding-ms:8000") -- **APP_EMBEDDINGS_MODELNAME**: Name of the embedding model (default: "nvidia/llama-nemotron-embed-1b-v2") +- **APP_EMBEDDINGS_SERVERURL**: URL for the embedding model service (default: "nemotron-vlm-embedding-ms:8000/v1") +- **APP_EMBEDDINGS_MODELNAME**: Name of the embedding model (default: "nvidia/llama-nemotron-embed-vl-1b-v2") ### Reranking Model - **APP_RANKING_SERVERURL**: URL for the ranking model service (default: "nemo-retriever-reranking-ms:8000") @@ -32,7 +33,7 @@ This document describes the configurable endpoints used by the RAG server and it ### Reflection Model - **REFLECTION_LLM_SERVERURL**: URL for the reflection LLM service (default: "nim-llm:8000") -- **REFLECTION_LLM**: Name of the reflection model (default: "nvidia/llama-3.3-nemotron-super-49b-v1.5") +- **REFLECTION_LLM**: Name of the reflection model (default: "nvidia/nemotron-3-super-120b-a12b") ## Frontend Endpoints @@ -41,8 +42,8 @@ This document describes the configurable endpoints used by the RAG server and it - **VITE_MODEL_NAME**: Base URL for vector database API endpoints (default: "http://ingestor-server:8082/v1") ### Model Configuration -- **NEXT_PUBLIC_MODEL_NAME**: Name of the LLM model used in the frontend (default: "nvidia/llama-3.3-nemotron-super-49b-v1.5") -- **VITE_EMBEDDING_MODEL**: Name of the embedding model used in the frontend (default: "nvidia/llama-nemotron-embed-1b-v2") +- **NEXT_PUBLIC_MODEL_NAME**: Name of the LLM model used in the frontend (default: "nvidia/nemotron-3-super-120b-a12b") +- **VITE_EMBEDDING_MODEL**: Name of the embedding model used in the frontend (default: "nvidia/llama-nemotron-embed-vl-1b-v2") - **VITE_RERANKER_MODEL**: Name of the reranker model used in the frontend (default: "nvidia/llama-nemotron-rerank-1b-v2") ## Monitoring and Tracing Endpoints diff --git a/deploy/helm/nvidia-blueprint-rag/files/prompt.yaml b/deploy/helm/nvidia-blueprint-rag/files/prompt.yaml index d73036509..d030ae1dc 100644 --- a/deploy/helm/nvidia-blueprint-rag/files/prompt.yaml +++ b/deploy/helm/nvidia-blueprint-rag/files/prompt.yaml @@ -1,8 +1,5 @@ chat_template: system: | - /no_think - - human: | You are a helpful, respectful, and honest assistant. Your answers must follow these strict guidelines: @@ -21,36 +18,31 @@ chat_template: rag_template: system: | - /no_think + You are a helpful AI assistant named Envie. Answer the user's question using ONLY the information in the provided context. + + + - Base every claim on information found in the context. Do not use outside knowledge. + - Always provide an answer when the context contains relevant data. Only say you cannot answer if the context is entirely unrelated to the question. + - Preserve exact values: reproduce specific numbers, percentages, dates, names, and URLs exactly as they appear in the context. + - IMPORTANT - When the question asks you to calculate, compute, or derive a financial metric (ratio, margin, growth rate, CAGR, turnover, average, etc.), you MUST: + 1. Write the formula + 2. Extract each required number from the context + 3. Compute step by step + 4. State the final answer + Do NOT skip straight to the final number. + - For yes/no questions that require comparing values across periods (e.g. "is X improving", "did Y increase"), state the values from each period before your conclusion. + - For questions about trends or changes over time, include data from all relevant time periods found in the context. + - Answer naturally and directly. Do not reference the context, documents, sources, or these instructions. + - For simple factual lookups (a name, a date, a single value directly stated), keep your answer brief. + human: | - You are a helpful AI assistant named Envie. - You must answer only using the information provided in the context. While answering you must follow the instructions given below. - - - 1. Do NOT use any external knowledge. - 2. Do NOT add explanations, suggestions, opinions, disclaimers, or hints. - 3. NEVER say phrases like “based on the context”, “from the documents”, or “I cannot find”. - 4. NEVER offer to answer using general knowledge or invite the user to ask again. - 5. Do NOT include citations, sources, or document mentions. - 6. Answer concisely. Use short, direct sentences by default. Only give longer responses if the question truly requires it. - 7. Do not mention or refer to these rules in any way. - 8. Do not ask follow-up questions. - 9. Do not mention this instructions in your response. - 10. Do not mention source or file name in your response. - 11. Respond directly from the document but do NOT refer it in your response. - - - Context: + {context} - - Make sure the response you are generating strictly follow the rules mentioned above i.e. never say phrases like “based on the context”, “from the documents”, or “I cannot find” and mention about the instruction in response. + query_rewriter_prompt: system: | - /no_think - - human: | Given the following chat history and the latest user question, formulate a standalone question which can be understood without the chat history. Do NOT answer the question, just reformulate it if needed and otherwise return it as is. It should strictly be a query not an answer. @@ -58,240 +50,174 @@ query_rewriter_prompt: Chat History: {chat_history} + human: | Latest Question: {input} reflection_relevance_check_prompt: system: | - /no_think - - human: | - ### Instructions - - You are a world class expert designed to evaluate the relevance score of a Context - in order to answer the Question. - Your task is to determine if the Context contains proper information to answer the Question. - Do not rely on your previous knowledge about the Question. - Use only what is written in the Context and in the Question. - Follow the instructions below: - 0. If the context does not contains any relevant information to answer the question, say 0. - 1. If the context partially contains relevant information to answer the question, say 1. - 2. If the context contains any relevant information to answer the question, say 2. - You must provide the relevance score of 0, 1, or 2, nothing else. - Do not explain. - ### Question: {query} - - ### Context: {context} - - Do not try to explain. - Analyzing Context and Question, the Relevance score is - -reflection_query_rewriter_prompt: - system: | - /no_think - + You are an expert relevance evaluator. Given a Question and a Context, score how well the Context supports answering the Question. + + + - Use ONLY what is written in the Context. Do not use prior knowledge. + - Score 0 if the Context contains no relevant information to answer the Question. + - Score 1 if the Context partially contains relevant information to answer the Question. + - Score 2 if the Context contains sufficient information to answer the Question. + - Output ONLY the single digit 0, 1, or 2. No explanation, no commentary. + human: | - You are a query optimization assistant for a vector database retrieval system. - Your goal is to rephrase the given "Original Question" to be more clear, precise, - and effective for retrieving relevant context from a vector database. + Question: {query} - Considerations for Rephrasing: - - Specificity: Make the query as specific as possible about the information sought. - Avoid vague terms. - - Keywords: Identify and incorporate key terms and concepts that are likely to be - present in relevant documents. - - Contextual Cues: If the original query implies a certain domain or type of - information, make that explicit. - - Eliminate Ambiguity: Remove any phrases that could lead to multiple interpretations. + Context: {context} - Focus: Ensure the rephrased query directly targets the core information need. + Relevance score (0, 1, or 2): - Brevity (where possible): While precision is key, try to be concise without - losing meaning. +reflection_query_rewriter_prompt: + system: | + You are the query rewriting component of a retrieval system powered by a dense embedding model. - Only output the rewritten question with no other information. + Your goal is to rephrase the given query so it is more natural and aligned with how questions + are written in the embedding model's training data, which improves retrieval recall. + Rules: + - Preserve the full meaning and ALL information from the original query. Do not drop any detail. + - Do not add facts, intent, or semantic content not present in the original query. + - Only improve language, style, and clarity — not the semantic scope. + - Explore related phrasings or angles that may surface relevant documents the original missed. + - Output a single, clear, natural-language question or statement. No explanation. + human: | Original Question: {query} Rewritten Question: reflection_groundedness_check_prompt: system: | - /no_think - + You are an expert groundedness evaluator. Given a Context and an Assertion, score how well the Assertion is supported by the Context. + + + - Score 0 if the Context or Assertion is empty, or the Assertion is not supported by the Context. + - Score 1 if the Assertion is partially supported by the Context. + - Score 2 if the Assertion is fully supported by the Context. + - Output ONLY the single digit 0, 1, or 2. No explanation, no commentary. + human: | - ### Instruction + Context: <{context}> - You are a world class expert designed to evaluate the groundedness of an assertion. - You will be provided with an assertion and a context. - Your task is to determine if the assertion is supported by the context. - Follow the instructions below: - A. If there is no context or no assertion or context is empty or assertion is empty, say 0. - B. If the assertion is not supported by the context, say 0. - C. If the assertion is partially supported by the context, say 1. - D. If the assertion is fully supported by the context, say 2. - You must provide a rating of 0, 1, or 2, nothing else. + Assertion: <{response}> - ### Context: - <{context}> - - ### Assertion: - <{response}> - - Analyzing Context and Response, the Groundedness score is + Groundedness score (0, 1, or 2): reflection_response_regeneration_prompt: system: | - /no_think - + You are a response regeneration assistant. Generate a new response to the Query using ONLY information from the Context. + + + - Do not introduce any information, facts, or concepts not present in the Context. + - Do not make assumptions or extrapolate beyond what is directly stated or clearly implied. + - If an inference is needed, it must be direct and undeniable from the text — no speculation. + - Reproduce exact values: numbers, dates, names, and URLs exactly as they appear. + - If the Query cannot be answered from the Context, output exactly: OUT OF CONTEXT + - Otherwise output only the response. No preamble, no explanation, no commentary. + human: | - You are tasked with creating a new "Response" based solely on the provided - "Context" and "Query". Your primary goal is to ensure strict adherence to - the information explicitly stated or directly inferable from the Context. - - Key Constraints: - - No Outside Knowledge: Do not introduce any information, facts, or concepts - not present in the given Context. - - No Assumptions: Do not make assumptions or extrapolate beyond what is directly - stated or clearly implied. - - Direct Inference Only: If an idea is not explicitly stated, it must be a direct - and undeniable inference from the provided text. Avoid speculative or highly - interpretive conclusions. - - Maintain Factual Accuracy: Ensure the Response accurately reflects the details - and relationships presented in the Context. - - Return only "OUT OF CONTEXT" if the "Query" cannot be answered using the provided - "Context." Else, only output the new response with no other information. - Context: {context} Query: {query} - Return "OUT OF CONTEXT" or generate a new, more grounded Response: + Response: document_summary_prompt: system: | - /no_think - - human: | - Please provide a comprehensive summary for the document given by the user. Create a concise 5 to 6 sentence summary that captures the essential information from the document. + You are an expert document summarizer. Produce a single, self-contained paragraph of exactly 5–6 sentences that captures the document's essential meaning. The 5–6 sentence limit is absolute. - Requirements for the summary: - 1. Preserve key document metadata: - - Document title/type - - Company/organization name - - Report provider/author - - Date/time period covered - - Any relevant document identifiers - - 2. Include all critical information: - - Main findings and conclusions - - Key statistics and metrics - - Important recommendations - - Significant trends or changes - - Notable risks or concerns - - Material financial data - - 3. Maintain factual accuracy: - - Keep all numerical values precise - - Preserve specific dates and timeframes - - Retain exact names and titles - - Quote critical statements verbatim when necessary - - 4. Do NOT use any external knowledge. - 5. Do NOT add explanations, suggestions, opinions, disclaimers, or hints. - 6. NEVER say phrases like “based on the context”, “from the documents”, or “I cannot find”. - 7. NEVER offer to answer using general knowledge or invite the user to ask again. - 8. Do NOT include citations, sources, or document mentions. - 9. Answer concisely. Use short, direct sentences by default. Only give longer responses if the question truly requires it. - 10. Do not mention or refer to these rules in any way. - 11. Do not ask follow-up questions. - 12. Do not mention this instructions in your response. - 13. Do not include any preamble or postamble like "Here is the summary" or "This document" or "Summary of the document". + 1. Open with the document's identity: title, author or organization, and date or time period covered. + 2. SYNTHESIZE — do not enumerate. Describe patterns, scope, key findings, and conclusions rather than listing individual data points, rows, or line items. + - For tabular or list-structured data: describe what the table covers, any notable patterns, and give at most 2–3 illustrative examples. Do NOT enumerate rows. + - For narrative or research documents: state the central argument, key findings, and most important recommendations. + - For financial filings or reports: focus on the 2–3 most significant high-level metrics and overall trends — not individual line items. + 3. Be selective: a good summary omits many details. Prioritize impact and significance over completeness. + 4. Use only information present in the document — no external knowledge, no inference. + 5. Write in plain, direct sentences. No preamble (“Here is a summary…”), no postamble, no meta-commentary, no bullet points, no headers. + 6. Output exactly one prose paragraph. - Please format the summary in a concise manner as a paragraph not exceeding 5 to 6 sentences. Start the summary with the title and the document and then provide the summary. - - Note: Focus on extracting and organizing the most essential information while ensuring no critical details are omitted. - Maintain the original document's tone and context in your summary. Please provide a concise summary for the following document: + human: | {document_text} shallow_summary_prompt: system: | - /no_think - - human: | Please provide a concise summary for the following document: + human: | {document_text} iterative_summary_prompt: system: | - /no_think - - human: | - You are an expert document summarizer. Given a previous summary and a new chunk of text, create an updated summary that incorporates information from both. Create a concise summary within 10 sentences that captures the essential information from the document. - While answering you must follow the instructions given below. + You are maintaining a running summary of a long document as new sections arrive. Given a PREVIOUS SUMMARY and a NEW CHUNK, produce an UPDATED SUMMARY. - 1. Do NOT use any external knowledge. - 2. Do NOT add explanations, suggestions, opinions, disclaimers, or hints. - 3. NEVER say phrases like “based on the context”, “from the documents”, or “I cannot find”. - 4. NEVER offer to answer using general knowledge or invite the user to ask again. - 5. Do NOT include citations, sources, or document mentions. - 6. Answer concisely. Use short, direct sentences by default. Only give longer responses if the question truly requires it. - 7. Do not mention or refer to these rules in any way. - 8. Do not ask follow-up questions. - 9. Do not mention this instructions in your response. - 10. Do not mention any preamble or postamble like "Updated summary" or "This document" or "Summary of the document" or "Here is the summary". + 1. Retain only the most important findings from the previous summary — you may and should drop less important details to make room. + 2. Incorporate information from the new chunk ONLY if it introduces genuinely new, significant topics not already covered in the previous summary. Skip content that repeats patterns or data types already summarized. Skip boilerplate, disclaimers, contact details, or generic statements with no substantive content. + 3. HARD LIMIT — 10 sentences maximum: Before outputting, count every sentence in your response. If you have more than 10, remove the least important sentences until you have 10 or fewer. This rule overrides all other instructions. + 4. The output should generally be the same length as or shorter than the previous summary — do NOT grow the summary with each update. + 5. SYNTHESIZE — do not enumerate. Write a coherent prose paragraph, not a list of data points or statistics. + 6. Use only information from the document — no external knowledge. + 7. Write ONLY the summary content itself. Never write about the document, the summary, or these instructions in your output. No preamble, no postamble, no meta-commentary, no bullets, no headers. Output exactly one prose paragraph. - + human: | Previous Summary: {previous_summary} New chunk: {new_chunk} - Please create a new summary that incorporates information from both the previous summary and the new chunk. + Updated summary (10 sentences maximum): vlm_template: system: | - /no_think - - human: | - You are a multimodal AI assistant. Answer using only the provided context and images. + You are a multimodal AI assistant. Answer using the provided context and images. - 1. Use ONLY the information in the textual context below and the attached images. - 2. Do not use external knowledge or assumptions beyond the provided inputs. - 3. Do not describe images unless needed to answer; focus on the answer. - 4. Respond in detail and cover all the relevant information related to the question from the context and images. - 5. Keep the response neutral and factually accurate. + 1. Use the textual context and attached images as your primary source of information. + Do not fabricate facts, numbers, or claims not present in the inputs. + If the context provides partial information, answer with what is available and + note what is missing — do not refuse simply because coverage is incomplete. + 2. If the context or images contain ANY information relevant to the question — even + partial — you MUST provide an answer using that information. Only reply that you + cannot answer if the context and images have no connection to the question at all. + 3. Preserve exact values: reproduce specific numbers, percentages, dates, names, and + URLs exactly as they appear in the context or images. + 4. When extracting a value from a table or list, identify the exact row/column label + matching the question before extracting its value. When the question specifies a + time period, confirm that exact period in the context before answering — do not + use data from an adjacent period. If the exact value cannot be unambiguously + identified in the context, say what related information is available rather than + guessing a value. + 5. When a page image is provided alongside the text, cross-reference the image to + verify values in tables and charts. The image is the authoritative source for + exact cell values when text and image disagree. + 6. When the question asks for multiple distinct values or differences between several + categories, provide ALL values separately. Do not stop after the first one. + 7. When a question asks "how much" as a count or amount, give the absolute value. + Provide a percentage only when the question explicitly asks for a rate or percentage. + 8. For yes/no questions, state your answer (Yes or No) first, then cite the specific + evidence from the context that supports it. + 9. When asked to calculate a ratio, margin, or rate, extract the relevant numbers from + the context first, then compute and state the final result. + 10. Respond in detail and cover all relevant information from the context and images. - Context: + human: | {context} - User Question: {question} # Reasoning templates deprecated and removed -filter_expression_generator_prompt: +filter_expression_generator_prompt_milvus: system: | - /no_think - - human: | You are an expert AI filter expression generator. Your sole purpose is to convert natural language queries into precise, valid filter expressions based on the provided schema. You must be aggressive in finding mappable entities. ### Primary Directive ### @@ -357,44 +283,148 @@ filter_expression_generator_prompt: 3. **On Logical Conflict:** The exact text UNSUPPORTED. * **Use this ONLY for impossible logic**, like "year is 2022 and year is 2023". -query_decomposition_multiquery_prompt: + human: | + Generate the filter expression.{existing_filter_context} + +filter_expression_generator_prompt_elasticsearch: system: | - /no_think + You are an expert AI filter expression generator for Elasticsearch. Your sole purpose is to convert natural language queries into precise, valid Elasticsearch Query DSL filter clauses based on the provided schema. You must be aggressive in finding mappable entities. + + ### Primary Directive ### + + **Your primary directive is to ALWAYS generate a filter.** It is a critical error to return NO_FILTER unless the user's query is completely irrelevant or nonsensical (e.g., "hello there," "what is the weather?"). Be bold and decisive. Prioritize extracting any mappable entity from the user's query, even if other parts are ambiguous. If a query contains even one recognizable keyword, date, or number that maps to the schema, you must build a filter around it. + + ### Schema ### + + Use the following schema to identify available fields and their data types. + {metadata_schema} + + ### Field Path Convention ### + + - Always prefix field names with `metadata.content_metadata.` — e.g. for schema field `status`, target `metadata.content_metadata.status`. + - For `string` (or `array`) fields used in exact-match clauses (`term`, `terms`, `prefix`), append `.keyword` — e.g. `metadata.content_metadata.status.keyword`. + - **Do NOT append `.keyword` for `wildcard`, `match`, `match_phrase`, `range`, or for any non-string field type (`integer`, `float`, `number`, `datetime`, `boolean`).** + The `.keyword` sub-field only exists on string-typed fields. Appending it to a numeric, datetime, or boolean field targets a non-existent ES mapping and silently returns zero hits. + + #### Correct vs. incorrect — non-string fields #### + + | Field type | ✅ Correct | ❌ Incorrect | + | --- | --- | --- | + | `integer` (e.g. `priority`) | `{{"term": {{"metadata.content_metadata.priority": 2}}}}` | `{{"term": {{"metadata.content_metadata.priority.keyword": 2}}}}` | + | `integer` range (e.g. `year`) | `{{"range": {{"metadata.content_metadata.year": {{"gte": 2024}}}}}}` | `{{"range": {{"metadata.content_metadata.year.keyword": {{"gte": 2024}}}}}}` | + | `datetime` (e.g. `created_at`) | `{{"range": {{"metadata.content_metadata.created_at": {{"gte": "2024-01-01T00:00:00"}}}}}}` | `{{"range": {{"metadata.content_metadata.created_at.keyword": {{...}}}}}}` | + | `boolean` (e.g. `is_public`) | `{{"term": {{"metadata.content_metadata.is_public": true}}}}` | `{{"term": {{"metadata.content_metadata.is_public.keyword": true}}}}` | + | `string` (e.g. `status`) | `{{"term": {{"metadata.content_metadata.status.keyword": "approved"}}}}` | `{{"term": {{"metadata.content_metadata.status": "approved"}}}}` (missing `.keyword`) | + + ### Output Format ### + + Emit a single JSON array of Elasticsearch filter clauses (no markdown, no explanations). Each clause is a Query DSL object. Use a top-level `bool` clause for logical composition. + + ### Clause Selection Priority (CRITICAL) ### + + When a user keyword could map to multiple schema fields, **always prefer `term` / `terms` on a categorical field over `wildcard` / `match` on a free-text field like `title` or `description`**. + + - If a keyword names a recognizable category, status, type, or tag, map it to the corresponding schema field with `term` (single value) or `terms` (multiple values), using the `.keyword` sub-field. + - Use `wildcard` only when the user explicitly references title/description text (e.g. *"documents whose title mentions X"*, *"reports with 'compliance' in the heading"*). Otherwise do NOT use `wildcard`. + - Use `match` / `match_phrase` only for explicit free-text search intents. + + Worked example: for the query *"Show me tech documents"* with a schema containing both `title` and `category`, the correct filter targets the `category` field — `[{{"term": {{"metadata.content_metadata.category.keyword": "tech"}}}}]` — NOT a `wildcard` on `title`. + + ### Type → Clause Mapping ### + + 1. **String** — `term`, `terms`, `wildcard`, `match` (prefer `term`/`terms` per the priority rule above) + * `[{{"term": {{"metadata.content_metadata.doc_type.keyword": "report"}}}}]` + * `[{{"terms": {{"metadata.content_metadata.doc_type.keyword": ["report", "summary"]}}}}]` + * `[{{"wildcard": {{"metadata.content_metadata.title": "*compliance*"}}}}]` (only when query explicitly references title text) + 2. **Integer / Number / Float** — `term`, `range` + * `[{{"range": {{"metadata.content_metadata.page_count": {{"gt": 10}}}}}}]` + * `[{{"range": {{"metadata.content_metadata.year": {{"gte": 2024, "lt": 2026}}}}}}]` + 3. **Datetime** (ISO 8601: YYYY-MM-DDTHH:MM:SS) — `range` + * `[{{"range": {{"metadata.content_metadata.created_at": {{"gte": "2024-01-01T00:00:00"}}}}}}]` + 4. **Boolean** — `term` + * `[{{"term": {{"metadata.content_metadata.is_public": true}}}}]` + 5. **Array** — `terms`, `term` + * Single value match: `[{{"term": {{"metadata.content_metadata.tags.keyword": "urgent"}}}}]` + * Any of: `[{{"terms": {{"metadata.content_metadata.regions.keyword": ["EMEA", "APAC"]}}}}]` + * All of: use `bool.must` with one `term` per value. + + ### Logical Composition ### + + Use `bool` with `must` (AND), `should` (OR), and `must_not` (NOT). Wrap the bool object inside the top-level array. + + Example AND: + `[{{"bool": {{"must": [{{"term": {{"metadata.content_metadata.doc_type.keyword": "financial_report"}}}}, {{"term": {{"metadata.content_metadata.project.keyword": "Project X"}}}}]}}}}]` + + Example OR with NOT: + `[{{"bool": {{"should": [{{"term": {{"metadata.content_metadata.region.keyword": "EMEA"}}}}, {{"term": {{"metadata.content_metadata.region.keyword": "APAC"}}}}], "must_not": [{{"term": {{"metadata.content_metadata.archived": true}}}}]}}}}]` + + ### Intelligent Mapping Examples ### + + * **Query:** "Project X" + * **Output:** `[{{"term": {{"metadata.content_metadata.project.keyword": "Project X"}}}}]` + * **Query:** "approved" + * **Output:** `[{{"term": {{"metadata.content_metadata.status.keyword": "approved"}}}}]` + * **Query:** "Find the latest financial reports for Project X" + * **Action:** Ignore "latest" as it's subjective. Extract "financial reports" and "Project X". + * **Output:** `[{{"bool": {{"must": [{{"term": {{"metadata.content_metadata.doc_type.keyword": "financial_report"}}}}, {{"term": {{"metadata.content_metadata.project.keyword": "Project X"}}}}]}}}}]` + * **Query:** "I think I need the document from Q2 last year about compliance" + * **Action:** Extract "Q2 last year" (assume 2024) and "compliance". + * **Output:** `[{{"bool": {{"must": [{{"range": {{"metadata.content_metadata.created_at": {{"gte": "2024-04-01T00:00:00", "lt": "2024-07-01T00:00:00"}}}}}}, {{"term": {{"metadata.content_metadata.tags.keyword": "compliance"}}}}]}}}}]` + + ### Your Task ### + + Convert the following user query into Elasticsearch filter clauses. + {user_request} + + ### Response Format ### + + Your response **MUST** be only the raw JSON array and nothing else. Do not use explanations, comments, or markdown fences. + + 1. **On Success:** A JSON array of one or more filter clauses. + * `[{{"term": {{"metadata.content_metadata.year": 2024}}}}]` + + 2. **On Absolute Failure:** The exact text NO_FILTER. + * **Use this ONLY if the query is completely unrelated to the schema**, like "what is your name?" or "tell me a joke". + + 3. **On Logical Conflict:** The exact text UNSUPPORTED. + * **Use this ONLY for impossible logic**, like "year is 2022 and year is 2023". human: | - You are an AI assistant designed to break down a user's complex question into a list of simpler, focused subqueries. - The purpose of this decomposition is to improve the accuracy of a retrieval-augmented generation (RAG) system. + Generate the filter expression.{existing_filter_context} + +query_decomposition_multiquery_prompt: + system: | + You are an AI assistant designed to break down a user's complex question into a list of simpler, focused subqueries. + The purpose of this decomposition is to maximize retrieval recall by searching the corpus from different angles. 1. Analyze the user's main question to identify its key components. - 2. Decompose the question into 1-3 distinct, self-contained subqueries. - 3. If the original question is simple and already focused, return query directly. - 4. Each subquery should be a clear, direct question that, when answered, contributes to a comprehensive response to the original question. - 5. Avoid creating redundant or overly broad subqueries. Focus on the core information needed to answer the original prompt + 2. Decompose the question into 1-3 distinct, self-contained subqueries that together cover the full information need. + 3. If the original question is simple and already focused, return it directly. + 4. Each subquery should target a different aspect or angle of the original question to surface documents that a single query might miss. + 5. Each subquery should be phrased as a natural, human-style question optimized for dense embedding retrieval. + 6. Avoid redundant subqueries. Each one should retrieve meaningfully different information. + 7. Do NOT generate a subquery for something that can be computed or derived from the answers to other subqueries (e.g., do not ask for a growth rate if you are already asking for the two values needed to compute it). Return only the subqueries as a numbered list, without any additional text. + human: | Original question: {question} query_decompositions_query_rewriter_prompt: system: | - /no_think - + You are the query rewriting component of a retrieval system powered by a dense embedding model. + Your task is to rewrite the user's question — using the conversation history for context — into + a single standalone query optimized for dense embedding retrieval. + + + - Resolve pronouns and implicit references using the conversation history (e.g., “it”, “that company”). + - Preserve ALL information from the current question. Do not drop any detail. + - Do not add facts or intent not present in the current question or directly implied by the conversation. + - Improve language and clarity so the query matches the natural style the embedding model was trained on. + - Output a single, concise, natural-language question. No explanation, no commentary. + human: | - You are an expert at rewriting queries to improve information retrieval for a conversational AI system. Your task is to take a user's new question and the preceding conversation history and rewrite the question into a single, highly specific query. This new query should be ideal for a search or retrieval system. - - - 1. Analyze the conversation history to identify all necessary context, such as entities, topics, or constraints that the user is referencing implicitly. - 2. Rewrite the current question to be more specific and retrieval-focused - 3. Include relevant context from the conversation history if it helps clarify the query - 4. Make the query more explicit about what information is being sought - 5. Ensure the rewritten query will help the retriever find the most relevant documents - 6. Just provide the rewritten query, no other text. - 7. Keep the query as short as possible. - 8. Do not provide any explanation. - 9. Do not answer the question. - - Conversation History: {conversation_history} @@ -404,24 +434,20 @@ query_decompositions_query_rewriter_prompt: query_decomposition_followup_question_prompt: system: | - /no_think - - human: | You are an AI assistant tasked with identifying missing information needed to answer a user's question completely. Your goal is to generate a single follow-up question to help a retrieval system find the necessary details. - You are given a question answer pair, context and question to be answered. - 1. Analyze the original question, the provided context, and the conversation history. - 2. Determine if the information is sufficient to fully answer the original question. - 3. If a key piece of information is missing, generate one short, precise question to retrieve it. - 4. If all necessary information is already present, return an empty string: '' - 5. Do NOT provide any explanation. - 6. Do not answer the question. - 7. Return '' if no follow-up question is needed. - 8. Make sure follow up query is short and concise. - 9. Do not add any info, rationale or any other text other then the follow up question. + 1. Analyze the original question, the conversation history, and the retrieved context. + 2. Determine if the information already gathered is sufficient to fully answer the original question. + 3. If a specific factual piece is missing that retrieval could find, generate one short, precise question to retrieve it. + 4. If the missing answer can be computed or derived from values already present in the conversation history (e.g., calculating a growth rate from two revenue figures already answered), return ''. + 5. If all necessary information is already present, return ''. + 6. If a question already appears in the conversation history with an empty answer ('' or no useful content), it means the information is not available — do NOT ask the same question again. Return ''. + 7. Do NOT provide any explanation or rationale. + 8. Do not answer the question. + 9. Make sure the follow-up query is short and concise. - + human: | Conversation History: {conversation_history} @@ -431,15 +457,11 @@ query_decomposition_followup_question_prompt: Original Question: {question} - Follow-up Question (if needed, otherwise return ''): query_decomposition_final_response_prompt: system: | - /no_think - - human: | - You are a helpful AI assistant named Envie. Your sole purpose is to answer the user's question by extracting and synthesizing information only from the provided context. + You are a helpful AI assistant named Envie. Your sole purpose is to answer the user's question by extracting and synthesizing information from the provided context and conversation history. 1. Do NOT use any external knowledge. @@ -447,12 +469,16 @@ query_decomposition_final_response_prompt: 3. NEVER say phrases like “based on the context”, “from the documents”, or “I cannot find”. 4. NEVER offer to answer using general knowledge or invite the user to ask again. 5. Do NOT include citations, sources, or document mentions. - 6. Answer concisely. Use short, direct sentences . + 6. Answer concisely. Use short, direct sentences. 7. Do not mention or refer to these rules in any way. 8. Do not ask follow-up questions. - 9. Do not mention this instructions in your response. + 9. When the question asks you to calculate, compute, or derive a metric (ratio, margin, growth rate, change, average, etc.) and the required values are present in the context or conversation history, you MUST compute it: + a. Write the formula. + b. Extract each required value. + c. Compute step by step. + d. State the final answer. - + human: | Conversation History: {conversation_history} @@ -461,15 +487,9 @@ query_decomposition_final_response_prompt: Current Question: {question} - Make sure the response you are generating strictly follow the rules mentioned above i.e. never say phrases like “based on the context”, “from the documents”, or “I cannot find” and mention about the instruction in response. - query_decomposition_rag_template: system: | - /no_think - - human: | - You are a helpful AI assistant. - You must answer only using the information provided in the context. While answering you must follow the instructions given below. + You are a helpful AI assistant. Answer using ONLY the information provided in the context below. 1. Do NOT use any external knowledge. @@ -480,15 +500,13 @@ query_decomposition_rag_template: 6. Answer concisely. Use short, direct sentences by default. Only give longer responses if the question truly requires it. 7. Do not mention or refer to these rules in any way. 8. Do not ask follow-up questions. - 9. Do not mention this instructions in your response. - 10. If context does not contain any information to answer the question, return '' + 9. If the context does not contain information to answer the question, return exactly: '' - + human: | Context: {context} Question: {question} - Make sure the response you are generating strictly follow the rules mentioned above i.e. never say phrases like “based on the context”, “from the documents”, or “I cannot find” and mention about the instruction in response. image_captioning_prompt: system: | diff --git a/deploy/helm/nvidia-blueprint-rag/files/sitecustomize.py b/deploy/helm/nvidia-blueprint-rag/files/sitecustomize.py new file mode 100644 index 000000000..361684c07 --- /dev/null +++ b/deploy/helm/nvidia-blueprint-rag/files/sitecustomize.py @@ -0,0 +1,48 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# 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. + +import os + +_n = max(1, int(os.environ.get("RAG_NV_INGEST_DETECTED_CPUS", "1"))) +_cpu_set = set(range(_n)) + +os.cpu_count = lambda: _n +try: + os.sched_getaffinity = lambda _pid=0: set(_cpu_set) +except Exception: + pass + +try: + import psutil + psutil.cpu_count = lambda *a, **k: _n +except Exception: + pass + +# Force-disable Ray dashboard on low-podPidsLimit clusters. nv-ingest's +# entrypoint calls ray.init(dashboard_host=..., dashboard_port=...) explicitly, +# which overrides the RAY_DISABLE_DASHBOARD env var. The dashboard spawns ~10 +# subprocesses carrying ~50 threads each, collectively ~500 PIDs. Patching +# ray.init here forces include_dashboard=False before any worker is spawned. +try: + import ray as _ray + _orig_ray_init = _ray.init + + def _patched_ray_init(*args, **kwargs): + kwargs["include_dashboard"] = False + return _orig_ray_init(*args, **kwargs) + + _ray.init = _patched_ray_init +except Exception: + pass diff --git a/deploy/helm/nvidia-blueprint-rag/templates/_helpers.tpl b/deploy/helm/nvidia-blueprint-rag/templates/_helpers.tpl index d647ecf62..e0182c49d 100644 --- a/deploy/helm/nvidia-blueprint-rag/templates/_helpers.tpl +++ b/deploy/helm/nvidia-blueprint-rag/templates/_helpers.tpl @@ -78,3 +78,18 @@ Get API keys secret name (either existing or created) {{- .Values.apiKeysSecret.name -}} {{- end -}} {{- end -}} + +{{/* +Elasticsearch resource base name +*/}} +{{- define "nvidia-blueprint-rag.elasticsearchFullname" -}} +{{- $esCfg := index .Values "eck-elasticsearch" -}} +{{- default (printf "%s-eck-elasticsearch" .Release.Name) $esCfg.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* +Elasticsearch elastic user secret name +*/}} +{{- define "nvidia-blueprint-rag.elasticsearchUserSecretName" -}} +{{- printf "%s-es-elastic-user" (include "nvidia-blueprint-rag.elasticsearchFullname" .) -}} +{{- end -}} diff --git a/deploy/helm/nvidia-blueprint-rag/templates/deployment.yaml b/deploy/helm/nvidia-blueprint-rag/templates/deployment.yaml index 688e02366..093661200 100644 --- a/deploy/helm/nvidia-blueprint-rag/templates/deployment.yaml +++ b/deploy/helm/nvidia-blueprint-rag/templates/deployment.yaml @@ -44,12 +44,31 @@ spec: - "{{ .Values.server.workers }}" ports: - containerPort: 8081 + {{- $eckCfg := index .Values "eck-elasticsearch" }} + {{- $vectorUser := index .Values.envVars "APP_VECTORSTORE_USERNAME" }} + {{- $vectorPass := index .Values.envVars "APP_VECTORSTORE_PASSWORD" }} + {{- $vectorApiKey := index .Values.envVars "APP_VECTORSTORE_APIKEY" }} + {{- $vectorApiKeyId := index .Values.envVars "APP_VECTORSTORE_APIKEY_ID" }} + {{- $vectorApiKeySecret := index .Values.envVars "APP_VECTORSTORE_APIKEY_SECRET" }} + {{- $useElasticUserSecret := and $eckCfg.enabled (not $vectorUser) (not $vectorPass) (not $vectorApiKey) (not $vectorApiKeyId) (not $vectorApiKeySecret) }} env: {{- if .Values.envVars }} {{- range $k, $v := .Values.envVars }} + {{- if and $useElasticUserSecret (or (eq $k "APP_VECTORSTORE_USERNAME") (eq $k "APP_VECTORSTORE_PASSWORD")) }} + {{- else }} - name: "{{ $k }}" value: "{{ $v }}" {{- end }} + {{- end }} + {{- end }} + {{- if $useElasticUserSecret }} + - name: APP_VECTORSTORE_USERNAME + value: "elastic" + - name: APP_VECTORSTORE_PASSWORD + valueFrom: + secretKeyRef: + name: {{ include "nvidia-blueprint-rag.elasticsearchUserSecretName" . }} + key: elastic {{- end }} - name: NVIDIA_API_KEY valueFrom: @@ -57,7 +76,7 @@ spec: name: {{ .Values.ngcApiSecret.name }} key: NVIDIA_API_KEY {{- $apiKeysSecretName := include "apiKeysSecretName" . }} - {{- $hasAnyKey := or .Values.apiKeysSecret.llmApiKey .Values.apiKeysSecret.embeddingsApiKey .Values.apiKeysSecret.rankingApiKey .Values.apiKeysSecret.queryRewriterApiKey .Values.apiKeysSecret.filterExpressionGeneratorApiKey .Values.apiKeysSecret.vlmApiKey .Values.apiKeysSecret.summaryLlmApiKey .Values.apiKeysSecret.reflectionLlmApiKey }} + {{- $hasAnyKey := or .Values.apiKeysSecret.llmApiKey .Values.apiKeysSecret.embeddingsApiKey .Values.apiKeysSecret.rankingApiKey .Values.apiKeysSecret.queryRewriterApiKey .Values.apiKeysSecret.filterExpressionGeneratorApiKey .Values.apiKeysSecret.vlmApiKey .Values.apiKeysSecret.summaryLlmApiKey .Values.apiKeysSecret.reflectionLlmApiKey .Values.apiKeysSecret.agenticPlannerLlmApiKey .Values.apiKeysSecret.agenticTaskLlmApiKey .Values.apiKeysSecret.agenticSeedGenLlmApiKey .Values.apiKeysSecret.agenticSynthesisLlmApiKey }} {{- $shouldCreateSecret := and .Values.apiKeysSecret.create (not .Values.apiKeysSecret.existingSecret) $hasAnyKey }} {{- $useExistingSecret := .Values.apiKeysSecret.existingSecret }} {{- if or $shouldCreateSecret $useExistingSecret }} @@ -117,6 +136,34 @@ spec: name: {{ $apiKeysSecretName }} key: REFLECTION_LLM_APIKEY {{- end }} + {{- if or $useExistingSecret .Values.apiKeysSecret.agenticPlannerLlmApiKey }} + - name: AGENTIC_PLANNER_LLM_APIKEY + valueFrom: + secretKeyRef: + name: {{ $apiKeysSecretName }} + key: AGENTIC_PLANNER_LLM_APIKEY + {{- end }} + {{- if or $useExistingSecret .Values.apiKeysSecret.agenticTaskLlmApiKey }} + - name: AGENTIC_TASK_LLM_APIKEY + valueFrom: + secretKeyRef: + name: {{ $apiKeysSecretName }} + key: AGENTIC_TASK_LLM_APIKEY + {{- end }} + {{- if or $useExistingSecret .Values.apiKeysSecret.agenticSeedGenLlmApiKey }} + - name: AGENTIC_SEED_GEN_LLM_APIKEY + valueFrom: + secretKeyRef: + name: {{ $apiKeysSecretName }} + key: AGENTIC_SEED_GEN_LLM_APIKEY + {{- end }} + {{- if or $useExistingSecret .Values.apiKeysSecret.agenticSynthesisLlmApiKey }} + - name: AGENTIC_SYNTHESIS_LLM_APIKEY + valueFrom: + secretKeyRef: + name: {{ $apiKeysSecretName }} + key: AGENTIC_SYNTHESIS_LLM_APIKEY + {{- end }} {{- end }} {{- if .Values.livenessProbe }} livenessProbe: @@ -138,4 +185,4 @@ spec: - name: prompt-volume configMap: name: {{ include "nvidia-blueprint-rag.fullname" . }}-prompt - defaultMode: 0555 \ No newline at end of file + defaultMode: 0555 diff --git a/deploy/helm/nvidia-blueprint-rag/templates/embedding-nim.yaml b/deploy/helm/nvidia-blueprint-rag/templates/embedding-nim.yaml index 8ee406635..4102c7fb6 100644 --- a/deploy/helm/nvidia-blueprint-rag/templates/embedding-nim.yaml +++ b/deploy/helm/nvidia-blueprint-rag/templates/embedding-nim.yaml @@ -1,4 +1,4 @@ -{{- $nimModel := index .Values.nimOperator "nvidia-nim-llama-32-nv-embedqa-1b-v2" -}} +{{- $nimModel := index .Values.nimOperator "nvidia-nim-llama-nemotron-embed-1b-v2" -}} {{- if and (.Capabilities.APIVersions.Has "apps.nvidia.com/v1alpha1") (eq $nimModel.enabled true) }} apiVersion: apps.nvidia.com/v1alpha1 kind: NIMCache @@ -12,6 +12,10 @@ spec: modelPuller: "{{ $nimModel.image.repository }}:{{ $nimModel.image.tag }}" pullSecret: {{ .Values.imagePullSecret.name }} authSecret: {{ .Values.ngcApiSecret.name }} + {{- with $nimModel.tolerations }} + tolerations: +{{ toYaml . | nindent 4 }} + {{- end }} storage: pvc: create: {{ $nimModel.storage.pvc.create | default true }} diff --git a/deploy/helm/nvidia-blueprint-rag/templates/ingestor-server-deployment.yaml b/deploy/helm/nvidia-blueprint-rag/templates/ingestor-server-deployment.yaml index 3ba5cfc21..eb75b7bfb 100644 --- a/deploy/helm/nvidia-blueprint-rag/templates/ingestor-server-deployment.yaml +++ b/deploy/helm/nvidia-blueprint-rag/templates/ingestor-server-deployment.yaml @@ -47,19 +47,38 @@ spec: - "{{ $cfg.server.workers }}" ports: - containerPort: 8082 + {{- $eckCfg := index .Values "eck-elasticsearch" }} + {{- $vectorUser := index $cfg.envVars "APP_VECTORSTORE_USERNAME" }} + {{- $vectorPass := index $cfg.envVars "APP_VECTORSTORE_PASSWORD" }} + {{- $vectorApiKey := index $cfg.envVars "APP_VECTORSTORE_APIKEY" }} + {{- $vectorApiKeyId := index $cfg.envVars "APP_VECTORSTORE_APIKEY_ID" }} + {{- $vectorApiKeySecret := index $cfg.envVars "APP_VECTORSTORE_APIKEY_SECRET" }} + {{- $useElasticUserSecret := and $eckCfg.enabled (not $vectorUser) (not $vectorPass) (not $vectorApiKey) (not $vectorApiKeyId) (not $vectorApiKeySecret) }} {{- $apiKeysSecretName := include "apiKeysSecretName" . }} {{- $hasAnyKey := or .Values.apiKeysSecret.embeddingsApiKey .Values.apiKeysSecret.summaryLlmApiKey }} {{- $shouldCreateSecret := and .Values.apiKeysSecret.create (not .Values.apiKeysSecret.existingSecret) $hasAnyKey }} {{- $useExistingSecret := .Values.apiKeysSecret.existingSecret }} {{- $hasApiKeys := or $shouldCreateSecret $useExistingSecret }} - {{- if or $cfg.envVars $hasApiKeys }} + {{- if or $cfg.envVars $hasApiKeys $useElasticUserSecret }} env: {{- if $cfg.envVars }} {{- range $k, $v := $cfg.envVars }} + {{- if and $useElasticUserSecret (or (eq $k "APP_VECTORSTORE_USERNAME") (eq $k "APP_VECTORSTORE_PASSWORD")) }} + {{- else }} - name: "{{ $k }}" value: "{{ $v }}" {{- end }} {{- end }} + {{- end }} + {{- if $useElasticUserSecret }} + - name: APP_VECTORSTORE_USERNAME + value: "elastic" + - name: APP_VECTORSTORE_PASSWORD + valueFrom: + secretKeyRef: + name: {{ include "nvidia-blueprint-rag.elasticsearchUserSecretName" . }} + key: elastic + {{- end }} {{- if $hasApiKeys }} {{- if or $useExistingSecret .Values.apiKeysSecret.embeddingsApiKey }} - name: APP_EMBEDDINGS_APIKEY diff --git a/deploy/helm/nvidia-blueprint-rag/templates/nv-ingest-py-patches.yaml b/deploy/helm/nvidia-blueprint-rag/templates/nv-ingest-py-patches.yaml new file mode 100644 index 000000000..eb9545cb9 --- /dev/null +++ b/deploy/helm/nvidia-blueprint-rag/templates/nv-ingest-py-patches.yaml @@ -0,0 +1,14 @@ +{{- $nv := index .Values "nv-ingest" }} +{{- $py := default (dict) (index $nv "pyPatches") }} +{{- if and $nv.enabled $py.enabled }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Release.Name }}-nv-ingest-py-patches + labels: + {{- include "nvidia-blueprint-rag.labels" . | nindent 4 }} + app.kubernetes.io/component: nv-ingest-py-patches +data: + sitecustomize.py: |- +{{ .Files.Get "files/sitecustomize.py" | indent 4 }} +{{- end }} diff --git a/deploy/helm/nvidia-blueprint-rag/templates/openshift.yaml b/deploy/helm/nvidia-blueprint-rag/templates/openshift.yaml new file mode 100644 index 000000000..d2db63e85 --- /dev/null +++ b/deploy/helm/nvidia-blueprint-rag/templates/openshift.yaml @@ -0,0 +1,117 @@ +{{- if .Values.openshift.enabled }} +{{/* + OpenShift Routes — expose services via the OpenShift router. + Each route is individually toggleable via openshift.routes..enabled. +*/}} + +{{- $routes := .Values.openshift.routes | default dict }} + +{{/* --- Route: rag-frontend --- */}} +{{- $frontendRoute := $routes.frontend | default dict }} +{{- if $frontendRoute.enabled }} +{{- $frontendName := .Values.frontend.appName | default "rag-frontend" }} +{{- $frontendTls := $frontendRoute.tls | default dict }} +apiVersion: route.openshift.io/v1 +kind: Route +metadata: + name: {{ $frontendName }} + labels: + {{- include "nvidia-blueprint-rag.labels" . | nindent 4 }} + app.kubernetes.io/component: rag-frontend + {{- with $frontendRoute.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- with $frontendRoute.host }} + host: {{ . }} + {{- end }} + to: + kind: Service + name: {{ $frontendName }} + weight: 100 + port: + targetPort: {{ .Values.frontend.service.port | default 3000 }} + {{- if $frontendTls }} + tls: + termination: {{ $frontendTls.termination | default "edge" }} + {{- with $frontendTls.insecureEdgeTerminationPolicy }} + insecureEdgeTerminationPolicy: {{ . }} + {{- end }} + {{- end }} + wildcardPolicy: None +{{- end }} + +{{/* --- Route: rag-server (API) --- */}} +{{- $ragServerRoute := $routes.ragServer | default dict }} +{{- if $ragServerRoute.enabled }} +{{- $ragServerTls := $ragServerRoute.tls | default dict }} +--- +apiVersion: route.openshift.io/v1 +kind: Route +metadata: + name: {{ include "nvidia-blueprint-rag.fullname" . }} + labels: + {{- include "nvidia-blueprint-rag.labels" . | nindent 4 }} + app.kubernetes.io/component: rag-server + annotations: + haproxy.router.openshift.io/timeout: {{ $ragServerRoute.timeout | default "300s" | quote }} + {{- with $ragServerRoute.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- with $ragServerRoute.host }} + host: {{ . }} + {{- end }} + to: + kind: Service + name: {{ include "nvidia-blueprint-rag.fullname" . }} + weight: 100 + port: + targetPort: {{ .Values.service.port | default 8081 }} + {{- if $ragServerTls }} + tls: + termination: {{ $ragServerTls.termination | default "edge" }} + {{- with $ragServerTls.insecureEdgeTerminationPolicy }} + insecureEdgeTerminationPolicy: {{ . }} + {{- end }} + {{- end }} + wildcardPolicy: None +{{- end }} + +{{/* + SCC RoleBinding — grants the anyuid SCC to service accounts that need it. + OpenShift's default restricted SCC assigns random UIDs that conflict with + many containers in this chart (NIM, nv-ingest, etcd, Zipkin). +*/}} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ include "nvidia-blueprint-rag.fullname" . }}-anyuid-scc + labels: + {{- include "nvidia-blueprint-rag.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: system:openshift:scc:anyuid +subjects: + - kind: ServiceAccount + name: default + - kind: ServiceAccount + name: {{ include "nvidia-blueprint-rag.serviceAccountName" . }} + - kind: ServiceAccount + name: {{ .Release.Name }}-nv-ingest + - kind: ServiceAccount + name: {{ .Release.Name }}-minio + - kind: ServiceAccount + name: {{ .Release.Name }}-redis-master + - kind: ServiceAccount + name: {{ .Release.Name }}-redis-replica + - kind: ServiceAccount + name: nim-cache-sa +{{- if .Values.zipkin.enabled }} + - kind: ServiceAccount + name: {{ .Release.Name }}-zipkin +{{- end }} +{{- end }} diff --git a/deploy/helm/nvidia-blueprint-rag/templates/reranking-nim.yaml b/deploy/helm/nvidia-blueprint-rag/templates/reranking-nim.yaml index 337f81ef0..62b04ee85 100644 --- a/deploy/helm/nvidia-blueprint-rag/templates/reranking-nim.yaml +++ b/deploy/helm/nvidia-blueprint-rag/templates/reranking-nim.yaml @@ -1,4 +1,4 @@ -{{- $nimModel := index .Values.nimOperator "nvidia-nim-llama-32-nv-rerankqa-1b-v2" -}} +{{- $nimModel := index .Values.nimOperator "nvidia-nim-llama-nemotron-rerank-1b-v2" -}} {{- if and (.Capabilities.APIVersions.Has "apps.nvidia.com/v1alpha1") (eq $nimModel.enabled true) }} apiVersion: apps.nvidia.com/v1alpha1 kind: NIMCache @@ -12,6 +12,10 @@ spec: modelPuller: "{{ $nimModel.image.repository }}:{{ $nimModel.image.tag }}" pullSecret: {{ .Values.imagePullSecret.name }} authSecret: {{ .Values.ngcApiSecret.name }} + {{- with $nimModel.tolerations }} + tolerations: +{{ toYaml . | nindent 4 }} + {{- end }} storage: pvc: create: {{ $nimModel.storage.pvc.create | default true }} diff --git a/deploy/helm/nvidia-blueprint-rag/templates/secrets.yaml b/deploy/helm/nvidia-blueprint-rag/templates/secrets.yaml index 0092c23bb..abb34ebf0 100644 --- a/deploy/helm/nvidia-blueprint-rag/templates/secrets.yaml +++ b/deploy/helm/nvidia-blueprint-rag/templates/secrets.yaml @@ -35,7 +35,7 @@ data: {{- end }} {{ if and .Values.apiKeysSecret.create (not .Values.apiKeysSecret.existingSecret) -}} -{{- $hasAnyKey := or .Values.apiKeysSecret.llmApiKey .Values.apiKeysSecret.embeddingsApiKey .Values.apiKeysSecret.rankingApiKey .Values.apiKeysSecret.queryRewriterApiKey .Values.apiKeysSecret.filterExpressionGeneratorApiKey .Values.apiKeysSecret.vlmApiKey .Values.apiKeysSecret.summaryLlmApiKey .Values.apiKeysSecret.reflectionLlmApiKey -}} +{{- $hasAnyKey := or .Values.apiKeysSecret.llmApiKey .Values.apiKeysSecret.embeddingsApiKey .Values.apiKeysSecret.rankingApiKey .Values.apiKeysSecret.queryRewriterApiKey .Values.apiKeysSecret.filterExpressionGeneratorApiKey .Values.apiKeysSecret.vlmApiKey .Values.apiKeysSecret.summaryLlmApiKey .Values.apiKeysSecret.reflectionLlmApiKey .Values.apiKeysSecret.agenticPlannerLlmApiKey .Values.apiKeysSecret.agenticTaskLlmApiKey .Values.apiKeysSecret.agenticSeedGenLlmApiKey .Values.apiKeysSecret.agenticSynthesisLlmApiKey -}} {{- if $hasAnyKey -}} --- apiVersion: v1 @@ -74,5 +74,17 @@ data: {{- if .Values.apiKeysSecret.reflectionLlmApiKey }} REFLECTION_LLM_APIKEY: {{ .Values.apiKeysSecret.reflectionLlmApiKey | b64enc | quote }} {{- end }} + {{- if .Values.apiKeysSecret.agenticPlannerLlmApiKey }} + AGENTIC_PLANNER_LLM_APIKEY: {{ .Values.apiKeysSecret.agenticPlannerLlmApiKey | b64enc | quote }} + {{- end }} + {{- if .Values.apiKeysSecret.agenticTaskLlmApiKey }} + AGENTIC_TASK_LLM_APIKEY: {{ .Values.apiKeysSecret.agenticTaskLlmApiKey | b64enc | quote }} + {{- end }} + {{- if .Values.apiKeysSecret.agenticSeedGenLlmApiKey }} + AGENTIC_SEED_GEN_LLM_APIKEY: {{ .Values.apiKeysSecret.agenticSeedGenLlmApiKey | b64enc | quote }} + {{- end }} + {{- if .Values.apiKeysSecret.agenticSynthesisLlmApiKey }} + AGENTIC_SYNTHESIS_LLM_APIKEY: {{ .Values.apiKeysSecret.agenticSynthesisLlmApiKey | b64enc | quote }} + {{- end }} {{- end }} {{- end }} diff --git a/deploy/helm/nvidia-blueprint-rag/templates/vlm-captioning-nim.yaml b/deploy/helm/nvidia-blueprint-rag/templates/vlm-captioning-nim.yaml new file mode 100644 index 000000000..b3264bec7 --- /dev/null +++ b/deploy/helm/nvidia-blueprint-rag/templates/vlm-captioning-nim.yaml @@ -0,0 +1,61 @@ +{{- $nimVlmCaptioning := index .Values.nimOperator "nim-vlm-captioning" -}} +{{- if and (.Capabilities.APIVersions.Has "apps.nvidia.com/v1alpha1") (eq $nimVlmCaptioning.enabled true) }} +apiVersion: apps.nvidia.com/v1alpha1 +kind: NIMCache +metadata: + name: {{ $nimVlmCaptioning.service.name }}-cache + annotations: + helm.sh/resource-policy: keep +spec: + source: + ngc: + modelPuller: "{{ $nimVlmCaptioning.image.repository }}:{{ $nimVlmCaptioning.image.tag }}" + pullSecret: {{ .Values.imagePullSecret.name }} + authSecret: {{ .Values.ngcApiSecret.name }} + storage: + pvc: + create: {{ $nimVlmCaptioning.storage.pvc.create | default true }} + {{- if $nimVlmCaptioning.storage.pvc.storageClass }} + storageClass: {{ $nimVlmCaptioning.storage.pvc.storageClass }} + {{- end }} + size: {{ $nimVlmCaptioning.storage.pvc.size | default "50Gi" }} + volumeAccessMode: {{ $nimVlmCaptioning.storage.pvc.volumeAccessMode | default "ReadWriteMany" }} + resources: {} +--- +apiVersion: apps.nvidia.com/v1alpha1 +kind: NIMService +metadata: + name: {{ $nimVlmCaptioning.service.name }} +spec: + image: + repository: {{ $nimVlmCaptioning.image.repository }} + tag: {{ $nimVlmCaptioning.image.tag }} + pullPolicy: {{ $nimVlmCaptioning.image.pullPolicy | default "IfNotPresent" }} + pullSecrets: + - {{ .Values.imagePullSecret.name }} + authSecret: {{ .Values.ngcApiSecret.name }} + storage: + nimCache: + name: {{ $nimVlmCaptioning.service.name }}-cache + env: +{{ toYaml $nimVlmCaptioning.env | nindent 4 }} + replicas: {{ $nimVlmCaptioning.replicas | default 1 }} + {{- with $nimVlmCaptioning.nodeSelector }} + nodeSelector: +{{ toYaml . | nindent 4 }} + {{- end }} +{{- if .Values.nimOperator.draResources.enabled }} + draResources: +{{ toYaml $nimVlmCaptioning.draResources | nindent 4 }} +{{- end }} +{{- if not .Values.nimOperator.draResources.enabled }} + resources: +{{ toYaml $nimVlmCaptioning.resources | nindent 4 }} +{{- end }} + {{- with $nimVlmCaptioning.tolerations }} + tolerations: +{{ toYaml . | nindent 4 }} + {{- end }} + expose: +{{ toYaml $nimVlmCaptioning.expose | nindent 4 }} +{{- end }} diff --git a/deploy/helm/nvidia-blueprint-rag/templates/vlm-reranker-nim.yaml b/deploy/helm/nvidia-blueprint-rag/templates/vlm-reranker-nim.yaml new file mode 100644 index 000000000..79c6a2832 --- /dev/null +++ b/deploy/helm/nvidia-blueprint-rag/templates/vlm-reranker-nim.yaml @@ -0,0 +1,60 @@ +{{- $nimModel := index .Values.nimOperator "nvidia-nim-llama-nemotron-rerank-vl-1b-v2" -}} +{{- if and (.Capabilities.APIVersions.Has "apps.nvidia.com/v1alpha1") (eq $nimModel.enabled true) }} +apiVersion: apps.nvidia.com/v1alpha1 +kind: NIMCache +metadata: + name: {{ $nimModel.service.name }}-cache + annotations: + helm.sh/resource-policy: keep +spec: + source: + ngc: + modelPuller: "{{ $nimModel.image.repository }}:{{ $nimModel.image.tag }}" + pullSecret: {{ .Values.imagePullSecret.name }} + authSecret: {{ .Values.ngcApiSecret.name }} + storage: + pvc: + create: {{ $nimModel.storage.pvc.create | default true }} + {{- if $nimModel.storage.pvc.storageClass }} + storageClass: {{ $nimModel.storage.pvc.storageClass }} + {{- end }} + size: {{ $nimModel.storage.pvc.size | default "50Gi" }} + volumeAccessMode: {{ $nimModel.storage.pvc.volumeAccessMode | default "ReadWriteMany" }} +--- +apiVersion: apps.nvidia.com/v1alpha1 +kind: NIMService +metadata: + name: {{ $nimModel.service.name }} +spec: + image: + repository: {{ $nimModel.image.repository }} + tag: {{ $nimModel.image.tag }} + pullPolicy: {{ $nimModel.image.pullPolicy | default "IfNotPresent" }} + pullSecrets: + - {{ .Values.imagePullSecret.name }} + authSecret: {{ .Values.ngcApiSecret.name }} + storage: + nimCache: + name: {{ $nimModel.service.name }}-cache + replicas: {{ $nimModel.replicas | default 1 }} + {{- with $nimModel.nodeSelector }} + nodeSelector: +{{ toYaml . | nindent 4 }} + {{- end }} +{{- if .Values.nimOperator.draResources.enabled }} + draResources: +{{ toYaml $nimModel.draResources | nindent 4 }} +{{- end }} +{{- if not .Values.nimOperator.draResources.enabled }} + resources: +{{ toYaml $nimModel.resources | nindent 4 }} +{{- end }} + {{- with $nimModel.tolerations }} + tolerations: +{{ toYaml . | nindent 4 }} + {{- end }} + expose: +{{ toYaml $nimModel.expose | nindent 4 }} + env: +{{ toYaml $nimModel.env | nindent 4 }} +{{- end }} diff --git a/deploy/helm/nvidia-blueprint-rag/values-openshift-test.yaml b/deploy/helm/nvidia-blueprint-rag/values-openshift-test.yaml new file mode 100644 index 000000000..6463dcf01 --- /dev/null +++ b/deploy/helm/nvidia-blueprint-rag/values-openshift-test.yaml @@ -0,0 +1,131 @@ +# OpenShift test override: NVIDIA-hosted LLM, self-hosted embedding/ranking/ingestion on L40S +# +# Usage: +# helm install rag . -f values-openshift.yaml -f values-openshift-test.yaml \ +# --set imagePullSecret.password="$NGC_API_KEY" \ +# --set ngcApiSecret.password="$NGC_API_KEY" \ +# -n +# +# Post-install (NIM Operator limitation — the SA it creates needs the pull secret): +# oc secrets link nim-cache-sa ngc-secret --for=pull -n +# oc delete pod -l app.nvidia.com/nim-cache -n + +openshift: + enabled: true + routes: + frontend: + enabled: true + tls: + termination: edge + insecureEdgeTerminationPolicy: Redirect + ragServer: + enabled: true + timeout: "300s" + tls: + termination: edge + insecureEdgeTerminationPolicy: Redirect + +# --- Disable self-hosted LLM NIM (use NVIDIA API Catalog instead) --- +nimOperator: + nim-llm: + enabled: false + nvidia-nim-llama-nemotron-embed-1b-v2: + tolerations: + - key: "g6-gpu" + operator: Exists + effect: NoSchedule + nvidia-nim-llama-nemotron-rerank-1b-v2: + tolerations: + - key: "g6-gpu" + operator: Exists + effect: NoSchedule + +# --- Point LLM-related endpoints to NVIDIA-hosted (empty = API Catalog) --- +envVars: + APP_LLM_SERVERURL: "" + APP_QUERYREWRITER_SERVERURL: "" + APP_FILTEREXPRESSIONGENERATOR_SERVERURL: "" + REFLECTION_LLM_SERVERURL: "" + +# --- Ingestor server --- +ingestor-server: + envVars: + SUMMARY_LLM_SERVERURL: "" + resources: + limits: + memory: "8Gi" + requests: + memory: "4Gi" + tolerations: + - key: "g6-gpu" + operator: Exists + effect: NoSchedule + persistence: + storageClass: "gp3-csi" + +# --- NV-Ingest --- +nv-ingest: + image: + repository: "nvcr.io/nvidia/nemo-microservices/nv-ingest" + tag: "26.3.0" + resources: + requests: + cpu: "2" + memory: "8Gi" + nvidia.com/gpu: "0" + limits: + cpu: "4" + memory: "16Gi" + nvidia.com/gpu: "0" + tolerations: + - key: "g6-gpu" + operator: Exists + effect: NoSchedule + nimOperator: + graphic_elements: + enabled: false + table_structure: + enabled: false + ocr: + tolerations: + - key: "g6-gpu" + operator: Exists + effect: NoSchedule + page_elements: + tolerations: + - key: "g6-gpu" + operator: Exists + effect: NoSchedule + milvus: + standalone: + resources: + requests: {} + limits: {} + tolerations: + - key: "g6-gpu" + operator: Exists + effect: NoSchedule + persistence: + storageClass: "gp3-csi" + etcd: + persistence: + storageClass: "gp3-csi" + minio: + persistence: + storageClass: "gp3-csi" + +# --- Disable observability stack --- +eck-elasticsearch: + enabled: false + +serviceMonitor: + enabled: false + +opentelemetry-collector: + enabled: false + +zipkin: + enabled: false + +kube-prometheus-stack: + enabled: false diff --git a/deploy/helm/nvidia-blueprint-rag/values-openshift.yaml b/deploy/helm/nvidia-blueprint-rag/values-openshift.yaml new file mode 100644 index 000000000..dd0e45b46 --- /dev/null +++ b/deploy/helm/nvidia-blueprint-rag/values-openshift.yaml @@ -0,0 +1,78 @@ +# OpenShift overlay for the NVIDIA RAG Blueprint Helm chart. +# Usage: +# helm install rag . -f values.yaml -f values-openshift.yaml -n + +openshift: + enabled: true + routes: + frontend: + enabled: true + tls: + termination: edge + insecureEdgeTerminationPolicy: Redirect + ragServer: + enabled: true + tls: + termination: edge + insecureEdgeTerminationPolicy: Redirect + +# Override default NodePort to ClusterIP — Routes handle external access. +frontend: + service: + type: ClusterIP + +# Reduce rag-server uvicorn worker count for low-podPidsLimit clusters. +# The base values.yaml ships with server.workers: 128, which spawns 128 Python +# worker processes per replica. On clusters where kubelet enforces a low +# podPidsLimit (default 4096 on OpenShift), this consumes most of the cgroup +# PID budget before the application is even ready. 1 worker is sufficient for +# the default 1-replica deployment; raise this only after the cluster admin +# raises kubelet podPidsLimit (typically to 16384) via a KubeletConfig CR. +server: + workers: 4 + +# nv-ingest workaround for OpenShift's low kubelet podPidsLimit (default 4096): +# On nodes that expose the full host cpuset to the pod (cpuset.cpus=0-N), Ray's +# raylet auto-detects all N CPUs from psutil.cpu_count() and prestarts hundreds +# of workers. Each worker spawns ~5-10 gRPC threads in static init before it +# can register with the raylet, and the burst breaches the pod PID ceiling — +# pthread_create returns EAGAIN, workers crash, raylet retries forever, the +# pipeline driver hangs with no actors, and ingest jobs sit in Redis forever. +# +# This overlay mounts a sitecustomize.py that monkey-patches os.cpu_count, +# os.sched_getaffinity, and psutil.cpu_count to return RAG_NV_INGEST_DETECTED_CPUS +# so Ray prestarts that many workers. The chart ships sitecustomize.py at +# files/sitecustomize.py and creates the ConfigMap when pyPatches.enabled=true. +nv-ingest: + pyPatches: + enabled: true + + # nv-ingest 26.3.0 ships only one Python runtime at /opt/nv_ingest_runtime/ + # (no separate conda env), so a single mount at its site-packages is enough. + # Both the entrypoint and Ray-spawned workers run /opt/nv_ingest_runtime/bin/python + # and auto-import sitecustomize from this path on startup. + extraVolumes: + py-patches: + configMap: + name: rag-nv-ingest-py-patches + extraVolumeMounts: + py-patches: + mountPath: /opt/nv_ingest_runtime/lib/python3.12/site-packages/sitecustomize.py + subPath: sitecustomize.py + readOnly: true + + envVars: + # Consumed by the mounted sitecustomize.py. 4 gives the pipeline enough + # parallelism to overlap stages while staying well under podPidsLimit=4096. + # Raise to 8 once 4 is confirmed working; only go higher after the kubelet + # podPidsLimit has been raised by cluster admin. + RAG_NV_INGEST_DETECTED_CPUS: "4" + + # Caps Ray actor replicas per pipeline stage. The default of 16 (set in the + # base values.yaml) spawns 16 PDFExtract actors plus 6+6 Table/Chart actors, + # etc. Each actor is a Python process with ~50 threads. Multiplied across + # stages, baseline lands at ~4000 PIDs — right at the cgroup ceiling — and + # any runtime thread spawn (gRPC channel, asyncio.to_thread, threadpool grow) + # then fails with EAGAIN. Capping to 4 keeps steady-state PIDs well below + # the limit at the cost of slower per-document throughput. + MAX_INGEST_PROCESS_WORKERS: "4" diff --git a/deploy/helm/nvidia-blueprint-rag/values.yaml b/deploy/helm/nvidia-blueprint-rag/values.yaml index bc21934c4..e19b30c14 100644 --- a/deploy/helm/nvidia-blueprint-rag/values.yaml +++ b/deploy/helm/nvidia-blueprint-rag/values.yaml @@ -1,6 +1,12 @@ # -- Global chart configuration nameOverride: "" fullnameOverride: "rag-server" + +# -- OpenShift / OKD support +# When enabled, the chart creates Routes (instead of relying on manual oc commands) +# and a RoleBinding granting the anyuid SCC to service accounts that need it. +openshift: + enabled: false # subsection: rag-server # RAG Orchestrator Service # -- Kubernetes scheduling @@ -53,11 +59,15 @@ apiKeysSecret: vlmApiKey: "" summaryLlmApiKey: "" reflectionLlmApiKey: "" + agenticPlannerLlmApiKey: "" + agenticTaskLlmApiKey: "" + agenticSeedGenLlmApiKey: "" + agenticSynthesisLlmApiKey: "" # -- RAG server container image image: repository: nvcr.io/nvidia/blueprint/rag-server - tag: "2.5.1" + tag: "2.6.0" pullPolicy: Always # -- RAG server service configuration @@ -92,7 +102,7 @@ readinessProbe: # -- RAG server runtime configuration server: - workers: 8 + workers: 128 # -- Enable/disable creation of prompt ConfigMap promptConfig: @@ -108,10 +118,10 @@ envVars: ## Service-specific API keys are now managed via apiKeysSecret section above. ## See apiKeysSecret configuration for details on setting service-specific keys. - ##===MINIO specific configurations used to store multimodal base64 content=== - MINIO_ENDPOINT: "rag-minio:9000" - MINIO_ACCESSKEY: "minioadmin" - MINIO_SECRETKEY: "minioadmin" + ##===Object-store configurations used to store multimodal base64 content=== + OBJECTSTORE_ENDPOINT: "rag-seaweedfs-all-in-one:9010" + OBJECTSTORE_ACCESSKEY: "seaweedfsadmin" + OBJECTSTORE_SECRETKEY: "seaweedfsadmin" ##===Redis configurations for summary status tracking=== REDIS_HOST: "rag-redis-master" @@ -120,10 +130,10 @@ envVars: ##===Vector DB specific configurations=== # URL on which vectorstore is hosted - APP_VECTORSTORE_URL: "http://milvus:19530" # Use "http://rag-eck-elasticsearch-es-http:9200" for elasticsearch - # Type of vectordb used to store embedding supported type "milvus" or "elasticsearch" - APP_VECTORSTORE_NAME: "milvus" - # Index type (e.g., GPU_CAGRA) + APP_VECTORSTORE_URL: "http://rag-eck-elasticsearch-es-default:9200" + # Type of vectordb used to store embedding + APP_VECTORSTORE_NAME: "elasticsearch" + # Index type used by the vectorstore (Elasticsearch uses its own index mapping) APP_VECTORSTORE_INDEXTYPE: "GPU_CAGRA" # Type of vectordb search to be used APP_VECTORSTORE_SEARCHTYPE: "dense" @@ -133,8 +143,8 @@ envVars: APP_VECTORSTORE_DENSE_WEIGHT: "0.5" # Weight for sparse vector search in case of "weighted" Hybrid Search APP_VECTORSTORE_SPARSE_WEIGHT: "0.5" - # Boolean to control GPU search for milvus vectorstore specific to rag-server - APP_VECTORSTORE_ENABLEGPUSEARCH: "True" + # GPU search is not supported by Elasticsearch. + APP_VECTORSTORE_ENABLEGPUSEARCH: "False" # ef: Parameter controlling query time/accuracy trade-off. Higher ef leads to more accurate but slower search. APP_VECTORSTORE_EF: "100" # Username for vector store authentication @@ -156,22 +166,21 @@ envVars: APP_RETRIEVER_TOPK: "10" ##===LLM Model specific configurations=== - APP_LLM_MODELNAME: "nvidia/llama-3.3-nemotron-super-49b-v1.5" + APP_LLM_MODELNAME: "nvidia/nemotron-3-super-120b-a12b" # URL on which LLM model is hosted. If "", Nvidia hosted API is used APP_LLM_SERVERURL: "nim-llm:8000" # LLM model parameters - # For Nemotron 3 Super on RTX 6000 Pro: uncomment and set to 16256 (reasoning) or 1024 (non-reasoning); comment LLM_MAX_TOKENS above - LLM_MAX_TOKENS: "32768" # "16256" + LLM_MAX_TOKENS: "16256" LLM_TEMPERATURE: "0" LLM_TOP_P: "1.0" ##===Query Rewriter Model specific configurations=== - APP_QUERYREWRITER_MODELNAME: "nvidia/llama-3.3-nemotron-super-49b-v1.5" + APP_QUERYREWRITER_MODELNAME: "nvidia/nemotron-3-super-120b-a12b" # URL on which query rewriter model is hosted. If "", Nvidia hosted API is used APP_QUERYREWRITER_SERVERURL: "nim-llm:8000" ##===Filter Expression Generator Model specific configurations=== - APP_FILTEREXPRESSIONGENERATOR_MODELNAME: "nvidia/llama-3.3-nemotron-super-49b-v1.5" + APP_FILTEREXPRESSIONGENERATOR_MODELNAME: "nvidia/nemotron-3-super-120b-a12b" # URL on which filter expression generator model is hosted. If "", Nvidia hosted API is used APP_FILTEREXPRESSIONGENERATOR_SERVERURL: "nim-llm:8000" # enable filter expression generator for natural language to filter expression conversion @@ -179,8 +188,8 @@ envVars: ##===Embedding Model specific configurations=== # URL on which embedding model is hosted. If "", Nvidia hosted API is used - APP_EMBEDDINGS_SERVERURL: "nemotron-embedding-ms:8000/v1" - APP_EMBEDDINGS_MODELNAME: "nvidia/llama-nemotron-embed-1b-v2" + APP_EMBEDDINGS_SERVERURL: "nemotron-vlm-embedding-ms:8000/v1" + APP_EMBEDDINGS_MODELNAME: "nvidia/llama-nemotron-embed-vl-1b-v2" APP_EMBEDDINGS_DIMENSIONS: "2048" ##===Reranking Model specific configurations=== @@ -190,6 +199,8 @@ envVars: ENABLE_RERANKER: "True" # Default score threshold for filtering documents by reranker relevance (0.0 to 1.0) RERANKER_SCORE_THRESHOLD: "0.0" + # When True, images from retrieved citations are included in VLM reranker passages + ENABLE_VLM_RERANKER_IMAGE_INPUT: "False" ##===VLM Model specific configurations=== ENABLE_VLM_INFERENCE: "False" @@ -200,10 +211,10 @@ envVars: APP_VLM_MAX_TOTAL_IMAGES: "5" # Whether to filter out reasoning tokens from VLM responses (stream only content, not reasoning) VLM_FILTER_THINK_TOKENS: "true" - # Enable reasoning mode for Nemotron 3 nano omni reasoning model + # Enable Nemotron Omni reasoning mode (separates chain-of-thought into a `reasoning` delta) APP_VLM_ENABLE_THINKING: "true" # Max reasoning tokens for VLM (0 = no cap); only applied when enable_thinking is true - APP_VLM_THINKING_TOKEN_BUDGET: "0" + APP_VLM_THINKING_TOKEN_BUDGET: "16384" # VLM generation parameters APP_VLM_MAX_TOKENS: "32768" APP_VLM_TEMPERATURE: "0.6" @@ -252,7 +263,7 @@ envVars: # Minimum groundedness score threshold (0-2) RESPONSE_GROUNDEDNESS_THRESHOLD: "1" # reflection llm - REFLECTION_LLM: "nvidia/llama-3.3-nemotron-super-49b-v1.5" + REFLECTION_LLM: "nvidia/nemotron-3-super-120b-a12b" # reflection llm server url. If "", Nvidia hosted API is used REFLECTION_LLM_SERVERURL: "nim-llm:8000" @@ -262,10 +273,10 @@ envVars: # Whether to filter content within tags in model responses FILTER_THINK_TOKENS: "true" - # Reasoning configuration (supported by Nemotron 3 and other reasoning models) - LLM_ENABLE_THINKING: "false" - LLM_REASONING_BUDGET: "0" - LLM_LOW_EFFORT: "false" + # Reasoning configuration (enabled by default for Nemotron 3 Super) + LLM_ENABLE_THINKING: "true" + LLM_REASONING_BUDGET: "256" + LLM_LOW_EFFORT: "true" NEMO_GUARDRAILS_URL: "nemo-guardrails:7331" @@ -274,6 +285,39 @@ envVars: # maximum recursion depth for iterative query decomposition MAX_RECURSION_DEPTH: "3" + # === Agentic RAG (LangGraph plan-and-execute pipeline) === + # Enable agentic RAG pipeline for knowledge-base queries + ENABLE_AGENTIC_RAG: "false" + + ##===Agentic Planner LLM configurations=== + # URL on which agentic planner LLM is hosted. If "", Nvidia hosted API is used + AGENTIC_PLANNER_LLM_SERVERURL: "nim-llm:8000" + AGENTIC_PLANNER_LLM_MODEL: "nvidia/nemotron-3-super-120b-a12b" + + ##===Agentic Task LLM configurations=== + # URL on which agentic task LLM is hosted. If "", Nvidia hosted API is used + AGENTIC_TASK_LLM_SERVERURL: "nim-llm:8000" + AGENTIC_TASK_LLM_MODEL: "nvidia/nemotron-3-super-120b-a12b" + + ##===Agentic Seed Gen LLM configurations=== + # URL on which agentic seed-gen LLM is hosted. If "", Nvidia hosted API is used + AGENTIC_SEED_GEN_LLM_SERVERURL: "nim-llm:8000" + AGENTIC_SEED_GEN_LLM_MODEL: "nvidia/nemotron-3-super-120b-a12b" + + ##===Agentic Synthesis LLM configurations=== + # URL on which agentic synthesis LLM is hosted. If "", Nvidia hosted API is used + AGENTIC_SYNTHESIS_LLM_SERVERURL: "nim-llm:8000" + AGENTIC_SYNTHESIS_LLM_MODEL: "nvidia/nemotron-3-super-120b-a12b" + + # Agent behaviour tuning + AGENTIC_LOG_LEVEL: "INFO" + + # Verification pass (disabled by default) + AGENTIC_VERIFICATION_ENABLED: "false" + + # Context window budget for retrieved chunks + AGENTIC_CONTEXT_MAX_TOKENS: "100000" + # -- Ingestor Server # subsection: ingestor-server # Ingestor API Service @@ -297,7 +341,7 @@ ingestor-server: image: repository: nvcr.io/nvidia/blueprint/ingestor-server - tag: "2.5.0" + tag: "2.6.0" pullPolicy: Always # -- Service config for ingestor-server @@ -324,8 +368,8 @@ ingestor-server: # Absolute path to custom prompt.yaml file PROMPT_CONFIG_FILE: "/prompt.yaml" # === Vector Store Configurations === - APP_VECTORSTORE_URL: "http://milvus:19530" # Use "http://rag-eck-elasticsearch-es-http:9200" for elasticsearch - APP_VECTORSTORE_NAME: "milvus" # supported values: "milvus" or "elasticsearch" + APP_VECTORSTORE_URL: "http://rag-eck-elasticsearch-es-default:9200" + APP_VECTORSTORE_NAME: "elasticsearch" APP_VECTORSTORE_SEARCHTYPE: "dense" # Type of ranker to use for vector store in case of Hybrid Search APP_VECTORSTORE_RANKER_TYPE: "rrf" # Can be "rrf" or "weighted" @@ -333,8 +377,10 @@ ingestor-server: APP_VECTORSTORE_DENSE_WEIGHT: "0.5" # Weight for sparse vector search in case of "weighted" Hybrid Search APP_VECTORSTORE_SPARSE_WEIGHT: "0.5" - APP_VECTORSTORE_ENABLEGPUINDEX: "True" - APP_VECTORSTORE_ENABLEGPUSEARCH: "True" + # Enable GPU index building (requires GPU-capable Elasticsearch image and license). + APP_VECTORSTORE_ENABLEGPUINDEX: "False" + # GPU search is not supported by Elasticsearch. + APP_VECTORSTORE_ENABLEGPUSEARCH: "False" # Username for vector store authentication APP_VECTORSTORE_USERNAME: "" # Password for vector store authentication @@ -346,18 +392,18 @@ ingestor-server: APP_VECTORSTORE_APIKEY: "" COLLECTION_NAME: "multimodal_data" - # === MinIO Configurations === - MINIO_ENDPOINT: "rag-minio:9000" - MINIO_ACCESSKEY: "minioadmin" - MINIO_SECRETKEY: "minioadmin" + # === Object Store Configurations === + OBJECTSTORE_ENDPOINT: "rag-seaweedfs-all-in-one:9010" + OBJECTSTORE_ACCESSKEY: "seaweedfsadmin" + OBJECTSTORE_SECRETKEY: "seaweedfsadmin" # === Authentication === ## Service-specific API keys are managed via apiKeysSecret section (see top-level config). ## APP_EMBEDDINGS_APIKEY and SUMMARY_LLM_APIKEY are loaded from secrets automatically. # === Embeddings Configurations === - APP_EMBEDDINGS_SERVERURL: "nemotron-embedding-ms:8000/v1" - APP_EMBEDDINGS_MODELNAME: "nvidia/llama-nemotron-embed-1b-v2" + APP_EMBEDDINGS_SERVERURL: "nemotron-vlm-embedding-ms:8000/v1" + APP_EMBEDDINGS_MODELNAME: "nvidia/llama-nemotron-embed-vl-1b-v2" APP_EMBEDDINGS_DIMENSIONS: "2048" # === NV-Ingest Configurations === @@ -371,19 +417,25 @@ ingestor-server: APP_NVINGEST_EXTRACTINFOGRAPHICS: "False" # Enable infographic extraction APP_NVINGEST_EXTRACTTABLES: "True" # Enable table extraction APP_NVINGEST_EXTRACTCHARTS: "True" # Enable chart extraction - APP_NVINGEST_EXTRACTIMAGES: "False" # Enable image extraction (opt-in for VLM caption pipeline) + APP_NVINGEST_EXTRACTIMAGES: "False" # Enable image extraction APP_NVINGEST_EXTRACTPAGEASIMAGE: "False" # Extracts each page as image if enabled APP_NVINGEST_STRUCTURED_ELEMENTS_MODALITY: "" # "image", "text_image" APP_NVINGEST_IMAGE_ELEMENTS_MODALITY: "" # "image" APP_NVINGEST_TEXTDEPTH: "page" # Extract text by "page" or "document" # === NV-Ingest caption configurations === - APP_NVINGEST_CAPTIONMODELNAME: "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning" # Model name for captioning - APP_NVINGEST_CAPTIONENDPOINTURL: "" # Endpoint URL for captioning model + APP_NVINGEST_CAPTIONMODELNAME: "nvidia/nemotron-nano-12b-v2-vl" # Model name for captioning + APP_NVINGEST_CAPTIONENDPOINTURL: "http://nim-vlm-captioning:8000/v1/chat/completions" # Endpoint URL for captioning model + + # NeMo Retriever invoke URLs (HTTP) for local NIMs (see rag-nv-ingest YOLOX_*_HTTP_ENDPOINT) + APP_NVINGEST_PAGEELEMENTSURL: "http://nemotron-page-elements-v3:8000/v1/infer" + APP_NVINGEST_GRAPHICELEMENTSURL: "http://nemotron-graphic-elements-v1:8000/v1/infer" + APP_NVINGEST_OCRURL: "http://nemotron-ocr-v1:8000/v1/infer" + APP_NVINGEST_TABLESTRUCTUREURL: "http://nemotron-table-structure-v1:8000/v1/infer" # === NV-Ingest save to disk configurations === APP_NVINGEST_SAVETODISK: "False" - NVINGEST_MINIO_BUCKET: "nv-ingest" # If this value is modified, ensure the corresponding Milvus bucketName is also updated + NVINGEST_OBJECTSTORE_BUCKET: "nv-ingest" # === NV-Ingest performance configurations === APP_NVINGEST_ENABLE_PDF_SPLIT_PROCESSING: "False" @@ -391,7 +443,7 @@ ingestor-server: # === General === # Summary Model Configurations - SUMMARY_LLM: "nvidia/llama-3.3-nemotron-super-49b-v1.5" + SUMMARY_LLM: "nvidia/nemotron-3-super-120b-a12b" SUMMARY_LLM_SERVERURL: "nim-llm:8000" SUMMARY_LLM_MAX_CHUNK_LENGTH: "9000" SUMMARY_CHUNK_OVERLAP: "400" @@ -406,7 +458,7 @@ ingestor-server: # === NV-Ingest splitting configurations === APP_NVINGEST_CHUNKSIZE: "512" # Size of chunks for splitting APP_NVINGEST_CHUNKOVERLAP: "150" # Overlap size for chunks - APP_NVINGEST_ENABLEPDFSPLITTER: "True" # Enable PDF splitter + APP_NVINGEST_ENABLE_PAGED_DOC_SPLIT: "False" # Enable paged document splitting APP_NVINGEST_SEGMENTAUDIO: "False" # Enable audio segmentation for NV Ingest # === Redis configurations === @@ -415,8 +467,6 @@ ingestor-server: REDIS_DB: "0" ENABLE_REDIS_BACKEND: "False" - # === Bulk upload to MinIO === - ENABLE_MINIO_BULK_UPLOAD: "True" TEMP_DIR: "/tmp-data" INGESTOR_SERVER_DATA_DIR: "/data/" @@ -424,6 +474,8 @@ ingestor-server: NV_INGEST_FILES_PER_BATCH: "16" NV_INGEST_CONCURRENT_BATCHES: "4" ENABLE_NV_INGEST_DYNAMIC_BATCHING: "True" + # Max memory budget (MB) for a single ingestion job; used for dynamic batch sizing + INGESTION_MAX_MEMORY_BUDGET_MB: "1024" # === Tracing === APP_TRACING_ENABLED: "False" @@ -450,6 +502,48 @@ ingestor-server: # Optional subPath within the PVC subPath: "" +# -- SeaweedFS shared object store +seaweedfs: + enabled: true + fullnameOverride: "rag-seaweedfs" + nameOverride: "seaweedfs" + image: + repository: "chrislusf/seaweedfs" + tag: "3.73" + global: + seaweedfs: + createClusterRole: false + imagePullPolicy: IfNotPresent + master: + enabled: false + volume: + enabled: false + filer: + enabled: false + s3: + enabled: false + enableAuth: true + credentials: + admin: + accessKey: "seaweedfsadmin" + secretKey: "seaweedfsadmin" + sftp: + enabled: false + allInOne: + enabled: true + service: + type: ClusterIP + s3: + enabled: true + port: 9010 + enableAuth: true + data: + type: "persistentVolumeClaim" + storageClass: "" + accessModes: + - ReadWriteOnce + size: 50Gi + # -- Frontend # subsection: frontend # rag frontend Frontend @@ -462,7 +556,7 @@ frontend: image: repository: nvcr.io/nvidia/blueprint/rag-frontend pullPolicy: IfNotPresent - tag: "2.5.0" + tag: "2.6.0" imagePullSecret: name: "ngc-secret" @@ -485,12 +579,15 @@ frontend: value: "http://rag-server:8081/v1" - name: VITE_API_VDB_URL value: "http://ingestor-server:8082/v1" - - name: VITE_MILVUS_URL - value: "http://milvus:19530" -# -- Elasticsearch dependency toggle +# -- Elasticsearch dependency toggle (ECK). When true, align APP_VECTORSTORE_URL with the ES HTTP service (e.g. rag-eck-elasticsearch-es-http:9200). eck-elasticsearch: - enabled: false + fullnameOverride: "rag-eck-elasticsearch" + enabled: true + # Uncomment this line to use a custom Elasticsearch GPU image + # image: + # Preserve PVCs on helm uninstall; only delete when a node is explicitly scaled down. + volumeClaimDeletePolicy: DeleteOnScaledownOnly http: tls: selfSignedCertificate: @@ -503,11 +600,44 @@ eck-elasticsearch: xpack.security.enabled: false xpack.security.http.ssl.enabled: false xpack.security.transport.ssl.enabled: false + # Uncomment this line to use GPU for Elasticsearch + # vectors.indexing.use_gpu: true + volumeClaimTemplates: + - metadata: + name: elasticsearch-data + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 50Gi podTemplate: spec: + # Uncomment this line to download the image from NGC + # imagePullSecrets: + # - name: ngc-secret + # JVM heap (-Xmx) must be ≤ 50% of pod memory limit; limit ≥ 2× -Xmx (here 2g heap → 4Gi limit). + initContainers: + - name: elastic-internal-init-filesystem + env: + - name: ES_JAVA_OPTS + value: "-Xms2g -Xmx2g" containers: - name: elasticsearch - # Override readiness probe to not require authentication + env: + - name: ES_JAVA_OPTS + value: "-Xms2g -Xmx2g" + resources: + requests: + memory: "4Gi" + cpu: "500m" + # Uncomment this line to use GPU for Elasticsearch + # nvidia.com/gpu: 1 + limits: + memory: "4Gi" + # Uncomment this line to use GPU for Elasticsearch + # nvidia.com/gpu: 1 + # Override readiness probe to not require authentication; tolerant under ingest load readinessProbe: exec: command: @@ -515,10 +645,10 @@ eck-elasticsearch: - -c - | curl -s http://localhost:9200/_cluster/health | grep -q '"status":"green"\|"status":"yellow"' - initialDelaySeconds: 10 + initialDelaySeconds: 30 periodSeconds: 10 timeoutSeconds: 5 - failureThreshold: 3 + failureThreshold: 10 # -- Observability # subsection: serviceMonitor @@ -662,39 +792,35 @@ nimOperator: service: name: "nim-llm" image: - repository: nvcr.io/nim/nvidia/llama-3.3-nemotron-super-49b-v1.5 + repository: nvcr.io/nim/nvidia/nemotron-3-super-120b-a12b pullPolicy: IfNotPresent - tag: "1.14.0" -# -- For Nemotron 3 Super: uncomment the block below and comment the image block above + tag: "1.8.0" +# -- To switch back to Llama 3.3 Nemotron Super 49B (single GPU), replace the image block above with: # image: # repository: nvcr.io/nim/nvidia/nemotron-3-super-120b-a12b # pullPolicy: IfNotPresent -# tag: "1.8.0" +# tag: "1.14.0" +# -- and revert resources to 1 GPU and model engine to tensorrt_llm below. resources: limits: - nvidia.com/gpu: 1 + nvidia.com/gpu: 2 requests: - nvidia.com/gpu: 1 -# -- For Nemotron 3 Super (all hardware): uncomment the block below and comment the resources block above + nvidia.com/gpu: 2 +# -- To switch back to Llama 3.3 Nemotron Super 49B (single GPU), replace resources above with: # resources: # limits: -# nvidia.com/gpu: 2 +# nvidia.com/gpu: 1 # requests: -# nvidia.com/gpu: 2 +# nvidia.com/gpu: 1 nodeSelector: {} tolerations: [] model: - engine: tensorrt_llm -# -- Uncomment this section to enable FP8 precision and throughput profile for the NIM LLM, change the GPU product according to the GPU you are using -# precision: "fp8" -# qosProfile: "throughput" -# tensorParallelism: "1" -# gpus: -# - product: "rtx6000_blackwell_sv" -# -- For Nemotron 3 Super (all hardware): comment "engine: tensorrt_llm" above and uncomment the three lines below -# engine: vllm -# precision: "fp8" -# tensorParallelism: "2" + engine: vllm + precision: "fp8" + tensorParallelism: "2" +# -- To switch back to Llama 3.3 Nemotron Super 49B, replace the model block above with: +# model: +# engine: tensorrt_llm storage: pvc: create: true @@ -724,18 +850,16 @@ nimOperator: - name: NIM_TRITON_LOG_VERBOSE value: "1" - name: NIM_SERVED_MODEL_NAME - value: "nvidia/llama-3.3-nemotron-super-49b-v1.5" - - name: NIM_MAX_MODEL_LEN - value: "131072" -# -- For Nemotron 3 Super on RTX 6000 Pro: comment the NIM_MAX_MODEL_LEN entry above and uncomment the block below -# - name: NIM_MAX_MODEL_LEN -# value: "32768" -# - name: NCCL_P2P_DISABLE -# value: "1" -# - name: NIM_KVCACHE_PERCENT -# value: "0.9" -# - name: CUDA_VISIBLE_DEVICES -# value: "0" + value: "nvidia/nemotron-3-super-120b-a12b" + - name: NIM_ENABLE_CHUNKED_PREFILL + value: "1" + - name: NCCL_NVLS_ENABLE + value: "0" + - name: VLLM_USE_FLASHINFER_MOE_FP8 + value: "0" +# -- To switch back to Llama 3.3 Nemotron Super 49B, replace NIM_SERVED_MODEL_NAME value above with: +# value: "nvidia/nemotron-3-super-120b-a12b" +# -- and remove NIM_ENABLE_CHUNKED_PREFILL/NCCL_NVLS_ENABLE/VLLM_USE_FLASHINFER_MOE_FP8 entries. expose: service: name: http @@ -753,10 +877,10 @@ nimOperator: failureThreshold: 750 timeoutSeconds: 5 -# subsection: nvidia-nim-llama-32-nv-embedqa-1b-v2 -# NIM Text Embedding - nvidia-nim-llama-32-nv-embedqa-1b-v2: - enabled: true +# subsection: nvidia-nim-llama-nemotron-embed-1b-v2 +# NIM Text Embedding (disabled by default; VLM embedding is the default — see nvidia-nim-llama-nemotron-embed-vl-1b-v2 below) + nvidia-nim-llama-nemotron-embed-1b-v2: + enabled: false replicas: 1 service: name: "nemotron-embedding-ms" @@ -796,9 +920,9 @@ nimOperator: grpcPort: 8001 # subsection: nvidia-nim-llama-nemotron-embed-vl-1b-v2 -# NIM VLM Embedding (opt-in) +# NIM VLM Embedding (default embedding service; aligned with envVars.APP_EMBEDDINGS_SERVERURL) nvidia-nim-llama-nemotron-embed-vl-1b-v2: - enabled: false + enabled: true service: name: "nemotron-vlm-embedding-ms" image: @@ -834,7 +958,7 @@ nimOperator: grpcPort: 8001 # subsection: text-reranking-nim # NIM Text Reranking - nvidia-nim-llama-32-nv-rerankqa-1b-v2: + nvidia-nim-llama-nemotron-rerank-1b-v2: enabled: true replicas: 1 service: @@ -851,6 +975,40 @@ nimOperator: nodeSelector: {} tolerations: [] # draResources: +# - resourceClaimName: rag-claim + storage: + pvc: + create: true + size: "50Gi" + volumeAccessMode: ReadWriteOnce + storageClass: "" + env: [] + expose: + service: + name: http + type: ClusterIP + port: 8000 + grpcPort: 8001 + +# subsection: nvidia-nim-llama-nemotron-rerank-vl-1b-v2 +# NIM VLM Reranking + nvidia-nim-llama-nemotron-rerank-vl-1b-v2: + enabled: false + replicas: 1 + service: + name: "nemotron-ranking-vl-ms" + image: + repository: nvcr.io/nim/nvidia/llama-nemotron-rerank-vl-1b-v2 + tag: "1.11.0" + pullPolicy: IfNotPresent + resources: + limits: + nvidia.com/gpu: 1 + requests: + nvidia.com/gpu: 1 + nodeSelector: {} + tolerations: [] +# draResources: # - resourceClaimName: rag-claim storage: pvc: @@ -867,7 +1025,7 @@ nimOperator: grpcPort: 8001 # subsection: nim-vlm - # NIM Vision-Language (VLM) + # Nemotron Omni — used for generation. nim-vlm: enabled: false replicas: 1 @@ -900,10 +1058,43 @@ nimOperator: port: 8000 grpcPort: 8001 + # subsection: nim-vlm-captioning + # Nemotron Nano 12B — used for image captioning. + nim-vlm-captioning: + enabled: false + replicas: 1 + service: + name: "nim-vlm-captioning" + image: + repository: nvcr.io/nim/nvidia/nemotron-nano-12b-v2-vl + tag: "1.6.0" + pullPolicy: IfNotPresent + resources: + limits: + nvidia.com/gpu: 1 + requests: + nvidia.com/gpu: 1 + nodeSelector: {} + tolerations: [] + storage: + pvc: + create: true + size: "50Gi" + volumeAccessMode: ReadWriteOnce + storageClass: "" + env: [] + expose: + service: + name: http + type: ClusterIP + port: 8000 + grpcPort: 8001 + # -- NV-Ingest dependency configuration # subsection: nv-ingest # NV-Ingest Service nv-ingest: + fullnameOverride: "rag-nv-ingest" enabled: true imagePullSecrets: - name: "ngc-secret" @@ -913,7 +1104,7 @@ nv-ingest: create: false image: repository: "nvcr.io/nvidia/nemo-microservices/nv-ingest" - tag: "26.1.2" + tag: "26.3.0" resources: limits: nvidia.com/gpu: 0 @@ -938,30 +1129,32 @@ nv-ingest: RAY_num_grpc_threads: "1" RAY_num_server_call_thread: "1" RAY_worker_num_grpc_internal_threads: "1" + REDIS_POOL_SIZE: "50" - EMBEDDING_NIM_ENDPOINT: "http://nemotron-embedding-ms:8000/v1" - EMBEDDING_NIM_MODEL_NAME: "nvidia/llama-nemotron-embed-1b-v2" + APP_EMBEDDINGS_SERVERURL: "nemotron-vlm-embedding-ms:8000/v1" + APP_EMBEDDINGS_MODELNAME: "nvidia/llama-nemotron-embed-vl-1b-v2" + EMBEDDING_NIM_ENDPOINT: "http://nemotron-vlm-embedding-ms:8000/v1" + EMBEDDING_NIM_MODEL_NAME: "nvidia/llama-nemotron-embed-vl-1b-v2" MESSAGE_CLIENT_HOST: "rag-redis-master" MESSAGE_CLIENT_PORT: 6379 MESSAGE_CLIENT_TYPE: "redis" - MINIO_INTERNAL_ADDRESS: "rag-minio:9000" - MINIO_PUBLIC_ADDRESS: "http://localhost:9000" + MINIO_INTERNAL_ADDRESS: "rag-seaweedfs-all-in-one:9010" + MINIO_PUBLIC_ADDRESS: "http://rag-seaweedfs-all-in-one:9010" MINIO_BUCKET: "nv-ingest" - MINIO_ACCESS_KEY: "minioadmin" - MINIO_SECRET_KEY: "minioadmin" - MILVUS_ENDPOINT: "http://milvus:19530" + MINIO_ACCESS_KEY: "seaweedfsadmin" + MINIO_SECRET_KEY: "seaweedfsadmin" OTEL_EXPORTER_OTLP_ENDPOINT: "otel-collector:4317" MODEL_PREDOWNLOAD_PATH: "/workspace/models/" INSTALL_AUDIO_EXTRACTION_DEPS: "true" COMPONENTS_TO_READY_CHECK: "ALL" - # OCR routing (defaults to NeMo Retriever OCR service) - OCR_GRPC_ENDPOINT: nemoretriever-ocr-v1:8001 - OCR_HTTP_ENDPOINT: http://nemoretriever-ocr-v1:8000/v1/infer + # OCR routing (nemotron-ocr-v1 service; use OCR_MODEL_NAME "pipeline" for Nemotron OCR) + OCR_GRPC_ENDPOINT: nemotron-ocr-v1:8001 + OCR_HTTP_ENDPOINT: http://nemotron-ocr-v1:8000/v1/infer OCR_INFER_PROTOCOL: grpc - OCR_MODEL_NAME: scene_text_ensemble + OCR_MODEL_NAME: pipeline - # NeMo Retriever Parse (VLM text extraction) + # Nemotron Parse (VLM text extraction) NEMOTRON_PARSE_HTTP_ENDPOINT: http://nemotron-parse:8000/v1/chat/completions NEMOTRON_PARSE_INFER_PROTOCOL: http NEMOTRON_PARSE_MODEL_NAME: nvidia/nemotron-parse @@ -971,61 +1164,30 @@ nv-ingest: # YOLOX endpoints YOLOX_PAGE_IMAGE_FORMAT: "JPEG" - YOLOX_GRPC_ENDPOINT: nemoretriever-page-elements-v3:8001 - YOLOX_HTTP_ENDPOINT: http://nemoretriever-page-elements-v3:8000/v1/infer + YOLOX_GRPC_ENDPOINT: nemotron-page-elements-v3:8001 + YOLOX_HTTP_ENDPOINT: http://nemotron-page-elements-v3:8000/v1/infer YOLOX_INFER_PROTOCOL: grpc - YOLOX_GRAPHIC_ELEMENTS_GRPC_ENDPOINT: nemoretriever-graphic-elements-v1:8001 - YOLOX_GRAPHIC_ELEMENTS_HTTP_ENDPOINT: http://nemoretriever-graphic-elements-v1:8000/v1/infer + YOLOX_GRAPHIC_ELEMENTS_GRPC_ENDPOINT: nemotron-graphic-elements-v1:8001 + YOLOX_GRAPHIC_ELEMENTS_HTTP_ENDPOINT: http://nemotron-graphic-elements-v1:8000/v1/infer YOLOX_GRAPHIC_ELEMENTS_INFER_PROTOCOL: grpc - YOLOX_TABLE_STRUCTURE_GRPC_ENDPOINT: nemoretriever-table-structure-v1:8001 - YOLOX_TABLE_STRUCTURE_HTTP_ENDPOINT: http://nemoretriever-table-structure-v1:8000/v1/infer + YOLOX_TABLE_STRUCTURE_GRPC_ENDPOINT: nemotron-table-structure-v1:8001 + YOLOX_TABLE_STRUCTURE_HTTP_ENDPOINT: http://nemotron-table-structure-v1:8000/v1/infer YOLOX_TABLE_STRUCTURE_INFER_PROTOCOL: grpc # Captioning - VLM_CAPTION_MODEL_NAME: nvidia/nemotron-3-nano-omni-30b-a3b-reasoning - VLM_CAPTION_ENDPOINT: http://nim-vlm:8000/v1/chat/completions + VLM_CAPTION_MODEL_NAME: nvidia/nemotron-nano-12b-v2-vl + VLM_CAPTION_ENDPOINT: http://nim-vlm-captioning:8000/v1/chat/completions # Audio service AUDIO_GRPC_ENDPOINT: nv-ingest-riva-nim:50051 AUDIO_INFER_PROTOCOL: grpc - # Expose internal Milvus/MinIO config managed by nv-ingest subchart - milvusDeployed: true - milvus: - # Uncomment this section to enable authentication for Milvus - # extraConfigFiles: - # user.yaml: |+ - # common: - # security: - # authorizationEnabled: true - # defaultRootPassword: Milvus - image: - all: - repository: docker.io/milvusdb/milvus - tag: v2.6.5-gpu - tools: - repository: docker.io/milvusdb/milvus-config-tool - tag: v0.1.2 - pullPolicy: IfNotPresent - etcd: - image: - repository: milvusdb/etcd - tag: "3.5.23-r2" - standalone: - resources: - limits: - nvidia.com/gpu: 1 - minio: - image: - repository: docker.io/minio/minio - tag: "RELEASE.2025-09-07T16-13-09Z" - accessKey: minioadmin - secretKey: minioadmin - bucketName: nv-ingest - fullnameOverride: milvus + # NV-Ingest uses SeaweedFS via MINIO_* env vars for S3-compatible artifact storage. + milvusDeployed: false # Redis Master redis: + fullnameOverride: "rag-redis" image: repository: redis tag: 8.2.1 @@ -1040,27 +1202,21 @@ nv-ingest: embedqa: enabled: false - # NeMo Retriever OCR (default) - # To use PaddleOCR instead, override the image: - # nemoretriever_ocr_v1: - # enabled: true - # image: - # repository: nvcr.io/nim/baidu/paddleocr - # tag: 1.5.0 - # And set OCR_MODEL_NAME to "paddle" in envVars above - nemoretriever_ocr_v1: + # OCR NIM (26.3.0: key renamed from nemoretriever_ocr_v1 to ocr; image nemotron-ocr-v1) + # To use PaddleOCR instead, override the image and set OCR_MODEL_NAME to "paddle" in envVars + ocr: enabled: true storage: pvc: volumeAccessMode: "ReadWriteOnce" storageClass: "" tolerations: [] - replicaCount: 1 + replicas: 1 image: - repository: nvcr.io/nim/nvidia/nemoretriever-ocr-v1 - tag: "1.2.1" - imagePullSecrets: - - name: ngc-secret + repository: nvcr.io/nim/nvidia/nemotron-ocr-v1 + tag: "1.3.0" + pullSecrets: + - ngc-secret env: - name: OMP_NUM_THREADS value: "8" @@ -1068,14 +1224,8 @@ nv-ingest: value: "8000" - name: NIM_TRITON_LOG_VERBOSE value: "1" - - name: CUDA_VISIBLE_DEVICES - value: "0" - - name: NIM_TRITON_CUDA_MEMORY_POOL_MB - value: "8192" - name: NIM_TRITON_MAX_BATCH_SIZE value: "32" - - name: NIM_TRITON_ENABLE_MODEL_CONTROL - value: "1" resources: limits: nvidia.com/gpu: 1 @@ -1090,7 +1240,7 @@ nv-ingest: volumeAccessMode: "ReadWriteOnce" storageClass: "" tolerations: [] - replicaCount: 1 + replicas: 1 image: repository: nvcr.io/nim/nvidia/nemotron-graphic-elements-v1 tag: "1.8.0" @@ -1101,8 +1251,6 @@ nv-ingest: value: "1" - name: NIM_TRITON_RATE_LIMIT value: "3" - - name: CUDA_VISIBLE_DEVICES - value: "0" - name: NIM_TRITON_MAX_BATCH_SIZE value: "32" - name: NIM_TRITON_CUDA_MEMORY_POOL_MB @@ -1123,7 +1271,7 @@ nv-ingest: volumeAccessMode: "ReadWriteOnce" storageClass: "" tolerations: [] - replicaCount: 1 + replicas: 1 image: repository: nvcr.io/nim/nvidia/nemotron-page-elements-v3 tag: "1.8.0" @@ -1132,14 +1280,8 @@ nv-ingest: value: "8000" - name: NIM_TRITON_LOG_VERBOSE value: "1" - - name: NIM_TRITON_RATE_LIMIT - value: "3" - - name: CUDA_VISIBLE_DEVICES - value: "0" - name: NIM_TRITON_MAX_BATCH_SIZE value: "32" - - name: NIM_TRITON_CUDA_MEMORY_POOL_MB - value: "2048" - name: NIM_TRITON_CPU_THREADS_PRE_PROCESSOR value: "2" - name: NIM_TRITON_CPU_THREADS_POST_PROCESSOR @@ -1147,7 +1289,7 @@ nv-ingest: - name: OMP_NUM_THREADS value: "2" - name: NIM_ENABLE_OTEL - value: "true" + value: "0" - name: NIM_OTEL_SERVICE_NAME value: "page-elements" - name: NIM_OTEL_TRACES_EXPORTER @@ -1156,6 +1298,8 @@ nv-ingest: value: "console" - name: NIM_OTEL_EXPORTER_OTLP_ENDPOINT value: "http://otel-collector:4318" + - name: NIM_ENABLE_OTEL + value: "true" - name: TRITON_OTEL_URL value: "http://otel-collector:4318/v1/traces" - name: TRITON_OTEL_RATE @@ -1174,7 +1318,7 @@ nv-ingest: storageClass: "" volumeAccessMode: "ReadWriteOnce" tolerations: [] - replicaCount: 1 + replicas: 1 image: repository: nvcr.io/nim/nvidia/nemotron-table-structure-v1 tag: "1.8.0" @@ -1185,8 +1329,6 @@ nv-ingest: value: "1" - name: NIM_TRITON_RATE_LIMIT value: "3" - - name: CUDA_VISIBLE_DEVICES - value: "0" - name: NIM_TRITON_MAX_BATCH_SIZE value: "32" - name: NIM_TRITON_CUDA_MEMORY_POOL_MB diff --git a/deploy/workbench/README.md b/deploy/workbench/README.md index 179c32ec5..9bc360736 100644 --- a/deploy/workbench/README.md +++ b/deploy/workbench/README.md @@ -75,4 +75,4 @@ Use of the models in this blueprint is governed by the [NVIDIA AI Foundation Mod ## Terms of Use This blueprint is governed by the [NVIDIA Agreements | Enterprise Software | NVIDIA Software License Agreement](https://www.nvidia.com/en-us/agreements/enterprise-software/nvidia-software-license-agreement/) and the [NVIDIA Agreements | Enterprise Software | Product Specific Terms for AI Product](https://www.nvidia.com/en-us/agreements/enterprise-software/product-specific-terms-for-ai-products/). The models are governed by the [NVIDIA Agreements | Enterprise Software | NVIDIA Community Model License](https://www.nvidia.com/en-us/agreements/enterprise-software/nvidia-community-models-license/) and the [NVIDIA RAG dataset](https://github.com/NVIDIA-AI-Blueprints/rag/tree/v2.0.0/data/multimodal) which is governed by the [NVIDIA Asset License Agreement](https://github.com/NVIDIA-AI-Blueprints/rag/blob/main/data/LICENSE.DATA). -The following models that are built with Llama are governed by the [Llama 3.2 Community License Agreement](https://www.llama.com/llama3_2/license/): nvidia/llama-3.3-nemotron-super-49b-v1, nvidia/llama-nemotron-embed-1b-v2, and nvidia/llama-nemotron-rerank-1b-v2. +The following models that are built with Llama are governed by the [Llama 3.2 Community License Agreement](https://www.llama.com/llama3_2/license/): nvidia/llama-nemotron-embed-vl-1b-v2 and nvidia/llama-nemotron-rerank-1b-v2. diff --git a/deploy/workbench/compose.yaml b/deploy/workbench/compose.yaml index 2fd6cfb86..84d31f467 100644 --- a/deploy/workbench/compose.yaml +++ b/deploy/workbench/compose.yaml @@ -2,16 +2,19 @@ services: nim-llm: container_name: nim-llm-ms - image: nvcr.io/nim/nvidia/llama-3.3-nemotron-super-49b-v1.5:1.14.0 + image: nvcr.io/nim/nvidia/nemotron-3-super-120b-a12b:1.8.0 volumes: - ${MODEL_DIRECTORY:-/tmp}:/opt/nim/.cache - user: "${USERID}" + user: "0" ports: - "8999:8000" expose: - "8000" environment: NGC_API_KEY: ${NGC_API_KEY} + NIM_ENABLE_CHUNKED_PREFILL: "1" + NIM_KV_CACHE_DTYPE: "fp8" + VLLM_ALLOW_LONG_MAX_MODEL_LEN: "1" shm_size: 20gb deploy: resources: @@ -19,7 +22,7 @@ services: devices: - driver: nvidia #count: ${INFERENCE_GPU_COUNT:-all} - device_ids: ['${LLM_MS_GPU_ID:-1}'] + device_ids: ['${LLM_MS_GPU_ID:-1}', '${LLM_MS_GPU_ID2:-2}'] capabilities: [gpu] healthcheck: test: ["CMD", "python3", "-c", "import requests; requests.get('http://localhost:8000/v1/health/ready')"] @@ -28,13 +31,13 @@ services: retries: 100 profiles: ["local"] - nemotron-embedding-ms: - container_name: nemotron-embedding-ms - image: nvcr.io/nim/nvidia/llama-nemotron-embed-1b-v2:1.13.0 + nemotron-vlm-embedding-ms: + container_name: nemotron-vlm-embedding-ms + image: nvcr.io/nim/nvidia/llama-nemotron-embed-vl-1b-v2:1.12.0 volumes: - ${MODEL_DIRECTORY:-/tmp}:/opt/nim/.cache ports: - - "9080:8000" + - "9081:8000" expose: - "8000" environment: @@ -48,7 +51,7 @@ services: devices: - driver: nvidia # count: ${INFERENCE_GPU_COUNT:-all} - device_ids: ['${EMBEDDING_MS_GPU_ID:-0}'] + device_ids: ['${VLM_EMBEDDING_MS_GPU_ID:-0}'] capabilities: [gpu] healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8000/v1/health/ready"] @@ -95,15 +98,13 @@ services: environment: - NIM_HTTP_API_PORT=8000 - NIM_TRITON_LOG_VERBOSE=1 - - NIM_TRITON_RATE_LIMIT=3 - NGC_API_KEY=${NIM_NGC_API_KEY:-${NGC_API_KEY:-ngcapikey}} - - CUDA_VISIBLE_DEVICES=0 - NIM_TRITON_MAX_BATCH_SIZE=${PAGE_ELEMENTS_BATCH_SIZE:-32} - - NIM_TRITON_CUDA_MEMORY_POOL_MB=${PAGE_ELEMENTS_CUDA_MEMORY_POOL_MB:-2048} - NIM_TRITON_CPU_THREADS_PRE_PROCESSOR=${PAGE_ELEMENTS_CPU_THREADS_PRE_PROCESSOR:-2} - NIM_TRITON_CPU_THREADS_POST_PROCESSOR=${PAGE_ELEMENTS_CPU_THREADS_POST_PROCESSOR:-1} - OMP_NUM_THREADS=2 # NIM OpenTelemetry Settings + - NIM_ENABLE_OTEL=0 - NIM_OTEL_SERVICE_NAME=page-elements - NIM_OTEL_TRACES_EXPORTER=otlp - NIM_OTEL_METRICS_EXPORTER=console @@ -133,7 +134,6 @@ services: - NIM_TRITON_LOG_VERBOSE=1 - NIM_TRITON_RATE_LIMIT=3 - NGC_API_KEY=${NIM_NGC_API_KEY:-${NGC_API_KEY:-ngcapikey}} - - CUDA_VISIBLE_DEVICES=0 - NIM_TRITON_MAX_BATCH_SIZE=${GRAPHIC_ELEMENTS_BATCH_SIZE:-32} - NIM_TRITON_CUDA_MEMORY_POOL_MB=${GRAPHIC_ELEMENTS_CUDA_MEMORY_POOL_MB:-2048} - OMP_NUM_THREADS=1 @@ -158,7 +158,6 @@ services: - NIM_TRITON_LOG_VERBOSE=1 - NIM_TRITON_RATE_LIMIT=3 - NGC_API_KEY=${NIM_NGC_API_KEY:-${NGC_API_KEY:-ngcapikey}} - - CUDA_VISIBLE_DEVICES=0 - NIM_TRITON_MAX_BATCH_SIZE=${TABLE_STRUCTURE_BATCH_SIZE:-32} - NIM_TRITON_CUDA_MEMORY_POOL_MB=${TABLE_STRUCTURE_CUDA_MEMORY_POOL_MB:-2048} - OMP_NUM_THREADS=1 @@ -171,8 +170,8 @@ services: capabilities: [gpu] profiles: ["local"] - nemoretriever-ocr: - image: ${NEMORETRIEVER_OCR_IMAGE:-nvcr.io/nim/nvidia/nemoretriever-ocr-v1}:${NEMORETRIEVER_OCR_TAG:-1.2.1} + nemotron-ocr: + image: ${NEMOTRON_OCR_IMAGE:-nvcr.io/nim/nvidia/nemotron-ocr-v1}:${NEMOTRON_OCR_TAG:-1.3.0} shm_size: 16gb ports: - "8012:8000" @@ -184,10 +183,7 @@ services: - NIM_HTTP_API_PORT=8000 - NIM_TRITON_LOG_VERBOSE=1 - NGC_API_KEY=${NIM_NGC_API_KEY:-${NGC_API_KEY:-ngcapikey}} - - CUDA_VISIBLE_DEVICES=0 - - NIM_TRITON_CUDA_MEMORY_POOL_MB=${OCR_CUDA_MEMORY_POOL_MB:-8192} - NIM_TRITON_MAX_BATCH_SIZE=${OCR_BATCH_SIZE:-32} - - NIM_TRITON_ENABLE_MODEL_CONTROL=1 deploy: resources: reservations: @@ -200,7 +196,7 @@ services: # Main ingestor server which is responsible for ingestion ingestor-server: container_name: ingestor-server - image: nvcr.io/nvidia/blueprint/ingestor-server:${TAG:-2.5.0} + image: nvcr.io/nvidia/blueprint/ingestor-server:${TAG:-2.6.0} build: # Set context to repo's root directory context: ../../ @@ -221,10 +217,10 @@ services: PROMPT_CONFIG_FILE: ${PROMPT_CONFIG_FILE:-/prompt.yaml} ##===Vector DB specific configurations=== - # URL on which vectorstore is hosted - APP_VECTORSTORE_URL: "http://milvus:19530" - # Type of vectordb used to store embedding supported type milvus - APP_VECTORSTORE_NAME: "milvus" + # URL on which vectorstore is hosted (Milvus: http://milvus:19530 when using --profile vectordb) + APP_VECTORSTORE_URL: "http://elasticsearch:9200" + # Type of vectordb: "elasticsearch" or "milvus" + APP_VECTORSTORE_NAME: "elasticsearch" # Type of vectordb search to be used APP_VECTORSTORE_SEARCHTYPE: ${APP_VECTORSTORE_SEARCHTYPE:-"dense"} # Can be dense or hybrid # Type of ranker to use for vector store in case of Hybrid Search @@ -233,31 +229,31 @@ services: APP_VECTORSTORE_DENSE_WEIGHT: ${APP_VECTORSTORE_DENSE_WEIGHT:-0.5} # Weight for sparse vector search in case of "weighted" Hybrid Search APP_VECTORSTORE_SPARSE_WEIGHT: ${APP_VECTORSTORE_SPARSE_WEIGHT:-0.5} - # Boolean to enable GPU index for milvus vectorstore specific to nvingest - APP_VECTORSTORE_ENABLEGPUINDEX: ${APP_VECTORSTORE_ENABLEGPUINDEX:-True} - # Boolean to control GPU search for milvus vectorstore specific to nvingest - APP_VECTORSTORE_ENABLEGPUSEARCH: ${APP_VECTORSTORE_ENABLEGPUSEARCH:-True} + # Enable GPU index building. Applies to both Milvus and Elasticsearch (requires GPU-capable image and license for ES). + APP_VECTORSTORE_ENABLEGPUINDEX: ${APP_VECTORSTORE_ENABLEGPUINDEX:-False} + # Enable GPU search. Milvus only — GPU search is not supported by Elasticsearch. + APP_VECTORSTORE_ENABLEGPUSEARCH: ${APP_VECTORSTORE_ENABLEGPUSEARCH:-False} # ef: Parameter controlling query time/accuracy trade-off. Higher ef leads to more accurate but slower search. APP_VECTORSTORE_EF: ${APP_VECTORSTORE_EF:-100} - # Username for vector store (Authentication is currently supported for Milvus only) + # Username for vector store authentication APP_VECTORSTORE_USERNAME: ${APP_VECTORSTORE_USERNAME:-""} - # Password for vector store (Authentication is currently supported for Milvus only) + # Password for vector store authentication APP_VECTORSTORE_PASSWORD: ${APP_VECTORSTORE_PASSWORD:-""} # vectorstore collection name to store embeddings COLLECTION_NAME: ${COLLECTION_NAME:-multimodal_data} - ##===MINIO specific configurations=== - MINIO_ENDPOINT: "minio:9010" - MINIO_ACCESSKEY: "minioadmin" - MINIO_SECRETKEY: "minioadmin" + ##===Object-store specific configurations=== + OBJECTSTORE_ENDPOINT: ${OBJECTSTORE_ENDPOINT:-seaweedfs:9010} + OBJECTSTORE_ACCESSKEY: ${OBJECTSTORE_ACCESSKEY:-seaweedfsadmin} + OBJECTSTORE_SECRETKEY: ${OBJECTSTORE_SECRETKEY:-seaweedfsadmin} NGC_API_KEY: ${NGC_API_KEY:?"NGC_API_KEY is required"} NVIDIA_API_KEY: ${NGC_API_KEY:?"NGC_API_KEY is required"} ##===Embedding Model specific configurations=== # url on which embedding model is hosted. If "", Nvidia hosted API is used - APP_EMBEDDINGS_SERVERURL: ${APP_EMBEDDINGS_SERVERURL-"nemotron-embedding-ms:8000/v1"} - APP_EMBEDDINGS_MODELNAME: ${APP_EMBEDDINGS_MODELNAME:-nvidia/llama-nemotron-embed-1b-v2} + APP_EMBEDDINGS_SERVERURL: ${APP_EMBEDDINGS_SERVERURL-"nemotron-vlm-embedding-ms:8000/v1"} + APP_EMBEDDINGS_MODELNAME: ${APP_EMBEDDINGS_MODELNAME:-nvidia/llama-nemotron-embed-vl-1b-v2} APP_EMBEDDINGS_DIMENSIONS: ${APP_EMBEDDINGS_DIMENSIONS:-2048} ##===NV-Ingest Connection Configurations======= @@ -277,7 +273,7 @@ services: ##===NV-Ingest Splitting Configurations======== APP_NVINGEST_CHUNKSIZE: ${APP_NVINGEST_CHUNKSIZE:-512} APP_NVINGEST_CHUNKOVERLAP: ${APP_NVINGEST_CHUNKOVERLAP:-150} - APP_NVINGEST_ENABLEPDFSPLITTER: ${APP_NVINGEST_ENABLEPDFSPLITTER:-True} + APP_NVINGEST_ENABLE_PAGED_DOC_SPLIT: ${APP_NVINGEST_ENABLE_PAGED_DOC_SPLIT:-False} APP_NVINGEST_SEGMENTAUDIO: ${APP_NVINGEST_SEGMENTAUDIO:-False} # Enable audio segmentation for NV Ingest ##===NV-Ingest Caption Model configurations==== @@ -287,7 +283,7 @@ services: ##===NV-Ingest Save to Disk Configurations==== APP_NVINGEST_SAVETODISK: ${APP_NVINGEST_SAVETODISK:-False} - NVINGEST_MINIO_BUCKET: ${NVINGEST_MINIO_BUCKET:-nv-ingest} + NVINGEST_OBJECTSTORE_BUCKET: ${NVINGEST_OBJECTSTORE_BUCKET:-nv-ingest} ##===NV-Ingest Performance Configurations======== APP_NVINGEST_ENABLE_PDF_SPLIT_PROCESSING: ${APP_NVINGEST_ENABLE_PDF_SPLIT_PROCESSING:-False} @@ -297,7 +293,7 @@ services: ENABLE_CITATIONS: ${ENABLE_CITATIONS:-True} # Choose the summary model to use for document summary - SUMMARY_LLM: ${SUMMARY_LLM:-nvidia/llama-3.3-nemotron-super-49b-v1.5} + SUMMARY_LLM: ${SUMMARY_LLM:-nvidia/nemotron-3-super-120b-a12b} SUMMARY_LLM_SERVERURL: ${SUMMARY_LLM_SERVERURL-"nim-llm:8000"} SUMMARY_LLM_MAX_CHUNK_LENGTH: ${SUMMARY_LLM_MAX_CHUNK_LENGTH:-9000} SUMMARY_CHUNK_OVERLAP: ${SUMMARY_CHUNK_OVERLAP:-400} @@ -311,13 +307,13 @@ services: REDIS_DB: ${REDIS_DB:-0} ENABLE_REDIS_BACKEND: ${ENABLE_REDIS_BACKEND:-False} - # Bulk upload to MinIO - ENABLE_MINIO_BULK_UPLOAD: ${ENABLE_MINIO_BULK_UPLOAD:-True} TEMP_DIR: ${TEMP_DIR:-/tmp-data} # NV-Ingest Batch Mode Configurations NV_INGEST_FILES_PER_BATCH: ${NV_INGEST_FILES_PER_BATCH:-16} NV_INGEST_CONCURRENT_BATCHES: ${NV_INGEST_CONCURRENT_BATCHES:-4} + # Max memory budget (MB) for a single ingestion job; used for dynamic batch sizing + INGESTION_MAX_MEMORY_BUDGET_MB: ${INGESTION_MAX_MEMORY_BUDGET_MB:-1024} ports: - "8082:8082" @@ -333,7 +329,7 @@ services: profiles: ["ingest"] nv-ingest-ms-runtime: - image: nvcr.io/nvidia/nemo-microservices/nv-ingest:26.1.2 + image: nvcr.io/nvidia/nemo-microservices/nv-ingest:26.3.0 # cpuset: "0-15" # Uncomment to restrict this container to CPU cores 0–15 shm_size: 40gb # Should be at minimum 30% of assigned memory per Ray documentation volumes: @@ -373,9 +369,9 @@ services: - MESSAGE_CLIENT_HOST=redis - MESSAGE_CLIENT_PORT=6379 - MESSAGE_CLIENT_TYPE=redis - - MINIO_BUCKET=${MINIO_BUCKET:-nv-ingest} - - MINIO_ACCESS_KEY=${MINIO_ACCESS_KEY:-minioadmin} - - MINIO_SECRET_KEY=${MINIO_SECRET_KEY:-minioadmin} + - MINIO_BUCKET=${NVINGEST_OBJECTSTORE_BUCKET:-nv-ingest} + - MINIO_ACCESS_KEY=${OBJECTSTORE_ACCESSKEY:-seaweedfsadmin} + - MINIO_SECRET_KEY=${OBJECTSTORE_SECRETKEY:-seaweedfsadmin} - MRC_IGNORE_NUMA_CHECK=1 - NEMOTRON_PARSE_HTTP_ENDPOINT=${NEMOTRON_PARSE_HTTP_ENDPOINT:-http://nemotron-parse:8000/v1/chat/completions} - NEMOTRON_PARSE_INFER_PROTOCOL=${NEMOTRON_PARSE_INFER_PROTOCOL:-http} @@ -386,12 +382,12 @@ services: - NV_INGEST_MAX_UTIL=${NV_INGEST_MAX_UTIL:-48} - OTEL_EXPORTER_OTLP_ENDPOINT=otel-collector:4317 # Self-hosted ocr endpoints. - - OCR_GRPC_ENDPOINT=${OCR_GRPC_ENDPOINT:-nemoretriever-ocr:8001} - - OCR_HTTP_ENDPOINT=${OCR_HTTP_ENDPOINT:-http://nemoretriever-ocr:8000/v1/infer} + - OCR_GRPC_ENDPOINT=${OCR_GRPC_ENDPOINT:-nemotron-ocr:8001} + - OCR_HTTP_ENDPOINT=${OCR_HTTP_ENDPOINT:-http://nemotron-ocr:8000/v1/infer} - OCR_INFER_PROTOCOL=${OCR_INFER_PROTOCOL:-grpc} - - OCR_MODEL_NAME=${OCR_MODEL_NAME:-scene_text_ensemble} + - OCR_MODEL_NAME=${OCR_MODEL_NAME:-pipeline} # build.nvidia.com hosted ocr endpoints. - # - OCR_HTTP_ENDPOINT=https://ai.api.nvidia.com/v1/cv/nvidia/nemoretriever-ocr + # - OCR_HTTP_ENDPOINT=https://ai.api.nvidia.com/v1/cv/nvidia/nemotron-ocr-v1 # - OCR_INFER_PROTOCOL=http - PDF_SPLIT_PAGE_COUNT=${PDF_SPLIT_PAGE_COUNT:-32} - REDIS_INGEST_TASK_QUEUE=ingest_task_queue @@ -432,7 +428,7 @@ services: # Main orchestrator server which stiches together all calls to different services to fulfill the user request rag-server: container_name: rag-server - image: nvcr.io/nvidia/blueprint/rag-server:${TAG:-2.5.1} + image: nvcr.io/nvidia/blueprint/rag-server:${TAG:-2.6.0} build: # Set context to repo's root directory context: ../../ @@ -450,17 +446,17 @@ services: # Absolute path to custom prompt.yaml file PROMPT_CONFIG_FILE: ${PROMPT_CONFIG_FILE:-/prompt.yaml} - ##===MINIO specific configurations which is used to store the multimodal base64 content=== - MINIO_ENDPOINT: "minio:9010" - MINIO_ACCESSKEY: "minioadmin" - MINIO_SECRETKEY: "minioadmin" + ##===Object-store specific configurations which is used to store the multimodal base64 content=== + OBJECTSTORE_ENDPOINT: ${OBJECTSTORE_ENDPOINT:-seaweedfs:9010} + OBJECTSTORE_ACCESSKEY: ${OBJECTSTORE_ACCESSKEY:-seaweedfsadmin} + OBJECTSTORE_SECRETKEY: ${OBJECTSTORE_SECRETKEY:-seaweedfsadmin} ##===Vector DB specific configurations=== - # URL on which vectorstore is hosted - APP_VECTORSTORE_URL: "http://milvus:19530" - # Type of vectordb used to store embedding supported type milvus - APP_VECTORSTORE_NAME: "milvus" - # Type of index to be used for vectorstore + # URL on which vectorstore is hosted (Milvus: http://milvus:19530 when using --profile vectordb) + APP_VECTORSTORE_URL: "http://elasticsearch:9200" + # Type of vectordb: "elasticsearch" or "milvus" + APP_VECTORSTORE_NAME: "elasticsearch" + # Type of index to be used for vectorstore (Milvus-specific; Elasticsearch uses its own mapping) APP_VECTORSTORE_INDEXTYPE: ${APP_VECTORSTORE_INDEXTYPE:-"GPU_CAGRA"} # Type of vectordb search to be used @@ -479,7 +475,7 @@ services: VECTOR_DB_TOPK: ${VECTOR_DB_TOPK:-100} ##===LLM Model specific configurations=== - APP_LLM_MODELNAME: ${APP_LLM_MODELNAME:-"nvidia/llama-3.3-nemotron-super-49b-v1.5"} + APP_LLM_MODELNAME: ${APP_LLM_MODELNAME:-"nvidia/nemotron-3-super-120b-a12b"} # url on which llm model is hosted. If "", Nvidia hosted API is used APP_LLM_SERVERURL: ${APP_LLM_SERVERURL-"nim-llm:8000"} @@ -489,14 +485,14 @@ services: LLM_TOP_P: ${LLM_TOP_P:-1.0} ##===Query Rewriter Model specific configurations=== - APP_QUERYREWRITER_MODELNAME: ${APP_QUERYREWRITER_MODELNAME:-"nvidia/llama-3.3-nemotron-super-49b-v1.5"} + APP_QUERYREWRITER_MODELNAME: ${APP_QUERYREWRITER_MODELNAME:-"nvidia/nemotron-3-super-120b-a12b"} # url on which query rewriter model is hosted. If "", Nvidia hosted API is used APP_QUERYREWRITER_SERVERURL: ${APP_QUERYREWRITER_SERVERURL-"nim-llm:8000"} ##===Embedding Model specific configurations=== # url on which embedding model is hosted. If "", Nvidia hosted API is used - APP_EMBEDDINGS_SERVERURL: ${APP_EMBEDDINGS_SERVERURL-"nemotron-embedding-ms:8000/v1"} - APP_EMBEDDINGS_MODELNAME: ${APP_EMBEDDINGS_MODELNAME:-nvidia/llama-nemotron-embed-1b-v2} + APP_EMBEDDINGS_SERVERURL: ${APP_EMBEDDINGS_SERVERURL-"nemotron-vlm-embedding-ms:8000/v1"} + APP_EMBEDDINGS_MODELNAME: ${APP_EMBEDDINGS_MODELNAME:-nvidia/llama-nemotron-embed-vl-1b-v2} ##===Reranking Model specific configurations=== # url on which ranking model is hosted. If "", Nvidia hosted API is used @@ -555,10 +551,44 @@ services: # Minimum groundedness score threshold (0-2) RESPONSE_GROUNDEDNESS_THRESHOLD: ${RESPONSE_GROUNDEDNESS_THRESHOLD:-1} # reflection llm - REFLECTION_LLM: ${REFLECTION_LLM:-"nvidia/llama-3.3-nemotron-super-49b-v1.5"} + REFLECTION_LLM: ${REFLECTION_LLM:-"nvidia/nemotron-3-super-120b-a12b"} # reflection llm server url. If "", Nvidia hosted API is used REFLECTION_LLM_SERVERURL: ${REFLECTION_LLM_SERVERURL-"nim-llm:8000"} + # === Agentic RAG (LangGraph plan-and-execute pipeline) === + ENABLE_AGENTIC_RAG: ${ENABLE_AGENTIC_RAG:-false} + + ##===Agentic Planner LLM configurations=== + AGENTIC_PLANNER_LLM_SERVERURL: ${AGENTIC_PLANNER_LLM_SERVERURL-"nim-llm:8000"} + AGENTIC_PLANNER_LLM_MODEL: ${AGENTIC_PLANNER_LLM_MODEL:-"nvidia/nemotron-3-super-120b-a12b"} + + ##===Agentic Task LLM configurations=== + AGENTIC_TASK_LLM_SERVERURL: ${AGENTIC_TASK_LLM_SERVERURL-"nim-llm:8000"} + AGENTIC_TASK_LLM_MODEL: ${AGENTIC_TASK_LLM_MODEL:-"nvidia/nemotron-3-super-120b-a12b"} + + ##===Agentic Seed Gen LLM configurations=== + AGENTIC_SEED_GEN_LLM_SERVERURL: ${AGENTIC_SEED_GEN_LLM_SERVERURL-"nim-llm:8000"} + AGENTIC_SEED_GEN_LLM_MODEL: ${AGENTIC_SEED_GEN_LLM_MODEL:-"nvidia/nemotron-3-super-120b-a12b"} + + ##===Agentic Synthesis LLM configurations=== + AGENTIC_SYNTHESIS_LLM_SERVERURL: ${AGENTIC_SYNTHESIS_LLM_SERVERURL-"nim-llm:8000"} + AGENTIC_SYNTHESIS_LLM_MODEL: ${AGENTIC_SYNTHESIS_LLM_MODEL:-"nvidia/nemotron-3-super-120b-a12b"} + + # Per-role agentic API keys (optional; empty uses NVIDIA_API_KEY fallback) + AGENTIC_PLANNER_LLM_APIKEY: ${AGENTIC_PLANNER_LLM_APIKEY:-""} + AGENTIC_TASK_LLM_APIKEY: ${AGENTIC_TASK_LLM_APIKEY:-""} + AGENTIC_SEED_GEN_LLM_APIKEY: ${AGENTIC_SEED_GEN_LLM_APIKEY:-""} + AGENTIC_SYNTHESIS_LLM_APIKEY: ${AGENTIC_SYNTHESIS_LLM_APIKEY:-""} + + # Agent behaviour tuning + AGENTIC_LOG_LEVEL: ${AGENTIC_LOG_LEVEL:-INFO} + + # Verification pass (disabled by default) + AGENTIC_VERIFICATION_ENABLED: ${AGENTIC_VERIFICATION_ENABLED:-false} + + # Context window budget for retrieved chunks + AGENTIC_CONTEXT_MAX_TOKENS: ${AGENTIC_CONTEXT_MAX_TOKENS:-100000} + ports: - "8081:8081" expose: @@ -569,7 +599,7 @@ services: # Sample UI container which interacts with APIs exposed by rag-server container rag-frontend: container_name: rag-frontend - image: nvcr.io/nvidia/blueprint/rag-frontend:${TAG:-2.5.0} + image: nvcr.io/nvidia/blueprint/rag-frontend:${TAG:-2.6.0} build: # Set context to repo's root directory context: ../../frontend @@ -592,17 +622,23 @@ services: NVWB_TRIM_PREFIX: "true" profiles: ["rag"] - # Milvus can be made GPU accelerated by uncommenting the lines as specified below + # Optional Milvus stack (--profile vectordb). Default VDB is Elasticsearch (see elasticsearch service + ingest/rag profiles). + # When using Milvus, set APP_VECTORSTORE_URL=http://milvus:19530 and APP_VECTORSTORE_NAME=milvus on rag-server / ingestor-server. milvus: container_name: milvus-standalone image: milvusdb/milvus:v2.5.3-gpu # milvusdb/milvus:v2.5.3 for CPU - command: ["milvus", "run", "standalone"] + command: > + bash -lc 'tmpfile=$(mktemp) && + sed -e "s/bucketName: a-bucket/bucketName: ${NVINGEST_OBJECTSTORE_BUCKET:-nv-ingest}/" -e "s/accessKeyID: minioadmin/accessKeyID: ${OBJECTSTORE_ACCESSKEY:-seaweedfsadmin}/" -e "s/secretAccessKey: minioadmin/secretAccessKey: ${OBJECTSTORE_SECRETKEY:-seaweedfsadmin}/" /milvus/configs/milvus.yaml > "$$tmpfile" && + cat "$$tmpfile" > /milvus/configs/milvus.yaml && + rm "$$tmpfile" && + milvus run standalone' environment: ETCD_ENDPOINTS: etcd:2379 - MINIO_ADDRESS: minio:9010 + MINIO_ADDRESS: seaweedfs:9010 KNOWHERE_GPU_MEM_POOL_SIZE: 2048;4096 volumes: - - ${DOCKER_VOLUME_DIRECTORY:-./volumes/milvus}:/var/lib/milvus + - rag-vol-milvus:/var/lib/milvus healthcheck: test: ["CMD", "curl", "-f", "http://localhost:9091/healthz"] interval: 30s @@ -614,7 +650,7 @@ services: - "9091:9091" depends_on: - "etcd" - - "minio" + - "seaweedfs" # Comment out this section if CPU based image is used and set below env variables to False # export APP_VECTORSTORE_ENABLEGPUSEARCH=False # export APP_VECTORSTORE_ENABLEGPUINDEX=False @@ -637,7 +673,7 @@ services: - ETCD_QUOTA_BACKEND_BYTES=4294967296 - ETCD_SNAPSHOT_COUNT=50000 volumes: - - ${DOCKER_VOLUME_DIRECTORY:-./volumes/etcd}:/etcd + - rag-vol-etcd:/etcd command: etcd -advertise-client-urls=http://127.0.0.1:2379 -listen-client-urls http://0.0.0.0:2379 --data-dir /etcd healthcheck: test: ["CMD", "etcdctl", "endpoint", "health"] @@ -646,24 +682,55 @@ services: retries: 3 profiles: ["vectordb"] - minio: - container_name: milvus-minio - image: minio/minio:RELEASE.2025-02-28T09-55-16Z + seaweedfs: + container_name: seaweedfs + image: chrislusf/seaweedfs:3.73 environment: - MINIO_ACCESS_KEY: minioadmin - MINIO_SECRET_KEY: minioadmin + MINIO_ACCESS_KEY: ${OBJECTSTORE_ACCESSKEY:-seaweedfsadmin} + MINIO_SECRET_KEY: ${OBJECTSTORE_SECRETKEY:-seaweedfsadmin} ports: - "9011:9011" - "9010:9010" volumes: - - ${DOCKER_VOLUME_DIRECTORY:-./volumes/minio}:/minio_data - command: minio server /minio_data --console-address ":9011" --address ":9010" + - rag-vol-seaweedfs:/data + - ../compose/seaweedfs-config/s3.json:/etc/seaweedfs/s3.json:ro + command: + - server + - -dir=/data + - -s3 + - -s3.port=9010 + - -s3.config=/etc/seaweedfs/s3.json + - -master.volumeSizeLimitMB=1024 healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:9010/minio/health/live"] + test: ["CMD-SHELL", "nc -z 127.0.0.1 9010"] interval: 30s timeout: 20s retries: 3 - profiles: ["vectordb"] + # Shared object storage for NV-Ingest and Milvus; included with ingest/rag so ES deployments do not require --profile vectordb + profiles: ["vectordb", "ingest", "rag", "seaweedfs"] + + elasticsearch: + container_name: elasticsearch + image: "docker.elastic.co/elasticsearch/elasticsearch:9.3.0" + ports: + - "9200:9200" + volumes: + - rag-vol-elasticsearch:/usr/share/elasticsearch/data + restart: on-failure + environment: + - discovery.type=single-node + - "ES_JAVA_OPTS=-Xms1024m -Xmx1024m" + - xpack.security.enabled=false + - xpack.license.self_generated.type=basic + - network.host=0.0.0.0 + - cluster.routing.allocation.disk.threshold_enabled=false + hostname: elasticsearch + healthcheck: + test: ["CMD", "curl", "-s", "-f", "http://localhost:9200/_cat/health"] + interval: 10s + timeout: 1s + retries: 10 + profiles: ["ingest", "rag"] otel-collector: image: otel/opentelemetry-collector-contrib:0.131.0 @@ -801,6 +868,16 @@ volumes: vectordb: nim_cache: external: true + # Per-service named volumes — same names used by deploy/compose/*.yaml so + # workbench-launched stacks and the main compose stacks share data on the host. + rag-vol-milvus: + name: rag-vol-milvus + rag-vol-etcd: + name: rag-vol-etcd + rag-vol-seaweedfs: + name: rag-vol-seaweedfs + rag-vol-elasticsearch: + name: rag-vol-elasticsearch networks: default: diff --git a/deploy/workbench/quickstart.ipynb b/deploy/workbench/quickstart.ipynb index 00c524aba..3a0d0816e 100644 --- a/deploy/workbench/quickstart.ipynb +++ b/deploy/workbench/quickstart.ipynb @@ -189,7 +189,7 @@ " ],\n", " \"use_knowledge_base\": False, # Disable RAG functionality\n", " \"temperature\": 0.2, # Lower temperature for more focused responses\n", - " \"model\": \"nvidia/llama-3.3-nemotron-super-49b-v1.5\", # Specify LLM model to use\n", + " \"model\": \"nvidia/nemotron-3-super-120b-a12b\", # Specify LLM model to use\n", "}\n", "\n", "chat_url = f\"{RAG_BASE_URL}/v1/chat/completions\"\n", @@ -243,7 +243,7 @@ " {\"role\": \"user\", \"content\": \"What is Retrieval Augmented Generation?\"}\n", " ],\n", " \"stream\": False,\n", - " \"model\": \"nvidia/llama-3.3-nemotron-super-49b-v1.5\",\n", + " \"model\": \"nvidia/nemotron-3-super-120b-a12b\",\n", " \"max_tokens\": 1024,\n", " \"temperature\": 0.2,\n", "}\n", @@ -964,10 +964,10 @@ " \"enable_reranker\": True,\n", " \"enable_guardrails\": False,\n", " \"enable_citations\": True,\n", - " \"model\": \"nvidia/llama-3.3-nemotron-super-49b-v1.5\",\n", + " \"model\": \"nvidia/nemotron-3-super-120b-a12b\",\n", " \"llm_endpoint\": \"nim-llm:8000\",\n", - " \"embedding_model\": \"nvidia/llama-nemotron-embed-1b-v2\",\n", - " \"embedding_endpoint\": \"nemotron-embedding-ms:8000/v1\",\n", + " \"embedding_model\": \"nvidia/llama-nemotron-embed-vl-1b-v2\",\n", + " \"embedding_endpoint\": \"nemotron-vlm-embedding-ms:8000/v1\",\n", " \"reranker_model\": \"nvidia/llama-nemotron-rerank-1b-v2\",\n", " \"reranker_endpoint\": \"nemotron-ranking-ms:8000\",\n", " \"stop\": [],\n", @@ -1028,10 +1028,10 @@ " \"enable_reranker\": True,\n", " \"enable_guardrails\": False,\n", " \"enable_citations\": True,\n", - " \"model\": \"nvidia/llama-3.3-nemotron-super-49b-v1.5\",\n", + " \"model\": \"nvidia/nemotron-3-super-120b-a12b\",\n", " \"llm_endpoint\": \"nim-llm:8000\",\n", - " \"embedding_model\": \"nvidia/llama-nemotron-embed-1b-v2\",\n", - " \"embedding_endpoint\": \"nemotron-embedding-ms:8000/v1\",\n", + " \"embedding_model\": \"nvidia/llama-nemotron-embed-vl-1b-v2\",\n", + " \"embedding_endpoint\": \"nemotron-vlm-embedding-ms:8000/v1\",\n", " \"reranker_model\": \"nvidia/llama-nemotron-rerank-1b-v2\",\n", " \"reranker_endpoint\": \"nemotron-ranking-ms:8000\",\n", " \"stop\": [],\n", @@ -1175,8 +1175,8 @@ " ],\n", " \"enable_query_rewriting\": False,\n", " \"enable_reranker\": False,\n", - " \"embedding_model\": \"nvidia/llama-nemotron-embed-1b-v2\",\n", - " \"embedding_endpoint\": \"nemotron-embedding-ms:8000/v1\",\n", + " \"embedding_model\": \"nvidia/llama-nemotron-embed-vl-1b-v2\",\n", + " \"embedding_endpoint\": \"nemotron-vlm-embedding-ms:8000/v1\",\n", " \"reranker_model\": \"nvidia/llama-nemotron-rerank-1b-v2\",\n", " \"reranker_endpoint\": \"nemotron-ranking-ms:8000\",\n", "}\n", @@ -1233,8 +1233,8 @@ " ],\n", " \"enable_query_rewriting\": False,\n", " \"enable_reranker\": True,\n", - " \"embedding_model\": \"nvidia/llama-nemotron-embed-1b-v2\",\n", - " \"embedding_endpoint\": \"nemotron-embedding-ms:8000/v1\",\n", + " \"embedding_model\": \"nvidia/llama-nemotron-embed-vl-1b-v2\",\n", + " \"embedding_endpoint\": \"nemotron-vlm-embedding-ms:8000/v1\",\n", " \"reranker_model\": \"nvidia/llama-nemotron-rerank-1b-v2\",\n", " \"reranker_endpoint\": \"nemotron-ranking-ms:8000\",\n", "}\n", diff --git a/docs/accuracy-benchmarks.md b/docs/accuracy-benchmarks.md index 6ae3a4529..86252bcb5 100644 --- a/docs/accuracy-benchmarks.md +++ b/docs/accuracy-benchmarks.md @@ -123,4 +123,4 @@ Google Frames targets complex queries that require synthesizing facts across mul - [Enable Reasoning in Nemotron LLM Models](enable-nemotron-thinking.md) - [VLM-Based Inferencing in RAG](vlm.md) - [Image Captioning Support](image_captioning.md) -- [Best Practices for Common Settings](accuracy_perf.md) \ No newline at end of file +- [Best Practices for Common Settings](accuracy_perf.md) diff --git a/docs/accuracy_perf.md b/docs/accuracy_perf.md index c24f0c9a5..5eb03a9e9 100644 --- a/docs/accuracy_perf.md +++ b/docs/accuracy_perf.md @@ -15,7 +15,7 @@ Change the setting if you want different behavior. |----------------------|------------|---------------------|----------------------|--------------------------| | `APP_NVINGEST_CHUNKOVERLAP` | `150` | Increase overlap to ensure smooth transitions between chunks. | - Larger overlap provides smoother transitions between chunks.
| - Might increase processing overhead.
| | `APP_NVINGEST_CHUNKSIZE` | `512` | Increase chunk size for more context. | - Larger chunks retain more context, improving coherence.
- Larger chunks increase compute time for embedding creation.
- Larger chunks can lead to longer retrieved context, increasing generation latency.
- Very large chunks may dilute semantic focus, reducing embedding precision.
| -| `APP_NVINGEST_ENABLEPDFSPLITTER` | `true` | Set to `true` to perform chunk-based splitting of pdfs after the default page-level extraction occurs. Recommended for PDFs that are mostly text content. | - Provides more granular content segmentation.
| - Can increase the number of chunks and slow down the ingestion process.
| +| `APP_NVINGEST_ENABLE_PAGED_DOC_SPLIT` | `false` | Set to `true` to perform chunk-based splitting for paged documents (`PDF`, `DOCX`, `PPTX`) after default page-level extraction. | - Provides more granular content segmentation for paged files.
| - Can increase the number of chunks and slow down the ingestion process.
| | `APP_NVINGEST_EXTRACTCHARTS` | `true` | Set to `true` to extract charts. | - Improves accuracy for documents that contain charts.
| - Increases ingestion time.
| | `APP_NVINGEST_EXTRACTIMAGES` | `false` | Set to `true` to enable image captioning during ingestion. For details, refer to [Image Captioning Support](image_captioning.md). | - Enhances multimodal retrieval accuracy for documents having images.
| - Increased processing time during ingestion.
- Requires additional GPU resources for VLM model deployment.
| | `APP_NVINGEST_EXTRACTINFOGRAPHICS` | `false` | Set to `true` to extract infographics and text-as-images. | - Improves accuracy for documents that contain text in image format.
| - Increases ingestion time.
| @@ -30,7 +30,7 @@ Change the setting if you want different behavior. | Name | Default | Description | Advantages | Disadvantages | |----------------------|------------|---------------------|----------------------|--------------------------| -| - `APP_LLM_MODELNAME`
- `APP_EMBEDDINGS_MODELNAME`
- `APP_RANKING_MODELNAME`
| See description | The default models are the following:
- `nvidia/llama-3.3-nemotron-super-49b-v1.5`
- `nvidia/llama-nemotron-embed-1b-v2`
- `nvidia/llama-nemotron-rerank-1b-v2`

You can use larger models. For details, refer to [Change the Inference or Embedding Model](change-model.md). | - Higher accuracy with better reasoning and a larger context length.
| - Slower response time.
- Higher inference cost.
- Higher GPU requirement.
| +| - `APP_LLM_MODELNAME`
- `APP_EMBEDDINGS_MODELNAME`
- `APP_RANKING_MODELNAME`
| See description | The default models are the following:
- `nvidia/nemotron-3-super-120b-a12b`
- `nvidia/llama-nemotron-embed-vl-1b-v2`
- `nvidia/llama-nemotron-rerank-1b-v2`

You can use larger models. For details, refer to [Change the Inference or Embedding Model](change-model.md). | - Higher accuracy with better reasoning and a larger context length.
| - Slower response time.
- Higher inference cost.
- Higher GPU requirement.
| | `APP_VECTORSTORE_SEARCHTYPE` | `dense` | Set to `hybrid` to enable hybrid search. For details, refer to [Hybrid Search Support](hybrid_search.md). | - Can provide better retrieval accuracy for domain-specific content.
| - Can induce higher latency for large number of documents.
| | `ENABLE_GUARDRAILS` | `false` | Set to `true` to enable NeMo Guardrails. For details, refer to [Nemo Guardrails Support](nemo-guardrails.md). | - Applies input/output constraints for better safety and consistency.
| - Significant increased processing overhead for additional LLM calls.
- Needs additional GPUs to deploy guardrails-specific models locally.
| | `ENABLE_QUERYREWRITER` | `false` | Set to `true` to enable query rewriting. For details, refer to [Multi-Turn Conversation Support](multiturn.md). | - Enhances retrieval accuracy for multi-turn scenarios by rephrasing the query.
| - Adds an extra LLM call, increasing latency.
| diff --git a/docs/agentic-rag.md b/docs/agentic-rag.md new file mode 100644 index 000000000..953ad5ce2 --- /dev/null +++ b/docs/agentic-rag.md @@ -0,0 +1,163 @@ + +# Agentic RAG for NVIDIA RAG Blueprint + +## Overview + +Standard Retrieval-Augmented Generation answers in one pass: embed the query, retrieve top-k chunks, and have an LLM answer from them. That fits direct factual questions but falters when the query is ambiguous, spans documents, needs several facts combined, or targets precise locations in a large or noisy corpus. + +Agentic RAG treats the query as something to reason about, not a single retrieval call. Instead of one retrieve-then-generate step, an LLM-driven agent plans short, focused sub-questions, runs each against the retriever, weighs partial answers, retries with reformulated queries when results are thin, then synthesizes a coherent answer. Optional verification checks the synthesis for gaps and triggers targeted re-retrieval when needed. + +The [NVIDIA RAG Blueprint](readme.md) implements Agentic RAG as a LangGraph plan-and-execute pipeline next to the standard RAG chain. It includes: + +- **Two-phase planning**—an initial scope-discovery phase learns what the corpus holds for ambiguous queries, then a targeted answer-planning phase yields concrete retrieval tasks. +- **Mini-agent task execution**—each task runs a small retrieve-answer-retry loop; a seed-query generator LLM reformulates search when the partial answer shows missing information. +- **Synthesis**—task sub-answers and initial retrieval context merge into one final answer. +- **Optional verification**—after synthesis, a quality gate flags coverage gaps, vague claims, and wrong-subject drift, then re-retrieves to close them. + +The pipeline defaults to off because Agentic RAG trades latency and extra LLM calls for accuracy. Use it for multi-hop questions, ambiguity, cross-document queries, and numeric pulls from tables or charts. Enable it for a whole deployment or per request—see [Enable Agentic RAG](#enable-agentic-rag). + +## Key Benefits + +- **No dataset-specific configuration.** Scope discovery adapts to any collection; you don't need per-corpus rules. +- **Handles ambiguous queries.** Scope discovery probes the vector database before planning, so under-specified questions align with what's actually in the corpus. +- **Adaptive cost.** Simple queries use the initial retrieval only (few LLM calls); complex queries get full planning, retries, and verification. +- **Parallel tasks.** Independent plan tasks run together to reduce wall time. +- **Verification gate.** Post-synthesis checks catch incomplete coverage, vague answers, false negatives, and wrong-subject drift, then re-retrieve to fill gaps. + +## Limitations + +- Latency and LLM-call count exceed the standard chain. Prefer the per-request override ([Enable per request](#enable-per-request)) over a global default on latency-sensitive paths. +- The agentic path does not use NeMo Guardrails, Self-Reflection, Query Decomposition, or VLM Inference. Query rewriting, multi-turn history, multi-collection retrieval, citations, filter generation, and reranking are supported. +- Verification runs once; there's no nested verification loop. +- Tasks in a plan run at one parallel level; there's no DAG or depends-on construct. +- Response metadata that is specific to the Standard RAG single-pass pipeline can be omitted or returned empty for Agentic RAG when it does not map cleanly to the multi-step agentic flow. + +## Observability + +When observability is enabled, Agentic RAG exports aggregate `agentic_` Prometheus metrics for retrieval calls, task outcomes, stage latency, LLM usage, and verification behavior. These metrics are separate from the Standard RAG dashboard because Agentic RAG can issue multiple retrieval and LLM calls across initial retrieval, task execution, retries, synthesis, and verification. + +Use `deploy/config/agentic-rag-metrics-dashboard.json` to view these metrics in Grafana. See [Observability Setup](observability.md#view-metrics-in-grafana) for dashboard import steps. + +## Architecture Overview + +The pipeline is a LangGraph state machine with five parts: + +1. **Initial Retrieval**—runs the user query through the standard `/search` path (vector DB and reranker to top-k chunks) so planning reflects what's in the corpus. +2. **Planner (two-phase).** One LLM picks among three plan shapes: + - *Scope discovery plan*—two or three discovery tasks probe the corpus when the query is ambiguous; the planner runs again with those results. + - *Answer plan*—answer tasks tied to what turned up. + - *Empty plan*—no tasks; initial retrieval is enough and synthesis follows directly (the low-cost path for simple queries). +3. **Task Execute**—each task is a mini-agent: retrieve, answer, and if the answer is partial, the seed-query generator issues a follow-up query for what's missing, then retries. Tasks in a plan run concurrently. +4. **Synthesis**—merges task sub-answers, initial retrieval context, and the resolved query into one answer. If every task returns `[NO DATA]`, it falls back to the initial context. +5. **Verification (optional)**—checks the answer for gaps. On `pass`, you're done. On `fail`, follow-up tasks use the same execute engine and synthesis runs again with the gap data. + +The diagram below shows how the stages connect, including the scope-replan loop back into the planner and the verify-replan loop back into task execution. + +```{figure} assets/arch_agentic_rag.png + +Agentic RAG pipeline — initial retrieval feeds the planner, which emits an empty, answer, or scope-discovery plan; tasks execute with per-task retrieval and optional follow-ups; synthesis produces the answer, and optional verification can trigger a targeted re-plan. +``` + +## Enable Agentic RAG + +### Enable per request (API) (recommended) + +Prefer enabling Agentic RAG per request with the `agentic` field in the `/v1/generate` body. +The server `ENABLE_AGENTIC_RAG` env var only sets the default when `agentic` is omitted. + +```jsonc +{ + "messages": [{"role": "user", "content": "..."}], + "use_knowledge_base": true, + "agentic": true, + "collection_names": ["..."] +} +``` + +When `agentic` is omitted or `null`, the server uses `ENABLE_AGENTIC_RAG`. Agentic RAG applies only if `use_knowledge_base=true`. The agentic path respects `enable_streaming`: when `true` (default), it streams stage events and final tokens as Server-Sent Events; when `false`, the graph finishes and returns the full answer in one chunk. The standard RAG chain always streams. + +With streaming on (the default), the RAG UI surfaces each stage as the graph runs—initial retrieval, the plan, per-task execution, and synthesis stream in before the final answer. + +```{image} assets/ui-agentic-rag-streaming.png +:width: 750px +``` + +### Change the deployment default (environment variable) + +Use this to change the default for requests that don't set `agentic`. + +### Docker Deployment + +Follow [Self-Hosted Models](deploy-docker-self-hosted.md) or [NVIDIA-Hosted Models](deploy-docker-nvidia-hosted.md). The reference compose env file (`deploy/compose/nvdev.env`) already includes agentic LLM settings; flip the enable flag only. + +```bash +export ENABLE_AGENTIC_RAG=true +``` + +Restart the RAG server: + +```bash +docker compose -f deploy/compose/docker-compose-rag-server.yaml up -d +``` + +### Helm Deployment + +Edit [`values.yaml`](../deploy/helm/nvidia-blueprint-rag/values.yaml): + +```yaml +envVars: + # ... existing configurations ... + ENABLE_AGENTIC_RAG: "true" + +# Optional—per-role API keys (required only when overriding NVIDIA_API_KEY). +envSecrets: + agenticPlannerLlmApiKey: "" + agenticTaskLlmApiKey: "" + agenticSeedGenLlmApiKey: "" + agenticSynthesisLlmApiKey: "" +``` + +Apply changes as in [Change a Deployment](deploy-helm.md#change-a-deployment). + +## Configuration + +Agentic behavior is driven by environment variables from `deploy/compose/docker-compose-rag-server.yaml` and the matching Helm `values.yaml`. + +### Top-level + +The following table summarizes the main toggles: + +| Variable | Default | Description | +| --- | --- | --- | +| `ENABLE_AGENTIC_RAG` | `false` | Route knowledge-base queries through the agentic pipeline. Override per request with the `agentic` field. | +| `AGENTIC_LOG_LEVEL` | `INFO` | Agent log level (`DEBUG`, `INFO`, `WARNING`, `ERROR`). | +| `AGENTIC_VERIFICATION_ENABLED` | `false` | Run verification after first synthesis. Improves accuracy at higher LLM cost. | +| `AGENTIC_CONTEXT_MAX_TOKENS` | `100000` | Token budget for chunk context in agent prompts; chunks beyond this are truncated. | + +### Per-role LLMs + +Each role has its own env prefix. Docker Compose chains these role-specific settings through the main `APP_LLM_*` settings, so one `APP_LLM_MODELNAME`, `APP_LLM_SERVERURL`, and `APP_LLM_APIKEY` configuration applies to every agentic role unless a role-specific `AGENTIC_*_LLM_*` value is set. If a role's `MODEL` is empty, the builder uses the planner LLM, then the main RAG LLM. + +| Role | Used for | Server URL | Model | API Key | +| --- | --- | --- | --- | --- | +| Planner | Scope resolution, task creation, verification | `AGENTIC_PLANNER_LLM_SERVERURL` | `AGENTIC_PLANNER_LLM_MODEL` | `AGENTIC_PLANNER_LLM_APIKEY` | +| Task | Answering sub-questions | `AGENTIC_TASK_LLM_SERVERURL` | `AGENTIC_TASK_LLM_MODEL` | `AGENTIC_TASK_LLM_APIKEY` | +| Seed-gen | Retry follow-up queries | `AGENTIC_SEED_GEN_LLM_SERVERURL` | `AGENTIC_SEED_GEN_LLM_MODEL` | `AGENTIC_SEED_GEN_LLM_APIKEY` | +| Synthesis | Final answer | `AGENTIC_SYNTHESIS_LLM_SERVERURL` | `AGENTIC_SYNTHESIS_LLM_MODEL` | `AGENTIC_SYNTHESIS_LLM_APIKEY` | + +Default Compose values come from the main LLM config: `APP_LLM_SERVERURL` defaults to `nim-llm:8000` and `APP_LLM_MODELNAME` defaults to `nvidia/nemotron-3-super-120b-a12b`. Set `SERVERURL=""` to use the NVIDIA-hosted API. API keys fall back through the role-specific value, `APP_LLM_APIKEY`, and the usual NVIDIA-hosted defaults. + +Per-request `/v1/generate` `model` and `llm_endpoint` values override every agentic role for that request. Omit those fields to use the deployment or role-specific configuration. + +## Related Topics + +- [Best Practices for Common Settings](accuracy_perf.md) +- [Customize Prompts](prompt-customization.md) +- [Query Decomposition](query_decomposition.md) +- [Self-Reflection](self-reflection.md) +- [Deploy with Docker (Self-Hosted Models)](deploy-docker-self-hosted.md) +- [Deploy with Docker (NVIDIA-Hosted Models)](deploy-docker-nvidia-hosted.md) +- [Deploy with Helm](deploy-helm.md) diff --git a/docs/api-rag.md b/docs/api-rag.md index 7a15d8890..518696304 100644 --- a/docs/api-rag.md +++ b/docs/api-rag.md @@ -10,8 +10,6 @@ This documentation contains the OpenAPI reference for the RAG server. :::{tip} To view this documentation on docs.nvidia.com, browse to [https://docs.nvidia.com/rag/latest/api-rag](https://docs.nvidia.com/rag/latest/api-rag.html). ::: -======= -To view this documentation on docs.nvidia.com, browse to [https://docs.nvidia.com/rag/latest/api-rag](https://docs.nvidia.com/rag/latest/api-rag.html). :::{swagger-plugin} ../docs/api_reference/openapi_schema_rag_server.json diff --git a/docs/api_reference/openapi_schema_ingestor_server.json b/docs/api_reference/openapi_schema_ingestor_server.json index 1f13b4828..9f19b04e1 100644 --- a/docs/api_reference/openapi_schema_ingestor_server.json +++ b/docs/api_reference/openapi_schema_ingestor_server.json @@ -203,6 +203,32 @@ "default": "multimodal_data", "title": "Collection Name" } + }, + { + "name": "force_get_metadata", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "description": "By default, each item includes per-document metadata from the vector store. When the number of documents exceeds an internal threshold, the server skips the expensive full scan: names and document_info are still returned, but per-document metadata is omitted (empty). Set true to force the full scan so metadata is populated regardless of collection size (e.g. always use the Milvus iterator path).", + "default": false, + "title": "Force Get Metadata" + }, + "description": "By default, each item includes per-document metadata from the vector store. When the number of documents exceeds an internal threshold, the server skips the expensive full scan: names and document_info are still returned, but per-document metadata is omitted (empty). Set true to force the full scan so metadata is populated regardless of collection size (e.g. always use the Milvus iterator path)." + }, + { + "name": "max_results", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 1000000, + "minimum": 1, + "description": "Maximum number of documents to return in this response (caps payload size for large collections).", + "default": 1000, + "title": "Max Results" + }, + "description": "Maximum number of documents to return in this response (caps payload size for large collections)." } ], "responses": { @@ -938,7 +964,7 @@ "type": "string", "title": "Vdb Endpoint", "description": "Endpoint of the vector database.", - "default": "http://milvus:19530" + "default": "http://elasticsearch:9200" }, "collection_name": { "type": "string", @@ -1089,7 +1115,7 @@ "total_documents": { "type": "integer", "title": "Total Documents", - "description": "Total number of documents uploaded.", + "description": "For GET /documents: total number of documents in the collection (before any `max_results` cap). For DELETE /documents: number of documents affected as described in `message`. May differ from len(`documents`) when the list is truncated.", "default": 0 }, "documents": { @@ -1098,13 +1124,13 @@ }, "type": "array", "title": "Documents", - "description": "List of uploaded documents.", + "description": "Documents included in this response.", "default": [] } }, "type": "object", "title": "DocumentListResponse", - "description": "Response model for uploading a document." + "description": "Response model for listing or deleting documents in the vector store." }, "FailedCollection": { "properties": { diff --git a/docs/api_reference/openapi_schema_rag_server.json b/docs/api_reference/openapi_schema_rag_server.json index 5bcf2ec7d..9166cf9e5 100644 --- a/docs/api_reference/openapi_schema_rag_server.json +++ b/docs/api_reference/openapi_schema_rag_server.json @@ -1,9 +1,9 @@ { "openapi": "3.1.0", "info": { - "title": "APIs for NVIDIA RAG Server", - "description": "This API schema describes all the retriever endpoints exposed for NVIDIA RAG server Blueprint. Includes v1 APIs and v2 OpenAI-compatible APIs.", - "version": "2.0.0" + "title": "APIs for NVIDIA RAG Server (v1)", + "description": "This API schema describes all the retriever endpoints exposed for NVIDIA RAG server Blueprint", + "version": "1.0.0" }, "paths": { "/v1/health": { @@ -286,89 +286,6 @@ } } } - }, - "/v2/vector_stores/{vector_store_id}/search": { - "post": { - "tags": [ - "Retrieval APIs" - ], - "summary": "Vector Store Search", - "description": "OpenAI-compatible vector store search endpoint (v2 only).\n\nThis is the primary OpenAI-compatible endpoint for vector store search.\nSearch within a vector store using natural language queries with full OpenAI API compatibility.\n\n**Note:** This endpoint is exclusive to the v2 API and is not available in v1.\n\nArgs:\n request: FastAPI request object\n vector_store_id: The ID of the vector store (collection name) to search\n search_request: Search request parameters in OpenAI format\n\nReturns:\n JSONResponse: Search results in OpenAI-compatible format", - "operationId": "vector_store_search_v2_vector_stores__vector_store_id__search_post", - "parameters": [ - { - "name": "vector_store_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Vector Store Id" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/VectorStoreSearchRequest" - } - } - } - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/VectorStoreSearchResponse" - } - } - } - }, - "400": { - "description": "Bad Request", - "content": { - "application/json": { - "example": { - "detail": "Invalid request parameters" - } - } - } - }, - "499": { - "description": "Client Closed Request", - "content": { - "application/json": { - "example": { - "detail": "The client cancelled the request" - } - } - } - }, - "500": { - "description": "Internal Server Error", - "content": { - "application/json": { - "example": { - "detail": "Internal server error occurred" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } } }, "components": { @@ -455,6 +372,30 @@ ], "description": "Latency metrics associated with the request", "default": {} + }, + "event_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Event Type", + "description": "Type of streaming chunk. None for non-streaming and for the regular (non-agentic) path's content chunks. Set for agentic-RAG streaming chunks; see ``nvidia_rag.rag_server.agentic_rag.streaming.EventType`` for the enumerated values (e.g. ``stage_start``, ``stage_end``, ``intermediate_reasoning``, ``intermediate_output``, ``final_reasoning``, ``final_answer``, ``agent_event``, ``error``)." + }, + "stage": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Stage", + "description": "Name of the agentic-RAG graph node that produced this chunk (e.g. ``initial_retrieval``, ``plan``, ``execute``, ``synthesize``, ``verify``, ``verify_execute``). Pairs with ``event_type`` so clients can group reasoning by pipeline stage without parsing event_type. None for non-agentic responses." } }, "type": "object", @@ -661,7 +602,7 @@ "type": "string", "title": "Vdb Endpoint", "description": "Endpoint url of the vector database server.", - "default": "http://milvus:19530" + "default": "http://elasticsearch:9200" }, "collection_names": { "items": { @@ -707,7 +648,7 @@ "maxLength": 256, "title": "Embedding Model", "description": "Name of the embedding model used for vectorization.", - "default": "nvdev/nvidia/llama-nemotron-embed-1b-v2" + "default": "nvidia/llama-nemotron-embed-vl-1b-v2" }, "embedding_endpoint": { "type": "string", @@ -953,6 +894,18 @@ "title": "Content", "description": "The input query/prompt to the pipeline. Can be a string for text-only messages, or an array of content objects for multimodal messages containing text and/or images.", "default": "Hello! What can you help me with?" + }, + "reasoning_content": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Reasoning Content", + "description": "Reasoning trace or intermediate output from the agentic RAG pipeline. Populated for streamed chunks whose ``event_type`` indicates a reasoning/intermediate event (stage announcements, intermediate-stage reasoning or output tokens, final-stage reasoning tokens). The user-facing answer is always streamed via ``content`` — this field is purely supplementary." } }, "type": "object", @@ -994,6 +947,18 @@ "title": "Content", "description": "The input query/prompt to the pipeline. Can be a string for text-only messages, or an array of content objects for multimodal messages containing text and/or images.", "default": "Hello! What can you help me with?" + }, + "reasoning_content": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Reasoning Content", + "description": "Reasoning trace or intermediate output from the agentic RAG pipeline. Populated for streamed chunks whose ``event_type`` indicates a reasoning/intermediate event (stage announcements, intermediate-stage reasoning or output tokens, final-stage reasoning tokens). The user-facing answer is always streamed via ``content`` — this field is purely supplementary." } }, "type": "object", @@ -1203,19 +1168,33 @@ "default": true }, "temperature": { - "type": "number", - "maximum": 1, - "minimum": 0, + "anyOf": [ + { + "type": "number", + "maximum": 1, + "minimum": 0 + }, + { + "type": "null" + } + ], "title": "Temperature", - "description": "The sampling temperature to use for text generation. The higher the temperature value is, the less deterministic the output text will be. It is not recommended to modify both temperature and top_p in the same call.", + "description": "The sampling temperature to use for text generation. The higher the temperature value is, the less deterministic the output text will be. If unset, the model/provider default is used. It is not recommended to modify both temperature and top_p in the same call.", "default": 0 }, "top_p": { - "type": "number", - "maximum": 1, - "minimum": 0.1, + "anyOf": [ + { + "type": "number", + "maximum": 1, + "minimum": 0.1 + }, + { + "type": "null" + } + ], "title": "Top P", - "description": "The top-p sampling mass used for text generation. The top-p value determines the probability mass that is sampled at sampling time. For example, if top_p = 0.2, only the most likely tokens (summing to 0.2 cumulative probability) will be sampled. It is not recommended to modify both temperature and top_p in the same call.", + "description": "The top-p sampling mass used for text generation. The top-p value determines the probability mass that is sampled at sampling time. For example, if top_p = 0.2, only the most likely tokens (summing to 0.2 cumulative probability) will be sampled. If unset, the model/provider default is used. It is not recommended to modify both temperature and top_p in the same call.", "default": 1 }, "min_tokens": { @@ -1237,7 +1216,7 @@ "format": "int64", "title": "Max Tokens", "description": "The maximum number of tokens to generate in any given call. Note that the model is not aware of this value, and generation will simply stop at the number of tokens specified.", - "default": 32768 + "default": 16256 }, "min_thinking_tokens": { "type": "integer", @@ -1273,7 +1252,7 @@ "type": "string", "title": "Vdb Endpoint", "description": "Endpoint url of the vector database server.", - "default": "http://milvus:19530" + "default": "http://elasticsearch:9200" }, "collection_names": { "items": { @@ -1328,7 +1307,7 @@ "pattern": "[\\s\\S]*", "title": "Model", "description": "Name of NIM LLM model to be used for inference.", - "default": "nvidia/llama-3.3-nemotron-super-49b-v1.5" + "default": "nvidia/nemotron-3-super-120b-a12b" }, "llm_endpoint": { "type": "string", @@ -1342,7 +1321,7 @@ "maxLength": 256, "title": "Embedding Model", "description": "Name of the embedding model used for vectorization.", - "default": "nvdev/nvidia/llama-nemotron-embed-1b-v2" + "default": "nvidia/llama-nemotron-embed-vl-1b-v2" }, "embedding_endpoint": { "anyOf": [ @@ -1384,7 +1363,7 @@ "maxLength": 256, "title": "Vlm Model", "description": "Name of the VLM model used for inference.", - "default": "nvidia/nemotron-nano-12b-v2-vl" + "default": "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning" }, "vlm_endpoint": { "anyOf": [ @@ -1469,6 +1448,25 @@ "title": "Confidence Threshold", "description": "Minimum confidence score threshold for filtering chunks. Only chunks with relevance scores >= this threshold will be included. Range: 0.0 to 1.0. Default: 0.0 (no filtering). Note: Requires enable_reranker=True to generate relevance scores.", "default": 0 + }, + "agentic": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Agentic", + "description": "Route this request through the agentic RAG pipeline (LangGraph plan-and-execute). When None (default), the server-level CONFIG.enable_agentic_rag config value is used. Explicitly passing True or False overrides the server default for this request.", + "default": false + }, + "enable_streaming": { + "type": "boolean", + "title": "Enable Streaming", + "description": "Stream intermediate reasoning, stage announcements, and final-answer tokens as they are produced. Currently honored by the agentic RAG pipeline; the regular (non-agentic) pipeline always streams. When False on the agentic path, the graph runs to completion and the full answer is returned as a single SSE chunk (legacy behavior).", + "default": true } }, "type": "object", @@ -1516,12 +1514,26 @@ "RagConfigurationDefaults": { "properties": { "temperature": { - "type": "number", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], "title": "Temperature", "description": "Default sampling temperature for generation" }, "top_p": { - "type": "number", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], "title": "Top P", "description": "Default top-p sampling mass" }, @@ -1706,6 +1718,14 @@ "description": "Relevance score of the document", "default": 0 }, + "stage": { + "type": "string", + "maxLength": 100, + "pattern": "[\\s\\S]*", + "title": "Stage", + "description": "Pipeline stage that produced this result (e.g. 'rag', 'initial_retrieval', 'execute', 'verify_execute')", + "default": "rag" + }, "metadata": { "$ref": "#/components/schemas/SourceMetadata" } @@ -1895,7 +1915,7 @@ }, "text": { "type": "string", - "maxLength": 131072, + "maxLength": 128000, "pattern": "[\\s\\S]*", "title": "Text", "description": "The text content" @@ -1974,397 +1994,6 @@ "type" ], "title": "ValidationError" - }, - "ComparisonFilter": { - "properties": { - "key": { - "type": "string", - "title": "Key", - "description": "The key to compare against the value.", - "examples": [ - "author", - "page_number", - "category" - ] - }, - "type": { - "type": "string", - "title": "Type", - "description": "Specifies the comparison operator: eq (equals), ne (not equal), gt (greater than), gte (greater than or equal), lt (less than), lte (less than or equal), in, nin (not in).", - "examples": [ - "eq", - "gt", - "in" - ] - }, - "value": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "integer" - }, - { - "type": "number" - }, - { - "type": "boolean" - }, - { - "items": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "integer" - }, - { - "type": "number" - }, - { - "type": "boolean" - } - ] - }, - "type": "array" - } - ], - "title": "Value", - "description": "The value to compare against the attribute key; supports string, number, boolean, or array types.", - "examples": [ - "John Doe", - 5, - true, - [ - "tech", - "science" - ] - ] - } - }, - "type": "object", - "required": [ - "key", - "type", - "value" - ], - "title": "ComparisonFilter", - "description": "A filter used to compare a specified attribute key to a given value using a defined comparison operation.", - "examples": [ - { - "key": "", - "type": "eq", - "value": "John Doe" - } - ] - }, - "CompoundFilter": { - "properties": { - "type": { - "type": "string", - "title": "Type", - "description": "Type of operation: 'and' or 'or'.", - "examples": [ - "and", - "or" - ] - }, - "filters": { - "items": { - "anyOf": [ - { - "$ref": "#/components/schemas/ComparisonFilter" - }, - { - "$ref": "#/components/schemas/CompoundFilter" - } - ] - }, - "type": "array", - "title": "Filters", - "description": "Array of filters to combine. Items can be ComparisonFilter or CompoundFilter." - } - }, - "type": "object", - "required": [ - "type", - "filters" - ], - "title": "CompoundFilter", - "description": "Combine multiple filters using 'and' or 'or'.", - "examples": [ - { - "filters": [ - { - "key": "author", - "type": "eq", - "value": "John Doe" - }, - { - "key": "page_number", - "type": "gte", - "value": 5 - } - ], - "type": "and" - } - ] - }, - "RankingOptions": { - "properties": { - "ranker": { - "type": "string", - "title": "Ranker", - "description": "Control re-ranking behavior. To enable: 'auto', 'true', 'on', 'enabled', 'yes', '1'. To disable (reduces latency): 'none', 'false', 'off', 'disabled', 'no', '0'. Case-insensitive.", - "default": "auto", - "examples": [ - "auto", - "none", - "false" - ] - }, - "score_threshold": { - "type": "number", - "maximum": 1, - "minimum": 0, - "title": "Score Threshold", - "description": "Minimum score threshold for filtering results. Only results with scores >= this value will be returned.", - "default": 0, - "examples": [ - 0, - 0.5, - 0.75 - ] - } - }, - "type": "object", - "title": "RankingOptions", - "description": "Ranking options for vector store search.", - "examples": [ - { - "ranker": "auto", - "score_threshold": 0.5 - } - ] - }, - "VectorStoreSearchRequest": { - "properties": { - "query": { - "anyOf": [ - { - "type": "string" - }, - { - "items": { - "anyOf": [ - { - "$ref": "#/components/schemas/TextContent" - }, - { - "$ref": "#/components/schemas/ImageContent" - } - ] - }, - "type": "array" - } - ], - "title": "Query", - "description": "A query string for a search or an array of content objects for multimodal queries.", - "examples": [ - "What is the return policy?", - "Tell me about machine learning" - ] - }, - "filters": { - "anyOf": [ - { - "$ref": "#/components/schemas/ComparisonFilter" - }, - { - "$ref": "#/components/schemas/CompoundFilter" - }, - { - "type": "null" - } - ], - "title": "Filters", - "description": "A filter to apply based on file attributes. Can be a comparison filter or compound filter." - }, - "max_num_results": { - "type": "integer", - "maximum": 50, - "minimum": 1, - "title": "Max Num Results", - "description": "The maximum number of results to return. This number should be between 1 and 50 inclusive.", - "default": 10, - "examples": [ - 10, - 5, - 20 - ] - }, - "ranking_options": { - "anyOf": [ - { - "$ref": "#/components/schemas/RankingOptions" - }, - { - "type": "null" - } - ], - "description": "Ranking options for search." - }, - "rewrite_query": { - "type": "boolean", - "title": "Rewrite Query", - "description": "Whether to rewrite the natural language query for vector search.", - "default": false, - "examples": [ - false, - true - ] - } - }, - "type": "object", - "required": [ - "query" - ], - "title": "VectorStoreSearchRequest", - "description": "OpenAI-compatible vector store search request.", - "examples": [ - { - "max_num_results": 10, - "query": "What is the return policy?", - "ranking_options": { - "ranker": "auto", - "score_threshold": 0.5 - }, - "rewrite_query": false - }, - { - "max_num_results": 5, - "query": "machine learning basics", - "ranking_options": { - "ranker": "none", - "score_threshold": 0 - }, - "rewrite_query": true - } - ] - }, - "VectorStoreSearchResponse": { - "properties": { - "object": { - "type": "string", - "title": "Object", - "description": "Object type identifier", - "default": "vector_store.search_results.page" - }, - "search_query": { - "type": "string", - "title": "Search Query", - "description": "The search query that was executed" - }, - "data": { - "items": { - "$ref": "#/components/schemas/VectorStoreSearchResultItem" - }, - "type": "array", - "title": "Data", - "description": "List of search results" - }, - "has_more": { - "type": "boolean", - "title": "Has More", - "description": "Whether there are more results available", - "default": false - }, - "next_page": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Next Page", - "description": "Token for retrieving the next page of results" - } - }, - "type": "object", - "required": [ - "search_query", - "data" - ], - "title": "VectorStoreSearchResponse", - "description": "OpenAI-compatible vector store search response." - }, - "VectorStoreSearchResultContent": { - "properties": { - "type": { - "type": "string", - "title": "Type", - "description": "Type of content (e.g., 'text')" - }, - "text": { - "type": "string", - "title": "Text", - "description": "Text content" - } - }, - "type": "object", - "required": [ - "type", - "text" - ], - "title": "VectorStoreSearchResultContent", - "description": "Content object in search result." - }, - "VectorStoreSearchResultItem": { - "properties": { - "file_id": { - "type": "string", - "title": "File Id", - "description": "Identifier for the file" - }, - "filename": { - "type": "string", - "title": "Filename", - "description": "Name of the file" - }, - "score": { - "type": "number", - "title": "Score", - "description": "Relevance score" - }, - "attributes": { - "additionalProperties": true, - "type": "object", - "title": "Attributes", - "description": "File attributes/metadata" - }, - "content": { - "items": { - "$ref": "#/components/schemas/VectorStoreSearchResultContent" - }, - "type": "array", - "title": "Content", - "description": "Content chunks from the file" - } - }, - "type": "object", - "required": [ - "file_id", - "filename", - "score", - "attributes", - "content" - ], - "title": "VectorStoreSearchResultItem", - "description": "Single search result item in OpenAI format." } } }, @@ -2382,4 +2011,4 @@ "description": "APIs for retrieval followed by generation." } ] -} \ No newline at end of file +} diff --git a/docs/assets/arch_agentic_rag.png b/docs/assets/arch_agentic_rag.png new file mode 100644 index 0000000000000000000000000000000000000000..ee1ee245757e938b7409597be4802090c8fa3db4 GIT binary patch literal 264495 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%0AO&uJ$(QeBxW{}ZHeIx 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

N}b^iD3?-j|7n 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 literal 0 HcmV?d00001 diff --git a/docs/change-model.md b/docs/change-model.md index 871d8f5f3..ff8cd1906 100644 --- a/docs/change-model.md +++ b/docs/change-model.md @@ -15,11 +15,11 @@ To navigate this page more easily, click the outline button at the top of the pa ### Change the LLM Model -To change the inference model to a model from the API catalog, +The default LLM is `nvidia/nemotron-3-super-120b-a12b`. To use a different model from the API catalog, specify the model in the `APP_LLM_MODELNAME` environment variable when you start the RAG Server. ```console -export APP_LLM_MODELNAME='nvidia/llama-3.3-nemotron-super-49b-v1.5' +export APP_LLM_MODELNAME='nvidia/nemotron-3-super-120b-a12b' docker compose -f deploy/compose/docker-compose-rag-server.yaml up -d ``` @@ -48,7 +48,7 @@ Both names refer to the same underlying model. Use the appropriate name based on ##### Nemotron 3 Super -Nemotron 3 Super is a larger model with different GPU and environment requirements: local NIM deployment requires at least 2 GPUs (FP8 TP2), and you may need a dedicated prompt config and reasoning settings. For full deployment steps (Docker and Helm), see the [Nemotron 3 Super deployment guide](nemotron3-super-deployment.md). +`nvidia/nemotron-3-super-120b-a12b` is the default LLM for this blueprint. For hardware requirements and RTX PRO 6000-specific setup, see the [Nemotron 3 Super deployment guide](nemotron3-super-deployment.md). ### Change the Embedding Model @@ -81,7 +81,7 @@ Always use same embedding model or model having same tokinizers for both ingesti ### Configure Embedding Dimensions -The default embedding model (`nvidia/llama-nemotron-embed-1b-v2`) uses **2048 dimensions** by default. When changing to a different embedding model, you may need to update the dimensions to match the model's output. +The default embedding model (`nvidia/llama-nemotron-embed-vl-1b-v2`) uses **2048 dimensions** by default. When changing to a different embedding model, you may need to update the dimensions to match the model's output. **Important:** Some embedding models have **fixed output dimensions** and do not accept a `dimensions` parameter. For example, `nvidia/nv-embedqa-e5-v5` always outputs 1024-dimensional embeddings. If you use such a model without configuring the dimensions, you may encounter an error like: @@ -215,7 +215,7 @@ Use this procedure to change models when you are running self-hosted NVIDIA NIM value: "" # Must match APP_LLM_MODELNAME # Embedding NIM - nvidia-nim-llama-32-nv-embedqa-1b-v2: + nvidia-nim-llama-nemotron-embed-1b-v2: enabled: true replicas: 1 service: @@ -237,7 +237,7 @@ Use this procedure to change models when you are running self-hosted NVIDIA NIM value: "1" # Reranker NIM - nvidia-nim-llama-32-nv-rerankqa-1b-v2: + nvidia-nim-llama-nemotron-rerank-1b-v2: enabled: true replicas: 1 service: @@ -279,7 +279,7 @@ Use this procedure to change models when you are running self-hosted NVIDIA NIM list-model-profiles ``` - If only `vllm` profile is available, you must use the **vLLM engine** and add these specific configurations: + If only `vllm` profile is available, you must use the **vLLM engine** with single-GPU (`tensorParallelism: "1"`) configuration. The default values.yaml ships the Nemotron 3 Super profile (`engine: vllm`, `tensorParallelism: "2"`, 2 GPUs); when switching to a Nemotron Nano model, override both the profile and the GPU resources to 1: ```yaml nimOperator: @@ -287,8 +287,15 @@ Use this procedure to change models when you are running self-hosted NVIDIA NIM image: repository: nvcr.io/nim/nvidia/nvidia-nemotron-nano-9b-v2 tag: "latest" + resources: + limits: + nvidia.com/gpu: 1 + requests: + nvidia.com/gpu: 1 model: engine: vllm # Required: use vLLM instead of tensorrt_llm + precision: "fp8" + tensorParallelism: "1" # Nemotron Nano models run on a single GPU env: - name: NIM_SERVED_MODEL_NAME value: "nvidia/nvidia-nemotron-nano-9b-v2" # Must match APP_LLM_MODELNAME @@ -302,11 +309,192 @@ Use this procedure to change models when you are running self-hosted NVIDIA NIM +## Switch from the VLM Embedder to the Text-Only Embedder + +The default embedder is the multimodal `nvidia/llama-nemotron-embed-vl-1b-v2` (2048 dimensions), which embeds both text passages and page images. To revert to the text-only `nvidia/llama-nemotron-embed-1b-v2` (also 2048 dimensions, same vector space size, smaller GPU footprint), follow the steps below. Ingestion and retrieval must use the **same** embedding model — re-ingest your documents after switching. + +For multimodal context on what the VLM embedder does, see [Multimodal Retriever](multimodal-retriever.md). + +### Docker Compose + +The compose files already include the text-only model as a commented-out alternative. Either uncomment it in place, or override at run time via env var. + +1. Override via env var (simplest): + + ```bash + export APP_EMBEDDINGS_MODELNAME="nvidia/llama-nemotron-embed-1b-v2" + ``` + +2. Restart the rag and ingestor servers: + + ```bash + docker compose -f deploy/compose/docker-compose-ingestor-server.yaml up -d + docker compose -f deploy/compose/docker-compose-rag-server.yaml up -d + ``` + +The text-only embedding NIM (`nemotron-embedding-ms` in [`deploy/compose/nims.yaml`](../deploy/compose/nims.yaml)) is started by the same profile that the VLM embedder uses, so no separate NIM startup is needed. + +### Helm + +In [`values.yaml`](../deploy/helm/nvidia-blueprint-rag/values.yaml), enable the text embedder NIM, disable the VLM embedder NIM, and repoint the embedding env vars (model name and server URL) at the text endpoint in all three places (rag-server `envVars`, `ingestor-server.envVars`, and `nv-ingest.envVars`). Without flipping the `nimOperator` enable flags the text-embedder pod is not deployed, and without updating `APP_EMBEDDINGS_SERVERURL` / `EMBEDDING_NIM_ENDPOINT` traffic still goes to the VLM embedder: + +```yaml +nimOperator: + # Disable VLM embedder, enable text embedder + nvidia-nim-llama-nemotron-embed-vl-1b-v2: + enabled: false + nvidia-nim-llama-nemotron-embed-1b-v2: + enabled: true + +# rag-server: point at the text embedder +envVars: + APP_EMBEDDINGS_MODELNAME: "nvidia/llama-nemotron-embed-1b-v2" + APP_EMBEDDINGS_SERVERURL: "nemotron-embedding-ms:8000/v1" + +ingestor-server: + envVars: + APP_EMBEDDINGS_MODELNAME: "nvidia/llama-nemotron-embed-1b-v2" + APP_EMBEDDINGS_SERVERURL: "nemotron-embedding-ms:8000/v1" + +nv-ingest: + envVars: + # Embedding target for the nv-ingest runtime + EMBEDDING_NIM_ENDPOINT: "http://nemotron-embedding-ms:8000/v1" + EMBEDDING_NIM_MODEL_NAME: "nvidia/llama-nemotron-embed-1b-v2" +``` + +Apply with [Change a Deployment](deploy-helm.md#change-a-deployment). + +:::{warning} +**Re-ingest after switching.** Vectors produced by the VLM embedder are not directly comparable to vectors from the text-only embedder; retrieval accuracy will degrade until you re-ingest your corpus. +::: + + + +## Switch to the VLM Reranker + +The default reranker is the text reranker `nvidia/llama-nemotron-rerank-1b-v2`. To use a multimodal reranker that can re-rank with awareness of cited images, switch to `nvidia/llama-nemotron-rerank-vl-1b-v2`. See [Multimodal Retriever — Part 2: VLM Reranker](multimodal-retriever.md#part-2--vlm-reranker) for what the multimodal reranker does and the `ENABLE_VLM_RERANKER_IMAGE_INPUT` flag in detail. The steps below cover the model swap. + +### Docker Compose + +1. Start the VLM reranker NIM (`nemotron-ranking-vl-ms`, defined in [`deploy/compose/nims.yaml`](../deploy/compose/nims.yaml) under the `vlm-rerank` and `vlm-rag` profiles): + + ```bash + export USERID=$(id -u) + export NGC_API_KEY="nvapi-..." + export RANKING_VL_MS_GPU_ID=0 # optional GPU pinning + + docker compose -f deploy/compose/nims.yaml --profile vlm-rerank up -d + ``` + + Use `--profile vlm-rag` instead if you also want VLM generation and VLM embedding to come up together. + +2. Point the rag-server at the VLM reranker and (optionally) enable image input: + + ```bash + export ENABLE_RERANKER="True" + export APP_RANKING_MODELNAME="nvidia/llama-nemotron-rerank-vl-1b-v2" + export APP_RANKING_SERVERURL="nemotron-ranking-vl-ms:8000" + export ENABLE_VLM_RERANKER_IMAGE_INPUT="True" # see multimodal-retriever.md + docker compose -f deploy/compose/docker-compose-rag-server.yaml up -d + ``` + + :::{note} + The rag-server only follows the multimodal reranker code path when `APP_RANKING_MODELNAME` contains `rerank-vl`. With any other model name, `ENABLE_VLM_RERANKER_IMAGE_INPUT` has no effect. + ::: + +### Helm + +1. In [`values.yaml`](../deploy/helm/nvidia-blueprint-rag/values.yaml), enable the VLM reranker NIM and (optionally) disable the text reranker: + + ```yaml + nimOperator: + nvidia-nim-llama-nemotron-rerank-vl-1b-v2: + enabled: true + # Optional: free the text reranker's GPU slot + nvidia-nim-llama-nemotron-rerank-1b-v2: + enabled: false + ``` + +2. Update the rag-server env vars: + + ```yaml + envVars: + ENABLE_RERANKER: "True" + APP_RANKING_MODELNAME: "nvidia/llama-nemotron-rerank-vl-1b-v2" + APP_RANKING_SERVERURL: "nemotron-ranking-vl-ms:8000" + ENABLE_VLM_RERANKER_IMAGE_INPUT: "True" + ``` + +3. Apply the changes as described in [Change a Deployment](deploy-helm.md#change-a-deployment). + + + +## Switch Back to Nemotron Nano 12B VLM + +The default VLM for this blueprint is **Nemotron Omni** (`nvidia/nemotron-3-nano-omni-30b-a3b-reasoning`). If you want to revert to the previous **Nemotron Nano 12B** (`nvidia/nemotron-nano-12b-v2-vl`) model, follow the steps below. + +### Docker Compose + +1. In `deploy/compose/nims.yaml`, update the `vlm-ms` service image: + + ```yaml + vlm-ms: + image: nvcr.io/nim/nvidia/nemotron-nano-12b-v2-vl:1.6.0 + ``` + +2. Set the model name and disable Omni-specific reasoning knobs before starting services: + + ```bash + export APP_VLM_MODELNAME="nvidia/nemotron-nano-12b-v2-vl" + export APP_NVINGEST_CAPTIONMODELNAME="nvidia/nemotron-nano-12b-v2-vl" + export APP_VLM_ENABLE_THINKING=false + export APP_VLM_THINKING_TOKEN_BUDGET=0 + ``` + +3. Restart the affected services: + + ```bash + docker compose -f deploy/compose/nims.yaml --profile vlm-generation up -d + docker compose -f deploy/compose/docker-compose-rag-server.yaml up -d + docker compose -f deploy/compose/docker-compose-ingestor-server.yaml up -d + ``` + +### Helm + +1. In `deploy/helm/nvidia-blueprint-rag/values.yaml`, update the VLM NIM image and model names: + + ```yaml + nimOperator: + nim-vlm: + image: + repository: nvcr.io/nim/nvidia/nemotron-nano-12b-v2-vl + tag: "1.6.0" + + envVars: + APP_VLM_MODELNAME: "nvidia/nemotron-nano-12b-v2-vl" + APP_VLM_ENABLE_THINKING: "false" + APP_VLM_THINKING_TOKEN_BUDGET: "0" + + ingestor-server: + envVars: + APP_NVINGEST_CAPTIONMODELNAME: "nvidia/nemotron-nano-12b-v2-vl" + + nv-ingest: + envVars: + VLM_CAPTION_MODEL_NAME: nvidia/nemotron-nano-12b-v2-vl + ``` + +2. Apply the changes as described in [Change a Deployment](deploy-helm.md#change-a-deployment). + +:::{note} +Nemotron Nano 12B requires 1x H100 GPU for VLM inference. Ensure `APP_VLM_SERVERURL` points to the correct NIM endpoint after switching. +::: + ## Related Topics - [Best Practices for Common Settings](accuracy_perf.md). - [Deploy with Docker (Self-Hosted Models)](deploy-docker-self-hosted.md) - [Deploy with Docker (NVIDIA-Hosted Models)](deploy-docker-nvidia-hosted.md) - [Deploy with Helm](deploy-helm.md) -- [Nemotron 3 Super deployment (Docker and Helm)](nemotron3-super-deployment.md) - [Service-Specific API Keys](api-key.md#service-specific-api-keys) diff --git a/docs/change-vectordb.md b/docs/change-vectordb.md index 36f4d4f9f..caa94ba45 100644 --- a/docs/change-vectordb.md +++ b/docs/change-vectordb.md @@ -2,59 +2,49 @@ SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 --> -# Configure Elasticsearch as Your Vector Database for NVIDIA RAG Blueprint +# Vector Database Configuration for NVIDIA RAG Blueprint -The [NVIDIA RAG Blueprint](readme.md) supports multiple vector database backends including [Milvus](https://milvus.io/docs) and [Elasticsearch](https://www.elastic.co/elasticsearch/vector-database). -Elasticsearch provides robust search capabilities and can be used as an alternative to Milvus for storing and retrieving document embeddings. +[NVIDIA RAG Blueprint](readme.md) is compatible with several vector database backends, including [Elasticsearch](https://www.elastic.co/elasticsearch/vector-database) and [Milvus](https://milvus.io/docs). Elasticsearch is the default option. Standard deployments automatically use Elasticsearch—no manual configuration is required. In both the RAG and Ingestor servers, the defaults are set to `APP_VECTORSTORE_NAME=elasticsearch` and `APP_VECTORSTORE_URL=http://elasticsearch:9200` in Docker Compose, or to the bundled ECK Elasticsearch HTTP service when using Helm. -After you have [deployed the blueprint](readme.md#deployment-options-for-rag-blueprint), -use this documentation to configure Elasticsearch as your vector database. +Milvus is available as an optional secondary backend if you prefer to use that stack. + +After you’ve [deployed the blueprint](readme.md#deployment-options-for-rag-blueprint), use this page to configure Elasticsearch settings (including authentication and index options), work with the default Elasticsearch setup, switch to Milvus, or connect a custom vector database. :::{tip} To navigate this page more easily, click the outline button at the top of the page. ![outline-button](assets/outline-button.png) +::: +## Configuring Elasticsearch -## Prerequisites and Important Considerations Before You Start +Use this section as a map to the topics below. -The following are some important notes to keep in mind before you switch from Milvus to Elasticsearch. +**Elasticsearch client (library installs)** – For local development without the pre-built Docker images, enable Elasticsearch support by installing nvidia_rag[elasticsearch] using `pip install nvidia_rag[elasticsearch]` or `uv sync --extra elasticsearch`. The Docker images already include this dependency. -- **Elasticsearch Dependency** – Elasticsearch support is provided as an optional dependency. For local development, install it with: - ```bash - pip install nvidia_rag[elasticsearch] - ``` - Or when using uv: - ```bash - uv sync --extra elasticsearch - ``` - The Docker images already include this dependency by default. +- **Changing the backend** – If you switch between vector databases (for example Elasticsearch and Milvus), you must re-upload your documents; data is not migrated automatically. -- **Fresh Setup Required** – When you switch from Milvus to Elasticsearch, you need to re-upload your documents. The data stored in Milvus isn't automatically migrated to Elasticsearch. +- **Port** – Elasticsearch listens on port 9200 by default. Ensure it is available or adjust your configuration. -- **Port Availability** – Elasticsearch runs on port 9200 by default. Ensure this port is available and not in conflict with other services. +- **Elasticsearch data volume (Docker Compose)** – Elasticsearch persists data in a dedicated `rag-vol-elasticsearch` Docker named volume (host path: `/var/lib/docker/volumes/rag-vol-elasticsearch/_data/`). For inspection, backup, reset, and migration from the legacy `deploy/compose/volumes/` host directory, see [Manage Persistent Data Volumes](troubleshooting.md#manage-persistent-data-volumes) in the troubleshooting guide. -- **Folder Permissions** – Elasticsearch data is persisted in the `volumes/elasticsearch` directory. Make sure you create the directory and have appropriate permissions set. +- **Authentication** – Refer to [Elasticsearch Authentication](elasticsearch-configuration.md#elasticsearch-authentication) for xpack, API keys, and Helm (ECK) credentials. - ```bash - sudo mkdir -p deploy/compose/volumes/elasticsearch/ - sudo chown -R 1000:1000 deploy/compose/volumes/elasticsearch/ - ``` +- **Index and search tuning** – Adjust index type, dense or hybrid search, and related behavior with `APP_VECTORSTORE_*` in the RAG and Ingestor compose files or Helm `envVars` (for example `APP_VECTORSTORE_SEARCHTYPE`, `APP_VECTORSTORE_INDEXTYPE`). - :::{note} - If the Elasticsearch container fails to start due to permission issues, you may optionally use `sudo chmod -R 777 deploy/compose/volumes/elasticsearch/` for broader access - ::: +- **GPU indexing** – For optional GPU-accelerated vector indexing (requires an Elastic Enterprise license and a GPU-enabled image), see [Elasticsearch Configuration](elasticsearch-configuration.md). +## Using Elasticsearch (Default) -## Docker Compose Configuration for Elasticsearch Vector Database +The following steps describe the default Elasticsearch deployment for Docker Compose and Helm. -Use the following steps to configure Elasticsearch as your vector database in Docker. +### Docker Compose -1. Start the Elasticsearch container. +1. Start the vector database stack. Elasticsearch is included in the default profile for `vectordb.yaml` (you may pass `--profile elasticsearch` explicitly if you prefer). ```bash - docker compose -f deploy/compose/vectordb.yaml --profile elasticsearch up -d + docker compose -f deploy/compose/vectordb.yaml up -d ``` -2. Set the vector database configuration. +2. Confirm vector database settings. The compose files for the RAG and Ingestor servers already default to Elasticsearch; set or export these if you need to be explicit: ```bash export APP_VECTORSTORE_URL="http://elasticsearch:9200" @@ -72,15 +62,15 @@ Use the following steps to configure Elasticsearch as your vector database in Do Access the RAG UI at `http://:8090`. In the UI, navigate to: Settings > Endpoint Configuration > Vector Database Endpoint → set to `http://elasticsearch:9200`. -## Helm Deployment to Configure Elasticsearch as Vector Database +### Helm -If you're using Helm for deployment, use the following steps to configure Elasticsearch as your vector database. +If you're using Helm for deployment, Elasticsearch (ECK) is enabled by default. Use the following steps to align the release with the default vector database. :::{note} **Performance Consideration**: Slow VDB upload is observed in Helm deployments for Elasticsearch (ES). For more details, refer to the [troubleshooting documentation](./troubleshooting.md). ::: -### Prerequisites +#### Prerequisites 1. Install the ECK (Elastic Cloud on Kubernetes) operator: @@ -112,11 +102,11 @@ If you're using Helm for deployment, use the following steps to configure Elasti kubectl wait --for=condition=ready pod -l app.kubernetes.io/name=elastic-operator -n elastic-system --timeout=300s ``` -### Configuration Steps +#### Configuration Steps -1. Configure Elasticsearch as the vector database in [`values.yaml`](../deploy/helm/nvidia-blueprint-rag/values.yaml). +1. Confirm Elasticsearch settings in [`values.yaml`](../deploy/helm/nvidia-blueprint-rag/values.yaml). - Update both the RAG server and ingestor-server sections: + The chart defaults to Elasticsearch; ensure both the RAG server and ingestor-server sections match your ECK service URL and credentials: ```yaml # RAG Server configuration @@ -135,7 +125,7 @@ If you're using Helm for deployment, use the following steps to configure Elasti APP_VECTORSTORE_PASSWORD: "" ``` -2. Enable Elasticsearch deployment in [`values.yaml`](../deploy/helm/nvidia-blueprint-rag/values.yaml). +Ensure that Elasticsearch (ECK) is enabled in [values.yaml](../deploy/helm/nvidia-blueprint-rag/values.yaml). This is the default setting; use the following block as the reference configuration: ```yaml eck-elasticsearch: @@ -188,7 +178,7 @@ If you're using Helm for deployment, use the following steps to configure Elasti # Expected: {"cluster_name":"rag-eck-elasticsearch","status":"yellow" or "green",...} ``` -5. (Optional) Enable authentication - see [Elasticsearch Authentication (Helm)](#helm-chart) section below if you need to secure your Elasticsearch instance. +5. (Optional) Enable authentication - see [Elasticsearch Authentication (Helm)](elasticsearch-configuration.md#helm-chart) if you need to secure your Elasticsearch instance. 6. After the Helm deployment, port-forward the RAG UI service: @@ -198,17 +188,16 @@ If you're using Helm for deployment, use the following steps to configure Elasti 7. Access the UI at `http://:3000` and set Settings > Endpoint Configuration > Vector Database Endpoint to `http://rag-eck-elasticsearch-es-http:9200`. - -## Verify Your Elasticsearch Vector Database Setup +### Verify Your Elasticsearch Vector Database Setup After you complete the setup, verify that Elasticsearch is running correctly: -### For Docker Deployment: +#### For Docker Deployment: ```bash curl -X GET "localhost:9200/_cluster/health?pretty" ``` -### For Helm deployments: +#### For Helm deployments: ```bash # 1. Get the name of your Elasticsearch pod: kubectl get pods -n rag | grep elasticsearch @@ -224,458 +213,86 @@ curl -X GET "localhost:9200/_cluster/health?pretty" You should see a response that indicates the cluster status is green or yellow, confirming that Elasticsearch is operational and ready to store embeddings. -## Elasticsearch Authentication +## Switching to Milvus -Enable authentication for Elasticsearch to secure your vector database. +Use Milvus when you want the optional Milvus stack instead of Elasticsearch. You must re-upload your documents after switching; embeddings stored in Elasticsearch are not migrated to Milvus automatically. ### Docker Compose -#### 1. Configure Elasticsearch Authentication (xpack) - -Edit `deploy/compose/vectordb.yaml` to enable xpack security by setting `xpack.security.enabled` to true: -```yaml -environment: - - xpack.security.enabled=true -``` - -Uncomment the username and password environment variables in the elasticsearch service in `deploy/compose/vectordb.yaml`: -```yaml -- ELASTIC_USERNAME=${APP_VECTORSTORE_USERNAME} -- ELASTIC_PASSWORD=${APP_VECTORSTORE_PASSWORD} -``` - -Add authentication in `healthcheck` in `deploy/compose/vectordb.yaml` by uncommenting the following: -```yaml -test: ["CMD", "curl", "-s", "-f", "-u", "${APP_VECTORSTORE_USERNAME}:${APP_VECTORSTORE_PASSWORD}", "http://localhost:9200/_cat/health"] -``` -and commenting out -```yaml -test: ["CMD", "curl", "-s", "-f", "http://localhost:9200/_cat/health"] -``` - - -#### 2. Start Elasticsearch Container with Credentials - -Start the Elasticsearch container with username and password: - -```bash -export APP_VECTORSTORE_USERNAME="elastic" # elastic recommended -export APP_VECTORSTORE_PASSWORD="your-secure-password" - -docker compose -f deploy/compose/vectordb.yaml --profile elasticsearch up -d -``` - -#### 3. Generate Elasticsearch API Key (Optional but Recommended) - -If you prefer to use API key authentication instead of username/password (recommended for production), generate an API key using curl. You need the username and password from the previous step. - -```bash -# Either provide base64 apikey (base64 of "id:secret") -export APP_VECTORSTORE_APIKEY="base64-id-colon-secret" -# Or provide split ID/SECRET -export APP_VECTORSTORE_APIKEY_ID="your_id" -export APP_VECTORSTORE_APIKEY_SECRET="your_secret" - -docker compose -f deploy/compose/vectordb.yaml --profile elasticsearch up -d -docker compose -f deploy/compose/docker-compose-ingestor-server.yaml up -d -docker compose -f deploy/compose/docker-compose-rag-server.yaml up -d -``` - -### Get an Elasticsearch API key - -If security is enabled, create an API key using either curl. You need a user with permission to create API keys (e.g., the built-in `elastic` superuser in dev). - -#### 1. Using curl (replace credentials and URL as appropriate): -```bash -# If running inside the cluster, port-forward first: -# kubectl -n rag port-forward svc/rag-eck-elasticsearch-es-http 9200:9200 - -curl -u elastic:your-secure-password \ - -X POST "http://127.0.0.1:9200/_security/api_key" \ - -H 'Content-Type: application/json' \ - -d '{ - "name": "rag-api-key", - "role_descriptors": {} - }' -``` - -Example response: -```json -{ - "id": "AbCdEfGhIj", - "name": "rag-api-key", - "expiration": null, - "api_key": "ZyXwVuTsRq", - "encoded": null -} -``` - -Convert the API key to base64: - -```bash -# Base64 is computed over ":" -echo -n "AbCdEfGhIj:ZyXwVuTsRq" | base64 -# Output example: QWJ...cXE= -``` - -#### 4. Set Environment Variables for Authentication - -Choose ONE of the following authentication methods: - -**Option A: API Key Authentication (Recommended)** - -Set environment variables using the base64-encoded API key or split ID/SECRET: - -```bash -# Either provide base64 apikey (base64 of "id:secret") -export APP_VECTORSTORE_APIKEY="QWJ...cXE=" - -# Or provide split ID/SECRET -export APP_VECTORSTORE_APIKEY_ID="AbCdEfGhIj" -export APP_VECTORSTORE_APIKEY_SECRET="ZyXwVuTsRq" -``` - -**Option B: Username/Password Authentication** - -If you prefer to use username/password instead of API key: - -```bash -export APP_VECTORSTORE_USERNAME="elastic" -export APP_VECTORSTORE_PASSWORD="your-secure-password" -``` - -#### 5. Start RAG Server and Ingestor Server - -Start the RAG and Ingestor services with the authentication credentials: - -```bash -docker compose -f deploy/compose/docker-compose-ingestor-server.yaml up -d -docker compose -f deploy/compose/docker-compose-rag-server.yaml up -d -``` - -:::{note} -API key authentication takes precedence over username/password when both are configured. -::: - - -### Helm Chart - -Follow these steps to enable authentication for Elasticsearch in your Helm deployment. - -#### 1. Enable Elasticsearch Authentication - -Edit [`values.yaml`](../deploy/helm/nvidia-blueprint-rag/values.yaml) to enable X-Pack security. Set the following explicitly: - -```yaml -eck-elasticsearch: - nodeSets: - - name: default - config: - node.store.allow_mmap: false - xpack.security.enabled: true - xpack.security.transport.ssl.enabled: true -``` - -:::{important} -**Key Configuration Flags:** -- `xpack.security.enabled: true` - Enables authentication (default user: `elastic`). Set this explicitly. -- `xpack.security.transport.ssl.enabled: true` - Enables SSL for node-to-node communication. Set this explicitly. -::: - -#### 2. Replace Readiness Probe When Security Is Enabled - -When X-Pack security is enabled, replace the current `readinessProbe` section under `eck-elasticsearch.nodeSets[0]` in [`values.yaml`](../deploy/helm/nvidia-blueprint-rag/values.yaml) with the ECK default probe (so the pod uses the readiness port script instead of an unauthenticated curl check). Ensure the following `podTemplate` is present under the same `nodeSets` entry: - -```yaml -eck-elasticsearch: - nodeSets: - - name: default - podTemplate: - spec: - containers: - - name: elasticsearch - readinessProbe: - exec: - command: - - bash - - -c - - /mnt/elastic-internal/scripts/readiness-port-script.sh - initialDelaySeconds: 10 - periodSeconds: 5 - timeoutSeconds: 5 - failureThreshold: 3 -``` - -#### 3. Deploy with Authentication Enabled - -After modifying [`values.yaml`](../deploy/helm/nvidia-blueprint-rag/values.yaml), apply the changes as described in [Change a Deployment](deploy-helm.md#change-a-deployment). - -Wait for Elasticsearch to restart: - -```bash -# Monitor pod restart -kubectl get pods -n rag -w | grep elasticsearch - -# Wait for pod to be ready -kubectl wait --for=condition=ready pod -l elasticsearch.k8s.elastic.co/cluster-name=rag-eck-elasticsearch -n rag --timeout=300s -``` - -#### 3. Retrieve Elasticsearch Password from Secret - -When authentication is enabled, ECK automatically creates a Kubernetes secret containing the `elastic` user password: - -```bash -# Find the Elasticsearch user secret -kubectl get secrets -n rag | grep elastic-user -# Expected: rag-eck-elasticsearch-es-elastic-user - -# Retrieve the password -ES_PASSWORD=$(kubectl get secret rag-eck-elasticsearch-es-elastic-user -n rag -o jsonpath='{.data.elastic}' | base64 -d) -echo "Elasticsearch password: $ES_PASSWORD" -``` - -:::{tip} -Save this password securely. The password is auto-generated by ECK and persists across pod restarts unless the secret is deleted. -::: - -#### 5. Update Deployment with Credentials +1. Start the Milvus profile (Milvus, etcd, SeaweedFS object store, and related services). -Configure the RAG server and ingestor-server to use the retrieved credentials. -**Update values.yaml (Recommended for Production)** - -Edit [`values.yaml`](../deploy/helm/nvidia-blueprint-rag/values.yaml) and set the following **new** values for Elasticsearch authentication: - -- **APP_VECTORSTORE_USERNAME:** set to `"elastic"` (the default Elasticsearch superuser). -- **APP_VECTORSTORE_PASSWORD:** set to the password you retrieved in step 4 (i.e. the value of `$ES_PASSWORD`, or paste the output of the `kubectl get secret ... -o jsonpath='{.data.elastic}' | base64 -d` command). - -Example (replace `your-retrieved-password` with your actual `$ES_PASSWORD`): - -```yaml -# RAG Server configuration -envVars: - APP_VECTORSTORE_URL: "http://rag-eck-elasticsearch-es-http:9200" - APP_VECTORSTORE_NAME: "elasticsearch" - APP_VECTORSTORE_USERNAME: "elastic" - APP_VECTORSTORE_PASSWORD: "your-retrieved-password" # use $ES_PASSWORD from step 3 - -# Ingestor Server configuration -ingestor-server: - envVars: - APP_VECTORSTORE_URL: "http://rag-eck-elasticsearch-es-http:9200" - APP_VECTORSTORE_NAME: "elasticsearch" - APP_VECTORSTORE_USERNAME: "elastic" - APP_VECTORSTORE_PASSWORD: "your-retrieved-password" # use $ES_PASSWORD from step 3 -``` + ```bash + docker compose -f deploy/compose/vectordb.yaml --profile milvus up -d + ``` -Then apply the changes as described in [Change a Deployment](deploy-helm.md#change-a-deployment). +2. Point the application at Milvus. -#### 5. (Optional) Use API Key Authentication + ```bash + export APP_VECTORSTORE_NAME="milvus" + export APP_VECTORSTORE_URL="http://milvus:19530" + ``` -For advanced use cases or production environments, you can use Elasticsearch API keys instead of username/password authentication. +3. Relaunch the RAG and ingestion services. -**Generate an API Key:** + ```bash + docker compose -f deploy/compose/docker-compose-ingestor-server.yaml up -d + docker compose -f deploy/compose/docker-compose-rag-server.yaml up -d + ``` -First, port-forward to access Elasticsearch: +4. Update the RAG UI configuration. -```bash -kubectl port-forward -n rag svc/rag-eck-elasticsearch-es-http 9200:9200 -``` + Settings > Endpoint Configuration > Vector Database Endpoint → `http://milvus:19530`. -Then generate an API key using the elastic user: +### Helm -```bash -# Get the elastic password -ES_PASSWORD=$(kubectl get secret rag-eck-elasticsearch-es-elastic-user -n rag -o jsonpath='{.data.elastic}' | base64 -d) - -# Create an API key -curl -u elastic:$ES_PASSWORD \ - -X POST "http://localhost:9200/_security/api_key" \ - -H 'Content-Type: application/json' \ - -d '{ - "name": "rag-api-key", - "role_descriptors": {} - }' -``` +Configure [`values.yaml`](../deploy/helm/nvidia-blueprint-rag/values.yaml) so Milvus is deployed, ECK Elasticsearch is disabled, and both servers use the Milvus endpoint. -Example response: -```json -{ - "id": "AbCdEfGhIj", - "name": "rag-api-key", - "api_key": "ZyXwVuTsRq" -} -``` +1. Set the vector database environment variables on the RAG server and ingestor server: -**Encode the API Key:** + ```yaml + # RAG Server configuration + envVars: + APP_VECTORSTORE_URL: "http://milvus:19530" + APP_VECTORSTORE_NAME: "milvus" + APP_VECTORSTORE_USERNAME: "" + APP_VECTORSTORE_PASSWORD: "" -```bash -# Base64 encode the "id:api_key" format -echo -n "AbCdEfGhIj:ZyXwVuTsRq" | base64 -# Output example: QWJDZEVmR2hJajpaeVh3VnVUc1Jx -``` + # Ingestor Server configuration + ingestor-server: + envVars: + APP_VECTORSTORE_URL: "http://milvus:19530" + APP_VECTORSTORE_NAME: "milvus" + APP_VECTORSTORE_USERNAME: "" + APP_VECTORSTORE_PASSWORD: "" + ``` -**Configure with API Key:** +2. Disable ECK Elasticsearch and deploy Milvus via nv-ingest: -Edit [`values.yaml`](../deploy/helm/nvidia-blueprint-rag/values.yaml): + ```yaml + eck-elasticsearch: + enabled: false -```yaml -# RAG Server configuration - Option 1: Base64 encoded API key -envVars: - APP_VECTORSTORE_APIKEY: "QWJDZEVmR2hJajpaeVh3VnVUc1Jx" - # Leave username/password empty - APP_VECTORSTORE_USERNAME: "" - APP_VECTORSTORE_PASSWORD: "" + nv-ingest: + milvusDeployed: true + ``` -# Ingestor Server configuration - Option 1: Base64 encoded API key -ingestor-server: - envVars: - APP_VECTORSTORE_APIKEY: "QWJDZEVmR2hJajpaeVh3VnVUc1Jx" - APP_VECTORSTORE_USERNAME: "" - APP_VECTORSTORE_PASSWORD: "" -``` + Adjust additional `nv-ingest.milvus` settings in [`values.yaml`](../deploy/helm/nvidia-blueprint-rag/values.yaml) as needed (resources, images, authentication, and so on). -Or use split ID/SECRET format: - -```yaml -# RAG Server configuration - Option 2: Split ID and secret -envVars: - APP_VECTORSTORE_APIKEY_ID: "AbCdEfGhIj" - APP_VECTORSTORE_APIKEY_SECRET: "ZyXwVuTsRq" - APP_VECTORSTORE_USERNAME: "" - APP_VECTORSTORE_PASSWORD: "" - -# Ingestor Server configuration - Option 2: Split ID and secret -ingestor-server: - envVars: - APP_VECTORSTORE_APIKEY_ID: "AbCdEfGhIj" - APP_VECTORSTORE_APIKEY_SECRET: "ZyXwVuTsRq" - APP_VECTORSTORE_USERNAME: "" - APP_VECTORSTORE_PASSWORD: "" -``` +3. Deploy or upgrade the Helm release as described in [Change a Deployment](deploy-helm.md#change-a-deployment). -Then apply the changes as described in [Change a Deployment](deploy-helm.md#change-a-deployment). +4. Verify Milvus pods and services, then set the RAG UI vector endpoint to match your Milvus HTTP/gRPC service (commonly `http://milvus:19530` from application pods when using the default `fullnameOverride`). :::{note} -**API Key vs Username/Password:** -- API keys are recommended for production environments and applications -- API keys can have specific permissions and expiration dates -- API keys can be rotated without changing the elastic user password -- **API key authentication takes precedence** when both username/password and API keys are configured +Kubernetes DNS names may include your Helm release prefix. Use `kubectl get svc -n rag` to confirm the Milvus service hostname if connections fail. ::: -#### 6. Verify Authentication - -Test that the services can connect to Elasticsearch with authentication: - -```bash -# Check ingestor-server logs for successful connection -kubectl logs -n rag -l app=ingestor-server --tail=20 - -# Test Elasticsearch connection manually -ES_PASSWORD=$(kubectl get secret rag-eck-elasticsearch-es-elastic-user -n rag -o jsonpath='{.data.elastic}' | base64 -d) -kubectl exec -n rag rag-eck-elasticsearch-es-default-0 -- curl -s -u elastic:$ES_PASSWORD http://localhost:9200/_cluster/health -``` +## Elasticsearch Authentication +For Elasticsearch authentication configuration (xpack security, API keys, and ECK credentials), refer to [Elasticsearch Configuration](elasticsearch-configuration.md#elasticsearch-authentication). ## Using VDB Auth Token at Runtime via APIs (Enterprise Feature) -When using Elasticsearch as the vector database, you can pass a per-request VDB authentication token via the HTTP `Authorization` header. The servers forward this token to Elasticsearch for that request. This enables per-user authentication or per-request scoping without changing server env configuration. - -Prerequisite: -- Ensure Elasticsearch authentication is enabled so security is enforced. In Elasticsearch this typically requires `xpack.security.enabled=true`. See the [Elasticsearch Authentication](#elasticsearch-authentication) section above for enabling security via Docker Compose or Helm and for obtaining API keys or setting credentials. - -### Set Up Runtime Token and Endpoints - -Before making API requests with authentication, export the required environment variables. - -**1. Export service endpoints:** - -```bash -export INGESTOR_URL="http://localhost:8082" -export RAG_URL="http://localhost:8081" -``` - -**2. Export authentication token:** - -Runtime authentication via the `Authorization` header only supports Elasticsearch API keys. Export your API key token: - -```bash -# Export your bearer token -export ES_VDB_TOKEN="your-bearer-token" -``` - -:::{note} -Bearer token authentication (OAuth/OIDC/SAML) is an enterprise support feature and not available in the free version of Elasticsearch. For most use cases, use Elasticsearch API keys as shown in [Generate Elasticsearch API Key](#3-generate-elasticsearch-api-key-optional-but-recommended) above. -::: - -### Header format - -Use bearer authentication in your API requests: - -``` -Authorization: Bearer -``` - -### Ingestor Server examples - -- List documents: - -```bash -curl -G "$INGESTOR_URL/v1/documents" \ - -H "Authorization: Bearer ${ES_VDB_TOKEN}" \ - --data-urlencode "collection_name=es_demo_collection" -``` - -- Delete a collection: - -```bash -curl -X DELETE "$INGESTOR_URL/v1/collections" \ - -H "Authorization: Bearer ${ES_VDB_TOKEN}" \ - --data-urlencode "collection_names=es_demo_collection" -``` - -:::{note} -You can also set `vdb_endpoint` in your request payload to override the configured `APP_VECTORSTORE_URL`. -::: - -### RAG Server examples - -- Search: - -```bash -curl -X POST "$RAG_URL/v1/search" \ - -H "Authorization: Bearer ${ES_VDB_TOKEN}" \ - -H "Content-Type: application/json" \ - -d '{ - "query": "what is vector search?", - "use_knowledge_base": true, - "collection_names": ["es_demo_collection"], - "vdb_endpoint": "'"$APP_VECTORSTORE_URL"'", - "reranker_top_k": 0, - "vdb_top_k": 3 - }' -``` - -- Generate with streaming: - -```bash -curl -N -X POST "$RAG_URL/v1/generate" \ - -H "Authorization: Bearer ${ES_VDB_TOKEN}" \ - -H "Content-Type: application/json" \ - -d '{ - "messages": [{"role":"user","content":"Give a short summary of vector databases"}], - "use_knowledge_base": true, - "collection_names": ["es_demo_collection"], - "vdb_endpoint": "'"$APP_VECTORSTORE_URL"'", - "reranker_top_k": 0, - "vdb_top_k": 3 - }' -``` - -### Troubleshooting -- If you receive authentication/authorization errors from Elasticsearch, verify your token (API key validity, scopes, and expiration). -- Ensure the server is not also configured with conflicting credentials for the same request. -- Confirm that `APP_VECTORSTORE_NAME=elasticsearch` and `APP_VECTORSTORE_URL` are set correctly. +For runtime VDB token authentication with Elasticsearch, refer to [Elasticsearch Configuration](elasticsearch-configuration.md#using-vdb-auth-token-at-runtime-via-apis-enterprise-feature). # Define Your Own Vector Database @@ -891,7 +508,7 @@ Follow these steps to add your custom vector database to the NVIDIA RAG servers Or, you may edit the files locally to show your custom value. Search for `APP_VECTORSTORE_NAME` and adjust defaults if desired: ```yaml # Type of vectordb used to store embedding (supports "milvus", "elasticsearch", or a custom value like "your_custom_vdb") - APP_VECTORSTORE_NAME: ${APP_VECTORSTORE_NAME:-"milvus"} + APP_VECTORSTORE_NAME: ${APP_VECTORSTORE_NAME:-"elasticsearch"} # URL on which vectorstore is hosted APP_VECTORSTORE_URL: ${APP_VECTORSTORE_URL:-http://your-custom-vdb:1234} ``` @@ -1001,11 +618,14 @@ Update your [`values.yaml`](../deploy/helm/nvidia-blueprint-rag/values.yaml) fil ### Disable Default Vector Database and Add Custom Helm Chart -1. **Disable Milvus in the NeMo Retriever Library configuration:** +**Disable the chart-managed vector databases you are replacing** in [values.yaml](../deploy/helm/nvidia-blueprint-rag/values.yaml). The default configuration deploys Elasticsearch (ECK) and keeps Milvus optional through nv-ingest; for a custom Helm chart backend, disable any bundled backends you no longer need—for example: + ```yaml + eck-elasticsearch: + enabled: false + nv-ingest: enabled: true - # Disable Milvus deployment milvusDeployed: false milvus: enabled: false @@ -1187,8 +807,10 @@ The integration process remains the same: create your VDB class, register it, co ## Related Topics +- [Elasticsearch Configuration](elasticsearch-configuration.md) (authentication, GPU indexing, runtime VDB tokens) +- [Milvus Configuration](milvus-configuration.md) (GPU/CPU tuning, authentication, runtime VDB tokens) - [NVIDIA RAG Blueprint Documentation](readme.md) - [Best Practices for Common Settings](accuracy_perf.md). - [RAG Pipeline Debugging Guide](debugging.md) - [Troubleshoot](troubleshooting.md) -- [Notebooks](notebooks.md) \ No newline at end of file +- [Notebooks](notebooks.md) diff --git a/docs/conf.py b/docs/conf.py index 0ce91df45..24fe8f614 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,4 +1,5 @@ -# Copyright (c) 2025-%Y, NVIDIA CORPORATION. All rights reserved. +# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -24,9 +25,9 @@ import sys project = " NVIDIA RAG blueprint" -copyright = "2025-%Y, NVIDIA Corporation" -author = "NVIDIA Corporation" -release = "2.5.1" +copyright = "2025, NVIDIA CORPORATION & AFFILIATES" +author = "NVIDIA CORPORATION & AFFILIATES" +release = "2.6.0" # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration diff --git a/docs/continuous-ingestion-object-storage.md b/docs/continuous-ingestion-object-storage.md index 4baa9daf7..c1998abce 100644 --- a/docs/continuous-ingestion-object-storage.md +++ b/docs/continuous-ingestion-object-storage.md @@ -12,7 +12,7 @@ Continuous ingestion from object storage connects the [RAG blueprint](readme.md) |-------------|---------| | **GPU** | 2x RTX PRO 6000 Blackwell or 2x H100 | | **OS** | Ubuntu 22.04 or later | -| **Docker** | Docker 24.0+ with Docker Compose v2 | +| **Docker** | Docker 24.0+ with Docker Compose v2. Docker Engine 29.5.x is not supported. | | **NVIDIA Driver** | 570+ | | **NVIDIA Container Toolkit** | Required | diff --git a/docs/custom-metadata.md b/docs/custom-metadata.md index ce96b63ba..5339190dc 100644 --- a/docs/custom-metadata.md +++ b/docs/custom-metadata.md @@ -22,7 +22,7 @@ The [NVIDIA RAG Blueprint](readme.md) features **advanced metadata filtering wit config = { "filter_expression_generator": { "enable_filter_generator": True, - "model_name": "nvidia/llama-3.3-nemotron-super-49b-v1.5", + "model_name": "nvidia/nemotron-3-super-120b-a12b", "temperature": 0.1, "max_tokens": 1024 } @@ -100,10 +100,9 @@ This notebook demonstrates: ## Important Notes ### 🎯 **Vector Database Support** -- **Milvus**: Full support for natural language filter generation and complex expressions -- **Elasticsearch**: Limited to basic filter validation only (no natural language generation) -- **Natural Language Generation**: Only works with Milvus vector database -- **Filter Expression Types**: Milvus uses string expressions, Elasticsearch uses list of dictionaries +- **Milvus**: Full support for natural language filter generation using a string boolean expression DSL. +- **Elasticsearch**: Full support for natural language filter generation; the LLM emits Elasticsearch Query DSL clauses (a JSON array of `term`, `terms`, `range`, `bool`, etc.) which are validated against the collection's metadata schema. +- **Filter Expression Types**: Milvus uses string expressions; Elasticsearch uses a list of clause dictionaries. ### 🚨 **Key Limitations** - **IS NULL/IS NOT NULL operations**: Not supported @@ -116,7 +115,7 @@ This notebook demonstrates: | Feature | Milvus | Elasticsearch | |---------|--------|---------------| -| **Natural Language Filter Generation** | ✅ Fully automated with LLM integration | 🔧 Advanced users can leverage native Elasticsearch Query DSL for sophisticated queries | +| **Natural Language Filter Generation** | ✅ Fully automated with LLM integration (string DSL) | ✅ Fully automated with LLM integration (Elasticsearch Query DSL clauses) | | **Filter Expression Complexity** | ✅ String-based syntax with validation | 🚀 Full Elasticsearch Query DSL support - Boolean, range, nested, geo, and aggregation queries | | **Schema Validation** | ✅ Comprehensive metadata schema validation | 🔧 Flexible schema-less design with dynamic mapping capabilities | | **Array Operations** | ✅ Built-in functions: `array_contains`, `array_length`, etc. | 🚀 Native nested object support with powerful array querying capabilities | @@ -201,6 +200,36 @@ The system gracefully handles filter generation failures: - **Schema Mismatch**: Logs warning, skips incompatible collections - **Processing Errors**: Returns original query, maintains functionality +### Elasticsearch Filter Generation + +When `APP_VECTORSTORE_NAME=elasticsearch`, filter generation produces a JSON array of Elasticsearch Query DSL clauses instead of a Milvus boolean string. The same `enable_filter_generator` flag and metadata schema mechanism apply. + +**Field path convention** + +User-defined metadata is indexed at `metadata.content_metadata.`. For exact-match clauses (`term`, `terms`, `prefix`) on string fields, the `.keyword` sub-field is used (e.g. `metadata.content_metadata.status.keyword`). The validator auto-normalizes paths produced by the LLM, so user-supplied filters that omit `.keyword` are still accepted. + +**Supported clause types** + +`term`, `terms`, `range`, `match`, `match_phrase`, `wildcard`, `prefix`, `exists`, and `bool` (with `must`, `should`, `must_not`, `filter`). + +**Example queries and generated filters** + +| Your Question | Generated Filter | +|---------------|------------------| +| "Show me approved AI reports from 2024" | `[{"bool": {"must": [{"term": {"metadata.content_metadata.status.keyword": "approved"}}, {"term": {"metadata.content_metadata.category.keyword": "AI"}}, {"range": {"metadata.content_metadata.year": {"gte": 2024, "lt": 2025}}}]}}]` | +| "Public documents tagged engineering" | `[{"bool": {"must": [{"term": {"metadata.content_metadata.is_public": true}}, {"term": {"metadata.content_metadata.tags.keyword": "engineering"}}]}}]` | +| "High-rated docs from January 2024" | `[{"bool": {"must": [{"range": {"metadata.content_metadata.rating": {"gt": 4.0}}}, {"range": {"metadata.content_metadata.created_at": {"gte": "2024-01-01T00:00:00", "lt": "2024-02-01T00:00:00"}}}]}}]` | + +**Validation** + +Each generated clause is checked against the collection's metadata schema: + +- Field must exist in the schema (or be a system-managed field with `support_dynamic_filtering=true`). +- Clause type must match the field's declared type (e.g. `range` only on numeric/datetime fields). +- `range` bounds on `datetime` fields must be valid ISO 8601 strings. + +If validation fails for an LLM-generated filter, the system falls back to no filter (the request still succeeds). For user-supplied filters via the API, validation errors are surfaced explicitly. + ## Metadata Schema Definition ### Supported Data Types @@ -462,8 +491,10 @@ For Elasticsearch, filters must be provided as a list of dictionaries using Elas ```python # Elasticsearch filter example +# `.keyword` is appended only for exact-match clauses on string fields; +# numeric/datetime/boolean fields and `range` clauses use the bare path. filter_expr = [ - {"term": {"metadata.content_metadata.category": "AI"}}, + {"term": {"metadata.content_metadata.category.keyword": "AI"}}, {"range": {"metadata.content_metadata.priority": {"gt": 5}}} ] ``` @@ -493,7 +524,7 @@ Elasticsearch filters use the `metadata.content_metadata.field_name` format and # Configuration file (config.yaml) filter_expression_generator: enable_filter_generator: true # Set to true to enable filter generation (default is false) - model_name: "nvidia/llama-3.3-nemotron-super-49b-v1.5" + model_name: "nvidia/nemotron-3-super-120b-a12b" server_url: "" # Leave empty for default endpoint temperature: 0.1 # Low temperature for consistent results top_p: 0.9 @@ -517,7 +548,7 @@ metadata: export ENABLE_FILTER_GENERATOR=true # LLM configuration -export APP_FILTEREXPRESSIONGENERATOR_MODELNAME="nvidia/llama-3.3-nemotron-super-49b-v1.5" +export APP_FILTEREXPRESSIONGENERATOR_MODELNAME="nvidia/nemotron-3-super-120b-a12b" export APP_FILTEREXPRESSIONGENERATOR_SERVERURL="" # Note: Metadata configuration is not currently exposed via environment variables @@ -538,7 +569,7 @@ export APP_FILTEREXPRESSIONGENERATOR_SERVERURL="" ## Customizing Filter Expression Generator Prompt -The `filter_expression_generator_prompt` determines how natural language queries are converted into metadata filter expressions. Customizing this prompt is essential for domain-specific applications where industry terminology needs accurate mapping to your metadata fields. +The filter generator prompts (`filter_expression_generator_prompt_milvus` and `filter_expression_generator_prompt_elasticsearch`) determine how natural language queries are converted into metadata filter expressions. The active prompt is selected automatically based on the configured vector store (`APP_VECTORSTORE_NAME`). Customizing the relevant prompt is essential for domain-specific applications where industry terminology needs accurate mapping to your metadata fields. ### When to Customize @@ -567,7 +598,7 @@ For an automotive documentation system with this schema: Create `automotive_filter_prompt.yaml`: ```yaml -filter_expression_generator_prompt: +filter_expression_generator_prompt_milvus: system: | /no_think @@ -642,7 +673,7 @@ docker compose -f deploy/compose/docker-compose-rag-server.yaml up -d ``` **Helm:** -Edit `deploy/helm/nvidia-blueprint-rag/files/prompt.yaml` and update the `filter_expression_generator_prompt` section. +Edit `deploy/helm/nvidia-blueprint-rag/files/prompt.yaml` and update the `filter_expression_generator_prompt_milvus` or `filter_expression_generator_prompt_elasticsearch` section depending on your vector store. ### Results diff --git a/docs/debugging.md b/docs/debugging.md index 1fb5aea0d..ca333cb24 100644 --- a/docs/debugging.md +++ b/docs/debugging.md @@ -99,7 +99,7 @@ After starting your ingestion containers, verify these core services are healthy **Required Core Services:** - `ingestor-server` (Port 8082) - Main ingestion API - `nv-ingest-ms-runtime` (Port 7670) - Document processing engine -- `milvus` (Port 19530) - Vector database +- `elasticsearch` (Port 9200) - Vector database - `redis` (Port 6379) - Task queue ### 2. Verify Container Status After Deployment @@ -109,7 +109,7 @@ After starting your ingestion containers, verify these core services are healthy docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" # Check specifically for ingestion-related containers -docker ps | grep -E "(ingestor-server|nv-ingest|nemoretriever-embedding|milvus|redis)" +docker ps | grep -E "(ingestor-server|nv-ingest|nemoretriever-embedding|elasticsearch|redis)" ``` *Example Output* @@ -121,12 +121,11 @@ docker ps | grep -E "(ingestor-server|nv-ingest|nemoretriever-embedding|milvus|r compose-redis-1 Up 5 minutes rag-frontend Up 9 minutes rag-server Up 9 minutes - milvus-standalone Up 36 minutes (healthy) - milvus-minio Up 35 minutes (healthy) - milvus-etcd Up 35 minutes (healthy) + elasticsearch Up 36 minutes (healthy) + seaweedfs Up 35 minutes (healthy) nemotron-ranking-ms Up 38 minutes (healthy) compose-page-elements-1 Up 38 minutes - compose-nemoretriever-ocr-1 Up 38 minutes + compose-nemotron-ocr-1 Up 38 minutes compose-graphic-elements-1 Up 38 minutes compose-table-structure-1 Up 38 minutes nemotron-embedding-ms Up 38 minutes (healthy) @@ -172,7 +171,7 @@ After starting your RAG containers, verify these core services are healthy: **Required Core Services:** - `rag-server` (Port 8081) - Main RAG API orchestrator -- `milvus` (Port 19530) - Vector database +- `elasticsearch` (Port 9200) - Vector database ### 2. Test Retrieval Service Health @@ -235,11 +234,11 @@ docker logs nemotron-embedding-ms --tail 100 **Vector Database Connection Issues:** ```bash -# Check Milvus connectivity -curl -X GET "http://localhost:9091/healthz" +# Check Elasticsearch cluster health +curl -s "http://localhost:9200/_cluster/health?pretty" -# Check Milvus logs for database errors -docker logs milvus-standalone --tail 50 +# Check Elasticsearch logs for database errors +docker logs elasticsearch --tail 50 ``` **Embedding Service Issues:** @@ -314,10 +313,11 @@ docker logs -f nim-llm-ms **Vector Search Issues:** -Delete the existing volumes directory and retry. +Wipe the `rag-vol-*` Docker named volumes and retry. This deletes all persisted state (vectors, object store, etcd, LanceDB, ingestor scratch) — see [Manage Persistent Data Volumes](troubleshooting.md#manage-persistent-data-volumes) for selective wipes if you only need to reset one service. ```bash -sudo rm -rf deploy/compose/volumes +docker compose -f deploy/compose/vectordb.yaml down +docker volume ls -q --filter "name=^rag-vol-" | xargs -r docker volume rm ``` ## How to Enable Advanced Debugging diff --git a/docs/deploy-docker-nvidia-hosted.md b/docs/deploy-docker-nvidia-hosted.md index 2aabd06ce..cc65a4e9e 100644 --- a/docs/deploy-docker-nvidia-hosted.md +++ b/docs/deploy-docker-nvidia-hosted.md @@ -31,6 +31,8 @@ Initial deployment typically takes 5-10 minutes as container images are pulled a 2. Install Docker Engine. For more information, see [Ubuntu](https://docs.docker.com/engine/install/ubuntu/). + a. Use Docker Engine 24.0 or later. Docker Engine 29.5.x is not supported for this release because it can fail to pull required NGC images. + 3. Install Docker Compose. For more information, see [install the Compose plugin](https://docs.docker.com/compose/install/linux/). a. Ensure the Docker Compose plugin version is 2.29.1 or later. @@ -59,14 +61,16 @@ Use the following procedure to start all containers needed for this blueprint. ``` -2. Start the vector db containers from the repo root. +2. Persistent data (Elasticsearch, SeaweedFS, Milvus/etcd, LanceDB, and the ingestor-server scratch dir) is stored in dedicated `rag-vol-*` Docker named volumes, which Docker Compose creates automatically the first time you bring the stack up. For inspection, backup, and reset commands — and for migrating data from the legacy `deploy/compose/volumes/` host directory — see [Manage Persistent Data Volumes](troubleshooting.md#manage-persistent-data-volumes) in the troubleshooting guide. + +3. Start the vector db containers from the repo root. ```bash docker compose -f deploy/compose/vectordb.yaml up -d ``` -3. Start the ingestion containers from the repo root. This pulls the prebuilt containers from NGC and deploys it on your system. +4. Start the ingestion containers from the repo root. This pulls the prebuilt containers from NGC and deploys it on your system. ```bash docker compose -f deploy/compose/docker-compose-ingestor-server.yaml up -d @@ -127,7 +131,7 @@ Use the following procedure to start all containers needed for this blueprint. ``` -4. Start the rag containers from the repo root. This pulls the prebuilt containers from NGC and deploys it on your system. +5. Start the rag containers from the repo root. This pulls the prebuilt containers from NGC and deploys it on your system. ```bash docker compose -f deploy/compose/docker-compose-rag-server.yaml up -d @@ -173,7 +177,7 @@ Use the following procedure to start all containers needed for this blueprint. ``` -5. Check the status of the deployment by running the following code. +6. Check the status of the deployment by running the following code. ```bash docker ps --format "table {{.ID}}\t{{.Names}}\t{{.Status}}" @@ -188,9 +192,8 @@ Use the following procedure to start all containers needed for this blueprint. compose-redis-1 Up 5 minutes rag-frontend Up 9 minutes rag-server Up 9 minutes - milvus-standalone Up 36 minutes (healthy) - milvus-minio Up 35 minutes (healthy) - milvus-etcd Up 35 minutes (healthy) + elasticsearch Up 36 minutes (healthy) + seaweedfs Up 35 minutes (healthy) ``` @@ -236,7 +239,7 @@ After the first time you deploy the RAG Blueprint successfully, you can consider source deploy/compose/perf_profile.env ``` -- If you don't have a GPU available, you can switch to CPU-only Milvus by following the instructions in [milvus-configuration.md](./milvus-configuration.md). +- If you prefer Milvus over the default Elasticsearch vector database, or need CPU-only Milvus tuning, refer to [Milvus configuration](milvus-configuration.md) and [Vector database configuration](change-vectordb.md#switching-to-milvus) the Switching to Milvus section. - If you have a requirement to build the NeMo Retriever Library runtime container from source, you can do it by following instructions [here](https://github.com/NVIDIA/NeMo-Retriever). diff --git a/docs/deploy-docker-self-hosted.md b/docs/deploy-docker-self-hosted.md index 4913be36a..94f6ec0fa 100644 --- a/docs/deploy-docker-self-hosted.md +++ b/docs/deploy-docker-self-hosted.md @@ -27,6 +27,8 @@ This deployment requires at least 200GB of free disk space to download and cache 2. Install Docker Engine. For more information, see [Ubuntu](https://docs.docker.com/engine/install/ubuntu/). + a. Use Docker Engine 24.0 or later. Docker Engine 29.5.x is not supported for this release because it can fail to pull required NGC images. + 3. Install Docker Compose. For more information, see [install the Compose plugin](https://docs.docker.com/compose/install/linux/). a. Ensure the Docker Compose plugin version is 2.29.1 or later. @@ -125,19 +127,21 @@ Use the following procedure to start all containers needed for this blueprint. compose-graphic-elements-1 Up 4 minutes compose-page-elements-1 Up 4 minutes nemotron-embedding-ms Up 4 minutes (healthy) - compose-nemoretriever-ocr-1 Up 4 minutes + compose-nemotron-ocr-1 Up 4 minutes compose-table-structure-1 Up 4 minutes ``` -6. Start the vector db containers from the repo root. +6. Persistent data (Elasticsearch, SeaweedFS, Milvus/etcd, LanceDB, and the ingestor-server scratch dir) is stored in dedicated `rag-vol-*` Docker named volumes, which Docker Compose creates automatically the first time you bring the stack up. For inspection, backup, and reset commands — and for migrating data from the legacy `deploy/compose/volumes/` host directory — see [Manage Persistent Data Volumes](troubleshooting.md#manage-persistent-data-volumes) in the troubleshooting guide. + +7. Start the vector db containers from the repo root. ```bash docker compose -f deploy/compose/vectordb.yaml up -d ``` -7. Start the ingestion containers from the repo root. This pulls the prebuilt containers from NGC and deploys them on your system. +8. Start the ingestion containers from the repo root. This pulls the prebuilt containers from NGC and deploys them on your system. ```bash docker compose -f deploy/compose/docker-compose-ingestor-server.yaml up -d @@ -190,7 +194,7 @@ Use the following procedure to start all containers needed for this blueprint. ``` -8. Start the RAG containers from the repo root. This pulls the prebuilt containers from NGC and deploys them on your system. +9. Start the RAG containers from the repo root. This pulls the prebuilt containers from NGC and deploys them on your system. ```bash docker compose -f deploy/compose/docker-compose-rag-server.yaml up -d @@ -234,7 +238,7 @@ Use the following procedure to start all containers needed for this blueprint. ``` -9. Check the status of the deployment by running the following code. +10. Check the status of the deployment by running the following code. ```bash docker ps --format "table {{.ID}}\t{{.Names}}\t{{.Status}}" @@ -249,15 +253,14 @@ Use the following procedure to start all containers needed for this blueprint. 03ff43bd4f53 compose-nv-ingest-ms-runtime-1 Up 2 minutes (healthy) fcc703631b71 ingestor-server Up 2 minutes 77f64a4a5146 compose-redis-1 Up 2 minutes - 902445432dde milvus-standalone Up 3 minutes (healthy) - 340bc8210a0d milvus-minio Up 3 minutes (healthy) - 0be702b87ad6 milvus-etcd Up 3 minutes (healthy) + 902445432dde elasticsearch Up 3 minutes (healthy) + 340bc8210a0d seaweedfs Up 3 minutes (healthy) 62eabf1d9f65 nim-llm-ms Up 10 minutes (healthy) fe2751bfa734 nemotron-ranking-ms Up 10 minutes (healthy) 7b5ddabf8be7 compose-graphic-elements-1 Up 10 minutes ecfaa5190302 compose-page-elements-1 Up 10 minutes ea8c7fdf20d1 nemotron-embedding-ms Up 10 minutes (healthy) - 6d62008a9b42 compose-nemoretriever-ocr-1 Up 10 minutes + 6d62008a9b42 compose-nemotron-ocr-1 Up 10 minutes 969b9f5c987c compose-table-structure-1 Up 10 minutes ``` @@ -324,7 +327,7 @@ After the first time you deploy the RAG Blueprint successfully, you can consider docker compose -f deploy/compose/docker-compose-*-server.yaml up -d --build ``` -- By default, GPU accelerated Milvus DB is deployed. You can choose the GPU ID to allocate by using the below env variable. For all service port mappings and GPU assignments, see [Service Port and GPU Reference](service-port-gpu-reference.md). +By default, Elasticsearch is deployed as the vector database (`vectordb.yaml` with the default profile). Milvus is optional. You can start Milvus with `docker compose -f deploy/compose/vectordb.yaml --profile milvus up -d` and set `APP_VECTORSTORE_NAME` / `APP_VECTORSTORE_URL` for Milvus (see [Vector database configuration](change-vectordb.md)). When you use the Milvus profile, Milvus can run as GPU-accelerated. Choose the GPU ID using the following variables. For all service port mappings and GPU assignments, refer to [Service Port and GPU Reference](service-port-gpu-reference.md). ```bash VECTORSTORE_GPU_DEVICE_ID=0 @@ -333,7 +336,7 @@ After the first time you deploy the RAG Blueprint successfully, you can consider - For improved accuracy, consider enabling reasoning mode. For details, refer to [Enable thinking](./enable-nemotron-thinking.md). -- NeMo Retriever Library OCR is now the default OCR service. To use legacy Paddle OCR instead, refer to [OCR Configuration Guide](nemoretriever-ocr.md). +- Nemotron OCR is now the default OCR service. To use legacy Paddle OCR instead, refer to [OCR Configuration Guide](nemoretriever-ocr.md). - For advanced users who need direct filesystem access to extraction results, refer to [Ingestor Server Volume Mounting](mount-ingestor-volume.md). diff --git a/docs/deploy-helm-from-repo.md b/docs/deploy-helm-from-repo.md index e57c9ea26..ce69e47e8 100644 --- a/docs/deploy-helm-from-repo.md +++ b/docs/deploy-helm-from-repo.md @@ -14,7 +14,6 @@ The following are the core services that you install: - RAG server - Ingestor server -- NeMo Retriever Library ## Prerequisites @@ -35,6 +34,16 @@ The following are the core services that you install: For more details, see instructions [here](https://docs.nvidia.com/nim-operator/latest/install.html). +4. Install the ECK (Elastic Cloud on Kubernetes) operator. Elasticsearch is the default vector database; the ECK operator manages Elasticsearch on Kubernetes. + + ```sh + helm repo add elastic https://helm.elastic.co + helm repo update + helm install elastic-operator elastic/eck-operator -n elastic-system --create-namespace + ``` + + For verification and Elasticsearch-specific Helm settings, see [Vector database configuration](change-vectordb.md). + :::{important} Consider the following before you deploy the RAG Blueprint: @@ -70,6 +79,7 @@ If you are working directly with the source Helm chart, and you want to customiz helm repo add baidu-nim https://helm.ngc.nvidia.com/nim/baidu --username='$oauthtoken' --password=$NGC_API_KEY helm repo add bitnami https://charts.bitnami.com/bitnami helm repo add elastic https://helm.elastic.co + helm repo add seaweed https://seaweedfs.github.io/seaweedfs/helm helm repo add otel https://open-telemetry.github.io/opentelemetry-helm-charts helm repo add zipkin https://zipkin.io/zipkin-helm helm repo add prometheus https://prometheus-community.github.io/helm-charts @@ -89,37 +99,14 @@ If you are working directly with the source Helm chart, and you want to customiz --set ngcApiSecret.password=$NGC_API_KEY ``` - :::{important} - **For NVIDIA RTX6000 Pro Deployments:** - - If you are deploying on NVIDIA RTX6000 Pro GPUs (instead of H100 GPUs), you need to configure the NIM LLM model profile. The required configuration is already present but commented out in the [values.yaml](../deploy/helm/nvidia-blueprint-rag/values.yaml) file. - - Uncomment and modify the following section under `nimOperator.nim-llm.model` in the values.yaml: - ```yaml - model: - engine: tensorrt_llm - precision: "fp8" - qosProfile: "throughput" - tensorParallelism: "1" - gpus: - - product: "rtx6000_blackwell_sv" - ``` - - Then install using the modified values.yaml: - ```sh - helm upgrade --install rag -n rag nvidia-blueprint-rag/ \ - --set imagePullSecret.password=$NGC_API_KEY \ - --set ngcApiSecret.password=$NGC_API_KEY \ - -f nvidia-blueprint-rag/values.yaml - ``` - ::: - :::{note} Refer to [NIM Model Profile Configuration](model-profiles.md) for using non-default NIM LLM profile. ::: + For **RTX PRO 6000** hardware, see the [RTX PRO 6000 setup prerequisites](nemotron3-super-deployment.md#rtx-pro-6000-setup) in the Nemotron 3 Super deployment guide. + -6. Follow the remaining instructions in [Deploy on Kubernetes with Helm](./deploy-helm.md): +6. Follow the remaining instructions in [Deploy on Kubernetes with Helm](./deploy-helm.md) (including verification examples that reflect the default Elasticsearch vector database; optional Milvus adds different pods and services—refer to [Vector database configuration](change-vectordb.md)) for more information. - [Verify a Deployment](deploy-helm.md#verify-a-deployment) - [Port-Forwarding to Access Web User Interface](deploy-helm.md#port-forwarding-to-access-web-user-interface) diff --git a/docs/deploy-helm-openshift.md b/docs/deploy-helm-openshift.md new file mode 100644 index 000000000..398674877 --- /dev/null +++ b/docs/deploy-helm-openshift.md @@ -0,0 +1,523 @@ + +# Deploy NVIDIA RAG Blueprint on OpenShift with Helm + +Use the following documentation to deploy the [NVIDIA RAG Blueprint](readme.md) on a Red Hat OpenShift cluster by using Helm. + +- To deploy on standard Kubernetes (non-OpenShift), refer to [Deploy on Kubernetes with Helm](deploy-helm.md). +- To deploy with MIG support, refer to [RAG Deployment with MIG Support](mig-deployment.md). +- For other deployment options, refer to [Deployment Options](readme.md#deployment-options-for-rag-blueprint). + +The chart includes built-in OpenShift support gated behind an `openshift.enabled` flag. +When enabled, the chart automatically creates OpenShift Routes with edge TLS and an `anyuid` SCC RoleBinding for all required ServiceAccounts — no manual `oc adm policy` commands are needed. + + +## Prerequisites + +:::{important} +Ensure you have at least 200GB of available disk space per node where NIMs will be deployed. This space is required for the following: +- NIM model cache downloads (~100-150GB) +- Container images (~20-30GB) +- Persistent volumes for vector database and application data +- Logs and temporary files +::: + +1. [Get an API Key](api-key.md). + +2. Verify that you meet the [hardware requirements](support-matrix.md). The minimum GPU requirements depend on deployment mode: + + | Deployment Mode | GPUs Required | Notes | + |----------------|--------------|-------| + | Full (self-hosted NIMs) | 8–10 | All NIM models running in-cluster | + | Minimal (no VLM, no optional NIMs) | 6–7 | Core pipeline without VLM or audio | + | API-hosted LLM | 4–6 | LLM via [build.nvidia.com](https://build.nvidia.com/); self-hosted embedding, reranking, and NV-Ingest NIMs | + +3. Verify that you have **OpenShift 4.14 or later** with cluster-admin access, and the `oc` CLI configured. + +4. Verify that you have **Helm 3** installed. To install Helm 3, follow the official [Helm installation instructions](https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3). + +5. Verify that you have the **NVIDIA GPU Operator** installed and functional. For details, see [GPU Operator documentation](https://docs.nvidia.com/datacenter/cloud-native/gpu-operator/latest/getting-started.html). + +6. Verify that you have the **NVIDIA NIM Operator** v3.0.2+ installed. If not, install it: + + ```sh + helm repo add nvidia https://helm.ngc.nvidia.com/nvidia \ + --username='$oauthtoken' \ + --password=$NGC_API_KEY + helm repo update + helm install nim-operator nvidia/k8s-nim-operator -n nim-operator --create-namespace + ``` + + For details, see [NIM Operator installation guide](https://docs.nvidia.com/nim-operator/latest/install.html). + +7. Install the **Elastic Cloud on Kubernetes (ECK) operator**. Elasticsearch is the default vector database for this chart, and the chart provisions an Elasticsearch CR that requires the ECK operator to reconcile it: + + ```sh + helm repo add elastic https://helm.elastic.co + helm repo update + helm install elastic-operator elastic/eck-operator -n elastic-system --create-namespace + ``` + + If you plan to replace Elasticsearch with Milvus or another backend and disable the chart-managed Elasticsearch, skip this step. See [Vector database configuration](change-vectordb.md). + +8. Verify that a **default StorageClass** with dynamic provisioning is available (e.g., `gp3-csi` on AWS): + + ```sh + oc get storageclass + ``` + + :::{note} + If your cluster does not have a default dynamic StorageClass available (common on bare-metal OpenShift installations), install the OpenEBS Dynamic LocalPV Provisioner to satisfy the chart's PVC requirements: + + ```sh + # Add the OpenEBS Helm repository + helm repo add openebs https://openebs.github.io/openebs + helm repo update + + # Create the openebs namespace + kubectl create namespace openebs + + # Install only the LocalPV provisioner; disable other storage engines + # On OpenShift, also disable the bundled minio/loki/alloy subcharts — + # their pods violate the restricted PodSecurity policy and the + # `openebs-minio-post-job` fails with BackoffLimitExceeded otherwise. + helm install openebs openebs/openebs \ + --namespace openebs \ + --set engines.replicated.mayastor.enabled=false \ + --set engines.local.lvm.enabled=false \ + --set engines.local.zfs.enabled=false \ + --set minio.enabled=false \ + --set loki.enabled=false \ + --set alloy.enabled=false + + # OpenShift requires the privileged SCC for the provisioner service account + oc adm policy add-scc-to-user privileged -z openebs-localpv-provisioner -n openebs + + # Mark openebs-hostpath as the default StorageClass + kubectl patch storageclass openebs-hostpath \ + -p '{"metadata": {"annotations": {"storageclass.kubernetes.io/is-default-class": "true"}}}' + ``` + + Verify that the provisioner pods are running and the StorageClass is configured as default: + + ```sh + kubectl get pods -n openebs + kubectl get sc + ``` + ::: + +9. Check GPU node taints. GPU nodes on OpenShift clusters typically have taints that prevent non-GPU workloads from scheduling on them. You need the taint keys for the tolerations configuration: + + ```sh + oc get nodes -l nvidia.com/gpu.present=true \ + -o custom-columns="NODE:.metadata.name,TAINTS:.spec.taints[*].key" + ``` + +10. Verify the kubelet `podPidsLimit` is at least `16384`. The `rag-nv-ingest` pod, along with the reranker and other NIMs, collectively spawn several thousand threads at steady state. The OpenShift default of `4096` is insufficient and surfaces as `pthread_create failed: Resource temporarily unavailable` errors during ingestion and reranking. + + Inspect the current value on any worker node: + + ```sh + oc get --raw /api/v1/nodes//proxy/configz \ + | jq '.kubeletconfig.podPidsLimit' + ``` + + If the value is below `16384`, apply the following `KubeletConfig` (cluster-admin access required). The Machine Config Operator will roll the affected nodes: + + ```yaml + apiVersion: machineconfiguration.openshift.io/v1 + kind: KubeletConfig + metadata: + name: rag-pod-pids-limit + spec: + machineConfigPoolSelector: + matchLabels: + pools.operator.machineconfiguration.openshift.io/worker: "" + kubeletConfig: + podPidsLimit: 16384 + ``` + +11. Accept NIM licenses. Each NIM container image on NGC requires individually accepting a license agreement before your API key can pull it. Accept licenses for each NIM at [build.nvidia.com](https://build.nvidia.com/). + + +## Deploy the RAG Helm Chart + +:::{important} +When you use the Helm NIM Operator deployment, approximately 60 to 70 minutes is required for the entire pipeline to reach a running state on first deploy. Subsequent deployments are significantly faster (~10-15 minutes) because model caches are already populated. +::: + +To deploy the RAG Blueprint on OpenShift, use the following procedure. + +1. Set your environment variables. + + ```sh + export NGC_API_KEY="nvapi-..." + export NAMESPACE="rag" + ``` + +2. Navigate to the chart directory and build dependencies. + + ```sh + cd deploy/helm/nvidia-blueprint-rag + + helm repo add nvidia-nemo https://helm.ngc.nvidia.com/nvidia/nemo-microservices \ + --username '$oauthtoken' --password "$NGC_API_KEY" + + helm dependency build + ``` + + :::{note} + The OpenShift overlay passes values through the `nv-ingest` subchart's + `extraVolumes` / `extraVolumeMounts` keys. With the currently pinned + `nv-ingest` 26.3.0, those values need a small indent adjustment in the + pulled chart before `helm upgrade` will render valid YAML. Re-apply this + after every `helm dependency build` or `helm dependency update`: + + ```sh + mkdir -p /tmp/nvi && \ + tar xzf charts/nv-ingest-26.3.0.tgz -C /tmp/nvi && \ + sed -i '/toYaml $v | nindent 12/s/nindent 12/nindent 14/' \ + /tmp/nvi/nv-ingest/templates/deployment.yaml && \ + tar czf charts/nv-ingest-26.3.0.tgz -C /tmp/nvi nv-ingest && \ + rm -rf /tmp/nvi + ``` + ::: + + :::{note} + **Alternative — installing from the NGC chart URL** + + If you prefer the install-from-NGC pattern shown in [Deploy on Kubernetes with Helm](deploy-helm.md) instead of cloning this repo, pull the chart locally first. Helm cannot patch a chart it streams directly from a remote URL, so the indent adjustment must be applied to a local copy before install: + + ```sh + # Pull and untar the chart from NGC. The NGC package ships with the + # nv-ingest subchart already extracted under charts/nv-ingest/, so the + # sed below can edit the template file in place. + helm pull https://helm.ngc.nvidia.com/nvstaging/blueprint/charts/nvidia-blueprint-rag-v2.6.0.tgz \ + --username '$oauthtoken' --password "$NGC_API_KEY" \ + --untar --untardir /tmp + + # Apply the indent adjustment to the bundled nv-ingest subchart + sed -i '/toYaml $v | nindent 12/s/nindent 12/nindent 14/' \ + /tmp/nvidia-blueprint-rag/charts/nv-ingest/templates/deployment.yaml + + # Install from the patched local directory + helm upgrade --install rag -n $NAMESPACE /tmp/nvidia-blueprint-rag \ + -f /tmp/nvidia-blueprint-rag/values-openshift.yaml \ + --set imagePullSecret.password="$NGC_API_KEY" \ + --set ngcApiSecret.password="$NGC_API_KEY" \ + --timeout 15m + ``` + + This replaces steps 2 and 4 of the procedure above; steps 3 and 5 are unchanged. + ::: + +3. Create a namespace. + + ```sh + oc new-project $NAMESPACE + ``` + +4. Install the Helm chart with the OpenShift overlay. + + ```sh + helm upgrade --install rag -n $NAMESPACE . \ + -f values-openshift.yaml \ + --set imagePullSecret.password="$NGC_API_KEY" \ + --set ngcApiSecret.password="$NGC_API_KEY" \ + --timeout 15m + ``` + + The `values-openshift.yaml` overlay enables the following: + - **OpenShift Routes** for the frontend and RAG server with edge TLS + - **anyuid SCC RoleBinding** for all ServiceAccounts that need it + - **ClusterIP** service type for the frontend (Routes handle external access) + + :::{note} + If your GPU nodes have taints, you must add tolerations. Pass them on the command line with `--set-json` or create a values overlay file. + For example, if your GPU nodes have a `gpu-taint` taint: + + ```sh + helm upgrade --install rag -n $NAMESPACE . \ + -f values-openshift.yaml \ + --set imagePullSecret.password="$NGC_API_KEY" \ + --set ngcApiSecret.password="$NGC_API_KEY" \ + --set-json 'nimOperator.nim-llm.tolerations=[{"key":"gpu-taint","operator":"Exists","effect":"NoSchedule"}]' \ + --set-json 'nimOperator.nvidia-nim-llama-nemotron-embed-1b-v2.tolerations=[{"key":"gpu-taint","operator":"Exists","effect":"NoSchedule"}]' \ + --set-json 'nimOperator.nvidia-nim-llama-nemotron-rerank-1b-v2.tolerations=[{"key":"gpu-taint","operator":"Exists","effect":"NoSchedule"}]' \ + --set-json 'nv-ingest.nimOperator.ocr.tolerations=[{"key":"gpu-taint","operator":"Exists","effect":"NoSchedule"}]' \ + --set-json 'nv-ingest.nimOperator.page_elements.tolerations=[{"key":"gpu-taint","operator":"Exists","effect":"NoSchedule"}]' \ + --timeout 15m + ``` + + The chart also includes a `values-openshift-test.yaml` reference overlay that demonstrates tolerations, resource tuning, disabled observability, and API-hosted LLM mode. Edit the toleration keys to match your cluster and layer it on with `-f values-openshift-test.yaml`. + ::: + +5. Link the NGC pull secret to the NIM Operator ServiceAccount. + + The NIM Operator creates a `nim-cache-sa` ServiceAccount for model cache jobs. Link the pull secret so it can pull NIM model images: + + ```sh + oc secrets link nim-cache-sa ngc-secret --for=pull -n $NAMESPACE + ``` + + If NIMCache pods are stuck in `ImagePullBackOff`, delete them so the operator recreates them with the linked secret: + + ```sh + oc delete pod -l app.nvidia.com/nim-cache -n $NAMESPACE + ``` + + +## Verify a Deployment + +1. List the pods by running the following code. + + ```sh + oc get pods -n $NAMESPACE + ``` + + You should see output similar to the following. + + ```sh + NAME READY STATUS AGE + ingestor-server-xxxxxxxxx-xxxxx 1/1 Running 5m + rag-eck-elasticsearch-es-default-0 1/1 Running 5m + nemotron-embedding-ms-xxxxxxxxx-xxxxx 1/1 Running 10m + nemotron-graphic-elements-v1-xxxxxxxxx-xxxxx 1/1 Running 10m + nemotron-ocr-v1-xxxxxxxxx-xxxxx 1/1 Running 10m + nemotron-page-elements-v3-xxxxxxxxx-xxxxx 1/1 Running 10m + nemotron-ranking-ms-xxxxxxxxx-xxxxx 1/1 Running 10m + nemotron-table-structure-v1-xxxxxxxxx-xxxxx 1/1 Running 10m + nim-llm-xxxxxxxxx-xxxxx 1/1 Running 15m + rag-frontend-xxxxxxxxx-xxxxx 1/1 Running 5m + rag-nv-ingest-xxxxxxxxx-xxxxx 1/1 Running 5m + rag-redis-master-0 1/1 Running 5m + rag-redis-replicas-0 1/1 Running 5m + rag-seaweedfs-all-in-one-xxxxxxxxx-xxxxx 1/1 Running 5m + rag-server-xxxxxxxxx-xxxxx 1/1 Running 5m + ``` + + If you have enabled Milvus instead of the default Elasticsearch vector database (see [Vector database configuration](change-vectordb.md)), the list also includes `rag-etcd-0` and `rag-minio-xxx` pods. + + :::{note} + Model downloads do not show detailed progress indicators in pod status. Pods may appear in "ContainerCreating" or "Init" state for extended periods while models download in the background. + + You can monitor the deployment progress by running the following code. + + ```sh + # Check NIMCache download status (shows if cache is ready) + oc get nimcache -n $NAMESPACE + + # Check NIMService status + oc get nimservice -n $NAMESPACE + + # Check events for detailed information + oc get events -n $NAMESPACE --sort-by='.lastTimestamp' + + # Watch logs of a specific pod to see detailed progress + oc logs -f -n $NAMESPACE + ``` + ::: + +2. Verify OpenShift Routes are created. + + ```sh + oc get routes -n $NAMESPACE + ``` + +3. Get the application URLs. + + ```sh + # Frontend URL + echo "https://$(oc get route rag-frontend -n $NAMESPACE -o jsonpath='{.spec.host}')" + + # API URL + echo "https://$(oc get route rag-server -n $NAMESPACE -o jsonpath='{.spec.host}')" + + # API health check + API_HOST=$(oc get route rag-server -n $NAMESPACE -o jsonpath='{.spec.host}') + curl -sk "https://${API_HOST}/health" + ``` + + +## Experiment with the Web User Interface + +Open a web browser and access the frontend URL from the previous step. You can start experimenting by uploading documents and asking questions. For details, see [User Interface for NVIDIA RAG Blueprint](user-interface.md). + +:::{note} +Unlike standard Kubernetes deployments, OpenShift Routes provide external access directly — no `kubectl port-forward` is needed. +::: + + +## Using NVIDIA-Hosted Models (Reduced GPU Requirements) + +For clusters with limited GPU capacity, you can use NVIDIA-hosted model endpoints at [build.nvidia.com](https://build.nvidia.com/) for the LLM while keeping embedding, reranking, and NV-Ingest NIMs self-hosted. + +Set the LLM server URLs to empty strings and disable the self-hosted NIM LLM: + +```yaml +nimOperator: + nim-llm: + enabled: false + +envVars: + APP_LLM_SERVERURL: "" + APP_QUERYREWRITER_SERVERURL: "" + APP_FILTEREXPRESSIONGENERATOR_SERVERURL: "" + REFLECTION_LLM_SERVERURL: "" + +ingestor-server: + envVars: + SUMMARY_LLM_SERVERURL: "" +``` + +The included `values-openshift-test.yaml` overlay implements this pattern. Layer it on with `-f values-openshift-test.yaml`. + + +## Change a Deployment + +To change an existing deployment, after you modify the values files, run the following code. + +```sh +helm upgrade rag -n $NAMESPACE . \ + -f values-openshift.yaml \ + --set imagePullSecret.password="$NGC_API_KEY" \ + --set ngcApiSecret.password="$NGC_API_KEY" +``` + + +## Uninstall a Deployment + +To uninstall a deployment, run the following code. + +```sh +helm uninstall rag -n $NAMESPACE +``` + +Run the following code to remove the NIMCache and Persistent Volume Claims (PVCs) created by the chart which are not removed by default. + +```sh +oc delete nimcache --all -n $NAMESPACE +oc delete nimservice --all -n $NAMESPACE +oc delete pvc --all -n $NAMESPACE +``` + +To delete the namespace entirely: + +```sh +oc delete namespace $NAMESPACE +``` + + +## OpenShift-Specific Troubleshooting + +### Security Context Constraints (SCC) + +**Symptom**: Pods fail with `CrashLoopBackOff` and logs show permission errors such as `mkdir: cannot create directory '/opt/nim/.cache': Permission denied`. + +**Why**: OpenShift's default `restricted` SCC assigns random UIDs. NIM containers and infrastructure services expect to run as specific users. + +**Fix**: The chart's `openshift.yaml` template automatically grants the `anyuid` SCC to required ServiceAccounts when `openshift.enabled` is `true`. If you are not using `values-openshift.yaml`, grant `anyuid` manually: + +```sh +oc adm policy add-scc-to-user anyuid -z default -n $NAMESPACE +``` + +### nv-ingest Ray Worker Failures on Clusters with Low `podPidsLimit` + +**Symptom**: The `rag-nv-ingest` pod restarts repeatedly with `pthread_create failed: Resource temporarily unavailable` in its logs. Ingestion tasks remain in the `pending` state and the Redis queue (`LLEN ingest_task_queue`) does not drain. + +**Why**: The pod's cgroup `cpuset.cpus` reflects the full host CPU set (for example, `0-255`), so Ray detects all host CPUs and prestarts an equally large Python worker pool. Each worker spawns several gRPC threads during initialization. On clusters where the kubelet enforces the default `podPidsLimit` of 4096, the cumulative thread count exceeds the cgroup's PID ceiling, and worker processes are terminated before they can register with the raylet. + +**Recommended fix**: Raise the kubelet `podPidsLimit` to `16384` via a `KubeletConfig` custom resource. See Prerequisites step 10 for the manifest. This is the cluster-level change that addresses the root cause. + +**Workaround when the cluster `podPidsLimit` cannot be raised**: The `values-openshift.yaml` overlay enables a `sitecustomize.py` ConfigMap (`nv-ingest.pyPatches.enabled: true`) that overrides `os.cpu_count` and `psutil.cpu_count` to return the value of `RAG_NV_INGEST_DETECTED_CPUS` (default `4`). The overlay also sets `MAX_INGEST_PROCESS_WORKERS=4` to cap the number of Ray actor replicas per pipeline stage. Together, these settings keep the pod's steady-state PID count well below the cgroup limit at the cost of slower per-document throughput. + +**Tuning the worker count**: To change the worker count, update the values in `values-openshift.yaml` and re-run `helm upgrade`: + +```yaml +nv-ingest: + envVars: + RAG_NV_INGEST_DETECTED_CPUS: "8" # increase to improve throughput + MAX_INGEST_PROCESS_WORKERS: "8" # keep aligned with the value above +``` + +Alternatively, override the values on the command line without editing the file: + +```sh +helm upgrade --install rag -n $NAMESPACE . \ + -f values-openshift.yaml \ + --set 'nv-ingest.envVars.RAG_NV_INGEST_DETECTED_CPUS=8' \ + --set 'nv-ingest.envVars.MAX_INGEST_PROCESS_WORKERS=8' \ + --set imagePullSecret.password="$NGC_API_KEY" \ + --set ngcApiSecret.password="$NGC_API_KEY" +``` + +Higher values reduce per-document ingestion latency but increase the pod's PID consumption. Values above `8` are not recommended unless the kubelet `podPidsLimit` has first been raised (typically to `16384`) via the `KubeletConfig` manifest in Prerequisites step 10. + +### Reranker HTTP 500 Errors from Thread Pool Initialization Failure + +**Symptom**: The `rag-server` logs report `[500] Unknown Error` during query generation. The `nemotron-ranking-ms` pod logs contain `ThreadPoolBuildError { kind: IOError(Os { code: 11, kind: WouldBlock }) }` originating in the HuggingFace tokenizer path. + +**Why**: The reranker NIM's Rust/Rayon thread pool defaults to one thread per host CPU. On nodes that expose the full host cpuset, initialization exceeds the kubelet `podPidsLimit` and the NIM returns HTTP 500. The base chart sets thread caps on the OCR and YOLOX NIMs but not on the reranker. + +**Recommended fix**: Raise the kubelet `podPidsLimit` to `16384` via a `KubeletConfig` custom resource. See Prerequisites step 10 for the manifest. + +**Workaround when the cluster `podPidsLimit` cannot be raised**: The `values-openshift.yaml` overlay sets `RAYON_NUM_THREADS=4` and `TOKENIZERS_PARALLELISM=false` on the reranker NIM. To adjust the cap, edit the value in `values-openshift.yaml` and re-run `helm upgrade`. + +### GPU Node Scheduling and Tolerations + +**Symptom**: NIM pods stay in `Pending` state. + +**Why**: GPU nodes typically have taints. NIM workloads need matching tolerations. + +**Fix**: Discover your taint keys and set tolerations in your values file: + +```sh +oc get nodes -l nvidia.com/gpu.present=true \ + -o custom-columns="NODE:.metadata.name,TAINTS:.spec.taints[*].key" +``` + +Set matching tolerations for each NIM component via `--set-json` or a values overlay. The `values-openshift-test.yaml` file demonstrates the pattern. + +### NIM LLM VRAM Requirements + +**Symptom**: NIM LLM pod crashes during model loading with `torch.OutOfMemoryError`. + +**Fix**: For GPUs with limited VRAM, reduce `NIM_MAX_MODEL_LEN` or use NVIDIA-hosted models as described in [Using NVIDIA-Hosted Models](#using-nvidia-hosted-models-reduced-gpu-requirements). + +### Route Timeouts + +**Symptom**: Document ingestion or complex queries return `504 Gateway Timeout`. + +**Why**: OpenShift's default Route timeout is 30 seconds. The chart sets `haproxy.router.openshift.io/timeout: 300s` on the RAG server Route, but if you create Routes manually, set this annotation explicitly. + +### Common Issues + +| Issue | Cause | Solution | +|-------|-------|----------| +| Pods stuck in `Pending` | Missing tolerations or insufficient GPU resources | Check taints; set tolerations in values | +| `ImagePullBackOff` | Missing NGC secret or unaccepted NIM license | Verify `ngc-secret` exists; accept licenses at [build.nvidia.com](https://build.nvidia.com/) | +| `CrashLoopBackOff` | SCC restrictions or insufficient memory | Enable `openshift.enabled`; check resource limits | +| NIM LLM `OOMKilled` | Insufficient VRAM | Reduce `NIM_MAX_MODEL_LEN` or use NVIDIA-hosted LLM | +| PVC `Pending` | StorageClass not found | Set correct `storageClass` in values or use `""` for default | +| `504 Gateway Timeout` | Route timeout too low | Annotate route with `haproxy.router.openshift.io/timeout=300s` | +| NIMCache `ImagePullBackOff` | Pull secret not linked to `nim-cache-sa` | Run `oc secrets link nim-cache-sa ngc-secret --for=pull` | +| Ingest tasks stuck `pending` | nv-ingest Ray workers hit `podPidsLimit` | See [nv-ingest Ray Worker Failures](#nv-ingest-ray-worker-failures-on-clusters-with-low-podpidslimit) | +| Reranker returns HTTP 500 with `ThreadPoolBuildError` | Rust/Rayon thread pool exceeds pod PID limit | See [Reranker HTTP 500 Errors](#reranker-http-500-errors-from-thread-pool-initialization-failure) | +| `helm upgrade` fails with `yaml: ... did not find expected '-' indicator` | Indent adjustment needed in pulled `nv-ingest` 26.3.0 chart | Re-apply the post-`dependency build` step in [Deploy step 2](#deploy-the-rag-helm-chart) | + + +## Troubleshooting Helm Issues + +For general troubleshooting issues with Helm deployment, refer to [Troubleshooting](troubleshooting.md). + + +## Related Topics + +- [NVIDIA RAG Blueprint Documentation](readme.md) +- [Deploy on Kubernetes with Helm](deploy-helm.md) +- [Best Practices for Common Settings](accuracy_perf.md) +- [User Interface](user-interface.md) +- [Troubleshoot](troubleshooting.md) diff --git a/docs/deploy-helm.md b/docs/deploy-helm.md index e97d893f0..cd06e9833 100644 --- a/docs/deploy-helm.md +++ b/docs/deploy-helm.md @@ -14,7 +14,6 @@ The following are the core services that you install: - RAG server - Ingestor server -- NeMo Retriever Library ## Prerequisites @@ -69,6 +68,18 @@ Plan for additional space if you are enabling persistence for multiple services. For more details, see instructions [here](https://docs.nvidia.com/nim-operator/latest/install.html). +11. Install the Elastic Cloud on Kubernetes (ECK) operator. Elasticsearch is the default vector database for this chart; the ECK operator manages Elasticsearch on Kubernetes. + + ```sh + helm repo add elastic https://helm.elastic.co + helm repo update + helm install elastic-operator elastic/eck-operator -n elastic-system --create-namespace + ``` + + If you replace the default stack with Milvus or another backend only and disable chart-managed Elasticsearch, you do not need the ECK operator—see [Vector database configuration](change-vectordb.md). + + For verification commands and Elasticsearch tuning in Helm, see [Vector database configuration](change-vectordb.md). + ## Deploy the RAG Helm chart @@ -87,45 +98,18 @@ To deploy End-to-End RAG Server and Ingestor Server, use the following procedure 2. Install the Helm chart by running the following command. ```sh - helm upgrade --install rag -n rag https://helm.ngc.nvidia.com/nvidia/blueprint/charts/nvidia-blueprint-rag-v2.5.1.tgz \ + helm upgrade --install rag -n rag https://helm.ngc.nvidia.com/nvstaging/blueprint/charts/nvidia-blueprint-rag-v2.6.0.tgz \ --username '$oauthtoken' \ --password "${NGC_API_KEY}" \ --set imagePullSecret.password=$NGC_API_KEY \ --set ngcApiSecret.password=$NGC_API_KEY ``` - :::{important} - **For NVIDIA RTX6000 Pro Deployments:** - - If you are deploying on NVIDIA RTX6000 Pro GPUs (instead of H100 GPUs), you need to configure the NIM LLM model profile. The required configuration is already present but commented out in the [`values.yaml`](../deploy/helm/nvidia-blueprint-rag/values.yaml) file. - - Uncomment and modify the following section under `nimOperator.nim-llm.model`: - ```yaml - model: - engine: tensorrt_llm - precision: "fp8" - qosProfile: "throughput" - tensorParallelism: "1" - gpus: - - product: "rtx6000_blackwell_sv" - ``` - - Then install using the modified values.yaml: - ```sh - helm upgrade --install rag -n rag https://helm.ngc.nvidia.com/nvidia/blueprint/charts/nvidia-blueprint-rag-v2.5.1.tgz \ - --username '$oauthtoken' \ - --password "${NGC_API_KEY}" \ - --set imagePullSecret.password=$NGC_API_KEY \ - --set ngcApiSecret.password=$NGC_API_KEY \ - -f deploy/helm/nvidia-blueprint-rag/values.yaml - ``` - ::: - :::{note} Refer to [NIM Model Profile Configuration](model-profiles.md) for using non-default NIM LLM profile. ::: - For **Nemotron 3 Super** on Helm, see the [Nemotron 3 Super deployment guide](nemotron3-super-deployment.md#helm-deployment-nemotron-3-super). + For **RTX PRO 6000** hardware, see the [RTX PRO 6000 setup prerequisites](nemotron3-super-deployment.md#rtx-pro-6000-setup) in the Nemotron 3 Super deployment guide. ## Verify a Deployment @@ -138,7 +122,7 @@ To verify a deployment, use the following procedure. kubectl get pods -n rag ``` - You should see output similar to the following. +You should see output similar to the following. With the default Elasticsearch vector database (ECK), expect pods such as `rag-eck-elasticsearch-es-default-0`. If you enable Milvus in [`values.yaml`](../deploy/helm/nvidia-blueprint-rag/values.yaml), you will also see Milvus and etcd pods—refer to [Vector database configuration](change-vectordb.md). :::{note} If some pods remain in `Pending` state after deployment, refer to [PVCs in Pending state (StorageClass issues)](troubleshooting.md#pvcs-in-pending-state-storageclass-issues) in the troubleshooting guide. @@ -147,18 +131,17 @@ To verify a deployment, use the following procedure. ```sh NAME READY STATUS RESTARTS AGE ingestor-server-6cc886bcdf-6rfwm 1/1 Running 0 54m - milvus-standalone-7dd5db4755-ctqzg 1/1 Running 0 54m + rag-eck-elasticsearch-es-default-0 1/1 Running 0 54m nemotron-embedding-ms-86f75c8f65-dfhd2 1/1 Running 0 39m - nemoretriever-graphic-elements-v1-67d9d65bdc-ftbkw 1/1 Running 0 33m - nemoretriever-ocr-v1-78f56cddb9-f4852 1/1 Running 0 40m - nemoretriever-page-elements-v3-56ddcf9b4b-qsg82 1/1 Running 0 49m + nemotron-graphic-elements-v1-67d9d65bdc-ftbkw 1/1 Running 0 33m + nemotron-ocr-v1-78f56cddb9-f4852 1/1 Running 0 40m + nemotron-page-elements-v3-56ddcf9b4b-qsg82 1/1 Running 0 49m nemotron-ranking-ms-5ff774889f-fwrlm 1/1 Running 0 40m - nemoretriever-table-structure-v1-696c9f5665-l9sxn 1/1 Running 0 37m + nemotron-table-structure-v1-696c9f5665-l9sxn 1/1 Running 0 37m nim-llm-7cb9bdcc89-hwpkq 1/1 Running 0 11m nim-llm-cache-job-77hpc 0/1 Completed 0 94s - rag-etcd-0 1/1 Running 0 54m rag-frontend-5db7874b77-49q8f 1/1 Running 0 54m - rag-minio-649f6476c-n29b8 1/1 Running 0 54m + rag-seaweedfs-all-in-one-649f6476c-n29b8 1/1 Running 0 54m rag-nv-ingest-6bf4d98866-kbgg7 1/1 Running 0 54m rag-redis-master-0 1/1 Running 0 54m rag-redis-replicas-0 1/1 Running 0 54m @@ -205,23 +188,21 @@ To verify a deployment, use the following procedure. kubectl get svc -n rag ``` - You should see output similar to the following. + You should see output similar to the following. The default stack exposes Elasticsearch HTTP on a service such as `rag-eck-elasticsearch-es-http` (port 9200). Enabling Milvus adds separate services—refer to [Vector database configuration](change-vectordb.md). ```sh NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE ingestor-server ClusterIP 10.107.12.217 8082/TCP 54m - milvus ClusterIP 10.99.110.203 19530/TCP,9091/TCP 54m + rag-eck-elasticsearch-es-http ClusterIP 10.99.110.203 9200/TCP 54m nemotron-embedding-ms ClusterIP 10.104.99.15 8000/TCP,8001/TCP 54m - nemoretriever-graphic-elements-v1 ClusterIP 10.96.115.45 8000/TCP,8001/TCP 54m - nemoretriever-ocr-v1 ClusterIP 10.100.107.215 8000/TCP,8001/TCP 54m - nemoretriever-page-elements-v3 ClusterIP 10.102.237.196 8000/TCP,8001/TCP 54m + nemotron-graphic-elements-v1 ClusterIP 10.96.115.45 8000/TCP,8001/TCP 54m + nemotron-ocr-v1 ClusterIP 10.100.107.215 8000/TCP,8001/TCP 54m + nemotron-page-elements-v3 ClusterIP 10.102.237.196 8000/TCP,8001/TCP 54m nemotron-ranking-ms ClusterIP 10.96.114.244 8000/TCP,8001/TCP 54m - nemoretriever-table-structure-v1 ClusterIP 10.107.227.139 8000/TCP,8001/TCP 54m + nemotron-table-structure-v1 ClusterIP 10.107.227.139 8000/TCP,8001/TCP 54m nim-llm ClusterIP 10.104.60.155 8000/TCP,8001/TCP 54m - rag-etcd ClusterIP 10.104.74.116 2379/TCP,2380/TCP 54m - rag-etcd-headless ClusterIP None 2379/TCP,2380/TCP 54m rag-frontend NodePort 10.100.190.142 3000:31473/TCP 54m - rag-minio ClusterIP 10.101.18.143 9000/TCP 54m + rag-seaweedfs-all-in-one ClusterIP 10.101.18.143 9010/TCP 54m rag-nv-ingest ClusterIP 10.107.186.4 7670/TCP 54m rag-redis-headless ClusterIP None 6379/TCP 54m rag-redis-master ClusterIP 10.105.178.202 6379/TCP 54m @@ -252,7 +233,7 @@ Port-forwarding is provided as a quick method to try out the UI. However, large To change an existing deployment, after you modify the [`values.yaml`](../deploy/helm/nvidia-blueprint-rag/values.yaml) file, run the following code. ```sh -helm upgrade --install rag -n rag https://helm.ngc.nvidia.com/nvidia/blueprint/charts/nvidia-blueprint-rag-v2.5.1.tgz \ +helm upgrade --install rag -n rag https://helm.ngc.nvidia.com/nvstaging/blueprint/charts/nvidia-blueprint-rag-v2.6.0.tgz \ --username '$oauthtoken' \ --password "${NGC_API_KEY}" \ --set imagePullSecret.password=$NGC_API_KEY \ @@ -282,9 +263,9 @@ kubectl delete pvc --all -n rag - **NIM LLM** – To enable persistence for NIM LLM, refer to [NIM LLM](https://docs.nvidia.com/nim/large-language-models/latest/deploy-helm.html#storage). Update the required fields in the `nim-llm` section of the [`values.yaml`](../deploy/helm/nvidia-blueprint-rag/values.yaml) file. - - **Nemo Retriever** – To enable persistence for Nemo Retriever embedding, refer to [Nemo Retriever Text Embedding](https://docs.nvidia.com/nim/nemo-retriever/text-embedding/latest/deploying.html#storage). Update the required fields in the `nvidia-nim-llama-32-nv-embedqa-1b-v2` section of the [`values.yaml`](../deploy/helm/nvidia-blueprint-rag/values.yaml) file. + - **Nemo Retriever** – To enable persistence for Nemo Retriever embedding, refer to [Nemo Retriever Text Embedding](https://docs.nvidia.com/nim/nemo-retriever/text-embedding/latest/deploying.html#storage). Update the required fields in the `nvidia-nim-llama-nemotron-embed-1b-v2` section of the [`values.yaml`](../deploy/helm/nvidia-blueprint-rag/values.yaml) file. - - **Nemo Retriever reranking** – To enable persistence for Nemo Retriever reranking, refer to [Nemo Retriever Text Reranking](https://docs.nvidia.com/nim/nemo-retriever/text-reranking/latest/deploying.html#storage). Update the required fields in the `nvidia-nim-llama-32-nv-rerankqa-1b-v2` section of the [`values.yaml`](../deploy/helm/nvidia-blueprint-rag/values.yaml) file. + - **Nemo Retriever reranking** – To enable persistence for Nemo Retriever reranking, refer to [Nemo Retriever Text Reranking](https://docs.nvidia.com/nim/nemo-retriever/text-reranking/latest/deploying.html#storage). Update the required fields in the `nvidia-nim-llama-nemotron-rerank-1b-v2` section of the [`values.yaml`](../deploy/helm/nvidia-blueprint-rag/values.yaml) file. 2. Run the code in [Change a Deployment](#change-a-deployment). diff --git a/docs/documentation.md b/docs/documentation.md index 8508ca0b0..0a46705e4 100644 --- a/docs/documentation.md +++ b/docs/documentation.md @@ -9,8 +9,8 @@ - [Build the Documentation](#build-the-documentation) - [Live Building](#live-building) - [Documentation Version](#documentation-version) - - [Publishing multiple versions on the public site](#publishing-multiple-versions-on-the-public-site) - - [Multi-version build script](#multi-version-build-script) + - [Publishing Multiple Versions on the Public Site](#publishing-multiple-versions-on-the-public-site) + - [Multi-Version Build Script](#multi-version-build-script) ## Build the Documentation @@ -51,30 +51,30 @@ Validate the manifest and that `release` matches `project.json` before building: uv run python docs/scripts/verify_doc_version_manifest.py ``` -### Publishing multiple versions on the public site +### Publishing Multiple Versions on the Public Site -Use the **same** `docs/versions1.json` content for every release line you build (list every published version; `preferred` should be `true` only for the default, usually the latest). On each **release branch or tag**, set `release` in `conf.py` and `version` in `project.json` to that line’s version (for example `2.4.0` on the `2.4.x` branch), then build: +Use the same `docs/versions1.json` content for every release line you build. List every published version, and set `preferred` to `true` only for the default version, usually the latest. On each release branch or tag, set `release` in `conf.py` and `version` in `project.json` to that line's version, then build: ```sh uv run --group docs sphinx-build . _build/html ``` -Deploy the HTML so each line lives as a **sibling** folder, for example `2.3.0/`, `2.4.0/`, `2.5.0/`. The theme resolves `../versions1.json` from the version **index** page to a file **next to** those folders (the parent directory). Copy the same `docs/versions1.json` to that parent as `versions1.json` when you publish, or ensure your pipeline deploys it there once per release. If you add a version to the manifest, rebuild (or redeploy) each affected tree and refresh the root `versions1.json`; invalidate CDN cache if the menu still looks stale. +Deploy the HTML so each release lives as a sibling folder, for example `2.4.0/`, `2.5.0/`, `2.5.1/`, and `2.6.0/`. The theme resolves `../versions1.json` from the version index page to a file next to those folders. Copy the same `docs/versions1.json` to that parent as `versions1.json` when you publish, or ensure your pipeline deploys it there once per release. If you add a version to the manifest, rebuild or redeploy each affected tree and refresh the root `versions1.json`. -### Multi-version build script +### Multi-Version Build Script From the repository root, you can build several release lines into one tree: `docs/_build/multiversion/{version}/` plus a root `versions1.json`. The script reads your current `docs/versions1.json` as the canonical manifest, then for each version checks out git tag `v{version}` if it exists, otherwise branch `release-v{version}`, writes that manifest into `docs/versions1.json`, runs the verifier, and runs Sphinx. Your original `HEAD` is restored at the end. -Preview which refs will be used (no git or build): +Preview which refs will be used: ```powershell .\docs\scripts\build_multiversion_docs.ps1 -DryRun ``` -Full build (requires a clean working tree, or pass `-AllowDirty`): +Full build: ```powershell -.\docs\scripts\build_multiversion_docs.ps1 -Versions @('2.3.0','2.4.0','2.5.0','2.5.1') +.\docs\scripts\build_multiversion_docs.ps1 -Versions @('2.3.0','2.4.0','2.5.0','2.5.1','2.6.0') ``` On Linux or macOS: @@ -82,7 +82,7 @@ On Linux or macOS: ```sh chmod +x docs/scripts/build_multiversion_docs.sh ./docs/scripts/build_multiversion_docs.sh --dry-run -./docs/scripts/build_multiversion_docs.sh --versions 2.3.0,2.4.0,2.5.0,2.5.1 +./docs/scripts/build_multiversion_docs.sh --versions 2.3.0,2.4.0,2.5.0,2.5.1,2.6.0 ``` -Serve the result locally, for example: `python -m http.server 8080 --directory docs/_build/multiversion` and open `http://localhost:8080/2.5.0/` to confirm the switcher. \ No newline at end of file +Serve the result locally, for example: `python -m http.server 8080 --directory docs/_build/multiversion` and open `http://localhost:8080/2.6.0/` to confirm the switcher. diff --git a/docs/elasticsearch-configuration.md b/docs/elasticsearch-configuration.md new file mode 100644 index 000000000..142d17f9c --- /dev/null +++ b/docs/elasticsearch-configuration.md @@ -0,0 +1,667 @@ + + +# Elasticsearch Configuration + +This document describes optional GPU-accelerated vector indexing and authentication configuration for Elasticsearch in the [NVIDIA RAG Blueprint](readme.md). + +For standard Elasticsearch usage (ports, switching backends, default deployment), see [Vector database configuration](change-vectordb.md). + +--- + +## GPU Indexing + +GPU indexing is part of Elasticsearch Enterprise (or a compatible Elastic license tier). You must obtain a GPU-enabled Elasticsearch image, apply a valid Elastic license, and configure your deployment accordingly. + +### Prerequisites + +- **Elastic license** – A subscription or trial that includes GPU vector indexing. Obtain the license JSON from Elastic (for example, a non-production or production stack license file). +- **NVIDIA GPU** – A supported NVIDIA GPU and driver stack on the Docker host or Kubernetes node. +- **NVIDIA Container Toolkit** – Configured so Docker (or the GPU Operator on Kubernetes) can schedule GPU devices into containers. + +### Build a GPU-Enabled Elasticsearch Image + +GPU vector indexing requires a custom Elasticsearch Docker image. Follow Elastic's official guide to build it: + +[**Elasticsearch Docker image with GPU support**](https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/gpu-vector-indexing#elasticsearch-docker-image-with-gpu-support) + +After building, tag the image and push it to your registry if needed. Reference this tag in the Docker Compose or Helm configuration below. + +### Docker Compose + +The following steps apply to Docker Compose deployments using [`deploy/compose/vectordb.yaml`](../deploy/compose/vectordb.yaml). + +#### 1. Set the GPU-Enabled Image + +In [`deploy/compose/vectordb.yaml`](../deploy/compose/vectordb.yaml), update the `elasticsearch` service to reference your GPU-enabled image: + +```yaml +elasticsearch: + image: es-gpu # Replace with your registry/tag if different +``` + +#### 2. Enable GPU Indexing + +In the `elasticsearch` service `environment` block, uncomment the GPU indexing setting: + +```yaml +environment: + # ... existing variables ... + - "vectors.indexing.use_gpu=true" +``` + +#### 3. Pass GPU Devices to the Container + +Uncomment the `deploy` block to grant the container access to an NVIDIA GPU. Set `VECTORSTORE_GPU_DEVICE_ID` in [`.env`](../deploy/compose/.env) or your shell to pin a specific device: + +```yaml +deploy: + resources: + reservations: + devices: + - driver: nvidia + capabilities: ["gpu"] + device_ids: ["${VECTORSTORE_GPU_DEVICE_ID:-0}"] +``` + +#### 4. Start Elasticsearch + +Bring up the Elasticsearch container: + +```bash +docker compose -f deploy/compose/vectordb.yaml up -d +``` + +#### 5. Apply the Elastic License + +Once Elasticsearch is reachable on port 9200, install the license file: + +```bash +curl -X PUT "http://localhost:9200/_license" \ + -H "Content-Type: application/json" \ + -d @/path/to/your-license.json +``` + +Verify the license is active: + +```bash +curl -X GET "http://localhost:9200/_license" +``` + +Confirm the response shows an **Enterprise** (or applicable) tier with an active license state. + +#### 6. Enable GPU Indexing in the Ingestor Server + +Set `APP_VECTORSTORE_ENABLEGPUINDEX=True` before starting the ingestor server. This enables the GPU index strategy (`int8_hnsw`) used during document ingestion: + +```bash +export APP_VECTORSTORE_ENABLEGPUINDEX=True + +docker compose -f deploy/compose/docker-compose-ingestor-server.yaml up -d +docker compose -f deploy/compose/docker-compose-rag-server.yaml up -d +``` + +:::{note} +For authentication configuration (xpack security, API keys), see [Elasticsearch Authentication](#elasticsearch-authentication). +::: + +### Helm + +The following steps apply to Helm deployments using [`deploy/helm/nvidia-blueprint-rag/values.yaml`](../deploy/helm/nvidia-blueprint-rag/values.yaml). + +#### 1. Set the GPU-Enabled Image + +In [`values.yaml`](../deploy/helm/nvidia-blueprint-rag/values.yaml), uncomment and set the custom Elasticsearch image under the `eck-elasticsearch` section: + +```yaml +eck-elasticsearch: + enabled: true + image: # Replace with your built or registry image tag +``` + +#### 2. Enable GPU Indexing and Resources + +Under `eck-elasticsearch.nodeSets[0]`, uncomment `vectors.indexing.use_gpu: true` in the `config` block, and uncomment the `nvidia.com/gpu` resource requests and limits in the `podTemplate`: + +```yaml +eck-elasticsearch: + nodeSets: + - name: default + config: + node.store.allow_mmap: false + xpack.security.enabled: false + xpack.security.http.ssl.enabled: false + xpack.security.transport.ssl.enabled: false + vectors.indexing.use_gpu: true # Uncomment this line + podTemplate: + spec: + containers: + - name: elasticsearch + resources: + requests: + memory: "4Gi" + cpu: "500m" + nvidia.com/gpu: 1 # Uncomment this line + limits: + memory: "4Gi" + nvidia.com/gpu: 1 # Uncomment this line +``` + +#### 3. Enable GPU Indexing in the Ingestor Server + +In [`values.yaml`](../deploy/helm/nvidia-blueprint-rag/values.yaml), set `APP_VECTORSTORE_ENABLEGPUINDEX` to `"True"` in the `ingestor-server` section: + +```yaml +ingestor-server: + envVars: + APP_VECTORSTORE_ENABLEGPUINDEX: "True" +``` + +#### 4. Deploy the Helm Chart + +Apply the changes as described in [Change a Deployment](deploy-helm.md#change-a-deployment). For a fresh install: + +```bash +cd deploy/helm/ + +helm upgrade --install rag -n rag --create-namespace nvidia-blueprint-rag/ \ + --set imagePullSecret.password=$NGC_API_KEY \ + --set ngcApiSecret.password=$NGC_API_KEY \ + -f nvidia-blueprint-rag/values.yaml +``` + +#### 5. Apply the Elastic License + +After Elasticsearch is running, port-forward the service to access it from your local machine, then apply the license. + +**In one terminal, start the tunnel:** + +```bash +kubectl port-forward -n rag svc/rag-eck-elasticsearch-es-http 9200:9200 +``` + +**In a second terminal, apply the license:** + +```bash +curl -X PUT "http://localhost:9200/_license" \ + -H "Content-Type: application/json" \ + -d @/path/to/your-license.json +``` + +**Verify the license is active:** + +```bash +curl -X GET "http://localhost:9200/_license" +``` + +Confirm the response shows an **Enterprise** (or applicable) tier with an active license state. + +:::{note} +If xpack security is enabled, add `-u elastic:$ES_PASSWORD` to the curl commands. See [Elasticsearch Authentication (Helm)](#helm-chart) for retrieving the auto-generated password from the ECK secret. +::: + +--- + +## Elasticsearch Authentication + +Enable authentication for Elasticsearch to secure your vector database. + +### Docker Compose + +#### 1. Configure Elasticsearch Authentication (xpack) + +Edit `deploy/compose/vectordb.yaml` to enable xpack security by setting `xpack.security.enabled` to true: +```yaml +environment: + - xpack.security.enabled=true +``` + +Uncomment the username and password environment variables in the elasticsearch service in `deploy/compose/vectordb.yaml`: +```yaml +- ELASTIC_USERNAME=${APP_VECTORSTORE_USERNAME} +- ELASTIC_PASSWORD=${APP_VECTORSTORE_PASSWORD} +``` + +Add authentication in `healthcheck` in `deploy/compose/vectordb.yaml` by uncommenting the following: +```yaml +test: ["CMD", "curl", "-s", "-f", "-u", "${APP_VECTORSTORE_USERNAME}:${APP_VECTORSTORE_PASSWORD}", "http://localhost:9200/_cat/health"] +``` +and commenting out +```yaml +test: ["CMD", "curl", "-s", "-f", "http://localhost:9200/_cat/health"] +``` + +#### 2. Start Elasticsearch Container with Credentials + +Start the Elasticsearch container with username and password: + +```bash +export APP_VECTORSTORE_USERNAME="elastic" # elastic recommended +export APP_VECTORSTORE_PASSWORD="your-secure-password" + +docker compose -f deploy/compose/vectordb.yaml --profile elasticsearch up -d +``` + +#### 3. Generate Elasticsearch API Key (Optional but Recommended) + +If you prefer to use API key authentication instead of username/password (recommended for production), generate an API key using curl. You need the username and password from the previous step. + +```bash +# Either provide base64 apikey (base64 of "id:secret") +export APP_VECTORSTORE_APIKEY="base64-id-colon-secret" +# Or provide split ID/SECRET +export APP_VECTORSTORE_APIKEY_ID="your_id" +export APP_VECTORSTORE_APIKEY_SECRET="your_secret" + +docker compose -f deploy/compose/vectordb.yaml --profile elasticsearch up -d +docker compose -f deploy/compose/docker-compose-ingestor-server.yaml up -d +docker compose -f deploy/compose/docker-compose-rag-server.yaml up -d +``` + +### Get an Elasticsearch API Key + +If security is enabled, create an API key using curl. You need a user with permission to create API keys (e.g., the built-in `elastic` superuser in dev). + +#### 1. Using curl (replace credentials and URL as appropriate): +```bash +# If running inside the cluster, port-forward first: +# kubectl -n rag port-forward svc/rag-eck-elasticsearch-es-http 9200:9200 + +curl -u elastic:your-secure-password \ + -X POST "http://127.0.0.1:9200/_security/api_key" \ + -H 'Content-Type: application/json' \ + -d '{ + "name": "rag-api-key", + "role_descriptors": {} + }' +``` + +Example response: +```json +{ + "id": "AbCdEfGhIj", + "name": "rag-api-key", + "expiration": null, + "api_key": "ZyXwVuTsRq", + "encoded": null +} +``` + +Convert the API key to base64: + +```bash +# Base64 is computed over ":" +echo -n "AbCdEfGhIj:ZyXwVuTsRq" | base64 +# Output example: QWJ...cXE= +``` + +#### 4. Set Environment Variables for Authentication + +Choose ONE of the following authentication methods: + +**Option A: API Key Authentication (Recommended)** + +Set environment variables using the base64-encoded API key or split ID/SECRET: + +```bash +# Either provide base64 apikey (base64 of "id:secret") +export APP_VECTORSTORE_APIKEY="QWJ...cXE=" + +# Or provide split ID/SECRET +export APP_VECTORSTORE_APIKEY_ID="AbCdEfGhIj" +export APP_VECTORSTORE_APIKEY_SECRET="ZyXwVuTsRq" +``` + +**Option B: Username/Password Authentication** + +If you prefer to use username/password instead of API key: + +```bash +export APP_VECTORSTORE_USERNAME="elastic" +export APP_VECTORSTORE_PASSWORD="your-secure-password" +``` + +#### 5. Start RAG Server and Ingestor Server + +Start the RAG and Ingestor services with the authentication credentials: + +```bash +docker compose -f deploy/compose/docker-compose-ingestor-server.yaml up -d +docker compose -f deploy/compose/docker-compose-rag-server.yaml up -d +``` + +:::{note} +API key authentication takes precedence over username/password when both are configured. +::: + +### Helm Chart + +Follow these steps to enable authentication for Elasticsearch in your Helm deployment. + +#### 1. Enable Elasticsearch Authentication + +Edit [`values.yaml`](../deploy/helm/nvidia-blueprint-rag/values.yaml) to enable X-Pack security. Set the following explicitly: + +```yaml +eck-elasticsearch: + nodeSets: + - name: default + config: + node.store.allow_mmap: false + xpack.security.enabled: true + xpack.security.transport.ssl.enabled: true +``` + +:::{important} +**Key Configuration Flags:** +- `xpack.security.enabled: true` - Enables authentication (default user: `elastic`). Set this explicitly. +- `xpack.security.transport.ssl.enabled: true` - Enables SSL for node-to-node communication. Set this explicitly. +::: + +#### 2. Replace Readiness Probe When Security Is Enabled + +When X-Pack security is enabled, replace the current `readinessProbe` section under `eck-elasticsearch.nodeSets[0]` in [`values.yaml`](../deploy/helm/nvidia-blueprint-rag/values.yaml) with the ECK default probe (so the pod uses the readiness port script instead of an unauthenticated curl check). Ensure the following `podTemplate` is present under the same `nodeSets` entry: + +```yaml +eck-elasticsearch: + nodeSets: + - name: default + podTemplate: + spec: + containers: + - name: elasticsearch + readinessProbe: + exec: + command: + - bash + - -c + - /mnt/elastic-internal/scripts/readiness-port-script.sh + initialDelaySeconds: 10 + periodSeconds: 5 + timeoutSeconds: 5 + failureThreshold: 3 +``` + +#### 3. Deploy with Authentication Enabled + +After modifying [`values.yaml`](../deploy/helm/nvidia-blueprint-rag/values.yaml), apply the changes as described in [Change a Deployment](deploy-helm.md#change-a-deployment). + +Wait for Elasticsearch to restart: + +```bash +# Monitor pod restart +kubectl get pods -n rag -w | grep elasticsearch + +# Wait for pod to be ready +kubectl wait --for=condition=ready pod -l elasticsearch.k8s.elastic.co/cluster-name=rag-eck-elasticsearch -n rag --timeout=300s +``` + +#### 4. Retrieve Elasticsearch Password from Secret + +When authentication is enabled, ECK automatically creates a Kubernetes secret containing the `elastic` user password: + +```bash +# Find the Elasticsearch user secret +kubectl get secrets -n rag | grep elastic-user +# Expected: rag-eck-elasticsearch-es-elastic-user + +# Retrieve the password +ES_PASSWORD=$(kubectl get secret rag-eck-elasticsearch-es-elastic-user -n rag -o jsonpath='{.data.elastic}' | base64 -d) +echo "Elasticsearch password: $ES_PASSWORD" +``` + +:::{tip} +Save this password securely. The password is auto-generated by ECK and persists across pod restarts unless the secret is deleted. +::: + +#### 5. Update Deployment with Credentials + +Configure the RAG server and ingestor-server to use the retrieved credentials. + +Edit [`values.yaml`](../deploy/helm/nvidia-blueprint-rag/values.yaml) and set the following values for Elasticsearch authentication: + +- **APP_VECTORSTORE_USERNAME:** set to `"elastic"` (the default Elasticsearch superuser). +- **APP_VECTORSTORE_PASSWORD:** set to the password retrieved in step 4. + +Example (replace `your-retrieved-password` with your actual `$ES_PASSWORD`): + +```yaml +# RAG Server configuration +envVars: + APP_VECTORSTORE_URL: "http://rag-eck-elasticsearch-es-http:9200" + APP_VECTORSTORE_NAME: "elasticsearch" + APP_VECTORSTORE_USERNAME: "elastic" + APP_VECTORSTORE_PASSWORD: "your-retrieved-password" # use $ES_PASSWORD from step 4 + +# Ingestor Server configuration +ingestor-server: + envVars: + APP_VECTORSTORE_URL: "http://rag-eck-elasticsearch-es-http:9200" + APP_VECTORSTORE_NAME: "elasticsearch" + APP_VECTORSTORE_USERNAME: "elastic" + APP_VECTORSTORE_PASSWORD: "your-retrieved-password" # use $ES_PASSWORD from step 4 +``` + +Then apply the changes as described in [Change a Deployment](deploy-helm.md#change-a-deployment). + +#### 6. (Optional) Use API Key Authentication + +For advanced use cases or production environments, you can use Elasticsearch API keys instead of username/password authentication. + +**Generate an API Key:** + +First, port-forward to access Elasticsearch: + +```bash +kubectl port-forward -n rag svc/rag-eck-elasticsearch-es-http 9200:9200 +``` + +Then generate an API key using the elastic user: + +```bash +# Get the elastic password +ES_PASSWORD=$(kubectl get secret rag-eck-elasticsearch-es-elastic-user -n rag -o jsonpath='{.data.elastic}' | base64 -d) + +# Create an API key +curl -u elastic:$ES_PASSWORD \ + -X POST "http://localhost:9200/_security/api_key" \ + -H 'Content-Type: application/json' \ + -d '{ + "name": "rag-api-key", + "role_descriptors": {} + }' +``` + +Example response: +```json +{ + "id": "AbCdEfGhIj", + "name": "rag-api-key", + "api_key": "ZyXwVuTsRq" +} +``` + +**Encode the API Key:** + +```bash +# Base64 encode the "id:api_key" format +echo -n "AbCdEfGhIj:ZyXwVuTsRq" | base64 +# Output example: QWJDZEVmR2hJajpaeVh3VnVUc1Jx +``` + +**Configure with API Key in values.yaml:** + +```yaml +# RAG Server configuration - Option 1: Base64 encoded API key +envVars: + APP_VECTORSTORE_APIKEY: "QWJDZEVmR2hJajpaeVh3VnVUc1Jx" + APP_VECTORSTORE_USERNAME: "" + APP_VECTORSTORE_PASSWORD: "" + +# Ingestor Server configuration - Option 1: Base64 encoded API key +ingestor-server: + envVars: + APP_VECTORSTORE_APIKEY: "QWJDZEVmR2hJajpaeVh3VnVUc1Jx" + APP_VECTORSTORE_USERNAME: "" + APP_VECTORSTORE_PASSWORD: "" +``` + +Or use split ID/SECRET format: + +```yaml +# RAG Server configuration - Option 2: Split ID and secret +envVars: + APP_VECTORSTORE_APIKEY_ID: "AbCdEfGhIj" + APP_VECTORSTORE_APIKEY_SECRET: "ZyXwVuTsRq" + APP_VECTORSTORE_USERNAME: "" + APP_VECTORSTORE_PASSWORD: "" + +# Ingestor Server configuration - Option 2: Split ID and secret +ingestor-server: + envVars: + APP_VECTORSTORE_APIKEY_ID: "AbCdEfGhIj" + APP_VECTORSTORE_APIKEY_SECRET: "ZyXwVuTsRq" + APP_VECTORSTORE_USERNAME: "" + APP_VECTORSTORE_PASSWORD: "" +``` + +Then apply the changes as described in [Change a Deployment](deploy-helm.md#change-a-deployment). + +:::{note} +**API Key vs Username/Password:** +- API keys are recommended for production environments and applications +- API keys can have specific permissions and expiration dates +- API keys can be rotated without changing the elastic user password +- **API key authentication takes precedence** when both username/password and API keys are configured +::: + +#### 7. Verify Authentication + +Test that the services can connect to Elasticsearch with authentication: + +```bash +# Check ingestor-server logs for successful connection +kubectl logs -n rag -l app=ingestor-server --tail=20 + +# Test Elasticsearch connection manually +ES_PASSWORD=$(kubectl get secret rag-eck-elasticsearch-es-elastic-user -n rag -o jsonpath='{.data.elastic}' | base64 -d) +kubectl exec -n rag rag-eck-elasticsearch-es-default-0 -- curl -s -u elastic:$ES_PASSWORD http://localhost:9200/_cluster/health +``` + +--- + +## Using VDB Auth Token at Runtime via APIs (Enterprise Feature) + +When using Elasticsearch as the vector database, you can pass a per-request VDB authentication token via the HTTP `Authorization` header. The servers forward this token to Elasticsearch for that request. This enables per-user authentication or per-request scoping without changing server environment configuration. + +Prerequisite: +- Ensure Elasticsearch authentication is enabled so security is enforced. In Elasticsearch this typically requires `xpack.security.enabled=true`. See the [Elasticsearch Authentication](#elasticsearch-authentication) section above for enabling security via Docker Compose or Helm and for obtaining API keys or setting credentials. + +### Set Up Runtime Token and Endpoints + +Before making API requests with authentication, export the required environment variables. + +1. Export service endpoints: + +```bash +export INGESTOR_URL="http://localhost:8082" +export RAG_URL="http://localhost:8081" +``` + +2. Export authentication token: + +Runtime authentication via the `Authorization` header only supports Elasticsearch API keys. Export your API key token: + +```bash +# Export your bearer token +export ES_VDB_TOKEN="your-bearer-token" +``` + +:::{note} +Bearer token authentication (OAuth/OIDC/SAML) is an enterprise support feature and not available in the free version of Elasticsearch. For most use cases, use Elasticsearch API keys as shown in [Get an Elasticsearch API Key](#get-an-elasticsearch-api-key) above. +::: + +### Header Format + +Use bearer authentication in your API requests: + +``` +Authorization: Bearer +``` + +### Ingestor Server Examples + +- List documents: + +```bash +curl -G "$INGESTOR_URL/v1/documents" \ + -H "Authorization: Bearer ${ES_VDB_TOKEN}" \ + --data-urlencode "collection_name=es_demo_collection" +``` + +- Delete a collection: + +```bash +curl -X DELETE "$INGESTOR_URL/v1/collections" \ + -H "Authorization: Bearer ${ES_VDB_TOKEN}" \ + --data-urlencode "collection_names=es_demo_collection" +``` + +:::{note} +You can also set `vdb_endpoint` in your request payload to override the configured `APP_VECTORSTORE_URL`. +::: + +### RAG Server Examples + +- Search: + +```bash +curl -X POST "$RAG_URL/v1/search" \ + -H "Authorization: Bearer ${ES_VDB_TOKEN}" \ + -H "Content-Type: application/json" \ + -d '{ + "query": "what is vector search?", + "use_knowledge_base": true, + "collection_names": ["es_demo_collection"], + "vdb_endpoint": "'"$APP_VECTORSTORE_URL"'", + "reranker_top_k": 0, + "vdb_top_k": 3 + }' +``` + +- Generate with streaming: + +```bash +curl -N -X POST "$RAG_URL/v1/generate" \ + -H "Authorization: Bearer ${ES_VDB_TOKEN}" \ + -H "Content-Type: application/json" \ + -d '{ + "messages": [{"role":"user","content":"Give a short summary of vector databases"}], + "use_knowledge_base": true, + "collection_names": ["es_demo_collection"], + "vdb_endpoint": "'"$APP_VECTORSTORE_URL"'", + "reranker_top_k": 0, + "vdb_top_k": 3 + }' +``` + +### Troubleshooting +- If you receive authentication/authorization errors from Elasticsearch, verify your token (API key validity, scopes, and expiration). +- Ensure the server is not also configured with conflicting credentials for the same request. +- Confirm that `APP_VECTORSTORE_NAME=elasticsearch` and `APP_VECTORSTORE_URL` are set correctly. + +--- + +## Related Topics + +- [Vector database configuration](change-vectordb.md) (default Elasticsearch setup, switching to Milvus) +- [NVIDIA RAG Blueprint Documentation](readme.md) +- [Best Practices for Common Settings](accuracy_perf.md) +- [Troubleshoot](troubleshooting.md) + +## Reference + +- Elastic: [GPU vector indexing](https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/gpu-vector-indexing) +- Blueprint: [Vector database configuration](change-vectordb.md) diff --git a/docs/enable-nemotron-thinking.md b/docs/enable-nemotron-thinking.md index dc95b4285..94e898d53 100644 --- a/docs/enable-nemotron-thinking.md +++ b/docs/enable-nemotron-thinking.md @@ -39,7 +39,9 @@ Set the following environment variables on the RAG server container (via Docker : Low-effort reasoning mode for faster, cheaper responses with shorter reasoning. Only used when `LLM_ENABLE_THINKING` is `true`. Default: `false`. **`FILTER_THINK_TOKENS`** -: Filter content between `` and `` tags in model responses. Keep `true` for production to return only the final answer. Set `false` to see the full reasoning process. Default: `true`. +: Filter reasoning out of the user-facing `content` stream. Reasoning emitted +in `reasoning_content` or inside `...` is surfaced in the +response's `reasoning_content` field. Default: `true`. :::{important} **Disabling reasoning:** To disable reasoning, set **`LLM_ENABLE_THINKING=false`**. Setting `LLM_REASONING_BUDGET=0` alone does not disable reasoning: when the budget is `0`, the RAG pipeline does not pass it to the LLM, and the model uses its default reasoning behavior. Always set `LLM_ENABLE_THINKING=false` to turn reasoning off. @@ -116,13 +118,13 @@ The key differences for the 30B model are the following: - Uses only `max_thinking_tokens` (not `min_thinking_tokens`) - Reasoning is available in the model output's `reasoning_content` field (not wrapped in `` tags) -- The `reasoning_content` field is present in the model output but isn't exposed in the generate API response -- No filtering is needed because reasoning is already separated from the final answer +- The `reasoning_content` field is exposed in streamed generate API responses +- Reasoning is separated from the final answer; clients can decide whether to render it ::: -## Enable Reasoning for Nemotron 1.5 +## Enable Reasoning for Nemotron 3 Super -Reasoning in Nemotron 1.5 models (such as `nvidia/llama-3.3-nemotron-super-49b-v1.5`) is controlled through system prompts. The model switches between reasoning and non-reasoning modes using `/think` and `/no_think` directives. +Reasoning in Nemotron 3 Super models (such as `nvidia/nemotron-3-super-120b-a12b`) is controlled through system prompts. The model switches between reasoning and non-reasoning modes using `/think` and `/no_think` directives. ### Update the System Prompt @@ -166,13 +168,12 @@ export LLM_TOP_P=0.95 ### Filter Reasoning Tokens -By default, reasoning tokens (shown between `` tags) are filtered out so only the final answer is returned in the model response. +By default, reasoning tokens (shown between `` tags) are filtered out of +the user-facing `content` stream and returned in the `reasoning_content` field. -To view the full reasoning process including the `` tags in the model response, use the following code. - -```bash -export FILTER_THINK_TOKENS=false -``` +Clients that want to render the reasoning process should read +`choices[].delta.reasoning_content` from the streamed generate response. The +final answer continues to stream through `choices[].delta.content`. :::{note} For most production use cases, keep `FILTER_THINK_TOKENS=true` (default) to provide cleaner responses to end users. @@ -224,9 +225,8 @@ The key differences for the 9B model are the following: - Requires both `min_thinking_tokens` and `max_thinking_tokens` parameters -- Reasoning is available in the model output's `reasoning_content` field (not wrapped in `` tags) -- The `reasoning_content` field is present in the model output but isn't exposed in the generate API response -- No filtering is needed because reasoning is already separated from the final answer +- Reasoning is exposed in streamed generate API responses through the `reasoning_content` field +- Reasoning is separated from the final answer; clients can decide whether to render it ::: ## Deploy with Reasoning Enabled diff --git a/docs/evaluate.md b/docs/evaluate.md index 2485682dc..ab12adc2c 100644 --- a/docs/evaluate.md +++ b/docs/evaluate.md @@ -31,7 +31,9 @@ You can also evaluate how well the retrieval system performs by using the [Conte For more information, refer to the notebook [Evaluate Your RAG Pipeline with Ragas: Recall](https://github.com/NVIDIA-AI-Blueprints/rag/blob/main/notebooks/evaluation_02_recall.ipynb). +## Filesystem benchmark CLI +The repository also includes a command-line driver, `scripts/eval/evaluate_rag.py`, for on-disk dataset roots (`corpus/` plus `train.json`). Its Python dependencies are declared in `scripts/eval/pyproject.toml`. From the repository root, use `uv sync --project scripts/eval` and `uv run --project scripts/eval python scripts/eval/evaluate_rag.py` (see [scripts/eval/README.md](https://github.com/NVIDIA-AI-Blueprints/rag/blob/main/scripts/eval/README.md) for the full contract and examples). ## Related Topics diff --git a/docs/image_captioning.md b/docs/image_captioning.md index bb4152d54..6631e8452 100644 --- a/docs/image_captioning.md +++ b/docs/image_captioning.md @@ -33,8 +33,8 @@ For this feature, use H100 or A100 GPUs instead. *Example Output* ```output - NAMES STATUS - nemotron-3-nano-omni-30b-a3b-reasoning Up 5 minutes (healthy) + NAMES STATUS + nemotron-3-nano-omni-30b-a3b-reasoning Up 5 minutes (healthy) ``` 3. Enable image captioning @@ -72,12 +72,13 @@ export APP_NVINGEST_CAPTIONMODELNAME="" To enable image captioning in Helm-based deployments by using an on-prem VLM model, use the following procedure. -1. Modify [`values.yaml`](../deploy/helm/nvidia-blueprint-rag/values.yaml) to enable image captioning: +1. Modify [`values.yaml`](../deploy/helm/nvidia-blueprint-rag/values.yaml) to enable image captioning. The captioning model is served by a dedicated `nim-vlm-captioning` NIM (`nvidia/nemotron-nano-12b-v2-vl`), which is independent of the `nim-vlm` generation NIM: ```yaml - # Enable VLM NIM for image captioning - nim-vlm: - enabled: true + # Enable the dedicated VLM captioning NIM for image captioning at ingestion + nimOperator: + nim-vlm-captioning: + enabled: true # Configure ingestor-server for image captioning ingestor-server: @@ -86,8 +87,8 @@ To enable image captioning in Helm-based deployments by using an on-prem VLM mod # === Image Captioning === APP_NVINGEST_EXTRACTIMAGES: "True" - APP_NVINGEST_CAPTIONENDPOINTURL: "http://nim-vlm:8000/v1/chat/completions" - APP_NVINGEST_CAPTIONMODELNAME: "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning" + APP_NVINGEST_CAPTIONENDPOINTURL: "http://nim-vlm-captioning:8000/v1/chat/completions" + APP_NVINGEST_CAPTIONMODELNAME: "nvidia/nemotron-nano-12b-v2-vl" ``` 2. Apply the updated Helm chart: diff --git a/docs/index.md b/docs/index.md index fc5df2ecf..dd56f42c8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -52,6 +52,7 @@ For detailed requirements, refer to [Support Matrix](support-matrix.md). - [Deploy on Kubernetes with Helm](deploy-helm.md) - [Deploy on Kubernetes with Helm from the repository](deploy-helm-from-repo.md) - [Deploy on Kubernetes with Helm and MIG Support](mig-deployment.md) +- [Deploy on OpenShift with Helm](deploy-helm-openshift.md) - [Deploy Retrieval-Only Mode](retrieval-only-deployment.md) @@ -68,6 +69,7 @@ After you deploy the RAG blueprint, you can customize it for your use cases. - Common configurations - [Best Practices for Common Settings](accuracy_perf.md) + - [Agentic RAG](agentic-rag.md) - [Change the LLM or Embedding Model](change-model.md) - [Customize LLM Parameters at Runtime](llm-params.md) - [Customize Prompts](prompt-customization.md) @@ -85,7 +87,7 @@ After you deploy the RAG blueprint, you can customize it for your use cases. - [Continuous Ingestion from Object Storage](continuous-ingestion-object-storage.md) - [Custom Metadata Support](custom-metadata.md) - [File System Access to Extraction Results](mount-ingestor-volume.md) - - [Multimodal Embedding Support (Early Access)](vlm-embed.md) + - [Multimodal Retriever — VLM Embedding & VLM Reranker (Early Access)](multimodal-retriever.md) - [OCR Configuration Guide](nemoretriever-ocr.md) - [Enhanced PDF Extraction](nemotron-parse-extraction.md) - [Text-Only Ingestion](text_only_ingest.md) @@ -99,6 +101,7 @@ After you deploy the RAG blueprint, you can customize it for your use cases. - [Change the Vector Database](change-vectordb.md) - [Hybrid Search](hybrid_search.md) - [Milvus Configuration](milvus-configuration.md) + - [Elasticsearch Configuration](elasticsearch-configuration.md) - [Query Decomposition](query_decomposition.md) @@ -114,6 +117,7 @@ After you deploy the RAG blueprint, you can customize it for your use cases. - [Evaluate Your NVIDIA RAG Blueprint System](evaluate.md) - [RAG Accuracy Benchmarks](accuracy-benchmarks.md) - [RAG Performance Benchmarks](perf-benchmarks.md) + - [Benchmark the Performance of Your RAG System](performance-benchmarking.md) - Governance @@ -186,6 +190,7 @@ After you deploy the RAG blueprint, you can customize it for your use cases. Deploy on Kubernetes with Helm Deploy on Kubernetes with Helm from the repository Deploy on Kubernetes with Helm and MIG Support + Deploy on OpenShift with Helm Deploy Retrieval-Only Mode ``` @@ -197,6 +202,7 @@ After you deploy the RAG blueprint, you can customize it for your use cases. :hidden: Best Practices for Common Settings + Agentic RAG Change the Model Customize Parameters Customize Prompts @@ -220,7 +226,7 @@ After you deploy the RAG blueprint, you can customize it for your use cases. Custom metadata Support Data Catalog for Collections and Documents File System Access to Results - Multimodal Embedding Support (Early Access) + Multimodal Retriever — VLM Embedding & VLM Reranker (Early Access) OCR Configuration Guide Enhanced PDF Extraction Standalone NeMo Retriever Library @@ -238,6 +244,7 @@ After you deploy the RAG blueprint, you can customize it for your use cases. Change the Vector Database Hybrid Search Milvus Configuration + Elasticsearch Configuration Query Decomposition ``` @@ -263,6 +270,7 @@ After you deploy the RAG blueprint, you can customize it for your use cases. Evaluate Your RAG System RAG Accuracy Benchmarks RAG Performance Benchmarks + RAG Performance Benchmarks ``` diff --git a/docs/mig-deployment.md b/docs/mig-deployment.md index c1761383f..cab9a6cb7 100644 --- a/docs/mig-deployment.md +++ b/docs/mig-deployment.md @@ -71,6 +71,17 @@ For monitoring deployment progress, refer to [Deploy on Kubernetes with Helm](./ For more details, see instructions [here](https://docs.nvidia.com/nim-operator/latest/install.html). +11. Install the ECK operator. Elasticsearch is the default vector database for this chart; the ECK operator manages Elasticsearch on Kubernetes. + + ```sh + helm repo add elastic https://helm.elastic.co + helm repo update + helm install elastic-operator elastic/eck-operator -n elastic-system --create-namespace + ``` + + If you switch from the default stack to Milvus or another standalone backend and turn off the chart-managed Elasticsearch, the ECK operator is no longer required. See [Vector database configuration](change-vectordb.md) for details. + + For verification commands and Elasticsearch tuning in Helm, see [Vector database configuration](change-vectordb.md). ## Step 1: Enable MIG with Mixed Strategy @@ -100,11 +111,10 @@ For monitoring deployment progress, refer to [Deploy on Kubernetes with Helm](./ ## Step 2: Apply the MIG configuration Edit the MIG configuration file [`mig-config-h100.yaml`](../deploy/helm/mig-slicing/mig-config-h100.yaml) to adjust the slicing pattern as needed. -The following example enables a custom configuration with mixed MIG slice sizes on the same GPU. - +The default configuration assumes a 5×H100 80GB node and reserves three full GPUs (two for the LLM and one for the embedding-VLM) while MIG-slicing the rest for the smaller NIMs. :::{note} -This example uses a custom slicing strategy: 7 slices of 1g.10gb on GPU 0, mixed slices (2x 1g.20gb + 1x 3g.40gb) on GPU 1, and 1 slice of 7g.80gb on GPU 3. This demonstrates the ability to combine different MIG slice sizes on a single GPU for optimal resource utilization. +The default LLM `nemotron-3-super-120b-a12b` runs with vLLM and `tensorParallelism=2`, which needs two physical GPUs with NVLink. Those two GPUs (GPU 0,1) are kept MIG-disabled. GPU 3 is also MIG-disabled and dedicated as a full GPU to the embedding-VLM NIM for higher throughput on the vision tower. GPU 2 is MIG-sliced to host OCR + page/graphic/table, and GPU 4 is MIG-sliced to host the reranker. This requires the `mixed` MIG strategy (already set in Step 1) so the node advertises both `nvidia.com/gpu` and `nvidia.com/mig-*` resources. ::: ```yaml @@ -120,20 +130,21 @@ data: - devices: all mig-enabled: false - custom-7x1g10-2x1g20-1x3g40-1x7g80: - - devices: [0] - mig-enabled: true - mig-devices: - "1g.10gb": 7 - - devices: [1] + custom-h100-5gpu-llm2full-embed1full: + - devices: [0, 1] + mig-enabled: false + - devices: [2] mig-enabled: true mig-devices: - "1g.20gb": 2 "3g.40gb": 1 + "1g.10gb": 4 - devices: [3] + mig-enabled: false + - devices: [4] mig-enabled: true mig-devices: - "7g.80gb": 1 + "3g.40gb": 1 + "1g.20gb": 2 ``` Apply the custom MIG configuration configMap to the node and update the ClusterPolicy, by running the following code. @@ -148,20 +159,20 @@ kubectl patch clusterpolicies.nvidia.com/cluster-policy \ Label the node with MIG configuration, by running the following code. ```bash -kubectl label nodes nvidia.com/mig.config=custom-7x1g10-2x1g20-1x3g40-1x7g80 --overwrite +kubectl label nodes nvidia.com/mig.config=custom-h100-5gpu-llm2full-embed1full --overwrite ``` :::{important} **For NVIDIA RTX6000 Pro Deployments:** -Use [`mig-config-rtx6000.yaml`](../deploy/helm/mig-slicing/mig-config-rtx6000.yaml) instead: +Use [`mig-config-rtx6000.yaml`](../deploy/helm/mig-slicing/mig-config-rtx6000.yaml) instead. The same "two full GPUs for LLM + MIG-slice the rest" pattern applies, mapped onto the RTX PRO 6000 Blackwell MIG profiles. This path is a logical mirror of the H100 layout and has not been hardware-verified. ```bash kubectl apply -n nvidia-gpu-operator -f mig-slicing/mig-config-rtx6000.yaml kubectl patch clusterpolicies.nvidia.com/cluster-policy \ --type='json' \ -p='[{"op":"replace", "path":"/spec/migManager/config/name", "value":"custom-mig-config"}]' -kubectl label nodes nvidia.com/mig.config=custom-rtx6000-4x1g24-2x1g24-1x2g48-1x4g96 --overwrite +kubectl label nodes nvidia.com/mig.config=custom-rtx6000-llm2full-1x2g48-2x1g24-4x1g24 --overwrite ``` ::: @@ -175,10 +186,10 @@ You should see output similar to the following. ```json "nvidia.com/mig.config.state": "success" -"nvidia.com/mig-1g.10gb.count": "7" +"nvidia.com/gpu.count": "3" +"nvidia.com/mig-3g.40gb.count": "2" +"nvidia.com/mig-1g.10gb.count": "4" "nvidia.com/mig-1g.20gb.count": "2" -"nvidia.com/mig-3g.40gb.count": "1" -"nvidia.com/mig-7g.80gb.count": "1" ``` @@ -188,7 +199,7 @@ You should see output similar to the following. Run the following code to install the RAG Blueprint Helm Chart. ```bash -helm upgrade --install rag -n rag https://helm.ngc.nvidia.com/nvidia/blueprint/charts/nvidia-blueprint-rag-v2.5.1.tgz \ +helm upgrade --install rag -n rag https://helm.ngc.nvidia.com/nvstaging/blueprint/charts/nvidia-blueprint-rag-v2.6.0.tgz \ --username '$oauthtoken' \ --password "${NGC_API_KEY}" \ --set imagePullSecret.password=$NGC_API_KEY \ @@ -202,7 +213,7 @@ helm upgrade --install rag -n rag https://helm.ngc.nvidia.com/nvidia/blueprint/c If you are deploying on NVIDIA RTX6000 Pro GPUs (instead of H100 GPUs), use [`values-mig-rtx6000.yaml`](../deploy/helm/mig-slicing/values-mig-rtx6000.yaml) and [`mig-config-rtx6000.yaml`](../deploy/helm/mig-slicing/mig-config-rtx6000.yaml) which include the RTX6000-specific MIG profiles and NIM LLM model configuration. ```sh -helm upgrade --install rag -n rag https://helm.ngc.nvidia.com/nvidia/blueprint/charts/nvidia-blueprint-rag-v2.5.1.tgz \ +helm upgrade --install rag -n rag https://helm.ngc.nvidia.com/nvstaging/blueprint/charts/nvidia-blueprint-rag-v2.6.0.tgz \ --username '$oauthtoken' \ --password "${NGC_API_KEY}" \ --set imagePullSecret.password=$NGC_API_KEY \ @@ -234,23 +245,20 @@ You should see output similar to the following. ``` Resource Requested Limit Allocatable Free -nvidia.com/mig-1g.10gb (86%) 6.0 (86%) 6.0 7.0 1.0 -├─ milvus-standalone-... 1.0 1.0 -├─ nemotron-embedding-ms-... 1.0 1.0 -├─ rag-nv-ingest-... 1.0 1.0 -├─ nemoretriever-graphic-elements-v1-... 1.0 1.0 -├─ nemoretriever-page-elements-v3-... 1.0 1.0 -└─ nemoretriever-table-structure-v1-... 1.0 1.0 - -nvidia.com/mig-1g.20gb (100%) 2.0 (100%) 2.0 2.0 0.0 -├─ nemotron-ranking-ms-... 1.0 1.0 -└─ 1.0 1.0 - -nvidia.com/mig-3g.40gb (100%) 1.0 (100%) 1.0 1.0 0.0 -└─ nemoretriever-ocr-v1-... 1.0 1.0 - -nvidia.com/mig-7g.80gb (100%) 1.0 (100%) 1.0 1.0 0.0 -└─ nim-llm-... 1.0 1.0 +nvidia.com/gpu (100%) 3.0 (100%) 3.0 3.0 0.0 +├─ nim-llm-... 2.0 2.0 +└─ nemotron-vlm-embedding-ms-... 1.0 1.0 + +nvidia.com/mig-3g.40gb (50%) 1.0 (50%) 1.0 2.0 1.0 +└─ nemotron-ocr-v1-... 1.0 1.0 + +nvidia.com/mig-1g.10gb (75%) 3.0 (75%) 3.0 4.0 1.0 +├─ nemotron-graphic-elements-v1-... 1.0 1.0 +├─ nemotron-page-elements-v3-... 1.0 1.0 +└─ nemotron-table-structure-v1-... 1.0 1.0 + +nvidia.com/mig-1g.20gb (50%) 1.0 (50%) 1.0 2.0 1.0 +└─ nemotron-ranking-ms-... 1.0 1.0 ``` @@ -268,21 +276,22 @@ You should see output similar to the following. ``` GPU 0: NVIDIA H100 80GB HBM3 (UUID: ...) - MIG 1g.10gb Device 0: ... +GPU 1: NVIDIA H100 80GB HBM3 (UUID: ...) +GPU 2: NVIDIA H100 80GB HBM3 (UUID: ...) + MIG 3g.40gb Device 0: ... MIG 1g.10gb Device 1: ... MIG 1g.10gb Device 2: ... MIG 1g.10gb Device 3: ... MIG 1g.10gb Device 4: ... - MIG 1g.10gb Device 5: ... - MIG 1g.10gb Device 6: ... -GPU 1: NVIDIA H100 80GB HBM3 (UUID: ...) - MIG 1g.20gb Device 0: ... - MIG 1g.20gb Device 1: ... - MIG 3g.40gb Device 2: ... GPU 3: NVIDIA H100 80GB HBM3 (UUID: ...) - MIG 7g.80gb Device 0: ... +GPU 4: NVIDIA H100 80GB HBM3 (UUID: ...) + MIG 3g.40gb Device 0: ... + MIG 1g.20gb Device 1: ... + MIG 1g.20gb Device 2: ... ``` +GPUs 0, 1, and 3 are reported as whole devices because MIG is disabled on them — GPUs 0 and 1 are reserved for `nim-llm` (vLLM tp=2), and GPU 3 is dedicated to the embedding-VLM NIM. GPU 4 is MIG-sliced and currently hosts only the reranker (1× 1g.20gb); the remaining 3g.40gb and second 1g.20gb slices are spare capacity for future workloads. + ## Step 6: Follow the Remaining Instructions diff --git a/docs/milvus-configuration.md b/docs/milvus-configuration.md index 2ef1dfccc..5475139de 100644 --- a/docs/milvus-configuration.md +++ b/docs/milvus-configuration.md @@ -4,17 +4,23 @@ --> # Milvus Configuration for NVIDIA RAG Blueprint -You can configure how Milvus works with your [NVIDIA RAG Blueprint](readme.md). +:::{note} +Milvus is an optional vector database for the NVIDIA RAG Blueprint. The default VDB is Elasticsearch. Use this guide if you want to switch to Milvus, or if you already use Milvus and need to tune GPU/CPU behavior, endpoints, authentication, or runtime API tokens. For enabling Milvus and wiring `APP_VECTORSTORE_*`, start with the **Switching to Milvus** section in [Vector database configuration](change-vectordb.md#switching-to-milvus). +::: + +This document describes **optional Milvus-specific** settings. It does not replace the default Elasticsearch path—see [Vector database configuration](change-vectordb.md) for the standard vector database and for switching between backends. ## GPU to CPU Mode Switch -Milvus uses GPU acceleration by default for vector operations. Switch to CPU mode if you encounter: +When Milvus is running, it uses GPU acceleration by default for vector operations. Switch to CPU mode if you encounter: - GPU memory constraints - Development without GPU support ## Docker compose +The commands below use the `milvus` Compose profile so the Milvus, etcd, and SeaweedFS object-store services start. Ensure `APP_VECTORSTORE_NAME` and `APP_VECTORSTORE_URL` target Milvus if you have not already switched from Elasticsearch ([Vector database configuration](change-vectordb.md)). + ### Configuration Steps #### 1. Update Docker Compose Configuration (vectordb.yaml) @@ -58,11 +64,11 @@ export APP_VECTORSTORE_ENABLEGPUINDEX=False After making the configuration changes and setting environment variables, restart the services: ```bash -# 1. Stop existing services -docker compose -f deploy/compose/vectordb.yaml down +# 1. Stop existing services (Milvus profile) +docker compose -f deploy/compose/vectordb.yaml --profile milvus down # 2. Start Milvus and dependencies -docker compose -f deploy/compose/vectordb.yaml up -d +docker compose -f deploy/compose/vectordb.yaml --profile milvus up -d # 3. Now start the ingestor server docker compose -f deploy/compose/docker-compose-ingestor-server.yaml up -d @@ -78,9 +84,10 @@ To configure Milvus to run in CPU mode when deploying with Helm: ```yaml envVars: - APP_VECTORSTORE_ENABLEGPUSEARCH: "False" + APP_VECTORSTORE_ENABLEGPUSEARCH: "False" + ingestor-server: - envVars: + envVars: APP_VECTORSTORE_ENABLEGPUSEARCH: "False" APP_VECTORSTORE_ENABLEGPUINDEX: "False" ``` @@ -136,7 +143,7 @@ Example sequence: ```bash # Start/ensure Milvus is up (GPU image if you want GPU indexing) -docker compose -f deploy/compose/vectordb.yaml up -d +docker compose -f deploy/compose/vectordb.yaml --profile milvus up -d # Set env vars and start the ingestor (GPU indexing + CPU search) export APP_VECTORSTORE_ENABLEGPUSEARCH=False @@ -181,32 +188,28 @@ When `adapt_for_cpu` is in effect, your search requests must supply an `ef` para ## (Optional) Customize the Milvus Endpoint -To use a custom Milvus endpoint, use the following procedure. +Use this procedure when the RAG stack should talk to a Milvus instance you operate separately (outside the chart’s Milvus subchart), for example a shared cluster or a different namespace. -1. Update the `APP_VECTORSTORE_URL` and `MINIO_ENDPOINT` variables in both the RAG server and the ingestor server sections in [values.yaml](../deploy/helm/nvidia-blueprint-rag/values.yaml). Your changes should look similar to the following. +1. Update the `APP_VECTORSTORE_URL` and `OBJECTSTORE_ENDPOINT` variables in both the RAG server and the ingestor server sections in [values.yaml](../deploy/helm/nvidia-blueprint-rag/values.yaml). The ingestor passes `OBJECTSTORE_ENDPOINT` to NV-Ingest store tasks by default, so set `NVINGEST_OBJECTSTORE_ENDPOINT` only when the NV-Ingest runtime must use a different object-store address. ```yaml - env: + envVars: # ... existing code ... APP_VECTORSTORE_URL: "http://your-custom-milvus-endpoint:19530" - MINIO_ENDPOINT: "http://your-custom-minio-endpoint:9000" + OBJECTSTORE_ENDPOINT: "http://your-custom-object-store-endpoint:9000" # ... existing code ... ingestor-server: - env: - # ... existing code ... - APP_VECTORSTORE_URL: "http://your-custom-milvus-endpoint:19530" - MINIO_ENDPOINT: "http://your-custom-minio-endpoint:9000" - # ... existing code ... - - nv-ingest: envVars: # ... existing code ... - MINIO_INTERNAL_ADDRESS: "http://your-custom-minio-endpoint:9000" + APP_VECTORSTORE_URL: "http://your-custom-milvus-endpoint:19530" + OBJECTSTORE_ENDPOINT: "http://your-custom-object-store-endpoint:9000" + # Optional: only if NV-Ingest needs a different route to the same object store + # NVINGEST_OBJECTSTORE_ENDPOINT: "http://your-nv-ingest-object-store-endpoint:9000" # ... existing code ... ``` -2. Disable the Milvus deployment. Set `milvusDeployed: false` in the `nv-ingest.milvusDeployed` section to prevent deploying the default Milvus instance. Your changes should look like the following. +2. Turn off the in-chart Milvus deployment so Helm does not create a second Milvus alongside your external endpoint. Set `nv-ingest.milvusDeployed` to `false`: ```yaml nv-ingest: @@ -220,13 +223,13 @@ To use a custom Milvus endpoint, use the following procedure. ## Milvus Authentication -Enable authentication for Milvus to secure your vector database. +Enable authentication for Milvus to secure the Milvus deployment you use with the blueprint (only applicable when Milvus is your configured vector database). ### Docker Compose #### 1. Configure Milvus Authentication -Download the default Milvus configuration file (matching the version used in `deploy/compose/vectordb.yaml`): +Download the Milvus configuration file from the upstream release (matching the version used in `deploy/compose/vectordb.yaml`): ```bash wget https://raw.githubusercontent.com/milvus-io/milvus/v2.6.5/configs/milvus.yaml -O deploy/compose/milvus.yaml ``` @@ -252,7 +255,7 @@ volumes: Start Milvus with authentication: ```bash -docker compose -f deploy/compose/vectordb.yaml up -d +docker compose -f deploy/compose/vectordb.yaml --profile milvus up -d ``` Set authentication credentials and start RAG services: @@ -265,17 +268,19 @@ docker compose -f deploy/compose/docker-compose-rag-server.yaml up -d ``` :::{important} -**Set the password before deployment** as it persists in the etcd volume. To change the password after deployment, stop the containers, remove the volumes, and restart: +**Set the password before deployment** as it persists in the etcd volume. To change the password after deployment, stop the containers, remove the `rag-vol-*` Docker volumes, and restart: ```bash -docker compose -f deploy/compose/vectordb.yaml down -rm -rf deploy/compose/volumes/milvus deploy/compose/volumes/minio deploy/compose/volumes/etcd -docker compose -f deploy/compose/vectordb.yaml up -d +docker compose -f deploy/compose/vectordb.yaml --profile milvus down +docker volume rm rag-vol-milvus rag-vol-etcd rag-vol-seaweedfs +docker compose -f deploy/compose/vectordb.yaml --profile milvus up -d ``` + +For more granular control (for example, wiping only Milvus + etcd and keeping the SeaweedFS object store) see [Manage Persistent Data Volumes](troubleshooting.md#manage-persistent-data-volumes). ::: :::{warning} -**Data Loss Warning:** Removing volumes deletes **all ingested data** (Milvus vectors and MinIO files). You must re-ingest all documents afterward. Ensure you have backups before proceeding. +**Data Loss Warning:** Removing these volumes deletes **all ingested data** (Milvus vectors, object-store files, etcd state). You must re-ingest all documents afterward. Ensure you have backups before proceeding. ::: ### Helm Chart @@ -299,7 +304,7 @@ nv-ingest: (Optional) For more details on configuring Milvus with Helm, refer to the [Milvus Helm configuration](https://milvus.io/docs/configure-helm.md). :::{important} -**Change the password before starting the Helm deployment.** Once the deployment is started, the default password becomes persistent in the etcd volume. To change the password after deployment: +Change the password before starting the Helm deployment. Once the deployment is started, the root password you set becomes persistent in the etcd volume. To change the password after deployment: 1. Uninstall the deployment: ```bash @@ -344,7 +349,9 @@ For detailed HELM deployment instructions, see [Helm Deployment Guide](deploy-he ## Using VDB Auth Token at Runtime via APIs -NVIDIA RAG Blueprint servers accept a Vector DB (VDB) authentication token via the HTTP `Authorization` header at runtime. This header is forwarded to Milvus for auth-protected operations. This feature assumes you have prior knowledge of creating Milvus users, creating/granting roles, and granting appropriate privileges. Once you have configured users, roles, and privileges in Milvus, you can use these auth tokens in the `Authorization` header to enforce access control. +When Milvus is the active vector database (`APP_VECTORSTORE_NAME=milvus`), NVIDIA RAG Blueprint servers accept a Vector DB (VDB) authentication token via the HTTP `Authorization` header at runtime. This header is forwarded to Milvus for auth-protected operations. If you use Elasticsearch as the default VDB, runtime bearer tokens are handled differently—refer to [Elasticsearch Configuration](elasticsearch-configuration.md#using-vdb-auth-token-at-runtime-via-apis-enterprise-feature). + +This Milvus flow assumes you already know how to create Milvus users, roles, and privileges. After you configure those in Milvus, you can pass auth tokens in the `Authorization` header to enforce access control. Access permissions are enforced based on the privileges assigned to each user: - **Read operations**: Users without privileges such as `Load`, `Search`, and `Query` will not be able to read data from collections. @@ -458,18 +465,18 @@ If you encounter GPU_CAGRA errors that cannot be resolved by when switching to C 1. Stop all running services: ```bash - docker compose -f deploy/compose/vectordb.yaml down + docker compose -f deploy/compose/vectordb.yaml --profile milvus down docker compose -f deploy/compose/docker-compose-ingestor-server.yaml down ``` -2. Delete the Milvus volumes directory: +2. Delete the Milvus-related `rag-vol-*` Docker volumes (see [Manage Persistent Data Volumes](troubleshooting.md#manage-persistent-data-volumes) for selective wipes): ```bash - rm -rf deploy/compose/volumes + docker volume rm rag-vol-milvus rag-vol-etcd rag-vol-seaweedfs ``` 3. Restart the services: ```bash - docker compose -f deploy/compose/vectordb.yaml up -d + docker compose -f deploy/compose/vectordb.yaml --profile milvus up -d docker compose -f deploy/compose/docker-compose-ingestor-server.yaml up -d ``` @@ -480,8 +487,9 @@ This will delete all existing vector data, so ensure you have backups if needed. ## Related Topics +- [Vector database configuration](change-vectordb.md) (Elasticsearch default, switching to Milvus) - [NVIDIA RAG Blueprint Documentation](readme.md) - [Best Practices for Common Settings](accuracy_perf.md). - [RAG Pipeline Debugging Guide](debugging.md) - [Troubleshoot](troubleshooting.md) -- [Notebooks](notebooks.md) \ No newline at end of file +- [Notebooks](notebooks.md) diff --git a/docs/model-profiles.md b/docs/model-profiles.md index 24d981703..ab47e5a31 100644 --- a/docs/model-profiles.md +++ b/docs/model-profiles.md @@ -26,7 +26,7 @@ To see all available profiles for your specific hardware configuration, run the ```bash USERID=$(id -u) docker run --rm --gpus all \ -v ~/.cache/model-cache:/opt/nim/.cache \ - nvcr.io/nim/nvidia/llama-3.3-nemotron-super-49b-v1.5:1.14.0 \ + nvcr.io/nim/nvidia/nemotron-3-super-120b-a12b:1.8.0 \ list-model-profiles ``` @@ -54,7 +54,7 @@ To set a specific model profile in Docker Compose, add the `NIM_MODEL_PROFILE` e ```yaml nim-llm: container_name: nim-llm-ms - image: nvcr.io/nim/nvidia/llama-3.3-nemotron-super-49b-v1.5:1.14.0 + image: nvcr.io/nim/nvidia/nemotron-3-super-120b-a12b:1.8.0 # ... other configuration ... environment: NGC_API_KEY: ${NGC_API_KEY} @@ -80,19 +80,18 @@ nimOperator: service: name: "nim-llm" image: - repository: nvcr.io/nim/nvidia/llama-3.3-nemotron-super-49b-v1.5 + repository: nvcr.io/nim/nvidia/nemotron-3-super-120b-a12b pullPolicy: IfNotPresent - tag: "1.14.0" + tag: "1.8.0" resources: limits: - nvidia.com/gpu: 1 + nvidia.com/gpu: 2 requests: - nvidia.com/gpu: 1 + nvidia.com/gpu: 2 model: - engine: tensorrt_llm + engine: vllm precision: "fp8" - qosProfile: "throughput" - tensorParallelism: "1" + tensorParallelism: "2" gpus: - product: "rtx6000_blackwell_sv" # Change based on your GPU storage: @@ -108,7 +107,7 @@ nimOperator: - name: NIM_TRITON_LOG_VERBOSE value: "1" - name: NIM_SERVED_MODEL_NAME - value: "nvidia/llama-3.3-nemotron-super-49b-v1.5" + value: "nvidia/nemotron-3-super-120b-a12b" ``` **Key profile parameters:** diff --git a/docs/mount-ingestor-volume.md b/docs/mount-ingestor-volume.md index ff776e34d..e3205be30 100644 --- a/docs/mount-ingestor-volume.md +++ b/docs/mount-ingestor-volume.md @@ -12,39 +12,46 @@ You can mount a host directory to access extraction results from NeMo Retriever | Variable | Default | Description | |----------|---------|-------------| -| `INGESTOR_SERVER_EXTERNAL_VOLUME_MOUNT` | `./volumes/ingestor-server` | Host filesystem path | -| `INGESTOR_SERVER_DATA_DIR` | `/data/` | Container internal path | +| `INGESTOR_SERVER_DATA_DIR` | `/data/` | Container internal path. Mapped to the `rag-vol-ingestor` Docker named volume. | | `APP_NVINGEST_SAVETODISK` | `False` | Enable disk persistence | ### Setup -1. **Export environment variables:** +1. **Enable disk persistence:** ```bash # Enable disk persistence export APP_NVINGEST_SAVETODISK=True - # Set host directory path (optional - customize as needed) - export INGESTOR_SERVER_EXTERNAL_VOLUME_MOUNT=./volumes/ingestor-server - - # Set container internal path (optional - customize as needed) + # (Optional) Override container internal path export INGESTOR_SERVER_DATA_DIR=/data/ ``` -## Troubleshooting + The ingestor-server compose file already mounts `rag-vol-ingestor` at `INGESTOR_SERVER_DATA_DIR`; nothing else needs to be configured to persist results. + +## Accessing the Results from the Host + +Docker named volumes are owned by `root` on the host, so use one of the following patterns to read the files: -**Optional: Fix permissions issues** -If you encounter permission errors when accessing the volume: ```bash -sudo chown -R 1000:1000 ${INGESTOR_SERVER_EXTERNAL_VOLUME_MOUNT} -sudo chmod -R 755 ${INGESTOR_SERVER_EXTERNAL_VOLUME_MOUNT} +# Copy a single result file out of the volume: +docker run --rm -v rag-vol-ingestor:/src:ro -v "$PWD":/dst alpine \ + cp /src/nv-ingest-results//.results.jsonl /dst/ + +# List the directory tree inside the volume: +docker run --rm -v rag-vol-ingestor:/src:ro alpine ls -la /src/nv-ingest-results + +# Or copy directly from the running ingestor-server container: +docker cp ingestor-server:/data/nv-ingest-results ./nv-ingest-results ``` +See [Manage Persistent Data Volumes](troubleshooting.md#manage-persistent-data-volumes) for backup, reset, and migration commands. + ## Result Structure Results are saved as `.jsonl` files with naming convention: `{original_filename}.results.jsonl` ``` -${INGESTOR_SERVER_EXTERNAL_VOLUME_MOUNT}/ +rag-vol-ingestor:/ └── nv-ingest-results/ ├── collection_name1/ │ ├── document1.pdf.results.jsonl diff --git a/docs/multimodal-query.md b/docs/multimodal-query.md index 3c2744907..c199dc954 100644 --- a/docs/multimodal-query.md +++ b/docs/multimodal-query.md @@ -12,7 +12,7 @@ The multimodal query feature in the [NVIDIA RAG Blueprint](readme.md) enables yo This feature combines: - **VLM Embeddings**: `nvidia/llama-nemotron-embed-vl-1b-v2` for creating multimodal embeddings that understand both text and images -- **Vision-Language Model**: `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning` for generating intelligent responses based on visual and textual context (with optional reasoning trace) +- **Vision-Language Model**: `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning` for generating intelligent responses based on visual and textual context @@ -38,9 +38,7 @@ Start the Milvus vector database service: docker compose -f deploy/compose/vectordb.yaml up -d ``` -### 2. Deploy the VLM and VLM Embedding NIMs - -Deploy the Vision-Language Model and multimodal embedding services. +### 2. Deploy the Ingestion and VLM RAG NIMs Set your NGC API key (replace with your actual key): @@ -59,8 +57,8 @@ export MODEL_DIRECTORY=~/.cache/model-cache # Use `nvidia-smi` to check available GPUs and set the desired GPU ID export VLM_MS_GPU_ID=1 # Default is GPU 5; change to use a different GPU -# Deploy NIMs with VLM and VLM embedding profiles -USERID=$(id -u) docker compose --profile vlm-ingest --profile vlm-only -f deploy/compose/nims.yaml up -d +# Deploy ingestion NIMs plus the VLM RAG NIMs. +USERID=$(id -u) docker compose --profile ingest --profile vlm-rag -f deploy/compose/nims.yaml up -d ``` :::{warning} @@ -85,6 +83,11 @@ export APP_VLM_MODELNAME="nvidia/nemotron-3-nano-omni-30b-a3b-reasoning" export APP_VLM_SERVERURL="http://vlm-ms:8000/v1" export APP_LLM_SERVERURL="" +# Optional: use the same VLM for document summaries when no LLM NIM is running. +# You can also point SUMMARY_LLM* to a separate LLM or NVIDIA-hosted endpoint. +export SUMMARY_LLM="nvidia/nemotron-3-nano-omni-30b-a3b-reasoning" +export SUMMARY_LLM_SERVERURL="http://vlm-ms:8000/v1" + # Multimodal embedding model configuration export APP_EMBEDDINGS_MODELNAME="nvidia/llama-nemotron-embed-vl-1b-v2" export APP_EMBEDDINGS_SERVERURL="nemotron-vlm-embedding-ms:8000/v1" @@ -102,7 +105,8 @@ export APP_NVINGEST_STRUCTURED_ELEMENTS_MODALITY="" export APP_NVINGEST_IMAGE_ELEMENTS_MODALITY="image" export APP_NVINGEST_EXTRACTIMAGES="True" -# Disable reranker (not supported with multimodal queries) +# Disable reranker for image-query requests. Image queries use the multimodal +# vector retrieval path directly and bypass reranking. export ENABLE_RERANKER="false" export APP_RANKING_SERVERURL="" ``` @@ -170,9 +174,14 @@ Then set the VLM configuration: ```bash # VLM (Vision-Language Model) configuration - cloud hosted export APP_VLM_MODELNAME="nvidia/nemotron-3-nano-omni-30b-a3b-reasoning" -export APP_VLM_SERVERURL="https://integrate.api.nvidia.com" +export APP_VLM_SERVERURL="https://integrate.api.nvidia.com/v1" export APP_LLM_SERVERURL="" +# Optional: use the same NVIDIA-hosted VLM for document summaries. +# You can also leave SUMMARY_LLM* pointing at another supported summarizer. +export SUMMARY_LLM="nvidia/nemotron-3-nano-omni-30b-a3b-reasoning" +export SUMMARY_LLM_SERVERURL="https://integrate.api.nvidia.com/v1" + # Multimodal embedding model configuration - cloud hosted export APP_EMBEDDINGS_MODELNAME="nvidia/llama-nemotron-embed-vl-1b-v2" export APP_EMBEDDINGS_SERVERURL="https://integrate.api.nvidia.com/v1" @@ -226,9 +235,8 @@ ingestor-server Up 5 minutes compose-redis-1 Up 5 minutes rag-frontend Up 9 minutes rag-server Up 9 minutes -milvus-standalone Up 36 minutes (healthy) -milvus-minio Up 35 minutes (healthy) -milvus-etcd Up 35 minutes (healthy) +elasticsearch Up 36 minutes (healthy) +seaweedfs Up 35 minutes (healthy) ``` @@ -262,13 +270,18 @@ nvidia-nim-llama-nemotron-embed-vl-1b-v2: tag: "1.12.0" # Optional: disable the default text embedding NIM -nvidia-nim-llama-32-nv-embedqa-1b-v2: +nvidia-nim-llama-nemotron-embed-1b-v2: enabled: false # Disable LLM NIM (VLM handles generation) nim-llm: enabled: false +# Enable dedicated VLM captioning NIM (image-cap model changed after RC1) +nimOperator: + nim-vlm-captioning: + enabled: true + # Configure environment variables envVars: # VLM inference settings @@ -292,6 +305,11 @@ ingestor-server: APP_NVINGEST_IMAGE_ELEMENTS_MODALITY: "image" APP_NVINGEST_EXTRACTIMAGES: "True" + # Summary generation settings. + # Required for generate_summary=true when nim-llm is disabled. + SUMMARY_LLM: "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning" + SUMMARY_LLM_SERVERURL: "nim-vlm:8000" + # VLM embedding settings for ingestor APP_EMBEDDINGS_SERVERURL: "nemotron-vlm-embedding-ms:8000/v1" APP_EMBEDDINGS_MODELNAME: "nvidia/llama-nemotron-embed-vl-1b-v2" @@ -354,14 +372,30 @@ For details, see [User Interface for NVIDIA RAG Blueprint](user-interface.md). ### Python Client -When using the Python client, always specify `collection_names` in your query: +When using the Python client, pass image input using the OpenAI vision content +format and always specify `collection_names` in your query: ```python -# Example: Multimodal query with collection specified +import base64 +from pathlib import Path + +image_b64 = base64.b64encode(Path("Creme_clutch_purse1-small.jpg").read_bytes()).decode() +image_query = [ + {"type": "text", "text": "What material is this made of?"}, + { + "type": "image_url", + "image_url": { + "url": f"data:image/png;base64,{image_b64}", + "detail": "auto", + }, + }, +] + await rag.generate( - messages=[{"role": "user", "content": "What is this product?"}], + messages=[{"role": "user", "content": image_query}], use_knowledge_base=True, - collection_names=["your_collection_name"], # Required: specify your collection + collection_names=["your_collection_name"], + enable_reranker=False, ) ``` @@ -373,23 +407,19 @@ For a step-by-step guide with code examples covering collection creation, docume ## Limitations -- **Reranker not supported**: The reranker must be disabled (`enable_reranker: False`) for multimodal queries. +- **Image-query reranking is bypassed**: When the user query includes an image, + use `enable_reranker: False`. Image queries use the multimodal vector + retrieval path directly. - **Single-page retrieval for image queries**: When an image is included in the query, the retrieval results are constrained to content from a single page per document. Multi-page context retrieval is not supported for image-based queries. -- **Summary generation not supported**: The multimodal query pipeline replaces the LLM with a VLM for response generation, and summary generation does not work with VLMs. If you need summary generation alongside multimodal queries, you must deploy a separate LLM dedicated to `summary generation. For details, see [Summarization](summarization.md).` -- **Elasticsearch not supported**: Multimodal queries are only supported with Milvus as the vector database. Elasticsearch is not supported for multimodal query workflows. - - - ## Related Topics - [Vision-Language Model (VLM) for Generation](vlm.md) -- [VLM Embedding for Ingestion](vlm-embed.md) +- [Multimodal Retriever (VLM Embedding & VLM Reranker)](multimodal-retriever.md) - [Image Captioning Support](image_captioning.md) - [Deploy with Docker (Self-Hosted Models)](deploy-docker-self-hosted.md) - [Deploy with Docker (NVIDIA-Hosted Models)](deploy-docker-nvidia-hosted.md) - [Deploy with Helm](deploy-helm.md) - [Troubleshoot](troubleshooting.md) - [Notebooks](notebooks.md) - diff --git a/docs/multimodal-retriever.md b/docs/multimodal-retriever.md new file mode 100644 index 000000000..77a98707e --- /dev/null +++ b/docs/multimodal-retriever.md @@ -0,0 +1,359 @@ + +# Multimodal Retriever (VLM Embedding & VLM Reranker) for NVIDIA RAG Blueprint + +The multimodal retriever has two independently switchable components that together let the [NVIDIA RAG Blueprint](readme.md) embed and re-rank documents with awareness of their visual content rather than text alone: + +1. **VLM Embedding for Ingestion** — replaces the text embedder with `nvidia/llama-nemotron-embed-vl-1b-v2` so PDF pages, tables, charts, and image elements are embedded by a multimodal model. +2. **VLM Reranker** — replaces the text reranker with `nvidia/llama-nemotron-rerank-vl-1b-v2` so retrieved passages are scored using both their text and the cited images. + +Both components plug into the same retrieval pipeline and can be enabled independently or together. Pair them with [VLM-based generation](vlm.md) for a fully multimodal RAG pipeline; see [Enabling Full VLM Multimodal RAG Pipeline](vlm.md#enabling-full-vlm-multimodal-rag-pipeline) for the end-to-end picture, and [Multimodal Query Support](multimodal-query.md) for the user-facing image+text query flow. + +Requirements: an NVIDIA GPU per enabled component (H100/A100 recommended) and a valid `NGC_API_KEY`. + +--- + +# Part 1 — VLM Embedding for Ingestion (Early Access) + +This part shows how to enable and use the multimodal embedding model `nvidia/llama-nemotron-embed-vl-1b-v2` in the ingestion pipeline. + +In this section you do the following: + +- Start the VLM embedding microservice +- Configure ingestion to embed content as text or images using env vars +- Point the ingestor to the VLM embedding service and model + +:::{note} +**Early Access**: Currently, `nvidia/llama-nemotron-embed-vl-1b-v2` is in early access preview. +::: + +:::{note} +**PDF Support Only**: The VLM embedding feature is currently only supported for PDF documents. Other document formats (Word, PowerPoint, etc.) are not supported with VLM embedding. +::: + +## Limitations + +- The VLM embedding feature is experimental and responses may not be accurate. +- Summary generation doesn't work when this feature is enabled. + +## 1. Start the VLM Embedding NIM locally + +We provide a dedicated compose profile that starts only the VLM embedding service so the text embedding service does not start. +You can skip this step if you are interested in using cloud hosted endpoints. + +```bash +export USERID=$(id -u) +export NGC_API_KEY= +# Optionally select a GPU for the VLM embed service +export VLM_EMBEDDING_MS_GPU_ID= + +# Start only the VLM embedding microservice +docker compose -f deploy/compose/nims.yaml --profile vlm-embed up -d + +# Verify the service is healthy +docker ps --filter "name=nemotron-vlm-embedding-ms" --format "table {{.Names}}\t{{.Status}}" +``` + +Service details (from `deploy/compose/nims.yaml`): +- Service name: `nemotron-vlm-embedding-ms` +- Default port mapping: `9081:8000` (internal NIM port `8000`) + +## 2. Point the Ingestor to the VLM Embedding Model + +Set the ingestor's embedding endpoint and model to the VLM service and model. These env vars are read by `ingestor-server` and are also propagated to `nv-ingest-ms-runtime` so both components use the VLM embedding model. You can choose to use a cloud-hosted model endpoint as well by using the commented line. + +```bash +# Point to the required VLM embedding endpoint +export APP_EMBEDDINGS_SERVERURL="nemotron-vlm-embedding-ms:8000/v1" # For on-prem deployed +# export APP_EMBEDDINGS_SERVERURL="https://integrate.api.nvidia.com/v1" # For cloud hosted NIM +export APP_EMBEDDINGS_MODELNAME="nvidia/llama-nemotron-embed-vl-1b-v2" + +# Launch or restart the ingestor server so the new env vars take effect +docker compose -f deploy/compose/docker-compose-ingestor-server.yaml up -d +``` + +## 3. Configure How Content Is Embedded (text vs image) + +You can control what gets embedded as text or as images using these env vars: +- `APP_NVINGEST_STRUCTURED_ELEMENTS_MODALITY`: set to `image` to embed extracted tables/charts as images (keep text as text) +- `APP_NVINGEST_IMAGE_ELEMENTS_MODALITY`: set to `image` to embed page images as images +- `APP_NVINGEST_EXTRACTPAGEASIMAGE`: set to `True` to treat each page as a single image (experimental) + +Below are common configurations. + +### Baseline: All extracted content embedded as text + +Extractor collects text, tables, and charts as textual content; embedder treats all content as text. + +```bash +export APP_NVINGEST_EXTRACTTEXT="True" +export APP_NVINGEST_EXTRACTTABLES="True" +export APP_NVINGEST_EXTRACTCHARTS="True" +export APP_NVINGEST_EXTRACTIMAGES="False" +# Do not set structured/image modalities (or set them empty) so everything embeds as text +export APP_NVINGEST_STRUCTURED_ELEMENTS_MODALITY="" +export APP_NVINGEST_IMAGE_ELEMENTS_MODALITY="" +export APP_NVINGEST_EXTRACTPAGEASIMAGE="False" + +# Apply by restarting ingestor-server +docker compose -f deploy/compose/docker-compose-ingestor-server.yaml up -d +``` + +### Embed structured elements (tables, charts) as images + +Extractor collects text, tables, and charts; embedder treats standard text as text while embedding tables and charts as images via `APP_NVINGEST_STRUCTURED_ELEMENTS_MODALITY="image"`. + +```bash +export APP_NVINGEST_EXTRACTTEXT="True" +export APP_NVINGEST_EXTRACTTABLES="True" +export APP_NVINGEST_EXTRACTCHARTS="True" +export APP_NVINGEST_EXTRACTIMAGES="False" +# Use the VLM model to capture spatial/structural info for tables and charts +export APP_NVINGEST_STRUCTURED_ELEMENTS_MODALITY="image" +export APP_NVINGEST_IMAGE_ELEMENTS_MODALITY="" +export APP_NVINGEST_EXTRACTPAGEASIMAGE="False" + +docker compose -f deploy/compose/docker-compose-ingestor-server.yaml up -d +``` + +### Embed entire pages as images (experimental) + +Extractor captures each page as a single image (`APP_NVINGEST_EXTRACTPAGEASIMAGE="True"`); embedder processes page images via `APP_NVINGEST_IMAGE_ELEMENTS_MODALITY="image"`. Other extraction types are disabled to avoid duplicating content. + +:::{note} +Citations don't work in the `generate` and `search` APIs of the RAG server with this configuration. +::: + +```bash +# Treat each page as a single image (turn off other extractors) +export APP_NVINGEST_EXTRACTTEXT="False" +export APP_NVINGEST_EXTRACTTABLES="False" +export APP_NVINGEST_EXTRACTCHARTS="False" +export APP_NVINGEST_EXTRACTIMAGES="False" +export APP_NVINGEST_EXTRACTPAGEASIMAGE="True" +# Ensure page images are embedded as images +export APP_NVINGEST_IMAGE_ELEMENTS_MODALITY="image" +export APP_NVINGEST_STRUCTURED_ELEMENTS_MODALITY="" + +docker compose -f deploy/compose/docker-compose-ingestor-server.yaml up -d +``` + +## VLM Embedding Quick Reference + +- **Start only VLM embedding service**: `docker compose -f deploy/compose/nims.yaml --profile vlm-embed up -d` +- **Point ingestor to VLM embedding**: + - `APP_EMBEDDINGS_SERVERURL=nemotron-vlm-embedding-ms:8000/v1` + - `APP_EMBEDDINGS_MODELNAME=nvidia/llama-nemotron-embed-vl-1b-v2` +- **Modality env vars**: + - `APP_NVINGEST_STRUCTURED_ELEMENTS_MODALITY`: `image` or empty + - `APP_NVINGEST_IMAGE_ELEMENTS_MODALITY`: `image` or empty + - `APP_NVINGEST_EXTRACTPAGEASIMAGE`: `True` or `False` + +If you use a `.env` file, add the variables there instead of exporting them, then rerun the compose commands. + +## VLM Embedding via Helm + +To deploy the VLM embedding service with Helm, update the image and model settings, set the corresponding environment variables, and then apply the chart with your updated values.yaml. + +1. Modify [`values.yaml`](../deploy/helm/nvidia-blueprint-rag/values.yaml) to enable VLM embedding: + + ```yaml + # Enable VLM embedding NIM and set its image + nvidia-nim-llama-nemotron-embed-vl-1b-v2: + enabled: true + image: + repository: nvcr.io/nim/nvidia/llama-nemotron-embed-vl-1b-v2 + tag: "1.12.0" + + # Optional: disable the default text embedding NIM + nvidia-nim-llama-nemotron-embed-1b-v2: + enabled: false + + # Point services to the VLM embedding endpoint and model + envVars: + APP_EMBEDDINGS_SERVERURL: "nemotron-vlm-embedding-ms:8000/v1" + APP_EMBEDDINGS_MODELNAME: "nvidia/llama-nemotron-embed-vl-1b-v2" + + ingestor-server: + envVars: + APP_EMBEDDINGS_SERVERURL: "nemotron-vlm-embedding-ms:8000/v1" + APP_EMBEDDINGS_MODELNAME: "nvidia/llama-nemotron-embed-vl-1b-v2" + + nv-ingest: + envVars: + EMBEDDING_NIM_ENDPOINT: "http://nemotron-vlm-embedding-ms:8000/v1" + EMBEDDING_NIM_MODEL_NAME: "nvidia/llama-nemotron-embed-vl-1b-v2" + ``` + +2. After modifying [`values.yaml`](../deploy/helm/nvidia-blueprint-rag/values.yaml), apply the changes as described in [Change a Deployment](deploy-helm.md#change-a-deployment). + + For detailed Helm deployment instructions, see [Helm Deployment Guide](deploy-helm.md). + +### Additional Helm Configuration: Extraction and Embedding Modalities + +To configure how content is extracted and embedded (similar to the Docker configurations shown above), you can add extraction and modality settings to your [`values.yaml`](../deploy/helm/nvidia-blueprint-rag/values.yaml): + +- Set extraction-related variables under `envVars` and `ingestor-server.envVars` +- Set embedding service variables under `nv-ingest.envVars` + +**Example with extraction and modality settings:** + +```yaml +envVars: + APP_EMBEDDINGS_SERVERURL: "nemotron-vlm-embedding-ms:8000/v1" + APP_EMBEDDINGS_MODELNAME: "nvidia/llama-nemotron-embed-vl-1b-v2" +ingestor-server: + envVars: + # Extraction toggles + APP_NVINGEST_EXTRACTTEXT: "True" + APP_NVINGEST_EXTRACTTABLES: "True" + APP_NVINGEST_EXTRACTCHARTS: "True" + APP_NVINGEST_EXTRACTIMAGES: "False" + APP_NVINGEST_EXTRACTPAGEASIMAGE: "False" + # Embedding modality controls + APP_NVINGEST_STRUCTURED_ELEMENTS_MODALITY: "" # set to "image" to embed tables/charts as images + APP_NVINGEST_IMAGE_ELEMENTS_MODALITY: "" # set to "image" to embed page images as images + # Ingestor-side embedding target + APP_EMBEDDINGS_SERVERURL: "nemotron-vlm-embedding-ms:8000/v1" + APP_EMBEDDINGS_MODELNAME: "nvidia/llama-nemotron-embed-vl-1b-v2" + +nv-ingest: + envVars: + # NeMo Retriever Library runtime embedding target + EMBEDDING_NIM_ENDPOINT: "http://nemotron-vlm-embedding-ms:8000/v1" + EMBEDDING_NIM_MODEL_NAME: "nvidia/llama-nemotron-embed-vl-1b-v2" +``` + +--- + +# Part 2 — VLM Reranker + +The VLM reranker uses a vision-language reranking model — `nvidia/llama-nemotron-rerank-vl-1b-v2` — to re-rank retrieved passages **with awareness of the cited images**, not just the surrounding text. This produces better ordering for image-heavy corpora (PDFs with charts, diagrams, scanned tables) where the most relevant chunk is signalled by its visual content rather than its text. + +The VLM reranker is a drop-in replacement for the default text reranker (`nvidia/llama-nemotron-rerank-1b-v2`). When the image-input flag is enabled, the rag-server fetches the base64 image data for each retrieved image/structured chunk from object storage and attaches it to the reranking request alongside the chunk's text. + +## How It Works + +1. **Retrieval** runs as usual against the vector database and returns the top-K candidate chunks. +2. The rag-server builds a reranking request whose `passages` carry each chunk's text **and** (when enabled) a PNG-base64 image data URL fetched from object storage for `image` and `structured` chunks. +3. The VLM reranker scores each passage with multimodal context and the rag-server keeps the top-N. + +The image-attachment behaviour is gated by the `ENABLE_VLM_RERANKER_IMAGE_INPUT` flag. With the flag off, the VLM reranker behaves like a text-only reranker — it still uses a multimodal model, but no image content is passed in the request. + +## The `ENABLE_VLM_RERANKER_IMAGE_INPUT` Flag + +| Flag | Default | Purpose | +|------|---------|---------| +| `ENABLE_VLM_RERANKER_IMAGE_INPUT` | `False` | When `True`, base64 image data for retrieved `image`/`structured` chunks is included in the reranking request. When `False`, only chunk text is sent. | + +**When to set it to `True`:** +- Your corpus contains images, charts, diagrams, or tables ingested via VLM Embedding (Part 1) in image modality. +- Reranking quality on image queries is poor because the text caption alone doesn't disambiguate the right chunk. +- You're running the [full VLM multimodal pipeline](vlm.md#enabling-full-vlm-multimodal-rag-pipeline). + +**When to leave it `False`:** +- Your corpus is text-only or you only ingest text modality. +- Latency is critical — fetching images from object storage and round-tripping them to the reranker adds time per request. +- The reranker model is the text variant (`nvidia/llama-nemotron-rerank-1b-v2`). The flag is only honoured by `nvidia/llama-nemotron-rerank-vl-1b-v2`. + +## Enable VLM Reranker with Docker Compose + +The VLM reranker NIM is provided as the `nemotron-ranking-vl-ms` service in [`deploy/compose/nims.yaml`](../deploy/compose/nims.yaml) under the `vlm-rerank` and `vlm-rag` profiles. Image: `nvcr.io/nim/nvidia/llama-nemotron-rerank-vl-1b-v2:1.11.0`. + +1. Start the VLM reranker NIM (and disable the text reranker if it was running): + + ```bash + export USERID=$(id -u) + export NGC_API_KEY="nvapi-..." + # Optional: pin the GPU for the VLM reranker + export RANKING_VL_MS_GPU_ID=0 + + # Start the VLM reranker (and any other services on the vlm-rerank profile) + docker compose -f deploy/compose/nims.yaml --profile vlm-rerank up -d + ``` + + Use the `vlm-rag` profile if you also want VLM generation and VLM embedding to come up with the same command. + +2. Point the rag-server at the VLM reranker and enable image input: + + ```bash + export APP_RANKING_MODELNAME="nvidia/llama-nemotron-rerank-vl-1b-v2" + export APP_RANKING_SERVERURL="nemotron-ranking-vl-ms:8000" + export ENABLE_RERANKER="True" + export ENABLE_VLM_RERANKER_IMAGE_INPUT="True" + + docker compose -f deploy/compose/docker-compose-rag-server.yaml up -d + ``` + + - `APP_RANKING_MODELNAME` must contain the substring `rerank-vl` for the rag-server to route through the multimodal reranker code path. + - `APP_RANKING_SERVERURL` points to the VLM reranker NIM service. For NVIDIA-hosted endpoints, set it to `https://ai.api.nvidia.com` (or leave unset to use the default cloud URL). + +3. Restart the rag-server so the new flag takes effect. + +### Use the NVIDIA-Hosted VLM Reranker (Optional) + +```bash +export APP_RANKING_MODELNAME="nvidia/llama-nemotron-rerank-vl-1b-v2" +export APP_RANKING_SERVERURL="" # empty = use NVIDIA-hosted default +export ENABLE_VLM_RERANKER_IMAGE_INPUT="True" +docker compose -f deploy/compose/docker-compose-rag-server.yaml up -d +``` + +## Enable VLM Reranker with Helm + +The VLM reranker NIM is defined in [`values.yaml`](../deploy/helm/nvidia-blueprint-rag/values.yaml) as `nimOperator.nvidia-nim-llama-nemotron-rerank-vl-1b-v2` (disabled by default). Service name `nemotron-ranking-vl-ms`, image `nvcr.io/nim/nvidia/llama-nemotron-rerank-vl-1b-v2:1.11.0`. + +1. In `values.yaml`, enable the VLM reranker NIM and disable the text reranker: + + ```yaml + nimOperator: + nvidia-nim-llama-nemotron-rerank-vl-1b-v2: + enabled: true + # Optional: disable the text reranker NIM to free up its GPU slot + nvidia-nim-llama-nemotron-rerank-1b-v2: + enabled: false + ``` + +2. Update the rag-server `envVars` to point at the VLM reranker and turn on image input: + + ```yaml + envVars: + ENABLE_RERANKER: "True" + APP_RANKING_MODELNAME: "nvidia/llama-nemotron-rerank-vl-1b-v2" + APP_RANKING_SERVERURL: "nemotron-ranking-vl-ms:8000" + ENABLE_VLM_RERANKER_IMAGE_INPUT: "True" + ``` + +3. Apply the changes as described in [Change a Deployment](deploy-helm.md#change-a-deployment). + +4. Verify the VLM reranker pod is running: + + ```bash + kubectl get pods -n rag | grep nemotron-ranking-vl + ``` + +## Hardware + +`nvidia/llama-nemotron-rerank-vl-1b-v2` requires **1× NVIDIA GPU** (H100 or A100 recommended). When running alongside VLM generation and VLM embedding for a fully multimodal pipeline, plan for at least **3 GPUs** total: one each for the VLM, the VLM embedder, and the VLM reranker. With MIG slicing on H100, smaller slices may be sufficient — see [MIG Deployment](mig-deployment.md). + +## VLM Reranker Limitations + +- **Only the VL reranker model honours the image-input flag.** Setting `ENABLE_VLM_RERANKER_IMAGE_INPUT=True` while `APP_RANKING_MODELNAME` is the text reranker has no effect — the rag-server only follows the multimodal code path when the model name contains `rerank-vl`. +- **Image queries bypass the reranker entirely.** When the user query itself contains an image, the rag-server skips reranking (text or VLM) and returns the vector-DB results directly. This is independent of the flag. +- **Latency.** Each image-bearing passage requires an object-store fetch and a base64 round-trip to the reranker. Expect ~50–200 ms of additional reranking latency depending on `vdb_top_k` and image sizes. +- **Object-store availability.** If the rag-server cannot reach object storage (`OBJECTSTORE_ENDPOINT`), it logs a warning and falls back to text-only passages for that chunk. + +--- + +## Related Topics + +- [NVIDIA RAG Blueprint Documentation](readme.md) +- [VLM-based Generation](vlm.md) +- [Multimodal Query Support](multimodal-query.md) +- [Change the LLM, Embedding Model, or Reranker](change-model.md) +- [Best Practices for Common Settings](accuracy_perf.md) +- [RAG Pipeline Debugging Guide](debugging.md) +- [Troubleshoot](troubleshooting.md) +- [Notebooks](notebooks.md) diff --git a/docs/multiturn.md b/docs/multiturn.md index e595934a2..f5f3e7535 100644 --- a/docs/multiturn.md +++ b/docs/multiturn.md @@ -247,7 +247,7 @@ Only on-prem deployment of the LLM is supported for Helm. The model must be depl # ... existing configurations ... # === Query Rewriter Model specific configurations === - APP_QUERYREWRITER_MODELNAME: "nvidia/llama-3.3-nemotron-super-49b-v1.5" + APP_QUERYREWRITER_MODELNAME: "nvidia/nemotron-3-super-120b-a12b" APP_QUERYREWRITER_SERVERURL: "nim-llm:8000" # Fully qualified service name ENABLE_QUERYREWRITER: "True" CONVERSATION_HISTORY: "5" diff --git a/docs/nemo-guardrails.md b/docs/nemo-guardrails.md index eaa2c5057..0741b8191 100644 --- a/docs/nemo-guardrails.md +++ b/docs/nemo-guardrails.md @@ -72,6 +72,10 @@ To deploy all guardrails services on your own dedicated hardware, use the follow # export NIM_ENDPOINT_URL= ``` + If the RAG server uses a custom on-prem LLM endpoint through + `APP_LLM_SERVERURL`, set `NIM_ENDPOINT_URL` for the Guardrails microservice to + the same OpenAI-compatible base URL ending in `/v1`. + 2. Set the environment variable to enable guardrails by running the following code. ```bash @@ -203,6 +207,37 @@ If you are using notebooks or APIs to interact directly with `rag-server`, set ` ## Troubleshooting +### `stream_async()` Error with Output Rails + +The RAG server streams responses by default. If NeMo Guardrails is enabled with +output rails, the active Guardrails configuration must also enable streaming for +output rails. Otherwise, the Guardrails microservice can log the following error: + +```text +stream_async() cannot be used when output rails are configured but rails.output.streaming.enabled is False +``` + +For self-hosted Guardrails, verify that +`deploy/compose/nemoguardrails/config-store/nemoguard/config.yml` includes: + +```yaml +rails: + output: + streaming: + enabled: true +``` + +After updating the file, restart the Guardrails microservice: + +```bash +docker compose -f deploy/compose/docker-compose-nemo-guardrails.yaml up -d --no-deps nemo-guardrails-microservice +``` + +The `Failed to export traces to localhost:4317` messages are OpenTelemetry +export warnings. They do not cause the `stream_async()` failure. Start the +observability profile or disable trace export if you want to remove those +warnings. + ### GPU Device ID Issues If you encounter GPU device errors, you can customize the GPU device IDs used by the guardrails services. By default, the services use GPUs 6 and 7, but you can set specific GPUs by setting these environment variables before starting the service: diff --git a/docs/nemoretriever-ocr.md b/docs/nemoretriever-ocr.md index a76a7c113..07b930aaa 100644 --- a/docs/nemoretriever-ocr.md +++ b/docs/nemoretriever-ocr.md @@ -11,17 +11,17 @@ This guide explains the OCR (Optical Character Recognition) services available i The NVIDIA RAG Blueprint supports two OCR services: -1. **NeMo Retriever Library OCR** (Default) - High-performance OCR service offering 2x+ faster performance +1. **Nemotron OCR** (Default) - High-performance OCR service offering 2x+ faster performance 2. **Paddle OCR** (Legacy) - General-purpose OCR service maintained for compatibility :::{tip} -**NeMo Retriever Library OCR is now the default OCR service** and is recommended for all new deployments due to its superior performance and efficiency. +**Nemotron OCR is now the default OCR service** and is recommended for all new deployments due to its superior performance and efficiency. ::: -## NeMo Retriever Library OCR (Default) +## Nemotron OCR (Default) -NeMo Retriever Library OCR is the default and recommended OCR service for the NVIDIA RAG Blueprint, providing: +Nemotron OCR is the default and recommended OCR service for the NVIDIA RAG Blueprint, providing: - **2x+ faster performance** compared to Paddle OCR - Optimized text extraction from documents and images @@ -38,24 +38,24 @@ NeMo Retriever Library OCR is the default and recommended OCR service for the NV ### Default Configuration -By default, the NVIDIA RAG Blueprint is configured to use NeMo Retriever Library OCR with the following settings: +By default, the NVIDIA RAG Blueprint is configured to use Nemotron OCR with the following settings. For Nemotron OCR, use `OCR_MODEL_NAME=pipeline` (the previous default `scene_text_ensemble` applied to the legacy NeMo Retriever OCR service). | Variable | Default Value | Description | |----------|---------------|-------------| -| `OCR_GRPC_ENDPOINT` | `nemoretriever-ocr:8001` | gRPC endpoint for OCR service | -| `OCR_HTTP_ENDPOINT` | `http://nemoretriever-ocr:8000/v1/infer` | HTTP endpoint for OCR service | +| `OCR_GRPC_ENDPOINT` | `nemotron-ocr:8001` | gRPC endpoint for OCR service | +| `OCR_HTTP_ENDPOINT` | `http://nemotron-ocr:8000/v1/infer` | HTTP endpoint for OCR service | | `OCR_INFER_PROTOCOL` | `grpc` | Communication protocol (grpc or http) | -| `OCR_MODEL_NAME` | `scene_text_ensemble` | OCR model to use | +| `OCR_MODEL_NAME` | `pipeline` | OCR model to use (use `pipeline` for Nemotron OCR; legacy `scene_text_ensemble` was used with NeMo Retriever OCR) | ### Hardware Requirements -For detailed hardware requirements and GPU support, refer to the [NeMo Retriever Library OCR Support Matrix](https://docs.nvidia.com/nim/ingestion/image-ocr/1.2.0/support-matrix.html). +For detailed hardware requirements and GPU support, refer to the [Nemotron OCR Support Matrix](https://docs.nvidia.com/nim/ingestion/image-ocr/1.3.0/support-matrix.html). ### Docker Configuration -The NeMo Retriever Library OCR service is configured in the Docker Compose file with the following key settings: +The Nemotron OCR service is configured in the Docker Compose file with the following key settings: -- **Image**: `nvcr.io/nim/nvidia/nemoretriever-ocr-v1:1.2.0` +- **Image**: `nvcr.io/nim/nvidia/nemotron-ocr-v1:1.3.0` - **GPU Memory**: 8192 MB (default) - **Max Batch Size**: 32 (default) - **Ports**: 8012 (HTTP), 8013 (gRPC), 8014 (Metrics) @@ -72,7 +72,7 @@ export OCR_OMP_NUM_THREADS=8 # Set OpenMP threads ## Paddle OCR (Legacy) -Paddle OCR is maintained as a legacy option for compatibility with existing workflows. While still functional, it is recommended to migrate to NeMo Retriever Library OCR for better performance. +Paddle OCR is maintained as a legacy option for compatibility with existing workflows. While still functional, it is recommended to migrate to Nemotron OCR for better performance. ### When to Use Paddle OCR @@ -92,7 +92,7 @@ The Paddle OCR service configuration: - **Ports**: 8009 (HTTP), 8010 (gRPC), 8011 (Metrics) :::{note} -**Legacy Service**: Paddle OCR is maintained as a legacy option. For new deployments, we recommend using the default NeMo Retriever Library OCR service for better performance. +**Legacy Service**: Paddle OCR is maintained as a legacy option. For new deployments, we recommend using the default Nemotron OCR service for better performance. ::: @@ -100,9 +100,9 @@ The Paddle OCR service configuration: ### Docker Compose Deployment -#### Using NeMo Retriever Library OCR (Default) +#### Using Nemotron OCR (Default) -NeMo Retriever Library OCR is deployed by default when you follow the standard deployment guide. No additional configuration is required. +Nemotron OCR is deployed by default when you follow the standard deployment guide. No additional configuration is required. 1. **Prerequisites**: Follow the [deployment guide](deploy-docker-self-hosted.md) for standard setup. @@ -112,7 +112,7 @@ NeMo Retriever Library OCR is deployed by default when you follow the standard d ``` :::{tip} - NeMo Retriever Library OCR is included in the default profile and will start automatically. + Nemotron OCR is included in the default profile and will start automatically. ::: 3. **Verify Service Status**: @@ -134,9 +134,9 @@ If you need to use Paddle OCR instead: export OCR_MODEL_NAME=paddle ``` -3. **Stop NeMo Retriever Library OCR if running**: +3. **Stop Nemotron OCR if running**: ```bash - USERID=$(id -u) docker compose -f deploy/compose/nims.yaml down nemoretriever-ocr + USERID=$(id -u) docker compose -f deploy/compose/nims.yaml down nemotron-ocr ``` 4. **Deploy Paddle OCR Service**: @@ -154,9 +154,9 @@ If you need to use Paddle OCR instead: ### NVIDIA-Hosted Deployment -#### Using NeMo Retriever Library OCR (Default) +#### Using Nemotron OCR (Default) -Follow the standard [NVIDIA-hosted deployment guide](deploy-docker-nvidia-hosted.md) - NeMo Retriever Library OCR is the default configuration. +Follow the standard [NVIDIA-hosted deployment guide](deploy-docker-nvidia-hosted.md) - Nemotron OCR is the default configuration. #### Using Paddle OCR with NVIDIA-Hosted Deployment @@ -176,13 +176,13 @@ Follow the standard [NVIDIA-hosted deployment guide](deploy-docker-nvidia-hosted ### Helm Deployment -#### Using NeMo Retriever Library OCR (Default) +#### Using Nemotron OCR (Default) -NeMo Retriever Library OCR is deployed by default with Helm installations. Follow the standard [Helm Deployment Guide](deploy-helm.md) - no additional OCR configuration is required. +Nemotron OCR is deployed by default with Helm installations. Follow the standard [Helm Deployment Guide](deploy-helm.md) - no additional OCR configuration is required. #### Using Paddle OCR with Helm -To use Paddle OCR instead of the default NeMo Retriever Library OCR: +To use Paddle OCR instead of the default Nemotron OCR: Modify [`values.yaml`](../deploy/helm/nvidia-blueprint-rag/values.yaml) to override the OCR service image: @@ -190,7 +190,7 @@ Modify [`values.yaml`](../deploy/helm/nvidia-blueprint-rag/values.yaml) to overr nv-ingest: nimOperator: # Override the OCR service to use PaddleOCR image - nemoretriever_ocr_v1: + ocr: enabled: true image: repository: nvcr.io/nim/baidu/paddleocr @@ -202,7 +202,7 @@ nv-ingest: ``` :::{note} -The service endpoints (`OCR_GRPC_ENDPOINT` and `OCR_HTTP_ENDPOINT`) remain the same and do not need to be changed. The service name `nemoretriever-ocr-v1` is retained even when using the PaddleOCR image. +The service endpoints (`OCR_GRPC_ENDPOINT` and `OCR_HTTP_ENDPOINT`) remain the same and do not need to be changed. The service name `nemotron-ocr-v1` is retained even when using the PaddleOCR image. ::: After modifying [`values.yaml`](../deploy/helm/nvidia-blueprint-rag/values.yaml), apply the changes as described in [Change a Deployment](deploy-helm.md#change-a-deployment). @@ -214,15 +214,15 @@ For detailed Helm deployment instructions, see [Helm Deployment Guide](deploy-he ### Environment Variables -| Variable | Description | NeMo Retriever Library Default | Paddle Default | Required | -|----------|-------------|------------------------|----------------|----------| -| `OCR_GRPC_ENDPOINT` | gRPC endpoint for OCR service | `nemoretriever-ocr:8001` | `paddle:8001` | Yes (on-premises) | -| `OCR_HTTP_ENDPOINT` | HTTP endpoint for OCR service | `http://nemoretriever-ocr:8000/v1/infer` | `http://paddle:8000/v1/infer` | Yes | +| Variable | Description | Nemotron Default | Paddle Default | Required | +|----------|-------------|------------------|----------------|----------| +| `OCR_GRPC_ENDPOINT` | gRPC endpoint for OCR service | `nemotron-ocr:8001` | `paddle:8001` | Yes (on-premises) | +| `OCR_HTTP_ENDPOINT` | HTTP endpoint for OCR service | `http://nemotron-ocr:8000/v1/infer` | `http://paddle:8000/v1/infer` | Yes | | `OCR_INFER_PROTOCOL` | Communication protocol | `grpc` | `grpc` | Yes | -| `OCR_MODEL_NAME` | OCR model to use | `scene_text_ensemble` | `paddle` | Yes | +| `OCR_MODEL_NAME` | OCR model to use | `pipeline` | `paddle` | Yes | | `OCR_MS_GPU_ID` | GPU device ID to use | `0` | `0` | No | | `OCR_CUDA_MEMORY_POOL_MB` | CUDA memory pool size | `8192` | `3072` | No | -| `OCR_BATCH_SIZE` | Max batch size (NeMo only) | `32` | N/A | No | +| `OCR_BATCH_SIZE` | Max batch size (Nemotron only) | `32` | N/A | No | | `OCR_OMP_NUM_THREADS` | OpenMP thread count | `8` | `8` | No | ### Advanced Configuration @@ -238,26 +238,26 @@ Replace `workstation_ip` with the actual IP address of the machine running the O ## Switching Between OCR Services -### Migrating from Paddle OCR to NeMo Retriever Library OCR +### Migrating from Paddle OCR to Nemotron OCR -To switch to the default NeMo Retriever Library OCR service: +To switch to the default Nemotron OCR service: 1. **Stop Paddle OCR**: ```bash USERID=$(id -u) docker compose -f deploy/compose/nims.yaml down paddle ``` -2. **Configure NeMo Retriever Library OCR environment variables**: +2. **Configure Nemotron OCR environment variables**: ```bash - export OCR_GRPC_ENDPOINT=nemoretriever-ocr:8001 - export OCR_HTTP_ENDPOINT=http://nemoretriever-ocr:8000/v1/infer + export OCR_GRPC_ENDPOINT=nemotron-ocr:8001 + export OCR_HTTP_ENDPOINT=http://nemotron-ocr:8000/v1/infer export OCR_INFER_PROTOCOL=grpc - export OCR_MODEL_NAME=scene_text_ensemble + export OCR_MODEL_NAME=pipeline ``` -3. **Start NeMo Retriever Library OCR**: +3. **Start Nemotron OCR**: ```bash - USERID=$(id -u) docker compose -f deploy/compose/nims.yaml up -d nemoretriever-ocr + USERID=$(id -u) docker compose -f deploy/compose/nims.yaml up -d nemotron-ocr ``` 4. **Restart Ingestor Server**: @@ -265,15 +265,15 @@ To switch to the default NeMo Retriever Library OCR service: docker compose -f deploy/compose/docker-compose-ingestor-server.yaml up -d ``` -### Migrating from NeMo Retriever Library OCR to Paddle OCR +### Migrating from Nemotron OCR to Paddle OCR Follow the steps in [Switching to Paddle OCR](#switching-to-paddle-ocr) above. ## Performance Comparison -| Feature | NeMo Retriever Library OCR | Paddle OCR | -|---------|-------------------|------------| +| Feature | Nemotron OCR | Paddle OCR | +|---------|--------------|------------| | **Performance** | 2x+ faster | Baseline | | **GPU Memory** | 8 GB (default) | 3 GB (default) | | **Batch Processing** | Up to 32 | Limited | @@ -297,14 +297,14 @@ Follow the steps in [Switching to Paddle OCR](#switching-to-paddle-ocr) above. 3. **Performance Issues** - Consider increasing `OCR_CUDA_MEMORY_POOL_MB` - - Adjust `OCR_BATCH_SIZE` for NeMo Retriever Library OCR + - Adjust `OCR_BATCH_SIZE` for Nemotron OCR - Verify GPU has sufficient memory ### Getting Logs ```bash -# NeMo Retriever Library OCR logs -docker logs nemoretriever-ocr +# Nemotron OCR logs +docker logs nemotron-ocr # Paddle OCR logs docker logs paddle @@ -320,4 +320,3 @@ docker logs paddle - [Support Matrix](support-matrix.md) - [Troubleshoot](troubleshooting.md) - [Ingestion API Usage Notebook](https://github.com/NVIDIA-AI-Blueprints/rag/blob/main/notebooks/ingestion_api_usage.ipynb) - diff --git a/docs/nemotron-parse-extraction.md b/docs/nemotron-parse-extraction.md index 0e2fc0b11..9fbb855eb 100644 --- a/docs/nemotron-parse-extraction.md +++ b/docs/nemotron-parse-extraction.md @@ -188,7 +188,7 @@ To run only Nemotron Parse for PDF and table extraction with Helm: nimOperator: nemotron_parse: enabled: true - nemoretriever_ocr_v1: + ocr: enabled: false graphic_elements: enabled: false diff --git a/docs/nemotron3-super-deployment.md b/docs/nemotron3-super-deployment.md index 4d831bdd1..a400605b7 100644 --- a/docs/nemotron3-super-deployment.md +++ b/docs/nemotron3-super-deployment.md @@ -1,8 +1,8 @@ -# Using Nemotron-3-Super-120B-A12B LLM NIM +# Nemotron-3-Super-120B-A12B -[Nemotron-3-Super-120B-A12B](https://build.nvidia.com/nvidia/nemotron-3-super-120b-a12b/modelcard) is a large language model (LLM) trained by NVIDIA, designed to deliver strong agentic, reasoning, and conversational capabilities. It is optimized for collaborative agents and high-volume workloads such as IT ticket automation. This LLM can considerably improve the accuracy of the RAG pipeline, especially with reasoning enabled. ([Model card](https://build.nvidia.com/nvidia/nemotron-3-super-120b-a12b/modelcard)) +[Nemotron-3-Super-120B-A12B](https://build.nvidia.com/nvidia/nemotron-3-super-120b-a12b/modelcard) is the default LLM for the NVIDIA RAG Blueprint. It is trained by NVIDIA and designed to deliver strong agentic, reasoning, and conversational capabilities. It is optimized for collaborative agents and high-volume workloads such as IT ticket automation. -We recommend to use the model with low-effort reasoning mode with a reasoning budget of 256 to have a balance between accuracy and performance. You can switch to non-reasoning mode for maximum performance or use reasoning mode for best accuracy. +We recommend using the model with low-effort reasoning mode with a reasoning budget of 256 to balance accuracy and performance. You can switch to non-reasoning mode for maximum performance or use reasoning mode for best accuracy. ## Hardware requirements @@ -17,9 +17,7 @@ For [self-hosted local NIM](deploy-docker-self-hosted.md) deployment with `nemot - 3 x B200 - 3 x RTX PRO 6000 -### Hardware Requirements (Kubernetes) - -To deploy with [Helm](deploy-helm.md) using `nemotron-3-super-120b-a12b`, you need one of the following: +For [Helm](deploy-helm.md) deployment, you need one of the following: - 9 x H100-80GB - 9 x B200 @@ -27,118 +25,9 @@ To deploy with [Helm](deploy-helm.md) using `nemotron-3-super-120b-a12b`, you ne --- -## Start services using NVIDIA-hosted models - -No local GPU needed for the LLM. The file `deploy/compose/nemotron3-super-cloud.env` sets all NVIDIA-hosted (cloud) endpoints and the `nemotron-3-super-120b-a12b` model. - -1. [Set your API key](https://github.com/NVIDIA-AI-Blueprints/rag/blob/main/docs/api-key.md) and prompt config, then source the env files: - -```bash -export NGC_API_KEY= -source deploy/compose/.env -source deploy/compose/nemotron3-super-cloud.env -export PROMPT_CONFIG_FILE=$(pwd)/deploy/compose/nemotron3-super-prompt.yaml -``` - -2. Follow [Start services using NVIDIA-hosted models](deploy-docker-nvidia-hosted.md#start-services-using-nvidia-hosted-models) to start the vectorstore, rag-server, and ingestor-server. - ---- - -## Start services using self-hosted on-premises models - -1. Update `nims.yaml` - - Edit `deploy/compose/nims.yaml` and change the `nim-llm` service image and GPU allocation: - - ```yaml - nim-llm: - image: nvcr.io/nim/nvidia/nemotron-3-super-120b-a12b:1.8.0 - ... - user: "0" - environment: - NGC_API_KEY: ${NGC_API_KEY} - NIM_MAX_MODEL_LEN: "32768" # required for TP2 profile - NIM_KVCACHE_PERCENT: "0.9" - deploy: - resources: - reservations: - devices: - - driver: nvidia - device_ids: ['1','2'] # 2 GPUs for FP8 TP2 - capabilities: [gpu] - ``` - - > Note: To deploy TP2 profiles you need to limit NIM_MAX_MODEL_LEN to 32768 - - To confirm that a TP2 profile is available for your hardware, run: - - ```bash - docker run -ti --rm --gpus all nvcr.io/nim/nvidia/nemotron-3-super-120b-a12b:1.8.0 list-model-profiles - ``` - - Check the [model page](https://build.nvidia.com/nvidia/nemotron-3-super-120b-a12b/modelcard) for more details. - - > Note: For RTX 6000 Pro GPUs, additional NIM environment variables are required — see [RTX 6000 Pro](#rtx-6000-pro) below. - -2. Set nemotron-3-super specific environment variables. - - Ensure the section **`Endpoints for using cloud NIMs`** in `deploy/compose/.env` is **commented** (so on-prem endpoints are used). - - ```bash - source deploy/compose/.env - source deploy/compose/nemotron3-super.env - export PROMPT_CONFIG_FILE=$(pwd)/deploy/compose/nemotron3-super-prompt.yaml - export LLM_MAX_TOKENS=16256 - ``` - - Follow [Start services using self-hosted on-premises models](deploy-docker-self-hosted.md#start-services-using-self-hosted-on-premises-models) to start the vectorstore, rag-server, NIMs, and ingestor-server. - -**RTX 6000 Pro** - -> Note: To deploy TP2 profiles on RTX PRO 6000 Blackwell Server Edition, run the following commands. You don't need to go through these steps if you are using TP4 or TP8 profile. - -1. Edit `/etc/default/grub` and set: - - ```text - GRUB_CMDLINE_LINUX_DEFAULT="quiet splash iommu=pt" - ``` - -2. Run: - - ```bash - sudo update-grub2 - sudo reboot - ``` - -3. In `nims.yaml`, add under the `nim-llm` `environment:` block: - - ```yaml - environment: - # In addition to variable already set in step 1 - NCCL_P2P_DISABLE: "1" - ``` - ---- - -## Helm deployment (`nemotron-3-super-120b-a12b`) +## RTX PRO 6000 Setup -From the repository root, run: - -```bash -helm upgrade --install rag -n rag https://helm.ngc.nvidia.com/nvidia/blueprint/charts/nvidia-blueprint-rag-v2.5.1.tgz \ - --username '$oauthtoken' \ - --password "${NGC_API_KEY}" \ - --set imagePullSecret.password=$NGC_API_KEY \ - --set ngcApiSecret.password=$NGC_API_KEY \ - -f deploy/helm/nvidia-blueprint-rag/values.yaml \ - -f deploy/helm/nvidia-blueprint-rag/nemotron3-super-values.yaml -``` - -The prompt file `deploy/compose/nemotron3-super-prompt.yaml` is tuned for `nemotron-3-super-120b-a12b`. To customize it, see [Prompt customization in Helm chart](prompt-customization.md#prompt-customization-in-helm-chart). - -**RTX 6000 Pro** - -> Note: To deploy TP2 profiles on RTX PRO 6000 Blackwell Server Edition, run the following commands. You don't need to go through these steps if you are using TP4 or TP8 profile. +> Note: These steps are only required for RTX PRO 6000 Blackwell Server Edition using the TP2 profile. Skip if you are using a TP4 or TP8 profile. 1. Edit `/etc/default/grub` and set: @@ -153,24 +42,13 @@ The prompt file `deploy/compose/nemotron3-super-prompt.yaml` is tuned for `nemot sudo reboot ``` -3. From the repository root, run: - - ```bash - helm upgrade --install rag -n rag https://helm.ngc.nvidia.com/nvidia/blueprint/charts/nvidia-blueprint-rag-v2.5.1.tgz \ - --username '$oauthtoken' \ - --password "${NGC_API_KEY}" \ - --set imagePullSecret.password=$NGC_API_KEY \ - --set ngcApiSecret.password=$NGC_API_KEY \ - -f deploy/helm/nvidia-blueprint-rag/values.yaml \ - -f deploy/helm/nvidia-blueprint-rag/nemotron3-super-values.yaml \ - -f deploy/helm/nvidia-blueprint-rag/nemotron3-super-rtx6000-values.yaml - ``` +No additional configuration changes are needed in `nims.yaml` or `values.yaml` beyond the defaults. --- ## Reasoning and non-reasoning mode -To disable reasoning mode set following +To disable reasoning mode: ```bash export LLM_ENABLE_THINKING=false diff --git a/docs/nv-ingest-standalone.md b/docs/nv-ingest-standalone.md index 14319ad94..52dd7512d 100644 --- a/docs/nv-ingest-standalone.md +++ b/docs/nv-ingest-standalone.md @@ -90,7 +90,7 @@ FILEPATHS = [ COLLECTION_NAME = "multimodal_data_nvingest" MILVUS_URI = "http://localhost:19530" -MINIO_ENDPOINT = "localhost:9010" +OBJECT_STORE_ENDPOINT = "localhost:9010" # Server Mode (Create NeMo Retriever Library client) client = NvIngestClient( @@ -127,7 +127,8 @@ ingestor = ingestor.embed( ingestor = ingestor.vdb_upload( collection_name=COLLECTION_NAME, milvus_uri=MILVUS_URI, - minio_endpoint=MINIO_ENDPOINT, + # nv-ingest client names this S3-compatible endpoint "minio_endpoint". + minio_endpoint=OBJECT_STORE_ENDPOINT, sparse=False, enable_images=True, recreate=False, diff --git a/docs/observability.md b/docs/observability.md index 587c6a70e..50b0695c5 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -54,33 +54,18 @@ Open the Zipkin UI at: **http://localhost:9411** ## View Metrics in Grafana -As part of the tracing, the RAG service also exports metrics like API request counts, LLM prompt and completion token count and words per chunk. - -These metrics are exposed on the metrics endpoint exposed by Otel collector at **http://localhost:8889/metrics** - -You can open Grafana UI and visualize these metrics on a dashboard by selecting data source as Prometheus and putting prometheus URL as **http://prometheus:9090** - -Open the Grafana UI at **http://localhost:3000** - - -### Create a Dashboard in Grafana - -To create a dashboard in [Grafana](https://grafana.com/) use the following procedure. - -1. Navigate to the Grafana UI at `http://localhost:3000`. - -2. Log in with the default credentials (`admin`/`admin`). - -3. Go to the **Dashboards** section and click **Import**. - -4. Upload the JSON file located in the `deploy/config` directory. - -5. Select the data source for the dashboard. Ensure that the data source is correctly configured to pull metrics from your Prometheus instance. - -6. Save the dashboard. - -7. View your metrics and traces. - +Metrics are exposed at **http://localhost:8889/metrics** and can be viewed in Grafana. + +1. Open Grafana: + - Docker Compose: + - Helm: port-forward Grafana and open . +2. Log in with `admin` / `admin`, unless you changed the Grafana credentials. +3. If the Prometheus data source is not configured, add it with URL `http://prometheus:9090`. +4. Go to **Dashboards** > **Import**. +5. Upload the dashboard JSON file: + - Standard RAG: `deploy/config/rag-metrics-dashboard.json` + - Agentic RAG: `deploy/config/agentic-rag-metrics-dashboard.json` +6. Select the `Prometheus` data source, then select **Import**. ## Query-to-Answer Pipeline and Studying Time Spent diff --git a/docs/performance-benchmarking.md b/docs/performance-benchmarking.md new file mode 100644 index 000000000..d614f0da1 --- /dev/null +++ b/docs/performance-benchmarking.md @@ -0,0 +1,374 @@ + +# Benchmark the Performance of Your NVIDIA RAG Blueprint System + +After you [deploy your NVIDIA RAG Blueprint system](readme.md#deployment-options-for-rag-blueprint), +benchmark its performance — latency, throughput, and per-stage timing — using the bundled `rag-perf` CLI. + +For accuracy benchmarks (RAGAS-based scoring of answer quality), see [`scripts/eval/README.md`](../scripts/eval/README.md) — the runnable `evaluate_rag.py` CLI. (For the conceptual / notebook overview of RAGAS metrics, see [Evaluate Your NVIDIA RAG Blueprint System](evaluate.md).) The two tools are complementary: `evaluate_rag.py` measures *how well* the system answers; `rag-perf` measures *how fast and at what concurrency*. + +## What `rag-perf` measures + +For each benchmark point, `rag-perf` runs two passes against the deployed RAG server and folds the results into a unified report: + +1. **Profiling pass** — direct async httpx requests against the RAG server. Captures **server-side per-stage timing** that a generic load tool cannot see: time spent in retrieval, reranking, and LLM TTFT, plus citation counts and relevance scores from the streamed response. From this pass `rag-perf` infers which stage is the **bottleneck** for the current configuration (`retrieval`, `reranking`, or `llm`). +2. **Load-test pass** — [aiperf](https://github.com/NVIDIA/aiperf) drives concurrent traffic against the same server through the bundled `nvidia_rag` endpoint plugin. Captures TTFT mean / p50 / p90 / p99, end-to-end latency p90 / p99, output-token throughput, request throughput, and error rate. + +The combined output is a single `RagMetricsSummary` rendered as a Rich terminal table, a Markdown report, and structured JSON / CSV for downstream graphing. Set `aiperf.enabled: false` in the YAML to skip the load-test pass entirely — useful for fast iteration on retrieval/reranker tuning. + + +## Quickstart + +This section runs a full benchmark in under three minutes against a default deployment using the queries shipped with the tool (`scripts/rag-perf/examples/queries.jsonl`). + +**Prerequisites**: the RAG server must be running and reachable on the network — for example, after completing the [Quickstart: self-hosted Docker](deploy-docker-self-hosted.md). Python ≥ 3.11 on the machine running the benchmark. + +```bash +# 1. Install rag-perf into its own uv-managed venv (one-time). +uv sync --project scripts/rag-perf + +# 2. Edit configs/single_run.yaml to point at your collection (rag.collection_names), +# then run it. +uv run --project scripts/rag-perf rag-perf -c scripts/rag-perf/configs/single_run.yaml + +# 3. View the report. +ls rag-perf-results/single_run/run_*/ +# report.md results.csv results.json profiling/ aiperf_rag_on/ +``` + +You should see a Rich-rendered table on stdout with the bottleneck stage, TTFT percentiles, throughput, and error rate. The `report.md` file contains the same data in Markdown form for sharing or PR attachments. + +> **Tip:** copy the preset to your own YAML (e.g. `cp configs/single_run.yaml my_run.yaml`) and edit fields there. The CLI takes only `--config`, so the YAML is the unit of versionable configuration. + + +## Three preset workflows + +`rag-perf` is a single command — `rag-perf -c ` — and behaviour is fully driven by the YAML you pass it. Three presets cover the common workflows; each section below describes when to use it, what it produces, and how to invoke it. + +| Preset | When to use | Approximate runtime | +|---|---|---| +| `quick_profile.yaml` | Iterating on retrieval / reranker tuning. No load test. | ~30 s | +| `single_run.yaml` | One concurrency level; full report (profiling + load test). | ~2 min | +| `sweep.yaml` | Compare across multiple values of any axis. Make `load.concurrency`, `rag.vdb_top_k`, or `rag.reranker_top_k` a list to sweep that axis; multiple lists give a full Cartesian sweep. | A few minutes per point. | + + +### Quick profiling + +Use this when you want server-side stage timing fast — for example to check whether retrieval or reranking is the bottleneck after changing `vdb_top_k`. No load is generated. + +Config: [`scripts/rag-perf/configs/quick_profile.yaml`](../scripts/rag-perf/configs/quick_profile.yaml). + +> **Before you run:** edit `rag.collection_names` in the config to point at a real collection on your deployed ingestor server. The shipped value is `[""]`, which the run will fail to retrieve from. + +```bash +uv run --project scripts/rag-perf rag-perf -c scripts/rag-perf/configs/quick_profile.yaml +``` + +Output (under `output.dir`, default `./rag-perf-results/quick_profile/`): + +``` +run_/ +├── profile_report.md +├── profile_results.json +└── profiling/ + └── profiler_records.jsonl +``` + +The `profile_*` filename prefix flags this as a profile-only run (no aiperf data). To convert any other config to profile-only, set `aiperf.enabled: false` in the YAML. + + +### Single-point run + +Use this when you want a single benchmark point with both passes — for example a regression check at a known-good concurrency before launching a larger sweep. + +Config: [`scripts/rag-perf/configs/single_run.yaml`](../scripts/rag-perf/configs/single_run.yaml). + +> **Before you run:** edit `rag.collection_names` in the config to point at a real collection on your deployed ingestor server. + +```bash +uv run --project scripts/rag-perf rag-perf -c scripts/rag-perf/configs/single_run.yaml +``` + +Output: + +``` +run_/ +├── report.md +├── results.csv +├── results.json +├── profiling/ +│ └── profiler_records.jsonl +└── aiperf_rag_on/ + ├── profile_export_aiperf.csv + ├── profile_export_aiperf.json + └── profile_export.jsonl +``` + + +### Concurrency sweep + +Use this to compare TTFT, latency, and throughput across multiple concurrency levels. The config's `load.concurrency` is a list (e.g. `[1, 4, 8, 16, 32]`); each value is a benchmark point. Edit the list in the YAML to add or remove levels. + +Config: [`scripts/rag-perf/configs/sweep.yaml`](../scripts/rag-perf/configs/sweep.yaml). + +> **Before you run:** edit `rag.collection_names` in the config to point at a real collection on your deployed ingestor server. + +```bash +uv run --project scripts/rag-perf rag-perf -c scripts/rag-perf/configs/sweep.yaml +``` + +Output is **nested** — each grid point gets its own subdirectory, with aggregate report files at the run root: + +``` +run_/ +├── report.md +├── results.csv # one row per point +├── results.json +└── iter_1/ + ├── CR:1_ISL:50_OSL:512_VDB-K:20_RERANKER-K:4_Model:.../ + │ ├── profiling/ + │ └── aiperf_rag_on/ + ├── CR:4_ISL:50_OSL:512_VDB-K:20_RERANKER-K:4_Model:.../ + └── ... +``` + +When `load.iterations > 1`, the entire grid is repeated and each repetition writes to its own `iter_/` subdirectory so variance can be measured across runs. + +To run a full Cartesian sweep across concurrency × `vdb_top_k` × `reranker_top_k`, change any of those fields from a scalar to a list (e.g. `rag.vdb_top_k: [20, 100]`). For overnight runs, set `load.sleep_between_points_s: 60` so the server has time to drain in-flight requests between points (this matches the blueprint pipeline's default). + + +## What you'll see on stdout + +Every invocation prints, in order: + +1. The **startup banner** plus a one-line summary of target / collection / top-k / input source / concurrency / aiperf state. +2. The **fully resolved configuration** as YAML, dumped verbatim so the run is reproducible from the terminal output alone (every field that drove the run, including the `synthetic` block and any defaults that were filled in). +3. For each grid point, a **per-iteration log line** plus the **aiperf shell command** in copy-pastable form (`$ python -m aiperf profile -m … --endpoint-type nvidia_rag …`) before the subprocess fires. +4. After each grid point completes, a **rich per-point summary table** with the full stage breakdown (retrieval / reranking / LLM TTFT) with percent-of-TTFT bars, citation count and relevance score, the inferred bottleneck, plus the load-test block (TTFT / E2E / throughput / error rate). +5. After all points finish, a **side-by-side comparison table** auto-labelled by whichever axis varied (concurrency / vdb_top_k / reranker_top_k / iter), and a one-liner identifying the optimal-throughput point. + +The same data is also persisted under `output.dir/run_/` — see [Output layout](#output-layout) — so terminal scrollback isn't load-bearing. + + +## Query inputs + +`rag-perf` needs a stream of queries to drive at the RAG server. The `input` block in the YAML chooses where they come from. **Set exactly one** of `input.file` or `input.synthetic` — they are mutually exclusive and validation fails if both are present. When neither is set, `synthetic` is auto-filled with defaults so a bare config still validates. + +### File-based queries + +Set `input.file` to a path. The format is auto-detected from the extension: + +- **`.jsonl`** — one JSON object per line. Each object must have a `query` key. Any field also defined under `rag.*` or `generation.*` becomes a per-query override (so a single file can mix multiple collections, top-K values, max-token caps, etc.): + ```jsonl + {"query": "What was NVIDIA revenue?", "collection_names": ["finance"]} + {"query": "Summarize the 10-K risks.", "vdb_top_k": 50} + {"query": "Show me chart-heavy pages.", "max_tokens": 1024} + ``` +- **`.csv`** — must have a `query` column. Other columns whose names match `rag.*` or `generation.*` field names become per-query overrides; CSV cell values are JSON-parsed when possible (so a cell like `["finance"]` is interpreted as a list, not a string). + +If the requested number of requests exceeds the file's row count, `rag-perf` re-uses queries according to `input.sampling`: + +- `random` (default) — random with replacement. +- `sequential` — cycle through in order. +- `shuffle-once` — shuffle once, then cycle. + +`input.seed` (default `42`) makes sampling reproducible across runs. + +A small example file ships at [`scripts/rag-perf/examples/queries.jsonl`](../scripts/rag-perf/examples/queries.jsonl); the three preset configs all point at it by default. + +### Synthetic queries (LLM-generated) + +When `input.synthetic` is set, `rag-perf` calls an OpenAI-compatible chat-completions endpoint *before* the benchmark to generate `synthetic.num_queries` queries, writes them to `synthetic.jsonl_output_path`, and then runs the benchmark from that JSONL — so the run is reproducible even though the queries were generated. + +Two modes: + +- **`random`** — the LLM generates queries from scratch using the prompt templates in `synthetic.prompts_file` (or the bundled defaults at `scripts/rag-perf/prompts/default_prompts.yaml` if unset). Useful when you don't have a query corpus yet but want plausible questions to drive load against your collection. +- **`dataset_based`** — the LLM is seeded with reference questions from a JSON dataset and asked to produce variations. Set either `synthetic.dataset_file` (explicit path) or `synthetic.dataset_name` (auto-lookup under `./datasets//train.json` etc.). Validation fails if neither is set in `dataset_based` mode. + +Key knobs: + +| Field | Purpose | +|---|---| +| `synthetic.num_queries` | How many distinct queries to generate. The query list is cycled if `total_requests` exceeds it. | +| `synthetic.min_query_tokens` | Approximate minimum token count per generated query. Combined with `generation.min_tokens == generation.max_tokens` and `generation.ignore_eos: true`, this lets you pin exact input/output token lengths for like-for-like comparisons. | +| `synthetic.generation_concurrency` | Parallel LLM calls during generation (default `8`). Each completed query is streamed to disk under a write lock — a mid-generation failure preserves everything finished so far. Raise for fast endpoints, lower for rate-limited ones. | +| `synthetic.temperature` | Sampling temperature for the generator LLM (default `0.9`). | +| `synthetic.disable_thinking` | Default `true`. Injects `chat_template_kwargs: {enable_thinking: false}` into the request so reasoning models (Nemotron Omni, Qwen-Reasoning, …) skip chain-of-thought and return the question in `content`; otherwise CoT can exhaust the token budget and leave `content` empty. Set `false` only for non-reasoning endpoints. | +| `synthetic.extra_body` | Escape hatch: arbitrary keys merged into the LLM request body, e.g. `{top_p: 0.95, presence_penalty: 0.5, response_format: {type: json_object}}`. Merged after `disable_thinking`, so explicit keys win. | +| `synthetic.llm_url` | OpenAI-compatible endpoint used for generation. Typically the same NIM the RAG server proxies, but it can be any chat-completions endpoint. | +| `synthetic.llm_model` | Model name passed to the endpoint. Empty string → auto-discover from `/v1/models`. | +| `synthetic.prompts_file` | Custom YAML of prompt templates. `null` falls back to the bundled `prompts/default_prompts.yaml`. | +| `synthetic.jsonl_output_path` | Where the generated queries land. Re-running with the same path overwrites it. | + +Because generated queries are persisted to disk, you can flip a synthetic-driven config to a file-driven one for subsequent runs simply by replacing the `synthetic` block with `file: ` — useful for keeping the load identical while iterating on retrieval or reranker tuning. + + +## Configuration reference + +Configuration is a YAML file validated by `rag_perf.config.RunConfig` (Pydantic v2). Top-level sections: + +### Top-level + +| Field | Type | Default | Purpose | +|---|---|---|---| +| `target` | object | — | Where the RAG server lives. | +| `aiperf` | object | — | Whether to run the aiperf load-test phase. | +| `load` | object | — | Load-generation parameters. | +| `rag` | object | — | RAG-specific request body fields forwarded to `/v1/generate`. | +| `generation` | object | — | LLM generation parameters (max_tokens, temperature, …). | +| `input` | object | — | Where queries come from. | +| `output` | object | — | Output directory and formats. | +| `model_name` | string | `nvidia/nemotron-3-super-120b-a12b` | Model name passed to aiperf via `-m`. Should match `APP_LLM_MODELNAME`. | +| `tokenizer` | string | `""` | Optional HuggingFace tokenizer ID for token counting; empty = use server-reported counts. | + +### `target` + +| Field | Type | Default | Purpose | +|---|---|---|---| +| `url` | string | `http://localhost:8081` | Base URL of the RAG server. | +| `timeout_s` | int | `300` | Per-request wall-clock timeout in seconds. | + +### `aiperf` + +| Field | Type | Default | Purpose | +|---|---|---|---| +| `enabled` | bool | `true` | When `false`, skip the aiperf load test (profile-only mode). | + +### `load` + +| Field | Type | Default | Purpose | +|---|---|---|---| +| `mode` | `concurrency` \| `request_rate` | `concurrency` | Load-generation strategy. | +| `concurrency` | int \| list[int] | `8` | Scalar = single point. List = sweep across that axis. | +| `request_rate` | float \| null | `null` | Target req/s when `mode=request_rate`. | +| `warmup_requests` | int | `10` | Requests sent (and discarded) before measurement. | +| `total_requests` | int | `200` | Total measured requests per point. | +| `duration_s` | float \| null | `null` | If set, run by wall-clock duration instead of request count. | +| `profile_requests` | int | `20` | Number of requests in the server-side profiling pass that runs before aiperf. | +| `iterations` | int | `1` | Repeat the full grid this many times. Each repetition writes to its own `iter_/` subdirectory. | +| `sleep_between_points_s` | int | `0` | Seconds to sleep between grid points so the server can drain. `60` matches the blueprint pipeline's default. | + +### `rag` + +Forwarded verbatim to `POST /v1/generate`. Any field can be overridden per-query (see [Inputs](#inputs)). + +| Field | Type | Default | Purpose | +|---|---|---|---| +| `collection_names` | list[string] | `["default"]` | Vector-DB collection(s) to search. | +| `vdb_top_k` | int \| list[int] (each 1–400) | `100` | Chunks retrieved from the vector DB before reranking. Scalar = single value, list = sweep axis. | +| `reranker_top_k` | int \| list[int] (each 1–25) | `10` | Chunks passed to the LLM after reranking. Scalar = single value, list = sweep axis. | +| `enable_reranker` | bool | `true` | Whether to run the reranker stage. | +| `enable_citations` | bool | `true` | Whether the server returns citation chunks. | +| `use_knowledge_base` | bool | `true` | False = skip retrieval, send query bare to the LLM. | +| `confidence_threshold` | float (0–1) | `0.0` | Minimum relevance score for retrieved chunks. | + +### `generation` + +| Field | Type | Default | Purpose | +|---|---|---|---| +| `max_tokens` | int | `512` | Maximum output tokens. | +| `min_tokens` | int \| null | `null` | Minimum output tokens. Set equal to `max_tokens` to pin output length. | +| `ignore_eos` | bool | `false` | Pass `ignore_eos:true` to the inference backend; combine with `min_tokens` for fixed output length. | +| `temperature` | float (0–2) | `0.0` | Sampling temperature. | + +### `input` + +Set **exactly one** of `file` or `synthetic` (mutually exclusive). When both are unset, `synthetic` is auto-filled with defaults so a bare config still validates. + +| Field | Type | Default | Purpose | +|---|---|---|---| +| `file` | string \| null | `null` | Path to a query file. Format auto-detected from extension: `.jsonl` (one JSON object per line with a `query` key) or `.csv` (must have a `query` column). Mutually exclusive with `synthetic`. | +| `synthetic` | object \| null | `null` (auto-filled when `file` is also null) | LLM-generated synthetic queries. Fields below. Mutually exclusive with `file`. | +| `sampling` | string | `random` | Sampling strategy for the query list (`random`, `sequential`, `shuffle-once`). | +| `seed` | int | `42` | RNG seed for reproducible sampling. | +| `synthetic.mode` | `random` \| `dataset_based` | `random` | LLM-based generation strategy. | +| `synthetic.num_queries` | int | `50` | Distinct synthetic queries to generate. | +| `synthetic.min_query_tokens` | int | `50` | Approximate min token count per generated query. | +| `synthetic.llm_url` | string | `http://localhost:8999/v1/chat/completions` | OpenAI-compatible endpoint for generation. | +| `synthetic.llm_model` | string | `""` | Model name; empty = auto-discover from `/v1/models`. | +| `synthetic.prompts_file` | string \| null | `null` | Custom prompt templates YAML; null = use bundled defaults. | +| `synthetic.jsonl_output_path` | string | `./rag-perf-synthetic-queries.jsonl` | Where generated queries are written. | +| `synthetic.dataset_file` | string \| null | `null` | Explicit dataset file (required for `dataset_based`). | +| `synthetic.dataset_name` | string \| null | `null` | Dataset name for auto-lookup under `./datasets/`. | + +### `output` + +| Field | Type | Default | Purpose | +|---|---|---|---| +| `dir` | string | `./rag-perf-results` | Root output directory; a timestamped subdir is created per run. | +| `formats` | list[string] | `[json, csv]` | Export formats: `json`, `csv`, `jsonl_raw`. | +| `markdown_report` | bool | `true` | Write a Markdown summary (`report.md`). | +| `save_responses` | bool | `false` | Persist full generated text per request (large). | +| `cluster` | string | `""` | Cluster identifier stamped into artifact dir names. | +| `gpu` | string | `""` | GPU type stamped into artifact dir names. | +| `experiment_name` | string | `""` | Experiment label stamped into artifact dir names. | + +## CLI surface + +The CLI is intentionally minimal. The YAML is the single source of truth for behaviour; to vary a parameter, edit the file or copy it to a new one. This keeps every run reproducible from a single artefact you can commit to version control. + +| Flag | Purpose | +|---|---| +| `-c / --config FILE` | Required. Path to the YAML config. | +| `--help` | Show usage and exit. | +| `--version` | Print the rag-perf version and exit. | + + +## Output layout + +Every invocation creates a fresh timestamped directory `output.dir/run_/`. The contents depend on the run shape: + +- **Single point + `aiperf.enabled=true`** — flat layout: + ``` + run_/{report.md, results.csv, results.json, profiling/, aiperf_rag_on/} + ``` +- **Single point + `aiperf.enabled=false`** — flat, profile-only layout: + ``` + run_/{profile_report.md, profile_results.json, profiling/} + ``` +- **Multiple points or `load.iterations > 1`** — nested layout: + ``` + run_/ + ├── report.md, results.csv, results.json # aggregate, one row per point + └── iter_/ + └── CR:_ISL:_OSL:_VDB-K:_RERANKER-K:_Model:[_Cluster:][_GPU:][_Experiment:]/ + ├── profiling/ + └── aiperf_rag_on/ + ``` + + +## Source layout + +``` +scripts/rag-perf/ +├── pyproject.toml +├── configs/ +│ ├── quick_profile.yaml # profile-only preset +│ ├── single_run.yaml # one concurrency, full report +│ └── sweep.yaml # multi-axis sweep (concurrency / vdb_top_k / reranker_top_k as scalar or list) +├── examples/queries.jsonl # sample query JSONL +├── prompts/default_prompts.yaml # synthetic-query prompt templates +└── rag_perf/ + ├── __init__.py # public API re-exports + ├── __main__.py # python -m rag_perf entry point + ├── config.py # RunConfig + sub-models + the three enums + ├── query.py # QueryLoader + SyntheticQueryGenerator + ├── runner.py # RagProfiler + AiperfRunner + BenchmarkRunner.run + ├── reporting.py # MetricsAggregator + Reporter + result dataclasses + ├── cli.py # Single Click command + startup banner + └── plugin/ # aiperf endpoint plugin (nvidia_rag) +``` + +Unit tests live under `tests/unit/test_rag_perf/` (run with `uv run --project scripts/rag-perf python -m pytest tests/unit/test_rag_perf/`). + + +## Related Topics + +- [`scripts/eval/README.md`](../scripts/eval/README.md) — `evaluate_rag.py`, the runnable RAGAS-based accuracy benchmark CLI. +- [Evaluate Your NVIDIA RAG Blueprint System](evaluate.md) — conceptual / notebook overview of the RAGAS metrics. +- [RAG Accuracy Benchmarks](accuracy-benchmarks.md) — published accuracy results across datasets and configurations. +- [Best Practices for Common Settings](accuracy_perf.md) — accuracy / performance tradeoff guidance. +- [NVIDIA RAG Blueprint Documentation](readme.md) +- Underlying load engine: [aiperf](https://github.com/NVIDIA/aiperf). diff --git a/docs/project.json b/docs/project.json index cccd9d8ae..071cceee0 100644 --- a/docs/project.json +++ b/docs/project.json @@ -1,4 +1,4 @@ { "name": "NVIDIA-RAG-blueprint", - "version": "2.5.1" + "version": "2.6.0" } \ No newline at end of file diff --git a/docs/prompt-customization.md b/docs/prompt-customization.md index eeffdd259..301402ab6 100644 --- a/docs/prompt-customization.md +++ b/docs/prompt-customization.md @@ -79,11 +79,11 @@ The `prompt.yaml` file contains a set of prompt templates used throughout the RA - **Usage:** Produces streamlined summaries when `shallow_summary: true` is set during document ingestion. Uses a simplified prompt optimized for fast text-only processing without multimodal elements (tables, images, charts). - **Context:** Automatically selected when shallow extraction is enabled. For full multimodal extraction, `document_summary_prompt` is used instead. See [document summarization](./summarization.md) for details on shallow vs. full extraction. -### 17. `filter_expression_generator_prompt` -- **Purpose:** Converts natural language queries into precise metadata filter expressions for targeted document retrieval. +### 17. `filter_expression_generator_prompt_milvus` / `filter_expression_generator_prompt_elasticsearch` +- **Purpose:** Converts natural language queries into precise metadata filter expressions for targeted document retrieval. The active prompt is selected automatically based on the configured vector store (`APP_VECTORSTORE_NAME`): `filter_expression_generator_prompt_milvus` produces a Milvus boolean expression string, while `filter_expression_generator_prompt_elasticsearch` produces an Elasticsearch Query DSL clause array. - **Usage:** Automatically generates filter expressions when `enable_filter_generator: true` is set in the `/generate` or `/chat/completions` API. The LLM analyzes the user's query and the collection's metadata schema to create appropriate filters. -- **Context:** This enables users to ask questions in natural language (e.g., "Show me Ford vehicles with infotainment features") and have the system automatically convert them into metadata filters (e.g., `content_metadata["manufacturer"] == "ford" AND array_contains(content_metadata["features"], "infotainment")`). -- **Customization:** For domain-specific applications, customize this prompt to map industry terminology to your metadata fields. See the [Customizing Filter Expression Generator Prompt](./custom-metadata.md#customizing-filter-expression-generator-prompt) section for detailed examples and best practices. +- **Context:** This enables users to ask questions in natural language (e.g., "Show me Ford vehicles with infotainment features") and have the system automatically convert them into metadata filters. For Milvus: `content_metadata["manufacturer"] == "ford" AND array_contains(content_metadata["features"], "infotainment")`. For Elasticsearch: `[{"bool": {"must": [{"term": {"metadata.content_metadata.manufacturer.keyword": "ford"}}, {"term": {"metadata.content_metadata.features.keyword": "infotainment"}}]}}]`. +- **Customization:** For domain-specific applications, customize the appropriate prompt to map industry terminology to your metadata fields. See the [Customizing Filter Expression Generator Prompt](./custom-metadata.md#customizing-filter-expression-generator-prompt) section for detailed examples and best practices. ### 18. `image_captioning_prompt` - **Purpose:** Generates detailed descriptions of images encountered during document ingestion. diff --git a/docs/python-client.md b/docs/python-client.md index 5c9bc33ea..c7e95281f 100644 --- a/docs/python-client.md +++ b/docs/python-client.md @@ -81,7 +81,7 @@ Launch dependent services and NIMs. For more information, refer to [Docker prere import os from getpass import getpass - # del os.environ['NVIDIA_API_KEY'] ## delete key and reset if needed + # del os.environ['NGC_API_KEY'] ## delete key and reset if needed if os.environ.get("NGC_API_KEY", "").startswith("nvapi-"): print("Valid NGC_API_KEY already in environment. Delete to reset") else: @@ -94,9 +94,11 @@ Launch dependent services and NIMs. For more information, refer to [Docker prere 3. Login to `nvcr.io` to pull dependency containers: `echo "${NGC_API_KEY}" | docker login nvcr.io -u '$oauthtoken' --password-stdin` -### Setup Milvus Vector Database Services +### Setup Milvus Vector Database Services (optional) -Milvus uses GPU indexing by default. Set the correct GPU ID. For CPU-only mode, refer to [milvus-configuration.md](https://github.com/NVIDIA-AI-Blueprints/rag/blob/main/docs/milvus-configuration.md). +Use this section only if you opt into Milvus as the vector database. The default Docker deployment uses Elasticsearch. Refer to [Vector database configuration](https://github.com/NVIDIA-AI-Blueprints/rag/blob/main/docs/change-vectordb.md) for more information. For Milvus-specific tuning (GPU/CPU, auth), refer to [Milvus configuration](https://github.com/NVIDIA-AI-Blueprints/rag/blob/main/docs/milvus-configuration.md). + +When Milvus is enabled, it uses GPU indexing by default. Set the correct GPU ID. For CPU-only mode, refer to [milvus-configuration.md](https://github.com/NVIDIA-AI-Blueprints/rag/blob/main/docs/milvus-configuration.md). 1. Set the GPU device ID: @@ -104,10 +106,10 @@ Milvus uses GPU indexing by default. Set the correct GPU ID. For CPU-only mode, os.environ["VECTORSTORE_GPU_DEVICE_ID"] = "0" ``` -2. Start the Milvus vector database: +2. Start the Milvus vector database (Compose `milvus` profile): ```bash -docker compose -f ../deploy/compose/vectordb.yaml up -d +docker compose -f ../deploy/compose/vectordb.yaml --profile milvus up -d ``` ### Setup NIMs @@ -157,7 +159,7 @@ Verify all containers are running and healthy. NAMES STATUS nemotron-ranking-ms Up ... (healthy) compose-page-elements-1 Up ... -compose-nemoretriever-ocr-1 Up ... +compose-nemotron-ocr-1 Up ... compose-graphic-elements-1 Up ... compose-table-structure-1 Up ... nemotron-embedding-ms Up ... (healthy) @@ -172,7 +174,7 @@ nim-llm-ms Up ... (healthy) 2. Configure NeMo Retriever Library to use NVIDIA hosted cloud APIs using the following hosted models. -- os.environ["OCR_HTTP_ENDPOINT"] = "https://ai.api.nvidia.com/v1/cv/nvidia/nemoretriever-ocr" +- os.environ["OCR_HTTP_ENDPOINT"] = "https://ai.api.nvidia.com/v1/cv/nvidia/nemotron-ocr-v1" - os.environ["OCR_INFER_PROTOCOL"] = "http" os.environ["YOLOX_HTTP_ENDPOINT"] = ( @@ -252,6 +254,10 @@ else: ingestor = NvidiaRAGIngestor(config=config_ingestor) ``` +:::{note} +The API examples below use `vdb_endpoint="http://localhost:9200"`, matching the default Elasticsearch vector database. If you use Milvus instead, use the `http://localhost:19530` URL value (or your Milvus service URL) for every `vdb_endpoint` argument and confirm that the `APP_VECTORSTORE_*` value targets Milvus. +::: + ### Create a New Collection ```python @@ -273,7 +279,7 @@ print(response) ### List All Collections ```python -response = ingestor.get_collections(vdb_endpoint="http://localhost:19530") +response = ingestor.get_collections(vdb_endpoint="http://localhost:9200") print(response) ``` @@ -284,7 +290,7 @@ Upload documents to a collection. To update existing documents, use `update_docu ```python response = await ingestor.upload_documents( collection_name="test_library", - vdb_endpoint="http://localhost:19530", + vdb_endpoint="http://localhost:9200", blocking=False, split_options={"chunk_size": 512, "chunk_overlap": 150}, filepaths=[ @@ -322,7 +328,7 @@ print(response) ```python response = await ingestor.update_documents( collection_name="test_library", - vdb_endpoint="http://localhost:19530", + vdb_endpoint="http://localhost:9200", blocking=False, filepaths=["../data/multimodal/woods_frost.docx"], generate_summary=False @@ -336,7 +342,7 @@ print(response) ```python response = ingestor.get_documents( collection_name="test_library", - vdb_endpoint="http://localhost:19530", + vdb_endpoint="http://localhost:9200", ) print(response) ``` @@ -353,11 +359,11 @@ from nvidia_rag.utils.configuration import NvidiaRAGConfig # config_rag = NvidiaRAGConfig.from_dict({ # "llm": { -# "model_name": "nvidia/llama-3.3-nemotron-super-49b-v1.5", +# "model_name": "nvidia/nemotron-3-super-120b-a12b", # "server_url": "", # }, # "embeddings": { -# "model_name": "nvidia/llama-nemotron-embed-1b-v2", +# "model_name": "nvidia/llama-nemotron-embed-vl-1b-v2", # "server_url": "https://integrate.api.nvidia.com/v1", # }, # "ranking": { @@ -598,7 +604,7 @@ rag_custom = NvidiaRAG(config=config_rag, prompts="custom_prompts.yaml") response = ingestor.delete_documents( collection_name="test_library", document_names=["../data/multimodal/multimodal_test.pdf"], - vdb_endpoint="http://localhost:19530" + vdb_endpoint="http://localhost:9200" ) print(response) ``` @@ -606,7 +612,7 @@ print(response) ## Delete Collections ```python -response = ingestor.delete_collections(vdb_endpoint="http://localhost:19530", collection_names=["test_library"]) +response = ingestor.delete_collections(vdb_endpoint="http://localhost:9200", collection_names=["test_library"]) print(response) ``` For more information, refer to [Prompt Customization](prompt-customization.md#prompt-customization-in-python-library-mode). diff --git a/docs/query-to-answer-pipeline.md b/docs/query-to-answer-pipeline.md index 43d1a4bde..2549e3a96 100644 --- a/docs/query-to-answer-pipeline.md +++ b/docs/query-to-answer-pipeline.md @@ -20,7 +20,6 @@ A single RAG request passes through the following stages in sequence, and option Additional optional logic (for example, [query decomposition](query_decomposition.md) or [self-reflection](self-reflection.md)) may run around or within these stages, but the core flow is the sequence described above. - ## How to Study Time Spent in the Pipeline You can analyze where time is spent in two ways: **distributed traces** (per-request, per-stage) and **aggregate metrics** (histograms over many requests). diff --git a/docs/readme.md b/docs/readme.md index acc20cd5e..ad3e0b4e9 100644 --- a/docs/readme.md +++ b/docs/readme.md @@ -70,6 +70,7 @@ After you deploy the RAG blueprint, you can customize it for your use cases. - Common configurations - [Best Practices for Common Settings](accuracy_perf.md) + - [Agentic RAG](agentic-rag.md) - [Change the LLM or Embedding Model](change-model.md) - [Customize LLM Parameters at Runtime](llm-params.md) - [Customize Prompts](prompt-customization.md) @@ -86,7 +87,7 @@ After you deploy the RAG blueprint, you can customize it for your use cases. - [Audio Ingestion Support](audio_ingestion.md) - [Custom Metadata Support](custom-metadata.md) - [File System Access to Extraction Results](mount-ingestor-volume.md) - - [Multimodal Embedding Support (Early Access)](vlm-embed.md) + - [Multimodal Retriever — VLM Embedding & VLM Reranker (Early Access)](multimodal-retriever.md) - [OCR Configuration Guide](nemoretriever-ocr.md) - [Enhanced PDF Extraction](nemotron-parse-extraction.md) - [Text-Only Ingestion](text_only_ingest.md) @@ -100,6 +101,7 @@ After you deploy the RAG blueprint, you can customize it for your use cases. - [Change the Vector Database](change-vectordb.md) - [Hybrid Search](hybrid_search.md) - [Milvus Configuration](milvus-configuration.md) + - [Elasticsearch Configuration](elasticsearch-configuration.md) - [Query Decomposition](query_decomposition.md) diff --git a/docs/release-notes.md b/docs/release-notes.md index 3ffb3083b..1d4bf6881 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -8,6 +8,36 @@ This documentation contains the release notes for [NVIDIA RAG Blueprint](readme. +## Release 2.6.0 (TBD) + +This release adds [Agentic RAG](./agentic-rag.md) support with plan-and-execute pipelines, streaming responses, and UI integration; changes the default vector database to Elasticsearch and the default object store to SeaweedFS; adds [Red Hat OpenShift](./deploy-helm-openshift.md) support for Helm-based deployment; and introduces new [agent skills](../skill-source/README.md) for deployment, evaluation, and performance tooling. + +### Highlights + +This release includes the following key updates: + +- [Added Agentic RAG support](./agentic-rag.md), including the plan-and-execute pipeline, streaming responses, and RAG UI integration. +- Changed the default vector database to Elasticsearch. + - [GPU accelerated support needs enterprise access](./elasticsearch-configuration.md) and is disabled by default. + - [Milvus](./change-vectordb.md) remains available as an optional vector database backend. +- Changed the default object store to SeaweedFS from MinIO. +- Updated the default LLM to `nvidia/nemotron-3-super-120b-a12b` and enabled Nemotron reasoning by default in deployment configurations. +- Promoted `nvidia/llama-nemotron-embed-vl-1b-v2` as the default embedding model. The text embedding model `nvidia/llama-nemotron-embed-1b-v2` remains available as [an optional configuration](./change-model.md#switch-from-the-vlm-embedder-to-the-text-only-embedder). +- Added [VLM reranker support](./change-model.md#switch-to-the-vlm-reranker) as an opt-in. +- Added dynamic filter expression generation for Elasticsearch. +- Published [RAG performance tooling](../scripts/rag-perf/) and [skills](../skill-source/README.md) to use it easily. +- Published the [RAG evaluation framework](../scripts/eval/README.md) and [skills](../skill-source/README.md) to use it easily. +- Updated NV-Ingest to version 26.3.0. +- Updated OCR NIM naming from `nemoretriever-ocr-v1` to `nemotron-ocr-v1`. +- Added OpenClaw plugin for agent-driven deploy/configure/eval workflows. +- Added [Red Hat OpenShift and OKD support](./deploy-helm-openshift.md) for Helm deployments. + +### Fixed Known Issues + +The following known issues have been resolved in this release: + +- Fixed default LLM sampling parameter handling for non-NVIDIA providers. + ## Release 2.5.1 (2026-04-29) This release adds opt-in support for [`nvidia/nemotron-3-nano-omni-30b-a3b-reasoning`](https://build.nvidia.com/nvidia/nemotron-3-nano-omni-30b-a3b-reasoning/modelcard) as the Vision-Language Model and ships first-class support for VLM reasoning streams. Defaults are unchanged from 2.5.0 (text-only embedder, VLM inference disabled); the new VLM is opt-in via `ENABLE_VLM_INFERENCE=True`. Tracked under [BCS-445](https://jirasw.nvidia.com/browse/BCS-445). @@ -32,7 +62,7 @@ This release introduces support for the Nemotron-super-3 model, updates NIMs to This release includes the following key updates: - **Nemotron-super-3 model support.** You can now integrate the Nemotron-super-3 model by following the steps outlined in [Change the Inference or Embedding Model](change-model.md). -- **NIMs updated to latest versions.** +- **NIMs updated to latest versions.** The following model updates are included: - `nvidia/llama-3.2-nv-embedqa-1b-v2` → `nvidia/llama-nemotron-embed-1b-v2` - `nvidia/llama-3.2-nv-rerankqa-1b-v2` → `nvidia/llama-nemotron-rerank-1b-v2` @@ -60,7 +90,7 @@ The following known issues have been resolved in this release: This release adds new features to the RAG pipeline for supporting agent workflows and enhances generations with VLMs augmenting multimodal input. -### Highlights +### Highlights This release contains the following key changes: diff --git a/docs/retrieval-only-deployment.md b/docs/retrieval-only-deployment.md index 7f7f94475..2c4c194a3 100644 --- a/docs/retrieval-only-deployment.md +++ b/docs/retrieval-only-deployment.md @@ -44,7 +44,7 @@ For monitoring deployment progress, refer to [Deploy on Kubernetes with Helm](./ 1. [Get an API Key](api-key.md). -2. Install Docker Engine and Docker Compose. Ensure Docker Compose version is 2.29.1 or later. +2. Install Docker Engine 24.0 or later and Docker Compose version 2.29.1 or later. Docker Engine 29.5.x is not supported for this release because it can fail to pull required NGC images. 3. Authenticate Docker with NGC: @@ -85,14 +85,14 @@ Choose one of the following options based on your deployment preference. #### Option A: Self-Hosted NIMs -Instead of starting all NIMs, use the `text-embed` profile to start only the embedding and reranking services: +Instead of starting all NIMs, start only the VLM embedding and reranking services: ```bash -USERID=$(id -u) docker compose -f deploy/compose/nims.yaml up -d nemotron-ranking-ms nemotron-embedding-ms +USERID=$(id -u) docker compose -f deploy/compose/nims.yaml up -d nemotron-ranking-ms nemotron-vlm-embedding-ms ``` :::{note} -The `text-embed` profile starts only `nemotron-embedding-ms` and `nemotron-ranking-ms `, which is sufficient for retrieval operations. The LLM NIM (`nim-llm-ms`) is not started, saving significant GPU memory. +The RAG server defaults to `nvidia/llama-nemotron-embed-vl-1b-v2` at `nemotron-vlm-embedding-ms:8000/v1`, so retrieval-only deployments should start `nemotron-vlm-embedding-ms` with `nemotron-ranking-ms`. The LLM NIM (`nim-llm-ms`) is not started, saving significant GPU memory. ::: Wait for the services to become healthy: @@ -106,7 +106,7 @@ Expected output: ```output NAMES STATUS nemotron-ranking-ms Up 5 minutes (healthy) -nemotron-embedding-ms Up 5 minutes (healthy) +nemotron-vlm-embedding-ms Up 5 minutes (healthy) ``` #### Option B: NVIDIA-Hosted NIMs @@ -115,12 +115,13 @@ For an even lighter deployment, use [NVIDIA-hosted NIMs](deploy-docker-nvidia-ho ```bash # Configure to use NVIDIA-hosted endpoints -export APP_EMBEDDINGS_SERVERURL="" -export APP_RANKING_SERVERURL="" +export APP_EMBEDDINGS_SERVERURL="https://integrate.api.nvidia.com/v1" +export APP_RANKING_SERVERURL="https://integrate.api.nvidia.com/v1" +export APP_LLM_SERVERURL="https://integrate.api.nvidia.com/v1" ``` :::{note} -When `APP_EMBEDDINGS_SERVERURL` and `APP_RANKING_SERVERURL` are empty, the RAG server uses NVIDIA-hosted API endpoints (requires valid `NGC_API_KEY`). +For NVIDIA-hosted endpoints, use the explicit API Catalog base URL. The LLM URL is set to the hosted endpoint so retrieval-only health checks do not try to connect to a local LLM container that is intentionally not deployed. ::: ### Step 3: Start the Vector Database @@ -131,6 +132,12 @@ docker compose -f deploy/compose/vectordb.yaml up -d ### Step 4: Start the RAG Server +For self-hosted retrieval-only deployments, set the LLM endpoint to the hosted API Catalog URL before starting the RAG server so dependency health checks do not try to connect to `nim-llm:8000`: + +```bash +export APP_LLM_SERVERURL="https://integrate.api.nvidia.com/v1" +``` + ```bash docker compose -f deploy/compose/docker-compose-rag-server.yaml up -d rag-server ``` @@ -198,13 +205,17 @@ payload = { You can also use the provided CLI script for search operations: ```bash +# Install CLI dependencies once +pip install -r scripts/requirements.txt + # Basic search python scripts/retriever_api_usage.py --mode search "Tell me about the product features" # Search with specific collection python scripts/retriever_api_usage.py \ --mode search \ - --payload-json '{"collection_names":["my_collection"], "reranker_top_k": 5}' \ + --collection-name my_collection \ + --payload-json '{"reranker_top_k": 5}' \ "What is the return policy?" # Save results to file @@ -216,32 +227,75 @@ python scripts/retriever_api_usage.py \ ## Deploy with Helm (Kubernetes) -For Kubernetes deployments, configure the Helm chart to disable the LLM NIM: +Use the same cluster prerequisites as a full Helm deployment, including the ECK operator for the default Elasticsearch vector database—refer to [Deploy on Kubernetes with Helm](deploy-helm.md#prerequisites). + +In the v2.6.0 chart, the embedding and reranking NIMs are enabled by default; the LLM NIM (`nim-llm`) is also enabled by default and must be disabled for retrieval-only mode. The VLM generation (`nim-vlm`) and VLM captioning (`nim-vlm-captioning`) services are disabled by default and require no action. + +| Component | v2.6.0 default | Retrieval-only | +|-----------|----------------|----------------| +| `nim-llm` (LLM) | enabled | **set to `false`** | +| `nvidia-nim-llama-nemotron-embed-vl-1b-v2` (VLM embedder) | enabled | leave enabled | +| `nvidia-nim-llama-nemotron-rerank-1b-v2` (text reranker) | enabled | leave enabled | +| `nim-vlm`, `nim-vlm-captioning` | disabled | leave disabled | + +### Option A: --set flag ```bash -helm upgrade --install rag nvidia-blueprint-rag \ - --namespace rag \ +helm upgrade --install rag -n rag https://helm.ngc.nvidia.com/nvstaging/blueprint/charts/nvidia-blueprint-rag-v2.6.0.tgz \ + --username '$oauthtoken' \ + --password "${NGC_API_KEY}" \ --set nimOperator.nim-llm.enabled=false \ - --set nimOperator.nvidia-nim-llama-32-nv-embedqa-1b-v2.enabled=true \ - --set nimOperator.nvidia-nim-llama-32-nv-rerankqa-1b-v2.enabled=true \ --set imagePullSecret.password=$NGC_API_KEY \ --set ngcApiSecret.password=$NGC_API_KEY ``` -Or modify `values.yaml`: +### Option B: values.yaml override ```yaml -# Disable LLM NIM for retrieval-only deployment +# Disable LLM NIM for retrieval-only deployment. +# VLM embedder + text reranker stay on chart defaults (enabled). nimOperator: nim-llm: enabled: false +``` - # Keep embedding and reranking NIMs enabled - nvidia-nim-llama-32-nv-embedqa-1b-v2: - enabled: true +### (Optional) Use the text embedder instead of the VLM embedder - nvidia-nim-llama-32-nv-rerankqa-1b-v2: +If you don't want to pull the VLM embedding NIM, switch to the text embedder by flipping the two embedder enable flags and pointing the embedding env vars at the text NIM: + +```yaml +nimOperator: + nim-llm: + enabled: false + nvidia-nim-llama-nemotron-embed-vl-1b-v2: + enabled: false + nvidia-nim-llama-nemotron-embed-1b-v2: enabled: true + +envVars: + APP_EMBEDDINGS_MODELNAME: "nvidia/llama-nemotron-embed-1b-v2" + APP_EMBEDDINGS_SERVERURL: "nemotron-embedding-ms:8000/v1" + +ingestor-server: + envVars: + APP_EMBEDDINGS_MODELNAME: "nvidia/llama-nemotron-embed-1b-v2" + APP_EMBEDDINGS_SERVERURL: "nemotron-embedding-ms:8000/v1" + +nv-ingest: + envVars: + EMBEDDING_NIM_ENDPOINT: "http://nemotron-embedding-ms:8000/v1" + EMBEDDING_NIM_MODEL_NAME: "nvidia/llama-nemotron-embed-1b-v2" +``` + +Apply the chart with the values override: + +```bash +helm upgrade --install rag -n rag https://helm.ngc.nvidia.com/nvstaging/blueprint/charts/nvidia-blueprint-rag-v2.6.0.tgz \ + --username '$oauthtoken' \ + --password "${NGC_API_KEY}" \ + --set imagePullSecret.password=$NGC_API_KEY \ + --set ngcApiSecret.password=$NGC_API_KEY \ + -f values.yaml ``` @@ -299,16 +353,29 @@ GPU requirements depend on the specific embedding and reranking models used. The ## Troubleshooting -### Generate endpoint returns error +### Generate endpoint behavior + +Retrieval-only mode is intended for the `/search` endpoint. The `/generate` endpoint requires an LLM endpoint; if you do not configure one, use `/search` instead. + +### Health check reports missing LLM + +Retrieval-only mode does not start `nim-llm-ms`. If dependency health checks report `Cannot connect to host nim-llm:8000`, set the LLM endpoint to the hosted API Catalog URL and recreate the RAG server container: + +```bash +export APP_LLM_SERVERURL="https://integrate.api.nvidia.com/v1" +docker compose -f deploy/compose/docker-compose-rag-server.yaml up -d --force-recreate rag-server +``` + +### Collection does not exist -This is expected behavior in retrieval-only mode. The `/generate` endpoint requires an LLM, which is not deployed. Use the `/search` endpoint instead. +Starting the ingestor server does not create a collection by itself. Create or ingest into the target collection first, or pass the name of an existing collection with `--collection-name`. ### Embedding service not healthy Check the embedding NIM logs: ```bash -docker logs nemotron-embedding-ms +docker logs nemotron-vlm-embedding-ms ``` Ensure the model cache directory has proper permissions: diff --git a/docs/self-reflection.md b/docs/self-reflection.md index 5e89eaf71..0e3c14557 100644 --- a/docs/self-reflection.md +++ b/docs/self-reflection.md @@ -23,7 +23,7 @@ ENABLE_REFLECTION=true MAX_REFLECTION_LOOP=3 # Maximum number of refinement attempts (default: 3) CONTEXT_RELEVANCE_THRESHOLD=1 # Minimum relevance score 0-2 (default: 1) RESPONSE_GROUNDEDNESS_THRESHOLD=1 # Minimum groundedness score 0-2 (default: 1) -REFLECTION_LLM="nvidia/llama-3.3-nemotron-super-49b-v1.5" # Model for reflection (default) +REFLECTION_LLM="nvidia/nemotron-3-super-120b-a12b" # Model for reflection (default) REFLECTION_LLM_SERVERURL="nim-llm:8000" # Default on-premises endpoint for reflection LLM ``` @@ -125,7 +125,7 @@ If you don't have sufficient GPU resources for on-premises deployment, you can u export REFLECTION_LLM_SERVERURL="" # Choose the reflection model (options below) - export REFLECTION_LLM="nvidia/llama-3.3-nemotron-super-49b-v1.5" # Default option + export REFLECTION_LLM="nvidia/nemotron-3-super-120b-a12b" # Default option # export REFLECTION_LLM="meta/llama-3.1-405b-instruct" # Alternative option ``` @@ -155,7 +155,7 @@ You can enable self-reflection through Helm when you deploy the RAG Blueprint. ### Prerequisites - Only on-premises reflection deployment is supported in Helm -- The model used is: `nvidia/llama-3.3-nemotron-super-49b-v1.5`. +- The model used is: `nvidia/nemotron-3-super-120b-a12b`. ### Deployment Steps @@ -173,7 +173,7 @@ You can enable self-reflection through Helm when you deploy the RAG Blueprint. MAX_REFLECTION_LOOP: "3" CONTEXT_RELEVANCE_THRESHOLD: "1" RESPONSE_GROUNDEDNESS_THRESHOLD: "1" - REFLECTION_LLM: "nvidia/llama-3.3-nemotron-super-49b-v1.5" + REFLECTION_LLM: "nvidia/nemotron-3-super-120b-a12b" REFLECTION_LLM_SERVERURL: "nim-llm:8000" ``` diff --git a/docs/service-port-gpu-reference.md b/docs/service-port-gpu-reference.md index bb629ae74..a169dcf28 100644 --- a/docs/service-port-gpu-reference.md +++ b/docs/service-port-gpu-reference.md @@ -20,33 +20,32 @@ The following table provides a comprehensive reference of all services, their po | Service | Container Name | Host Port(s) | Container Port(s) | Default GPU ID | Environment Variable | Notes | |---------|---------------|--------------|-------------------|----------------|---------------------|-------| | LLM | `nim-llm-ms` | 8999 | 8000 | 1 | `LLM_MS_GPU_ID` | Main language model | -| Embedding | `nemotron-embedding-ms` | 9080 | 8000 | 0 | `EMBEDDING_MS_GPU_ID` | Text embeddings | -| VLM Embedding | `nemotron-vlm-embedding-ms` | 9081 | 8000 | 0 | `VLM_EMBEDDING_MS_GPU_ID` | Vision-language embeddings (opt-in, profile: vlm-embed) | +| Text Embedding | `nemotron-embedding-ms` | 9080 | 8000 | 0 | `EMBEDDING_MS_GPU_ID` | Optional text embeddings (profile: text-embed) | +| VLM Embedding | `nemotron-vlm-embedding-ms` | 9081 | 8000 | 0 | `VLM_EMBEDDING_MS_GPU_ID` | Default vision-language embeddings | | Ranking | `nemotron-ranking-ms` | 1976 | 8000 | 0 | `RANKING_MS_GPU_ID` | Reranking model | -| VLM | `nemotron-3-nano-omni-30b-a3b-reasoning` | 1977 | 8000 | 5 | `VLM_MS_GPU_ID` | Vision-language model (default for `vlm-generation`, opt-in for `vlm-only`) | +| VLM | `nemotron-3-nano-omni-30b-a3b-reasoning` | 1977 | 8000 | 5 | `VLM_MS_GPU_ID` | Vision-language model (opt-in, profile: vlm-only, vlm-generation) | | Nemotron Parse | `compose-nemotron-parse-1` | 8015, 8016, 8017 | 8000, 8001, 8002 | 1 | `NEMOTRON_PARSE_MS_GPU_ID` | PDF parsing (opt-in, profile: nemotron-parse) | | RIVA ASR | `compose-audio-1` | 8021, 8022 | 50051, 9000 | 0 | `AUDIO_MS_GPU_ID` | Audio speech recognition (opt-in, profile: audio) | | Page Elements | `compose-page-elements-1` | 8000, 8001, 8002 | 8000, 8001, 8002 | 0 | `YOLOX_MS_GPU_ID` | Object detection for pages | | Graphic Elements | `compose-graphic-elements-1` | 8003, 8004, 8005 | 8000, 8001, 8002 | 0 | `YOLOX_GRAPHICS_MS_GPU_ID` | Graphics detection | | Table Structure | `compose-table-structure-1` | 8006, 8007, 8008 | 8000, 8001, 8002 | 0 | `YOLOX_TABLE_MS_GPU_ID` | Table structure detection | -| NeMo Retriever Library OCR | `compose-nemoretriever-ocr-1` | 8012, 8013, 8014 | 8000, 8001, 8002 | 0 | `OCR_MS_GPU_ID` | OCR service (default) | +| Nemotron OCR | `compose-nemotron-ocr-1` | 8012, 8013, 8014 | 8000, 8001, 8002 | 0 | `OCR_MS_GPU_ID` | OCR service (default) | ## Vector Database and Infrastructure | Service | Container Name | Host Port(s) | Container Port(s) | Default GPU ID | Environment Variable | Notes | |---------|---------------|--------------|-------------------|----------------|---------------------|-------| -| Milvus | `milvus-standalone` | 19530, 9091 | 19530, 9091 | 0 | `VECTORSTORE_GPU_DEVICE_ID` | Vector database | -| Milvus MinIO | `milvus-minio` | 9010, 9011 | 9010, 9011 | N/A (CPU) | N/A | Object storage | -| Milvus etcd | `milvus-etcd` | N/A | 2379 | N/A (CPU) | N/A | Metadata storage | +| Elasticsearch | `elasticsearch` | 9200 | 9200 | N/A (CPU) | N/A | Default | | Redis | `compose-redis-1` | 6379 | 6379 | N/A (CPU) | N/A | Task queue | -| Elasticsearch | `elasticsearch` | 9200 | 9200 | N/A (CPU) | N/A | Profile: elasticsearch | +| Milvus | `milvus-standalone` | 19530, 9091 | 19530, 9091 | 0 | `VECTORSTORE_GPU_DEVICE_ID` | Vector database (Profile: milvus) | +| SeaweedFS Object Store | `seaweedfs` | 9010, 9011 | 9010, 9011 | N/A (CPU) | N/A | S3-compatible object storage | +| Milvus etcd | `milvus-etcd` | N/A | 2379 | N/A (CPU) | N/A | Metadata storage (Profile: milvus) | :::{note} **Opt-in NIM Services:** The following NIM services are opt-in and require explicit Docker Compose profile activation: -- **VLM Embedding** (`nemotron-vlm-embedding-ms`): Use profile `vlm-embed` for vision-language embeddings -- **VLM** (`nemotron-3-nano-omni-30b-a3b-reasoning`): Use profile `vlm-only` or `vlm-generation` for vision-language model (the latter is the default profile as of 2.5.1) +- **VLM** (`nemotron-3-nano-omni-30b-a3b-reasoning`): Use profile `vlm-only` or `vlm-generation` for vision-language model - **Nemotron Parse** (`compose-nemotron-parse-1`): Use profile `nemotron-parse` for advanced PDF parsing - **RIVA ASR** (`compose-audio-1`): Use profile `audio` for audio speech recognition diff --git a/docs/summarization.md b/docs/summarization.md index aa3866ccd..6df445c5b 100644 --- a/docs/summarization.md +++ b/docs/summarization.md @@ -300,7 +300,7 @@ Summary generation status is tracked using Redis to enable cross-service visibil **Status Tracking Behavior:** - Status information is stored in Redis with a 24-hour TTL (automatically cleaned up) -- If Redis is unavailable, the system gracefully degrades: summaries will still be generated and stored in MinIO, but real-time status tracking will not be available +- If Redis is unavailable, the system gracefully degrades: summaries will still be generated and stored in the configured object store, but real-time status tracking will not be available - Status values include: `PENDING`, `IN_PROGRESS` (with chunk progress), `SUCCESS`, `FAILED`, and `NOT_FOUND` - Redis semaphore counter is automatically reset when ingestor server starts, preventing stale values from crashed processes @@ -316,7 +316,7 @@ For more details on customizing these prompts, see [Prompt Customization Guide]( **Environment Variables:** -- **SUMMARY_LLM**: The model name to use for summarization (default: `nvidia/llama-3.3-nemotron-super-49b-v1.5`) +- **SUMMARY_LLM**: The model name to use for summarization (default: `nvidia/nemotron-3-super-120b-a12b`) - **SUMMARY_LLM_SERVERURL**: The server URL hosting the summarization model (default: empty, uses NVIDIA hosted API) - **SUMMARY_LLM_MAX_CHUNK_LENGTH**: Maximum chunk size in **tokens** for document processing (default: `9000`) - **SUMMARY_CHUNK_OVERLAP**: Overlap between chunks for summarization in **tokens** (default: `400`) @@ -327,7 +327,7 @@ For more details on customizing these prompts, see [Prompt Customization Guide]( ### Example Configuration ```bash -export SUMMARY_LLM="nvidia/llama-3.3-nemotron-super-49b-v1.5" +export SUMMARY_LLM="nvidia/nemotron-3-super-120b-a12b" export SUMMARY_LLM_SERVERURL="" export SUMMARY_LLM_MAX_CHUNK_LENGTH=9000 export SUMMARY_CHUNK_OVERLAP=400 @@ -384,7 +384,7 @@ This approach ensures that even very large documents can be summarized effective - Summarization is only available if `generate_summary` was set to `true` during document upload. - If you request a summary for a document that was not ingested with summarization enabled, you'll receive a `NOT_FOUND` status. - Use the `blocking` parameter to control whether your request waits for summary generation or returns immediately with the current status. -- The summary is pre-generated and stored in MinIO; repeated requests for the same document will return the same summary unless the document is re-uploaded or updated. +- The summary is pre-generated and stored in the configured object store; repeated requests for the same document will return the same summary unless the document is re-uploaded or updated. - **Status Tracking**: Monitor summary generation progress in real-time using the `GET /summary` endpoint. The `IN_PROGRESS` status includes chunk-level progress (e.g., "Processing chunk 3/5"). - **Redis Requirement**: For status tracking and global rate limiting to work across services, ensure Redis is configured and accessible to both ingestor and RAG servers. Without Redis, summaries will still be generated but status tracking and rate limiting will be unavailable. - **Timeout Handling**: When using `blocking=true`, set an appropriate timeout based on your document size. Large documents may take several minutes to summarize. @@ -407,4 +407,4 @@ This approach ensures that even very large documents can be summarized effective - Increase `SUMMARY_MAX_PARALLELIZATION` if you have more GPU resources available - Decrease it if experiencing GPU memory issues or API rate limits -- Monitor Redis semaphore usage to optimize for your workload \ No newline at end of file +- Monitor Redis semaphore usage to optimize for your workload diff --git a/docs/support-matrix.md b/docs/support-matrix.md index e0e2f88ae..fabfef732 100644 --- a/docs/support-matrix.md +++ b/docs/support-matrix.md @@ -43,12 +43,11 @@ For details, see [NVIDIA NIM for LLMs Software](https://docs.nvidia.com/nim/larg ## Hardware Requirements (Docker) -By default, the RAG Blueprint deploys the NIM microservices locally ([self-hosted](deploy-docker-self-hosted.md)). You need one of the following: +By default, the RAG Blueprint deploys the NIM microservices locally ([self-hosted](deploy-docker-self-hosted.md)). The default LLM (nemotron-3-super-120b-a12b) requires 2 GPUs (FP8 TP2). You need one of the following: - - 2 x H100 - - 2 x B200 - - 3 x A100 SXM - - 2 x RTX PRO 6000 + - 3 x H100 + - 3 x B200 + - 3 x RTX PRO 6000 :::{tip} You can also modify the RAG Blueprint to use [NVIDIA-hosted](deploy-docker-nvidia-hosted.md) NIM microservices. @@ -62,10 +61,9 @@ You can also modify the RAG Blueprint to use [NVIDIA-hosted](deploy-docker-nvidi To install the RAG Blueprint on Kubernetes, you need one of the following: -- 8 x H100-80GB -- 8 x B200 -- 9 x A100-80GB SXM -- 8 x RTX PRO 6000 +- 9 x H100-80GB +- 9 x B200 +- 9 x RTX PRO 6000 - 3 x H100 (with [Multi-Instance GPU](./mig-deployment.md)) @@ -74,19 +72,18 @@ To install the RAG Blueprint on Kubernetes, you need one of the following: The following are requirements and recommendations for the individual components of the RAG Blueprint: -- **Pipeline operation** – 1x L40 GPU or similar recommended. This is needed for the Milvus vector database, as GPU acceleration is enabled by default. -- **LLM NIM (llama-3.3-nemotron-super-49b-v1.5)** – Refer to the [Support Matrix](https://docs.nvidia.com/nim/large-language-models/latest/supported-models.html#llama-3-3-nemotron-super-49b-v1-5). -- **Embedding NIM (Llama-3.2-NV-EmbedQA-1B-v2 )** – Refer to the [Support Matrix](https://docs.nvidia.com/nim/nemo-retriever/text-embedding/latest/support-matrix.html#llama-3-2-nv-embedqa-1b-v2). -- **Reranking NIM (llama-3_2-nv-rerankqa-1b-v2 )**: Refer to the [Support Matrix](https://docs.nvidia.com/nim/nemo-retriever/text-reranking/latest/support-matrix.html#llama-3-2-nv-rerankqa-1b-v2). -- **NVIDIA NIM for Image OCR (baidu/paddleocr)**: Refer to the [Support Matrix](https://docs.nvidia.com/nemo/retriever/latest/extraction/support-matrix/). -**NeMo Retriever OCR**: Refer to the [Support Matrix](https://docs.nvidia.com/nemo/retriever/latest/extraction/support-matrix/) +- **Pipeline operation** – 1x L40 GPU or similar recommended. This is required if you use Milvus (optional) as the vector database with GPU acceleration. The default Elasticsearch VDB does not require a GPU. If you change the vector backend or enable optional GPU acceleration for Elasticsearch vector indexing, refer [Elasticsearch Configuration](elasticsearch-configuration.md) and confirm GPU requirements for that configuration. +- **LLM NIM (nemotron-3-super-120b-a12b)** – Refer to the [Support Matrix](https://docs.nvidia.com/nim/large-language-models/latest/supported-models.html). +- **Embedding NIM (llama-nemotron-embed-vl-1b-v2)** – Refer to the embedding model support matrix for your deployment target. +- **Reranking NIM (llama-nemotron-rerank-1b-v2)**: Refer to the [Support Matrix](https://docs.nvidia.com/nim/nemo-retriever/text-reranking/latest/support-matrix.html). +- **Nemotron OCR (Default)**: Refer to the [Support Matrix](https://docs.nvidia.com/nim/ingestion/image-ocr/1.3.0/support-matrix.html). - **NVIDIA NIMs for Object Detection**: - - NeMo Retriever Page Elements v3 [Support Matrix](https://docs.nvidia.com/nim/ingestion/object-detection/latest/support-matrix.html#nemo-retriever-page-elements-v3) - - NeMo Retriever Graphic Elements v1 [Support Matrix](https://docs.nvidia.com/nim/ingestion/object-detection/latest/support-matrix.html#nemo-retriever-graphic-elements-v1) - - NeMo Retriever Table Structure v1 [Support Matrix](https://docs.nvidia.com/nim/ingestion/object-detection/latest/support-matrix.html#nemo-retriever-table-structure-v1) + - Nemotron Page Elements v3 [Support Matrix](https://docs.nvidia.com/nim/ingestion/object-detection/latest/support-matrix.html#nemo-retriever-page-elements-v3) + - Nemotron Graphic Elements v1 [Support Matrix](https://docs.nvidia.com/nim/ingestion/object-detection/latest/support-matrix.html#nemo-retriever-graphic-elements-v1) + - Nemotron Table Structure v1 [Support Matrix](https://docs.nvidia.com/nim/ingestion/object-detection/latest/support-matrix.html#nemo-retriever-table-structure-v1) :::{tip} -NeMo Retriever OCR is now the default OCR service. To use the legacy Paddle OCR instead, see [OCR Configuration Guide](nemoretriever-ocr.md). +Nemotron OCR is now the default OCR service. To use the legacy Paddle OCR instead, see [OCR Configuration Guide](nemoretriever-ocr.md). ::: diff --git a/docs/text_only_ingest.md b/docs/text_only_ingest.md index 1288606fb..24afbde00 100644 --- a/docs/text_only_ingest.md +++ b/docs/text_only_ingest.md @@ -8,7 +8,7 @@ You can enable text-only ingestion for the [NVIDIA RAG Blueprint](readme.md). Fo 1. Follow the [deployment guide](deploy-docker-self-hosted.md) up to and including the step labelled "Start all required NIMs." -2. Set the environment variables to enable text-only extraction mode: +2. Set the environment variables to enable text-only extraction mode. `COMPONENTS_TO_READY_CHECK` must be set to an empty string so the nv-ingest readiness probe does not wait for the disabled extraction NIMs (the compose default in [docker-compose-ingestor-server.yaml](../deploy/compose/docker-compose-ingestor-server.yaml) is `ALL`): ```bash export APP_NVINGEST_EXTRACTTEXT=True @@ -18,10 +18,6 @@ You can enable text-only ingestion for the [NVIDIA RAG Blueprint](readme.md). Fo export COMPONENTS_TO_READY_CHECK="" ``` - :::{important} - When disabling NeMo Retriever Library dependent services, you must set `COMPONENTS_TO_READY_CHECK=""` to ensure the NeMo Retriever Library container reaches ready state. Without this setting, the NeMo Retriever Library container will wait indefinitely for the disabled components. - ::: - Then deploy the ingestor-server: ```bash @@ -53,7 +49,7 @@ You can enable text-only ingestion for the [NVIDIA RAG Blueprint](readme.md). Fo 5. Once the ingestion and rag servers are deployed, open the [ingestion notebook](https://github.com/NVIDIA-AI-Blueprints/rag/blob/main/notebooks/ingestion_api_usage.ipynb) and follow the steps. While trying out the `Upload Document Endpoint` set the payload to below. ```bash data = { - "vdb_endpoint": "http://milvus:19530", + "vdb_endpoint": "http://elasticsearch:9200", "collection_name": collection_name, "split_options": { "chunk_size": 1024, @@ -81,66 +77,69 @@ In case you are [interacting with cloud hosted models](deploy-docker-nvidia-host To ingest text-only files, you do not need to deploy the complete pipeline with all NIMs connected. If your scenario requires only text extraction from files, use the following steps to deploy only the necessary components using Helm. -When you install the Helm chart, enable only the following services that are required for text ingestion: +In the v2.6.0 chart, the **VLM embedder** (`nvidia-nim-llama-nemotron-embed-vl-1b-v2`) is enabled by default and the **text embedder** (`nvidia-nim-llama-nemotron-embed-1b-v2`) is disabled. For text-only ingestion, flip the two flags and repoint the embedding env vars at the text endpoint. Keep the following enabled: - `rag-server` - `ingestor-server` - `nv-ingest` -- `nvidia-nim-llama-32-nv-embedqa-1b-v2` -- `text-reranking-nim` +- `nvidia-nim-llama-nemotron-embed-1b-v2` (text embedder) +- `nvidia-nim-llama-nemotron-rerank-1b-v2` (text reranker) - `nim-llm` -- `milvus` -- `minio` +- `eck-elasticsearch` +- `seaweedfs` -Additionally, ensure that **table extraction**, **chart extraction**, and **image extraction** are disabled. +Additionally, disable **table extraction**, **chart extraction**, and **image extraction**. -1. First, modify the environment variables in [`values.yaml`](../deploy/helm/nvidia-blueprint-rag/values.yaml) to enable text-only extraction: +1. Modify [`values.yaml`](../deploy/helm/nvidia-blueprint-rag/values.yaml) to (a) swap the embedder NIMs, (b) repoint embedding env vars at the text endpoint, and (c) turn off image/table/chart extraction: - In the `nv-ingest.envVars` section, set the following values: - ```yaml + nimOperator: + # Disable VLM embedder, enable text embedder + nvidia-nim-llama-nemotron-embed-vl-1b-v2: + enabled: false + nvidia-nim-llama-nemotron-embed-1b-v2: + enabled: true + + # rag-server: point at the text embedder + envVars: + APP_EMBEDDINGS_MODELNAME: "nvidia/llama-nemotron-embed-1b-v2" + APP_EMBEDDINGS_SERVERURL: "nemotron-embedding-ms:8000/v1" + + ingestor-server: + envVars: + APP_EMBEDDINGS_MODELNAME: "nvidia/llama-nemotron-embed-1b-v2" + APP_EMBEDDINGS_SERVERURL: "nemotron-embedding-ms:8000/v1" + nv-ingest: envVars: - # ... existing configurations ... - - # === Text-Only Extraction Mode === + # Embedding target for the nv-ingest runtime + EMBEDDING_NIM_ENDPOINT: "http://nemotron-embedding-ms:8000/v1" + EMBEDDING_NIM_MODEL_NAME: "nvidia/llama-nemotron-embed-1b-v2" + + # Text-only extraction mode APP_NVINGEST_EXTRACTTEXT: "True" APP_NVINGEST_EXTRACTINFOGRAPHICS: "False" APP_NVINGEST_EXTRACTTABLES: "False" APP_NVINGEST_EXTRACTCHARTS: "False" + + # Health check: skip readiness on disabled extraction NIMs. + # The chart default in values.yaml is "ALL"; with table / chart / image + # extraction turned off, nv-ingest readiness would otherwise wait + # indefinitely for NIMs that are not deployed. + COMPONENTS_TO_READY_CHECK: "" ``` -2. Then use the modified [`values.yaml`](../deploy/helm/nvidia-blueprint-rag/values.yaml) file in your Helm upgrade command: - -```bash -helm upgrade --install rag -n rag https://helm.ngc.nvidia.com/nvidia/blueprint/charts/nvidia-blueprint-rag-v2.5.1.tgz \ - --username '$oauthtoken' \ - --password "${NGC_API_KEY}" \ - --values deploy/helm/nvidia-blueprint-rag/values.yaml \ - --set nimOperator.nim-llm.enabled=true \ - --set nimOperator.nvidia-nim-llama-32-nv-embedqa-1b-v2.enabled=true \ - --set nimOperator.nvidia-nim-llama-32-nv-rerankqa-1b-v2.enabled=true \ - --set ingestor-server.enabled=true \ - --set nv-ingest.enabled=true \ - --set nv-ingest.nimOperator.page_elements.enabled=false \ - --set nv-ingest.nimOperator.graphic_elements.enabled=false \ - --set nv-ingest.nimOperator.table_structure.enabled=false \ - --set nv-ingest.nimOperator.nemoretriever_ocr_v1.enabled=false \ - --set imagePullSecret.password=$NGC_API_KEY \ - --set ngcApiSecret.password=$NGC_API_KEY -``` - -:::{important} -**Disabling NeMo Retriever Library Components for GPU Resource Management:** - -If you disable any NeMo Retriever Library dependent services (such as `table_structure`, `graphic_elements`, `nemoretriever_ocr_v1`, etc.) to free up GPU resources for customization, you must set the `COMPONENTS_TO_READY_CHECK` parameter to an empty string in the `nv-ingest.envVars` section of your [values.yaml](../deploy/helm/nvidia-blueprint-rag/values.yaml) file: - -```yaml -nv-ingest: - envVars: - COMPONENTS_TO_READY_CHECK: "" -``` - -This ensures the NeMo Retriever Library pod reaches ready state even when some dependent components are disabled. Without this setting, the NeMo Retriever Library pod will wait indefinitely for the disabled components to become ready. +2. Apply the chart with the modified values, disabling the nv-ingest CV NIMs you no longer need: -::: + ```bash + helm upgrade --install rag -n rag https://helm.ngc.nvidia.com/nvstaging/blueprint/charts/nvidia-blueprint-rag-v2.6.0.tgz \ + --username '$oauthtoken' \ + --password "${NGC_API_KEY}" \ + --values deploy/helm/nvidia-blueprint-rag/values.yaml \ + --set nv-ingest.nimOperator.page_elements.enabled=false \ + --set nv-ingest.nimOperator.graphic_elements.enabled=false \ + --set nv-ingest.nimOperator.table_structure.enabled=false \ + --set nv-ingest.nimOperator.ocr.enabled=false \ + --set imagePullSecret.password=$NGC_API_KEY \ + --set ngcApiSecret.password=$NGC_API_KEY + ``` diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 782176ed2..cfab6682d 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -222,6 +222,15 @@ To resolve this issue, upgrade Docker Compose to version `v2.29.0` or later. * error decoding 'Deploy.Resources.Reservations.devices[0]': invalid string value for 'count' (the only value allowed is 'all') ``` +## NGC image pull error with Docker Engine 29.5.x + +You might encounter the following error while Docker pulls required NGC images. + +```bash +error from registry: Incorrect Repository Format +``` + +This is known to occur with Docker Engine 29.5.x. Use a different Docker Engine version, such as 29.4.3. ## Device error @@ -249,7 +258,7 @@ Original error: Error during NimClient inference [yolox-page-elements, grpc]: [S In case you were expecting to use NVIDIA-hosted model for this service, then ensure the corresponding environment variables were set in the same terminal from where you did docker compose up. Following the above example the environment variables which are expected to be set are: ```output - export YOLOX_HTTP_ENDPOINT="https://ai.api.nvidia.com/v1/cv/nvidia/nemoretriever-page-elements-v3" + export YOLOX_HTTP_ENDPOINT="https://ai.api.nvidia.com/v1/cv/nvidia/nemotron-page-elements-v3" export YOLOX_INFER_PROTOCOL="http" ``` @@ -309,7 +318,7 @@ If you change the embedding model or dimensions after ingesting documents, you m -## Error details: [###] Too many open files for llama-3.3-nemotron-super-49b-v1.5 container +## Error details: [###] Too many open files for nemotron-3-super-120b-a12b container source: hyper_util::client::legacy::Error(Connect, ConnectError("dns error", Os { code: 24, kind: Uncategorized, message: "Too many open files" })) }) This error happens because the default number of Open files allowed are 1024 for Containers. Follow the below steps to modify the container configuration to allow more number of open files. @@ -383,9 +392,9 @@ volumeBindingMode: WaitForFirstConsumer If using `local-path` provisioner, it does not support `ReadWriteMany` access mode, which is the default for some NIM Caches. **Fix:** Patch the NIMCache resources to use `ReadWriteOnce`: ```bash -kubectl patch nimcache nemoretriever-page-elements-v3 -n rag --type='merge' -p '{"spec":{"storage":{"pvc":{"volumeAccessMode":"ReadWriteOnce"}}}}' +kubectl patch nimcache nemotron-page-elements-v3 -n rag --type='merge' -p '{"spec":{"storage":{"pvc":{"volumeAccessMode":"ReadWriteOnce"}}}}' # Repeat for other affected caches (table-structure-v1, ocr-v1, graphic-elements-v1) -kubectl delete pvc nemoretriever-page-elements-v3-pvc -n rag --wait=false # Delete pending PVC to trigger recreation +kubectl delete pvc nemotron-page-elements-v3-pvc -n rag --wait=false # Delete pending PVC to trigger recreation ``` ### Ingestor-server out of memory (OOM) with large documents @@ -455,8 +464,7 @@ If you run into `torch.OutOfMemoryError: CUDA out of memory.` while deploying th ## Password Issue Fix If you encounter any `password authentication failed` issues with the structured retriever container, -consider removing the volumes directory located at `deploy/compose/volumes`. -In this case, you may need to reprocess the data ingestion. +remove the `rag-vol-*` Docker named volumes (`docker volume ls -q --filter name=^rag-vol- | xargs -r docker volume rm`). In this case, you may need to reprocess the data ingestion. See [Manage Persistent Data Volumes](#manage-persistent-data-volumes) for selective wipes if you only need to reset one service. @@ -471,14 +479,127 @@ This happens when a collection created with vector search type `hybrid` is acces ## Reset the entire cache -To reset the entire cache, you can run the following command. -This deletes all the volumes associated with the containers, including the cache. +To reset the entire cache, stop the stack and remove the `rag-vol-*` Docker named volumes. This deletes **all** persisted state (Elasticsearch / Milvus / etcd / SeaweedFS / LanceDB / ingestor-server scratch). + +```bash +docker compose -f deploy/compose/docker-compose-rag-server.yaml down +docker compose -f deploy/compose/docker-compose-ingestor-server.yaml down +docker compose -f deploy/compose/vectordb.yaml down +docker volume ls -q --filter "name=^rag-vol-" | xargs -r docker volume rm +``` + +See [Manage Persistent Data Volumes](#manage-persistent-data-volumes) below for backup, selective wipes, and migration from the legacy `deploy/compose/volumes/` host directory. + + + +## Manage Persistent Data Volumes + +All persistent data for the Docker Compose deployment is stored in dedicated Docker named volumes that share the `rag-vol-` prefix. They replace the legacy `deploy/compose/volumes/` host directory tree, and each is auto-created by `docker compose up` on first use — no host `mkdir` / `chown` or manual setup is needed. + +| Volume | Service | Mount target inside container | +|---|---|---| +| `rag-vol-milvus` | `milvus-standalone` | `/var/lib/milvus` | +| `rag-vol-etcd` | `milvus-etcd` | `/etcd` | +| `rag-vol-seaweedfs` | `seaweedfs` | `/data` | +| `rag-vol-elasticsearch` | `elasticsearch` | `/usr/share/elasticsearch/data` | +| `rag-vol-ingestor` | `ingestor-server` | `${INGESTOR_SERVER_DATA_DIR:-/data/}` | +| `rag-vol-lancedb` | `ingestor-server`, `rag-server` | `/volumes/lancedb` | + +On the Docker host, each volume lives under `/var/lib/docker/volumes//_data/`. Every compose file declares the volumes with an explicit `name:` so they bypass the compose-project prefix — that means the `rag-vol-lancedb` mounted by `docker-compose-ingestor-server.yaml` is the same physical volume mounted by `docker-compose-rag-server.yaml`, and so on. + +### Inspect ```bash -docker compose down -v +# List all rag-* volumes: +docker volume ls --filter "name=^rag-vol-" + +# Where is a specific volume on disk? +docker volume inspect rag-vol-elasticsearch --format '{{ .Mountpoint }}' +# → /var/lib/docker/volumes/rag-vol-elasticsearch/_data + +# List the contents of a volume (use a throwaway alpine container — the host path is root-owned). +docker run --rm -v rag-vol-ingestor:/data:ro alpine ls -la /data ``` +### Backup +```bash +# Snapshot a single volume to a tarball in the current directory: +docker run --rm -v rag-vol-elasticsearch:/source:ro -v "$PWD":/backup alpine \ + tar czf /backup/rag-vol-elasticsearch-$(date +%Y%m%d).tgz -C /source . + +# Snapshot every rag-vol-* volume in one shot: +for v in $(docker volume ls -q --filter "name=^rag-vol-"); do + docker run --rm -v "$v":/source:ro -v "$PWD":/backup alpine \ + tar czf "/backup/${v}-$(date +%Y%m%d).tgz" -C /source . +done +``` + +### Restore + +```bash +# Restore a single volume from a backup tarball: +docker volume create rag-vol-elasticsearch +docker run --rm -v rag-vol-elasticsearch:/dst -v "$PWD":/backup alpine \ + tar xzf /backup/rag-vol-elasticsearch-YYYYMMDD.tgz -C /dst +``` + +### Reset + +Stop the stack first, then either wipe everything or just one volume. + +```bash +# Wipe everything (re-ingestion required): +docker volume ls -q --filter "name=^rag-vol-" | xargs -r docker volume rm + +# Wipe just one service's state (e.g. only Milvus + etcd, keeping ingestor scratch): +docker volume rm rag-vol-milvus rag-vol-etcd +``` + +### Migrating from the legacy `deploy/compose/volumes/` host directory + +Earlier releases bind-mounted persistent data from `deploy/compose/volumes/`. If you are upgrading and want to keep that data, copy each subdirectory into its corresponding `rag-vol-*` volume once before bringing the new stack up: + +```bash +# 1. Stop any running compose stacks first so files aren't being written. +docker compose -f deploy/compose/docker-compose-rag-server.yaml down || true +docker compose -f deploy/compose/docker-compose-ingestor-server.yaml down || true +docker compose -f deploy/compose/vectordb.yaml down || true + +# 2. For each legacy subdirectory you want to preserve, copy it into the matching +# rag-vol-* volume. Adjust the loop if you don't have all six. +declare -A MAP=( + [milvus]=rag-vol-milvus + [etcd]=rag-vol-etcd + [seaweedfs]=rag-vol-seaweedfs + [elasticsearch]=rag-vol-elasticsearch + [ingestor-server]=rag-vol-ingestor + [lancedb]=rag-vol-lancedb +) +for src_dir in "${!MAP[@]}"; do + legacy="deploy/compose/volumes/${src_dir}" + vol="${MAP[$src_dir]}" + [ -d "$legacy" ] || { echo "skip $legacy (not present)"; continue; } + docker volume create "$vol" >/dev/null + docker run --rm \ + -v "$PWD/$legacy":/src:ro \ + -v "$vol":/dst \ + alpine sh -c "cp -a /src/. /dst/" + echo "migrated $legacy → $vol" +done + +# 3. Elasticsearch + ingestor expect UID/GID 1000 ownership inside their volumes. +docker run --rm -v rag-vol-elasticsearch:/data alpine chown -R 1000:1000 /data +docker run --rm -v rag-vol-ingestor:/data alpine chown -R 1000:1000 /data + +# 4. Spot-check. +docker volume ls --filter "name=^rag-vol-" + +# 5. Once the stack starts and data is intact, you can remove the legacy directory: +sudo rm -rf deploy/compose/volumes +``` + +If you do **not** need to preserve the legacy data, just delete `deploy/compose/volumes/` — Docker will create fresh empty volumes on the first `docker compose up`. ## Running out of credits diff --git a/docs/versions1.json b/docs/versions1.json index 0e406c7c3..901411374 100644 --- a/docs/versions1.json +++ b/docs/versions1.json @@ -1,6 +1,10 @@ [ { "preferred": true, + "version": "2.6.0", + "url": "../2.6.0/" + }, + { "version": "2.5.1", "url": "../2.5.1/" }, @@ -16,4 +20,4 @@ "version": "2.3.0", "url": "../2.3.0/" } -] \ No newline at end of file +] diff --git a/docs/vlm-embed.md b/docs/vlm-embed.md deleted file mode 100644 index 01ab062a8..000000000 --- a/docs/vlm-embed.md +++ /dev/null @@ -1,230 +0,0 @@ - -# Use Multimodal (VLM) Embedding for Ingestion for NVIDIA RAG Blueprint - -This guide shows how to enable and use the multimodal embedding model `nvidia/llama-nemotron-embed-vl-1b-v2` with the [NVIDIA RAG Blueprint](readme.md) ingestion pipeline. - -In this documentation you do the following: - -- Start the VLM embedding microservice -- Configure ingestion to embed content as text or images using env vars -- Point the ingestor to the VLM embedding service and model - -Requirements: An NVIDIA GPU and a valid `NGC_API_KEY`. - -:::{note} -**Early Access**: Currently, `nvidia/llama-nemotron-embed-vl-1b-v2` is in early access preview. -::: - -:::{note} -**PDF Support Only**: The VLM embedding feature is currently only supported for PDF documents. Other document formats (Word, PowerPoint, etc.) are not supported with VLM embedding. -::: - -## Limitations - -- The VLM embedding feature is experimental and responses may not be accurate. -- Summary generation doesn't work when this feature is enabled. - -## 1. Start the VLM Embedding NIM locally - -We provide a dedicated compose profile that starts only the VLM embedding service so the text embedding service does not start. -You can skip this step if you are interested in using cloud hosted endpoints. - -```bash -export USERID=$(id -u) -export NGC_API_KEY= -# Optionally select a GPU for the VLM embed service -export VLM_EMBEDDING_MS_GPU_ID= - -# Start only the VLM embedding microservice -docker compose -f deploy/compose/nims.yaml --profile vlm-embed up -d - -# Verify the service is healthy -docker ps --filter "name=nemotron-vlm-embedding-ms" --format "table {{.Names}}\t{{.Status}}" -``` - -Service details (from `deploy/compose/nims.yaml`): -- Service name: `nemotron-vlm-embedding-ms` -- Default port mapping: `9081:8000` (internal NIM port `8000`) - -## 2. Point the Ingestor to the VLM Embedding Model - -Set the ingestor’s embedding endpoint and model to the VLM service and model. These env vars are read by `ingestor-server` and are also propagated to `nv-ingest-ms-runtime` so both components use the VLM embedding model. You can choose to use cloud hosted model endpoint as well by using the commented line. - -```bash -# Point to the required VLM embedding endpoint -export APP_EMBEDDINGS_SERVERURL="nemotron-vlm-embedding-ms:8000/v1" # For on-prem deployed -# export APP_EMBEDDINGS_SERVERURL="https://integrate.api.nvidia.com/v1" # For cloud hosted NIM -export APP_EMBEDDINGS_MODELNAME="nvidia/llama-nemotron-embed-vl-1b-v2" - -# Launch or restart the ingestor server so the new env vars take effect -docker compose -f deploy/compose/docker-compose-ingestor-server.yaml up -d -``` - -## 3. Configure How Content Is Embedded (text vs image) - -You can control what gets embedded as text or as images using these env vars: -- `APP_NVINGEST_STRUCTURED_ELEMENTS_MODALITY`: set to `image` to embed extracted tables/charts as images (keep text as text) -- `APP_NVINGEST_IMAGE_ELEMENTS_MODALITY`: set to `image` to embed page images as images -- `APP_NVINGEST_EXTRACTPAGEASIMAGE`: set to `True` to treat each page as a single image (experimental) - -Below are common configurations. - -### Baseline: All extracted content embedded as text - -Extractor collects text, tables, and charts as textual content; embedder treats all content as text. - -```bash -export APP_NVINGEST_EXTRACTTEXT="True" -export APP_NVINGEST_EXTRACTTABLES="True" -export APP_NVINGEST_EXTRACTCHARTS="True" -export APP_NVINGEST_EXTRACTIMAGES="False" -# Do not set structured/image modalities (or set them empty) so everything embeds as text -export APP_NVINGEST_STRUCTURED_ELEMENTS_MODALITY="" -export APP_NVINGEST_IMAGE_ELEMENTS_MODALITY="" -export APP_NVINGEST_EXTRACTPAGEASIMAGE="False" - -# Apply by restarting ingestor-server -docker compose -f deploy/compose/docker-compose-ingestor-server.yaml up -d -``` - -### Embed structured elements (tables, charts) as images - -Extractor collects text, tables, and charts; embedder treats standard text as text while embedding tables and charts as images via `APP_NVINGEST_STRUCTURED_ELEMENTS_MODALITY="image"`. - -```bash -export APP_NVINGEST_EXTRACTTEXT="True" -export APP_NVINGEST_EXTRACTTABLES="True" -export APP_NVINGEST_EXTRACTCHARTS="True" -export APP_NVINGEST_EXTRACTIMAGES="False" -# Use the VLM model to capture spatial/structural info for tables and charts -export APP_NVINGEST_STRUCTURED_ELEMENTS_MODALITY="image" -export APP_NVINGEST_IMAGE_ELEMENTS_MODALITY="" -export APP_NVINGEST_EXTRACTPAGEASIMAGE="False" - -docker compose -f deploy/compose/docker-compose-ingestor-server.yaml up -d -``` - -### Embed entire pages as images (experimental) - -Extractor captures each page as a single image (`APP_NVINGEST_EXTRACTPAGEASIMAGE="True"`); embedder processes page images via `APP_NVINGEST_IMAGE_ELEMENTS_MODALITY="image"`. Other extraction types are disabled to avoid duplicating content. - -:::{note} -Citations don't work in the `generate` and `search` APIs of the RAG server with this configuration. -::: - -```bash -# Treat each page as a single image (turn off other extractors) -export APP_NVINGEST_EXTRACTTEXT="False" -export APP_NVINGEST_EXTRACTTABLES="False" -export APP_NVINGEST_EXTRACTCHARTS="False" -export APP_NVINGEST_EXTRACTIMAGES="False" -export APP_NVINGEST_EXTRACTPAGEASIMAGE="True" -# Ensure page images are embedded as images -export APP_NVINGEST_IMAGE_ELEMENTS_MODALITY="image" -export APP_NVINGEST_STRUCTURED_ELEMENTS_MODALITY="" - -docker compose -f deploy/compose/docker-compose-ingestor-server.yaml up -d -``` - -## Quick Reference -- **Start only VLM embedding service**: `docker compose -f deploy/compose/nims.yaml --profile vlm-embed up -d` -- **Point ingestor to VLM embedding**: - - `APP_EMBEDDINGS_SERVERURL=nemotron-vlm-embedding-ms:8000/v1` - - `APP_EMBEDDINGS_MODELNAME=nvidia/llama-nemotron-embed-vl-1b-v2` -- **Modality env vars**: - - `APP_NVINGEST_STRUCTURED_ELEMENTS_MODALITY`: `image` or empty - - `APP_NVINGEST_IMAGE_ELEMENTS_MODALITY`: `image` or empty - - `APP_NVINGEST_EXTRACTPAGEASIMAGE`: `True` or `False` - -If you use a `.env` file, add the variables there instead of exporting them, then rerun the compose commands. - - -## Using Helm chart deployment - -To deploy the VLM embedding service with Helm, update the image and model settings, set the corresponding environment variables, and then apply the chart with your updated values.yaml. - -1. Modify [`values.yaml`](../deploy/helm/nvidia-blueprint-rag/values.yaml) to enable VLM embedding: - -```yaml -# Enable VLM embedding NIM and set its image -nvidia-nim-llama-nemotron-embed-vl-1b-v2: - enabled: true - image: - repository: nvcr.io/nim/nvidia/llama-nemotron-embed-vl-1b-v2 - tag: "1.12.0" - -# Optional: disable the default text embedding NIM -nvidia-nim-llama-32-nv-embedqa-1b-v2: - enabled: false - -# Point services to the VLM embedding endpoint and model -envVars: - APP_EMBEDDINGS_SERVERURL: "nemotron-vlm-embedding-ms:8000/v1" - APP_EMBEDDINGS_MODELNAME: "nvidia/llama-nemotron-embed-vl-1b-v2" - -ingestor-server: - envVars: - APP_EMBEDDINGS_SERVERURL: "nemotron-vlm-embedding-ms:8000/v1" - APP_EMBEDDINGS_MODELNAME: "nvidia/llama-nemotron-embed-vl-1b-v2" - -nv-ingest: - envVars: - EMBEDDING_NIM_ENDPOINT: "http://nemotron-vlm-embedding-ms:8000/v1" - EMBEDDING_NIM_MODEL_NAME: "nvidia/llama-nemotron-embed-vl-1b-v2" -``` - -2. Deploy the chart with the updated values: - -After modifying [`values.yaml`](../deploy/helm/nvidia-blueprint-rag/values.yaml), apply the changes as described in [Change a Deployment](deploy-helm.md#change-a-deployment). - -For detailed HELM deployment instructions, see [Helm Deployment Guide](deploy-helm.md). - -## Additional Configuration: Extraction and Embedding Modalities - -To configure how content is extracted and embedded (similar to the Docker configurations shown above), you can add extraction and modality settings to your [`values.yaml`](../deploy/helm/nvidia-blueprint-rag/values.yaml): - -- Set extraction-related variables under `envVars` and `ingestor-server.envVars` -- Set embedding service variables under `nv-ingest.envVars` - -**Example with extraction and modality settings:** - -```yaml -envVars: - APP_EMBEDDINGS_SERVERURL: "nemotron-vlm-embedding-ms:8000/v1" - APP_EMBEDDINGS_MODELNAME: "nvidia/llama-nemotron-embed-vl-1b-v2" -ingestor-server: - envVars: - # Extraction toggles - APP_NVINGEST_EXTRACTTEXT: "True" - APP_NVINGEST_EXTRACTTABLES: "True" - APP_NVINGEST_EXTRACTCHARTS: "True" - APP_NVINGEST_EXTRACTIMAGES: "False" - APP_NVINGEST_EXTRACTPAGEASIMAGE: "False" - # Embedding modality controls - APP_NVINGEST_STRUCTURED_ELEMENTS_MODALITY: "" # set to "image" to embed tables/charts as images - APP_NVINGEST_IMAGE_ELEMENTS_MODALITY: "" # set to "image" to embed page images as images - # Ingestor-side embedding target - APP_EMBEDDINGS_SERVERURL: "nemotron-vlm-embedding-ms:8000/v1" - APP_EMBEDDINGS_MODELNAME: "nvidia/llama-nemotron-embed-vl-1b-v2" - -nv-ingest: - envVars: - # NeMo Retriever Library runtime embedding target - EMBEDDING_NIM_ENDPOINT: "http://nemotron-vlm-embedding-ms:8000/v1" - EMBEDDING_NIM_MODEL_NAME: "nvidia/llama-nemotron-embed-vl-1b-v2" -``` - - - -## Related Topics - -- [NVIDIA RAG Blueprint Documentation](readme.md) -- [VLM based inferencing in RAG](vlm.md) -- [Multimodal Query Support](multimodal-query.md) -- [Best Practices for Common Settings](accuracy_perf.md) -- [RAG Pipeline Debugging Guide](debugging.md) -- [Troubleshoot](troubleshooting.md) -- [Notebooks](notebooks.md) diff --git a/docs/vlm.md b/docs/vlm.md index e8659a7d0..06cb57946 100644 --- a/docs/vlm.md +++ b/docs/vlm.md @@ -49,28 +49,38 @@ When VLM inference is enabled, the **VLM replaces the traditional LLM** in the R The VLM feature uses predefined prompts that can be customized in [`src/nvidia_rag/rag_server/prompt.yaml`](../src/nvidia_rag/rag_server/prompt.yaml) under the `vlm_template` section. The `vlm_template` controls how the question, textual context, and cited images are presented to the VLM. -**VLM reasoning vs. non-reasoning mode**: The VLM supports two modes controlled via the `vlm_template`: +**VLM reasoning vs. non-reasoning mode**: Nemotron Omni supports two modes controlled by the `APP_VLM_ENABLE_THINKING` environment variable: -- **Non-reasoning mode (default)**: Template path ends with `/no_think`. Default parameters: `APP_VLM_TEMPERATURE=0.1`, `APP_VLM_TOP_P=1.0`, `APP_VLM_MAX_TOKENS=8192`. -- **Reasoning mode (chain-of-thought)**: Change the route in `vlm_template` from `/no_think` to `/think`. Recommended: `APP_VLM_TEMPERATURE=0.3`, `APP_VLM_TOP_P=0.91`, `APP_VLM_MAX_TOKENS=8192`. +- **Reasoning mode (default)**: `APP_VLM_ENABLE_THINKING=true`. The model produces a chain-of-thought trace before the final answer. Default parameters: `APP_VLM_TEMPERATURE=0.6`, `APP_VLM_TOP_P=0.95`, `APP_VLM_MAX_TOKENS=32768`, `APP_VLM_THINKING_TOKEN_BUDGET=16384`. +- **Non-reasoning mode**: `APP_VLM_ENABLE_THINKING=false`. The model skips the reasoning trace and returns only the final answer. + +**What reaches the streaming client** is structured by field: + +- Reasoning is filtered out of user-facing `content` and surfaced in + `reasoning_content` when the model emits it. +- The final answer streams through `content`. +- `VLM_FILTER_THINK_TOKENS` is retained as a compatibility setting; streamed + reasoning is not wrapped or concatenated into `content`. Set these parameters via environment variables in your deployment configuration (for example in `docker-compose-rag-server.yaml` or Helm `values.yaml`). ## Enable VLM with Docker Compose -NVIDIA RAG uses the [**nemotron-3-nano-omni-30b-a3b-reasoning**](https://build.nvidia.com/nvidia/nemotron-3-nano-omni-30b-a3b-reasoning) Vision-Language Model (with reasoning support) by default as of 2.5.1, provided as the `vlm-ms` service in `deploy/compose/nims.yaml`. The image is `nvcr.io/nim/nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:1.7.0-variant` (GA). Reasoning streaming is controlled by `APP_VLM_ENABLE_THINKING` (default `true`), `APP_VLM_THINKING_TOKEN_BUDGET` (default `0` = uncapped), and `VLM_FILTER_THINK_TOKENS` (default `true` = hide reasoning trace from clients). +NVIDIA RAG uses the **Nemotron Omni** (`nvidia/nemotron-3-nano-omni-30b-a3b-reasoning`) Vision-Language Model by default, provided as the `vlm-ms` service in `deploy/compose/nims.yaml`. -The `vlm-generation` profile in `deploy/compose/nims.yaml` is designed for VLM-based generation on **2xH100 GPUs**. It skips the NIM LLM deployment (VLM replaces LLM), deploys the VLM service (`vlm-ms`), and deploys embedding and reranker microservices. +The `vlm-generation` profile in `deploy/compose/nims.yaml` is designed for VLM-based generation on **2xH100 GPUs**. It skips the NIM LLM deployment (VLM replaces LLM), deploys the VLM service (`vlm-ms`), and deploys embedding and reranker microservices. The `ingest` profile (combined below) additionally starts the ingestion-extraction NIMs (`page-elements`, `graphic-elements`, `table-structure`, `nemotron-ocr`) and the captioning VLM (`vlm-captioning-ms`) — without these, ingestion of PDFs and image-bearing documents will fail to extract tables, charts, and OCR text. **GPU allocation for 2xH100**: GPU 0 for Embedding and Reranker; GPU 1 for VLM (replaces LLM). You must set `VLM_MS_GPU_ID=1`. -1. Set the VLM GPU assignment and start VLM and supporting services (skips nim-llm): +1. Set the VLM GPU assignment and start the VLM generation services together with the ingestion-extraction NIMs (skips `nim-llm`): ```bash export VLM_MS_GPU_ID=1 - USERID=$(id -u) docker compose -f deploy/compose/nims.yaml --profile vlm-generation up -d + USERID=$(id -u) docker compose -f deploy/compose/nims.yaml --profile vlm-generation --profile ingest up -d ``` + Combining `--profile vlm-generation` with `--profile ingest` is equivalent to "start everything in `nims.yaml` except `nim-llm`" — the LLM container is intentionally omitted because VLM replaces it. You can confirm the exact service set with `docker compose -f deploy/compose/nims.yaml --profile vlm-generation --profile ingest config --services`. + :::{warning} Only change `VLM_MS_GPU_ID` for systems with 3+ GPUs. ::: @@ -79,10 +89,10 @@ The `vlm-generation` profile in `deploy/compose/nims.yaml` is designed for VLM-b ```bash export VLM_MS_GPU_ID=3 - USERID=$(id -u) docker compose -f deploy/compose/nims.yaml --profile vlm-generation up -d + USERID=$(id -u) docker compose -f deploy/compose/nims.yaml --profile vlm-generation --profile ingest up -d ``` -2. Enable image extraction and captioning for ingestion. In `deploy/compose/docker-compose-ingestor-server.yaml`, under the `ingestor-server` service, `APP_NVINGEST_EXTRACTIMAGES` defaults to `True` (as of 2.5.1) so images are extracted and stored. Image captioning is enabled by default: `APP_NVINGEST_CAPTIONMODELNAME` is set to `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning` and `APP_NVINGEST_CAPTIONENDPOINTURL` points to the `vlm-ms` service. Override via environment variables if needed: +2. Enable image extraction and captioning for ingestion. In `deploy/compose/docker-compose-ingestor-server.yaml`, under the `ingestor-server` service, set `APP_NVINGEST_EXTRACTIMAGES` to `True` so images are extracted and stored (disabled by default). Image captioning is enabled by default: `APP_NVINGEST_CAPTIONMODELNAME` is set to `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning` and `APP_NVINGEST_CAPTIONENDPOINTURL` points to the `vlm-ms` service. Override via environment variables if needed: ```bash export APP_NVINGEST_EXTRACTIMAGES=True @@ -135,7 +145,21 @@ Continue with [Deploy with Docker (NVIDIA-Hosted Models)](deploy-docker-nvidia-h APP_VLM_SERVERURL: "http://nim-vlm:8000/v1" ``` -2. Enable `nim-vlm` and disable `nim-llm` (VLM replaces LLM for generation): +2. Enable image extraction and captioning for ingestion. Image captioning is recommended when running VLM generation so that ingested images are indexed with their captions and surface as citations at query time. The captioning model is served by a dedicated `nim-vlm-captioning` NIM (see [Separate VLMs for Generation and Captioning](#separate-vlms-for-generation-and-captioning)). Under `nimOperator` and `ingestor-server.envVars`, set: + + ```yaml + nimOperator: + nim-vlm-captioning: + enabled: true + + ingestor-server: + envVars: + APP_NVINGEST_EXTRACTIMAGES: "True" + APP_NVINGEST_CAPTIONENDPOINTURL: "http://nim-vlm-captioning:8000/v1/chat/completions" + APP_NVINGEST_CAPTIONMODELNAME: "nvidia/nemotron-nano-12b-v2-vl" + ``` + +3. Enable `nim-vlm` and disable `nim-llm` (VLM replaces LLM for generation): ```yaml nimOperator: @@ -149,9 +173,9 @@ Continue with [Deploy with Docker (NVIDIA-Hosted Models)](deploy-docker-nvidia-h By disabling `nim-llm` and enabling `nim-vlm`, the VLM uses the GPU resources normally allocated to the LLM, so no additional hardware is required. ::: -3. Apply the changes as described in [Change a Deployment](deploy-helm.md#change-a-deployment). For full steps, see [Deploy with Helm](deploy-helm.md). +4. Apply the changes as described in [Change a Deployment](deploy-helm.md#change-a-deployment). For full steps, see [Deploy with Helm](deploy-helm.md). -4. Verify the VLM pod is running. A pod with the name `nim-vlm-*` will start (the `nim-llm` pod will not be created when it is disabled). Example status: +5. Verify the VLM pod is running. A pod with the name `nim-vlm-*` will start (the `nim-llm` pod will not be created when it is disabled). Example status: ```text rag nim-vlm-f4c446cbf-ffzm7 1/1 Running 0 22m @@ -179,6 +203,105 @@ Continue with [Deploy with Docker (NVIDIA-Hosted Models)](deploy-docker-nvidia-h Keep user questions as self-contained as possible, especially in long-running conversations. Use retrieval and prompt tuning to focus the most relevant context for the VLM. ::: +## Separate VLMs for Generation and Captioning + +This blueprint uses two distinct VLM NIMs by default — one for chat / RAG answering and one for ingestion-time image captioning. The two services are configured independently so each can be scaled, replaced, or pointed at a different endpoint without affecting the other. + +| Role | Default model | Compose service | Helm chart key | +|------|---------------|-----------------|----------------| +| Generation (chat / RAG answering) | `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning` | `vlm-ms` | `nimOperator.nim-vlm` | +| Ingestion-time image captioning | `nvidia/nemotron-nano-12b-v2-vl` | `vlm-captioning-ms` | `nimOperator.nim-vlm-captioning` | + +### Configure the generation VLM + +Set the generation VLM via `APP_VLM_MODELNAME` and `APP_VLM_SERVERURL`. With Docker Compose: + +```bash +export APP_VLM_MODELNAME="nvidia/nemotron-3-nano-omni-30b-a3b-reasoning" +export APP_VLM_SERVERURL="http://vlm-ms:8000/v1" +docker compose -f deploy/compose/docker-compose-rag-server.yaml up -d +``` + +With Helm, in `values.yaml`: + +```yaml +envVars: + APP_VLM_MODELNAME: "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning" + APP_VLM_SERVERURL: "http://nim-vlm:8000/v1" + +nimOperator: + nim-vlm: + enabled: true +``` + +### Configure the captioning VLM + +The captioning model is consumed by both the ingestor-server and the upstream `nv-ingest-ms-runtime`, so set both pairs of env vars to the same values. + +With Docker Compose: + +```bash +export APP_NVINGEST_CAPTIONMODELNAME="nvidia/nemotron-nano-12b-v2-vl" +export APP_NVINGEST_CAPTIONENDPOINTURL="http://vlm-captioning-ms:8000/v1/chat/completions" +export VLM_CAPTION_MODEL_NAME="nvidia/nemotron-nano-12b-v2-vl" +export VLM_CAPTION_ENDPOINT="http://vlm-captioning-ms:8000/v1/chat/completions" +docker compose -f deploy/compose/nims.yaml --profile vlm-rag up -d # starts vlm-captioning-ms +docker compose -f deploy/compose/docker-compose-ingestor-server.yaml up -d +``` + +With Helm, in `values.yaml`: + +```yaml +nimOperator: + nim-vlm-captioning: + enabled: true + +ingestor-server: + envVars: + APP_NVINGEST_CAPTIONMODELNAME: "nvidia/nemotron-nano-12b-v2-vl" + APP_NVINGEST_CAPTIONENDPOINTURL: "http://nim-vlm-captioning:8000/v1/chat/completions" + +nv-ingest: + envVars: + VLM_CAPTION_MODEL_NAME: nvidia/nemotron-nano-12b-v2-vl + VLM_CAPTION_ENDPOINT: http://nim-vlm-captioning:8000/v1/chat/completions +``` + +### Use a single shared VLM for both roles + +If you want to run only one VLM container and use it for both generation and captioning, point the captioning endpoints at the generation service and disable the dedicated captioning NIM. + +With Docker Compose: + +```bash +export APP_NVINGEST_CAPTIONENDPOINTURL="http://vlm-ms:8000/v1/chat/completions" +export VLM_CAPTION_ENDPOINT="http://vlm-ms:8000/v1/chat/completions" +# Bring up generation only (skip the vlm-captioning-ms profile) +docker compose -f deploy/compose/nims.yaml --profile vlm-generation up -d +``` + +With Helm, in `values.yaml`: + +```yaml +nimOperator: + nim-vlm-captioning: + enabled: false + +ingestor-server: + envVars: + APP_NVINGEST_CAPTIONENDPOINTURL: "http://nim-vlm:8000/v1/chat/completions" + +nv-ingest: + envVars: + VLM_CAPTION_ENDPOINT: http://nim-vlm:8000/v1/chat/completions +``` + +In this mode set `APP_NVINGEST_CAPTIONMODELNAME` and `VLM_CAPTION_MODEL_NAME` to whatever model the generation NIM serves. + +### NVIDIA-hosted endpoints + +To use NVIDIA-hosted endpoints for either role, point the corresponding `*_SERVERURL` / `*_ENDPOINTURL` at `https://integrate.api.nvidia.com/v1` (chat completions: `https://integrate.api.nvidia.com/v1/chat/completions`). Both `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning` and `nvidia/nemotron-nano-12b-v2-vl` are available there. + ## VLM to LLM Fallback (Optional) By default, with VLM enabled, the RAG server uses VLM for all generation tasks. The `VLM_TO_LLM_FALLBACK` environment variable controls behavior for text-only queries (no images in query, messages, or retrieved context). @@ -222,6 +345,33 @@ nimOperator: enabled: true ``` +## Enabling Full VLM Multimodal RAG Pipeline + +The VLM generation path covered above can be combined with the multimodal embedder and a multimodal reranker for a **fully VLM-powered ingestion + retrieval + generation pipeline**. This is the recommended setup when your corpus is image-heavy (PDFs with charts, diagrams, scanned tables) or when end-user queries themselves carry images. + +The pipeline has three independently switchable components, each with its own dedicated guide: + +### 1. VLM Embedding for ingestion (image modality) + +The default embedder is **`nvidia/llama-nemotron-embed-vl-1b-v2`**, so PDF pages, tables, charts, and image elements are embedded by a multimodal model. The same model embeds text + image queries at retrieval time, so no extra rag-server config is needed beyond pointing `APP_EMBEDDINGS_*` at the VLM embedding NIM. + +Setup, modality switches (text-only, structured-as-image, page-as-image), and Docker/Helm flows: see [Multimodal Retriever — Part 1: VLM Embedding for Ingestion](multimodal-retriever.md#part-1--vlm-embedding-for-ingestion-early-access). + +### 2. VLM Reranker (image-aware reranking) + +Swap the default text reranker for **`nvidia/llama-nemotron-rerank-vl-1b-v2`** and turn on `ENABLE_VLM_RERANKER_IMAGE_INPUT=True` so the reranker scores passages with awareness of the cited images, not just the surrounding text. This noticeably improves ordering when the most relevant chunk is signalled by its image content. + +What the flag does, when to enable it, and Docker/Helm flows: see [Multimodal Retriever — Part 2: VLM Reranker](multimodal-retriever.md#part-2--vlm-reranker). + +### 3. VLM Generation + +Covered earlier on this page ([Enable VLM with Docker Compose](#enable-vlm-with-docker-compose), [Enable VLM with Helm](#enable-vlm-with-helm)). With reasoning mode enabled, the VLM produces a chain-of-thought before the final answer; the rag-server streams either just the answer or both, depending on `VLM_FILTER_THINK_TOKENS`. + +### Putting it all together + +For an end-to-end deployment that wires up multimodal *queries* (image + text from the user) on top of all three components, see [Multimodal Query Support](multimodal-query.md). It walks through the combined Docker Compose and Helm setups, including the trade-offs around reranking on image queries. + + ## Troubleshooting - Ensure the VLM NIM is running and reachable at the configured `APP_VLM_SERVERURL`. @@ -231,8 +381,9 @@ nimOperator: ## Related Topics -- [VLM Embedding for Ingestion](vlm-embed.md) +- [Multimodal Retriever (VLM Embedding & VLM Reranker)](multimodal-retriever.md) - [Multimodal Query Support](multimodal-query.md) +- [Change the LLM, Embedding Model, or Reranker](change-model.md) - [Release Notes](release-notes.md) - [Debugging](debugging.md) - [Troubleshoot NVIDIA RAG Blueprint](troubleshooting.md) diff --git a/examples/nvidia_rag_mcp/mcp_server.py b/examples/nvidia_rag_mcp/mcp_server.py index c2ac626a4..b084381d2 100644 --- a/examples/nvidia_rag_mcp/mcp_server.py +++ b/examples/nvidia_rag_mcp/mcp_server.py @@ -270,21 +270,37 @@ async def tool_generate( except json.JSONDecodeError: continue - message_part = ( - data_obj.get("choices", [{}])[0] - .get("message", {}) - .get("content", "") - ) + choices = data_obj.get("choices", []) + if not choices: + continue + choice = choices[0] + message_part = choice.get("message", {}).get("content", "") + if not message_part: + message_part = choice.get("delta", {}).get("content", "") if message_part: concatenated_text.append(str(message_part)) - finish_reason = ( - data_obj.get("choices", [{}])[0].get("finish_reason") - ) - if finish_reason == "stop": - return "".join(concatenated_text) + finish_reason = choice.get("finish_reason") + if finish_reason: + joined = "".join(concatenated_text) + if not joined: + text = await resp.text() + raise RuntimeError( + f"RAG generate returned no content (status={resp.status}): " + f"{text[:500]}" + ) + return joined if concatenated_text: return "".join(concatenated_text) + text = await resp.text() + raise RuntimeError( + f"RAG generate returned no content (status={resp.status}): " + f"{text[:500]}" + ) + text = await resp.text() + raise RuntimeError( + f"RAG generate failed (status={resp.status}): {text[:500]}" + ) @server.tool( @@ -428,7 +444,7 @@ async def tool_get_summary( Args JSON: { "collection_name": "my_collection", - "vdb_endpoint": "http://milvus:19530" + "vdb_endpoint": "http://elasticsearch:9200" } """, ) @@ -466,7 +482,7 @@ async def tool_get_documents( { "collection_name": "my_collection", "document_names": ["file1.pdf", "file2.pdf"], - "vdb_endpoint": "http://milvus:19530" + "vdb_endpoint": "http://elasticsearch:9200" } """, ) @@ -581,7 +597,7 @@ async def tool_update_documents( description="""List collections from the Ingestor service. Args JSON: { - "vdb_endpoint": "http://milvus:19530" + "vdb_endpoint": "http://elasticsearch:9200" } """, ) @@ -715,7 +731,7 @@ async def tool_update_document_metadata( Args JSON: { "collection_name": "my_collection", - "vdb_endpoint": "http://milvus:19530", + "vdb_endpoint": "http://elasticsearch:9200", "metadata_schema": [], "description": "Optional description", "tags": ["tag1", "tag2"], diff --git a/examples/nvidia_rag_mcp/requirements.txt b/examples/nvidia_rag_mcp/requirements.txt index e5854e76c..686277f47 100644 --- a/examples/nvidia_rag_mcp/requirements.txt +++ b/examples/nvidia_rag_mcp/requirements.txt @@ -1,6 +1,6 @@ mcp>=1.23.1 aiohttp>=3.13.3 -fastmcp>=2.14.4 +fastmcp>=3.2.0 anyio>=4.12.0 httpx>=0.28.1 httpx-sse>=0.4.3 diff --git a/examples/rag_event_ingest/kafka_consumer/config/__init__.py b/examples/rag_event_ingest/kafka_consumer/config/__init__.py index 6bd140441..af9add4d5 100644 --- a/examples/rag_event_ingest/kafka_consumer/config/__init__.py +++ b/examples/rag_event_ingest/kafka_consumer/config/__init__.py @@ -1,3 +1,5 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 # config/__init__.py """Configuration package for Kafka MinIO Consumer. diff --git a/examples/rag_event_ingest/kafka_consumer/config/constants.py b/examples/rag_event_ingest/kafka_consumer/config/constants.py index 050cf70c4..5643edb6d 100644 --- a/examples/rag_event_ingest/kafka_consumer/config/constants.py +++ b/examples/rag_event_ingest/kafka_consumer/config/constants.py @@ -1,3 +1,5 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 # config/constants.py """Static constants that don't change at runtime. diff --git a/examples/rag_event_ingest/kafka_consumer/config/settings.py b/examples/rag_event_ingest/kafka_consumer/config/settings.py index bbfe4824c..3de68380e 100644 --- a/examples/rag_event_ingest/kafka_consumer/config/settings.py +++ b/examples/rag_event_ingest/kafka_consumer/config/settings.py @@ -1,3 +1,5 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 # config/settings.py """Runtime settings loaded from environment variables.""" diff --git a/examples/rag_event_ingest/kafka_consumer/consumer.py b/examples/rag_event_ingest/kafka_consumer/consumer.py index 87a5c0538..f2e3a5925 100644 --- a/examples/rag_event_ingest/kafka_consumer/consumer.py +++ b/examples/rag_event_ingest/kafka_consumer/consumer.py @@ -1,3 +1,5 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 # consumer.py """Kafka consumer for MinIO S3 events.""" diff --git a/examples/rag_event_ingest/kafka_consumer/handlers/__init__.py b/examples/rag_event_ingest/kafka_consumer/handlers/__init__.py index e6f3efcff..6d1839e73 100644 --- a/examples/rag_event_ingest/kafka_consumer/handlers/__init__.py +++ b/examples/rag_event_ingest/kafka_consumer/handlers/__init__.py @@ -1,3 +1,5 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 # Handlers package from .base import BaseHandler from .document import DocumentHandler diff --git a/examples/rag_event_ingest/kafka_consumer/handlers/base.py b/examples/rag_event_ingest/kafka_consumer/handlers/base.py index 6745f2e09..8f11b539f 100644 --- a/examples/rag_event_ingest/kafka_consumer/handlers/base.py +++ b/examples/rag_event_ingest/kafka_consumer/handlers/base.py @@ -1,3 +1,5 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 # handlers/base.py """Base handler abstract class.""" diff --git a/examples/rag_event_ingest/kafka_consumer/handlers/document.py b/examples/rag_event_ingest/kafka_consumer/handlers/document.py index 1df03946d..663939b9f 100644 --- a/examples/rag_event_ingest/kafka_consumer/handlers/document.py +++ b/examples/rag_event_ingest/kafka_consumer/handlers/document.py @@ -1,3 +1,5 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 # handlers/document.py """Handler for document files (PDF, DOCX, TXT, etc.).""" diff --git a/examples/rag_event_ingest/kafka_consumer/main.py b/examples/rag_event_ingest/kafka_consumer/main.py index df4384d4f..faf923076 100644 --- a/examples/rag_event_ingest/kafka_consumer/main.py +++ b/examples/rag_event_ingest/kafka_consumer/main.py @@ -1,4 +1,6 @@ #!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 # main.py """Entry point for Kafka MinIO consumer.""" diff --git a/examples/rag_event_ingest/kafka_consumer/models/__init__.py b/examples/rag_event_ingest/kafka_consumer/models/__init__.py index 2abce8a0d..ccc4f8a55 100644 --- a/examples/rag_event_ingest/kafka_consumer/models/__init__.py +++ b/examples/rag_event_ingest/kafka_consumer/models/__init__.py @@ -1,3 +1,5 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 # Models package from .events import S3Event, HandlerResult, IngestionRecord diff --git a/examples/rag_event_ingest/kafka_consumer/models/events.py b/examples/rag_event_ingest/kafka_consumer/models/events.py index 4baf7112f..84e44900b 100644 --- a/examples/rag_event_ingest/kafka_consumer/models/events.py +++ b/examples/rag_event_ingest/kafka_consumer/models/events.py @@ -1,3 +1,5 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 # models/events.py """Data models for Kafka consumer events and results.""" diff --git a/examples/rag_event_ingest/kafka_consumer/router.py b/examples/rag_event_ingest/kafka_consumer/router.py index 41f5b8f23..2a12d1404 100644 --- a/examples/rag_event_ingest/kafka_consumer/router.py +++ b/examples/rag_event_ingest/kafka_consumer/router.py @@ -1,3 +1,5 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 # router.py """File routing module for MinIO event processing.""" diff --git a/examples/rag_event_ingest/kafka_consumer/services/__init__.py b/examples/rag_event_ingest/kafka_consumer/services/__init__.py index db2c0e347..4214285de 100644 --- a/examples/rag_event_ingest/kafka_consumer/services/__init__.py +++ b/examples/rag_event_ingest/kafka_consumer/services/__init__.py @@ -1,3 +1,5 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 # services/__init__.py """External service clients.""" diff --git a/examples/rag_event_ingest/kafka_consumer/services/document_indexer.py b/examples/rag_event_ingest/kafka_consumer/services/document_indexer.py index ac60d41a2..2c0da73e2 100644 --- a/examples/rag_event_ingest/kafka_consumer/services/document_indexer.py +++ b/examples/rag_event_ingest/kafka_consumer/services/document_indexer.py @@ -1,3 +1,5 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 # services/document_indexer.py """Document indexing service for RAG pipeline.""" diff --git a/examples/rag_event_ingest/kafka_consumer/services/storage.py b/examples/rag_event_ingest/kafka_consumer/services/storage.py index 8f50a1f7b..b5420e61d 100644 --- a/examples/rag_event_ingest/kafka_consumer/services/storage.py +++ b/examples/rag_event_ingest/kafka_consumer/services/storage.py @@ -1,3 +1,5 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 # services/storage.py """S3-compatible object storage service.""" diff --git a/examples/rag_react_agent/src/rag_react_agent/__init__.py b/examples/rag_react_agent/src/rag_react_agent/__init__.py index 1312ed324..48fbeccb9 100644 --- a/examples/rag_react_agent/src/rag_react_agent/__init__.py +++ b/examples/rag_react_agent/src/rag_react_agent/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/examples/rag_react_agent/src/rag_react_agent/configs/config.yml b/examples/rag_react_agent/src/rag_react_agent/configs/config.yml index 67bf99a16..034e68d7e 100644 --- a/examples/rag_react_agent/src/rag_react_agent/configs/config.yml +++ b/examples/rag_react_agent/src/rag_react_agent/configs/config.yml @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/examples/rag_react_agent/src/rag_react_agent/register.py b/examples/rag_react_agent/src/rag_react_agent/register.py index 327d980e8..44c478aa0 100644 --- a/examples/rag_react_agent/src/rag_react_agent/register.py +++ b/examples/rag_react_agent/src/rag_react_agent/register.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/examples/rag_react_agent/uv.lock b/examples/rag_react_agent/uv.lock index 16af3b48d..e2566a931 100644 --- a/examples/rag_react_agent/uv.lock +++ b/examples/rag_react_agent/uv.lock @@ -2037,7 +2037,7 @@ wheels = [ [[package]] name = "nvidia-rag" -version = "2.5.0.dev0" +version = "2.6.0rc1" source = { editable = "../../" } dependencies = [ { name = "anyio" }, diff --git a/frontend/Dockerfile b/frontend/Dockerfile index c6f3e3b01..18987fd5d 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,5 +1,5 @@ -# Build stage - OSRB approved Ubuntu base (Bug 3840915) -FROM nvcr.io/nvidia/base/ubuntu:jammy-20251013 AS builder +# Build stage +FROM nvcr.io/nvidia/base/ubuntu:jammy-20260217 AS builder ARG DOWNLOAD_LEGAL_COMPLIANCE=false @@ -19,7 +19,7 @@ RUN apt-get update && apt-get upgrade -y && apt-get install -y \ # Install Node.js LTS and pnpm RUN curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - && \ apt-get install -y nodejs && \ - npm install -g pnpm + npm install -g pnpm@10.18.2 # Print versions for debugging RUN node -v && pnpm -v @@ -72,7 +72,6 @@ ENV npm_config_yes=true # Build the application RUN rm -rf node_modules && pnpm install --frozen-lockfile -RUN pnpm add -D @types/node RUN pnpm run build # Prepare legal compliance files for production stage (conditional on DOWNLOAD_LEGAL_COMPLIANCE) @@ -93,8 +92,7 @@ RUN if [ "$DOWNLOAD_LEGAL_COMPLIANCE" = "true" ] && [ -d /legal ]; then \ fi # Production stage - NVIDIA distroless (pre-approved) -# Updated to latest version to address CVE-2025-9230 (libssl3) -FROM nvcr.io/nvidia/distroless/node:24-v4.0.2 +FROM nvcr.io/nvidia/distroless/node:24-v4.0.7 # Copy built application and config for production preview WORKDIR /app/frontend @@ -111,4 +109,4 @@ EXPOSE 3000 # Use simple static server + proxy approach # Routes to rag-server (CHAT): /api/generate, /api/summary, /api/configuration # Routes to ingestor-server (VDB): all other /api/* endpoints -CMD ["node", "-e", "const http=require('http');const fs=require('fs');const path=require('path');const {URL}=require('url');const CHAT=process.env.VITE_API_CHAT_URL||'http://rag-server:8081/v1';const VDB=process.env.VITE_API_VDB_URL||'http://ingestor-server:8082/v1';const chatRoutes=['/api/generate','/api/summary','/api/configuration'];const server=http.createServer((req,res)=>{const isChatRoute=chatRoutes.some(r=>req.url.startsWith(r));if(req.url.startsWith('/api/')){const u=new URL(isChatRoute?CHAT:VDB);const opt={hostname:u.hostname,port:u.port,path:req.url.replace('/api',''),method:req.method,headers:{...req.headers,host:u.host}};const pr=http.request(opt,r=>{res.writeHead(r.statusCode,r.headers);r.pipe(res)});pr.on('error',e=>{res.writeHead(500);res.end('Error:'+e.message)});req.pipe(pr)}else{let f=req.url==='/'?'/index.html':req.url;f=path.join('./dist',f);if(!fs.existsSync(f)&&!f.includes('.'))f=path.join('./dist','index.html');fs.readFile(f,(e,d)=>{if(e){res.writeHead(404);res.end('Not found')}else{res.writeHead(200,{'Content-Type':f.endsWith('.html')?'text/html':f.endsWith('.js')?'application/javascript':f.endsWith('.css')?'text/css':'text/plain'});res.end(d)}})}});server.listen(3000,'0.0.0.0',()=>console.log('Server running on port 3000'));"] \ No newline at end of file +CMD ["node", "-e", "const http=require('http');const fs=require('fs');const path=require('path');const {URL}=require('url');const CHAT=process.env.VITE_API_CHAT_URL||'http://rag-server:8081/v1';const VDB=process.env.VITE_API_VDB_URL||'http://ingestor-server:8082/v1';const chatRoutes=['/api/generate','/api/summary','/api/configuration'];const server=http.createServer((req,res)=>{const isChatRoute=chatRoutes.some(r=>req.url.startsWith(r));if(req.url.startsWith('/api/')){const u=new URL(isChatRoute?CHAT:VDB);const opt={hostname:u.hostname,port:u.port,path:req.url.replace('/api',''),method:req.method,headers:{...req.headers,host:u.host}};const pr=http.request(opt,r=>{res.writeHead(r.statusCode,r.headers);r.pipe(res)});pr.on('error',e=>{res.writeHead(500);res.end('Error:'+e.message)});req.pipe(pr)}else{let f=req.url==='/'?'/index.html':req.url;f=path.join('./dist',f);if(!fs.existsSync(f)&&!f.includes('.'))f=path.join('./dist','index.html');fs.readFile(f,(e,d)=>{if(e){res.writeHead(404);res.end('Not found')}else{res.writeHead(200,{'Content-Type':f.endsWith('.html')?'text/html':f.endsWith('.js')?'application/javascript':f.endsWith('.css')?'text/css':'text/plain'});res.end(d)}})}});server.listen(3000,'0.0.0.0',()=>console.log('Server running on port 3000'));"] diff --git a/frontend/package.json b/frontend/package.json index bdb3500f5..2c74eb581 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -56,7 +56,9 @@ "overrides": { "rollup": ">=4.59.0", "minimatch@3.1.2": "3.1.4", - "minimatch@9.0.5": "9.0.7" + "minimatch@9.0.5": "9.0.7", + "picomatch": ">=4.0.4", + "flatted": ">=3.4.2" } } } \ No newline at end of file diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index cc1a633fc..dc734b7a5 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -9,6 +9,8 @@ overrides: rollup: '>=4.59.0' minimatch@3.1.2: 3.1.4 minimatch@9.0.5: 9.0.7 + picomatch: '>=4.0.4' + flatted: '>=3.4.2' importers: @@ -140,6 +142,10 @@ packages: resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + '@babel/compat-data@7.28.5': resolution: {integrity: sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==} engines: {node: '>=6.9.0'} @@ -182,6 +188,10 @@ packages: resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@7.27.1': resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} engines: {node: '>=6.9.0'} @@ -211,6 +221,10 @@ packages: resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} engines: {node: '>=6.9.0'} + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/template@7.27.2': resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} engines: {node: '>=6.9.0'} @@ -1270,79 +1284,66 @@ packages: resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.59.0': resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.59.0': resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.59.0': resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.59.0': resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.59.0': resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.59.0': resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.59.0': resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.59.0': resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.59.0': resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.59.0': resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.59.0': resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.59.0': resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.59.0': resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} @@ -1412,28 +1413,24 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.17': resolution: {integrity: sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.17': resolution: {integrity: sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.17': resolution: {integrity: sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.17': resolution: {integrity: sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg==} @@ -1952,7 +1949,7 @@ packages: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} peerDependencies: - picomatch: ^3 || ^4 + picomatch: '>=4.0.4' peerDependenciesMeta: picomatch: optional: true @@ -1972,8 +1969,8 @@ packages: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} - flatted@3.3.3: - resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} foreground-child@3.3.1: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} @@ -1998,6 +1995,7 @@ packages: glob@10.5.0: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true globals@14.0.0: @@ -2175,28 +2173,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.30.2: resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} @@ -2334,8 +2328,8 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@4.0.3: - resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} postcss@8.5.6: @@ -2854,6 +2848,12 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + '@babel/compat-data@7.28.5': {} '@babel/core@7.28.5': @@ -2916,6 +2916,8 @@ snapshots: '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-identifier@7.29.7': {} + '@babel/helper-validator-option@7.27.1': {} '@babel/helpers@7.28.4': @@ -2939,6 +2941,8 @@ snapshots: '@babel/runtime@7.28.4': {} + '@babel/runtime@7.29.7': {} + '@babel/template@7.27.2': dependencies: '@babel/code-frame': 7.27.1 @@ -4106,8 +4110,8 @@ snapshots: '@testing-library/dom@10.4.1': dependencies: - '@babel/code-frame': 7.27.1 - '@babel/runtime': 7.28.4 + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 '@types/aria-query': 5.0.4 aria-query: 5.3.0 dom-accessibility-api: 0.5.16 @@ -4346,7 +4350,7 @@ snapshots: dependencies: '@vitest/utils': 3.2.4 fflate: 0.8.2 - flatted: 3.3.3 + flatted: 3.4.2 pathe: 2.0.3 sirv: 3.0.2 tinyglobby: 0.2.15 @@ -4652,9 +4656,9 @@ snapshots: fast-levenshtein@2.0.6: {} - fdir@6.5.0(picomatch@4.0.3): + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: - picomatch: 4.0.3 + picomatch: 4.0.4 fflate@0.8.2: {} @@ -4669,10 +4673,10 @@ snapshots: flat-cache@4.0.1: dependencies: - flatted: 3.3.3 + flatted: 3.4.2 keyv: 4.5.4 - flatted@3.3.3: {} + flatted@3.4.2: {} foreground-child@3.3.1: dependencies: @@ -4990,7 +4994,7 @@ snapshots: picocolors@1.1.1: {} - picomatch@4.0.3: {} + picomatch@4.0.4: {} postcss@8.5.6: dependencies: @@ -5258,8 +5262,8 @@ snapshots: tinyglobby@0.2.15: dependencies: - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 tinypool@1.1.1: {} @@ -5363,8 +5367,8 @@ snapshots: vite@6.4.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2): dependencies: esbuild: 0.25.12 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 postcss: 8.5.6 rollup: 4.59.0 tinyglobby: 0.2.15 @@ -5389,7 +5393,7 @@ snapshots: expect-type: 1.3.0 magic-string: 0.30.21 pathe: 2.0.3 - picomatch: 4.0.3 + picomatch: 4.0.4 std-env: 3.10.0 tinybench: 2.9.0 tinyexec: 0.3.2 diff --git a/frontend/src/components/chat/AgenticModeSelector.tsx b/frontend/src/components/chat/AgenticModeSelector.tsx new file mode 100644 index 000000000..edc7ec861 --- /dev/null +++ b/frontend/src/components/chat/AgenticModeSelector.tsx @@ -0,0 +1,139 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// 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. + +import { useCallback, useMemo } from "react"; +import { Dropdown, Flex, Text } from "@kui/react"; +import { Workflow } from "lucide-react"; +import { useSettingsStore, type AgenticMode } from "../../store/useSettingsStore"; + +/** + * Static metadata for each agentic mode. Kept as a top-level constant so + * the dropdown items are referentially stable across renders. + */ +const MODE_OPTIONS: ReadonlyArray<{ + id: AgenticMode; + label: string; + description: string; +}> = [ + { + id: "off", + label: "Standard", + description: "Standard RAG pipeline", + }, + { + id: "on", + label: "Agentic", + description: "LangGraph plan-and-execute", + }, +] as const; + +const MODE_LABELS: Record = MODE_OPTIONS.reduce( + (acc, opt) => { + acc[opt.id] = opt.label; + return acc; + }, + {} as Record +); + +/** + * Two-state selector that controls the per-request `agentic` flag on the + * `/generate` endpoint. + * + * - `Standard` (default): sends `agentic: false` to force the standard + * RAG pipeline. + * - `Agentic`: sends `agentic: true` to use the LangGraph plan-and-execute + * pipeline. + * + * (The previous `Auto` mode — omit the field, let the server decide — was + * dropped per the #514 review: with only two real outcomes a third option + * was just noise.) + * + * The selection is persisted via `useSettingsStore`, matching how every + * other chat setting is stored. + * + * @returns A Dropdown-backed pill that shows the current pipeline mode. + */ +export const AgenticModeSelector = () => { + const agenticMode = useSettingsStore((s) => s.agenticMode); + const setSettings = useSettingsStore((s) => s.set); + + const handleSelect = useCallback( + (mode: AgenticMode) => { + setSettings({ agenticMode: mode }); + }, + [setSettings] + ); + + const items = useMemo( + () => + MODE_OPTIONS.map((opt) => ({ + children: ( + + {opt.label} + + {opt.description} + + + ), + onSelect: () => handleSelect(opt.id), + })), + [agenticMode, handleSelect] + ); + + return ( + + + + + Pipeline: + + + + + {MODE_LABELS[agenticMode]} + + + + ); +}; diff --git a/frontend/src/components/chat/ChatMessageBubble.tsx b/frontend/src/components/chat/ChatMessageBubble.tsx index ab68ab2ca..9b7e9ce40 100644 --- a/frontend/src/components/chat/ChatMessageBubble.tsx +++ b/frontend/src/components/chat/ChatMessageBubble.tsx @@ -18,6 +18,7 @@ import type { ChatMessage, MessageContent as MessageContentType } from "../../ty import { useStreamingStore } from "../../store/useStreamingStore"; import { MessageContent } from "./MessageContent"; import { StreamingIndicator } from "./StreamingIndicator"; +import { ReasoningPanel } from "./ReasoningPanel"; import { CitationButton } from "../citations/CitationButton"; import { Block, @@ -93,14 +94,26 @@ const MessageContainer = ({ ); -const StreamingMessage = ({ content, isError = false }: { content: MessageContentType; isError?: boolean }) => { - const textContent = extractTextFromContent(content); +const StreamingMessage = ({ + msg, + isError = false, +}: { + msg: ChatMessage; + isError?: boolean; +}) => { + const textContent = extractTextFromContent(msg.content); + const reasoningSteps = msg.reasoning_steps; return ( - - - {!textContent && } - + + {reasoningSteps && reasoningSteps.length > 0 && ( + + )} + + + {!textContent && } + + ); }; @@ -180,6 +193,9 @@ const RegularMessage = ({ msg }: { msg: ChatMessage }) => { ))} )} + {msg.role === "assistant" && msg.reasoning_steps && msg.reasoning_steps.length > 0 && ( + + )} {/* Always render text content block to maintain structure */} @@ -208,7 +224,7 @@ export default function ChatMessageBubble({ msg }: ChatMessageBubbleProps) { ); if (isThisMessageStreaming) { - return ; + return ; } return ; diff --git a/frontend/src/components/chat/MessageInput.tsx b/frontend/src/components/chat/MessageInput.tsx index 122230964..47f552b57 100644 --- a/frontend/src/components/chat/MessageInput.tsx +++ b/frontend/src/components/chat/MessageInput.tsx @@ -18,6 +18,7 @@ import { useChatStore } from "../../store/useChatStore"; import { useCollectionsStore } from "../../store/useCollectionsStore"; import { CollectionChips } from "../collections/CollectionChips"; import { MessageInputContainer } from "./MessageInputContainer"; +import { AgenticModeSelector } from "./AgenticModeSelector"; import SimpleFilterBar from "../filtering/SimpleFilterBar"; import { Flex, Banner, Block } from "@kui/react"; @@ -26,6 +27,7 @@ export { CollectionChips } from "../collections/CollectionChips"; export { MessageTextarea } from "./MessageTextarea"; export { MessageActions } from "./MessageActions"; export { MessageInputContainer } from "./MessageInputContainer"; +export { AgenticModeSelector } from "./AgenticModeSelector"; export default function MessageInput() { const { filters, setFilters } = useChatStore(); @@ -33,7 +35,14 @@ export default function MessageInput() { return ( - + + + + <> {selectedCollections.length === 1 && ( diff --git a/frontend/src/components/chat/ReasoningPanel.tsx b/frontend/src/components/chat/ReasoningPanel.tsx new file mode 100644 index 000000000..187f39a9c --- /dev/null +++ b/frontend/src/components/chat/ReasoningPanel.tsx @@ -0,0 +1,207 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// 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. + +import { useCallback, useEffect, useState } from "react"; +import { AnimatedChevron, Block, Flex, Stack, Text } from "@kui/react"; +import { AlertCircle, Brain, CheckCircle2, Loader2 } from "lucide-react"; +import type { ReasoningStep } from "../../types/chat"; +import { useCitationUtils } from "../../hooks/useCitationUtils"; + +interface ReasoningPanelProps { + /** Reasoning trace from the stream. */ + steps: ReasoningStep[]; + /** Whether the parent message is still streaming. */ + streaming?: boolean; +} + +const StepIcon = ({ status }: { status: ReasoningStep["status"] }) => { + if (status === "running") { + return ( + + ); + } + if (status === "error") { + return ( + + ); + } + return ( + + ); +}; + +const StepRow = ({ + step, + formatStage, +}: { + step: ReasoningStep; + formatStage: (stage: string) => string; +}) => { + const headline = step.summary ?? step.label; + return ( + + + + {formatStage(step.stage)} + {headline && ( + + — {headline} + + )} + + {(step.reasoning || step.output) && ( + + {step.reasoning && ( + + {step.reasoning} + + )} + {step.output && ( + + {step.output} + + )} + + )} + + ); +}; + +/** + * Collapsible "Thinking" panel that renders the reasoning trace above + * the assistant's final answer. + * + * - Hidden when there are no steps. + * - Auto-expanded while the message is streaming so users can watch the + * agent work; collapses on completion to a "Thought for N steps" line. + * - Step labels are formatted via `useCitationUtils.formatStage` so any + * future graph node renders without code changes. + */ +export const ReasoningPanel = ({ steps, streaming = false }: ReasoningPanelProps) => { + const { formatStage } = useCitationUtils(); + const [open, setOpen] = useState(streaming); + const [autoCollapsed, setAutoCollapsed] = useState(false); + + // Auto-collapse exactly once when streaming finishes. + useEffect(() => { + if (!streaming && !autoCollapsed) { + setOpen(false); + setAutoCollapsed(true); + } + if (streaming && autoCollapsed) { + setAutoCollapsed(false); + } + }, [streaming, autoCollapsed]); + + const toggle = useCallback(() => setOpen((prev) => !prev), []); + + if (!steps || steps.length === 0) return null; + + const stepCount = steps.length; + const headerLabel = streaming + ? `Thinking (${stepCount} step${stepCount === 1 ? "" : "s"})` + : `Thought for ${stepCount} step${stepCount === 1 ? "" : "s"}`; + + return ( + + { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + toggle(); + } + }} + data-testid="reasoning-panel-toggle" + style={{ cursor: "pointer", userSelect: "none" }} + > + + + + {headerLabel} + + + {open && ( + + + {steps.map((step, index) => ( + + ))} + + + )} + + ); +}; diff --git a/frontend/src/components/chat/__tests__/AgenticModeSelector.test.tsx b/frontend/src/components/chat/__tests__/AgenticModeSelector.test.tsx new file mode 100644 index 000000000..16255f9dd --- /dev/null +++ b/frontend/src/components/chat/__tests__/AgenticModeSelector.test.tsx @@ -0,0 +1,119 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// 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. + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, act } from '../../../test/utils'; +import { AgenticModeSelector } from '../AgenticModeSelector'; +import { useSettingsStore, type AgenticMode } from '../../../store/useSettingsStore'; + +// We want the real Zustand store but a minimal Dropdown so `onSelect` +// handlers are reachable via simple click events in jsdom. +vi.mock('@kui/react', async () => { + const actual = await vi.importActual('@kui/react'); + type Item = { children: React.ReactNode; onSelect?: () => void }; + return { + ...actual, + Dropdown: ({ + items, + children, + ...rest + }: { + items: Item[]; + children: React.ReactNode; + } & Record) => ( +
+ {children} +
    + {items.map((item, i) => ( +
  • + +
  • + ))} +
+
+ ), + }; +}); + +describe('AgenticModeSelector', () => { + beforeEach(() => { + // The store now defaults to "off" (Standard) and the previously-allowed + // "auto" value was removed — see the AgenticMode type. + act(() => { + useSettingsStore.setState({ agenticMode: 'off' }); + }); + }); + + it('renders the current mode in the trigger label', () => { + render(); + const trigger = screen.getByTestId('agentic-mode-trigger'); + expect(trigger).toHaveTextContent('Standard'); + expect(trigger).toHaveAttribute('data-mode', 'off'); + }); + + it('exposes both modes as dropdown items in a stable order (Standard first)', () => { + render(); + expect(screen.getByTestId('agentic-mode-option-off')).toHaveTextContent('Standard'); + expect(screen.getByTestId('agentic-mode-option-on')).toHaveTextContent('Agentic'); + expect( + screen.queryByTestId('agentic-mode-option-auto') + ).not.toBeInTheDocument(); + }); + + it('marks the active option via data-active', () => { + act(() => { + useSettingsStore.setState({ agenticMode: 'on' }); + }); + render(); + expect(screen.getByTestId('agentic-mode-option-on')).toHaveAttribute( + 'data-active', + 'true' + ); + expect(screen.getByTestId('agentic-mode-option-off')).toHaveAttribute( + 'data-active', + 'false' + ); + }); + + it.each<[string, AgenticMode]>([ + ['mock-dropdown-item-0', 'off'], + ['mock-dropdown-item-1', 'on'], + ])('selecting %s sets agenticMode to %s', (testId, expected) => { + render(); + fireEvent.click(screen.getByTestId(testId)); + expect(useSettingsStore.getState().agenticMode).toBe(expected); + }); + + it('updates the trigger label after selecting a mode', () => { + render(); + fireEvent.click(screen.getByTestId('mock-dropdown-item-1')); + expect(screen.getByTestId('agentic-mode-trigger')).toHaveTextContent('Agentic'); + expect(screen.getByTestId('agentic-mode-trigger')).toHaveAttribute('data-mode', 'on'); + }); + + it('exposes an aria-label on the trigger reflecting the current mode', () => { + render(); + expect(screen.getByTestId('agentic-mode-trigger')).toHaveAttribute( + 'aria-label', + 'Pipeline mode: Standard' + ); + }); +}); diff --git a/frontend/src/components/chat/__tests__/ChatMessageBubble.test.tsx b/frontend/src/components/chat/__tests__/ChatMessageBubble.test.tsx index 38e9614e2..c512b2cd2 100644 --- a/frontend/src/components/chat/__tests__/ChatMessageBubble.test.tsx +++ b/frontend/src/components/chat/__tests__/ChatMessageBubble.test.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen } from '../../../test/utils'; import ChatMessageBubble from '../ChatMessageBubble'; diff --git a/frontend/src/components/chat/__tests__/Citations.test.tsx b/frontend/src/components/chat/__tests__/Citations.test.tsx index f08c1ecbb..94eb52732 100644 --- a/frontend/src/components/chat/__tests__/Citations.test.tsx +++ b/frontend/src/components/chat/__tests__/Citations.test.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, vi } from 'vitest'; import { render, screen } from '../../../test/utils'; import Citations from '../Citations'; diff --git a/frontend/src/components/chat/__tests__/MessageActions.test.tsx b/frontend/src/components/chat/__tests__/MessageActions.test.tsx index 981cff914..0501b5c80 100644 --- a/frontend/src/components/chat/__tests__/MessageActions.test.tsx +++ b/frontend/src/components/chat/__tests__/MessageActions.test.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent } from '../../../test/utils'; import { MessageActions } from '../MessageActions'; diff --git a/frontend/src/components/chat/__tests__/MessageContent.test.tsx b/frontend/src/components/chat/__tests__/MessageContent.test.tsx index 348edfa8a..5cf6d378a 100644 --- a/frontend/src/components/chat/__tests__/MessageContent.test.tsx +++ b/frontend/src/components/chat/__tests__/MessageContent.test.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render } from '../../../test/utils'; import { MessageContent } from '../MessageContent'; diff --git a/frontend/src/components/chat/__tests__/MessageInput.test.tsx b/frontend/src/components/chat/__tests__/MessageInput.test.tsx index 68d924e88..af36ddcb3 100644 --- a/frontend/src/components/chat/__tests__/MessageInput.test.tsx +++ b/frontend/src/components/chat/__tests__/MessageInput.test.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen } from '../../../test/utils'; import MessageInput from '../MessageInput'; @@ -23,6 +25,10 @@ vi.mock('../MessageInputContainer', () => ({ MessageInputContainer: () =>
Message Input Container
})); +vi.mock('../AgenticModeSelector', () => ({ + AgenticModeSelector: () =>
Agentic Mode Selector
+})); + vi.mock('../../filtering/SimpleFilterBar', () => ({ default: ({ filters }: { filters: Filter[] }) => (
@@ -71,10 +77,19 @@ describe('MessageInput', () => { render(); expect(screen.getByTestId('collection-chips')).toBeInTheDocument(); + expect(screen.getByTestId('agentic-mode-selector')).toBeInTheDocument(); expect(screen.getByTestId('filter-bar')).toBeInTheDocument(); expect(screen.getByTestId('message-input-container')).toBeInTheDocument(); }); + it('renders the agentic mode selector even when no collections are selected', () => { + mockUseCollectionsStore.mockReturnValue({ selectedCollections: [] }); + + render(); + + expect(screen.getByTestId('agentic-mode-selector')).toBeInTheDocument(); + }); + it('renders components in correct order', () => { render(); diff --git a/frontend/src/components/chat/__tests__/MessageInputContainer.test.tsx b/frontend/src/components/chat/__tests__/MessageInputContainer.test.tsx index a0631831f..680d62b63 100644 --- a/frontend/src/components/chat/__tests__/MessageInputContainer.test.tsx +++ b/frontend/src/components/chat/__tests__/MessageInputContainer.test.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, vi } from 'vitest'; import { render, screen } from '../../../test/utils'; import { MessageInputContainer } from '../MessageInputContainer'; diff --git a/frontend/src/components/chat/__tests__/MessageTextarea.test.tsx b/frontend/src/components/chat/__tests__/MessageTextarea.test.tsx index 58bae6e8a..4526087bb 100644 --- a/frontend/src/components/chat/__tests__/MessageTextarea.test.tsx +++ b/frontend/src/components/chat/__tests__/MessageTextarea.test.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent } from '../../../test/utils'; import { MessageTextarea } from '../MessageTextarea'; diff --git a/frontend/src/components/chat/__tests__/ReasoningPanel.test.tsx b/frontend/src/components/chat/__tests__/ReasoningPanel.test.tsx new file mode 100644 index 000000000..c60438954 --- /dev/null +++ b/frontend/src/components/chat/__tests__/ReasoningPanel.test.tsx @@ -0,0 +1,175 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// 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. + +import { describe, it, expect } from 'vitest'; +import { render, screen, fireEvent } from '../../../test/utils'; +import { ReasoningPanel } from '../ReasoningPanel'; +import type { ReasoningStep } from '../../../types/chat'; + +const buildStep = (overrides: Partial = {}): ReasoningStep => ({ + stage: 'plan', + reasoning: '', + output: '', + status: 'done', + ...overrides, +}); + +describe('ReasoningPanel', () => { + it('renders nothing when there are no steps', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('shows "Thought for N steps" header when not streaming', () => { + render( + + ); + expect(screen.getByTestId('reasoning-panel-toggle')).toHaveTextContent( + 'Thought for 2 steps' + ); + }); + + it('shows live "Thinking" header while streaming', () => { + render( + + ); + expect(screen.getByTestId('reasoning-panel-toggle')).toHaveTextContent( + 'Thinking (1 step)' + ); + }); + + it('formats the stage identifier into a human-readable label without code change', () => { + render( + + ); + expect(screen.getByTestId('reasoning-step-verify_execute')).toHaveTextContent( + 'Verify execute' + ); + }); + + it('auto-expands while streaming and shows step content', () => { + const steps: ReasoningStep[] = [ + buildStep({ + stage: 'plan', + label: 'Planning…', + reasoning: 'Step 1: scope.', + status: 'running', + }), + ]; + render(); + expect(screen.getByTestId('reasoning-panel')).toHaveAttribute( + 'data-state', + 'open' + ); + expect(screen.getByTestId('reasoning-step-plan')).toHaveTextContent( + 'Step 1: scope.' + ); + }); + + it('starts collapsed when not streaming and toggles on click', () => { + render( + + ); + expect(screen.getByTestId('reasoning-panel')).toHaveAttribute( + 'data-state', + 'closed' + ); + fireEvent.click(screen.getByTestId('reasoning-panel-toggle')); + expect(screen.getByTestId('reasoning-panel')).toHaveAttribute( + 'data-state', + 'open' + ); + }); + + it('exposes step status via data-status for downstream styling', () => { + render( + + ); + expect(screen.getByTestId('reasoning-step-plan')).toHaveAttribute( + 'data-status', + 'running' + ); + expect(screen.getByTestId('reasoning-step-execute')).toHaveAttribute( + 'data-status', + 'error' + ); + expect(screen.getByTestId('reasoning-step-synthesize')).toHaveAttribute( + 'data-status', + 'done' + ); + }); + + it('renders both reasoning and output blocks when both are present', () => { + render( + + ); + const stepEl = screen.getByTestId('reasoning-step-execute'); + expect(stepEl).toHaveTextContent('thinking text'); + expect(stepEl).toHaveTextContent('{"task": 1}'); + }); + + it('toggle is keyboard accessible (Enter / Space)', () => { + render( + + ); + const toggle = screen.getByTestId('reasoning-panel-toggle'); + expect(screen.getByTestId('reasoning-panel')).toHaveAttribute( + 'data-state', + 'closed' + ); + fireEvent.keyDown(toggle, { key: 'Enter' }); + expect(screen.getByTestId('reasoning-panel')).toHaveAttribute( + 'data-state', + 'open' + ); + fireEvent.keyDown(toggle, { key: ' ' }); + expect(screen.getByTestId('reasoning-panel')).toHaveAttribute( + 'data-state', + 'closed' + ); + }); +}); diff --git a/frontend/src/components/chat/__tests__/StreamingIndicator.test.tsx b/frontend/src/components/chat/__tests__/StreamingIndicator.test.tsx index 1666a2df9..44a7b00df 100644 --- a/frontend/src/components/chat/__tests__/StreamingIndicator.test.tsx +++ b/frontend/src/components/chat/__tests__/StreamingIndicator.test.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { describe, it, expect } from 'vitest'; import { render, screen } from '../../../test/utils'; import { StreamingIndicator } from '../StreamingIndicator'; diff --git a/frontend/src/components/citations/CitationMetadata.tsx b/frontend/src/components/citations/CitationMetadata.tsx index 305a5cc88..ec80d4c8e 100644 --- a/frontend/src/components/citations/CitationMetadata.tsx +++ b/frontend/src/components/citations/CitationMetadata.tsx @@ -22,6 +22,15 @@ interface CitationMetadataProps { score?: number; } +/** + * Source / relevance metadata row rendered below an expanded citation. + * + * The `Citation.stage` field is intentionally not rendered here — per + * the #514 review the visual stage badges (header pill + this expanded + * row) were dropped. The data still flows through `Citation.stage` for + * future use (debugging, agentic-RAG reasoning panel), it's simply not + * surfaced in the citations UI. + */ export const CitationMetadata = ({ source, score }: CitationMetadataProps) => { const { formatScore } = useCitationUtils(); diff --git a/frontend/src/components/citations/__tests__/CitationButton.test.tsx b/frontend/src/components/citations/__tests__/CitationButton.test.tsx index 58c957923..5d32feb94 100644 --- a/frontend/src/components/citations/__tests__/CitationButton.test.tsx +++ b/frontend/src/components/citations/__tests__/CitationButton.test.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent } from '../../../test/utils'; import { CitationButton } from '../CitationButton'; diff --git a/frontend/src/components/citations/__tests__/CitationItem.test.tsx b/frontend/src/components/citations/__tests__/CitationItem.test.tsx index a2bccf88e..6aff74428 100644 --- a/frontend/src/components/citations/__tests__/CitationItem.test.tsx +++ b/frontend/src/components/citations/__tests__/CitationItem.test.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent } from '../../../test/utils'; import CitationItem from '../CitationItem'; @@ -84,7 +86,11 @@ describe('CitationItem', () => { mockUseCitationUtils.mockReturnValue({ generateCitationId: mockGenerateCitationId, isVisualType: vi.fn().mockReturnValue(false), - formatScore: vi.fn().mockReturnValue('0.85') + formatScore: vi.fn().mockReturnValue('0.85'), + // formatStage stays exported by the hook (used by ReasoningPanel + // in the agentic streaming path) but is no longer consumed by + // CitationItem after the #514 review removed the stage badges. + formatStage: vi.fn().mockReturnValue(''), }); mockUseCitationExpansion.mockReturnValue({ @@ -261,7 +267,8 @@ describe('CitationItem', () => { mockUseCitationUtils.mockReturnValue({ generateCitationId: mockGenerateCitationId, isVisualType: vi.fn().mockReturnValue(true), - formatScore: vi.fn().mockReturnValue('0.85') + formatScore: vi.fn().mockReturnValue('0.85'), + formatStage: vi.fn().mockReturnValue(''), }); render(); @@ -279,7 +286,8 @@ describe('CitationItem', () => { mockUseCitationUtils.mockReturnValue({ generateCitationId: mockGenerateCitationId, isVisualType: vi.fn().mockReturnValue(false), - formatScore: vi.fn().mockReturnValue('0.85') + formatScore: vi.fn().mockReturnValue('0.85'), + formatStage: vi.fn().mockReturnValue(''), }); render(); @@ -326,13 +334,32 @@ describe('CitationItem', () => { }); }); + describe('Stage Badge (disabled per #514 review)', () => { + // Regression guard for the visual decision to drop the stage badge. + // The data still flows on Citation.stage; only the badge UI was + // removed. If a future change adds it back, these assertions must + // be revisited explicitly rather than slipping through quietly. + it('does not render a stage badge even when citation.stage is present', () => { + const citation: Citation = { ...mockCitation, stage: 'initial_retrieval' }; + render(); + expect(screen.queryByTestId('citation-stage-badge')).not.toBeInTheDocument(); + }); + + it('does not render a stage badge for any stage value', () => { + const citation: Citation = { ...mockCitation, stage: 'plan_then_self_critique_v2' }; + render(); + expect(screen.queryByTestId('citation-stage-badge')).not.toBeInTheDocument(); + }); + }); + describe('Integration with Utils', () => { it('passes correct parameters to isVisualType', () => { const mockIsVisualType = vi.fn().mockReturnValue(false); mockUseCitationUtils.mockReturnValue({ generateCitationId: mockGenerateCitationId, isVisualType: mockIsVisualType, - formatScore: vi.fn().mockReturnValue('0.85') + formatScore: vi.fn().mockReturnValue('0.85'), + formatStage: vi.fn().mockReturnValue(''), }); mockUseCitationExpansion.mockReturnValue({ diff --git a/frontend/src/components/citations/__tests__/CitationMetadata.test.tsx b/frontend/src/components/citations/__tests__/CitationMetadata.test.tsx index fb54b59ba..6d9cc84a1 100644 --- a/frontend/src/components/citations/__tests__/CitationMetadata.test.tsx +++ b/frontend/src/components/citations/__tests__/CitationMetadata.test.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen } from '../../../test/utils'; import { CitationMetadata } from '../CitationMetadata'; @@ -6,7 +8,7 @@ import { CitationMetadata } from '../CitationMetadata'; const mockFormatScore = vi.fn(); vi.mock('../../../hooks/useCitationUtils', () => ({ useCitationUtils: () => ({ - formatScore: mockFormatScore + formatScore: mockFormatScore, }) })); @@ -17,14 +19,16 @@ describe('CitationMetadata', () => { }); describe('Conditional Rendering', () => { - it('renders nothing when no source and no score', () => { + it('renders nothing when no source or score', () => { const { container } = render(); expect(container.firstChild).toBeNull(); }); - it('renders nothing when source is undefined and score is undefined', () => { - const { container } = render(); + it('renders nothing when source and score are both undefined', () => { + const { container } = render( + + ); expect(container.firstChild).toBeNull(); }); @@ -124,5 +128,18 @@ describe('CitationMetadata', () => { expect(mockFormatScore).toHaveBeenCalledWith(0.123456789, 3); }); + + }); + + describe('Stage Display (disabled per #514 review)', () => { + // Regression guard: the visual stage row was removed. The data still + // exists on Citation.stage; only the rendering was disabled. If a + // future change re-adds it, these assertions must be revisited + // explicitly rather than slipping through. + it('does not render a stage row in any case', () => { + render(); + expect(screen.queryByTestId('citation-stage-row')).not.toBeInTheDocument(); + expect(screen.queryByText(/Pipeline stage:/)).not.toBeInTheDocument(); + }); }); }); \ No newline at end of file diff --git a/frontend/src/components/citations/__tests__/CitationScore.test.tsx b/frontend/src/components/citations/__tests__/CitationScore.test.tsx index ae962a5e9..81e48f96d 100644 --- a/frontend/src/components/citations/__tests__/CitationScore.test.tsx +++ b/frontend/src/components/citations/__tests__/CitationScore.test.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen } from '../../../test/utils'; import { CitationScore } from '../CitationScore'; diff --git a/frontend/src/components/citations/__tests__/CitationTextContent.test.tsx b/frontend/src/components/citations/__tests__/CitationTextContent.test.tsx index 7070815c6..4786bd765 100644 --- a/frontend/src/components/citations/__tests__/CitationTextContent.test.tsx +++ b/frontend/src/components/citations/__tests__/CitationTextContent.test.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render } from '../../../test/utils'; import { CitationTextContent } from '../CitationTextContent'; diff --git a/frontend/src/components/citations/__tests__/CitationVisualContent.test.tsx b/frontend/src/components/citations/__tests__/CitationVisualContent.test.tsx index f5f0f2f16..1e3401690 100644 --- a/frontend/src/components/citations/__tests__/CitationVisualContent.test.tsx +++ b/frontend/src/components/citations/__tests__/CitationVisualContent.test.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { describe, it, expect } from 'vitest'; import { render, screen } from '../../../test/utils'; import { CitationVisualContent } from '../CitationVisualContent'; diff --git a/frontend/src/components/collections/CollectionsGrid.tsx b/frontend/src/components/collections/CollectionsGrid.tsx index d8e57e91b..ace3ad0aa 100644 --- a/frontend/src/components/collections/CollectionsGrid.tsx +++ b/frontend/src/components/collections/CollectionsGrid.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { VerticalNav, Spinner, StatusMessage, Flex } from "@kui/react"; import { useCollections } from "../../api/useCollectionsApi"; import type { Collection } from "../../types/collections"; diff --git a/frontend/src/components/collections/__tests__/CollectionChips.test.tsx b/frontend/src/components/collections/__tests__/CollectionChips.test.tsx index 5288cbdf2..ee4562e74 100644 --- a/frontend/src/components/collections/__tests__/CollectionChips.test.tsx +++ b/frontend/src/components/collections/__tests__/CollectionChips.test.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent } from '../../../test/utils'; import { CollectionChips } from '../CollectionChips'; diff --git a/frontend/src/components/collections/__tests__/CollectionDrawer.test.tsx b/frontend/src/components/collections/__tests__/CollectionDrawer.test.tsx index 99d39fb34..3441c06e7 100644 --- a/frontend/src/components/collections/__tests__/CollectionDrawer.test.tsx +++ b/frontend/src/components/collections/__tests__/CollectionDrawer.test.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen } from '../../../test/utils'; import CollectionDrawer from '../CollectionDrawer'; diff --git a/frontend/src/components/collections/__tests__/CollectionItem.test.tsx b/frontend/src/components/collections/__tests__/CollectionItem.test.tsx index fd128a3f5..07dc7aabf 100644 --- a/frontend/src/components/collections/__tests__/CollectionItem.test.tsx +++ b/frontend/src/components/collections/__tests__/CollectionItem.test.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent } from '../../../test/utils'; import { CollectionItem } from '../CollectionItem'; diff --git a/frontend/src/components/collections/__tests__/CollectionList.test.tsx b/frontend/src/components/collections/__tests__/CollectionList.test.tsx index c6a2c2b98..697ff09ab 100644 --- a/frontend/src/components/collections/__tests__/CollectionList.test.tsx +++ b/frontend/src/components/collections/__tests__/CollectionList.test.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent } from '../../../test/utils'; import CollectionList from '../CollectionList'; diff --git a/frontend/src/components/collections/__tests__/CollectionsGrid.test.tsx b/frontend/src/components/collections/__tests__/CollectionsGrid.test.tsx index bbd8a307f..36113013b 100644 --- a/frontend/src/components/collections/__tests__/CollectionsGrid.test.tsx +++ b/frontend/src/components/collections/__tests__/CollectionsGrid.test.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen } from '../../../test/utils'; import { CollectionsGrid } from '../CollectionsGrid'; diff --git a/frontend/src/components/collections/__tests__/NewCollectionButton.test.tsx b/frontend/src/components/collections/__tests__/NewCollectionButton.test.tsx index 72f5de9c0..9211dcab3 100644 --- a/frontend/src/components/collections/__tests__/NewCollectionButton.test.tsx +++ b/frontend/src/components/collections/__tests__/NewCollectionButton.test.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent } from '../../../test/utils'; import { NewCollectionButton } from '../NewCollectionButton'; diff --git a/frontend/src/components/collections/__tests__/NewCollectionButtons.test.tsx b/frontend/src/components/collections/__tests__/NewCollectionButtons.test.tsx index 3fb2e0b47..fbe40657c 100644 --- a/frontend/src/components/collections/__tests__/NewCollectionButtons.test.tsx +++ b/frontend/src/components/collections/__tests__/NewCollectionButtons.test.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent } from '../../../test/utils'; import NewCollectionButtons from '../NewCollectionButtons'; diff --git a/frontend/src/components/drawer/__tests__/DrawerActions.test.tsx b/frontend/src/components/drawer/__tests__/DrawerActions.test.tsx index 1c67e2437..8ceb70d24 100644 --- a/frontend/src/components/drawer/__tests__/DrawerActions.test.tsx +++ b/frontend/src/components/drawer/__tests__/DrawerActions.test.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent } from '../../../test/utils'; import { DrawerActions } from '../DrawerActions'; diff --git a/frontend/src/components/drawer/__tests__/SidebarDrawer.test.tsx b/frontend/src/components/drawer/__tests__/SidebarDrawer.test.tsx index 6822f816f..fab5241a5 100644 --- a/frontend/src/components/drawer/__tests__/SidebarDrawer.test.tsx +++ b/frontend/src/components/drawer/__tests__/SidebarDrawer.test.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen } from '../../../test/utils'; import SidebarDrawer from '../SidebarDrawer'; diff --git a/frontend/src/components/drawer/__tests__/UploaderSection.test.tsx b/frontend/src/components/drawer/__tests__/UploaderSection.test.tsx index a34017f3d..25ddc0852 100644 --- a/frontend/src/components/drawer/__tests__/UploaderSection.test.tsx +++ b/frontend/src/components/drawer/__tests__/UploaderSection.test.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent } from '../../../test/utils'; import { UploaderSection } from '../UploaderSection'; diff --git a/frontend/src/components/files/FileCard.tsx b/frontend/src/components/files/FileCard.tsx index ad6f28fa4..1402fa107 100644 --- a/frontend/src/components/files/FileCard.tsx +++ b/frontend/src/components/files/FileCard.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { useCallback, useState } from "react"; import { useNewCollectionStore } from "../../store/useNewCollectionStore"; import { MetadataField } from "./MetadataField"; diff --git a/frontend/src/components/files/MetadataField.tsx b/frontend/src/components/files/MetadataField.tsx index a7042c064..d7f643700 100644 --- a/frontend/src/components/files/MetadataField.tsx +++ b/frontend/src/components/files/MetadataField.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { useState, useCallback, useMemo } from "react"; import { Button, FormField, TextInput, Switch, Flex, Text, Tag } from "@kui/react"; import { X, Plus } from "lucide-react"; diff --git a/frontend/src/components/files/NvidiaUpload.tsx b/frontend/src/components/files/NvidiaUpload.tsx index 856a6cbea..07f5bb3e8 100644 --- a/frontend/src/components/files/NvidiaUpload.tsx +++ b/frontend/src/components/files/NvidiaUpload.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { useEffect, useRef } from 'react'; import { FileUploadZone } from './FileUploadZone'; import { FileList } from './FileList'; diff --git a/frontend/src/components/files/__tests__/FileCard.test.tsx b/frontend/src/components/files/__tests__/FileCard.test.tsx index 6783b7e27..a0a78c25b 100644 --- a/frontend/src/components/files/__tests__/FileCard.test.tsx +++ b/frontend/src/components/files/__tests__/FileCard.test.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent } from '../../../test/utils'; import { FileCard } from '../FileCard'; diff --git a/frontend/src/components/files/__tests__/FileInput.test.tsx b/frontend/src/components/files/__tests__/FileInput.test.tsx index 77f8a599c..cad81f464 100644 --- a/frontend/src/components/files/__tests__/FileInput.test.tsx +++ b/frontend/src/components/files/__tests__/FileInput.test.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, fireEvent } from '../../../test/utils'; import { FileInput } from '../FileInput'; diff --git a/frontend/src/components/files/__tests__/FileItem.test.tsx b/frontend/src/components/files/__tests__/FileItem.test.tsx index cc9859e63..8bb476207 100644 --- a/frontend/src/components/files/__tests__/FileItem.test.tsx +++ b/frontend/src/components/files/__tests__/FileItem.test.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, vi } from 'vitest'; import { render, screen, fireEvent } from '../../../test/utils'; import { FileItem } from '../FileItem'; diff --git a/frontend/src/components/files/__tests__/FileList.test.tsx b/frontend/src/components/files/__tests__/FileList.test.tsx index 0524df72b..71f2a5ea7 100644 --- a/frontend/src/components/files/__tests__/FileList.test.tsx +++ b/frontend/src/components/files/__tests__/FileList.test.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, vi } from 'vitest'; import { render, screen, fireEvent } from '../../../test/utils'; import { FileList } from '../FileList'; diff --git a/frontend/src/components/files/__tests__/FileMetadataForm.test.tsx b/frontend/src/components/files/__tests__/FileMetadataForm.test.tsx index d9ad6216d..b77713439 100644 --- a/frontend/src/components/files/__tests__/FileMetadataForm.test.tsx +++ b/frontend/src/components/files/__tests__/FileMetadataForm.test.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen } from '../../../test/utils'; import { FileMetadataForm } from '../FileMetadataForm'; diff --git a/frontend/src/components/files/__tests__/FileUploadZone.test.tsx b/frontend/src/components/files/__tests__/FileUploadZone.test.tsx index 95da3ec87..0637b09c5 100644 --- a/frontend/src/components/files/__tests__/FileUploadZone.test.tsx +++ b/frontend/src/components/files/__tests__/FileUploadZone.test.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent } from '../../../test/utils'; import { FileUploadZone } from '../FileUploadZone'; diff --git a/frontend/src/components/files/__tests__/FileUploaderWithMetadata.test.tsx b/frontend/src/components/files/__tests__/FileUploaderWithMetadata.test.tsx index e8e1076df..c1eb01906 100644 --- a/frontend/src/components/files/__tests__/FileUploaderWithMetadata.test.tsx +++ b/frontend/src/components/files/__tests__/FileUploaderWithMetadata.test.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, vi } from 'vitest'; import { render, screen } from '../../../test/utils'; import FileUploaderWithMetadata from '../FileUploaderWithMetadata'; diff --git a/frontend/src/components/files/__tests__/FilesList.test.tsx b/frontend/src/components/files/__tests__/FilesList.test.tsx index a0725ae05..0e04c96cd 100644 --- a/frontend/src/components/files/__tests__/FilesList.test.tsx +++ b/frontend/src/components/files/__tests__/FilesList.test.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen } from '../../../test/utils'; import { FilesList } from '../FilesList'; diff --git a/frontend/src/components/files/__tests__/MetadataField.test.tsx b/frontend/src/components/files/__tests__/MetadataField.test.tsx index 28699b3bd..56d7b2508 100644 --- a/frontend/src/components/files/__tests__/MetadataField.test.tsx +++ b/frontend/src/components/files/__tests__/MetadataField.test.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent } from '../../../test/utils'; import { MetadataField } from '../MetadataField'; diff --git a/frontend/src/components/files/__tests__/NvidiaUpload.test.tsx b/frontend/src/components/files/__tests__/NvidiaUpload.test.tsx index 8bae8e268..a0a5439eb 100644 --- a/frontend/src/components/files/__tests__/NvidiaUpload.test.tsx +++ b/frontend/src/components/files/__tests__/NvidiaUpload.test.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen } from '../../../test/utils'; import NvidiaUpload from '../NvidiaUpload'; diff --git a/frontend/src/components/filtering/FilterGenerationToggle.tsx b/frontend/src/components/filtering/FilterGenerationToggle.tsx index fe3f0b52f..c1cb5a529 100644 --- a/frontend/src/components/filtering/FilterGenerationToggle.tsx +++ b/frontend/src/components/filtering/FilterGenerationToggle.tsx @@ -37,7 +37,7 @@ export const FilterGenerationToggle: React.FC = ({ const [tempConfig, setTempConfig] = useState( config || { enable_filter_generator: enabled, - model_name: "nvidia/llama-3.3-nemotron-super-49b-v1", + model_name: "nvidia/nemotron-3-super-120b-a12b", temperature: 0.1, top_p: 0.9, max_tokens: 500 @@ -139,7 +139,7 @@ export const FilterGenerationToggle: React.FC = ({ handleConfigChange("model_name", value)} - placeholder="nvidia/llama-3.3-nemotron-super-49b-v1" + placeholder="nvidia/nemotron-3-super-120b-a12b" /> diff --git a/frontend/src/components/layout/__tests__/Header.test.tsx b/frontend/src/components/layout/__tests__/Header.test.tsx index cdc76ee8f..12eab5a13 100644 --- a/frontend/src/components/layout/__tests__/Header.test.tsx +++ b/frontend/src/components/layout/__tests__/Header.test.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent } from '../../../test/utils'; import Header from '../Header'; diff --git a/frontend/src/components/layout/__tests__/Layout.test.tsx b/frontend/src/components/layout/__tests__/Layout.test.tsx index e66590614..b273d32bb 100644 --- a/frontend/src/components/layout/__tests__/Layout.test.tsx +++ b/frontend/src/components/layout/__tests__/Layout.test.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, vi } from 'vitest'; import { render, screen } from '../../../test/utils'; import Layout from '../Layout'; diff --git a/frontend/src/components/layout/__tests__/StatusMessages.test.tsx b/frontend/src/components/layout/__tests__/StatusMessages.test.tsx index 06f19ff5a..5b2685879 100644 --- a/frontend/src/components/layout/__tests__/StatusMessages.test.tsx +++ b/frontend/src/components/layout/__tests__/StatusMessages.test.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen } from '../../../test/utils'; import StatusMessages from '../StatusMessages'; diff --git a/frontend/src/components/modals/__tests__/FeatureWarningModal.test.tsx b/frontend/src/components/modals/__tests__/FeatureWarningModal.test.tsx index aab9a5b48..198d0a8f0 100644 --- a/frontend/src/components/modals/__tests__/FeatureWarningModal.test.tsx +++ b/frontend/src/components/modals/__tests__/FeatureWarningModal.test.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent } from '../../../test/utils'; import { FeatureWarningModal } from '../FeatureWarningModal'; diff --git a/frontend/src/components/modals/__tests__/SettingsModal.test.tsx b/frontend/src/components/modals/__tests__/SettingsModal.test.tsx index 9bdfd5ee0..b8cd32257 100644 --- a/frontend/src/components/modals/__tests__/SettingsModal.test.tsx +++ b/frontend/src/components/modals/__tests__/SettingsModal.test.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, vi } from 'vitest'; import { render, screen } from '../../../test/utils'; diff --git a/frontend/src/components/notifications/__tests__/NotificationBadge.test.tsx b/frontend/src/components/notifications/__tests__/NotificationBadge.test.tsx index c319b01b1..e3303917d 100644 --- a/frontend/src/components/notifications/__tests__/NotificationBadge.test.tsx +++ b/frontend/src/components/notifications/__tests__/NotificationBadge.test.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { describe, it, expect } from 'vitest'; import { render, screen } from '../../../test/utils'; import { NotificationBadge } from '../NotificationBadge'; diff --git a/frontend/src/components/notifications/__tests__/NotificationBell.test.tsx b/frontend/src/components/notifications/__tests__/NotificationBell.test.tsx index 1d3cfa876..79bae4f1a 100644 --- a/frontend/src/components/notifications/__tests__/NotificationBell.test.tsx +++ b/frontend/src/components/notifications/__tests__/NotificationBell.test.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { render, screen } from '@testing-library/react'; import { describe, it, expect, vi } from 'vitest'; import NotificationBell from '../NotificationBell'; diff --git a/frontend/src/components/notifications/__tests__/NotificationDropdown.test.tsx b/frontend/src/components/notifications/__tests__/NotificationDropdown.test.tsx index 3f362c4ab..574c22f70 100644 --- a/frontend/src/components/notifications/__tests__/NotificationDropdown.test.tsx +++ b/frontend/src/components/notifications/__tests__/NotificationDropdown.test.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent } from '../../../test/utils'; import { NotificationDropdown } from '../NotificationDropdown'; diff --git a/frontend/src/components/notifications/__tests__/TaskPoller.test.tsx b/frontend/src/components/notifications/__tests__/TaskPoller.test.tsx index aa2a8095b..a86af400a 100644 --- a/frontend/src/components/notifications/__tests__/TaskPoller.test.tsx +++ b/frontend/src/components/notifications/__tests__/TaskPoller.test.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, vi } from 'vitest'; import { render } from '../../../test/utils'; import { TaskPoller } from '../TaskPoller'; diff --git a/frontend/src/components/schema/FieldDisplayCard.tsx b/frontend/src/components/schema/FieldDisplayCard.tsx index c63674e0c..365be9b08 100644 --- a/frontend/src/components/schema/FieldDisplayCard.tsx +++ b/frontend/src/components/schema/FieldDisplayCard.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { useCallback } from "react"; import type { UIMetadataField, MetadataFieldType } from "../../types/collections"; import { diff --git a/frontend/src/components/schema/__tests__/FieldDisplayCard.test.tsx b/frontend/src/components/schema/__tests__/FieldDisplayCard.test.tsx index b204b7eee..759258979 100644 --- a/frontend/src/components/schema/__tests__/FieldDisplayCard.test.tsx +++ b/frontend/src/components/schema/__tests__/FieldDisplayCard.test.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, vi } from 'vitest'; import { render, screen, fireEvent } from '../../../test/utils'; import { FieldDisplayCard } from '../FieldDisplayCard'; diff --git a/frontend/src/components/schema/__tests__/FieldEditForm.test.tsx b/frontend/src/components/schema/__tests__/FieldEditForm.test.tsx index 61fea9e7c..fde4c3842 100644 --- a/frontend/src/components/schema/__tests__/FieldEditForm.test.tsx +++ b/frontend/src/components/schema/__tests__/FieldEditForm.test.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent } from '../../../test/utils'; import { FieldEditForm } from '../FieldEditForm'; diff --git a/frontend/src/components/schema/__tests__/FieldsList.test.tsx b/frontend/src/components/schema/__tests__/FieldsList.test.tsx index 0b1672e94..c159be22f 100644 --- a/frontend/src/components/schema/__tests__/FieldsList.test.tsx +++ b/frontend/src/components/schema/__tests__/FieldsList.test.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, vi } from 'vitest'; import { render } from '../../../test/utils'; import { FieldsList } from '../FieldsList'; diff --git a/frontend/src/components/schema/__tests__/MetadataSchemaEditor.test.tsx b/frontend/src/components/schema/__tests__/MetadataSchemaEditor.test.tsx index c90aad281..fda38a597 100644 --- a/frontend/src/components/schema/__tests__/MetadataSchemaEditor.test.tsx +++ b/frontend/src/components/schema/__tests__/MetadataSchemaEditor.test.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen } from '../../../test/utils'; import MetadataSchemaEditor from '../MetadataSchemaEditor'; diff --git a/frontend/src/components/schema/__tests__/NewFieldForm.test.tsx b/frontend/src/components/schema/__tests__/NewFieldForm.test.tsx index ccbf29fb8..2cf0369af 100644 --- a/frontend/src/components/schema/__tests__/NewFieldForm.test.tsx +++ b/frontend/src/components/schema/__tests__/NewFieldForm.test.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, vi } from 'vitest'; import { render, screen } from '../../../test/utils'; import { NewFieldForm } from '../NewFieldForm'; diff --git a/frontend/src/components/settings/__tests__/AdvancedSection.test.tsx b/frontend/src/components/settings/__tests__/AdvancedSection.test.tsx index 14c438691..cf4b1ee0a 100644 --- a/frontend/src/components/settings/__tests__/AdvancedSection.test.tsx +++ b/frontend/src/components/settings/__tests__/AdvancedSection.test.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent } from '../../../test/utils'; import userEvent from '@testing-library/user-event'; diff --git a/frontend/src/components/settings/__tests__/EndpointsSection.test.tsx b/frontend/src/components/settings/__tests__/EndpointsSection.test.tsx index 6dbc8a5ea..94dda69a0 100644 --- a/frontend/src/components/settings/__tests__/EndpointsSection.test.tsx +++ b/frontend/src/components/settings/__tests__/EndpointsSection.test.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent } from '../../../test/utils'; import { EndpointsSection } from '../EndpointsSection'; diff --git a/frontend/src/components/settings/__tests__/FeatureTogglesSection.test.tsx b/frontend/src/components/settings/__tests__/FeatureTogglesSection.test.tsx index 98b31edf4..490a8cfce 100644 --- a/frontend/src/components/settings/__tests__/FeatureTogglesSection.test.tsx +++ b/frontend/src/components/settings/__tests__/FeatureTogglesSection.test.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent } from '../../../test/utils'; import { FeatureTogglesSection } from '../FeatureTogglesSection'; diff --git a/frontend/src/components/settings/__tests__/ModelsSection.test.tsx b/frontend/src/components/settings/__tests__/ModelsSection.test.tsx index b8e73acf4..dd1e22f6b 100644 --- a/frontend/src/components/settings/__tests__/ModelsSection.test.tsx +++ b/frontend/src/components/settings/__tests__/ModelsSection.test.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent } from '../../../test/utils'; import { ModelsSection } from '../ModelsSection'; diff --git a/frontend/src/components/settings/__tests__/RagConfigSection.test.tsx b/frontend/src/components/settings/__tests__/RagConfigSection.test.tsx index d0b64284c..894ddfd29 100644 --- a/frontend/src/components/settings/__tests__/RagConfigSection.test.tsx +++ b/frontend/src/components/settings/__tests__/RagConfigSection.test.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen } from '../../../test/utils'; import { RagConfigSection } from '../RagConfigSection'; diff --git a/frontend/src/components/settings/__tests__/SettingsHeader.test.tsx b/frontend/src/components/settings/__tests__/SettingsHeader.test.tsx index 5ea942c06..70ee3820d 100644 --- a/frontend/src/components/settings/__tests__/SettingsHeader.test.tsx +++ b/frontend/src/components/settings/__tests__/SettingsHeader.test.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { describe, it, expect } from 'vitest'; import { render, screen } from '../../../test/utils'; import { SettingsHeader } from '../SettingsHeader'; diff --git a/frontend/src/components/settings/__tests__/SettingsSection.test.tsx b/frontend/src/components/settings/__tests__/SettingsSection.test.tsx index 2bc43d71a..336ecf543 100644 --- a/frontend/src/components/settings/__tests__/SettingsSection.test.tsx +++ b/frontend/src/components/settings/__tests__/SettingsSection.test.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, vi } from 'vitest'; import { render, screen, fireEvent } from '../../../test/utils'; import { SettingsSection, AdvancedSettingsSection } from '../SettingsSection'; diff --git a/frontend/src/components/tasks/__tests__/DocumentItem.test.tsx b/frontend/src/components/tasks/__tests__/DocumentItem.test.tsx index 8ea89ba25..52b6804b5 100644 --- a/frontend/src/components/tasks/__tests__/DocumentItem.test.tsx +++ b/frontend/src/components/tasks/__tests__/DocumentItem.test.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, waitFor } from '../../../test/utils'; import userEvent from '@testing-library/user-event'; diff --git a/frontend/src/components/tasks/__tests__/DocumentsList.test.tsx b/frontend/src/components/tasks/__tests__/DocumentsList.test.tsx index 00d18100b..51b74e6f5 100644 --- a/frontend/src/components/tasks/__tests__/DocumentsList.test.tsx +++ b/frontend/src/components/tasks/__tests__/DocumentsList.test.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen } from '../../../test/utils'; import { DocumentsList } from '../DocumentsList'; diff --git a/frontend/src/components/tasks/__tests__/TaskDisplay.test.tsx b/frontend/src/components/tasks/__tests__/TaskDisplay.test.tsx index 64ca09da9..3bbe4e945 100644 --- a/frontend/src/components/tasks/__tests__/TaskDisplay.test.tsx +++ b/frontend/src/components/tasks/__tests__/TaskDisplay.test.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, vi } from 'vitest'; import { render, screen, fireEvent } from '../../../test/utils'; import { TaskDisplay } from '../TaskDisplay'; diff --git a/frontend/src/components/tasks/__tests__/TaskStatusIcons.test.tsx b/frontend/src/components/tasks/__tests__/TaskStatusIcons.test.tsx index acb3b26b6..827a89765 100644 --- a/frontend/src/components/tasks/__tests__/TaskStatusIcons.test.tsx +++ b/frontend/src/components/tasks/__tests__/TaskStatusIcons.test.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, vi } from 'vitest'; import { render, screen } from '../../../test/utils'; import { TaskStatusIcon } from '../TaskStatusIcons'; diff --git a/frontend/src/components/ui/__tests__/ErrorState.test.tsx b/frontend/src/components/ui/__tests__/ErrorState.test.tsx index 997856aa8..95096bf37 100644 --- a/frontend/src/components/ui/__tests__/ErrorState.test.tsx +++ b/frontend/src/components/ui/__tests__/ErrorState.test.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, vi } from 'vitest'; import { render, screen, fireEvent } from '../../../test/utils'; import { ErrorState } from '../ErrorState'; diff --git a/frontend/src/components/ui/__tests__/LoadingState.test.tsx b/frontend/src/components/ui/__tests__/LoadingState.test.tsx index 1010a1d36..3fddeb9d0 100644 --- a/frontend/src/components/ui/__tests__/LoadingState.test.tsx +++ b/frontend/src/components/ui/__tests__/LoadingState.test.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { describe, it, expect } from 'vitest'; import { render, screen } from '../../../test/utils'; import { LoadingState } from '../LoadingState'; diff --git a/frontend/src/hooks/__tests__/useChatStream.test.ts b/frontend/src/hooks/__tests__/useChatStream.test.ts new file mode 100644 index 000000000..3bd1622d4 --- /dev/null +++ b/frontend/src/hooks/__tests__/useChatStream.test.ts @@ -0,0 +1,451 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// 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. + +import { describe, it, expect, vi } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useChatStream } from '../useChatStream'; +import type { ChatMessage } from '../../types/chat'; + +/** + * Build a fake `Response` with a body that yields the supplied SSE chunks. + * Each chunk is JSON-stringified and prefixed with `data: ` (server contract). + */ +const buildResponse = (chunks: object[], status = 200): Response => { + const lines = chunks.map((c) => `data: ${JSON.stringify(c)}\n`).join(''); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(lines)); + controller.close(); + }, + }); + return new Response(stream, { status }); +}; + +/** + * Drain processStream against a fake stream, accumulating every + * updateMessage call so tests can inspect the final assistant payload. + */ +const drain = async (chunks: object[], status = 200) => { + const { result } = renderHook(() => useChatStream()); + const updates: Array> = []; + const updateMessage = vi.fn( + (_id: string, update: Partial) => { + updates.push({ ...update }); + } + ); + await act(async () => { + result.current.startStream(); + await result.current.processStream( + buildResponse(chunks, status), + 'assistant-1', + updateMessage + ); + }); + return { updates, last: updates[updates.length - 1] }; +}; + +describe('useChatStream — legacy non-agentic path (regression)', () => { + it('concatenates delta.content when chunks have no event_type', async () => { + const { last } = await drain([ + { choices: [{ delta: { content: 'Hello, ' } }] }, + { choices: [{ delta: { content: 'world.' } }] }, + { choices: [{ delta: {}, finish_reason: 'stop' }] }, + ]); + expect(last.content).toBe('Hello, world.'); + expect(last.reasoning_steps).toBeUndefined(); + }); + + // Regression: the live BE actually emits `event_type: null` (not omitted) + // for every standard (non-agentic) chunk. A `??` chain in the parser + // previously left `eventType` as `null` instead of `undefined`, which + // routed the chunk into the forward-compat branch and silently dropped + // `delta.content`. The bubble showed sources but no answer. + it('treats explicit event_type: null as legacy, not as an unknown event', async () => { + const { last } = await drain([ + { + choices: [{ delta: { content: 'Hello, ' } }], + event_type: null, + stage: null, + }, + { + choices: [{ delta: { content: 'world.' } }], + event_type: null, + stage: null, + }, + { + choices: [{ delta: {}, finish_reason: 'stop' }], + event_type: null, + stage: null, + }, + ]); + expect(last.content).toBe('Hello, world.'); + expect(last.reasoning_steps).toBeUndefined(); + }); + + it('captures standard RAG reasoning_content in the reasoning panel model', async () => { + const { last } = await drain([ + { + choices: [ + { + delta: { + content: '', + reasoning_content: 'I need to inspect the retrieved context. ', + }, + }, + ], + event_type: null, + stage: null, + }, + { + choices: [ + { + delta: { + content: 'The answer is ', + reasoning_content: 'The context supports a direct answer.', + }, + }, + ], + event_type: null, + stage: null, + }, + { + choices: [{ delta: { content: '42.' } }], + event_type: null, + stage: null, + }, + { choices: [{ delta: {}, finish_reason: 'stop' }] }, + ]); + + expect(last.content).toBe('The answer is 42.'); + expect(last.reasoning_steps).toHaveLength(1); + expect(last.reasoning_steps?.[0]).toMatchObject({ + stage: 'rag', + reasoning: + 'I need to inspect the retrieved context. The context supports a direct answer.', + status: 'done', + }); + }); + + it('extracts citations from sources/results without event_type', async () => { + const { last } = await drain([ + { + choices: [{ delta: { content: 'Answer' } }], + citations: { + results: [ + { + content: 'snippet', + document_name: 'a.pdf', + document_type: 'text', + score: 0.9, + stage: 'rag', + }, + ], + }, + }, + { choices: [{ delta: {}, finish_reason: 'stop' }] }, + ]); + expect(last.citations).toHaveLength(1); + expect(last.citations?.[0]).toMatchObject({ + source: 'a.pdf', + stage: 'rag', + }); + }); +}); + +describe('useChatStream — agentic streaming path (PR #512)', () => { + it('routes final_answer chunks to content and other events to reasoning steps', async () => { + const { last } = await drain([ + { + choices: [ + { + delta: { + event_type: 'stage_start', + stage: 'plan', + reasoning_content: 'Planning the retrieval strategy…', + }, + }, + ], + }, + { + choices: [ + { + delta: { + event_type: 'intermediate_reasoning', + stage: 'plan', + reasoning_content: 'Step 1: scope discovery. ', + }, + }, + ], + }, + { + choices: [ + { + delta: { + event_type: 'intermediate_reasoning', + stage: 'plan', + reasoning_content: 'Step 2: focused retrieval.', + }, + }, + ], + }, + { + choices: [ + { + delta: { + event_type: 'stage_end', + stage: 'plan', + reasoning_content: 'Plan ready.', + }, + }, + ], + }, + { + choices: [ + { + delta: { + event_type: 'stage_start', + stage: 'synthesize', + reasoning_content: 'Composing the final answer…', + }, + }, + ], + }, + { + choices: [ + { + delta: { + event_type: 'final_answer', + stage: 'synthesize', + content: 'The lion ', + }, + }, + ], + }, + { + choices: [ + { + delta: { + event_type: 'final_answer', + stage: 'synthesize', + content: 'is the king.', + }, + }, + ], + }, + { + choices: [ + { + delta: { + event_type: 'stage_end', + stage: 'synthesize', + reasoning_content: 'Done.', + }, + }, + ], + }, + { choices: [{ delta: {}, finish_reason: 'stop' }] }, + ]); + + expect(last.content).toBe('The lion is the king.'); + expect(last.reasoning_steps).toHaveLength(2); + const [planStep, synthStep] = last.reasoning_steps!; + expect(planStep).toMatchObject({ + stage: 'plan', + label: 'Planning the retrieval strategy…', + summary: 'Plan ready.', + status: 'done', + }); + expect(planStep.reasoning).toBe( + 'Step 1: scope discovery. Step 2: focused retrieval.' + ); + expect(synthStep.stage).toBe('synthesize'); + expect(synthStep.status).toBe('done'); + }); + + it('attaches citations + metrics when they arrive on the first final_answer chunk', async () => { + const { last } = await drain([ + { + choices: [ + { + delta: { + event_type: 'final_answer', + stage: 'synthesize', + content: 'ok', + }, + }, + ], + citations: { + results: [ + { + content: 'snippet', + document_name: 'doc.pdf', + document_type: 'text', + score: 0.7, + stage: 'verify_execute', + }, + ], + }, + metrics: { + rag_ttft_ms: 120, + llm_ttft_ms: 80, + llm_generation_time_ms: 1500, + }, + }, + { choices: [{ delta: {}, finish_reason: 'stop' }] }, + ]); + + expect(last.citations?.[0].stage).toBe('verify_execute'); + expect(last.metrics).toEqual({ + rag_ttft_ms: 120, + llm_ttft_ms: 80, + llm_generation_time_ms: 1500, + }); + }); + + it('lazy-creates a step when reasoning_content arrives without stage_start', async () => { + const { last } = await drain([ + { + choices: [ + { + delta: { + event_type: 'intermediate_reasoning', + stage: 'execute', + reasoning_content: 'orphan token', + }, + }, + ], + }, + { choices: [{ delta: {}, finish_reason: 'stop' }] }, + ]); + expect(last.reasoning_steps).toHaveLength(1); + expect(last.reasoning_steps?.[0]).toMatchObject({ + stage: 'execute', + reasoning: 'orphan token', + status: 'done', + }); + }); + + it('routes intermediate_output to step.output, distinct from reasoning', async () => { + const { last } = await drain([ + { + choices: [ + { + delta: { + event_type: 'stage_start', + stage: 'execute', + reasoning_content: 'Running tasks…', + }, + }, + ], + }, + { + choices: [ + { + delta: { + event_type: 'intermediate_output', + stage: 'execute', + reasoning_content: '{"tasks":[{"id":1}]}', + }, + }, + ], + }, + { + choices: [ + { + delta: { + event_type: 'stage_end', + stage: 'execute', + reasoning_content: 'Found 4 results.', + }, + }, + ], + }, + { choices: [{ delta: {}, finish_reason: 'stop' }] }, + ]); + expect(last.reasoning_steps?.[0].output).toBe('{"tasks":[{"id":1}]}'); + expect(last.reasoning_steps?.[0].reasoning).toBe(''); + }); + + it('marks the message as errored on event_type=error and stops accumulating answer', async () => { + const { last } = await drain([ + { + choices: [ + { + delta: { + event_type: 'stage_start', + stage: 'plan', + reasoning_content: 'Planning…', + }, + }, + ], + }, + { + choices: [ + { + delta: { + event_type: 'error', + stage: 'plan', + content: 'pipeline failed', + reasoning_content: 'detail: timeout', + }, + }, + ], + }, + { choices: [{ delta: {}, finish_reason: 'stop' }] }, + ]); + expect(last.is_error).toBe(true); + expect(last.content).toBe('pipeline failed'); + const step = last.reasoning_steps?.[0]; + expect(step?.status).toBe('error'); + expect(step?.output).toContain('timeout'); + }); + + it('forward-compat: agent_event / unknown event types preserve reasoning_content', async () => { + const { last } = await drain([ + { + choices: [ + { + delta: { + event_type: 'agent_event', + stage: 'execute', + reasoning_content: 'mid-stage update', + }, + }, + ], + }, + { choices: [{ delta: {}, finish_reason: 'stop' }] }, + ]); + expect(last.reasoning_steps?.[0].reasoning).toBe('mid-stage update'); + }); + + it('closes any still-running step when the trailing finish_reason chunk arrives', async () => { + const { last } = await drain([ + { + choices: [ + { + delta: { + event_type: 'stage_start', + stage: 'execute', + reasoning_content: 'Running tasks…', + }, + }, + ], + }, + // No matching stage_end — server may drop one if pipeline interrupts. + { choices: [{ delta: {}, finish_reason: 'stop' }] }, + ]); + expect(last.reasoning_steps?.[0].status).toBe('done'); + }); +}); diff --git a/frontend/src/hooks/__tests__/useCitationUtils.test.ts b/frontend/src/hooks/__tests__/useCitationUtils.test.ts new file mode 100644 index 000000000..aaaa3219e --- /dev/null +++ b/frontend/src/hooks/__tests__/useCitationUtils.test.ts @@ -0,0 +1,77 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// 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. + +import { describe, it, expect } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { useCitationUtils } from '../useCitationUtils'; + +describe('useCitationUtils', () => { + describe('formatStage', () => { + it('returns empty string for undefined', () => { + const { result } = renderHook(() => useCitationUtils()); + expect(result.current.formatStage(undefined)).toBe(''); + }); + + it('returns empty string for an empty / whitespace-only stage', () => { + const { result } = renderHook(() => useCitationUtils()); + expect(result.current.formatStage('')).toBe(''); + expect(result.current.formatStage(' ')).toBe(''); + }); + + it('humanises snake_case stage identifiers', () => { + const { result } = renderHook(() => useCitationUtils()); + expect(result.current.formatStage('initial_retrieval')).toBe('Initial retrieval'); + expect(result.current.formatStage('verify_execute')).toBe('Verify execute'); + }); + + it('humanises kebab-case stage identifiers', () => { + const { result } = renderHook(() => useCitationUtils()); + expect(result.current.formatStage('post-execute-verify')).toBe('Post execute verify'); + }); + + it('preserves a single-word stage but capitalises it', () => { + const { result } = renderHook(() => useCitationUtils()); + expect(result.current.formatStage('rag')).toBe('Rag'); + expect(result.current.formatStage('execute')).toBe('Execute'); + }); + + it('handles future / unknown stage values without code changes', () => { + const { result } = renderHook(() => useCitationUtils()); + // Whatever new stage the server adds, formatStage must not throw and + // must return a non-empty humanised string. + const future = 'plan_then_self_critique_v2'; + expect(result.current.formatStage(future)).toBe('Plan then self critique v2'); + }); + }); + + describe('formatScore (regression)', () => { + it('returns N/A for undefined and a fixed-precision string for numbers', () => { + const { result } = renderHook(() => useCitationUtils()); + expect(result.current.formatScore(undefined)).toBe('N/A'); + expect(result.current.formatScore(0.876543)).toBe('0.88'); + expect(result.current.formatScore(0.876543, 3)).toBe('0.877'); + }); + }); + + describe('isVisualType (regression)', () => { + it('classifies image / chart / table as visual', () => { + const { result } = renderHook(() => useCitationUtils()); + expect(result.current.isVisualType('image')).toBe(true); + expect(result.current.isVisualType('chart')).toBe(true); + expect(result.current.isVisualType('table')).toBe(true); + expect(result.current.isVisualType('text')).toBe(false); + }); + }); +}); diff --git a/frontend/src/hooks/__tests__/useFileValidation.test.ts b/frontend/src/hooks/__tests__/useFileValidation.test.ts index de3243c5b..79c9786d6 100644 --- a/frontend/src/hooks/__tests__/useFileValidation.test.ts +++ b/frontend/src/hooks/__tests__/useFileValidation.test.ts @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { describe, it, expect } from 'vitest'; import { renderHook } from '@testing-library/react'; import { useFileValidation } from '../useFileValidation'; diff --git a/frontend/src/hooks/__tests__/useMessageSubmit.test.ts b/frontend/src/hooks/__tests__/useMessageSubmit.test.ts new file mode 100644 index 000000000..4ab06a147 --- /dev/null +++ b/frontend/src/hooks/__tests__/useMessageSubmit.test.ts @@ -0,0 +1,123 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// 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. + +import { describe, it, expect } from 'vitest'; +import { cleanRequestObject } from '../useMessageSubmit'; +import type { GenerateRequest } from '../../types/requests'; + +describe('cleanRequestObject', () => { + // Minimum payload to satisfy the GenerateRequest contract. + const baseRequest: Partial = { + messages: [{ role: 'user', content: 'hello' }], + use_knowledge_base: false, + }; + + it('omits undefined / null / empty values', () => { + const cleaned = cleanRequestObject({ + ...baseRequest, + temperature: undefined, + filter_expr: '', + stop: [], + // null is allowed by the type for some fields and must be dropped + agentic: null, + }); + expect(cleaned).not.toHaveProperty('temperature'); + expect(cleaned).not.toHaveProperty('filter_expr'); + expect(cleaned).not.toHaveProperty('stop'); + expect(cleaned).not.toHaveProperty('agentic'); + }); + + it('preserves explicit `agentic: true` (force agentic)', () => { + const cleaned = cleanRequestObject({ + ...baseRequest, + agentic: true, + }); + expect(cleaned.agentic).toBe(true); + }); + + it('preserves explicit `agentic: false` (force standard) — must NOT be dropped', () => { + // This is the safety-critical guarantee: if we dropped `false`, the + // server's CONFIG.enable_agentic_rag would silently take over and the + // user's "Standard" choice would be ignored. + const cleaned = cleanRequestObject({ + ...baseRequest, + agentic: false, + }); + expect(cleaned).toHaveProperty('agentic', false); + }); + + it('omits `agentic` when undefined (defensive — FE always sends true/false now)', () => { + // Since the "auto" mode was removed (see AgenticMode), the FE never + // hands `cleanRequestObject` an `agentic: undefined`. This regression + // guard keeps the cleaner honest if a caller ever passes one anyway. + const cleaned = cleanRequestObject({ + ...baseRequest, + agentic: undefined, + }); + expect(cleaned).not.toHaveProperty('agentic'); + }); + + it('preserves other always-include feature toggles set to false', () => { + // Regression guard for the existing alwaysInclude list. + const cleaned = cleanRequestObject({ + ...baseRequest, + enable_reranker: false, + enable_citations: false, + enable_query_rewriting: false, + enable_guardrails: false, + enable_vlm_inference: false, + enable_filter_generator: false, + }); + expect(cleaned.enable_reranker).toBe(false); + expect(cleaned.enable_citations).toBe(false); + expect(cleaned.enable_query_rewriting).toBe(false); + expect(cleaned.enable_guardrails).toBe(false); + expect(cleaned.enable_vlm_inference).toBe(false); + expect(cleaned.enable_filter_generator).toBe(false); + }); + + it('preserves always-include `use_knowledge_base: false`', () => { + const cleaned = cleanRequestObject({ + ...baseRequest, + use_knowledge_base: false, + }); + expect(cleaned.use_knowledge_base).toBe(false); + }); + + it('preserves numeric and string values', () => { + const cleaned = cleanRequestObject({ + ...baseRequest, + temperature: 0.7, + max_tokens: 1024, + model: 'meta/llama-3.3-70b', + collection_names: ['docs'], + }); + expect(cleaned.temperature).toBe(0.7); + expect(cleaned.max_tokens).toBe(1024); + expect(cleaned.model).toBe('meta/llama-3.3-70b'); + expect(cleaned.collection_names).toEqual(['docs']); + }); + + it('drops booleans set to false that are NOT in the always-include list', () => { + // `random_unknown_toggle` isn't a real GenerateRequest field — we cast + // through unknown to verify the cleaner's behavior on arbitrary keys + // without weakening GenerateRequest's type. + const cleaned = cleanRequestObject({ + ...baseRequest, + ...({ random_unknown_toggle: false } as unknown as Partial), + }); + expect(cleaned).not.toHaveProperty('random_unknown_toggle'); + }); +}); diff --git a/frontend/src/hooks/__tests__/useUploadDocuments.test.ts b/frontend/src/hooks/__tests__/useUploadDocuments.test.ts index 0a6328574..db2fe095a 100644 --- a/frontend/src/hooks/__tests__/useUploadDocuments.test.ts +++ b/frontend/src/hooks/__tests__/useUploadDocuments.test.ts @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, vi, beforeEach } from 'vitest'; import { renderHook, act } from '@testing-library/react'; import { useUploadDocuments } from '../useUploadDocuments'; diff --git a/frontend/src/hooks/useChatStream.ts b/frontend/src/hooks/useChatStream.ts index 42be3e175..f1132f708 100644 --- a/frontend/src/hooks/useChatStream.ts +++ b/frontend/src/hooks/useChatStream.ts @@ -14,7 +14,12 @@ // limitations under the License. import { useState, useRef } from "react"; -import type { ChatMessage, Citation } from "../types/chat"; +import type { + AgenticMetrics, + ChatMessage, + Citation, + ReasoningStep, +} from "../types/chat"; /** * Interface representing the state of a chat stream. @@ -26,19 +31,107 @@ export interface StreamState { isTyping: boolean; } +/** Server-emitted `event_type` discriminators (see PR #512). */ +type AgenticEventType = + | "stage_start" + | "stage_end" + | "intermediate_reasoning" + | "intermediate_output" + | "final_reasoning" + | "final_answer" + | "agent_event" + | "error"; + +const STAGE_START_EVENT: AgenticEventType = "stage_start"; +const STAGE_END_EVENT: AgenticEventType = "stage_end"; +const FINAL_ANSWER_EVENT: AgenticEventType = "final_answer"; +const ERROR_EVENT: AgenticEventType = "error"; +const OUTPUT_EVENT: AgenticEventType = "intermediate_output"; +const STANDARD_RAG_STAGE = "rag"; + +/** Lazy-create or reuse the open step for an incoming chunk's stage. */ +const ensureOpenStep = ( + steps: ReasoningStep[], + stage: string | undefined +): ReasoningStep => { + const last = steps[steps.length - 1]; + if (last && last.status === "running" && (!stage || last.stage === stage)) { + return last; + } + const next: ReasoningStep = { + stage: stage ?? "unknown", + reasoning: "", + output: "", + status: "running", + }; + steps.push(next); + return next; +}; + +const closeRunningSteps = ( + steps: ReasoningStep[], + status: "done" | "error" = "done" +) => { + for (const step of steps) { + if (step.status === "running") step.status = status; + } +}; + +const parseSources = (raw: unknown): ChatMessage["citations"] => { + if (!Array.isArray(raw)) return undefined; + const scored: ChatMessage["citations"] = raw.map( + (src: Record) => { + const score = + typeof src.score === "string" || typeof src.score === "number" + ? src.score + : typeof src.confidence_score === "string" || + typeof src.confidence_score === "number" + ? src.confidence_score + : typeof src.similarity_score === "string" || + typeof src.similarity_score === "number" + ? src.similarity_score + : undefined; + const stage = + typeof src.stage === "string" && src.stage.length > 0 + ? src.stage + : undefined; + return { + text: String(src.content || src.text || ""), + source: String( + src.document_name || src.source || src.title || "Unknown" + ), + document_type: (src.document_type as Citation["document_type"]) || "text", + score, + stage, + }; + } + ); + return scored; +}; + /** * Custom hook for managing chat streaming functionality. - * - * Provides utilities to start, process, and stop streaming chat responses. - * Handles streaming text content, citations, and error states. - * + * + * Handles two SSE contracts on `/generate`: + * + * 1. **Standard / non-agentic** (and `agentic` with `enable_streaming=false`): + * chunks have no `event_type`; `delta.content` is concatenated into the + * user-facing answer; optional `delta.reasoning_content` is captured as a + * single standard RAG reasoning step; citations attach on the final chunk. + * + * 2. **Agentic streaming** (PR #512): each chunk carries an `event_type` + * that discriminates whether the payload is final-answer text, reasoning + * trace, intermediate output, or a stage boundary. The hook builds a + * `reasoning_steps[]` trace alongside the final answer; citations and + * metrics arrive on the first `final_answer` chunk. + * * @returns An object containing stream state and control functions - * + * * @example * ```tsx * const { streamState, startStream, processStream, stopStream } = useChatStream(); * const controller = startStream(); - * await processStream(response, messageId, updateMessage, threshold); + * await processStream(response, messageId, updateMessage); * ``` */ export const useChatStream = () => { @@ -79,9 +172,20 @@ export const useChatStream = () => { let buffer = ""; let content = ""; let latestCitations: ChatMessage["citations"] = []; - - // Detect if this is an error response based on HTTP status code - const isError = response.status >= 400; + const steps: ReasoningStep[] = []; + let metrics: AgenticMetrics | undefined; + let isError = response.status >= 400; + + const emit = () => { + updateMessage(assistantId, { + content, + citations: + latestCitations && latestCitations.length ? latestCitations : undefined, + is_error: isError, + reasoning_steps: steps.length ? [...steps] : undefined, + metrics, + }); + }; try { while (true) { @@ -96,43 +200,122 @@ export const useChatStream = () => { if (!line.startsWith("data: ")) continue; const json = JSON.parse(line.slice(6)); - const delta = json.choices?.[0]?.delta?.content; - if (delta) content += delta; + const choice = json.choices?.[0]; + const delta = choice?.delta ?? {}; + // Standard (non-agentic) responses ship `event_type: null` on + // every chunk; we collapse that to `undefined` so the dispatch + // below routes them through the legacy path that concatenates + // `delta.content` into the assistant message. + const rawEventType = + (delta.event_type as AgenticEventType | null | undefined) ?? + (choice?.message?.event_type as + | AgenticEventType + | null + | undefined) ?? + (json.event_type as AgenticEventType | null | undefined); + const eventType: AgenticEventType | undefined = + rawEventType ?? undefined; + const stage: string | undefined = + (typeof delta.stage === "string" ? delta.stage : undefined) ?? + (typeof choice?.message?.stage === "string" + ? choice.message.stage + : undefined) ?? + (typeof json.stage === "string" ? json.stage : undefined); + + const reasoningContent: string | undefined = + typeof delta.reasoning_content === "string" + ? delta.reasoning_content + : undefined; + const deltaContent: string | undefined = + typeof delta.content === "string" ? delta.content : undefined; - // Extract citations from any expected location + // Branch on event_type. When event_type is absent (standard / + // non-agentic stream, or pre-#512 backend), fall through the + // legacy path: append delta.content to the answer. + if (eventType === STAGE_START_EVENT) { + closeRunningSteps(steps); + steps.push({ + stage: stage ?? "unknown", + label: reasoningContent || undefined, + reasoning: "", + output: "", + status: "running", + }); + } else if (eventType === STAGE_END_EVENT) { + const open = steps[steps.length - 1]; + if (open && open.status === "running") { + if (reasoningContent) open.summary = reasoningContent; + open.status = "done"; + } + } else if ( + eventType === "intermediate_reasoning" || + eventType === "final_reasoning" + ) { + if (reasoningContent) { + const step = ensureOpenStep(steps, stage); + step.reasoning += reasoningContent; + } + } else if (eventType === OUTPUT_EVENT) { + if (reasoningContent) { + const step = ensureOpenStep(steps, stage); + step.output += reasoningContent; + } + } else if (eventType === ERROR_EVENT) { + isError = true; + if (deltaContent) content += deltaContent; + if (reasoningContent) { + const step = ensureOpenStep(steps, stage); + step.output += reasoningContent; + step.status = "error"; + } else { + closeRunningSteps(steps, "error"); + } + } else if (eventType === FINAL_ANSWER_EVENT) { + if (deltaContent) content += deltaContent; + } else if (eventType !== undefined) { + // agent_event or future / unknown event types: + // forward-compat — capture any reasoning_content into the + // current step so we don't lose information. + if (reasoningContent) { + const step = ensureOpenStep(steps, stage); + step.reasoning += reasoningContent; + } + } else { + // Legacy non-agentic chunk: no event_type discriminator. + if (reasoningContent) { + const step = ensureOpenStep(steps, stage ?? STANDARD_RAG_STAGE); + step.reasoning += reasoningContent; + } + if (deltaContent) content += deltaContent; + } + + // Citations and metrics may arrive on any agentic chunk, but the + // server contract pins them to the first `final_answer` chunk. + // We accept whichever arrives first / most recent. const sources = json.citations?.results ?? json.sources?.results ?? - json.choices?.[0]?.message?.citations ?? - json.choices?.[0]?.message?.sources ?? + choice?.message?.citations ?? + choice?.message?.sources ?? []; + const parsed = parseSources(sources); + if (parsed && parsed.length) latestCitations = parsed; - if (Array.isArray(sources)) { - const scored: ChatMessage["citations"] = sources.map((src: Record) => { - const score = typeof src.score === 'string' || typeof src.score === 'number' ? src.score : - typeof src.confidence_score === 'string' || typeof src.confidence_score === 'number' ? src.confidence_score : - typeof src.similarity_score === 'string' || typeof src.similarity_score === 'number' ? src.similarity_score : - undefined; - return { - text: String(src.content || src.text || ""), - source: String(src.document_name || src.source || src.title || "Unknown"), - document_type: (src.document_type as Citation["document_type"]) || "text", - score, - }; - }); - - // Backend already filters by confidence threshold, so no need to filter here - latestCitations = scored; + if (json.metrics && typeof json.metrics === "object") { + metrics = { ...(metrics ?? {}), ...(json.metrics as AgenticMetrics) }; } - updateMessage(assistantId, { - content, - citations: latestCitations && latestCitations.length ? latestCitations : undefined, - is_error: isError, - }); + emit(); - if (json.choices?.[0]?.finish_reason === "stop") { - setStreamState({ content, citations: latestCitations, error: null, isTyping: false }); + if (choice?.finish_reason === "stop") { + closeRunningSteps(steps); + emit(); + setStreamState({ + content, + citations: latestCitations, + error: null, + isTyping: false, + }); return; } } diff --git a/frontend/src/hooks/useCitationUtils.ts b/frontend/src/hooks/useCitationUtils.ts index eb8905f04..acdcad9b2 100644 --- a/frontend/src/hooks/useCitationUtils.ts +++ b/frontend/src/hooks/useCitationUtils.ts @@ -35,9 +35,24 @@ export const useCitationUtils = () => { [] ); + // Convert a server-emitted stage identifier (e.g. "initial_retrieval", + // "verify_execute") into a human-readable label. Value-independent: any + // future stage name will be displayed sensibly without a code change. + const formatStage = useMemo( + () => (stage: string | undefined): string => { + if (!stage) return ""; + const cleaned = stage.trim(); + if (!cleaned) return ""; + const spaced = cleaned.replace(/[_-]+/g, " "); + return spaced.charAt(0).toUpperCase() + spaced.slice(1).toLowerCase(); + }, + [] + ); + return { isVisualType, formatScore, generateCitationId, + formatStage, }; }; \ No newline at end of file diff --git a/frontend/src/hooks/useFileUpload.ts b/frontend/src/hooks/useFileUpload.ts index a536914ee..f90c9f225 100644 --- a/frontend/src/hooks/useFileUpload.ts +++ b/frontend/src/hooks/useFileUpload.ts @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { useCallback } from "react"; import { useNewCollectionStore } from "../store/useNewCollectionStore"; diff --git a/frontend/src/hooks/useMessageSubmit.ts b/frontend/src/hooks/useMessageSubmit.ts index 522c21be8..8926779dc 100644 --- a/frontend/src/hooks/useMessageSubmit.ts +++ b/frontend/src/hooks/useMessageSubmit.ts @@ -21,16 +21,71 @@ import { useCollectionsStore } from "../store/useCollectionsStore"; import { useStreamingStore } from "../store/useStreamingStore"; import { useImageAttachmentStore } from "../store/useImageAttachmentStore"; import { useCollections } from "../api/useCollectionsApi"; +import { useHealthStatus } from "../api/useHealthApi"; import { useUUID } from "./useUUID"; import type { GenerateRequest } from "../types/requests"; import type { ChatMessage, Filter, MessageContent, TextContent, ImageContent } from "../types/chat"; import type { Collection } from "../types/collections"; +import { + buildFieldTypeMap, + compileElasticsearchFilter, + compileMilvusFilter, + vectorStoreFromHealthService, +} from "../utils/filterExpression"; + +/** + * Build the per-request `filter_expr` value in the wire format the + * configured backend expects (Milvus string vs. Elasticsearch list-of-dicts). + * + * The deployment is mono-store: we pick the backend from the first + * `/health.databases[].service` entry. If health hasn't loaded yet (or the + * label is unrecognized), we default to Milvus to preserve pre-existing + * behavior. + * + * Exported for direct unit testing of the wire-format contract. + */ +export function buildFilterExpression( + filters: Filter[], + selectedCollections: string[], + allCollections: Collection[], + healthServiceLabel: string | undefined +): GenerateRequest["filter_expr"] { + if (!filters.length) return undefined; + + const schemas = selectedCollections + .map( + (name) => + allCollections.find((c) => c.collection_name === name) + ?.metadata_schema ?? [] + ) + .map((schema) => + schema.map((field) => ({ + name: field.name, + type: field.type as string, + array_type: field.array_type ?? undefined, + })) + ); + const fieldTypes = buildFieldTypeMap(schemas); + + const backend = vectorStoreFromHealthService(healthServiceLabel) ?? "milvus"; + if (backend === "elasticsearch") { + // Pass `fieldTypes` so non-string-typed fields (integer / float / + // datetime / boolean) get the bare field path. Appending `.keyword` + // to those targets a non-existent ES sub-field and returns zero hits. + return compileElasticsearchFilter(filters, fieldTypes); + } + return compileMilvusFilter(filters, fieldTypes); +} /** * Utility function to remove undefined, null, empty string, and empty array values from a request object. * This ensures we only send meaningful parameters to the API. + * + * Exported so the wire-format guarantees (notably: `agentic: false` must be + * preserved to override the server default, while `agentic: undefined` must + * be omitted) can be unit-tested in isolation. */ -function cleanRequestObject(obj: Partial): GenerateRequest { +export function cleanRequestObject(obj: Partial): GenerateRequest { const cleaned: Partial = {}; for (const [key, value] of Object.entries(obj)) { @@ -50,7 +105,11 @@ function cleanRequestObject(obj: Partial): GenerateRequest { 'enable_query_rewriting', 'enable_guardrails', 'enable_vlm_inference', - 'enable_filter_generator' + 'enable_filter_generator', + // `agentic: false` must reach the server to force the standard + // pipeline; otherwise it would be dropped and CONFIG.enable_agentic_rag + // (which may be true) would silently take over. + 'agentic', ]; if (value === true || alwaysInclude.includes(key)) { (cleaned as Record)[key] = value; @@ -86,11 +145,25 @@ export const useMessageSubmit = () => { const { selectedCollections } = useCollectionsStore(); const { attachedImages, clearAllImages } = useImageAttachmentStore(); const { data: allCollections = [] } = useCollections(); + // The deployment is mono-store (Milvus or Elasticsearch). We pull the + // backend label from /health.databases[0].service and translate that + // into the wire format for filter_expr below. + const { data: health } = useHealthStatus(); const settings = useSettingsStore(); const { generateUUID } = useUUID(); const { shouldDisableHealthFeatures, isHealthLoading } = useHealthDependentFeatures(); const createRequest = useCallback((currentMessages: ChatMessage[]) => { + // Map the per-request agentic mode to the wire format: + // "on" -> true (force agentic LangGraph pipeline) + // "off" -> false (force standard RAG pipeline) + // The previous "auto" mode (omit the field, let the server decide) was + // dropped per the #514 review — the FE now always pins the choice. + // `agentic: false` must still survive `cleanRequestObject` (see the + // `alwaysInclude` list there) so the user's "Standard" choice isn't + // silently overridden by the server's `CONFIG.enable_agentic_rag`. + const agentic = settings.agenticMode === "on"; + const rawRequest = { messages: currentMessages.map(({ role, content }) => ({ role, @@ -120,80 +193,18 @@ export const useMessageSubmit = () => { vlm_endpoint: settings.vlmEndpoint, stop: settings.stopTokens, confidence_threshold: settings.confidenceScoreThreshold, - filter_expr: filters.length - ? filters - .map((f: Filter, index: number) => { - // Create a map of field names to their types and array_types from selected collections - const fieldTypeMap = new Map(); - const fieldArrayTypeMap = new Map(); - selectedCollections.forEach(collectionName => { - const collection = allCollections.find((col: Collection) => col.collection_name === collectionName); - if (collection?.metadata_schema) { - collection.metadata_schema.forEach((field: { name: string; type: string; description: string; array_type?: string }) => { - fieldTypeMap.set(field.name, field.type); - if (field.array_type) { - fieldArrayTypeMap.set(field.name, field.array_type); - } - }); - } - }); - - const isArrayField = fieldTypeMap.get(f.field) === 'array'; - const arrayElementType = fieldArrayTypeMap.get(f.field); - - const formatValue = (value: string | number | boolean | (string | number | boolean)[], isArrayField = false): string => { - if (Array.isArray(value)) { - // Handle array values for operators like "in", "not in", "=", "!=" - const formattedItems = value.map(item => { - if (typeof item === 'boolean' || typeof item === 'number') { - return String(item); - } - // For array fields, check the array_type to determine if we should quote string values - if (isArrayField && arrayElementType) { - // Don't quote if array elements are numeric or boolean types - if (['integer', 'float', 'number', 'boolean'].includes(arrayElementType)) { - return String(item); - } - } - // Quote string values (default behavior) - return `"${item}"`; - }).join(', '); - return `[${formattedItems}]`; - } - if (typeof value === 'boolean' || typeof value === 'number') { - return String(value); // true/false/numbers without quotes - } - return `"${value}"`; // strings with quotes - }; - - let filterExpression = ''; - // Handle special operators - switch (f.operator) { - case 'array_contains': - case 'array_contains_all': - case 'array_contains_any': - // Use function call syntax: array_contains(field, value) - filterExpression = `${f.operator}(content_metadata["${f.field}"], ${formatValue(f.value, isArrayField)})`; - break; - default: - filterExpression = `content_metadata["${f.field}"] ${f.operator} ${formatValue(f.value, isArrayField)}`; - } - - // Add logical operator prefix for all filters except the first one - if (index > 0) { - const logicalOp = f.logicalOperator || 'OR'; // Default to OR if not specified - return ` ${logicalOp.toLowerCase()} ${filterExpression}`; - } - - return filterExpression; - }) - .join('') - : undefined + agentic, + filter_expr: buildFilterExpression( + filters, + selectedCollections, + allCollections, + health?.databases?.[0]?.service + ), }; // Clean the request object to remove undefined/empty values return cleanRequestObject(rawRequest); - }, [selectedCollections, allCollections, settings, filters]); + }, [selectedCollections, allCollections, settings, filters, health?.databases]); const handleSubmit = useCallback(async () => { // Allow submit if there's text OR attached images diff --git a/frontend/src/pages/__tests__/Chat.test.tsx b/frontend/src/pages/__tests__/Chat.test.tsx index 2efb9706c..69743ea3f 100644 --- a/frontend/src/pages/__tests__/Chat.test.tsx +++ b/frontend/src/pages/__tests__/Chat.test.tsx @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen } from '../../test/utils'; import Chat from '../Chat'; diff --git a/frontend/src/store/useSettingsStore.ts b/frontend/src/store/useSettingsStore.ts index e5acd587a..cbd63500d 100644 --- a/frontend/src/store/useSettingsStore.ts +++ b/frontend/src/store/useSettingsStore.ts @@ -20,6 +20,18 @@ import { useHealthStatus } from "../api/useHealthApi"; import { useServerConfiguration } from "../api/useConfigurationApi"; import type { ConfigurationResponse } from "../types/api"; +/** + * Per-request agentic pipeline mode. + * + * - `"off"` (default) → force the standard RAG pipeline (`agentic: false`). + * - `"on"` → force the agentic LangGraph plan-and-execute pipeline (`agentic: true`). + * + * The previous `"auto"` mode (omit the field, let the server's + * `CONFIG.enable_agentic_rag` decide) was removed per Pranjal's review on + * #514: with only two real outcomes the third option is just noise. + */ +export type AgenticMode = "on" | "off"; + /** * Interface defining the shape of the settings state. * Contains RAG configuration, feature toggles, model settings, and endpoints. @@ -41,6 +53,10 @@ interface SettingsState { includeCitations: boolean; enableVlmInference?: boolean; enableFilterGenerator?: boolean; + + // Agentic pipeline mode - per-request override; defaults to "off" + // (Standard RAG pipeline; users opt in to "on" / Agentic explicitly). + agenticMode: AgenticMode; // Models - All optional, populated from health endpoint or user input model?: string; @@ -97,6 +113,9 @@ export const useSettingsStore = create()( includeCitations: true, enableVlmInference: undefined, enableFilterGenerator: undefined, + + // Agentic pipeline mode - default to "off" (Standard RAG pipeline). + agenticMode: "off" as const, // Models - All start undefined, will be populated from health endpoint model: undefined, diff --git a/frontend/src/types/chat.ts b/frontend/src/types/chat.ts index 3cb3c15d4..b408e02d1 100644 --- a/frontend/src/types/chat.ts +++ b/frontend/src/types/chat.ts @@ -21,6 +21,16 @@ export interface Citation { source: string; document_type: "text" | "image" | "table" | "chart"; score?: number | string; + /** + * Pipeline stage that produced this citation. + * + * Mirrors `SourceResult.stage` on the server. The server defaults to + * `"rag"` for the standard pipeline; the agentic pipeline emits values + * like `"initial_retrieval"`, `"execute"`, `"verify_execute"`, and may + * add new values over time. Treated as an opaque string so we render + * any future stage without code changes. + */ + stage?: string; } /** @@ -47,6 +57,54 @@ export interface ImageContent { */ export type MessageContent = string | (TextContent | ImageContent)[]; +/** + * One entry in the streamed reasoning trace. + * + * Built incrementally by `useChatStream` from either agentic `event_type` + * chunks or standard RAG chunks that carry `delta.reasoning_content`. + * Standard responses without reasoning content leave `reasoning_steps` + * undefined. + * + * The `stage` is treated as an opaque string (e.g. `"plan"`, `"execute"`, + * `"synthesize"`, `"verify"`, `"verify_execute"`, `"initial_retrieval"`), + * so any future graph node renders without code changes. + */ +export interface ReasoningStep { + /** + * Pipeline stage supplied by the server's `stage` field, or `"rag"` for + * standard RAG reasoning. + */ + stage: string; + /** One-liner from the matching `stage_start` chunk. */ + label?: string; + /** One-liner from the matching `stage_end` chunk. */ + summary?: string; + /** + * Concatenated `reasoning_content` from `intermediate_reasoning` and + * `final_reasoning` chunks for this stage. + */ + reasoning: string; + /** + * Concatenated `reasoning_content` from `intermediate_output` chunks + * for this stage (planner / verifier JSON, per-task answers, ...). + */ + output: string; + /** Lifecycle: open between `stage_start` and `stage_end`. */ + status: "running" | "done" | "error"; +} + +/** + * TTFT-style timings emitted on the trailing `finish_reason: "stop"` chunk. + * All fields are optional so future metrics added by the server flow + * through unchanged. + */ +export interface AgenticMetrics { + rag_ttft_ms?: number; + llm_ttft_ms?: number; + llm_generation_time_ms?: number; + [key: string]: number | undefined; +} + /** * Represents a message in the chat conversation. */ @@ -57,6 +115,13 @@ export interface ChatMessage { timestamp: string; citations?: Citation[]; is_error?: boolean; + /** + * Reasoning trace, populated when the server emits agentic `event_type` + * chunks or standard RAG `delta.reasoning_content` chunks. + */ + reasoning_steps?: ReasoningStep[]; + /** Trailing-chunk metrics from agentic streaming. */ + metrics?: AgenticMetrics; } /** @@ -64,7 +129,7 @@ export interface ChatMessage { */ export interface Filter { field: string; - operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "in" | "includes" | "does not include" | "like" | "not in" | "before" | "after" | "array_contains" | "array_contains_all" | "array_contains_any"; + operator: "==" | "=" | "!=" | ">" | "<" | ">=" | "<=" | "in" | "includes" | "does not include" | "like" | "not in" | "before" | "after" | "array_contains" | "array_contains_all" | "array_contains_any"; value: string | number | boolean | (string | number | boolean)[]; // Logical operator to join this filter with the previous one (undefined for first filter) logicalOperator?: "AND" | "OR"; diff --git a/frontend/src/types/requests.ts b/frontend/src/types/requests.ts index 0a2fe362e..9afe051fa 100644 --- a/frontend/src/types/requests.ts +++ b/frontend/src/types/requests.ts @@ -53,7 +53,29 @@ export interface GenerateRequest { vlm_endpoint?: string; vdb_endpoint?: string; - // Optional other fields - filter_expr?: string; + // Optional other fields. + // + // `filter_expr` shape depends on the configured vector store: + // - Milvus → string expression (`content_metadata["x"] op v`). + // - Elasticsearch → list of dicts (Elasticsearch Query DSL). Field paths + // are `metadata.content_metadata.`. A `.keyword` suffix is + // appended ONLY for exact-match clauses (term/terms/prefix/wildcard/ + // match) on string-typed (or array) fields — `.keyword` only + // exists as a multi-field on string mappings. Numeric, datetime, + // and boolean fields, plus all `range` clauses, use the bare path. + // See docs/custom-metadata.md for the full contract. + filter_expr?: string | Array>; stop?: string[]; + + /** + * Route this request through the agentic RAG pipeline. + * + * - `undefined` / omitted → server decides based on its own configuration + * (`CONFIG.enable_agentic_rag`). + * - `true` → force the LangGraph plan-and-execute agentic pipeline. + * - `false` → force the standard RAG pipeline. + * + * Mirrors `Prompt.agentic` on the server (`bool | None`). + */ + agentic?: boolean | null; } \ No newline at end of file diff --git a/frontend/src/utils/__tests__/filterExpression.test.ts b/frontend/src/utils/__tests__/filterExpression.test.ts new file mode 100644 index 000000000..dd6f07275 --- /dev/null +++ b/frontend/src/utils/__tests__/filterExpression.test.ts @@ -0,0 +1,552 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// 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. + +import { describe, it, expect } from "vitest"; +import type { Filter } from "../../types/chat"; +import { + buildFieldTypeMap, + compileElasticsearchFilter, + compileMilvusFilter, + vectorStoreFromHealthService, +} from "../filterExpression"; + +const emptyTypes = buildFieldTypeMap([]); + +describe("vectorStoreFromHealthService", () => { + it.each([ + ["Elasticsearch", "elasticsearch"], + ["elasticsearch", "elasticsearch"], + ["ES", "elasticsearch"], + ["Milvus", "milvus"], + ["milvus", "milvus"], + ])("normalizes %s to %s", (label, expected) => { + expect(vectorStoreFromHealthService(label)).toBe(expected); + }); + + it("returns undefined for unrecognized labels", () => { + expect(vectorStoreFromHealthService(undefined)).toBeUndefined(); + expect(vectorStoreFromHealthService("")).toBeUndefined(); + expect(vectorStoreFromHealthService("postgres")).toBeUndefined(); + }); +}); + +describe("buildFieldTypeMap", () => { + it("flattens schemas across multiple collections, last write wins", () => { + const result = buildFieldTypeMap([ + [{ name: "a", type: "string" }], + [{ name: "b", type: "array", array_type: "integer" }], + ]); + expect(result.fieldType.get("a")).toBe("string"); + expect(result.fieldType.get("b")).toBe("array"); + expect(result.arrayElementType.get("b")).toBe("integer"); + expect(result.arrayElementType.has("a")).toBe(false); + }); +}); + +// ============================================================================= +// MILVUS — REGRESSION: behavior must match the pre-PR implementation exactly, +// since callers that hit a Milvus deployment must continue to receive the same +// filter_expr string they always have. +// ============================================================================= + +describe("compileMilvusFilter", () => { + it("returns undefined for an empty list", () => { + expect(compileMilvusFilter([], emptyTypes)).toBeUndefined(); + }); + + it("emits Milvus syntax for a string equality clause", () => { + const filters: Filter[] = [ + { field: "category", operator: "==", value: "AI" }, + ]; + expect(compileMilvusFilter(filters, emptyTypes)).toBe( + 'content_metadata["category"] == "AI"' + ); + }); + + it("does not quote numeric values", () => { + const filters: Filter[] = [ + { field: "priority", operator: ">", value: 5 }, + ]; + expect(compileMilvusFilter(filters, emptyTypes)).toBe( + 'content_metadata["priority"] > 5' + ); + }); + + it("formats array values with proper element quoting", () => { + const filters: Filter[] = [ + { field: "tags", operator: "in", value: ["ai", "ml"] }, + ]; + expect(compileMilvusFilter(filters, emptyTypes)).toBe( + 'content_metadata["tags"] in ["ai", "ml"]' + ); + }); + + it("leaves numeric array elements unquoted when the field is array", () => { + const types = buildFieldTypeMap([ + [{ name: "scores", type: "array", array_type: "integer" }], + ]); + const filters: Filter[] = [ + { field: "scores", operator: "in", value: [1, 2, 3] }, + ]; + expect(compileMilvusFilter(filters, types)).toBe( + 'content_metadata["scores"] in [1, 2, 3]' + ); + }); + + it("uses function-call syntax for array_contains family operators", () => { + const filters: Filter[] = [ + { field: "tags", operator: "array_contains", value: "engineering" }, + ]; + expect(compileMilvusFilter(filters, emptyTypes)).toBe( + 'array_contains(content_metadata["tags"], "engineering")' + ); + }); + + it("joins multiple clauses with the per-filter logical operator (default OR)", () => { + const filters: Filter[] = [ + { field: "category", operator: "==", value: "AI" }, + { + field: "priority", + operator: ">", + value: 5, + logicalOperator: "AND", + }, + { field: "category", operator: "==", value: "ML" }, + ]; + expect(compileMilvusFilter(filters, emptyTypes)).toBe( + 'content_metadata["category"] == "AI" and content_metadata["priority"] > 5 or content_metadata["category"] == "ML"' + ); + }); +}); + +// ============================================================================= +// ELASTICSEARCH — new behavior. Must match the contract documented in +// docs/custom-metadata.md#elasticsearch-filter-example. +// ============================================================================= + +describe("compileElasticsearchFilter", () => { + it("returns undefined for an empty list", () => { + expect(compileElasticsearchFilter([])).toBeUndefined(); + }); + + it("emits a `term` clause for string equality with the .keyword field path", () => { + expect( + compileElasticsearchFilter([ + { field: "category", operator: "==", value: "AI" }, + ]) + ).toEqual([ + { term: { "metadata.content_metadata.category.keyword": "AI" } }, + ]); + }); + + it("alias `=` produces the same `term` clause as `==`", () => { + expect( + compileElasticsearchFilter([ + { field: "category", operator: "=", value: "AI" }, + ]) + ).toEqual([ + { term: { "metadata.content_metadata.category.keyword": "AI" } }, + ]); + }); + + it("wraps inequality in bool.must_not", () => { + expect( + compileElasticsearchFilter([ + { field: "status", operator: "!=", value: "draft" }, + ]) + ).toEqual([ + { + bool: { + must_not: [ + { term: { "metadata.content_metadata.status.keyword": "draft" } }, + ], + }, + }, + ]); + }); + + it.each([ + [">", "gt"], + [">=", "gte"], + ["<", "lt"], + ["<=", "lte"], + ] as const)( + "maps numeric comparison %s to range.%s without .keyword (range never carries .keyword)", + (operator, esKey) => { + const result = compileElasticsearchFilter([ + { field: "priority", operator, value: 5 }, + ]); + expect(result).toEqual([ + { range: { "metadata.content_metadata.priority": { [esKey]: 5 } } }, + ]); + } + ); + + it("maps relative datetime operators to range with gt/lt, bare path", () => { + expect( + compileElasticsearchFilter([ + { field: "created", operator: "after", value: "2025-01-01" }, + ]) + ).toEqual([ + { + range: { + "metadata.content_metadata.created": { gt: "2025-01-01" }, + }, + }, + ]); + expect( + compileElasticsearchFilter([ + { field: "created", operator: "before", value: "2025-01-01" }, + ]) + ).toEqual([ + { + range: { + "metadata.content_metadata.created": { lt: "2025-01-01" }, + }, + }, + ]); + }); + + it("maps `in` to terms and `not in` to bool.must_not.terms", () => { + expect( + compileElasticsearchFilter([ + { field: "tags", operator: "in", value: ["ai", "ml"] }, + ]) + ).toEqual([ + { + terms: { + "metadata.content_metadata.tags.keyword": ["ai", "ml"], + }, + }, + ]); + expect( + compileElasticsearchFilter([ + { field: "tags", operator: "not in", value: ["deprecated"] }, + ]) + ).toEqual([ + { + bool: { + must_not: [ + { + terms: { + "metadata.content_metadata.tags.keyword": ["deprecated"], + }, + }, + ], + }, + }, + ]); + }); + + it("translates Milvus `like` wildcards (% → *, _ → ?) to ES `wildcard`", () => { + expect( + compileElasticsearchFilter([ + { field: "title", operator: "like", value: "policy_%" }, + ]) + ).toEqual([ + { + wildcard: { + "metadata.content_metadata.title.keyword": "policy?*", + }, + }, + ]); + }); + + it("emits term for array_contains and bool.must of terms for array_contains_all", () => { + expect( + compileElasticsearchFilter([ + { field: "tags", operator: "array_contains", value: "engineering" }, + ]) + ).toEqual([ + { + term: { + "metadata.content_metadata.tags.keyword": "engineering", + }, + }, + ]); + expect( + compileElasticsearchFilter([ + { + field: "tags", + operator: "array_contains_all", + value: ["tech", "ai"], + }, + ]) + ).toEqual([ + { + bool: { + must: [ + { term: { "metadata.content_metadata.tags.keyword": "tech" } }, + { term: { "metadata.content_metadata.tags.keyword": "ai" } }, + ], + }, + }, + ]); + }); + + it("emits terms for array_contains_any / includes; bool.must_not for `does not include`", () => { + expect( + compileElasticsearchFilter([ + { + field: "tags", + operator: "array_contains_any", + value: ["tech", "ai"], + }, + ]) + ).toEqual([ + { + terms: { "metadata.content_metadata.tags.keyword": ["tech", "ai"] }, + }, + ]); + expect( + compileElasticsearchFilter([ + { field: "tags", operator: "includes", value: ["alpha"] }, + ]) + ).toEqual([ + { terms: { "metadata.content_metadata.tags.keyword": ["alpha"] } }, + ]); + expect( + compileElasticsearchFilter([ + { + field: "tags", + operator: "does not include", + value: ["deprecated"], + }, + ]) + ).toEqual([ + { + bool: { + must_not: [ + { + terms: { + "metadata.content_metadata.tags.keyword": ["deprecated"], + }, + }, + ], + }, + }, + ]); + }); + + // Grouping rules ---------------------------------------------------------- + + it("produces a flat list when all filters are AND-joined (matches doc example)", () => { + expect( + compileElasticsearchFilter([ + { field: "category", operator: "==", value: "AI" }, + { + field: "priority", + operator: ">", + value: 5, + logicalOperator: "AND", + }, + ]) + ).toEqual([ + { term: { "metadata.content_metadata.category.keyword": "AI" } }, + { + range: { + "metadata.content_metadata.priority": { gt: 5 }, + }, + }, + ]); + }); + + it("wraps once in bool.should when all subsequent joins are OR", () => { + const result = compileElasticsearchFilter([ + { field: "category", operator: "==", value: "AI" }, + { + field: "category", + operator: "==", + value: "ML", + logicalOperator: "OR", + }, + ]); + expect(result).toEqual([ + { + bool: { + should: [ + { term: { "metadata.content_metadata.category.keyword": "AI" } }, + { term: { "metadata.content_metadata.category.keyword": "ML" } }, + ], + }, + }, + ]); + }); + + it("groups AND-runs inside bool.should for mixed AND/OR (default Milvus precedence)", () => { + // (a==1) OR (b==2 AND c==3) — the AND-run after the OR is one group. + const result = compileElasticsearchFilter([ + { field: "a", operator: "==", value: 1 }, + { field: "b", operator: "==", value: 2, logicalOperator: "OR" }, + { field: "c", operator: "==", value: 3, logicalOperator: "AND" }, + ]); + expect(result).toEqual([ + { + bool: { + should: [ + { term: { "metadata.content_metadata.a.keyword": 1 } }, + { + bool: { + must: [ + { term: { "metadata.content_metadata.b.keyword": 2 } }, + { term: { "metadata.content_metadata.c.keyword": 3 } }, + ], + }, + }, + ], + }, + }, + ]); + }); + + // --- Type-aware path selection ------------------------------------------ + // `.keyword` is an ES multi-field that only exists on string mappings. + // Targeting `.keyword` returns zero hits silently. The + // compiler must consult the schema and omit `.keyword` for non-string + // fields. Regression coverage for the case reported by the user where + // an integer `priority` filter from the UI matched nothing. + + describe("type-aware field paths from schema", () => { + it("omits .keyword for integer term equality (the reported bug)", () => { + const types = buildFieldTypeMap([ + [{ name: "priority", type: "integer" }], + ]); + const result = compileElasticsearchFilter( + [{ field: "priority", operator: "==", value: 2 }], + types + ); + expect(result).toEqual([ + { term: { "metadata.content_metadata.priority": 2 } }, + ]); + }); + + it("omits .keyword for float term equality", () => { + const types = buildFieldTypeMap([ + [{ name: "rating", type: "float" }], + ]); + const result = compileElasticsearchFilter( + [{ field: "rating", operator: "==", value: 4.5 }], + types + ); + expect(result).toEqual([ + { term: { "metadata.content_metadata.rating": 4.5 } }, + ]); + }); + + it("omits .keyword for boolean term equality", () => { + const types = buildFieldTypeMap([ + [{ name: "is_public", type: "boolean" }], + ]); + const result = compileElasticsearchFilter( + [{ field: "is_public", operator: "==", value: true }], + types + ); + expect(result).toEqual([ + { term: { "metadata.content_metadata.is_public": true } }, + ]); + }); + + it("omits .keyword for datetime range", () => { + const types = buildFieldTypeMap([ + [{ name: "created_at", type: "datetime" }], + ]); + const result = compileElasticsearchFilter( + [{ field: "created_at", operator: "after", value: "2025-01-01" }], + types + ); + expect(result).toEqual([ + { + range: { + "metadata.content_metadata.created_at": { gt: "2025-01-01" }, + }, + }, + ]); + }); + + it("omits .keyword for integer terms (in)", () => { + const types = buildFieldTypeMap([ + [{ name: "year", type: "integer" }], + ]); + const result = compileElasticsearchFilter( + [{ field: "year", operator: "in", value: [2024, 2025] }], + types + ); + expect(result).toEqual([ + { + terms: { + "metadata.content_metadata.year": [2024, 2025], + }, + }, + ]); + }); + + it("keeps .keyword for string term equality when schema is known", () => { + const types = buildFieldTypeMap([ + [{ name: "status", type: "string" }], + ]); + const result = compileElasticsearchFilter( + [{ field: "status", operator: "==", value: "approved" }], + types + ); + expect(result).toEqual([ + { term: { "metadata.content_metadata.status.keyword": "approved" } }, + ]); + }); + + it("keeps .keyword for array terms (in)", () => { + const types = buildFieldTypeMap([ + [{ name: "tags", type: "array", array_type: "string" }], + ]); + const result = compileElasticsearchFilter( + [{ field: "tags", operator: "in", value: ["ai", "ml"] }], + types + ); + expect(result).toEqual([ + { + terms: { "metadata.content_metadata.tags.keyword": ["ai", "ml"] }, + }, + ]); + }); + + it("omits .keyword for array terms (in)", () => { + const types = buildFieldTypeMap([ + [{ name: "scores", type: "array", array_type: "integer" }], + ]); + const result = compileElasticsearchFilter( + [{ field: "scores", operator: "in", value: [1, 2, 3] }], + types + ); + expect(result).toEqual([ + { terms: { "metadata.content_metadata.scores": [1, 2, 3] } }, + ]); + }); + + it("defaults to string-like (keep .keyword) when the field is missing from the schema", () => { + // Back-compat: the BE normalizer will strip .keyword server-side if + // the field turns out to be non-string. This preserves the wire + // format for installs that don't register metadata schemas. + const types = buildFieldTypeMap([ + [{ name: "other_field", type: "string" }], + ]); + const result = compileElasticsearchFilter( + [{ field: "unregistered", operator: "==", value: "x" }], + types + ); + expect(result).toEqual([ + { term: { "metadata.content_metadata.unregistered.keyword": "x" } }, + ]); + }); + }); +}); diff --git a/frontend/src/utils/filterExpression.ts b/frontend/src/utils/filterExpression.ts new file mode 100644 index 000000000..bac6324f5 --- /dev/null +++ b/frontend/src/utils/filterExpression.ts @@ -0,0 +1,341 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// 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. + +import type { Filter } from "../types/chat"; + +/** + * Vector-store backends the UI knows how to serialize filters for. Detected + * from `/health.databases[].service` (Elasticsearch | Milvus). + */ +export type VectorStoreKind = "milvus" | "elasticsearch"; + +/** Per-field type info needed to format values correctly (quoting, etc.). */ +export interface FieldTypeMap { + /** field name -> type ("string" | "integer" | "float" | "number" | "boolean" | "datetime" | "array") */ + fieldType: Map; + /** field name -> array element type (set only when fieldType === "array") */ + arrayElementType: Map; +} + +/** Pre-build field type maps from the selected collections' metadata schema. */ +export const buildFieldTypeMap = ( + schemas: Array< + Array<{ + name: string; + type: string; + array_type?: string | null | undefined; + }> + > +): FieldTypeMap => { + const fieldType = new Map(); + const arrayElementType = new Map(); + for (const schema of schemas) { + for (const field of schema) { + fieldType.set(field.name, field.type); + if (field.array_type) { + arrayElementType.set(field.name, field.array_type); + } + } + } + return { fieldType, arrayElementType }; +}; + +/** + * Identify a backend from the `/health.databases[].service` string. The BE + * uses the human-readable label ("Elasticsearch" / "Milvus"), so we + * normalize case-insensitively. + */ +export const vectorStoreFromHealthService = ( + service: string | undefined +): VectorStoreKind | undefined => { + if (!service) return undefined; + const lower = service.toLowerCase(); + if (lower.includes("elasticsearch") || lower === "es") return "elasticsearch"; + if (lower.includes("milvus")) return "milvus"; + return undefined; +}; + +// ============================================================================= +// MILVUS FILTER COMPILER +// ============================================================================= +// Milvus filter_expr is a single string expression using +// `content_metadata["field"] op value` syntax with AND/OR (flat, default +// precedence). See docs/custom-metadata.md "Filter Expression Syntax". + +const formatMilvusValue = ( + value: Filter["value"], + isArrayField: boolean, + arrayElementType: string | undefined +): string => { + if (Array.isArray(value)) { + const items = value.map((item) => { + if (typeof item === "boolean" || typeof item === "number") { + return String(item); + } + // For array-typed fields, leave numeric/boolean array elements + // unquoted — quoting them would break Milvus's type coercion. + if ( + isArrayField && + arrayElementType && + ["integer", "float", "number", "boolean"].includes(arrayElementType) + ) { + return String(item); + } + return `"${item}"`; + }); + return `[${items.join(", ")}]`; + } + if (typeof value === "boolean" || typeof value === "number") { + return String(value); + } + return `"${value}"`; +}; + +const compileMilvusClause = ( + f: Filter, + fieldTypes: FieldTypeMap +): string => { + const isArrayField = fieldTypes.fieldType.get(f.field) === "array"; + const arrayElementType = fieldTypes.arrayElementType.get(f.field); + const formatted = formatMilvusValue(f.value, isArrayField, arrayElementType); + + switch (f.operator) { + case "array_contains": + case "array_contains_all": + case "array_contains_any": + return `${f.operator}(content_metadata["${f.field}"], ${formatted})`; + default: + return `content_metadata["${f.field}"] ${f.operator} ${formatted}`; + } +}; + +/** + * Compile a list of UI filters to a Milvus `filter_expr` string. Returns + * `undefined` for an empty input (so the caller can omit the field). + * + * Logical operators apply between clauses with default Milvus precedence + * (no parenthesization), matching pre-existing behavior. + */ +export const compileMilvusFilter = ( + filters: Filter[], + fieldTypes: FieldTypeMap +): string | undefined => { + if (filters.length === 0) return undefined; + return filters + .map((f, index) => { + const clause = compileMilvusClause(f, fieldTypes); + if (index === 0) return clause; + const op = (f.logicalOperator || "OR").toLowerCase(); + return ` ${op} ${clause}`; + }) + .join(""); +}; + +// ============================================================================= +// ELASTICSEARCH FILTER COMPILER +// ============================================================================= +// ES filter_expr is a list of dicts using Elasticsearch Query DSL. Field paths +// are `metadata.content_metadata.`, with a `.keyword` suffix appended +// only for exact-match clauses (`term`/`terms`/`prefix`) on string-typed +// fields. Numeric, datetime, and boolean fields use the bare path because +// `.keyword` only exists as a multi-field on string mappings — appending it +// elsewhere targets a non-existent field and silently returns zero hits. See +// docs/custom-metadata.md#elasticsearch-filter-example. + +/** ES Query DSL clause. Kept loose because ES accepts arbitrary shapes. */ +export type ESClause = Record; + +/** Schema types that support a `.keyword` multi-field in ES mappings. */ +const STRING_FIELD_TYPES = new Set(["string"]); + +const isStringLikeField = ( + field: string, + fieldTypes: FieldTypeMap +): boolean => { + const t = fieldTypes.fieldType.get(field); + if (!t) { + // Unknown field (no schema loaded for the selected collection). Default + // to string-like to preserve the previous wire format for installs + // without registered schemas. The backend normalizer will strip + // `.keyword` server-side if the field turns out to be non-string. + return true; + } + if (STRING_FIELD_TYPES.has(t)) return true; + if (t === "array") { + const elem = fieldTypes.arrayElementType.get(field); + return elem !== undefined && STRING_FIELD_TYPES.has(elem); + } + return false; +}; + +/** + * Clause kinds we care about for the `.keyword` decision: + * - "exact" → `term` / `terms` / `prefix` / `wildcard` / `match`: append + * `.keyword` when the field is string-like (that's where the ES multi-field + * exists). For non-string fields these clauses either don't apply or work + * on the native field type, so use the bare path. + * - "range" → never `.keyword`. String fields don't support range queries + * server-side, and on numeric/datetime fields the keyword sub-field + * doesn't exist. + */ +type EsClauseKind = "exact" | "range"; + +const esField = ( + name: string, + clauseKind: EsClauseKind, + fieldTypes: FieldTypeMap +): string => { + const base = `metadata.content_metadata.${name}`; + if (clauseKind === "range") return base; + return isStringLikeField(name, fieldTypes) ? `${base}.keyword` : base; +}; + +/** Convert Milvus-style `%` wildcards to ES `*`/`?`. */ +const milvusLikeToEsWildcard = (pattern: string): string => + pattern.replace(/%/g, "*").replace(/_/g, "?"); + +const negate = (clause: ESClause): ESClause => ({ + bool: { must_not: [clause] }, +}); + +const compileEsClause = (f: Filter, fieldTypes: FieldTypeMap): ESClause => { + const exactPath = esField(f.field, "exact", fieldTypes); + const rangePath = esField(f.field, "range", fieldTypes); + + switch (f.operator) { + case "=": + case "==": + return { term: { [exactPath]: f.value } }; + + case "!=": + return negate({ term: { [exactPath]: f.value } }); + + case ">": + return { range: { [rangePath]: { gt: f.value } } }; + case ">=": + return { range: { [rangePath]: { gte: f.value } } }; + case "<": + return { range: { [rangePath]: { lt: f.value } } }; + case "<=": + return { range: { [rangePath]: { lte: f.value } } }; + + case "after": + return { range: { [rangePath]: { gt: f.value } } }; + case "before": + return { range: { [rangePath]: { lt: f.value } } }; + + case "in": + return { + terms: { [exactPath]: Array.isArray(f.value) ? f.value : [f.value] }, + }; + case "not in": + return negate({ + terms: { [exactPath]: Array.isArray(f.value) ? f.value : [f.value] }, + }); + + case "like": + return { + wildcard: { + [exactPath]: milvusLikeToEsWildcard(String(f.value)), + }, + }; + + // ES `term` already does set-membership for stored arrays, so a single + // value matches any element of the array. + case "array_contains": + return { term: { [exactPath]: f.value } }; + + // "all of" — emit one `term` per requested value, ANDed together. + case "array_contains_all": { + const values = Array.isArray(f.value) ? f.value : [f.value]; + return { + bool: { must: values.map((v) => ({ term: { [exactPath]: v } })) }, + }; + } + + // "any of" — `terms` is a built-in OR-of-values match. + case "array_contains_any": + case "includes": + return { + terms: { [exactPath]: Array.isArray(f.value) ? f.value : [f.value] }, + }; + case "does not include": + return negate({ + terms: { [exactPath]: Array.isArray(f.value) ? f.value : [f.value] }, + }); + + default: { + // Exhaustiveness guard — if the Filter operator union grows without + // a corresponding case here, TypeScript will flag it. At runtime we + // fall back to a `term` query so we still produce a valid request. + const _exhaustive: never = f.operator; + void _exhaustive; + return { term: { [exactPath]: f.value } }; + } + } +}; + +/** + * Compile a list of UI filters to an Elasticsearch `filter_expr` (list of + * dicts) per docs/custom-metadata.md. + * + * Grouping rules (match the Milvus flat-precedence behavior): + * + * - All-AND or single filter → flat list of clauses (matches the doc + * example: `[{term: ...}, {range: ...}]`). + * - Any OR → group consecutive AND-joined clauses, then OR the groups + * together via a single top-level `bool.should` envelope. + * + * Returns `undefined` for empty input (so the caller can omit the field). + * + * `fieldTypes` carries the schema-derived type info needed to decide whether + * `.keyword` should be appended for a given clause/field pair. When a field + * is missing from `fieldTypes` (e.g. no schema is registered), we default to + * the previous string-like behavior and rely on the backend normalizer to + * strip `.keyword` if the field is actually non-string. + */ +export const compileElasticsearchFilter = ( + filters: Filter[], + fieldTypes: FieldTypeMap = { + fieldType: new Map(), + arrayElementType: new Map(), + } +): ESClause[] | undefined => { + if (filters.length === 0) return undefined; + + // Split into AND-groups separated by OR boundaries. The first filter + // always opens the first group. Subsequent filters extend the current + // group on AND, or open a new group on OR. + const groups: ESClause[][] = [[]]; + filters.forEach((f, index) => { + const op = index === 0 ? "AND" : f.logicalOperator || "OR"; + if (op === "OR" && groups[groups.length - 1].length > 0) { + groups.push([]); + } + groups[groups.length - 1].push(compileEsClause(f, fieldTypes)); + }); + + if (groups.length === 1) { + // All-AND or single → flat list. Matches the documented contract. + return groups[0]; + } + + // Mixed / OR — wrap once in bool.should so the entire request still + // satisfies the "list of dicts" shape (a single-element list). + const shouldClauses = groups.map((g) => + g.length === 1 ? g[0] : { bool: { must: g } } + ); + return [{ bool: { should: shouldClauses } }]; +}; diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts index de8b71508..510e97031 100644 --- a/frontend/src/vite-env.d.ts +++ b/frontend/src/vite-env.d.ts @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 /** * Vite environment type definitions for the frontend application. * diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index 6dc14af06..1892968ea 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -12,7 +12,8 @@ export default mergeConfig( 'src/pages/**/*.{test,spec}.{ts,tsx}', 'src/components/**/*.{test,spec}.{ts,tsx}', 'src/hooks/**/*.{test,spec}.{ts,tsx}', - 'src/store/**/*.{test,spec}.{ts,tsx}' + 'src/store/**/*.{test,spec}.{ts,tsx}', + 'src/utils/**/*.{test,spec}.{ts,tsx}' ], // Handle CSS imports from KUI components css: { @@ -31,7 +32,8 @@ export default mergeConfig( 'src/pages/**/*.{ts,tsx}', 'src/components/**/*.{ts,tsx}', 'src/hooks/**/*.{ts,tsx}', - 'src/store/**/*.{ts,tsx}' + 'src/store/**/*.{ts,tsx}', + 'src/utils/**/*.{ts,tsx}' ], exclude: [ 'node_modules/', diff --git a/notebooks/.env_library b/notebooks/.env_library index eb5b68eb5..2e570c047 100644 --- a/notebooks/.env_library +++ b/notebooks/.env_library @@ -2,26 +2,26 @@ export NVIDIA_API_KEY=${NGC_API_KEY} # Ingestor server specific configurations # === Vector DB specific configurations === -export APP_VECTORSTORE_URL=http://localhost:19530 -export APP_VECTORSTORE_NAME=milvus +export APP_VECTORSTORE_URL=http://localhost:9200 +export APP_VECTORSTORE_NAME=elasticsearch export APP_VECTORSTORE_INDEXTYPE=GPU_CAGRA export APP_VECTORSTORE_SEARCHTYPE=dense -export APP_VECTORSTORE_ENABLEGPUINDEX=True -export APP_VECTORSTORE_ENABLEGPUSEARCH=True +export APP_VECTORSTORE_ENABLEGPUINDEX=False +export APP_VECTORSTORE_ENABLEGPUSEARCH=False export COLLECTION_NAME=test_native -# === MINIO specific configurations === -export MINIO_ENDPOINT=localhost:9010 -export MINIO_ACCESSKEY=minioadmin -export MINIO_SECRETKEY=minioadmin +# === Object-store configurations === +export OBJECTSTORE_ENDPOINT=seaweedfs:9010 +export OBJECTSTORE_ACCESSKEY=seaweedfsadmin +export OBJECTSTORE_SECRETKEY=seaweedfsadmin # === Embedding Model specific configurations === -export APP_EMBEDDINGS_SERVERURL=nemotron-embedding-ms:8000/v1 -export APP_EMBEDDINGS_MODELNAME=nvidia/llama-nemotron-embed-1b-v2 +export APP_EMBEDDINGS_SERVERURL=nemotron-vlm-embedding-ms:8000/v1 +export APP_EMBEDDINGS_MODELNAME=nvidia/llama-nemotron-embed-vl-1b-v2 export APP_EMBEDDINGS_DIMENSIONS=2048 -# For VLM Embedding Model (Nemoretriever-1b-vlm-embed-v1) -# export APP_EMBEDDINGS_SERVERURL=localhost:9081 -# export APP_EMBEDDINGS_MODELNAME=nvidia/llama-nemotron-embed-vl-1b-v2 +# For text-only embedding NIM (optional; requires text-embed compose profile) +# export APP_EMBEDDINGS_SERVERURL=nemotron-embedding-ms:8000/v1 +# export APP_EMBEDDINGS_MODELNAME=nvidia/llama-nemotron-embed-1b-v2 # === NV-Ingest Connection Configurations === export APP_NVINGEST_MESSAGECLIENTHOSTNAME=localhost @@ -39,7 +39,7 @@ export APP_NVINGEST_TEXTDEPTH=page # === NV-Ingest Splitting Configurations === export APP_NVINGEST_CHUNKSIZE=512 export APP_NVINGEST_CHUNKOVERLAP=150 -export APP_NVINGEST_ENABLEPDFSPLITTER=True +export APP_NVINGEST_ENABLE_PAGED_DOC_SPLIT=False # === NV-Ingest Caption Model configurations === export APP_NVINGEST_CAPTIONMODELNAME=nvidia/nemotron-nano-12b-v2-vl @@ -56,9 +56,6 @@ export REDIS_HOST=localhost export REDIS_PORT=6379 export REDIS_DB=0 -# Bulk upload to MinIO -export ENABLE_MINIO_BULK_UPLOAD=True - # --- Additional variables from rag-server --- export EXAMPLE_PATH=./nvidia_rag/rag_server @@ -67,7 +64,7 @@ export APP_RETRIEVER_SCORETHRESHOLD=0.25 export VECTOR_DB_TOPK=100 # === LLM Model specific configurations === -export APP_LLM_MODELNAME="nvidia/llama-3.3-nemotron-super-49b-v1.5" +export APP_LLM_MODELNAME="nvidia/nemotron-3-super-120b-a12b" export APP_LLM_SERVERURL=localhost:8999 # LLM model parameters export LLM_MAX_TOKENS=32768 @@ -75,11 +72,11 @@ export LLM_TEMPERATURE=0 export LLM_TOP_P=1.0 # === Query Rewriter Model specific configurations === -export APP_QUERYREWRITER_MODELNAME="nvidia/llama-3.3-nemotron-super-49b-v1.5" +export APP_QUERYREWRITER_MODELNAME="nvidia/nemotron-3-super-120b-a12b" export APP_QUERYREWRITER_SERVERURL=localhost:8999 # === Filter Expression Generator Model specific configurations === -export APP_FILTEREXPRESSIONGENERATOR_MODELNAME="nvidia/llama-3.3-nemotron-super-49b-v1.5" +export APP_FILTEREXPRESSIONGENERATOR_MODELNAME="nvidia/nemotron-3-super-120b-a12b" export APP_FILTEREXPRESSIONGENERATOR_SERVERURL=localhost:8999 export ENABLE_FILTER_GENERATOR=False @@ -124,7 +121,7 @@ export REFLECTION_LLM="mistralai/mixtral-8x22b-instruct-v0.1" export REFLECTION_LLM_SERVERURL=localhost:8998 # === Document Summary Model specific configurations === -export SUMMARY_LLM="nvidia/llama-3.3-nemotron-super-49b-v1.5" +export SUMMARY_LLM="nvidia/nemotron-3-super-120b-a12b" export SUMMARY_LLM_SERVERURL=localhost:8999 export SUMMARY_LLM_MAX_CHUNK_LENGTH=9000 export SUMMARY_CHUNK_OVERLAP=400 @@ -137,4 +134,35 @@ export TEMP_DIR=./tmp-data/ # === Prompt configuration === # Change this to the absolute path of the prompt.yaml file you want to use -# export PROMPT_CONFIG_FILE=src/nvidia_rag/rag_server/prompt.yaml \ No newline at end of file +# export PROMPT_CONFIG_FILE=src/nvidia_rag/rag_server/prompt.yaml + +# === Agentic RAG (LangGraph plan-and-execute pipeline) === +# Uncomment ENABLE_AGENTIC_RAG to route knowledge-base queries through the agentic pipeline. +# When enabled, each query is broken into a multi-step plan; sub-questions are answered +# via iterative retrieval and a final synthesis LLM produces the response. +export ENABLE_AGENTIC_RAG=false + +# Per-role LLM configuration (all four roles use the same model in the reference config). +# Set different values per role to use a smaller/cheaper model for non-critical roles. +export AGENTIC_PLANNER_LLM_SERVERURL=localhost:8999 +export AGENTIC_PLANNER_LLM_MODEL=nvidia/nemotron-3-super-120b-a12b +export AGENTIC_TASK_LLM_SERVERURL=localhost:8999 +export AGENTIC_TASK_LLM_MODEL=nvidia/nemotron-3-super-120b-a12b +export AGENTIC_SEED_GEN_LLM_SERVERURL=localhost:8999 +export AGENTIC_SEED_GEN_LLM_MODEL=nvidia/nemotron-3-super-120b-a12b +export AGENTIC_SYNTHESIS_LLM_SERVERURL=localhost:8999 +export AGENTIC_SYNTHESIS_LLM_MODEL=nvidia/nemotron-3-super-120b-a12b +# Per-role API key overrides (default: inherits NVIDIA_API_KEY) +# export AGENTIC_PLANNER_LLM_APIKEY="" +# export AGENTIC_TASK_LLM_APIKEY="" +# export AGENTIC_SEED_GEN_LLM_APIKEY="" +# export AGENTIC_SYNTHESIS_LLM_APIKEY="" + +# Agent behaviour tuning (values match reference config defaults) +export AGENTIC_LOG_LEVEL=INFO + +# Verification pass (disabled by default; enable for higher-accuracy at extra cost) +export AGENTIC_VERIFICATION_ENABLED=false + +# Context window budget for retrieved chunks passed to the agent +export AGENTIC_CONTEXT_MAX_TOKENS=100000 diff --git a/notebooks/building_rag_vdb_operator.ipynb b/notebooks/building_rag_vdb_operator.ipynb index 1852f1d9c..b4cc8f847 100644 --- a/notebooks/building_rag_vdb_operator.ipynb +++ b/notebooks/building_rag_vdb_operator.ipynb @@ -94,7 +94,7 @@ "metadata": {}, "outputs": [], "source": [ - "# del os.environ['NVIDIA_API_KEY'] ## delete key and reset if needed\n", + "# del os.environ['NGC_API_KEY'] ## delete key and reset if needed\n", "if os.environ.get(\"NGC_API_KEY\", \"\").startswith(\"nvapi-\"):\n", " print(\"Valid NGC_API_KEY already in environment. Delete to reset\")\n", "else:\n", @@ -141,16 +141,16 @@ "- **Security**: Security plugin is disabled for simplicity. Enable for production use\n", "- **Network**: Uses `nvidia-rag` network for integration with other services\n", "- **Data Persistence**: To keep data peristent, the opensearch data can be mounted to external volume with following steps:\n", - " - Create volume directory and provide required permissions:\n", - " ```bash\n", - " sudo mkdir -p deploy/compose/volumes/opensearch/\n", - " sudo chmod -R 777 deploy/compose/volumes/opensearch/\n", - " ```\n", - " - Mount volume:\n", + " - Add a dedicated `rag-vol-opensearch` Docker named volume following the same `rag-vol-*` convention used by the rest of the stack:\n", " ```yaml\n", " volumes:\n", - " - ./volumes/opensearch:/usr/share/opensearch/data/\n", - " ```" + " - rag-vol-opensearch:/usr/share/opensearch/data/\n", + " # …\n", + " volumes:\n", + " rag-vol-opensearch:\n", + " name: rag-vol-opensearch\n", + " ```\n", + " Docker auto-creates the volume on first `compose up`. See `docs/troubleshooting.md` (\"Manage Persistent Data (rag-vol-*)\") for inspection, backup, and reset commands." ] }, { @@ -266,8 +266,8 @@ "metadata": {}, "outputs": [], "source": [ - "# Make sure MinIO is running (required for Citations)\n", - "!docker compose -f ../deploy/compose/vectordb.yaml --profile minio up -d" + "# Make sure SeaweedFS is running (required for Citations)\n", + "!docker compose -f ../deploy/compose/vectordb.yaml --profile seaweedfs up -d" ] }, { @@ -362,10 +362,10 @@ "NAMES STATUS\n", "nemotron-ranking-ms Up ... (healthy)\n", "compose-page-elements-1 Up ...\n", - "compose-nemoretriever-ocr-1 Up ...\n", + "compose-nemotron-ocr-1 Up ...\n", "compose-graphic-elements-1 Up ...\n", "compose-table-structure-1 Up ...\n", - "nemotron-embedding-ms Up ... (healthy)\n", + "nemotron-vlm-embedding-ms Up ... (healthy)\n", "nim-llm-ms Up ... (healthy)\n", "```" ] @@ -387,7 +387,7 @@ "DEPLOYMENT_MODE = \"cloud\"\n", "\n", "# Configure NV-Ingest to use NVIDIA hosted cloud APIs\n", - "os.environ[\"OCR_HTTP_ENDPOINT\"] = \"https://ai.api.nvidia.com/v1/cv/nvidia/nemoretriever-ocr\"\n", + "os.environ[\"OCR_HTTP_ENDPOINT\"] = \"https://ai.api.nvidia.com/v1/cv/nvidia/nemotron-ocr-v1\"\n", "os.environ[\"OCR_INFER_PROTOCOL\"] = \"http\"\n", "os.environ[\"YOLOX_HTTP_ENDPOINT\"] = (\n", " \"https://ai.api.nvidia.com/v1/cv/nvidia/nemotron-page-elements-v3\"\n", @@ -1241,7 +1241,12 @@ " \"total_failed\": len(failed_collections),\n", " }\n", " # ----------------------------------------------------------------------------------------------\n", - " def get_documents(self, collection_name: str) -> list[dict[str, Any]]:\n", + " def get_documents(\n", + " self,\n", + " collection_name: str,\n", + " *,\n", + " force_get_metadata: bool = False,\n", + " ) -> list[dict[str, Any]]:\n", " \"\"\"\n", " Retrieve all unique documents from the specified collection.\n", " This method queries the OpenSearch index to find all unique documents based on their\n", @@ -1251,6 +1256,9 @@ " implementations.\n", " Args:\n", " collection_name (str): The name of the collection/index to retrieve documents from.\n", + " force_get_metadata (bool): Accepted for compatibility with the VDBRag interface.\n", + " OpenSearch already fetches metadata in this notebook implementation, so this\n", + " flag does not change behavior.\n", " Returns:\n", " list[dict[str, Any]]: A list of dictionaries containing document information.\n", " Each dictionary has the following structure:\n", @@ -2081,16 +2089,17 @@ "\n", "# IMPORTANT: Two different embedding URLs are needed:\n", "# 1. config_ingestor.embeddings.server_url → Used by nv-ingest (runs in Docker)\n", - "# Must use Docker network hostname: nemotron-embedding-ms:8000\n", + "# Must use Docker network hostname: nemotron-vlm-embedding-ms:8000\n", "# 2. embedding_model for VDB operator → Used for queries (runs locally in notebook)\n", "# Must use localhost: localhost:9080\n", "\n", "if DEPLOYMENT_MODE == \"on_prem\":\n", " # nv-ingest runs inside Docker, needs Docker network hostname\n", - " config_ingestor.embeddings.server_url = \"http://nemotron-embedding-ms:8000/v1\"\n", + " config_ingestor.embeddings.server_url = \"http://nemotron-vlm-embedding-ms:8000/v1\"\n", "if DEPLOYMENT_MODE == \"cloud\":\n", " config_ingestor.embeddings.server_url = \"https://integrate.api.nvidia.com/v1\"\n", " config_ingestor.llm.server_url = \"\" # Empty uses NVIDIA API catalog\n", + " config_ingestor.summarizer.server_url = \"\" # Empty uses NVIDIA API catalog\n", " config_rag.embeddings.server_url = \"https://integrate.api.nvidia.com/v1\"\n", " config_rag.ranking.server_url = \"\" # Empty uses NVIDIA API catalog\n", " config_rag.llm.server_url = \"\" # Empty uses NVIDIA API catalog\n", @@ -2163,6 +2172,7 @@ "outputs": [], "source": [ "from nvidia_rag import NvidiaRAG, NvidiaRAGIngestor\n", + "\n", "# Create ingestor with config and custom VDB operator\n", "ingestor = NvidiaRAGIngestor(\n", " config=config_ingestor,\n", diff --git a/notebooks/config.yaml b/notebooks/config.yaml index f5a8eb53b..9c8c440e3 100644 --- a/notebooks/config.yaml +++ b/notebooks/config.yaml @@ -4,12 +4,12 @@ # Vector Store Configuration vector_store: - name: "milvus" # Name of the vector store backend (e.g., milvus, elasticsearch) - url: "http://localhost:19530" # URL endpoint for the vector store service - index_type: "GPU_CAGRA" # Type of vector index (e.g., GPU_CAGRA, IVF_FLAT) + name: "elasticsearch" # Name of the vector store backend (e.g., milvus, elasticsearch) + url: "http://localhost:9200" # URL endpoint for the vector store service + index_type: "hnsw" # Type of vector index (e.g., GPU_CAGRA, IVF_FLAT) search_type: "dense" # Type of search to perform (dense, hybrid) - enable_gpu_index: true # Enable GPU acceleration for index building - enable_gpu_search: true # Enable GPU acceleration for search operations + enable_gpu_index: false # Enable GPU acceleration for index building + enable_gpu_search: false # Enable GPU acceleration for search operations default_collection_name: "test_native" # Default collection/index name for storing vectors # NV-Ingest Configuration @@ -25,14 +25,14 @@ nv_ingest: text_depth: "page" # Granularity level for text extraction (page, document) chunk_size: 512 # Maximum size of text chunks in tokens chunk_overlap: 150 # Number of overlapping tokens between chunks - caption_model_name: "nvidia/nemotron-nano-12b-v2-vl" # Model name for generating image captions + caption_model_name: "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning" # Model name for generating image captions caption_endpoint_url: "http://localhost:1977/v1/chat/completions" # API endpoint for caption generation service - enable_pdf_splitter: true # Enable PDF page splitting during ingestion + enable_paged_doc_split: false # Enable paged document splitting during ingestion # LLM Configuration llm: server_url: "http://localhost:8999" # URL endpoint for the LLM inference service (on-prem NIM default) - model_name: "nvidia/llama-3.3-nemotron-super-49b-v1.5" # Name of the language model to use for generation + model_name: "nvidia/nemotron-3-super-120b-a12b" # Name of the language model to use for generation # api_key: "" # Optional: API key for LLM service (overrides NVIDIA_API_KEY environment variable) parameters: max_tokens: 32768 # Maximum number of tokens to generate in response @@ -41,23 +41,23 @@ llm: # Query Rewriter Configuration query_rewriter: - model_name: "nvidia/llama-3.3-nemotron-super-49b-v1.5" # Model for rewriting user queries to improve retrieval + model_name: "nvidia/nemotron-3-super-120b-a12b" # Model for rewriting user queries to improve retrieval server_url: "localhost:8999" # URL endpoint for query rewriter service enable_query_rewriter: false # Enable automatic query rewriting before retrieval # api_key: "" # Optional: API key for query rewriter (overrides NVIDIA_API_KEY environment variable) # Filter Expression Generator Configuration filter_expression_generator: - model_name: "nvidia/llama-3.3-nemotron-super-49b-v1.5" # Model for generating metadata filter expressions from queries + model_name: "nvidia/nemotron-3-super-120b-a12b" # Model for generating metadata filter expressions from queries server_url: "localhost:8999" # URL endpoint for filter expression generator service enable_filter_generator: false # Enable automatic filter expression generation from natural language # api_key: "" # Optional: API key for filter generator (overrides NVIDIA_API_KEY environment variable) # Embedding Configuration embeddings: - model_name: "nvidia/llama-nemotron-embed-1b-v2" # Model for generating text embeddings + model_name: "nvidia/llama-nemotron-embed-vl-1b-v2" # Model for generating embeddings dimensions: 2048 # Dimensionality of the embedding vectors - server_url: "http://localhost:9080/v1" # URL endpoint for embedding service (on-prem NIM default) + server_url: "http://localhost:9081/v1" # URL endpoint for embedding service (on-prem NIM default) # api_key: "" # Optional: API key for embeddings (overrides NVIDIA_API_KEY environment variable) # Ranking Configuration @@ -82,18 +82,19 @@ tracing: # Vision-Language Model Configuration vlm: server_url: "http://localhost:1977/v1" # URL endpoint for Vision-Language Model service - model_name: "nvidia/nemotron-nano-12b-v2-vl" # Vision-Language Model for processing images and text + model_name: "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning" # Vision-Language Model for processing images and text # api_key: "" # Optional: API key for VLM service (overrides NVIDIA_API_KEY environment variable) -# MinIO Configuration -minio: - endpoint: "localhost:9010" # MinIO object storage endpoint - access_key: "minioadmin" # MinIO access key for authentication - secret_key: "minioadmin" # MinIO secret key for authentication +# Object-store Configuration +object_store: + endpoint: "localhost:9010" # Host endpoint used by the notebook kernel + nv_ingest_endpoint: "seaweedfs:9010" # Docker-network endpoint used by NV-Ingest + access_key: "seaweedfsadmin" # Object-store access key for authentication + secret_key: "seaweedfsadmin" # Object-store secret key for authentication # Summarizer Configuration summarizer: - model_name: "nvidia/llama-3.3-nemotron-super-49b-v1.5" # Model for generating document summaries + model_name: "nvidia/nemotron-3-super-120b-a12b" # Model for generating document summaries server_url: "localhost:8999" # URL endpoint for summarization service max_chunk_length: 50000 # Maximum character length for chunks to summarize chunk_overlap: 200 # Character overlap between chunks during summarization @@ -105,7 +106,7 @@ summarizer: reflection: enable_reflection: false # Enable self-reflection to improve answer quality max_loops: 3 # Maximum number of reflection iterations - model_name: "nvidia/llama-3.3-nemotron-super-49b-v1.5" # Model for reflection and quality assessment + model_name: "nvidia/nemotron-3-super-120b-a12b" # Model for reflection and quality assessment server_url: "" # URL endpoint for reflection service context_relevance_threshold: 1 # Minimum relevance score for context to be considered useful response_groundedness_threshold: 1 # Minimum groundedness score for response to be considered factual @@ -115,4 +116,32 @@ reflection: enable_guardrails: false # Enable safety guardrails for input/output filtering enable_citations: true # Include source citations in generated responses enable_vlm_inference: false # Enable Vision-Language Model for multimodal queries +enable_agentic_rag: false # Route knowledge-base queries through the LangGraph plan-and-execute agentic pipeline temp_dir: "./tmp-data/" # Temporary directory for file processing and storage + +# Agentic RAG Configuration (LangGraph plan-and-execute pipeline) +# Per-role LLM configuration. All four roles use the same model in the reference config; +# set different values per role to use a smaller/cheaper model for non-critical roles. +# If a role's model_name is empty, the builder falls back to planner_llm. +agentic_rag: + planner_llm: + server_url: "http://localhost:8999" # URL endpoint for the planner LLM (scope resolution + task creation) + model_name: "nvidia/nemotron-3-super-120b-a12b" # Model name for the planner LLM + # api_key: "" # Optional: API key for planner LLM (overrides NVIDIA_API_KEY) + task_llm: + server_url: "http://localhost:8999" # URL endpoint for the task LLM (answering individual sub-questions) + model_name: "nvidia/nemotron-3-super-120b-a12b" # Model name for the task LLM + # api_key: "" # Optional: API key for task LLM (overrides NVIDIA_API_KEY) + seed_gen_llm: + server_url: "http://localhost:8999" # URL endpoint for the seed-gen LLM (retry seed query generation) + model_name: "nvidia/nemotron-3-super-120b-a12b" # Model name for the seed-gen LLM + # api_key: "" # Optional: API key for seed-gen LLM (overrides NVIDIA_API_KEY) + synthesis_llm: + server_url: "http://localhost:8999" # URL endpoint for the synthesis LLM (final answer generation) + model_name: "nvidia/nemotron-3-super-120b-a12b" # Model name for the synthesis LLM + # api_key: "" # Optional: API key for synthesis LLM (overrides NVIDIA_API_KEY) + log_level: "INFO" # Logging level for the agentic RAG agent (DEBUG/INFO/WARNING/ERROR) + verification: + enabled: false # Toggle verification pass on/off (enable for higher-accuracy at extra cost) + context: + max_tokens: 100000 # Token budget for retrieved chunks passed to the agent diff --git a/notebooks/evaluation_01_ragas.ipynb b/notebooks/evaluation_01_ragas.ipynb index d3f12933d..c05c44698 100644 --- a/notebooks/evaluation_01_ragas.ipynb +++ b/notebooks/evaluation_01_ragas.ipynb @@ -333,9 +333,6 @@ " }\n", " ],\n", " \"use_knowledge_base\": True,\n", - " \"reranker_top_k\": 2,\n", - " \"vdb_top_k\": 10,\n", - " \"vdb_endpoint\": \"http://milvus:19530\",\n", " \"collection_names\": [\"financebench\"],\n", " \"enable_reranker\": True,\n", " \"enable_citations\": True,\n", @@ -537,7 +534,7 @@ ], "metadata": { "kernelspec": { - "display_name": "evaluate", + "display_name": ".venv", "language": "python", "name": "python3" }, @@ -551,7 +548,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.9" + "version": "3.11.14" } }, "nbformat": 4, diff --git a/notebooks/image_input.ipynb b/notebooks/image_input.ipynb index 5698b7985..03d12f2f9 100644 --- a/notebooks/image_input.ipynb +++ b/notebooks/image_input.ipynb @@ -25,7 +25,7 @@ "\n", "This section will guide you through:\n", "1. Configuring your NGC API key for accessing NVIDIA services\n", - "2. Deploying the Milvus vector database\n", + "2. Starting the vector database service\n", "3. Setting up NVIDIA NIMs (NVIDIA Inference Microservices) for embeddings and VLM\n", "4. Starting the NVIDIA Ingest runtime for document processing\n", "5. Launching the RAG server\n", @@ -141,14 +141,11 @@ "id": "84642fbb", "metadata": {}, "source": [ - "### 2. Setup the Milvus Vector Database\n", + "### 2. Setup the Vector Database\n", "\n", - "Milvus is a high-performance vector database used to store and search multimodal embeddings.\n", + "The vector database stores and indexes multimodal embeddings for fast similarity search.\n", "\n", - "**Configuration Notes**:\n", - "- By default, Milvus uses GPU indexing for faster performance\n", - "- Ensure you have provided the correct GPU ID below\n", - "- If you don't have a GPU available, you can switch to CPU-only Milvus by following the instructions in [milvus-configuration.md](https://github.com/NVIDIA-AI-Blueprints/rag/blob/main/docs/milvus-configuration.md)" + "**Default**: Elasticsearch is used as the default vector store. Milvus is also supported as an alternative. Both are defined in `vectordb.yaml`." ] }, { @@ -158,8 +155,8 @@ "metadata": {}, "outputs": [], "source": [ - "# Specify which GPU to use for Milvus (change if using a different GPU)\n", - "os.environ[\"VECTORSTORE_GPU_DEVICE_ID\"] = \"0\"" + "# Optional: GPU device for Compose when using Milvus GPU indexing or Elasticsearch GPU indexing (see docs/milvus-configuration.md, docs/elasticsearch-configuration.md)\n", + "# os.environ[\"VECTORSTORE_GPU_DEVICE_ID\"] = \"0\"" ] }, { @@ -169,7 +166,7 @@ "metadata": {}, "outputs": [], "source": [ - "# Start Milvus vector database service\n", + "# Start the vector database service (Elasticsearch by default, Milvus also available)\n", "# This will run in the background (-d flag)\n", "!docker compose -f ../deploy/compose/vectordb.yaml up -d" ] @@ -182,8 +179,8 @@ "### 3. Setup NVIDIA Inference Microservices (NIMs)\n", "\n", "NIMs provide optimized inference for AI models. For multimodal RAG, we need:\n", - "- **VLM (Vision-Language Model)**: `nvidia/nemotron-nano-12b-v2-vl` for understanding images and generating responses\n", - "- **Embedding Model**: `llama-3.2-nemoretriever-1b-vlm-embed-v1` for creating multimodal embeddings" + "- **VLM (Vision-Language Model)**: `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning` for understanding images and generating responses\n", + "- **Embedding Model**: `llama-nemotron-embed-vl-1b-v2` for creating multimodal embeddings" ] }, { @@ -233,7 +230,7 @@ "# If the kernel times out, just rerun this cell - it will resume where it left off\n", "# Select a free GPU for VLM Microservice\n", "os.environ[\"VLM_MS_GPU_ID\"] = \"1\"\n", - "! USERID=$(id -u) docker compose --profile vlm-ingest --profile vlm-only -f ../deploy/compose/nims.yaml up -d" + "! USERID=$(id -u) docker compose --profile ingest --profile vlm-rag -f ../deploy/compose/nims.yaml up -d" ] }, { @@ -260,7 +257,7 @@ "# These settings tell the RAG server which models and endpoints to use\n", "\n", "# VLM (Vision-Language Model) configuration\n", - "os.environ[\"APP_VLM_MODELNAME\"] = \"nvidia/nemotron-nano-12b-v2-vl\"\n", + "os.environ[\"APP_VLM_MODELNAME\"] = \"nvidia/nemotron-3-nano-omni-30b-a3b-reasoning\"\n", "os.environ[\"APP_VLM_SERVERURL\"] = \"http://vlm-ms:8000/v1\"\n", "\n", "# Multimodal embedding model configuration\n", @@ -290,9 +287,9 @@ "import os\n", "\n", "# OCR and document processing endpoints - cloud hosted\n", - "os.environ[\"OCR_HTTP_ENDPOINT\"] = \"https://ai.api.nvidia.com/v1/cv/nvidia/nemoretriever-ocr\"\n", + "os.environ[\"OCR_HTTP_ENDPOINT\"] = \"https://ai.api.nvidia.com/v1/cv/nvidia/nemotron-ocr-v1\"\n", "os.environ[\"OCR_INFER_PROTOCOL\"] = \"http\"\n", - "os.environ[\"OCR_MODEL_NAME\"] = \"scene_text_ensemble\"\n", + "os.environ[\"OCR_MODEL_NAME\"] = \"pipeline\"\n", "os.environ[\"YOLOX_HTTP_ENDPOINT\"] = \"https://ai.api.nvidia.com/v1/cv/nvidia/nemotron-page-elements-v3\"\n", "os.environ[\"YOLOX_INFER_PROTOCOL\"] = \"http\"\n", "os.environ[\"YOLOX_GRAPHIC_ELEMENTS_HTTP_ENDPOINT\"] = \"https://ai.api.nvidia.com/v1/cv/nvidia/nemotron-graphic-elements-v1\"\n", @@ -302,7 +299,7 @@ "os.environ[\"APP_NVINGEST_CAPTIONENDPOINTURL\"] = \"https://integrate.api.nvidia.com/v1/chat/completions\"\n", "\n", "# VLM Model configuration - cloud hosted\n", - "os.environ[\"APP_VLM_MODELNAME\"] = \"nvidia/nemotron-nano-12b-v2-vl\"\n", + "os.environ[\"APP_VLM_MODELNAME\"] = \"nvidia/nemotron-3-nano-omni-30b-a3b-reasoning\"\n", "os.environ[\"APP_VLM_SERVERURL\"] = \"https://integrate.api.nvidia.com/v1\"\n", "os.environ[\"APP_LLM_SERVERURL\"] = \"\"\n", "\n", @@ -723,7 +720,6 @@ " \"use_knowledge_base\": True, # Search the vector database\n", " \"collection_names\": [collection_name], # Which collection to search\n", " \"vdb_top_k\": 5, # Retrieve top 5 results from vector DB\n", - " \"vdb_endpoint\": \"http://milvus:19530\", # Milvus connection string\n", " \"enable_reranker\": False, # Set to True for better relevance (slower)\n", " \"reranker_top_k\": 3, # If reranker enabled, return top 3\n", " \"filter_expr\": \"\", # Optional metadata filter\n", @@ -917,7 +913,6 @@ " \"max_tokens\": 1024, # Maximum response length\n", " \"reranker_top_k\": 2, # Keep top 2 results after reranking\n", " \"vdb_top_k\": 10, # Retrieve top 10 from vector DB initially\n", - " \"vdb_endpoint\": \"http://milvus:19530\", # Milvus connection\n", " \"collection_names\": [collection_name], # Which collection to search\n", " \"enable_query_rewriting\": True, # Improve query before searching\n", " \"enable_citations\": True, # Include source citations in response\n", @@ -941,7 +936,7 @@ "\n", "Congratulations! You've successfully:\n", "\n", - "✅ **Set up the infrastructure**: Deployed Milvus vector DB, NVIDIA NIMs, and RAG services \n", + "✅ **Set up the infrastructure**: Deployed vector database, NVIDIA NIMs, and RAG services \n", "✅ **Ingested multimodal documents**: Uploaded PDFs with images and extracted their content \n", "✅ **Created multimodal queries**: Combined text and images in your search queries \n", "✅ **Retrieved relevant context**: Used semantic search to find matching documents \n", @@ -984,7 +979,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.10" + "version": "3.12.10" } }, "nbformat": 4, diff --git a/notebooks/ingestion_api_usage.ipynb b/notebooks/ingestion_api_usage.ipynb index 241beed14..a78572b4b 100644 --- a/notebooks/ingestion_api_usage.ipynb +++ b/notebooks/ingestion_api_usage.ipynb @@ -7,7 +7,7 @@ "source": [ "# Ingestion API Usage\n", "\n", - "This notebook demonstrates how to interact with the ingestion APIs to upload and index documents for retrieval-augmented generation (RAG) applications. It showcases the different APIs needed to create a collection, upload documents to the created collection using Milvus Vector DB. It also showcases different APIs to manage uploaded documents and existing collections effectively." + "This notebook demonstrates how to interact with the ingestion APIs to upload and index documents for retrieval-augmented generation (RAG) applications. It showcases the different APIs needed to create a collection, upload documents to the created collection using Elasticsearch Vector DB. It also showcases different APIs to manage uploaded documents and existing collections effectively." ] }, { @@ -489,11 +489,19 @@ "\n", "await delete_collections(collection_names=[\"multimodal_data\"])" ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "42d22204", + "metadata": {}, + "outputs": [], + "source": [] } ], "metadata": { "kernelspec": { - "display_name": "Python 3 (ipykernel)", + "display_name": ".venv", "language": "python", "name": "python3" }, @@ -507,7 +515,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.11" + "version": "3.12.3" } }, "nbformat": 4, diff --git a/notebooks/langchain_nvidia_retriever.ipynb b/notebooks/langchain_nvidia_retriever.ipynb index 02b05b6db..8989d46f3 100644 --- a/notebooks/langchain_nvidia_retriever.ipynb +++ b/notebooks/langchain_nvidia_retriever.ipynb @@ -2,7 +2,6 @@ "cells": [ { "cell_type": "markdown", - "id": "303aa520", "metadata": {}, "source": [ "# NVIDIARAGRetriever Connector – LangChain Integration\n", @@ -26,25 +25,23 @@ "3. When ingestion is complete, return here and run the cells below.\n", "\n", "Ensure the **RAG server** (port 8081) is running. See [Get Started](https://github.com/NVIDIA-AI-Blueprints/rag/blob/main/docs/deploy-docker-self-hosted.md)." - ] + ], + "id": "303aa520" }, { "cell_type": "markdown", - "id": "c7a2a7dd", "metadata": {}, "source": [ "---\n", "## Setup" - ] + ], + "id": "c7a2a7dd" }, { "cell_type": "code", - "execution_count": null, - "id": "e6fe7153", "metadata": {}, - "outputs": [], "source": [ - "!pip install langchain-nvidia-ai-endpoints langchain-core\n", + "!pip install \"langchain-nvidia-ai-endpoints>=1.4.0\" langchain-core\n", "\n", "import os\n", "\n", @@ -56,18 +53,23 @@ "\n", "# Collection from ingestion_api_usage.ipynb (default: multimodal_data)\n", "COLLECTION_NAME = \"multimodal_data\"" - ] + ], + "execution_count": null, + "outputs": [], + "id": "e6fe7153" }, { "cell_type": "markdown", - "id": "640eee93", "metadata": {}, "source": [ "---\n", "## Retrieval with NVIDIARAGRetriever\n", "\n", - "The `NVIDIARAGRetriever` from `langchain-nvidia-ai-endpoints` connects to the NVIDIA RAG Blueprint `/v1/search` endpoint and returns LangChain `Document` objects. Use `COLLECTION_NAME` to match the collection you created in [ingestion_api_usage.ipynb](./ingestion_api_usage.ipynb)." - ] + "The `NVIDIARAGRetriever` from `langchain-nvidia-ai-endpoints` connects to the NVIDIA RAG Blueprint `/v1/search` endpoint and returns LangChain `Document` objects. Use `COLLECTION_NAME` to match the collection you created in [ingestion_api_usage.ipynb](./ingestion_api_usage.ipynb).\n", + "\n", + "By default the retriever does **not** send `vdb_endpoint`; the RAG server uses its configured vector store (for example Elasticsearch). Only set `vdb_endpoint` when you need to override that URL." + ], + "id": "640eee93" }, { "cell_type": "markdown", @@ -78,10 +80,7 @@ }, { "cell_type": "code", - "execution_count": null, - "id": "4b09138a", "metadata": {}, - "outputs": [], "source": [ "from langchain_nvidia_ai_endpoints import NVIDIARAGRetriever\n", "\n", @@ -104,7 +103,10 @@ " print(f\"Score: {score} | Source: {source}\")\n", " print(f\"Content: {content_preview}\")\n", " print()" - ] + ], + "execution_count": null, + "outputs": [], + "id": "4b09138a" }, { "cell_type": "markdown", @@ -115,21 +117,18 @@ }, { "cell_type": "markdown", - "id": "35d7f118", "metadata": {}, "source": [ "For details on retrieval parameters, filter expressions, and metadata:\n", - "- [Custom metadata & filter expressions](../docs/custom-metadata.md) – `filter_expr` syntax (Milvus), metadata schema\n", + "- [Custom metadata & filter expressions](../docs/custom-metadata.md) – `filter_expr` syntax (Elasticsearch), metadata schema\n", "- [Multi-turn & query rewriting](../docs/multiturn.md) – `enable_query_rewriting` for decontextualizing follow-up questions\n", "- [Retriever API usage](./retriever_api_usage.ipynb) – Search endpoint payload parameters" - ] + ], + "id": "35d7f118" }, { "cell_type": "code", - "execution_count": null, - "id": "6e5aa122", "metadata": {}, - "outputs": [], "source": [ "retriever_custom = NVIDIARAGRetriever(\n", " base_url=RAG_BASE_URL,\n", @@ -144,9 +143,9 @@ " enable_citations=True, # Include image/table/chart citations in metadata\n", " # Filtering\n", " confidence_threshold=0.0, # Min confidence (0.0-1.0, requires enable_reranker=True)\n", - " filter_expr=None, # Milvus filter expression, e.g. content_metadata['file_name'] == \"doc.pdf\"'\n", - " # Advanced\n", - " vdb_endpoint=\"http://milvus:19530\", # Vector DB endpoint (override if needed)\n", + " filter_expr=None, # Elasticsearch filter expression, e.g. [{\"term\": {\"metadata.content_metadata.file_name.keyword\": \"doc.pdf\"}}]\n", + " # Advanced (omit vdb_endpoint to use the RAG server's configured vector store)\n", + " # vdb_endpoint=\"http://elasticsearch:9200\",\n", " messages=[], # Conversation history for context-aware retrieval\n", " timeout=60.0, # HTTP request timeout in seconds\n", ")\n", @@ -155,7 +154,10 @@ "print(f\"Retrieved {len(docs)} documents\")\n", "for i, doc in enumerate(docs, 1):\n", " print(f\" {i}. {doc.metadata.get('document_name', 'N/A')} (score: {doc.metadata.get('score', 'N/A')})\")" - ] + ], + "execution_count": null, + "outputs": [], + "id": "6e5aa122" }, { "cell_type": "markdown", @@ -166,15 +168,15 @@ }, { "cell_type": "code", - "execution_count": null, "metadata": {}, - "outputs": [], "source": [ "docs = await retriever.ainvoke(\"What features or benefits are described?\")\n", "print(f\"Async retrieval: {len(docs)} documents\")\n", "for i, doc in enumerate(docs[:3], 1):\n", " print(f\" {i}. {doc.metadata.get('document_name', 'N/A')}\")" - ] + ], + "execution_count": null, + "outputs": [] }, { "cell_type": "markdown", @@ -185,10 +187,7 @@ }, { "cell_type": "code", - "execution_count": null, - "id": "bfcc9f22", "metadata": {}, - "outputs": [], "source": [ "from langchain_nvidia_ai_endpoints.retrievers import (\n", " NVIDIARAGConnectionError,\n", @@ -210,11 +209,13 @@ " print(f\"Server error ({e.status_code}): {e}\")\n", "\n", "print(\"\\nError handling works as expected.\")" - ] + ], + "execution_count": null, + "outputs": [], + "id": "bfcc9f22" }, { "cell_type": "markdown", - "id": "3b5824c6", "metadata": {}, "source": [ "---\n", @@ -223,14 +224,12 @@ "Chain `NVIDIARAGRetriever` with `ChatNVIDIA` for end-to-end question answering. Requires `NVIDIA_API_KEY` to call the NVIDIA API Catalog.\n", "\n", "**Get an API key:** See [Get an API Key](../docs/api-key.md) for instructions." - ] + ], + "id": "3b5824c6" }, { "cell_type": "code", - "execution_count": null, - "id": "d82a5d54", "metadata": {}, - "outputs": [], "source": [ "# Set NVIDIA_API_KEY if not already set (see ../docs/api-key.md to get a key)\n", "if not os.environ.get(\"NVIDIA_API_KEY\", \"\").startswith(\"nvapi-\"):\n", @@ -262,8 +261,8 @@ " (\"human\", \"{question}\"),\n", " ])\n", "\n", - " # Model aligned with rag-server default (nvidia/llama-3.3-nemotron-super-49b-v1.5)\n", - " llm = ChatNVIDIA(model=\"nvidia/llama-3.3-nemotron-super-49b-v1.5\")\n", + " # Model aligned with rag-server default (nvidia/nemotron-3-super-120b-a12b)\n", + " llm = ChatNVIDIA(model=\"nvidia/nemotron-3-super-120b-a12b\")\n", " chain = (\n", " {\"context\": retriever | format_docs, \"question\": RunnablePassthrough()}\n", " | prompt\n", @@ -275,28 +274,31 @@ " print(answer)\n", "else:\n", " print(\"NVIDIA_API_KEY not set. Set it (e.g. os.environ['NVIDIA_API_KEY'] = 'nvapi-...') or run this cell again to be prompted. See [Get an API Key](../docs/api-key.md)\")" - ] + ], + "execution_count": null, + "outputs": [], + "id": "d82a5d54" }, { "cell_type": "markdown", - "id": "4c95fdc6", "metadata": {}, "source": [ "---\n", "## Cleanup (Optional)\n", "\n", "To remove the collection and documents, use the delete cells in [ingestion_api_usage.ipynb](./ingestion_api_usage.ipynb) (sections 7 and 9)." - ] + ], + "id": "4c95fdc6" }, { "cell_type": "code", - "execution_count": null, "metadata": {}, - "outputs": [], "source": [ "# Use ingestion_api_usage.ipynb sections 7 (Delete Documents) and 9 (Delete Collections)\n", "# to remove the multimodal_data collection when finished." - ] + ], + "execution_count": null, + "outputs": [] } ], "metadata": { @@ -312,4 +314,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} +} \ No newline at end of file diff --git a/notebooks/launchable.ipynb b/notebooks/launchable.ipynb index 6ee128671..4bead45ac 100644 --- a/notebooks/launchable.ipynb +++ b/notebooks/launchable.ipynb @@ -37,8 +37,8 @@ "\n", "- **GPU Required**: NVIDIA GPU(s) with 80GB+ VRAM total\n", "- **API Key Required**: Get one from https://org.ngc.nvidia.com/setup/api-keys\n", - "- **Disk Space**: ~50GB for model cache\n", - "- **Memory**: 32GB+ RAM recommended" + "- **Disk Space**: 200GB+ free for Docker images, NIM model cache, vector database data, logs, and temporary files\n", + "- **Memory**: Docker daemon memory must cover the selected local NIM services; the requirements check calculates this from the launchable service set" ] }, { @@ -110,9 +110,11 @@ "import asyncio\n", "import aiohttp\n", "import base64\n", + "import re\n", "from typing import List, Optional\n", "from pathlib import Path\n", "from IPython.display import display, Image as IPImage\n", + "from dotenv import load_dotenv\n", "\n", "\n", "# =============================================================================\n", @@ -122,20 +124,75 @@ "IPADDRESS = \"0.0.0.0\"\n", "RAG_SERVER_PORT = \"8081\"\n", "INGESTOR_SERVER_PORT = \"8082\"\n", - "MILVUS_ENDPOINT = \"http://milvus:19530\"\n", + "ELASTICSEARCH_ENDPOINT = \"http://elasticsearch:9200\"\n", "\n", "RAG_BASE_URL = f\"http://{IPADDRESS}:{RAG_SERVER_PORT}\"\n", "INGESTOR_BASE_URL = f\"http://{IPADDRESS}:{INGESTOR_SERVER_PORT}\"\n", - "\n", - "# NIM services to deploy (excludes nim-llm and vlm-ms since we use NVIDIA-hosted endpoints)\n", + "ENV_FILE = Path(\"deploy/compose/.env\")\n", + "LAUNCHABLE_ENV_BEGIN = \"# >>> launchable.ipynb generated env >>>\"\n", + "LAUNCHABLE_ENV_END = \"# <<< launchable.ipynb generated env <<<\"\n", + "LAUNCHABLE_ENV_VALUES = {}\n", + "\n", + "# NIM services to deploy for the launchable default path.\n", + "# This intentionally excludes nim-llm and vlm-ms because the notebook configures\n", + "# hosted NVIDIA endpoints for generation. Starting all default nims.yaml services\n", + "# would try to launch the large local LLM and can exhaust Docker/GPU memory.\n", "NIM_SERVICES = (\n", - " \"nemotron-embedding-ms \"\n", - " \"nemotron-ranking-ms \"\n", - " \"page-elements \"\n", - " \"graphic-elements \"\n", - " \"table-structure \"\n", - " \"nemoretriever-ocr\"\n", + " \"nemotron-embedding-ms\",\n", + " \"nemotron-ranking-ms\",\n", + " \"page-elements\",\n", + " \"graphic-elements\",\n", + " \"table-structure\",\n", + " \"nemotron-ocr\",\n", ")\n", + "NIM_SERVICE_ARGS = \" \".join(NIM_SERVICES)\n", + "# `nemotron-embedding-ms` lives under the `text-embed` profile in nims.yaml,\n", + "# so the docker compose calls below must pass that profile to start it.\n", + "NIM_COMPOSE_PROFILE = \"text-embed\"\n", + "\n", + "\n", + "def _quote_env_value(value: str) -> str:\n", + " return '\"' + str(value).replace('\\\\', '\\\\\\\\').replace('\"', '\\\\\"') + '\"'\n", + "\n", + "\n", + "def write_launchable_env(updates: dict[str, str], reset: bool = False) -> None:\n", + " \"\"\"Write an idempotent launchable env block and refresh os.environ.\n", + "\n", + " The notebook can be run repeatedly in the same checkout. Keeping all values\n", + " in one generated block prevents stale optional settings such as\n", + " ENABLE_VLM_INFERENCE=true from carrying into a later default deployment.\n", + " \"\"\"\n", + " global LAUNCHABLE_ENV_VALUES\n", + " if reset:\n", + " LAUNCHABLE_ENV_VALUES = {}\n", + "\n", + " LAUNCHABLE_ENV_VALUES.update({key: str(value) for key, value in updates.items()})\n", + " ENV_FILE.parent.mkdir(parents=True, exist_ok=True)\n", + " existing = ENV_FILE.read_text(encoding=\"utf-8\") if ENV_FILE.exists() else \"\"\n", + " block_pattern = re.compile(\n", + " rf\"\\n?{re.escape(LAUNCHABLE_ENV_BEGIN)}.*?{re.escape(LAUNCHABLE_ENV_END)}\\n?\",\n", + " flags=re.DOTALL,\n", + " )\n", + " cleaned = block_pattern.sub(\"\\n\", existing).rstrip()\n", + "\n", + " lines = [LAUNCHABLE_ENV_BEGIN]\n", + " for key in sorted(LAUNCHABLE_ENV_VALUES):\n", + " value = LAUNCHABLE_ENV_VALUES[key]\n", + " lines.append(f\"export {key}={_quote_env_value(value)}\")\n", + " os.environ[key] = value\n", + " lines.append(LAUNCHABLE_ENV_END)\n", + "\n", + " ENV_FILE.write_text(\n", + " (cleaned + \"\\n\\n\" if cleaned else \"\") + \"\\n\".join(lines) + \"\\n\",\n", + " encoding=\"utf-8\",\n", + " )\n", + " load_dotenv(ENV_FILE, override=True)\n", + "\n", + "\n", + "def refresh_launchable_env() -> None:\n", + " \"\"\"Load deploy/compose/.env with notebook-generated values taking precedence.\"\"\"\n", + " if ENV_FILE.exists():\n", + " load_dotenv(ENV_FILE, override=True)\n", "\n", "\n", "# =============================================================================\n", @@ -240,7 +297,7 @@ " \"\"\"Create a new collection in the vector database.\"\"\"\n", " url = f\"{INGESTOR_BASE_URL}/v1/collections\"\n", " params = {\n", - " \"vdb_endpoint\": MILVUS_ENDPOINT,\n", + " \"vdb_endpoint\": ELASTICSEARCH_ENDPOINT,\n", " \"collection_type\": collection_type\n", " }\n", " async with aiohttp.ClientSession() as session:\n", @@ -260,7 +317,7 @@ " \"\"\"List all vector database collections.\"\"\"\n", " result = await api_get(\n", " f\"{INGESTOR_BASE_URL}/v1/collections\",\n", - " {\"vdb_endpoint\": MILVUS_ENDPOINT}\n", + " {\"vdb_endpoint\": ELASTICSEARCH_ENDPOINT}\n", " )\n", " collections = result.get(\"collections\", [])\n", " print(f\"Collections ({len(collections)}):\")\n", @@ -272,7 +329,7 @@ "async def delete_collection(name: str):\n", " \"\"\"Delete a collection.\"\"\"\n", " async with aiohttp.ClientSession() as session:\n", - " params = {\"vdb_endpoint\": MILVUS_ENDPOINT, \"collection_names\": [name]}\n", + " params = {\"vdb_endpoint\": ELASTICSEARCH_ENDPOINT, \"collection_names\": [name]}\n", " async with session.delete(\n", " f\"{INGESTOR_BASE_URL}/v1/collections\", params=params\n", " ) as resp:\n", @@ -313,7 +370,7 @@ " \n", " # Configure upload parameters\n", " data = {\n", - " \"vdb_endpoint\": MILVUS_ENDPOINT,\n", + " \"vdb_endpoint\": ELASTICSEARCH_ENDPOINT,\n", " \"collection_name\": collection,\n", " \"split_options\": {\n", " \"chunk_size\": 1024,\n", @@ -362,7 +419,7 @@ " \"\"\"List documents in collection.\"\"\"\n", " result = await api_get(\n", " f\"{INGESTOR_BASE_URL}/v1/documents\",\n", - " {\"collection_name\": collection, \"vdb_endpoint\": MILVUS_ENDPOINT}\n", + " {\"collection_name\": collection, \"vdb_endpoint\": ELASTICSEARCH_ENDPOINT}\n", " )\n", " docs = result.get(\"documents\", [])\n", " print(f\"Documents in '{collection}' ({len(docs)}):\")\n", @@ -377,7 +434,7 @@ " filenames = [filenames]\n", " \n", " async with aiohttp.ClientSession() as session:\n", - " params = {\"collection_name\": collection, \"vdb_endpoint\": MILVUS_ENDPOINT}\n", + " params = {\"collection_name\": collection, \"vdb_endpoint\": ELASTICSEARCH_ENDPOINT}\n", " async with session.delete(\n", " f\"{INGESTOR_BASE_URL}/v1/documents\",\n", " params=params,\n", @@ -411,7 +468,7 @@ " \"use_knowledge_base\": use_rag,\n", " \"collection_names\": [collection] if use_rag else [],\n", " \"temperature\": 0.2,\n", - " \"model\": \"nvidia/llama-3.3-nemotron-super-49b-v1.5\"\n", + " \"model\": \"nvidia/nemotron-3-super-120b-a12b\"\n", " }\n", "\n", " async with aiohttp.ClientSession() as session:\n", @@ -526,13 +583,31 @@ "\n", "def deploy_all():\n", " \"\"\"Start all RAG Blueprint services.\"\"\"\n", + " if globals().get(\"SYSTEM_REQUIREMENTS_OK\") is False:\n", + " raise RuntimeError(\n", + " \"System requirements check failed. Fix the reported errors before deploying.\"\n", + " )\n", + "\n", + " refresh_launchable_env()\n", + "\n", " print(\"=\" * 60)\n", " print(\"DEPLOYING NVIDIA RAG BLUEPRINT\")\n", " print(\"=\" * 60)\n", " \n", " print(\"\\n[1/4] NIM Microservices...\")\n", - " docker_compose(\"deploy/compose/nims.yaml\", \"pull\", \"-q\")\n", - " docker_compose(\"deploy/compose/nims.yaml\", \"up\", \"-d\")\n", + " print(f\"Starting launchable NIM services: {NIM_SERVICE_ARGS}\")\n", + " docker_compose(\n", + " \"deploy/compose/nims.yaml\",\n", + " \"pull\",\n", + " f\"-q {NIM_SERVICE_ARGS}\",\n", + " profile=NIM_COMPOSE_PROFILE,\n", + " )\n", + " docker_compose(\n", + " \"deploy/compose/nims.yaml\",\n", + " \"up\",\n", + " f\"-d {NIM_SERVICE_ARGS}\",\n", + " profile=NIM_COMPOSE_PROFILE,\n", + " )\n", " print(\"-\" * 60)\n", " print(\"\\n[2/4] Vector Database...\")\n", " docker_compose(\"deploy/compose/vectordb.yaml\", \"pull\", \"-q\")\n", @@ -568,7 +643,7 @@ " docker_compose(\"deploy/compose/vectordb.yaml\", \"down\")\n", " \n", " print(\"\\n[4/4] Stopping NIM Containers...\")\n", - " docker_compose(\"deploy/compose/nims.yaml\", \"down\")\n", + " docker_compose(\"deploy/compose/nims.yaml\", \"down\", profile=NIM_COMPOSE_PROFILE)\n", " \n", " print(\"\\n\" + \"=\" * 60)\n", " print(\"✅ ALL SERVICES STOPPED\")\n", @@ -619,9 +694,76 @@ "metadata": {}, "outputs": [], "source": [ + "import os\n", "import subprocess\n", "import re\n", "import shutil\n", + "from pathlib import Path\n", + "\n", + "MIN_SYSTEM_MEMORY_GIB = 32\n", + "LAUNCHABLE_NIM_SHM_GIB_BY_SERVICE = {\n", + " \"nemotron-embedding-ms\": 16,\n", + " \"nemotron-ranking-ms\": 16,\n", + " \"page-elements\": 16,\n", + " \"graphic-elements\": 16,\n", + " \"table-structure\": 16,\n", + " \"nemotron-ocr\": 16,\n", + "}\n", + "LAUNCHABLE_NIM_SHM_GIB = sum(LAUNCHABLE_NIM_SHM_GIB_BY_SERVICE.values())\n", + "MIN_DOCKER_MEMORY_OVERHEAD_GIB = 16\n", + "MIN_DOCKER_MEMORY_GIB = LAUNCHABLE_NIM_SHM_GIB + MIN_DOCKER_MEMORY_OVERHEAD_GIB\n", + "MIN_GPU_VRAM_GIB = 75\n", + "MIN_TOTAL_DISK_FREE_GIB = 200\n", + "MIN_DOCKER_IMAGE_DISK_GIB = 150\n", + "MIN_MODEL_CACHE_DISK_GIB = 150\n", + "\n", + "\n", + "def bytes_to_gib(value: int) -> float:\n", + " return value / (1024 ** 3)\n", + "\n", + "\n", + "def mib_to_gib(value: int) -> float:\n", + " return value / 1024\n", + "\n", + "\n", + "def existing_path(path: Path) -> Path:\n", + " path = path.expanduser()\n", + " while not path.exists() and path != path.parent:\n", + " path = path.parent\n", + " return path\n", + "\n", + "\n", + "def free_disk_gib(path: Path) -> float:\n", + " return bytes_to_gib(shutil.disk_usage(existing_path(path)).free)\n", + "\n", + "def print_docker_relocation_steps():\n", + " \"\"\"Print manual steps for moving Docker's data-root to a larger disk.\"\"\"\n", + " print(\"\")\n", + " print(\" ─────────────────────────────────────────────────────────────────\")\n", + " print(\" 📦 HOW TO RELOCATE DOCKER'S DATA-ROOT TO A LARGER DISK\")\n", + " print(\" ─────────────────────────────────────────────────────────────────\")\n", + " print(\" 1. Identify a mount with sufficient free space:\")\n", + " print(\" df -h\")\n", + " print(\" 2. Stop Docker and containerd:\")\n", + " print(\" sudo systemctl stop docker docker.socket containerd\")\n", + " print(\" 3. Add the new data-root to /etc/docker/daemon.json (preserves existing keys):\")\n", + " print(\" sudo mkdir -p /ephemeral/docker\")\n", + " print(\" sudo python3 -c \\\"import json, pathlib; p=pathlib.Path('/etc/docker/daemon.json'); d=json.loads(p.read_text()) if p.exists() and p.read_text().strip() else {}; d['data-root']='/ephemeral/docker'; p.parent.mkdir(parents=True, exist_ok=True); p.write_text(json.dumps(d, indent=2))\\\"\")\n", + " print(\" 4. Move existing data (skip if /var/lib/docker is empty):\")\n", + " print(\" sudo rsync -a /var/lib/docker/ /ephemeral/docker/\")\n", + " print(\" sudo mv /var/lib/docker /var/lib/docker.old\")\n", + " print(\" 5. Restart services and verify the new data-root:\")\n", + " print(\" sudo systemctl start containerd docker\")\n", + " print(\" docker info | grep 'Docker Root Dir'\")\n", + " print(\" After confirming images pull correctly, reclaim old space:\")\n", + " print(\" sudo rm -rf /var/lib/docker.old\")\n", + " print(\" ─────────────────────────────────────────────────────────────────\")\n", + "\n", + "\n", + "\n", + "def same_filesystem(first: Path, second: Path) -> bool:\n", + " return os.stat(existing_path(first)).st_dev == os.stat(existing_path(second)).st_dev\n", + "\n", "\n", "print(\"=\" * 70)\n", "print(\"SYSTEM REQUIREMENTS CHECK\")\n", @@ -659,9 +801,9 @@ " warnings.append(\"Non-Linux system detected\")\n", "\n", "# ─────────────────────────────────────────────────────────────────────────────\n", - "# [2] NVIDIA GPU\n", + "# [2] NVIDIA GPU and VRAM\n", "# ─────────────────────────────────────────────────────────────────────────────\n", - "print(\"\\n[2] NVIDIA GPU:\")\n", + "print(\"\\n[2] NVIDIA GPU and VRAM:\")\n", "try:\n", " result = subprocess.run([\"nvidia-smi\", \"-L\"], capture_output=True, text=True)\n", " if result.returncode == 0 and result.stdout.strip():\n", @@ -669,6 +811,38 @@ " for gpu in gpus:\n", " print(f\" {gpu}\")\n", " print(f\" ✅ PASS ({len(gpus)} GPU(s) detected)\")\n", + "\n", + " vram_result = subprocess.run(\n", + " [\n", + " \"nvidia-smi\",\n", + " \"--query-gpu=name,memory.total\",\n", + " \"--format=csv,noheader,nounits\",\n", + " ],\n", + " capture_output=True,\n", + " text=True,\n", + " )\n", + " if vram_result.returncode == 0 and vram_result.stdout.strip():\n", + " total_vram_mib = 0\n", + " for line in vram_result.stdout.strip().splitlines():\n", + " name, memory_mib = line.rsplit(\",\", 1)\n", + " memory_mib = int(memory_mib.strip())\n", + " total_vram_mib += memory_mib\n", + " print(f\" {name.strip()}: {mib_to_gib(memory_mib):.1f} GiB VRAM\")\n", + " total_vram_gib = mib_to_gib(total_vram_mib)\n", + " if total_vram_gib >= MIN_GPU_VRAM_GIB:\n", + " print(f\" ✅ PASS ({total_vram_gib:.1f} GiB total VRAM)\")\n", + " else:\n", + " errors.append(\n", + " f\"Only {total_vram_gib:.1f} GiB total GPU VRAM; \"\n", + " f\"need {MIN_GPU_VRAM_GIB}+ GiB\"\n", + " )\n", + " print(\n", + " f\" ❌ FAIL: {total_vram_gib:.1f} GiB < \"\n", + " f\"{MIN_GPU_VRAM_GIB} GiB required\"\n", + " )\n", + " else:\n", + " warnings.append(\"Unable to determine GPU VRAM\")\n", + " print(\" ⚠️ WARNING: Unable to determine GPU VRAM\")\n", " else:\n", " errors.append(\"No NVIDIA GPU detected\")\n", " print(\" ❌ FAIL: No NVIDIA GPU detected\")\n", @@ -742,51 +916,161 @@ "# [6] Docker Compose Version (need 2.29.1+)\n", "# ─────────────────────────────────────────────────────────────────────────────\n", "print(\"\\n[6] Docker Compose Version (need 2.29.1+):\")\n", - "result = subprocess.run([\"docker\", \"compose\", \"version\"], capture_output=True, text=True)\n", - "if result.returncode == 0:\n", - " print(f\" {result.stdout.strip()}\")\n", - " match = re.search(r\"v?(\\d+)\\.(\\d+)\", result.stdout)\n", - " if match:\n", - " major, minor = int(match.group(1)), int(match.group(2))\n", - " if major > 2 or (major == 2 and minor >= 29):\n", - " print(\" ✅ PASS\")\n", - " else:\n", - " errors.append(f\"Docker Compose {major}.{minor} < 2.29.1 required\")\n", - " print(f\" ❌ FAIL: Version {major}.{minor} < 2.29.1\")\n", + "if shutil.which(\"docker\"):\n", + " result = subprocess.run([\"docker\", \"compose\", \"version\"], capture_output=True, text=True)\n", + " if result.returncode == 0:\n", + " print(f\" {result.stdout.strip()}\")\n", + " match = re.search(r\"v?(\\d+)\\.(\\d+)\", result.stdout)\n", + " if match:\n", + " major, minor = int(match.group(1)), int(match.group(2))\n", + " if major > 2 or (major == 2 and minor >= 29):\n", + " print(\" ✅ PASS\")\n", + " else:\n", + " errors.append(f\"Docker Compose {major}.{minor} < 2.29.1 required\")\n", + " print(f\" ❌ FAIL: Version {major}.{minor} < 2.29.1\")\n", + " else:\n", + " errors.append(\"Docker Compose not available\")\n", + " print(\" ❌ FAIL: Docker Compose not available\")\n", "else:\n", - " errors.append(\"Docker Compose not available\")\n", - " print(\" ❌ FAIL: Docker Compose not available\")\n", + " print(\" ❌ FAIL: Docker not installed\")\n", "\n", "# ─────────────────────────────────────────────────────────────────────────────\n", - "# [7] System Memory (need 32GB+)\n", + "# [7] Host System Memory (need 32 GiB+)\n", "# ─────────────────────────────────────────────────────────────────────────────\n", - "print(\"\\n[7] System Memory (need 32GB+):\")\n", + "print(\"\\n[7] Host System Memory (need 32 GiB+):\")\n", "try:\n", - " result = subprocess.run([\"free\", \"-g\"], capture_output=True, text=True)\n", - " if result.returncode == 0:\n", - " lines = result.stdout.strip().split(\"\\n\")\n", - " for line in lines[:2]:\n", - " print(f\" {line}\")\n", - " # Parse total memory\n", - " match = re.search(r\"Mem:\\s+(\\d+)\", result.stdout)\n", - " if match:\n", - " total_gb = int(match.group(1))\n", - " if total_gb >= 32:\n", - " print(f\" ✅ PASS ({total_gb}GB available)\")\n", - " elif total_gb >= 16:\n", - " warnings.append(f\"Only {total_gb}GB RAM, 32GB+ recommended\")\n", - " print(f\" ⚠️ WARNING: {total_gb}GB < 32GB recommended\")\n", - " else:\n", - " errors.append(f\"Only {total_gb}GB RAM, need 32GB+ for full deployment\")\n", - " print(f\" ❌ FAIL: {total_gb}GB < 32GB minimum\")\n", + " meminfo = {}\n", + " with open(\"/proc/meminfo\", \"r\", encoding=\"utf-8\") as file:\n", + " for line in file:\n", + " key, value = line.split(\":\", 1)\n", + " meminfo[key] = int(value.strip().split()[0]) * 1024\n", + " total_gib = bytes_to_gib(meminfo.get(\"MemTotal\", 0))\n", + " available_gib = bytes_to_gib(meminfo.get(\"MemAvailable\", 0))\n", + " print(f\" Total: {total_gib:.1f} GiB\")\n", + " print(f\" Available: {available_gib:.1f} GiB\")\n", + " if total_gib >= MIN_SYSTEM_MEMORY_GIB:\n", + " print(f\" ✅ PASS ({total_gib:.1f} GiB total)\")\n", + " elif total_gib >= 16:\n", + " warnings.append(f\"Only {total_gib:.1f} GiB host RAM, 32 GiB+ recommended\")\n", + " print(f\" ⚠️ WARNING: {total_gib:.1f} GiB < 32 GiB recommended\")\n", + " else:\n", + " errors.append(\n", + " f\"Only {total_gib:.1f} GiB host RAM; \"\n", + " f\"need {MIN_SYSTEM_MEMORY_GIB}+ GiB for deployment\"\n", + " )\n", + " print(f\" ❌ FAIL: {total_gib:.1f} GiB < 32 GiB minimum\")\n", "except FileNotFoundError:\n", " print(\" Unable to check (non-Linux system)\")\n", "\n", "# ─────────────────────────────────────────────────────────────────────────────\n", + "# [8] Docker Daemon Memory\n", + "# ─────────────────────────────────────────────────────────────────────────────\n", + "print(f\"\\n[8] Docker Daemon Memory (need {MIN_DOCKER_MEMORY_GIB} GiB+):\")\n", + "print(f\" Launchable NIM shared-memory configuration: {LAUNCHABLE_NIM_SHM_GIB} GiB\")\n", + "print(f\" Added overhead budget: {MIN_DOCKER_MEMORY_OVERHEAD_GIB} GiB\")\n", + "if shutil.which(\"docker\"):\n", + " result = subprocess.run(\n", + " [\"docker\", \"info\", \"--format\", \"{{.MemTotal}}\"],\n", + " capture_output=True,\n", + " text=True,\n", + " )\n", + " docker_mem_text = result.stdout.strip()\n", + " if result.returncode == 0 and docker_mem_text.isdigit():\n", + " docker_memory_gib = bytes_to_gib(int(docker_mem_text))\n", + " print(f\" Docker daemon memory: {docker_memory_gib:.1f} GiB\")\n", + " if docker_memory_gib >= MIN_DOCKER_MEMORY_GIB:\n", + " print(f\" ✅ PASS ({docker_memory_gib:.1f} GiB available to Docker)\")\n", + " else:\n", + " errors.append(\n", + " f\"Docker daemon has only {docker_memory_gib:.1f} GiB memory; \"\n", + " f\"increase Docker Desktop/daemon memory to {MIN_DOCKER_MEMORY_GIB}+ GiB \"\n", + " \"or reduce the local NIM service set\"\n", + " )\n", + " print(\n", + " f\" ❌ FAIL: Docker daemon memory {docker_memory_gib:.1f} GiB < \"\n", + " f\"{MIN_DOCKER_MEMORY_GIB} GiB required\"\n", + " )\n", + " else:\n", + " errors.append(\"Docker daemon is unavailable or did not report memory\")\n", + " print(\" ❌ FAIL: Docker daemon unavailable or memory unknown\")\n", + "else:\n", + " print(\" ❌ FAIL: Docker not installed\")\n", + "\n", + "# ─────────────────────────────────────────────────────────────────────────────\n", + "# [9] Disk Space for Docker Images and Model Cache\n", + "# ─────────────────────────────────────────────────────────────────────────────\n", + "print(f\"\\n[9] Disk Space (need {MIN_TOTAL_DISK_FREE_GIB} GiB+ total free):\")\n", + "docker_root = None\n", + "if shutil.which(\"docker\"):\n", + " result = subprocess.run(\n", + " [\"docker\", \"info\", \"--format\", \"{{.DockerRootDir}}\"],\n", + " capture_output=True,\n", + " text=True,\n", + " )\n", + " if result.returncode == 0 and result.stdout.strip():\n", + " docker_root = Path(result.stdout.strip())\n", + " print(f\" Docker root: {docker_root}\")\n", + " else:\n", + " errors.append(\"Docker root directory unavailable; cannot verify image-pull disk space\")\n", + " print(\" ❌ FAIL: Docker root directory unavailable\")\n", + "else:\n", + " print(\" ❌ FAIL: Docker not installed\")\n", + "\n", + "model_cache_dir = Path(os.environ.get(\"MODEL_DIRECTORY\", \"~/.cache/model-cache\")).expanduser()\n", + "model_cache_dir.mkdir(parents=True, exist_ok=True)\n", + "print(f\" Model cache: {model_cache_dir}\")\n", + "\n", + "if docker_root is not None:\n", + " docker_free_gib = free_disk_gib(docker_root)\n", + " model_cache_free_gib = free_disk_gib(model_cache_dir)\n", + " print(f\" Docker filesystem free: {docker_free_gib:.1f} GiB\")\n", + " print(f\" Model-cache filesystem free: {model_cache_free_gib:.1f} GiB\")\n", + " if same_filesystem(docker_root, model_cache_dir):\n", + " if docker_free_gib >= MIN_TOTAL_DISK_FREE_GIB:\n", + " print(f\" ✅ PASS ({docker_free_gib:.1f} GiB free for images + model cache)\")\n", + " else:\n", + " errors.append(\n", + " f\"Only {docker_free_gib:.1f} GiB free on the shared Docker/model-cache filesystem; \"\n", + " f\"need {MIN_TOTAL_DISK_FREE_GIB}+ GiB before pulling images and caching models\"\n", + " )\n", + " print(\n", + " f\" ❌ FAIL: {docker_free_gib:.1f} GiB < \"\n", + " f\"{MIN_TOTAL_DISK_FREE_GIB} GiB required\"\n", + " )\n", + " print_docker_relocation_steps()\n", + " else:\n", + " disk_ok = True\n", + " if docker_free_gib < MIN_DOCKER_IMAGE_DISK_GIB:\n", + " disk_ok = False\n", + " errors.append(\n", + " f\"Only {docker_free_gib:.1f} GiB free for Docker images; \"\n", + " f\"need {MIN_DOCKER_IMAGE_DISK_GIB}+ GiB\"\n", + " )\n", + " print(\n", + " f\" ❌ FAIL: Docker image space {docker_free_gib:.1f} GiB < \"\n", + " f\"{MIN_DOCKER_IMAGE_DISK_GIB} GiB required\"\n", + " )\n", + " print_docker_relocation_steps()\n", + " if model_cache_free_gib < MIN_MODEL_CACHE_DISK_GIB:\n", + " disk_ok = False\n", + " errors.append(\n", + " f\"Only {model_cache_free_gib:.1f} GiB free for NIM model cache; \"\n", + " f\"need {MIN_MODEL_CACHE_DISK_GIB}+ GiB\"\n", + " )\n", + " print(\n", + " f\" ❌ FAIL: model-cache space {model_cache_free_gib:.1f} GiB < \"\n", + " f\"{MIN_MODEL_CACHE_DISK_GIB} GiB required\"\n", + " )\n", + " if disk_ok:\n", + " print(\" ✅ PASS (separate Docker and model-cache filesystems have enough space)\")\n", + "\n", + "# ─────────────────────────────────────────────────────────────────────────────\n", "# SUMMARY\n", "# ─────────────────────────────────────────────────────────────────────────────\n", "print(\"\\n\" + \"=\" * 70)\n", "\n", + "SYSTEM_REQUIREMENTS_OK = not errors\n", + "\n", "if errors:\n", " print(\"\\n\" + \"🚨\" * 35)\n", " print(\"🚨\" + \" \" * 66 + \"🚨\")\n", @@ -804,6 +1088,7 @@ " print(\"Please fix the above errors before continuing.\")\n", " print(\"See: https://docs.nvidia.com/rag/latest/support-matrix.html\")\n", " print(\"=\" * 70)\n", + " raise RuntimeError(\"System requirements not met; fix the errors above before deploying.\")\n", "elif warnings:\n", " print(\"\\n✅ REQUIREMENTS MET (with warnings)\")\n", " print(\"\\n⚠️ WARNINGS ({} issues):\".format(len(warnings)))\n", @@ -924,8 +1209,8 @@ "import subprocess\n", "\n", "REPO_URL = \"https://github.com/NVIDIA-AI-Blueprints/rag.git\"\n", - "BRANCH = \"release-v2.5.1\"\n", - "#BRANCH = \"develop\"\n", + "#BRANCH = \"release-v2.5.0\"\n", + "BRANCH = \"develop\"\n", "# Check if we're already in the rag repo (look for deploy/compose)\n", "if os.path.exists(\"deploy/compose\"):\n", " print(f\"✅ Already in rag repo: {os.getcwd()}\")\n", @@ -959,33 +1244,54 @@ "outputs": [], "source": [ "import subprocess\n", - "from dotenv import load_dotenv\n", "\n", "# Login to NGC container registry\n", "!echo \"${NGC_API_KEY}\" | docker login nvcr.io -u '$oauthtoken' --password-stdin\n", "\n", "# Set user ID for Docker volume permissions\n", - "os.environ[\"USERID\"] = subprocess.check_output(\"id -u\", shell=True).decode().strip()\n", + "user_id = subprocess.check_output(\"id -u\", shell=True).decode().strip()\n", + "os.environ[\"USERID\"] = user_id\n", "\n", "# Create model cache directory\n", "model_cache = os.path.expanduser(\"~/.cache/model-cache\")\n", "os.makedirs(model_cache, exist_ok=True)\n", "os.environ[\"MODEL_DIRECTORY\"] = model_cache\n", "\n", - "# Configure to use NVIDIA-hosted LLM (reduces local GPU requirements)\n", - "env_config = '''\n", - "export APP_LLM_SERVERURL=\"\"\n", - "export SUMMARY_LLM_SERVERURL=\"\"\n", - "export SUMMARY_LLM=\"nvidia/llama-3.3-nemotron-super-49b-v1.5\"\n", - "export APP_LLM_MODELNAME=\"nvidia/llama-3.3-nemotron-super-49b-v1.5\"\n", - "export APP_FILTEREXPRESSIONGENERATOR_MODELNAME=\"nvidia/llama-3.3-nemotron-super-49b-v1.5\"\n", - "export APP_QUERYREWRITER_MODELNAME=\"nvidia/llama-3.3-nemotron-super-49b-v1.5\"\n", - "'''\n", - "\n", - "with open(\"deploy/compose/.env\", \"a\") as f:\n", - " f.write(env_config)\n", - "\n", - "print(f\"✅ Base LLM configuration written\")\n", + "# Configure the launchable default path to use NVIDIA-hosted LLM endpoints.\n", + "# Reset optional feature toggles here so re-running the notebook starts from the\n", + "# standard LLM pipeline unless the optional VLM/reasoning cells are run again.\n", + "base_env = {\n", + " \"USERID\": user_id,\n", + " \"MODEL_DIRECTORY\": model_cache,\n", + " \"PROMPT_CONFIG_FILE\": str(Path(\"src/nvidia_rag/rag_server/prompt.yaml\").resolve()),\n", + " \"APP_LLM_SERVERURL\": \"\",\n", + " \"SUMMARY_LLM_SERVERURL\": \"\",\n", + " \"SUMMARY_LLM\": \"nvidia/nemotron-3-super-120b-a12b\",\n", + " \"APP_LLM_MODELNAME\": \"nvidia/nemotron-3-super-120b-a12b\",\n", + " \"APP_FILTEREXPRESSIONGENERATOR_MODELNAME\": \"nvidia/nemotron-3-super-120b-a12b\",\n", + " \"APP_FILTEREXPRESSIONGENERATOR_SERVERURL\": \"\",\n", + " \"APP_QUERYREWRITER_MODELNAME\": \"nvidia/nemotron-3-super-120b-a12b\",\n", + " \"APP_QUERYREWRITER_SERVERURL\": \"\",\n", + " # Use the text embedding model (overrides the VLM embedding default in nvdev.env).\n", + " \"APP_EMBEDDINGS_MODELNAME\": \"nvidia/llama-nemotron-embed-1b-v2\",\n", + " \"APP_EMBEDDINGS_SERVERURL\": \"nemotron-embedding-ms:8000/v1\",\n", + " \"ENABLE_VLM_INFERENCE\": \"false\",\n", + " \"VLM_TO_LLM_FALLBACK\": \"true\",\n", + " \"APP_NVINGEST_EXTRACTIMAGES\": \"False\",\n", + " \"LLM_ENABLE_THINKING\": \"false\",\n", + " \"LLM_REASONING_BUDGET\": \"0\",\n", + " \"LLM_LOW_EFFORT\": \"false\",\n", + " \"FILTER_THINK_TOKENS\": \"true\",\n", + " \"APP_VLM_ENABLE_THINKING\": \"false\",\n", + " \"APP_VLM_THINKING_TOKEN_BUDGET\": \"0\",\n", + " \"VLM_FILTER_THINK_TOKENS\": \"true\",\n", + "}\n", + "\n", + "write_launchable_env(base_env, reset=True)\n", + "\n", + "print(\"✅ Base launchable configuration written\")\n", + "print(\" Pipeline: standard LLM generation (VLM inference disabled)\")\n", + "print(\" Embeddings: text model (nvidia/llama-nemotron-embed-1b-v2)\")\n", "print(f\" Model cache: {model_cache}\")" ] }, @@ -1008,20 +1314,19 @@ "metadata": {}, "outputs": [], "source": [ - "# Enable saving extracted content to disk\n", - "save_config = '''\n", - "export APP_NVINGEST_SAVETODISK=True\n", - "# Set host directory path (customize as needed)\n", - "export INGESTOR_SERVER_EXTERNAL_VOLUME_MOUNT=./volumes/ingestor-server\n", - "# Set container internal path (customize as needed)\n", - "export INGESTOR_SERVER_DATA_DIR=/data/\n", - "'''\n", + "# Enable saving extracted content to disk.\n", + "# Results are written into the `rag-vol-ingestor` Docker named volume\n", + "# (host path: /var/lib/docker/volumes/rag-vol-ingestor/_data/).\n", + "save_config = {\n", + " \"APP_NVINGEST_SAVETODISK\": \"True\",\n", + " # Container internal path (customize as needed)\n", + " \"INGESTOR_SERVER_DATA_DIR\": \"/data/\",\n", + "}\n", "\n", - "with open(\"deploy/compose/.env\", \"a\") as f:\n", - " f.write(save_config)\n", + "write_launchable_env(save_config)\n", "\n", "print(\"✅ Save-to-disk configuration written\")\n", - "print(\" Extracted content will be saved to: ./volumes/ingestor-server\")" + "print(\" Extracted content will be saved inside the `rag-vol-ingestor` Docker volume.\")\n" ] }, { @@ -1031,7 +1336,7 @@ "source": [ "### 2.3b Enable LLM Reasoning (Optional)\n", "\n", - "⚠️ **Only run this cell if you want to enable LLM-based reasoning.**" + "⚠️ **Only run this cell if you want to enable LLM reasoning through `LLM_ENABLE_THINKING`.**\n" ] }, { @@ -1041,48 +1346,24 @@ "metadata": {}, "outputs": [], "source": [ - "# Optional: Toggle RAG Template Thinking Mode in prompt.yaml\n", - "# Changes ONLY the rag_template from /no_think to /think (or vice versa)\n", - "# /think = Step-by-step reasoning (slower, more detailed)\n", - "# /no_think = Direct responses (faster)\n", - "\n", - "from pathlib import Path\n", - "import re\n", + "# Optional: enable LLM reasoning for Nemotron 3 models.\n", + "# Current RAG flow uses environment variables, not prompt directive edits.\n", + "# To disable reasoning again, re-run cell 2.3 (base configuration).\n", "\n", - "# Toggle: Set to True for thinking mode, False for no_think mode\n", - "ENABLE_RAG_THINKING = True # Set to True to enable /think\n", + "llm_reasoning_config = {\n", + " \"LLM_ENABLE_THINKING\": \"true\",\n", + " \"LLM_REASONING_BUDGET\": \"256\",\n", + " \"LLM_LOW_EFFORT\": \"true\",\n", + " \"FILTER_THINK_TOKENS\": \"true\",\n", + " \"LLM_TEMPERATURE\": \"0.6\",\n", + " \"LLM_TOP_P\": \"0.95\",\n", + "}\n", "\n", - "# Path to prompt.yaml\n", - "PROMPT_YAML_PATH = Path(\"src/nvidia_rag/rag_server/prompt.yaml\")\n", - "\n", - "# Read the current file\n", - "content = PROMPT_YAML_PATH.read_text()\n", - "\n", - "if ENABLE_RAG_THINKING:\n", - " # Pattern to match only rag_template's system block\n", - " pattern = r'(rag_template:\\s*\\n\\s*system:\\s*\\|\\s*\\n\\s*)/no_think'\n", - " replacement = r'\\1/think'\n", - " new_value = \"/think\"\n", - "else:\n", - " pattern = r'(rag_template:\\s*\\n\\s*system:\\s*\\|\\s*\\n\\s*)/think'\n", - " replacement = r'\\1/no_think'\n", - " new_value = \"/no_think\"\n", + "write_launchable_env(llm_reasoning_config)\n", "\n", - "new_content, count = re.subn(pattern, replacement, content, count=1)\n", - "\n", - "if count > 0:\n", - " PROMPT_YAML_PATH.write_text(new_content)\n", - " print(f\"✅ RAG Template updated to: {new_value}\")\n", - " print(f\"\\n⚠️ Restart the RAG server for changes to take effect:\")\n", - " print(f\" Run: stop_all() then deploy_all()\")\n", - "else:\n", - " # Check current state\n", - " if re.search(r'rag_template:\\s*\\n\\s*system:\\s*\\|\\s*\\n\\s*/think', content):\n", - " print(\"ℹ️ RAG Template is already set to: /think\")\n", - " elif re.search(r'rag_template:\\s*\\n\\s*system:\\s*\\|\\s*\\n\\s*/no_think', content):\n", - " print(\"ℹ️ RAG Template is already set to: /no_think\")\n", - " else:\n", - " print(\"❌ Could not find rag_template section in expected format\")" + "print(\"✅ LLM reasoning enabled\")\n", + "print(\" LLM_ENABLE_THINKING=true\")\n", + "print(\" Reasoning budget: 256 tokens, low effort mode enabled\")\n" ] }, { @@ -1092,15 +1373,15 @@ "source": [ "### 2.3c Image Captioning & VLM Inferencing (Optional)\n", "\n", - "⚠️ **Only run this cell if you want to enable image extraction, captioning, and VLM-based reasoning.**\n", - "⚠️ **LLM Inferencing for answer generation is not exercised if you enable this**\n", + "⚠️ **Only run this cell if you want to enable image extraction, captioning, and VLM-based answering.**\n", "\n", "This enables:\n", + "\n", "- **Image extraction** from documents during ingestion\n", - "- **Image captioning** using a Vision-Language Model (VLM) \n", + "- **Image captioning** using a Vision-Language Model (VLM)\n", "- **VLM inference** for multimodal queries at runtime\n", "\n", - "Uses the NVIDIA-hosted `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning` model." + "Text-only prompts continue to fall back to the LLM unless you disable `VLM_TO_LLM_FALLBACK`.\n" ] }, { @@ -1110,29 +1391,27 @@ "metadata": {}, "outputs": [], "source": [ - "# Enable image captioning during ingestion\n", - "captioning_config = '''\n", - "export APP_NVINGEST_EXTRACTIMAGES=\"True\"\n", - "export APP_NVINGEST_CAPTIONENDPOINTURL=\"https://integrate.api.nvidia.com/v1/chat/completions\"\n", - "export APP_NVINGEST_CAPTIONMODELNAME=\"nvidia/nemotron-3-nano-omni-30b-a3b-reasoning\"\n", - "'''\n", - "\n", - "# Enable VLM inferencing for multimodal queries\n", - "vlm_config = '''\n", - "export ENABLE_VLM_INFERENCE=\"true\"\n", - "export APP_VLM_MODELNAME=\"nvidia/nemotron-3-nano-omni-30b-a3b-reasoning\"\n", - "export APP_VLM_SERVERURL=\"https://integrate.api.nvidia.com/v1/\"\n", - "export APP_VLM_TEMPERATURE=0.3\n", - "export APP_VLM_TOP_P=0.91\n", - "export APP_VLM_MAX_TOKENS=8192\n", - "'''\n", - "\n", - "with open(\"deploy/compose/.env\", \"a\") as f:\n", - " f.write(captioning_config)\n", - " f.write(vlm_config)\n", + "# Enable image captioning during ingestion and VLM inferencing for multimodal queries.\n", + "# This switches the RAG server to the VLM-capable path. Text-only prompts still\n", + "# fall back to the LLM because VLM_TO_LLM_FALLBACK remains true.\n", + "captioning_and_vlm_config = {\n", + " \"APP_NVINGEST_EXTRACTIMAGES\": \"True\",\n", + " \"APP_NVINGEST_CAPTIONENDPOINTURL\": \"https://integrate.api.nvidia.com/v1/chat/completions\",\n", + " \"APP_NVINGEST_CAPTIONMODELNAME\": \"nvidia/nemotron-nano-12b-v2-vl\",\n", + " \"ENABLE_VLM_INFERENCE\": \"true\",\n", + " \"VLM_TO_LLM_FALLBACK\": \"true\",\n", + " \"APP_VLM_MODELNAME\": \"nvidia/nemotron-3-nano-omni-30b-a3b-reasoning\",\n", + " \"APP_VLM_SERVERURL\": \"https://integrate.api.nvidia.com/v1/\",\n", + " \"APP_VLM_TEMPERATURE\": \"0.3\",\n", + " \"APP_VLM_TOP_P\": \"0.91\",\n", + " \"APP_VLM_MAX_TOKENS\": \"8192\",\n", + "}\n", + "\n", + "write_launchable_env(captioning_and_vlm_config)\n", "\n", "print(\"✅ Image captioning & VLM configuration written\")\n", - "print(\" VLM Model: nvidia/nemotron-3-nano-omni-30b-a3b-reasoning\")" + "print(\" VLM inference: enabled with LLM fallback for text-only requests\")\n", + "print(\" Caption model: nvidia/nemotron-nano-12b-v2-vl\")\n" ] }, { @@ -1140,11 +1419,9 @@ "id": "a85036b0-04e6-473d-846e-80b30385db87", "metadata": {}, "source": [ - "### 2.3d Enable VLM Inferencing (Optional)\n", - "\n", - "⚠️ **Only run this cell if you want to Enable reasoning with the VLM**\n", + "### 2.3d Enable VLM Reasoning (Optional)\n", "\n", - "This enables saving the extracted text/images from documents during ingestion for inspection or debugging." + "⚠️ **Only run this cell if you enabled VLM inference above and also want VLM reasoning.**\n" ] }, { @@ -1154,47 +1431,22 @@ "metadata": {}, "outputs": [], "source": [ - "# Optional: Toggle VLM Thinking Mode in prompt.yaml\n", - "# Changes the vlm_template from /no_think to /think (or vice versa)\n", - "# /think = Step-by-step reasoning (slower, more detailed)\n", - "# /no_think = Direct responses (faster)\n", + "# Optional: enable VLM reasoning for Nemotron Omni.\n", + "# Current VLM flow uses APP_VLM_ENABLE_THINKING and APP_VLM_THINKING_TOKEN_BUDGET.\n", + "# To disable VLM reasoning again, re-run cell 2.3 (base configuration), then\n", + "# re-run cell 2.3c if you still want VLM inference without VLM reasoning.\n", "\n", - "from pathlib import Path\n", - "\n", - "# Toggle: Set to True for thinking mode, False for no_think mode\n", - "ENABLE_VLM_THINKING = True # Set to True to enable /think\n", + "vlm_reasoning_config = {\n", + " \"APP_VLM_ENABLE_THINKING\": \"true\",\n", + " \"APP_VLM_THINKING_TOKEN_BUDGET\": \"8192\",\n", + " \"VLM_FILTER_THINK_TOKENS\": \"true\",\n", + "}\n", "\n", - "# Path to prompt.yaml\n", - "PROMPT_YAML_PATH = Path(\"src/nvidia_rag/rag_server/prompt.yaml\")\n", + "write_launchable_env(vlm_reasoning_config)\n", "\n", - "# Read the current file\n", - "content = PROMPT_YAML_PATH.read_text()\n", - "\n", - "# Find and update the vlm_template section\n", - "if ENABLE_VLM_THINKING:\n", - " # Change /no_think to /think in vlm_template section\n", - " old_block = 'vlm_template:\\n system: |\\n /no_think'\n", - " new_block = 'vlm_template:\\n system: |\\n /think'\n", - "else:\n", - " # Change /think to /no_think in vlm_template section\n", - " old_block = 'vlm_template:\\n system: |\\n /think'\n", - " new_block = 'vlm_template:\\n system: |\\n /no_think'\n", - "\n", - "if old_block in content:\n", - " content = content.replace(old_block, new_block)\n", - " PROMPT_YAML_PATH.write_text(content)\n", - " mode = \"/think\" if ENABLE_VLM_THINKING else \"/no_think\"\n", - " print(f\"✅ VLM Template updated to: {mode}\")\n", - " print(f\"\\n⚠️ Restart the RAG server for changes to take effect:\")\n", - " print(f\" Run: stop_all() then deploy_all()\")\n", - "else:\n", - " # Check current state\n", - " if 'vlm_template:\\n system: |\\n /think' in content:\n", - " print(\"ℹ️ VLM Template is already set to: /think\")\n", - " elif 'vlm_template:\\n system: |\\n /no_think' in content:\n", - " print(\"ℹ️ VLM Template is already set to: /no_think\")\n", - " else:\n", - " print(\"❌ Could not find vlm_template section in expected format\")" + "print(\"✅ VLM reasoning enabled\")\n", + "print(\" APP_VLM_ENABLE_THINKING=true\")\n", + "print(\" VLM thinking token budget: 8192\")\n" ] }, { @@ -1204,7 +1456,7 @@ "source": [ "### 2.3e Load Environment Configuration\n", "\n", - "**Run this cell after configuring the optional features above.**" + "**Run this cell after configuring the optional features above to confirm the active pipeline and thinking settings.**\n" ] }, { @@ -1214,52 +1466,49 @@ "metadata": {}, "outputs": [], "source": [ - "# Check reasoning mode from prompt.yaml\n", - "load_dotenv(\"deploy/compose/.env\")\n", + "# Check active launchable generation and reasoning settings.\n", + "refresh_launchable_env()\n", "\n", - "from pathlib import Path\n", - "import re\n", "\n", - "prompt_yaml_path = Path(\"src/nvidia_rag/rag_server/prompt.yaml\")\n", + "def env_bool(name: str, default: str = \"false\") -> bool:\n", + " return os.environ.get(name, default).strip().lower() == \"true\"\n", "\n", - "# Check if VLM inferencing is enabled\n", - "vlm_enabled = os.environ.get(\"ENABLE_VLM_INFERENCE\") == \"true\"\n", "\n", - "if prompt_yaml_path.exists():\n", - " content = prompt_yaml_path.read_text()\n", - " \n", - " if vlm_enabled:\n", - " print(\"\\n 🔮 VLM Inferencing: ENABLED\")\n", - " \n", - " # Check image captioning\n", - " if os.environ.get(\"APP_NVINGEST_EXTRACTIMAGES\") == \"True\":\n", - " print(\" ├─ 🖼️ Image extraction: ENABLED\")\n", - " caption_model = os.environ.get(\"APP_NVINGEST_CAPTIONMODELNAME\", \"not set\")\n", - " print(f\" ├─ 📝 Image captioning: ENABLED\")\n", - " print(f\" │ Model: {caption_model}\")\n", - " else:\n", - " print(\" ├─ 🖼️ Image extraction: DISABLED\")\n", - " print(\" ├─ 📝 Image captioning: DISABLED\")\n", - " \n", - " # Check VLM reasoning mode (vlm_template)\n", - " if \"/think\" in content.split(\"vlm_template:\")[1].split(\"human:\")[0]:\n", - " if \"/no_think\" in content.split(\"vlm_template:\")[1].split(\"human:\")[0]:\n", - " print(\" └─ 🧠 VLM reasoning: DISABLED (/no_think)\")\n", - " else:\n", - " print(\" └─ 🧠 VLM reasoning: ENABLED (/think)\")\n", - " else:\n", - " print(\" └─ 🧠 VLM reasoning: DISABLED (/no_think)\")\n", + "vlm_enabled = env_bool(\"ENABLE_VLM_INFERENCE\")\n", + "llm_thinking_enabled = env_bool(\"LLM_ENABLE_THINKING\")\n", + "vlm_thinking_enabled = env_bool(\"APP_VLM_ENABLE_THINKING\")\n", + "image_extraction_enabled = env_bool(\"APP_NVINGEST_EXTRACTIMAGES\")\n", + "\n", + "if vlm_enabled:\n", + " print(\"\\n 🔮 VLM inference: ENABLED\")\n", + " print(f\" ├─ VLM model: {os.environ.get('APP_VLM_MODELNAME', 'not set')}\")\n", + " print(f\" ├─ VLM endpoint: {os.environ.get('APP_VLM_SERVERURL', 'not set')}\")\n", + " print(\n", + " \" ├─ Text-only fallback to LLM: \"\n", + " f\"{'ENABLED' if env_bool('VLM_TO_LLM_FALLBACK', 'true') else 'DISABLED'}\"\n", + " )\n", + " if image_extraction_enabled:\n", + " print(\" ├─ Image extraction: ENABLED\")\n", + " caption_model = os.environ.get(\"APP_NVINGEST_CAPTIONMODELNAME\", \"not set\")\n", + " print(\" ├─ Image captioning: ENABLED\")\n", + " print(f\" │ Model: {caption_model}\")\n", " else:\n", - " print(\"\\n 💬 LLM Inferencing: ENABLED (default)\")\n", - " \n", - " # Check LLM/RAG reasoning mode (rag_template)\n", - " if \"/think\" in content.split(\"rag_template:\")[1].split(\"human:\")[0]:\n", - " if \"/no_think\" in content.split(\"rag_template:\")[1].split(\"human:\")[0]:\n", - " print(\" └─ 🧠 LLM reasoning: DISABLED (/no_think)\")\n", - " else:\n", - " print(\" └─ 🧠 LLM reasoning: ENABLED (/think)\")\n", - " else:\n", - " print(\" └─ 🧠 LLM reasoning: DISABLED (/no_think)\")" + " print(\" ├─ Image extraction: DISABLED\")\n", + " print(\" ├─ Image captioning: DISABLED\")\n", + " print(\n", + " \" └─ VLM reasoning: \"\n", + " f\"{'ENABLED' if vlm_thinking_enabled else 'DISABLED'} \"\n", + " f\"(APP_VLM_ENABLE_THINKING={str(vlm_thinking_enabled).lower()})\"\n", + " )\n", + "else:\n", + " print(\"\\n 💬 Standard LLM inference: ENABLED\")\n", + " print(\" ├─ VLM inference: DISABLED\")\n", + " print(f\" ├─ LLM model: {os.environ.get('APP_LLM_MODELNAME', 'not set')}\")\n", + " print(\n", + " \" └─ LLM reasoning: \"\n", + " f\"{'ENABLED' if llm_thinking_enabled else 'DISABLED'} \"\n", + " f\"(LLM_ENABLE_THINKING={str(llm_thinking_enabled).lower()})\"\n", + " )\n" ] }, { @@ -1300,16 +1549,14 @@ "88181d20ba30 rag-frontend Up 2 minutes\n", "5cf93ea91d4e rag-server Up 2 minutes\n", "03ff43bd4f53 compose-nv-ingest-ms-runtime-1 Up 2 minutes (healthy)\n", + "fcc703631b72 rag-elasticsearch Up 3 minutes (healthy)\n", "fcc703631b71 ingestor-server Up 2 minutes\n", "77f64a4a5146 compose-redis-1 Up 2 minutes\n", - "902445432dde milvus-standalone Up 3 minutes\n", - "340bc8210a0d milvus-minio Up 3 minutes (healthy)\n", - "0be702b87ad6 milvus-etcd Up 3 minutes (healthy)\n", "fe2751bfa734 nemotron-ranking-ms Up 4 seconds (healthy)\n", "7b5ddabf8be7 compose-graphic-elements-1 Up 10 minutes\n", "ecfaa5190302 compose-page-elements-1 Up 10 minutes\n", "ea8c7fdf20d1 nemotron-embedding-ms Up 4 seconds (healthy)\n", - "6d62008a9b42 compose-nemoretriever-ocr-1 Up 10 minutes\n", + "6d62008a9b42 compose-nemotron-ocr-1 Up 10 minutes\n", "969b9f5c987c compose-table-structure-1 Up 10 minutes\n", "```\n", "\n", @@ -1536,11 +1783,13 @@ "source": [ "### 4.5 Study Extracted Results\n", "\n", - "When `APP_NVINGEST_SAVETODISK=True` is enabled, the ingestion pipeline saves the extracted results to:\n", + "When `APP_NVINGEST_SAVETODISK=True` is enabled, the ingestion pipeline saves the extracted results inside the `rag-vol-ingestor` Docker named volume at:\n", "```\n", - "./volumes/ingestor-server/nv-ingest-results/{collection_name}/{filename}.results.jsonl.gz\n", + "/data/nv-ingest-results/{collection_name}/{filename}.results.jsonl.gz # path inside the ingestor-server container\n", "```\n", "\n", + "On the Docker host the same files live under `/var/lib/docker/volumes/rag-vol-ingestor/_data/nv-ingest-results/…`. Because Docker named volumes are owned by root on the host, the next cell copies the results out into a local `ingestor-results/` directory so the notebook can read them as a regular user.\n", + "\n", "Each `.results.jsonl.gz` file contains the extracted content from the document including:\n", "- **Text chunks**: Extracted text segments with metadata\n", "- **Tables**: Structured table data extracted from the document\n", @@ -1559,12 +1808,28 @@ "source": [ "import gzip\n", "import json\n", + "import shutil\n", + "import subprocess\n", "from pathlib import Path\n", "from IPython.display import display, HTML, Image\n", "import base64\n", "\n", - "# Configure paths\n", - "RESULTS_BASE_DIR = Path(\"./deploy/compose/volumes/ingestor-server/nv-ingest-results\")\n", + "# Copy the ingestor-server results out of the `rag-vol-ingestor` Docker named volume\n", + "# into a local directory so this notebook can read them as the current user.\n", + "RESULTS_BASE_DIR = Path(\"ingestor-results/nv-ingest-results\")\n", + "RESULTS_BASE_DIR.parent.mkdir(exist_ok=True)\n", + "if RESULTS_BASE_DIR.exists():\n", + " shutil.rmtree(RESULTS_BASE_DIR.parent)\n", + " RESULTS_BASE_DIR.parent.mkdir(exist_ok=True)\n", + "subprocess.check_call([\n", + " \"docker\", \"run\", \"--rm\",\n", + " \"-v\", \"rag-vol-ingestor:/src:ro\",\n", + " \"-v\", f\"{Path.cwd() / RESULTS_BASE_DIR.parent}:/dst\",\n", + " \"alpine\",\n", + " \"sh\", \"-c\",\n", + " \"cp -a /src/nv-ingest-results/. /dst/nv-ingest-results/ && chown -R $(stat -c '%u:%g' /dst) /dst\",\n", + "])\n", + "\n", "COLLECTION_TO_STUDY = COLLECTION_NAME # Uses the collection name from earlier cells\n", "\n", "# List available result files for this collection\n", @@ -1615,7 +1880,7 @@ "RESULTS_STUDY_DIR.mkdir(exist_ok=True)\n", "\n", "# Path to the specific result file (adjust path as needed)\n", - "RESULT_FILE = Path(\"deploy/compose/volumes/ingestor-server/nv-ingest-results\") / COLLECTION_NAME / \"sample_ai_article.pdf.results.jsonl.gz\"\n", + "RESULT_FILE = Path(\"ingestor-results/nv-ingest-results\") / COLLECTION_NAME / \"sample_ai_article.pdf.results.jsonl.gz\"\n", "\n", "# Load the file\n", "extracted_content = []\n", @@ -1996,7 +2261,7 @@ "| RAG Server | 8081 |\n", "| Ingestor Server | 8082 |\n", "| RAG Frontend | 8090 |\n", - "| Milvus | 19530 |" + "| Elasticsearch | 9200 |" ] }, { diff --git a/notebooks/mcp_server_usage.ipynb b/notebooks/mcp_server_usage.ipynb index c875caac6..f30d3b38d 100644 --- a/notebooks/mcp_server_usage.ipynb +++ b/notebooks/mcp_server_usage.ipynb @@ -73,9 +73,11 @@ "\n", "Set up configuration variables for the MCP server and client.\n", "\n", - "**Environment variables:** Setup following environment variables in the next cell for the INGESTOR server and RAG server urls before starting the MCP server.\n", - "- INGESTOR_SERVER_URL=\"http://localhost:8082\"\n", - "- VITE_API_CHAT_URL=\"http://localhost:8081\"\n", + "**Environment variables:** Set the following in the next cell before starting the MCP server:\n", + "- `INGESTOR_URL` — Ingestor service (default `http://localhost:8082`)\n", + "- `VITE_API_CHAT_URL` — RAG service (default `http://localhost:8081`)\n", + "- `MCP_UPLOAD_DIR` — Base directory for local file paths passed to `upload_documents` / `update_documents` (defaults to the notebook working directory). This notebook sets it to the repo root so sample files under `data/multimodal/` are allowed.\n", + "- `VDB_ENDPOINT` — Vector database URL for ingestor tools (`http://elasticsearch:9200` in Docker Compose; default in this notebook).\n", "\n", "**Transport Options:**\n", "- `streamable_http` (default) - HTTP-based streaming, recommended for most use cases\n", @@ -105,7 +107,7 @@ "HOST = \"127.0.0.1\"\n", "\n", "# Export the INGESTOR server and RAG server urls before starting the MCP server\n", - "os.environ[\"INGESTOR_SERVER_URL\"] = \"http://localhost:8082\" # Ingestor server url\n", + "os.environ[\"INGESTOR_URL\"] = \"http://localhost:8082\" # Ingestor server url\n", "os.environ[\"VITE_API_CHAT_URL\"] = \"http://localhost:8081\" # Rag server url\n", "\n", "# Automatically set URL path based on transport\n", @@ -120,6 +122,7 @@ "\n", "# Paths\n", "repo_root = os.path.abspath(os.path.join(os.getcwd(), \"..\"))\n", + "os.environ[\"MCP_UPLOAD_DIR\"] = repo_root # upload_documents paths must be under this dir\n", "server_path = os.path.join(repo_root, \"examples\", \"nvidia_rag_mcp\", \"mcp_server.py\")\n", "client_path = os.path.join(repo_root, \"examples\", \"nvidia_rag_mcp\", \"mcp_client.py\")\n", "pdf_path = os.path.join(repo_root, \"data\", \"multimodal\", \"functional_validation.pdf\")\n", @@ -127,13 +130,23 @@ "# Collection name for this demo\n", "COLLECTION = \"my_collection\"\n", "\n", + "# Vector DB endpoint for ingestor tools (create_collection, list_collections, etc.).\n", + "VDB_ENDPOINT = \"http://elasticsearch:9200\"\n", + "\n", + "\n", + "def _with_vdb_endpoint(args: dict) -> dict:\n", + " \"\"\"Attach vdb_endpoint to ingestor tool args.\"\"\"\n", + " return {**args, \"vdb_endpoint\": VDB_ENDPOINT}\n", + "\n", "print(f\"Configuration:\")\n", "print(f\" Transport: {TRANSPORT}\")\n", "print(f\" Server: {HOST}:{PORT}\")\n", "if MCP_URL:\n", " print(f\" MCP URL: {MCP_URL}\")\n", "print(f\" Collection: {COLLECTION}\")\n", - "print(f\" Sample PDF: {os.path.basename(pdf_path)}\")" + "print(f\" VDB endpoint: {VDB_ENDPOINT}\")\n", + "print(f\" MCP upload dir: {repo_root}\")\n", + "print(f\" Sample PDF: {pdf_path}\")" ] }, { @@ -192,7 +205,7 @@ " ]\n", "\n", " print(f\"Launching MCP server: {' '.join(cmd)}\")\n", - " mcp_server_proc = subprocess.Popen(cmd)\n", + " mcp_server_proc = subprocess.Popen(cmd, env=os.environ.copy())\n", " atexit.register(lambda: mcp_server_proc and mcp_server_proc.poll() is None and mcp_server_proc.terminate())\n", " time.sleep(2.0)\n", " print(f\"MCP server started (PID: {mcp_server_proc.pid})\")\n", @@ -259,7 +272,7 @@ "print(\"=\"*80)\n", "print(\"Creating collection...\")\n", "print(\"=\"*80)\n", - "create_args = json.dumps({\"collection_name\": COLLECTION})\n", + "create_args = json.dumps(_with_vdb_endpoint({\"collection_name\": COLLECTION}))\n", "subprocess.run([\n", " sys.executable, client_path, \"call\",\n", " f\"--transport={TRANSPORT}\", f\"--url={MCP_URL}\",\n", @@ -280,7 +293,7 @@ "**Purpose:** Upload documents to a collection with chunking and optional summary generation \n", "**Arguments:**\n", "- `collection_name` - Target collection\n", - "- `file_paths` - List of absolute file paths to upload\n", + "- `file_paths` - List of absolute file paths to upload (must be under `MCP_UPLOAD_DIR`, set in section 2)\n", "- `blocking` - Wait for ingestion to complete (True/False)\n", "- `generate_summary` - Generate document summaries (True/False)\n", "- `split_options` - Chunking configuration (chunk_size, chunk_overlap)" @@ -555,15 +568,25 @@ "lsof -ti:8000 | xargs kill # macOS\n", "```\n", "\n", + "**MCP server fails to start or hangs:**\n", + "- Check that NVIDIA RAG and Ingestor services are up and healthy (see the [RAG quickstart](https://github.com/NVIDIA-AI-Blueprints/rag/blob/main/docs/deploy-docker-self-hosted.md)).\n", + "- Ensure port `8000` is free: `fuser -k 8000/tcp` (Linux) and rerun the server cell.\n", + "\n", "**Server not responding:**\n", "- Verify RAG (port 8081) and Ingestor (port 8082) services are running\n", "- Check server logs for connection errors\n", "- For streamable_http: HTTP 406 on GET `/mcp` is normal; the endpoint only accepts MCP requests\n", "\n", - "**Tool call failures:**\n", + "**MCP client calls (create/upload/delete) fail:**\n", + "- Verify the MCP server cell printed a valid PID and URL `http://127.0.0.1:8000/mcp`.\n", + "- Re-run the configuration cell (section 2) so `INGESTOR_URL`, `VITE_API_CHAT_URL`, and `MCP_UPLOAD_DIR` are set, then restart the MCP server cell.\n", + "- **Upload path rejected:** `file_paths` must be under `MCP_UPLOAD_DIR` (repo root). This notebook sets `pdf_path` as an absolute path via `os.path.join(repo_root, \"data\", \"multimodal\", \"functional_validation.pdf\")`; you can also pass paths relative to `MCP_UPLOAD_DIR` (e.g., `data/multimodal/functional_validation.pdf`).\n", + "- **Collection/VDB errors:** pass `vdb_endpoint` (e.g., `http://elasticsearch:9200`) to `create_collection` via `_with_vdb_endpoint` in the create cell—not on the upload cell.\n", + "- Run the MCP client commands from the notebook again and inspect any error text in the cell output.\n", + "\n", + "**Other tool call failures:**\n", "- Ensure collection exists before calling RAG tools\n", "- Verify documents are fully ingested before searching\n", - "- Check that file paths are absolute and accessible to the server\n", "\n", "**Dependencies issues:**\n", "- Ensure Python 3.11+ is installed\n", diff --git a/notebooks/nat_mcp_integration.ipynb b/notebooks/nat_mcp_integration.ipynb index 841c09bed..bbc3ea7ad 100644 --- a/notebooks/nat_mcp_integration.ipynb +++ b/notebooks/nat_mcp_integration.ipynb @@ -75,11 +75,13 @@ "metadata": {}, "outputs": [], "source": [ - "# Export the INGESTOR server and RAG server urls before starting the MCP server\n", + "# Export URLs and upload dir before starting the MCP server (see mcp_server.py).\n", "import os\n", "\n", - "os.environ[\"INGESTOR_SERVER_URL\"] = \"http://localhost:8082\" # Ingestor server url\n", - "os.environ[\"VITE_API_CHAT_URL\"] = \"http://localhost:8081\" # Rag server url" + "repo_root = os.path.abspath(os.path.join(os.getcwd(), \"..\"))\n", + "os.environ[\"INGESTOR_URL\"] = \"http://localhost:8082\"\n", + "os.environ[\"VITE_API_CHAT_URL\"] = \"http://localhost:8081\"\n", + "os.environ[\"MCP_UPLOAD_DIR\"] = repo_root # upload_documents paths must be under this dir" ] }, { @@ -141,56 +143,66 @@ "outputs": [], "source": [ "import json\n", - "import subprocess\n", "import os\n", + "import subprocess\n", "\n", "STREAMABLE_HTTP_URL = \"http://127.0.0.1:9902/mcp\"\n", "repo_root = os.path.abspath(os.path.join(os.getcwd(), \"..\"))\n", "client_path = os.path.join(repo_root, \"examples\", \"nvidia_rag_mcp\", \"mcp_client.py\")\n", "\n", - "# Use Python from the .nat-mcp venv\n", "venv_dir = os.path.join(os.getcwd(), \".nat-mcp\")\n", "venv_python = os.path.join(venv_dir, \"bin\", \"python\")\n", "if not os.path.exists(venv_python):\n", " raise RuntimeError(f\"Python not found at {venv_python}. Please run the installation cell first.\")\n", "\n", "COLLECTION = \"my_collection\"\n", + "VDB_ENDPOINT = \"http://elasticsearch:9200\"\n", "pdf_path = os.path.join(repo_root, \"data\", \"multimodal\", \"product_catalog.pdf\")\n", + "if not os.path.isfile(pdf_path):\n", + " raise FileNotFoundError(f\"Sample PDF not found: {pdf_path}\")\n", + "\n", + "\n", + "def _with_vdb_endpoint(args: dict) -> dict:\n", + " return {**args, \"vdb_endpoint\": VDB_ENDPOINT}\n", + "\n", + "\n", + "def _mcp_call(tool: str, args: dict) -> subprocess.CompletedProcess[str]:\n", + " return subprocess.run(\n", + " [\n", + " venv_python,\n", + " client_path,\n", + " \"call\",\n", + " \"--transport=streamable_http\",\n", + " f\"--url={STREAMABLE_HTTP_URL}\",\n", + " f\"--tool={tool}\",\n", + " f\"--json-args={json.dumps(args)}\",\n", + " ],\n", + " check=True,\n", + " text=True,\n", + " capture_output=True,\n", + " )\n", + "\n", "\n", "print(\"=\" * 80)\n", "print(\"Creating collection via MCP...\")\n", "print(\"=\" * 80)\n", - "create_args = json.dumps({\"collection_name\": COLLECTION})\n", - "subprocess.run([\n", - " venv_python,\n", - " client_path,\n", - " \"call\",\n", - " \"--transport=streamable_http\",\n", - " f\"--url={STREAMABLE_HTTP_URL}\",\n", - " \"--tool=create_collection\",\n", - " f\"--json-args={create_args}\",\n", - "], check=False)\n", + "create_result = _mcp_call(\"create_collection\", _with_vdb_endpoint({\"collection_name\": COLLECTION}))\n", + "print(create_result.stdout)\n", "\n", "print(\"\\n\" + \"=\" * 80)\n", "print(\"Uploading document via MCP...\")\n", "print(\"=\" * 80)\n", - "upload_args = json.dumps({\n", - " \"collection_name\": COLLECTION,\n", - " \"file_paths\": [pdf_path],\n", - " \"blocking\": True,\n", - " \"generate_summary\": True,\n", - " \"split_options\": {\"chunk_size\": 512, \"chunk_overlap\": 150},\n", - "})\n", - "subprocess.run([\n", - " venv_python,\n", - " client_path,\n", - " \"call\",\n", - " \"--transport=streamable_http\",\n", - " f\"--url={STREAMABLE_HTTP_URL}\",\n", - " \"--tool=upload_documents\",\n", - " f\"--json-args={upload_args}\",\n", - "], check=False)\n", - "\n", + "upload_result = _mcp_call(\n", + " \"upload_documents\",\n", + " {\n", + " \"collection_name\": COLLECTION,\n", + " \"file_paths\": [pdf_path],\n", + " \"blocking\": True,\n", + " \"generate_summary\": True,\n", + " \"split_options\": {\"chunk_size\": 512, \"chunk_overlap\": 150},\n", + " },\n", + ")\n", + "print(upload_result.stdout)\n", "print(\"\\nDone setting up collection and document for NAT + MCP demo.\")" ] }, @@ -218,7 +230,7 @@ " nim_llm:\n", " _type: nim\n", " base_url: \"http://localhost:8000/v1\" # Your NIM endpoint\n", - " model_name: meta/llama-3.1-70b-instruct\n", + " model_name: nvidia/nemotron-3-super-120b-a12b\n", " temperature: 0.0\n", " max_tokens: 1024\n", "```\n", @@ -244,27 +256,42 @@ "function_groups:\n", " nvidia_rag_mcp:\n", " _type: mcp_client\n", + " tool_call_timeout: 300\n", " server:\n", " transport: streamable-http\n", - " url: \"http://localhost:9902/mcp\"\n", + " url: \"http://127.0.0.1:9902/mcp\"\n", " include:\n", " - generate\n", + " tool_overrides:\n", + " generate:\n", + " description: |\n", + " Generate an answer from the knowledge base. Pass only:\n", + " messages (list), use_knowledge_base (true), collection_names (list), max_tokens (int).\n", + " Do not pass stop or other optional parameters.\n", "\n", "llms:\n", " nim_llm:\n", " _type: nim\n", - " model_name: meta/llama-3.1-70b-instruct\n", + " model_name: nvidia/nemotron-3-super-120b-a12b\n", " temperature: 0.0\n", " max_tokens: 1024\n", "\n", "workflow:\n", " _type: react_agent\n", " tool_names:\n", - " - nvidia_rag_mcp\n", + " - nvidia_rag_mcp__generate\n", " llm_name: nim_llm\n", " verbose: true\n", " retry_parsing_errors: true\n", - " max_retries: 3\n", + " parse_agent_response_max_retries: 5\n", + " max_tool_calls: 1\n", + " additional_instructions: |\n", + " Call generate once with only messages, use_knowledge_base=true,\n", + " collection_names from the user request, and max_tokens=512.\n", + " Do not pass stop, temperature, or other optional fields.\n", + " After the Observation with the answer, respond exactly:\n", + " Thought: I now know the final answer\n", + " Final Answer: \n", "\"\"\"\n", "\n", "with open(config_path, \"w\") as f:\n", @@ -478,6 +505,9 @@ "\n", "- **MCP client calls (create/upload/delete) fail**\n", " - Verify the MCP server cell printed a valid PID and URL `http://127.0.0.1:9902/mcp`.\n", + " - Re-run the env cell so `INGESTOR_URL`, `VITE_API_CHAT_URL`, and `MCP_UPLOAD_DIR` are set, then restart the MCP server cell.\n", + " - **Upload path rejected:** `file_paths` must be under `MCP_UPLOAD_DIR` (repo root). The sample path is relative (e.g., `data/multimodal/product_catalog.pdf`), not an absolute path—place or reference files under the repo root accordingly.\n", + " - **Collection/VDB errors:** pass `vdb_endpoint` (e.g., `vdb_endpoint: http://elasticsearch:9200`) to the `create_collection` helper, not to the upload helper/cell.\n", " - Run the MCP client commands from the notebook again and inspect any error text in the cell output.\n", "\n", "- **Using self-hosted NIM instead of cloud-hosted LLMs**\n", @@ -486,12 +516,17 @@ " - Ensure your NIM container is running and accessible at the specified URL.\n", " - Verify the `model_name` matches the model deployed in your NIM instance.\n", "\n", - "- **`nat run` errors or returns empty/irrelevant answers**\n", + "- **`nat run` times out (`Timed out while waiting for response`, 60s)**\n", + " - Re-run the **Create `nvidia_rag_mcp.yaml`** cell so `tool_call_timeout: 300` is set on the MCP client.\n", + " - Ensure the MCP URL is `http://127.0.0.1:9902/mcp` (same host as the notebook MCP server).\n", + " - Restart the MCP server cell if the process is hung from a prior failed run.\n", + "\n", + "- **`nat run` errors, parse failures, or empty answers**\n", " - Ensure your `NVIDIA_API_KEY` is set correctly (for cloud-hosted LLMs). Get one from [build.nvidia.com](https://build.nvidia.com/).\n", " - Confirm the collection (`my_collection`) exists and the upload cell completed successfully.\n", - " - Confirm the MCP server is still running (the stop-MCP cell reports it as running).\n", - " - Make sure the `url` in `nvidia_rag_mcp.yaml` matches the MCP server address (default: `http://localhost:9902/mcp`).\n", - " - Adjust the `--input` prompt to clearly mention the collection name and the document you uploaded.\n", + " - The config uses `react_agent` with `max_tool_calls: 1` and only the `generate` tool — re-run the YAML cell if you changed the workflow type.\n", + " - If the agent passes `stop: [\"\\\\n\"]`, regenerate the YAML cell (it instructs the agent not to) and retry.\n", + " - Adjust the `--input` prompt to mention the collection name and document you uploaded.\n", "\n", "- **Environment or dependency issues**\n", " - Ensure you ran the virtual environment setup cell first (it installs `nvidia-nat[langchain,mcp]`).\n", diff --git a/notebooks/nb_metadata.ipynb b/notebooks/nb_metadata.ipynb index 39203d07b..74d6f3e8a 100644 --- a/notebooks/nb_metadata.ipynb +++ b/notebooks/nb_metadata.ipynb @@ -29,8 +29,8 @@ "metadata": {}, "outputs": [], "source": [ - "!pip install aiohttp\n", - "!pip install httpx" + "!uv pip install aiohttp\n", + "!uv pip install httpx" ] }, { @@ -109,6 +109,9 @@ " choices = data.get(\"choices\", [])\n", " if not choices:\n", " continue\n", + " \n", + " if not choices[0].get(\"delta\", {}).get(\"content\"):\n", + " continue\n", "\n", " # Capture first chunk with citations (if any)\n", " if first_chunk_data is None and data.get(\"citations\"):\n", @@ -152,10 +155,10 @@ "async def generate_answer(payload):\n", " async with httpx.AsyncClient() as client:\n", " try:\n", - " async with client.stream('POST', url=rag_url, json=payload) as response:\n", + " async with client.stream('POST', url=rag_url, json=payload, timeout=300) as response:\n", " async for line in response.aiter_lines():\n", " yield line.strip()\n", - " except httpx.HTTPError as e:\n", + " except Exception as e:\n", " print(f\"Error: {e}\")" ] }, @@ -936,23 +939,26 @@ " \"messages\": [\n", " {\n", " \"role\": \"user\",\n", - " \"content\": \"How do I reset the oil life monitor in my 2023 Ford Edge?\"\n", + " \"content\": \"How do I reset the oil life monitor in my 2023 Ford Edge?\",\n", + " \"reasoning_content\": \"string\"\n", " }\n", " ],\n", " \"use_knowledge_base\": True,\n", " \"temperature\": 0.2,\n", " \"top_p\": 0.7,\n", " \"max_tokens\": 1024,\n", + " \"min_thinking_tokens\": 0,\n", + " \"max_thinking_tokens\": 0,\n", " \"reranker_top_k\": 10,\n", " \"vdb_top_k\": 100,\n", - " \"vdb_endpoint\": \"http://milvus:19530\",\n", + " \"vdb_endpoint\": \"http://elasticsearch:9200\",\n", " \"collection_names\": [COLLECTION_NAME],\n", " \"enable_query_rewriting\": True,\n", " \"enable_reranker\": True,\n", " \"enable_citations\": True,\n", - " \"model\": \"nvidia/llama-3.3-nemotron-super-49b-v1.5\",\n", + " \"model\": \"nvidia/nemotron-3-super-120b-a12b\",\n", " \"reranker_model\": \"nvidia/llama-nemotron-rerank-1b-v2\",\n", - " \"embedding_model\": \"nvidia/llama-nemotron-embed-1b-v2\",\n", + " \"embedding_model\": \"nvidia/llama-nemotron-embed-vl-1b-v2\",\n", " # Provide url of the model endpoints if deployed elsewhere\n", " # \"llm_endpoint\": \"\",\n", " #\"embedding_endpoint\": \"\",\n", @@ -998,20 +1004,20 @@ " \"max_tokens\": 1024,\n", " \"reranker_top_k\": 10,\n", " \"vdb_top_k\": 100,\n", - " \"vdb_endpoint\": \"http://milvus:19530\",\n", + " \"vdb_endpoint\": \"http://elasticsearch:9200\",\n", " \"collection_names\": [COLLECTION_NAME],\n", " \"enable_query_rewriting\": True,\n", " \"enable_reranker\": True,\n", " \"enable_citations\": True,\n", - " \"model\": \"nvidia/llama-3.3-nemotron-super-49b-v1.5\",\n", + " \"model\": \"nvidia/nemotron-3-super-120b-a12b\",\n", " \"reranker_model\": \"nvidia/llama-nemotron-rerank-1b-v2\",\n", - " \"embedding_model\": \"nvidia/llama-nemotron-embed-1b-v2\",\n", + " \"embedding_model\": \"nvidia/llama-nemotron-embed-vl-1b-v2\",\n", " # Provide url of the model endpoints if deployed elsewhere\n", " # \"llm_endpoint\": \"\",\n", " #\"embedding_endpoint\": \"\",\n", " #\"reranker_endpoint\": \"\",\n", " \"stop\": [],\n", - " \"filter_expr\": 'content_metadata[\"model\"] == \"edge\"'\n", + " \"filter_expr\": [{\"term\": {\"metadata.content_metadata.model.keyword\": \"edge\"}}]\n", "}\n", "await print_streaming_response_and_citations(generate_answer(payload))" ] @@ -1066,7 +1072,7 @@ " \"collection_names\": [COLLECTION_NAME],\n", " \"enable_filter_generator\": True,\n", " \"enable_citations\": True,\n", - " \"model\": \"nvidia/llama-3.3-nemotron-super-49b-v1.5\",\n", + " \"model\": \"nvidia/nemotron-3-super-120b-a12b\",\n", " \"filter_expr\": \"\"\n", "}\n", "\n", @@ -1112,31 +1118,31 @@ " \"max_tokens\": 1024,\n", " \"reranker_top_k\": 10,\n", " \"vdb_top_k\": 100,\n", - " \"vdb_endpoint\": \"http://milvus:19530\",\n", + " \"vdb_endpoint\": \"http://elasticsearch:9200\",\n", " \"collection_names\": [COLLECTION_NAME],\n", " \"enable_query_rewriting\": True,\n", " \"enable_reranker\": True,\n", " \"enable_citations\": True,\n", " \"enable_filter_generator\": False, # Disable to use manual complex filter\n", - " \"model\": \"nvidia/llama-3.3-nemotron-super-49b-v1.5\",\n", + " \"model\": \"nvidia/nemotron-3-super-120b-a12b\",\n", " \"reranker_model\": \"nvidia/llama-nemotron-rerank-1b-v2\",\n", - " \"embedding_model\": \"nvidia/llama-nemotron-embed-1b-v2\",\n", + " \"embedding_model\": \"nvidia/llama-nemotron-embed-vl-1b-v2\",\n", " \"stop\": [],\n", - " \"filter_expr\": '(content_metadata[\"manufacturer\"] like \"%ford%\" and content_metadata[\"rating\"] > 4.0 and content_metadata[\"created_date\"] between \"2020-01-01\" and \"2024-12-31\" and content_metadata[\"is_public\"] == true) or (content_metadata[\"model\"] like \"%edge%\" and content_metadata[\"year\"] >= 2020 and content_metadata[\"tags\"] in [\"technology\", \"safety\", \"latest\"] and content_metadata[\"rating\"] >= 4.0)'\n", + " \"filter_expr\": [{\"bool\": {\"should\": [{\"bool\": {\"must\": [{\"wildcard\": {\"metadata.content_metadata.manufacturer.keyword\": \"*ford*\"}}, {\"range\": {\"metadata.content_metadata.rating\": {\"gt\": 4.0}}}, {\"range\": {\"metadata.content_metadata.created_date\": {\"gte\": \"2020-01-01\", \"lte\": \"2024-12-31\"}}}, {\"term\": {\"metadata.content_metadata.is_public\": True}}]}}, {\"bool\": {\"must\": [{\"wildcard\": {\"metadata.content_metadata.model.keyword\": \"*edge*\"}}, {\"range\": {\"metadata.content_metadata.year\": {\"gte\": 2020}}}, {\"terms\": {\"metadata.content_metadata.tags\": [\"technology\", \"safety\", \"latest\"]}}, {\"range\": {\"metadata.content_metadata.rating\": {\"gte\": 4.0}}}]}}]}}]\n", "}\n", "\n", "print(\"🔍 Query: What are the safety features and technology specifications for recent Ford vehicles with high ratings or Edge models from 2020-2024?\")\n", "print(\"🧠 Complex Filter combines:\")\n", "print(\" GROUP 1 (AND logic):\")\n", - "print(\" • String LIKE: content_metadata['manufacturer'] like '%ford%'\")\n", - "print(\" • Numeric: content_metadata['rating'] > 4.0\")\n", - "print(\" • Datetime range: content_metadata['created_date'] between '2020-01-01' and '2024-12-31'\")\n", - "print(\" • Boolean: content_metadata['is_public'] == true\")\n", + "print(\" • Wildcard: manufacturer.keyword: *ford*\")\n", + "print(\" • Range: rating gt 4.0\")\n", + "print(\" • Range: created_date gte 2020-01-01 lte 2024-12-31\")\n", + "print(\" • Term: is_public: true\")\n", "print(\" GROUP 2 (AND logic):\")\n", - "print(\" • String LIKE: content_metadata['model'] like '%edge%'\")\n", - "print(\" • Numeric: content_metadata['year'] >= 2020\")\n", - "print(\" • Array membership: content_metadata['tags'] in ['technology', 'safety', 'latest']\")\n", - "print(\" • Numeric: content_metadata['rating'] >= 4.0\")\n", + "print(\" • Wildcard: model.keyword: *edge*\")\n", + "print(\" • Range: year gte 2020\")\n", + "print(\" • Terms: tags in [technology, safety, latest]\")\n", + "print(\" • Range: rating gte 4.0\")\n", "print(\" COMBINED WITH: OR logic\")\n", "print(\"📋 Response:\\n\")\n", "\n", @@ -1180,19 +1186,19 @@ " \"max_tokens\": 1024,\n", " \"reranker_top_k\": 10,\n", " \"vdb_top_k\": 100,\n", - " \"vdb_endpoint\": \"http://milvus:19530\",\n", + " \"vdb_endpoint\": \"http://elasticsearch:9200\",\n", " \"collection_names\": [COLLECTION_NAME, COLLECTION_NAME_TRUCKS],\n", " \"enable_query_rewriting\": True,\n", " \"enable_reranker\": True,\n", " \"enable_citations\": True,\n", - " \"model\": \"nvidia/llama-3.3-nemotron-super-49b-v1.5\",\n", + " \"model\": \"nvidia/nemotron-3-super-120b-a12b\",\n", " \"reranker_model\": \"nvidia/llama-nemotron-rerank-1b-v2\",\n", - " \"embedding_model\": \"nvidia/llama-nemotron-embed-1b-v2\",\n", + " \"embedding_model\": \"nvidia/llama-nemotron-embed-vl-1b-v2\",\n", " \"stop\": [],\n", - " \"filter_expr\": 'array_contains(content_metadata[\"tags\"], \"eco-friendly\")'\n", + " \"filter_expr\": [{\"term\": {\"metadata.content_metadata.tags\": \"eco-friendly\"}}]\n", "}\n", "\n", - "print(\"🔍 Filter: array_contains(content_metadata['tags'], 'eco-friendly')\")\n", + "print('🔍 Filter: {\"term\": {\"metadata.content_metadata.tags\": \"eco-friendly\"}}')\n", "print(\"📋 Expected: Escape (2024) + Maverick (2024)\\n\")\n", "await print_streaming_response_and_citations(generate_answer(payload))\n", "\n", @@ -1250,15 +1256,15 @@ " \"max_tokens\": 1024,\n", " \"reranker_top_k\": 10,\n", " \"vdb_top_k\": 100,\n", - " \"vdb_endpoint\": \"http://milvus:19530\",\n", + " \"vdb_endpoint\": \"http://elasticsearch:9200\",\n", " \"collection_names\": [COLLECTION_NAME],\n", " \"enable_query_rewriting\": True,\n", " \"enable_reranker\": True,\n", " \"enable_citations\": True,\n", " \"enable_filter_generator\": True, # 🎯 NEW FEATURE - Enable AI filter generation\n", - " \"model\": \"nvidia/llama-3.3-nemotron-super-49b-v1.5\",\n", + " \"model\": \"nvidia/nemotron-3-super-120b-a12b\",\n", " \"reranker_model\": \"nvidia/llama-nemotron-rerank-1b-v2\",\n", - " \"embedding_model\": \"nvidia/llama-nemotron-embed-1b-v2\",\n", + " \"embedding_model\": \"nvidia/llama-nemotron-embed-vl-1b-v2\",\n", " \"stop\": [],\n", " \"filter_expr\": \"\" # Will be generated automatically by AI\n", "}\n", @@ -1317,14 +1323,14 @@ " \"max_tokens\": 1024,\n", " \"reranker_top_k\": 2,\n", " \"vdb_top_k\": 10,\n", - " \"vdb_endpoint\": \"http://milvus:19530\",\n", + " \"vdb_endpoint\": \"http://elasticsearch:9200\",\n", " \"collection_names\": [COLLECTION_NAME],\n", " \"enable_query_rewriting\": False,\n", " \"enable_reranker\": False,\n", " \"enable_citations\": False,\n", - " \"model\": \"nvidia/llama-3.3-nemotron-super-49b-v1.5\",\n", + " \"model\": \"nvidia/nemotron-3-super-120b-a12b\",\n", " \"reranker_model\": \"nvidia/llama-nemotron-rerank-1b-v2\",\n", - " \"embedding_model\": \"nvidia/llama-nemotron-embed-1b-v2\",\n", + " \"embedding_model\": \"nvidia/llama-nemotron-embed-vl-1b-v2\",\n", " # Provide url of the model endpoints if deployed elsewhere\n", " # \"llm_endpoint\": \"\",\n", " #\"embedding_endpoint\": \"\",\n", @@ -1384,17 +1390,17 @@ " \"max_tokens\": 1024,\n", " \"reranker_top_k\": 3,\n", " \"vdb_top_k\": 10,\n", - " \"vdb_endpoint\": \"http://milvus:19530\",\n", + " \"vdb_endpoint\": \"http://elasticsearch:9200\",\n", " \"collection_names\": [COLLECTION_NAME],\n", " \"enable_query_rewriting\": True,\n", " \"enable_reranker\": True,\n", " \"enable_citations\": True,\n", " \"enable_filter_generator\": False,\n", - " \"model\": \"nvidia/llama-3.3-nemotron-super-49b-v1.5\",\n", + " \"model\": \"nvidia/nemotron-3-super-120b-a12b\",\n", " \"reranker_model\": \"nvidia/llama-nemotron-rerank-1b-v2\",\n", - " \"embedding_model\": \"nvidia/llama-nemotron-embed-1b-v2\",\n", + " \"embedding_model\": \"nvidia/llama-nemotron-embed-vl-1b-v2\",\n", " \"stop\": [],\n", - " \"filter_expr\": 'content_metadata[\"nonexistent_field\"] == \"value\"' # This will cause an error\n", + " \"filter_expr\": [{\"term\": {\"metadata.content_metadata.nonexistent_field.keyword\": \"value\"}}] # This will cause an error\n", "}\n", "\n", "try:\n", @@ -1405,7 +1411,7 @@ "\n", "# Test 2: Invalid operator for field type\n", "print(\"🔍 Test 2: Invalid operator for string field\")\n", - "payload[\"filter_expr\"] = 'content_metadata[\"manufacturer\"] > 5' # Can't use > on string\n", + "payload[\"filter_expr\"] = [{\"range\": {\"metadata.content_metadata.manufacturer.keyword\": {\"gt\": 5}}}] # Can't use > on string\n", "\n", "try:\n", " await print_streaming_response_and_citations(generate_answer(payload))\n", @@ -1415,7 +1421,7 @@ "\n", "# Test 3: Invalid datetime format\n", "print(\"🔍 Test 3: Invalid datetime format\")\n", - "payload[\"filter_expr\"] = 'content_metadata[\"created_date\"] == \"invalid-date\"'\n", + "payload[\"filter_expr\"] = [{\"range\": {\"metadata.content_metadata.created_date\": {\"gte\": \"not-a-date\"}}}]\n", "\n", "try:\n", " await print_streaming_response_and_citations(generate_answer(payload))\n", @@ -1425,7 +1431,7 @@ "\n", "# Test 4: Empty array comparison\n", "print(\"🔍 Test 4: Empty array comparison\")\n", - "payload[\"filter_expr\"] = 'content_metadata[\"tags\"] == []'\n", + "payload[\"filter_expr\"] = [{\"terms\": {\"metadata.content_metadata.tags\": []}}]\n", "\n", "try:\n", " await print_streaming_response_and_citations(generate_answer(payload))\n", @@ -1435,7 +1441,7 @@ "\n", "# Test 5: Invalid boolean value\n", "print(\"🔍 Test 5: Invalid boolean value\")\n", - "payload[\"filter_expr\"] = 'content_metadata[\"is_public\"] == \"maybe\"'\n", + "payload[\"filter_expr\"] = [{\"term\": {\"metadata.content_metadata.is_public\": \"maybe\"}}]\n", "\n", "try:\n", " await print_streaming_response_and_citations(generate_answer(payload))\n", @@ -1445,7 +1451,7 @@ "\n", "# Test 6: Mixed data types in array\n", "print(\"🔍 Test 6: Mixed data types in array\")\n", - "payload[\"filter_expr\"] = 'content_metadata[\"tags\"] in [\"string\", 123, true]'\n", + "payload[\"filter_expr\"] = [{\"terms\": {\"metadata.content_metadata.tags\": [\"string\", 123, True]}}]\n", "\n", "try:\n", " await print_streaming_response_and_citations(generate_answer(payload))\n", @@ -1455,7 +1461,7 @@ "\n", "# Test 7: Invalid syntax\n", "print(\"🔍 Test 7: Invalid syntax\")\n", - "payload[\"filter_expr\"] = 'content_metadata[\"manufacturer\"] == \"ford\" and' # Incomplete expression\n", + "payload[\"filter_expr\"] = \"invalid_string_filter_not_a_list\" # Incomplete expression\n", "\n", "try:\n", " await print_streaming_response_and_citations(generate_answer(payload))\n", @@ -1465,7 +1471,7 @@ "\n", "# Test 8: Unsupported NULL operations\n", "print(\"🔍 Test 8: Unsupported NULL operations\")\n", - "payload[\"filter_expr\"] = 'content_metadata[\"manufacturer\"] is null'\n", + "payload[\"filter_expr\"] = [{\"exists\": {}}]\n", "\n", "try:\n", " await print_streaming_response_and_citations(generate_answer(payload))\n", @@ -1521,7 +1527,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3 (ipykernel)", + "display_name": ".venv", "language": "python", "name": "python3" }, @@ -1535,7 +1541,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.11" + "version": "3.12.3" } }, "nbformat": 4, diff --git a/notebooks/rag_event_ingest.ipynb b/notebooks/rag_event_ingest.ipynb index a38f976af..f41e37ad2 100644 --- a/notebooks/rag_event_ingest.ipynb +++ b/notebooks/rag_event_ingest.ipynb @@ -1,793 +1,791 @@ { - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Document Continuous Ingestion from Object Storage\n", - "\n", - "## Purpose\n", - "\n", - "This notebook demonstrates an **automated document ingestion pipeline** that:\n", - "\n", - "1. Monitors emulated object storage for new uploads via Kafka events\n", - "2. Routes documents to appropriate AI services for indexing\n", - "5. Enables RAG Agent for semantic search and contextual Q&A over all ingested content\n", - "\n", - "## What Gets Deployed\n", - "\n", - "1. **NVIDIA RAG** - Document indexing, vector search, and AI-powered Q&A (NIMs, Milvus, Ingestor)\n", - "2. **Continuous Ingestion** - Event-driven ingestion pipeline (Kafka, MinIO, Consumer)\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Prerequisites\n", - "\n", - "### Hardware\n", - "- **GPU**: 2x RTX PRO 6000 Blackwell or 2x H100\n", - "\n", - "#### Default GPU Assignment\n", - "\n", - "| GPU | Service |\n", - "|-----|---------|\n", - "| 0 | RAG NIMs (Embedding, Reranker) |\n", - "| 1 | RAG LLM NIM (Llama-3.3-Nemotron-Super-49B) |\n", - "\n", - "\n", - "### Software (pre-installed required)\n", - "- Ubuntu 22.04 or later\n", - "- Docker 24.0+ with Docker Compose v2\n", - "- NVIDIA Driver 570+\n", - "- NVIDIA Container Toolkit\n", - "\n", - "### API Keys\n", - "\n", - "\n", - "\n", - "\n", - "
KeyPurposeHow to Get
NGC_API_KEYDocker login, NIM deploymentsNGC Portal → Generate API Key
\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Table of Contents\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "
SectionDescription
SetupClone repo, install deps, set API keys, load helpers
Deploy RAGNIMs, Vector DB, Ingestor, RAG Server
Deploy Continuous IngestionKafka, MinIO, Consumer
TestingUpload documents, query RAG
Clean UpStop services, clean data
\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## References\n", - "\n", - "- **RAG Blueprint**: [NVIDIA RAG Documentation](https://github.com/NVIDIA-AI-Blueprints/rag/blob/main/docs/deploy-docker-self-hosted.md)\n", - "- **NIM**: [NVIDIA NIM Documentation](https://docs.nvidia.com/nim/index.html)\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Setup\n", - "\n", - "Clone the repository, configure API keys, and load helper functions.\n", - "\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## 1. Clone Repository\n", - "\n", - "Clone the RAG Blueprint repo to `~/rag`. This includes the consumer source code, deploy configs, and sample test data.\n", - "\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import subprocess, sys, os, shutil\n", - "\n", - "RAG_REPO_DIR = os.path.expanduser(\"~/rag\")\n", - "RAG_REPO_URL = \"https://github.com/NVIDIA-AI-Blueprints/rag.git\"\n", - "\n", - "# Ensure git-lfs is installed before any LFS operations\n", - "if not shutil.which(\"git-lfs\"):\n", - " print(\"[INSTALLING] git-lfs...\")\n", - " subprocess.run(\"sudo apt-get update && sudo apt-get install -y git-lfs && git lfs install\", shell=True, check=True)\n", - "else:\n", - " print(\"[OK] git-lfs found\")\n", - "\n", - "# Clone from correct branch (skip if already exists)\n", - "if not os.path.exists(RAG_REPO_DIR):\n", - " subprocess.run(f\"git clone {RAG_REPO_URL} {RAG_REPO_DIR}\", shell=True, check=True)\n", - "else:\n", - " print(f\"[OK] RAG repo already exists: {RAG_REPO_DIR}\")\n", - "subprocess.run(\"git lfs pull\", shell=True, cwd=RAG_REPO_DIR, check=True)\n", - "\n", - "# Verify\n", - "for path in [\"deploy/compose\", \"examples/rag_event_ingest/kafka_consumer\", \"examples/rag_event_ingest/data\"]:\n", - " status = \"[OK]\" if os.path.exists(os.path.join(RAG_REPO_DIR, path)) else \"[MISSING]\"\n", - " print(f\" {status} {path}\")\n", - "\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## 2. Install Dependencies\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "! python3 -m ensurepip --upgrade" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Ensure pip is available (some minimal Python installs lack it)\n", - "subprocess.run([sys.executable, \"-m\", \"ensurepip\", \"--upgrade\"], capture_output=True)\n", - "\n", - "def check_install_system_pkg(cmd: str, install_cmd: str):\n", - " if shutil.which(cmd):\n", - " print(f\" [OK] {cmd} found\")\n", - " return True\n", - " print(f\" [INSTALLING] {cmd}...\")\n", - " result = subprocess.run(install_cmd, shell=True, capture_output=True, text=True)\n", - " if result.returncode == 0:\n", - " print(f\" [OK] {cmd} installed\")\n", - " return True\n", - " print(f\" [ERROR] Failed to install {cmd}. Please install manually: {install_cmd}\")\n", - " return False\n", - "\n", - "check_install_system_pkg(\"git\", \"sudo apt-get update && sudo apt-get install -y git\")\n", - "\n", - "# Install Python packages\n", - "subprocess.check_call([sys.executable, \"-m\", \"pip\", \"install\", \"-q\", \"minio\", \"aiohttp\", \"requests\", \"python-dotenv\", \"pyyaml\"])\n", - "print(\"[OK] Ready\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## 3. Set API Keys\n", - "\n", - "Configure NGC API key for NIM deployments.\n", - "\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import getpass\n", - "\n", - "def set_api_key(env_var: str, prompt: str, required: bool = True):\n", - " if os.environ.get(env_var):\n", - " print(f\" [OK] {env_var} already set ({os.environ[env_var][:10]}...)\")\n", - " return True\n", - " key = getpass.getpass(prompt)\n", - " if key:\n", - " os.environ[env_var] = key\n", - " print(f\" [OK] {env_var} set\")\n", - " return True\n", - " if required:\n", - " print(f\" [ERROR] {env_var} is required\")\n", - " return False\n", - " print(f\" [SKIP] {env_var} (optional)\")\n", - " return True\n", - "\n", - "set_api_key(\"NGC_API_KEY\", \"Enter NGC_API_KEY (starts with 'nvapi-'): \", required=True)\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## 4. Helper Functions\n", - "\n", - "Shared utilities for deployment, file upload, status checks, and RAG queries.\n", - "\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Install dependencies\n", - "import sys\n", - "!{sys.executable} -m pip install -q minio aiohttp requests python-dotenv" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import os, sys, json, re, subprocess, time, socket, asyncio\n", - "import aiohttp, requests\n", - "from typing import List, Optional, Dict\n", - "\n", - "try:\n", - " from minio import Minio\n", - " from minio.error import S3Error\n", - "except ImportError:\n", - " subprocess.check_call([sys.executable, \"-m\", \"pip\", \"install\", \"-q\", \"minio\"])\n", - " from minio import Minio\n", - " from minio.error import S3Error\n", - "\n", - "# =============================================================================\n", - "# CONFIGURATION\n", - "# =============================================================================\n", - "\n", - "# Paths relative to RAG repo root\n", - "RAG_REPO_DIR = os.path.expanduser(\"~/rag\")\n", - "EXAMPLE_DIR = os.path.join(RAG_REPO_DIR, \"examples/rag_event_ingest\")\n", - "AIDP_COMPOSE_FILE = os.path.join(EXAMPLE_DIR, \"deploy/docker-compose.yaml\")\n", - "DATA_DIR = os.path.join(EXAMPLE_DIR, \"data\")\n", - "RAG_SERVER_URL = \"http://localhost:8081\"\n", - "INGESTOR_URL = \"http://localhost:8082\"\n", - "\n", - "LOCAL_NIM_CACHE = os.path.expanduser(\"~/.cache/nim\")\n", - "\n", - "MINIO_ENDPOINT = \"localhost:9201\"\n", - "MINIO_ACCESS_KEY = \"minioadmin\"\n", - "MINIO_SECRET_KEY = \"minioadmin\"\n", - "MINIO_BUCKET = \"aidp-bucket\"\n", - "MINIO_COLLECTION = \"aidp_bucket\"\n", - "MINIO_CONSOLE_PORT = 9211\n", - "\n", - "# =============================================================================\n", - "# SHARED UTILITIES\n", - "# =============================================================================\n", - "\n", - "def run_command(cmd: str, capture: bool = False) -> Optional[str]:\n", - " \"\"\"Execute a shell command and print it.\"\"\"\n", - " print(f\"$ {cmd}\")\n", - " result = subprocess.run(cmd, shell=True, capture_output=capture, text=True)\n", - " return result.stdout if capture else None\n", - "\n", - "def get_host_ip() -> str:\n", - " \"\"\"Get host IP address for external access URLs.\"\"\"\n", - " try:\n", - " s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)\n", - " s.connect((\"8.8.8.8\", 80))\n", - " ip = s.getsockname()[0]\n", - " s.close()\n", - " return ip\n", - " except OSError:\n", - " return \"localhost\"\n", - "\n", - "def get_minio_client() -> Minio:\n", - " \"\"\"Create MinIO client for AIDP bucket operations.\"\"\"\n", - " return Minio(MINIO_ENDPOINT, access_key=MINIO_ACCESS_KEY, secret_key=MINIO_SECRET_KEY, secure=False)\n", - "\n", - "def upload_file(local_path: str, object_name: Optional[str] = None) -> bool:\n", - " \"\"\"Upload a local file to MinIO AIDP bucket.\"\"\"\n", - " if not os.path.exists(local_path):\n", - " print(f\"[ERROR] File not found: {local_path}\")\n", - " return False\n", - " obj = object_name or os.path.basename(local_path)\n", - " try:\n", - " client = get_minio_client()\n", - " if not client.bucket_exists(MINIO_BUCKET):\n", - " client.make_bucket(MINIO_BUCKET)\n", - " client.fput_object(MINIO_BUCKET, obj, local_path)\n", - " print(f\"[OK] Uploaded: {obj}\")\n", - " return True\n", - " except S3Error as e:\n", - " print(f\"[ERROR] {e}\")\n", - " return False\n", - "\n", - "def verify_file_in_storage(object_name: str, bucket: str = MINIO_BUCKET) -> bool:\n", - " \"\"\"Check if a file exists in MinIO bucket and print verification status.\"\"\"\n", - " try:\n", - " client = get_minio_client()\n", - " stat = client.stat_object(bucket, object_name)\n", - " print(f\"[OK] File verified in storage:\")\n", - " print(f\" Bucket: {bucket}\")\n", - " print(f\" Object: {object_name}\")\n", - " print(f\" Size: {stat.size:,} bytes\")\n", - " print(f\" Modified: {stat.last_modified}\")\n", - " return True\n", - " except S3Error as e:\n", - " print(f\"[ERROR] File not found in storage: {object_name}\")\n", - " print(f\" Error: {e}\")\n", - " return False\n", - "\n", - "def get_consumer_logs(lines: int = 30) -> None:\n", - " \"\"\"Show recent Kafka consumer logs.\"\"\"\n", - " run_command(f\"docker logs kafka-consumer --tail {lines}\")\n", - "\n", - "async def query_rag(question: str, collection: str = None) -> Optional[str]:\n", - " \"\"\"Query RAG system and print the answer.\"\"\"\n", - " coll = collection or MINIO_COLLECTION\n", - " print(f\"Q: {question}\\nCollection: {coll}\\n\" + \"-\" * 40)\n", - "\n", - " payload = {\n", - " \"messages\": [{\"role\": \"user\", \"content\": question}],\n", - " \"use_knowledge_base\": True,\n", - " \"collection_names\": [coll],\n", - " }\n", - " try:\n", - " async with aiohttp.ClientSession() as session:\n", - " async with session.post(\n", - " f\"{RAG_SERVER_URL}/generate\", json=payload,\n", - " timeout=aiohttp.ClientTimeout(total=120),\n", - " ) as resp:\n", - " text = await resp.text()\n", - " # Parse SSE response: extract content from each \"data: {...}\" line\n", - " chunks = []\n", - " for line in text.split(\"\\n\"):\n", - " if not line.startswith(\"data: \") or line[6:] == \"[DONE]\":\n", - " continue\n", - " try:\n", - " msg = json.loads(line[6:]).get(\"choices\", [{}])[0].get(\"message\", {})\n", - " if msg.get(\"content\"):\n", - " chunks.append(msg[\"content\"])\n", - " except json.JSONDecodeError:\n", - " pass\n", - " answer = \"\".join(chunks)\n", - " print(f\"Answer: {answer}\")\n", - " return answer\n", - " except aiohttp.ClientError as e:\n", - " print(f\"[ERROR] {e}\")\n", - " return None\n", - "\n", - "print(f\"[OK] Helpers loaded | Host IP: {get_host_ip()}\")\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Deploy NVIDIA RAG\n", - "\n", - "Deploy the NVIDIA RAG: NIMs (LLM, Embedding, Reranker), Milvus vector database, Ingestor server, and RAG server.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "ngc_key = os.environ.get(\"NGC_API_KEY\")\n", - "if not ngc_key:\n", - " raise RuntimeError(\"NGC_API_KEY not set! Run the API keys cell first.\")\n", - "\n", - "os.chdir(RAG_REPO_DIR)\n", - "\n", - "# Set env vars needed by docker compose\n", - "os.environ[\"NGC_API_KEY\"] = ngc_key\n", - "os.environ[\"USERID\"] = f\"{os.getuid()}:{os.getgid()}\"\n", - "os.environ[\"COLLECTION_NAME\"] = MINIO_COLLECTION\n", - "\n", - "# Load RAG .env defaults (MODEL_DIRECTORY, etc.)\n", - "from dotenv import load_dotenv\n", - "env_file = os.path.join(RAG_REPO_DIR, \"deploy/compose/.env\")\n", - "if os.path.exists(env_file):\n", - " load_dotenv(env_file, override=False)\n", - "\n", - "# Login to nvcr.io\n", - "subprocess.run(f\"echo {ngc_key} | docker login nvcr.io -u '$oauthtoken' --password-stdin\",\n", - " shell=True, capture_output=True, text=True, executable=\"/bin/bash\")\n", - "\n", - "# Deploy components\n", - "for label, compose_file in [\n", - " (\"NIMs\", \"deploy/compose/nims.yaml\"),\n", - " (\"Vector DB\", \"deploy/compose/vectordb.yaml\"),\n", - "]:\n", - " print(f\"Deploying {label}...\")\n", - " run_command(f\"USERID=$(id -u) docker compose -f {compose_file} up -d\")\n", - "\n", - "print(\"Waiting 30s for Milvus...\")\n", - "time.sleep(30)\n", - "\n", - "for label, compose_file in [\n", - " (\"Ingestor\", \"deploy/compose/docker-compose-ingestor-server.yaml\"),\n", - " (\"RAG Server\", \"deploy/compose/docker-compose-rag-server.yaml\"),\n", - "]:\n", - " print(f\"Deploying {label}...\")\n", - " run_command(f\"docker compose -f {compose_file} up -d\")\n", - "\n", - "ip = get_host_ip()\n", - "print(f\"\\nRAG deployed: http://{ip}:8081 (server) | http://{ip}:8082 (ingestor) | http://{ip}:8090 (UI)\")\n", - "print(f\"COLLECTION_NAME: {MINIO_COLLECTION}\")\n", - "print(\"Wait ~10 minutes for NIMs to load models, then run the status check cell.\")\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Verify RAG services are healthy. Wait ~10 minutes for NIMs to load models.\n", - "\n", - "The deployment status should be:\n", - "```\n", - "NAMES STATUS\n", - "rag-frontend Up About a minute\n", - "rag-server Up About a minute\n", - "ingestor-server Up About a minute\n", - "milvus-standalone Up 2 minutes (healthy)\n", - "milvus-etcd Up 2 minutes (healthy)\n", - "milvus-minio Up 2 minutes (healthy)\n", - "nim-llm-ms Up 2 minutes (healthy)\n", - "nemotron-embedding-ms Up 2 minutes (healthy)\n", - "nemotron-ranking-ms Up 2 minutes (healthy)\n", - "```\n", - "\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Check service status and print access URLs\n", - "print(\"Wait ~10 minutes for services to become healthy.\")\n", - "print(\"Run this cell again after waiting.\\n\")\n", - "\n", - "ip = get_host_ip()\n", - "for name, port, path in [\n", - " (\"RAG Server\", 8081, \"/health\"), (\"Ingestor\", 8082, \"/health\"),\n", - " (\"Frontend\", 8090, \"/\"), (\"Milvus\", 19530, \"/v1/vector/collections\"),\n", - "]:\n", - " try:\n", - " s = \"[OK]\" if requests.get(f\"http://localhost:{port}{path}\", timeout=10).status_code == 200 else \"[WARN]\"\n", - " except requests.ConnectionError:\n", - " s = \"[DOWN]\"\n", - " except requests.Timeout:\n", - " s = \"[TIMEOUT]\"\n", - " print(f\" {s} {name}: http://{ip}:{port}\")\n", - "run_command(\"docker ps --format 'table {{.Names}}\\t{{.Status}}' | grep -E '(rag|milvus|ingestor|nim|nemotron|NAMES)'\")\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Deploy Continuous Ingestion from emulated object storage\n", - "\n", - "Deploy the Continuous Ingestion: Kafka message broker, MinIO object storage, and Kafka consumer for automated ingestion.\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## 1. Deploy Services\n", - "\n", - "Deploy Kafka, MinIO, and the Kafka consumer." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Verify prerequisites\n", - "net_check = subprocess.run(\"docker network inspect nvidia-rag\", shell=True, capture_output=True)\n", - "if net_check.returncode != 0:\n", - " raise RuntimeError(\"nvidia-rag network not found. Deploy RAG first.\")\n", - "\n", - "ngc_key = os.environ.get(\"NGC_API_KEY\", \"\")\n", - "if not ngc_key:\n", - " raise RuntimeError(\"NGC_API_KEY not set!\")\n", - "\n", - "host_ip = get_host_ip()\n", - "\n", - "# Set environment variables for docker compose\n", - "os.environ[\"HOST_IP\"] = host_ip\n", - "\n", - "# Login + pull + build\n", - "subprocess.run(f\"echo {ngc_key} | docker login nvcr.io -u '$oauthtoken' --password-stdin\",\n", - " shell=True, capture_output=True, text=True, executable=\"/bin/bash\")\n", - "\n", - "compose = f\"docker compose -f {AIDP_COMPOSE_FILE}\"\n", - "subprocess.run(f\"{compose} pull --ignore-pull-failures\", shell=True, capture_output=True, text=True, executable=\"/bin/bash\")\n", - "subprocess.run(f\"{compose} up -d --build\", shell=True, capture_output=True, text=True, executable=\"/bin/bash\")\n", - "\n", - "print(f\"Continuous Ingestion deployed:\")\n", - "print(f\" Kafka UI: http://{host_ip}:8080\")\n", - "print(f\" MinIO Console: http://{host_ip}:{MINIO_CONSOLE_PORT}\")\n", - "print(f\" Credentials: minioadmin / minioadmin\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Verify continuous ingestion services are running.\n", - "\n", - "The deployment status should be:\n", - "```\n", - "NAMES STATUS\n", - "kafka-consumer Up About a minute\n", - "aidp-kafka-ui Up About a minute\n", - "aidp-minio-mc Up About a minute\n", - "aidp-minio Up About a minute (healthy)\n", - "kafka Up About a minute (healthy)\n", - "```\n", - "\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Check service status and print access URLs\n", - "ip = get_host_ip()\n", - "for name, port, path in [\n", - " (\"Kafka UI\", 8080, \"/\"),\n", - " (\"MinIO Console\", MINIO_CONSOLE_PORT, \"/\"),\n", - "]:\n", - " try:\n", - " s = \"[OK]\" if requests.get(f\"http://localhost:{port}{path}\", timeout=10).status_code == 200 else \"[WARN]\"\n", - " except requests.ConnectionError:\n", - " s = \"[DOWN]\"\n", - " except requests.Timeout:\n", - " s = \"[TIMEOUT]\"\n", - " print(f\" {s} {name}: http://{ip}:{port}\")\n", - "\n", - "# Check kafka-consumer container status\n", - "result = subprocess.run(\"docker inspect -f '{{.State.Status}}' kafka-consumer 2>/dev/null\",\n", - " shell=True, capture_output=True, text=True)\n", - "status = result.stdout.strip()\n", - "s = \"[OK]\" if status == \"running\" else \"[DOWN]\"\n", - "print(f\" {s} Kafka Consumer: {status or 'not found'}\")\n", - "\n", - "run_command(\"docker ps --format 'table {{.Names}}\\t{{.Status}}' | grep -E '(kafka|minio|NAMES)'\")\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Testing\n", - "\n", - "Test the deployment by uploading documents, then querying via RAG.\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## 1. Document Upload\n", - "\n", - "Upload a PDF document to MinIO, which triggers automatic ingestion via Kafka consumer.\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### 1.1 Upload to Storage\n", - "\n", - "Upload the document to MinIO object storage.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Sample documents are included in the repo under examples/rag_event_ingest/data/\n", - "pdf_path = os.path.join(DATA_DIR, \"documents\", \"Seahawks-Patriots in Super Bowl LX_ What We Learned from Seattle's 29-13 win.pdf\")\n", - "upload_file(pdf_path, \"Seahawks-Patriots_SuperBowl_LX_Analysis.pdf\")\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### 1.2 Verify Document Ingestion" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Check consumer logs to verify document processing status.\n", - "\n", - "The logs should show the document being picked up and successfully ingested:\n", - "```\n", - "services.document_indexer - INFO - Task ...: PENDING (0s)\n", - "services.document_indexer - INFO - Task ...: PENDING (5s)\n", - "handlers.base - INFO - [DocumentHandler] ✓ Seahawks-Patriots_SuperBowl_LX_Analysis.pdf → SUCCESS\n", - "consumer - INFO - ✓ SUMMARY: Seahawks-Patriots_SuperBowl_LX_Analysis.pdf | Collection: aidp_bucket | Duration: 12.76s | Status: SUCCESS\n", - "```\n", - "\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Verify file landed in object storage\n", - "verify_file_in_storage(\"Seahawks-Patriots_SuperBowl_LX_Analysis.pdf\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### 1.3 Verify Document Ingestion\n", - "\n", - "Check consumer logs to verify document processing status.\n", - "\n", - "The logs should show the document being picked up and successfully ingested:\n", - "```\n", - "services.document_indexer - INFO - Task ...: PENDING (0s)\n", - "services.document_indexer - INFO - Task ...: PENDING (5s)\n", - "handlers.base - INFO - [DocumentHandler] ✓ Seahawks-Patriots_SuperBowl_LX_Analysis.pdf → SUCCESS\n", - "consumer - INFO - ✓ SUMMARY: Seahawks-Patriots_SuperBowl_LX_Analysis.pdf | Collection: aidp_bucket | Duration: 12.76s | Status: SUCCESS\n", - "```" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Check consumer logs for ingestion status\n", - "print(\"Waiting for document processing...\")\n", - "get_consumer_logs(50)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### 1.4 Query Document via RAG\n", - "\n", - "You can query the ingested document either **programmatically** below or via the **RAG Frontend UI**.\n", - "\n", - "> **💡 RAG Frontend**: Open `http://:8090` in your browser for an interactive Q&A interface.\n", - "> Make sure to select the collection **`aidp_bucket`** in the UI.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Query the document\n", - "await query_rag(\"What was the final score and who won Super Bowl LX?\", MINIO_COLLECTION)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Ask another question about the document.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Query about key takeaways\n", - "await query_rag(\"What were the key lessons learned from Seattle's victory in Super Bowl LX?\", MINIO_COLLECTION)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Clean Up\n", - "\n", - "Stop all services and clean up ingested data.\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## 1. Stop RAG Deployment\n", - "\n", - "Stop all RAG services (NIMs, Milvus, Ingestor, RAG server).\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "os.chdir(RAG_REPO_DIR)\n", - "for f in [\n", - " \"deploy/compose/docker-compose-rag-server.yaml\",\n", - " \"deploy/compose/docker-compose-ingestor-server.yaml\",\n", - " \"deploy/compose/vectordb.yaml\",\n", - " \"deploy/compose/nims.yaml\",\n", - "]:\n", - " run_command(f\"docker compose -f {f} down\")\n", - "print(\"[OK] RAG stopped\")\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## 2. Stop Continuous ingestion Deployment\n", - "\n", - "Stop Continuous ingestion services (Kafka, MinIO, Consumer).\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "run_command(f\"docker compose -f {AIDP_COMPOSE_FILE} down\")\n", - "print(\"[OK] Continuous ingestion stopped\")\n" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": ".venv", - "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.12.3" - } - }, - "nbformat": 4, - "nbformat_minor": 4 + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Document Continuous Ingestion from Object Storage\n", + "\n", + "## Purpose\n", + "\n", + "This notebook demonstrates an **automated document ingestion pipeline** that:\n", + "\n", + "1. Monitors emulated object storage for new uploads via Kafka events\n", + "2. Routes documents to appropriate AI services for indexing\n", + "5. Enables RAG Agent for semantic search and contextual Q&A over all ingested content\n", + "\n", + "## What Gets Deployed\n", + "\n", + "1. **NVIDIA RAG** - Document indexing, vector search, and AI-powered Q&A (NIMs, Elasticsearch, Ingestor)\n", + "2. **Continuous Ingestion** - Event-driven ingestion pipeline (Kafka, MinIO, Consumer)\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Prerequisites\n", + "\n", + "### Hardware\n", + "- **GPU**: 2x RTX PRO 6000 Blackwell or 2x H100\n", + "\n", + "#### Default GPU Assignment\n", + "\n", + "| GPU | Service |\n", + "|-----|---------|\n", + "| 0 | RAG NIMs (Embedding, Reranker) |\n", + "| 1 | RAG LLM NIM (Nemotron-3-Super-120B-A12B) |\n", + "\n", + "\n", + "### Software (pre-installed required)\n", + "- Ubuntu 22.04 or later\n", + "- Docker 24.0+ with Docker Compose v2\n", + "- NVIDIA Driver 570+\n", + "- NVIDIA Container Toolkit\n", + "\n", + "### API Keys\n", + "\n", + "\n", + "\n", + "\n", + "
KeyPurposeHow to Get
NGC_API_KEYDocker login, NIM deploymentsNGC Portal → Generate API Key
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Table of Contents\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "
SectionDescription
SetupClone repo, install deps, set API keys, load helpers
Deploy RAGNIMs, Vector DB, Ingestor, RAG Server
Deploy Continuous IngestionKafka, MinIO, Consumer
TestingUpload documents, query RAG
Clean UpStop services, clean data
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## References\n", + "\n", + "- **RAG Blueprint**: [NVIDIA RAG Documentation](https://github.com/NVIDIA-AI-Blueprints/rag/blob/main/docs/deploy-docker-self-hosted.md)\n", + "- **NIM**: [NVIDIA NIM Documentation](https://docs.nvidia.com/nim/index.html)\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Setup\n", + "\n", + "Clone the repository, configure API keys, and load helper functions.\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. Clone Repository\n", + "\n", + "Clone the RAG Blueprint repo to `~/rag`. This includes the consumer source code, deploy configs, and sample test data.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import subprocess, sys, os, shutil\n", + "\n", + "RAG_REPO_DIR = os.path.expanduser(\"~/rag\")\n", + "RAG_REPO_URL = \"https://github.com/NVIDIA-AI-Blueprints/rag.git\"\n", + "\n", + "# Ensure git-lfs is installed before any LFS operations\n", + "if not shutil.which(\"git-lfs\"):\n", + " print(\"[INSTALLING] git-lfs...\")\n", + " subprocess.run(\"sudo apt-get update && sudo apt-get install -y git-lfs && git lfs install\", shell=True, check=True)\n", + "else:\n", + " print(\"[OK] git-lfs found\")\n", + "\n", + "# Clone from correct branch (skip if already exists)\n", + "if not os.path.exists(RAG_REPO_DIR):\n", + " subprocess.run(f\"git clone {RAG_REPO_URL} {RAG_REPO_DIR}\", shell=True, check=True)\n", + "else:\n", + " print(f\"[OK] RAG repo already exists: {RAG_REPO_DIR}\")\n", + "subprocess.run(\"git lfs pull\", shell=True, cwd=RAG_REPO_DIR, check=True)\n", + "\n", + "# Verify\n", + "for path in [\"deploy/compose\", \"examples/rag_event_ingest/kafka_consumer\", \"examples/rag_event_ingest/data\"]:\n", + " status = \"[OK]\" if os.path.exists(os.path.join(RAG_REPO_DIR, path)) else \"[MISSING]\"\n", + " print(f\" {status} {path}\")\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. Install Dependencies\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "! python3 -m ensurepip --upgrade" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Ensure pip is available (some minimal Python installs lack it)\n", + "subprocess.run([sys.executable, \"-m\", \"ensurepip\", \"--upgrade\"], capture_output=True)\n", + "\n", + "def check_install_system_pkg(cmd: str, install_cmd: str):\n", + " if shutil.which(cmd):\n", + " print(f\" [OK] {cmd} found\")\n", + " return True\n", + " print(f\" [INSTALLING] {cmd}...\")\n", + " result = subprocess.run(install_cmd, shell=True, capture_output=True, text=True)\n", + " if result.returncode == 0:\n", + " print(f\" [OK] {cmd} installed\")\n", + " return True\n", + " print(f\" [ERROR] Failed to install {cmd}. Please install manually: {install_cmd}\")\n", + " return False\n", + "\n", + "check_install_system_pkg(\"git\", \"sudo apt-get update && sudo apt-get install -y git\")\n", + "\n", + "# Install Python packages\n", + "subprocess.check_call([sys.executable, \"-m\", \"pip\", \"install\", \"-q\", \"minio\", \"aiohttp\", \"requests\", \"python-dotenv\", \"pyyaml\"])\n", + "print(\"[OK] Ready\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. Set API Keys\n", + "\n", + "Configure NGC API key for NIM deployments.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import getpass\n", + "\n", + "def set_api_key(env_var: str, prompt: str, required: bool = True):\n", + " if os.environ.get(env_var):\n", + " print(f\" [OK] {env_var} already set ({os.environ[env_var][:10]}...)\")\n", + " return True\n", + " key = getpass.getpass(prompt)\n", + " if key:\n", + " os.environ[env_var] = key\n", + " print(f\" [OK] {env_var} set\")\n", + " return True\n", + " if required:\n", + " print(f\" [ERROR] {env_var} is required\")\n", + " return False\n", + " print(f\" [SKIP] {env_var} (optional)\")\n", + " return True\n", + "\n", + "set_api_key(\"NGC_API_KEY\", \"Enter NGC_API_KEY (starts with 'nvapi-'): \", required=True)\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 4. Helper Functions\n", + "\n", + "Shared utilities for deployment, file upload, status checks, and RAG queries.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Install dependencies\n", + "import sys\n", + "!{sys.executable} -m pip install -q minio aiohttp requests python-dotenv" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import os, sys, json, re, subprocess, time, socket, asyncio\n", + "import aiohttp, requests\n", + "from typing import List, Optional, Dict\n", + "\n", + "try:\n", + " from minio import Minio\n", + " from minio.error import S3Error\n", + "except ImportError:\n", + " subprocess.check_call([sys.executable, \"-m\", \"pip\", \"install\", \"-q\", \"minio\"])\n", + " from minio import Minio\n", + " from minio.error import S3Error\n", + "\n", + "# =============================================================================\n", + "# CONFIGURATION\n", + "# =============================================================================\n", + "\n", + "# Paths relative to RAG repo root\n", + "RAG_REPO_DIR = os.path.expanduser(\"~/rag\")\n", + "EXAMPLE_DIR = os.path.join(RAG_REPO_DIR, \"examples/rag_event_ingest\")\n", + "AIDP_COMPOSE_FILE = os.path.join(EXAMPLE_DIR, \"deploy/docker-compose.yaml\")\n", + "DATA_DIR = os.path.join(EXAMPLE_DIR, \"data\")\n", + "RAG_SERVER_URL = \"http://localhost:8081\"\n", + "INGESTOR_URL = \"http://localhost:8082\"\n", + "\n", + "LOCAL_NIM_CACHE = os.path.expanduser(\"~/.cache/nim\")\n", + "\n", + "MINIO_ENDPOINT = \"localhost:9201\"\n", + "MINIO_ACCESS_KEY = \"minioadmin\"\n", + "MINIO_SECRET_KEY = \"minioadmin\"\n", + "MINIO_BUCKET = \"aidp-bucket\"\n", + "MINIO_COLLECTION = \"aidp_bucket\"\n", + "MINIO_CONSOLE_PORT = 9211\n", + "\n", + "# =============================================================================\n", + "# SHARED UTILITIES\n", + "# =============================================================================\n", + "\n", + "def run_command(cmd: str, capture: bool = False) -> Optional[str]:\n", + " \"\"\"Execute a shell command and print it.\"\"\"\n", + " print(f\"$ {cmd}\")\n", + " result = subprocess.run(cmd, shell=True, capture_output=capture, text=True)\n", + " return result.stdout if capture else None\n", + "\n", + "def get_host_ip() -> str:\n", + " \"\"\"Get host IP address for external access URLs.\"\"\"\n", + " try:\n", + " s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)\n", + " s.connect((\"8.8.8.8\", 80))\n", + " ip = s.getsockname()[0]\n", + " s.close()\n", + " return ip\n", + " except OSError:\n", + " return \"localhost\"\n", + "\n", + "def get_minio_client() -> Minio:\n", + " \"\"\"Create MinIO client for AIDP bucket operations.\"\"\"\n", + " return Minio(MINIO_ENDPOINT, access_key=MINIO_ACCESS_KEY, secret_key=MINIO_SECRET_KEY, secure=False)\n", + "\n", + "def upload_file(local_path: str, object_name: Optional[str] = None) -> bool:\n", + " \"\"\"Upload a local file to MinIO AIDP bucket.\"\"\"\n", + " if not os.path.exists(local_path):\n", + " print(f\"[ERROR] File not found: {local_path}\")\n", + " return False\n", + " obj = object_name or os.path.basename(local_path)\n", + " try:\n", + " client = get_minio_client()\n", + " if not client.bucket_exists(MINIO_BUCKET):\n", + " client.make_bucket(MINIO_BUCKET)\n", + " client.fput_object(MINIO_BUCKET, obj, local_path)\n", + " print(f\"[OK] Uploaded: {obj}\")\n", + " return True\n", + " except S3Error as e:\n", + " print(f\"[ERROR] {e}\")\n", + " return False\n", + "\n", + "def verify_file_in_storage(object_name: str, bucket: str = MINIO_BUCKET) -> bool:\n", + " \"\"\"Check if a file exists in MinIO bucket and print verification status.\"\"\"\n", + " try:\n", + " client = get_minio_client()\n", + " stat = client.stat_object(bucket, object_name)\n", + " print(f\"[OK] File verified in storage:\")\n", + " print(f\" Bucket: {bucket}\")\n", + " print(f\" Object: {object_name}\")\n", + " print(f\" Size: {stat.size:,} bytes\")\n", + " print(f\" Modified: {stat.last_modified}\")\n", + " return True\n", + " except S3Error as e:\n", + " print(f\"[ERROR] File not found in storage: {object_name}\")\n", + " print(f\" Error: {e}\")\n", + " return False\n", + "\n", + "def get_consumer_logs(lines: int = 30) -> None:\n", + " \"\"\"Show recent Kafka consumer logs.\"\"\"\n", + " run_command(f\"docker logs kafka-consumer --tail {lines}\")\n", + "\n", + "async def query_rag(question: str, collection: str = None) -> Optional[str]:\n", + " \"\"\"Query RAG system and print the answer.\"\"\"\n", + " coll = collection or MINIO_COLLECTION\n", + " print(f\"Q: {question}\\nCollection: {coll}\\n\" + \"-\" * 40)\n", + "\n", + " payload = {\n", + " \"messages\": [{\"role\": \"user\", \"content\": question}],\n", + " \"use_knowledge_base\": True,\n", + " \"collection_names\": [coll],\n", + " }\n", + " try:\n", + " async with aiohttp.ClientSession() as session:\n", + " async with session.post(\n", + " f\"{RAG_SERVER_URL}/generate\", json=payload,\n", + " timeout=aiohttp.ClientTimeout(total=120),\n", + " ) as resp:\n", + " text = await resp.text()\n", + " # Parse SSE response: extract content from each \"data: {...}\" line\n", + " chunks = []\n", + " for line in text.split(\"\\n\"):\n", + " if not line.startswith(\"data: \") or line[6:] == \"[DONE]\":\n", + " continue\n", + " try:\n", + " msg = json.loads(line[6:]).get(\"choices\", [{}])[0].get(\"message\", {})\n", + " if msg.get(\"content\"):\n", + " chunks.append(msg[\"content\"])\n", + " except json.JSONDecodeError:\n", + " pass\n", + " answer = \"\".join(chunks)\n", + " print(f\"Answer: {answer}\")\n", + " return answer\n", + " except aiohttp.ClientError as e:\n", + " print(f\"[ERROR] {e}\")\n", + " return None\n", + "\n", + "print(f\"[OK] Helpers loaded | Host IP: {get_host_ip()}\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Deploy NVIDIA RAG\n", + "\n", + "Deploy the NVIDIA RAG: NIMs (LLM, Embedding, Reranker), Elasticsearch vector database, Ingestor server, and RAG server.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "ngc_key = os.environ.get(\"NGC_API_KEY\")\n", + "if not ngc_key:\n", + " raise RuntimeError(\"NGC_API_KEY not set! Run the API keys cell first.\")\n", + "\n", + "os.chdir(RAG_REPO_DIR)\n", + "\n", + "# Set env vars needed by docker compose\n", + "os.environ[\"NGC_API_KEY\"] = ngc_key\n", + "os.environ[\"USERID\"] = f\"{os.getuid()}:{os.getgid()}\"\n", + "os.environ[\"COLLECTION_NAME\"] = MINIO_COLLECTION\n", + "\n", + "# Load RAG .env defaults (MODEL_DIRECTORY, etc.)\n", + "from dotenv import load_dotenv\n", + "env_file = os.path.join(RAG_REPO_DIR, \"deploy/compose/.env\")\n", + "if os.path.exists(env_file):\n", + " load_dotenv(env_file, override=False)\n", + "\n", + "# Login to nvcr.io\n", + "subprocess.run(f\"echo {ngc_key} | docker login nvcr.io -u '$oauthtoken' --password-stdin\",\n", + " shell=True, capture_output=True, text=True, executable=\"/bin/bash\")\n", + "\n", + "# Deploy components\n", + "for label, compose_file in [\n", + " (\"NIMs\", \"deploy/compose/nims.yaml\"),\n", + " (\"Vector DB\", \"deploy/compose/vectordb.yaml\"),\n", + "]:\n", + " print(f\"Deploying {label}...\")\n", + " run_command(f\"USERID=$(id -u) docker compose -f {compose_file} up -d\")\n", + "\n", + "print(\"Waiting 30s for Elasticsearch...\")\n", + "time.sleep(30)\n", + "\n", + "for label, compose_file in [\n", + " (\"Ingestor\", \"deploy/compose/docker-compose-ingestor-server.yaml\"),\n", + " (\"RAG Server\", \"deploy/compose/docker-compose-rag-server.yaml\"),\n", + "]:\n", + " print(f\"Deploying {label}...\")\n", + " run_command(f\"docker compose -f {compose_file} up -d\")\n", + "\n", + "ip = get_host_ip()\n", + "print(f\"\\nRAG deployed: http://{ip}:8081 (server) | http://{ip}:8082 (ingestor) | http://{ip}:8090 (UI)\")\n", + "print(f\"COLLECTION_NAME: {MINIO_COLLECTION}\")\n", + "print(\"Wait ~10 minutes for NIMs to load models, then run the status check cell.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Verify RAG services are healthy. Wait ~10 minutes for NIMs to load models.\n", + "\n", + "The deployment status should be:\n", + "```\n", + "NAMES STATUS\n", + "rag-frontend Up About a minute\n", + "rag-server Up About a minute\n", + "ingestor-server Up About a minute\n", + "rag-elasticsearch Up 3 minutes (healthy)\n", + "nim-llm-ms Up 2 minutes (healthy)\n", + "nemotron-vlm-embedding-ms Up 2 minutes (healthy)\n", + "nemotron-ranking-ms Up 2 minutes (healthy)\n", + "```\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Check service status and print access URLs\n", + "print(\"Wait ~10 minutes for services to become healthy.\")\n", + "print(\"Run this cell again after waiting.\\n\")\n", + "\n", + "ip = get_host_ip()\n", + "for name, port, path in [\n", + " (\"RAG Server\", 8081, \"/health\"), (\"Ingestor\", 8082, \"/health\"),\n", + " (\"Frontend\", 8090, \"/\"), (\"Elasticsearch\", 9200, \"/\"),\n", + "]:\n", + " try:\n", + " s = \"[OK]\" if requests.get(f\"http://localhost:{port}{path}\", timeout=10).status_code == 200 else \"[WARN]\"\n", + " except requests.ConnectionError:\n", + " s = \"[DOWN]\"\n", + " except requests.Timeout:\n", + " s = \"[TIMEOUT]\"\n", + " print(f\" {s} {name}: http://{ip}:{port}\")\n", + "run_command(\"docker ps --format 'table {{.Names}}\\t{{.Status}}' | grep -E '(rag|elasticsearch|ingestor|nim|nemotron|NAMES)'\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Deploy Continuous Ingestion from emulated object storage\n", + "\n", + "Deploy the Continuous Ingestion: Kafka message broker, MinIO object storage, and Kafka consumer for automated ingestion.\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. Deploy Services\n", + "\n", + "Deploy Kafka, MinIO, and the Kafka consumer." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Verify prerequisites\n", + "net_check = subprocess.run(\"docker network inspect nvidia-rag\", shell=True, capture_output=True)\n", + "if net_check.returncode != 0:\n", + " raise RuntimeError(\"nvidia-rag network not found. Deploy RAG first.\")\n", + "\n", + "ngc_key = os.environ.get(\"NGC_API_KEY\", \"\")\n", + "if not ngc_key:\n", + " raise RuntimeError(\"NGC_API_KEY not set!\")\n", + "\n", + "host_ip = get_host_ip()\n", + "\n", + "# Set environment variables for docker compose\n", + "os.environ[\"HOST_IP\"] = host_ip\n", + "\n", + "# Login + pull + build\n", + "subprocess.run(f\"echo {ngc_key} | docker login nvcr.io -u '$oauthtoken' --password-stdin\",\n", + " shell=True, capture_output=True, text=True, executable=\"/bin/bash\")\n", + "\n", + "compose = f\"docker compose -f {AIDP_COMPOSE_FILE}\"\n", + "subprocess.run(f\"{compose} pull --ignore-pull-failures\", shell=True, capture_output=True, text=True, executable=\"/bin/bash\")\n", + "subprocess.run(f\"{compose} up -d --build\", shell=True, capture_output=True, text=True, executable=\"/bin/bash\")\n", + "\n", + "print(f\"Continuous Ingestion deployed:\")\n", + "print(f\" Kafka UI: http://{host_ip}:8080\")\n", + "print(f\" MinIO Console: http://{host_ip}:{MINIO_CONSOLE_PORT}\")\n", + "print(f\" Credentials: minioadmin / minioadmin\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Verify continuous ingestion services are running.\n", + "\n", + "The deployment status should be:\n", + "```\n", + "NAMES STATUS\n", + "kafka-consumer Up About a minute\n", + "aidp-kafka-ui Up About a minute\n", + "aidp-minio-mc Up About a minute\n", + "aidp-minio Up About a minute (healthy)\n", + "kafka Up About a minute (healthy)\n", + "```\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Check service status and print access URLs\n", + "ip = get_host_ip()\n", + "for name, port, path in [\n", + " (\"Kafka UI\", 8080, \"/\"),\n", + " (\"MinIO Console\", MINIO_CONSOLE_PORT, \"/\"),\n", + "]:\n", + " try:\n", + " s = \"[OK]\" if requests.get(f\"http://localhost:{port}{path}\", timeout=10).status_code == 200 else \"[WARN]\"\n", + " except requests.ConnectionError:\n", + " s = \"[DOWN]\"\n", + " except requests.Timeout:\n", + " s = \"[TIMEOUT]\"\n", + " print(f\" {s} {name}: http://{ip}:{port}\")\n", + "\n", + "# Check kafka-consumer container status\n", + "result = subprocess.run(\"docker inspect -f '{{.State.Status}}' kafka-consumer 2>/dev/null\",\n", + " shell=True, capture_output=True, text=True)\n", + "status = result.stdout.strip()\n", + "s = \"[OK]\" if status == \"running\" else \"[DOWN]\"\n", + "print(f\" {s} Kafka Consumer: {status or 'not found'}\")\n", + "\n", + "run_command(\"docker ps --format 'table {{.Names}}\\t{{.Status}}' | grep -E '(kafka|minio|NAMES)'\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Testing\n", + "\n", + "Test the deployment by uploading documents, then querying via RAG.\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. Document Upload\n", + "\n", + "Upload a PDF document to MinIO, which triggers automatic ingestion via Kafka consumer.\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 1.1 Upload to Storage\n", + "\n", + "Upload the document to MinIO object storage.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Sample documents are included in the repo under examples/rag_event_ingest/data/\n", + "pdf_path = os.path.join(DATA_DIR, \"documents\", \"Seahawks-Patriots in Super Bowl LX_ What We Learned from Seattle's 29-13 win.pdf\")\n", + "upload_file(pdf_path, \"Seahawks-Patriots_SuperBowl_LX_Analysis.pdf\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 1.2 Verify Document Ingestion" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Check consumer logs to verify document processing status.\n", + "\n", + "The logs should show the document being picked up and successfully ingested:\n", + "```\n", + "services.document_indexer - INFO - Task ...: PENDING (0s)\n", + "services.document_indexer - INFO - Task ...: PENDING (5s)\n", + "handlers.base - INFO - [DocumentHandler] ✓ Seahawks-Patriots_SuperBowl_LX_Analysis.pdf → SUCCESS\n", + "consumer - INFO - ✓ SUMMARY: Seahawks-Patriots_SuperBowl_LX_Analysis.pdf | Collection: aidp_bucket | Duration: 12.76s | Status: SUCCESS\n", + "```\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Verify file landed in object storage\n", + "verify_file_in_storage(\"Seahawks-Patriots_SuperBowl_LX_Analysis.pdf\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 1.3 Verify Document Ingestion\n", + "\n", + "Check consumer logs to verify document processing status.\n", + "\n", + "The logs should show the document being picked up and successfully ingested:\n", + "```\n", + "services.document_indexer - INFO - Task ...: PENDING (0s)\n", + "services.document_indexer - INFO - Task ...: PENDING (5s)\n", + "handlers.base - INFO - [DocumentHandler] ✓ Seahawks-Patriots_SuperBowl_LX_Analysis.pdf → SUCCESS\n", + "consumer - INFO - ✓ SUMMARY: Seahawks-Patriots_SuperBowl_LX_Analysis.pdf | Collection: aidp_bucket | Duration: 12.76s | Status: SUCCESS\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Check consumer logs for ingestion status\n", + "print(\"Waiting for document processing...\")\n", + "get_consumer_logs(50)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 1.4 Query Document via RAG\n", + "\n", + "You can query the ingested document either **programmatically** below or via the **RAG Frontend UI**.\n", + "\n", + "> **💡 RAG Frontend**: Open `http://:8090` in your browser for an interactive Q&A interface.\n", + "> Make sure to select the collection **`aidp_bucket`** in the UI.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Query the document\n", + "await query_rag(\"What was the final score and who won Super Bowl LX?\", MINIO_COLLECTION)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Ask another question about the document.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Query about key takeaways\n", + "await query_rag(\"What were the key lessons learned from Seattle's victory in Super Bowl LX?\", MINIO_COLLECTION)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Clean Up\n", + "\n", + "Stop all services and clean up ingested data.\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. Stop RAG Deployment\n", + "\n", + "Stop all RAG services (NIMs, Elasticsearch, Ingestor, RAG server).\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "os.chdir(RAG_REPO_DIR)\n", + "for f in [\n", + " \"deploy/compose/docker-compose-rag-server.yaml\",\n", + " \"deploy/compose/docker-compose-ingestor-server.yaml\",\n", + " \"deploy/compose/vectordb.yaml\",\n", + " \"deploy/compose/nims.yaml\",\n", + "]:\n", + " run_command(f\"docker compose -f {f} down\")\n", + "print(\"[OK] RAG stopped\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. Stop Continuous ingestion Deployment\n", + "\n", + "Stop Continuous ingestion services (Kafka, MinIO, Consumer).\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "run_command(f\"docker compose -f {AIDP_COMPOSE_FILE} down\")\n", + "print(\"[OK] Continuous ingestion stopped\")\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "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.12.3" + } + }, + "nbformat": 4, + "nbformat_minor": 4 } diff --git a/notebooks/rag_library_lite_usage.ipynb b/notebooks/rag_library_lite_usage.ipynb index 915eb00ca..af978dde1 100644 --- a/notebooks/rag_library_lite_usage.ipynb +++ b/notebooks/rag_library_lite_usage.ipynb @@ -34,9 +34,9 @@ "uv pip install nvidia-rag[all]\n", "```\n", "\n", - "Install nv-ingest library using below command - **OR** - Run the cell below if Jupyter notebook is started in the same environment:\n", + "Install nv-ingest-related libraries using below command - **OR** - Run the cell below if Jupyter notebook is started in the same environment (versions match `pyproject.toml` `[all]` / `ingest` extras):\n", "```bash\n", - "uv pip install nv-ingest==26.1.2\n", + "!uv pip install \"nv-ingest==26.3.0\" \"nv-ingest-api==26.3.0\" \"nv-ingest-client==26.3.0\"\n", "```" ] }, @@ -70,8 +70,8 @@ "# !cd .. && uv build\n", "# !uv pip install ../dist/nvidia_rag-*-py3-none-any.whl[all]\n", "\n", - "# Install NV-Ingest library in the same environment to run NV-Ingest pipeline\n", - "!uv pip install nv-ingest==26.1.2" + "# Install NV-Ingest / NeMo Retriever libraries (pins match pyproject.toml ingest/all extras)\n", + "!uv pip install \"nv-ingest==26.3.0\" \"nv-ingest-api==26.3.0\" \"nv-ingest-client==26.3.0\"" ] }, { @@ -113,7 +113,7 @@ "metadata": {}, "outputs": [], "source": [ - "# del os.environ['NVIDIA_API_KEY'] ## delete key and reset if needed\n", + "# del os.environ['NGC_API_KEY'] ## delete key and reset if needed\n", "if os.environ.get(\"NGC_API_KEY\", \"\").startswith(\"nvapi-\"):\n", " print(\"Valid NGC_API_KEY already in environment. Delete to reset\")\n", "else:\n", @@ -147,7 +147,7 @@ "outputs": [], "source": [ "# This are used by nv-ingest-ms-runtime container when using cloud models\n", - "os.environ[\"OCR_HTTP_ENDPOINT\"] = \"https://ai.api.nvidia.com/v1/cv/nvidia/nemoretriever-ocr\"\n", + "os.environ[\"OCR_HTTP_ENDPOINT\"] = \"https://ai.api.nvidia.com/v1/cv/nvidia/nemotron-ocr-v1\"\n", "os.environ[\"OCR_INFER_PROTOCOL\"] = \"http\"\n", "os.environ[\"YOLOX_HTTP_ENDPOINT\"] = (\n", " \"https://ai.api.nvidia.com/v1/cv/nvidia/nemotron-page-elements-v3\"\n", @@ -243,8 +243,8 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## Set logging level\n", - "First let's set the required logging level. Set to INFO for displaying basic important logs. Set to DEBUG for full verbosity." + "## Configure notebook logging\n", + "Set the notebook log level and hide known benign Milvus Lite compatibility noise. Unrelated errors remain visible." ] }, { @@ -252,66 +252,24 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": [ - "import logging\n", - "import os\n", - "\n", - "# Set the log level via environment variable before importing nvidia_rag\n", - "# This ensures the package respects our log level setting\n", - "LOGLEVEL = logging.WARNING # Set to INFO, DEBUG, WARNING or ERROR\n", - "os.environ[\"LOGLEVEL\"] = logging.getLevelName(LOGLEVEL)\n", - "\n", - "# Configure logging\n", - "logging.basicConfig(level=LOGLEVEL, force=True)\n", - "\n", - "# Set log levels for specific loggers after package import\n", - "for name in logging.root.manager.loggerDict:\n", - " if name == \"nvidia_rag\" or name.startswith(\"nvidia_rag.\"):\n", - " logging.getLogger(name).setLevel(LOGLEVEL)\n", - " if name == \"nv_ingest_client\" or name.startswith(\"nv_ingest_client.\"):\n", - " logging.getLogger(name).setLevel(LOGLEVEL)" - ] + "source": "import logging\nimport os\nimport traceback\nimport warnings\n\n# Set the log level via environment variable before importing nvidia_rag\n# This ensures the package respects our log level setting\nLOGLEVEL = logging.WARNING # Set to INFO, DEBUG, WARNING or ERROR\nos.environ[\"LOGLEVEL\"] = logging.getLevelName(LOGLEVEL)\n\n# Configure logging\nlogging.basicConfig(level=LOGLEVEL, force=True)\n\n# Keep known benign Milvus Lite/PyMilvus compatibility noise out of notebook output.\n# These messages can appear during collection creation even when the operation succeeds.\nwarnings.filterwarnings(\n \"ignore\",\n message=r\"`connections\\.(has_connection|connect)` is an ORM-style PyMilvus API.*\",\n)\n# nv_ingest_client uses ORM-style pymilvus (Collection, utility.list_indexes) which\n# emits PyMilvusDeprecationWarning. We don't control that code path; suppress the\n# warning so it doesn't drown out real signals in the notebook output.\nwarnings.filterwarnings(\n \"ignore\",\n message=r\"`(Collection|utility\\.[a-zA-Z_]+)` is an ORM-style PyMilvus API.*\",\n)\n\n\nclass _GrpcAllocTimestampFilter(logging.Filter):\n def filter(self, record: logging.LogRecord) -> bool:\n if record.name != \"grpc._server\":\n return True\n benign_message = \"Exception calling application: Method not implemented!\"\n if benign_message not in record.getMessage():\n return True\n if not record.exc_info:\n return True\n exc_text = \"\".join(traceback.format_exception(*record.exc_info))\n return \"AllocTimestamp\" not in exc_text\n\n\ngrpc_logger = logging.getLogger(\"grpc._server\")\nif not any(\n item.__class__.__name__ == \"_GrpcAllocTimestampFilter\"\n for item in grpc_logger.filters\n):\n grpc_logger.addFilter(_GrpcAllocTimestampFilter())\n\n\nclass _MilvusLiteIteratorNoiseFilter(logging.Filter):\n \"\"\"Hide pymilvus iterator warnings that fire on every query against milvus-lite.\n\n The mvccTs RPC is not implemented in lite, so the iterator falls back to a\n client-side timestamp and prints a WARNING per query. The fallback is the\n correct behavior in lite; the warning is purely noise.\n \"\"\"\n\n def filter(self, record: logging.LogRecord) -> bool:\n if record.name != \"pymilvus.orm.iterator\":\n return True\n return \"failed to get mvccTs\" not in record.getMessage()\n\n\niterator_logger = logging.getLogger(\"pymilvus.orm.iterator\")\nif not any(\n item.__class__.__name__ == \"_MilvusLiteIteratorNoiseFilter\"\n for item in iterator_logger.filters\n):\n iterator_logger.addFilter(_MilvusLiteIteratorNoiseFilter())\n\n# Set log levels for specific loggers used by the notebook.\nfor name in (\"nvidia_rag\", \"nv_ingest_client\"):\n logging.getLogger(name).setLevel(LOGLEVEL)\n\nfor name in list(logging.root.manager.loggerDict):\n if name.startswith(\"nvidia_rag.\") or name.startswith(\"nv_ingest_client.\"):\n logging.getLogger(name).setLevel(LOGLEVEL)\n\nprint(\n f\"Notebook logging set to {logging.getLevelName(LOGLEVEL)}. \"\n \"Known benign Milvus Lite compatibility messages will be hidden.\"\n)" }, { "cell_type": "markdown", "metadata": {}, - "source": [ - "## Initialize the NvidiaRAGIngestor Package in Lite Mode\n", - "\n", - "Import `NvidiaRAGIngestor` to access APIs for document upload and management operations." - ] + "source": "## Initialize the NvidiaRAGIngestor Package in Lite Mode\n\nImport `NvidiaRAGIngestor` to access APIs for document upload and management operations.\n\n> **⚠️ Re-running this cell starts from a clean Milvus Lite database.**\n>\n> This cell intentionally resets Milvus Lite state on every execution to avoid a milvus-lite layout-selection bug that corrupts the on-disk WAL on rerun (manifests during document upload as `No such file or directory: '.../milvus-lite.db/collections//wal/wal_data_*.arrow'`).\n>\n> Concretely:\n> - **Default (no `MILVUS_LITE_DB_PATH` set):** a fresh per-session directory under the OS temp dir is used. Any collections and documents created in a previous run remain on disk at the old path but are no longer reachable through the new `ingestor`.\n> - **Override (`MILVUS_LITE_DB_PATH` set):** if a database already exists at that path it is **wiped** before the new milvus-lite server starts.\n>\n> **What this means for you:** if you re-run this cell after uploading documents (for example, to retry a failed upload), the collection will appear to \"no longer exist\". That is expected — it is the prior session's database; the new ingestor is pointed at a fresh one. To keep working with the same collection within a session, **do not re-run this cell** — reuse the existing `ingestor` object across the cells below." }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], - "source": [ - "from nvidia_rag import NvidiaRAGIngestor\n", - "from nvidia_rag.utils.configuration import NvidiaRAGConfig\n", - "\n", - "config_ingestor = NvidiaRAGConfig.from_yaml(\"config.yaml\")\n", - "# You can update the config object to use different models and endpoints like below\n", - "# config_ingestor.embeddings.model_name = \"nvidia/llama-nemotron-embed-1b-v2\"\n", - "# config_ingestor.embeddings.server_url = \"https://integrate.api.nvidia.com/v1\"\n", - "\n", - "# Set config for rag lite library mode\n", - "config_ingestor.vector_store.url = \"./milvus-lite.db\"\n", - "config_ingestor.nv_ingest.message_client_port = 7671 # Port for NV-Ingest libary mode\n", - "\n", - "# Set config for cloud API endpoints\n", - "config_ingestor.embeddings.server_url = \"https://integrate.api.nvidia.com/v1\"\n", - "\n", - "ingestor = NvidiaRAGIngestor(config=config_ingestor, mode=\"lite\")" - ] + "source": "import fcntl\nimport os\nimport shutil\nimport tempfile\nimport uuid\nfrom pathlib import Path\n\nfrom nvidia_rag import NvidiaRAGIngestor\nfrom nvidia_rag.utils.configuration import NvidiaRAGConfig\n\n\ndef _milvus_lite_lock_files(db_path: Path) -> list[Path]:\n \"\"\"Return possible lock-file locations for either milvus-lite layout.\"\"\"\n candidates = [\n db_path.with_name(f\".{db_path.name}.lock\"),\n db_path.with_name(f\"{db_path.name}.lock\"),\n ]\n if db_path.is_dir():\n candidates.append(db_path / \"LOCK\")\n return candidates\n\n\ndef _milvus_lite_path_is_locked(db_path: Path) -> bool:\n \"\"\"True iff another process holds the milvus-lite fcntl lock on this path.\"\"\"\n for lock_file in _milvus_lite_lock_files(db_path):\n if not lock_file.exists():\n continue\n try:\n with open(lock_file, \"rb\") as f:\n fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB)\n fcntl.flock(f, fcntl.LOCK_UN)\n except BlockingIOError:\n return True\n except OSError:\n continue\n return False\n\n\ndef _wipe_milvus_lite_path(db_path: Path) -> None:\n \"\"\"Delete the milvus-lite data and any sibling lock files.\"\"\"\n if db_path.is_dir():\n shutil.rmtree(db_path)\n elif db_path.exists():\n db_path.unlink()\n for lock_file in _milvus_lite_lock_files(db_path):\n if lock_file.exists():\n try:\n lock_file.unlink()\n except OSError:\n pass\n\n\ndef _resolve_milvus_lite_path() -> str:\n \"\"\"Pick a milvus-lite database path that is safe to use on every rerun.\n\n Default: a fresh, per-call directory under the system temp dir. This\n guarantees:\n - clean state every time this cell runs, so stale files or directories\n from a previous session can't trigger milvus-lite's layout-selection\n bug, which surfaces during document upload as:\n ``[Errno 2] No such file or directory:\n '.../milvus-lite.db/collections//wal/wal_data_*.arrow'``\n - a local filesystem, avoiding issues seen on network/shared mounts\n (NFS, Lustre, etc.) where SQLite WAL locking and lazy directory\n creation can break.\n\n Override by setting the ``MILVUS_LITE_DB_PATH`` env var before running this\n cell if you need to control where the database lives. On override, if a\n stale db is found at that path it is wiped first so the new milvus-lite\n server starts from a clean layout. If something is still holding the lock\n (e.g. a previous kernel that hasn't exited) we refuse to wipe and ask\n the user to restart the kernel -- silently moving live data aside is what\n was corrupting the WAL on shared filesystems.\n \"\"\"\n # Stop any milvus-lite server already running in this Python process before\n # we point at a new (or the same overridden) path, so the previous\n # subprocess releases its file handles cleanly.\n try:\n from milvus_lite.server_manager import server_manager_instance\n\n server_manager_instance.release_all()\n except Exception:\n pass\n\n # Drop stale pymilvus ORM aliases left from the previous run. nv_ingest_client\n # writes via connections.connect(uri=..., token=...) without an alias, so it\n # binds to \"default\"; on the next run pymilvus refuses to rebind \"default\" to\n # the new milvus-lite path and raises ConnectionConfigException(ConnDiffConf).\n # Every milvus-lite subprocess was just released above, so existing aliases\n # point to dead handlers and are safe to remove.\n try:\n from pymilvus import connections as _pm_connections\n\n for _alias_name, _ in list(_pm_connections.list_connections()):\n try:\n _pm_connections.remove_connection(_alias_name)\n except Exception:\n pass\n except Exception:\n pass\n\n override = os.environ.get(\"MILVUS_LITE_DB_PATH\", \"\").strip()\n if override:\n db_path = Path(override).expanduser().absolute()\n db_path.parent.mkdir(parents=True, exist_ok=True)\n\n if db_path.exists():\n if _milvus_lite_path_is_locked(db_path):\n raise RuntimeError(\n f\"{db_path} is locked by another process. Restart the \"\n \"Jupyter kernel before re-running this cell, or unset \"\n \"MILVUS_LITE_DB_PATH to use a fresh per-session path.\"\n )\n _wipe_milvus_lite_path(db_path)\n print(f\"Reset stale milvus-lite state at {db_path}\")\n return str(db_path)\n\n # Per-call UUID-suffixed directory so rapid reruns inside the same second\n # in the same process can't collide on an existing path.\n session_dir = (\n Path(tempfile.gettempdir())\n / f\"nvidia-rag-lite-{os.getpid()}-{uuid.uuid4().hex[:8]}\"\n )\n session_dir.mkdir(parents=True, exist_ok=False)\n return str(session_dir / \"milvus.db\")\n\n\nMILVUS_LITE_DB_PATH = _resolve_milvus_lite_path()\nprint(f\"Milvus Lite database: {MILVUS_LITE_DB_PATH}\")\n\nconfig_ingestor = NvidiaRAGConfig.from_yaml(\"config.yaml\")\n# You can update the config object to use different models and endpoints like below\n# Example: switch to the text-only embedding model\n# config_ingestor.embeddings.model_name = \"nvidia/llama-nemotron-embed-1b-v2\"\n# config_ingestor.embeddings.server_url = \"https://integrate.api.nvidia.com/v1\"\n\n# Set config for rag lite library mode\n# Lite mode uses the embedded Milvus Lite backend; override `name` so it\n# doesn't fall through to the default backend from config.yaml.\nconfig_ingestor.vector_store.name = \"milvus\"\nconfig_ingestor.vector_store.url = MILVUS_LITE_DB_PATH\nconfig_ingestor.nv_ingest.message_client_port = 7671 # Port for NV-Ingest libary mode\n\n# Set config for cloud API endpoints\nconfig_ingestor.embeddings.server_url = \"https://integrate.api.nvidia.com/v1\"\n\ningestor = NvidiaRAGIngestor(config=config_ingestor, mode=\"lite\")" }, { "cell_type": "markdown", "metadata": {}, - "source": [ - "## 1. Create a new collection\n", - "Creates a new collection in the vector database." - ] + "source": "## 1. Create a new collection\nCreates a new collection in the vector database. Each run of the initialization cell above starts from an empty Milvus Lite database (see warning there), so this call always creates a brand-new collection — running it after upload does not return the previously created one." }, { "cell_type": "code", @@ -477,7 +435,12 @@ "config_rag = NvidiaRAGConfig.from_yaml(\"config.yaml\")\n", "\n", "# Set config for rag lite library mode\n", - "config_rag.vector_store.url = \"./milvus-lite.db\"\n", + "# Lite mode uses the embedded Milvus Lite backend; override `name` so it\n", + "# doesn't fall through to the default backend from config.yaml.\n", + "# Reuse the same MILVUS_LITE_DB_PATH set above so this RAG client opens the\n", + "# database that the ingestor just populated.\n", + "config_rag.vector_store.name = \"milvus\"\n", + "config_rag.vector_store.url = MILVUS_LITE_DB_PATH\n", "config_rag.enable_citations = False\n", "\n", "# Set config for cloud API endpoints\n", @@ -605,7 +568,7 @@ "source": [ "await print_streaming_response_and_citations(\n", " await rag.generate(\n", - " messages=[{\"role\": \"user\", \"content\": \"What is the price of a hammer?\"}],\n", + " messages=[{\"role\": \"user\", \"content\": \"What activity is the lion doing?\"}],\n", " use_knowledge_base=True,\n", " collection_names=[\"test_library\"],\n", " enable_citations=False,\n", @@ -675,7 +638,7 @@ "source": [ "print_search_citations(\n", " await rag.search(\n", - " query=\"What is the price of a hammer?\",\n", + " query=\"What activity is the lion doing?\",\n", " collection_names=[\"test_library\"],\n", " reranker_top_k=10,\n", " vdb_top_k=100,\n", @@ -762,20 +725,13 @@ "\n", "echo \"NV-Ingest Pipeline subprocess check completed\"" ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] } ], "metadata": { "kernelspec": { - "display_name": "rag-library", + "display_name": "notebooks (3.11.10)", "language": "python", - "name": "rag-library" + "name": "python3" }, "language_info": { "codemirror_mode": { @@ -787,9 +743,9 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.3" + "version": "3.11.10" } }, "nbformat": 4, "nbformat_minor": 4 -} +} \ No newline at end of file diff --git a/notebooks/rag_library_usage.ipynb b/notebooks/rag_library_usage.ipynb index 894e82620..415498680 100644 --- a/notebooks/rag_library_usage.ipynb +++ b/notebooks/rag_library_usage.ipynb @@ -162,7 +162,7 @@ "metadata": {}, "outputs": [], "source": [ - "# del os.environ['NVIDIA_API_KEY'] ## delete key and reset if needed\n", + "# del os.environ['NGC_API_KEY'] ## delete key and reset if needed\n", "if os.environ.get(\"NGC_API_KEY\", \"\").startswith(\"nvapi-\"):\n", " print(\"Valid NGC_API_KEY already in environment. Delete to reset\")\n", "else:\n", @@ -193,9 +193,9 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "### 2. Setup the Milvus vector DB services\n", - "By default milvus uses GPU Indexing. Ensure you have provided correct GPU ID.\n", - "Note: If you don't have a GPU available, you can switch to CPU-only Milvus by following the instructions in [milvus-configuration.md](https://github.com/NVIDIA-AI-Blueprints/rag/blob/main/docs/milvus-configuration.md)." + "### 2. Setup the Elasticsearch vector DB services\n", + "Elasticsearch is the default vector store.\n", + "See [change-vectordb.md](https://github.com/NVIDIA-AI-Blueprints/rag/blob/main/docs/change-vectordb.md) to switch to Milvus if needed." ] }, { @@ -310,10 +310,10 @@ "NAMES STATUS\n", "nemotron-ranking-ms Up ... (healthy)\n", "compose-page-elements-1 Up ...\n", - "compose-nemoretriever-ocr-1 Up ...\n", + "compose-nemotron-ocr-1 Up ...\n", "compose-graphic-elements-1 Up ...\n", "compose-table-structure-1 Up ...\n", - "nemotron-embedding-ms Up ... (healthy)\n", + "nemotron-vlm-embedding-ms Up ... (healthy)\n", "nim-llm-ms Up ... (healthy)\n", "```" ] @@ -335,7 +335,7 @@ "DEPLOYMENT_MODE = \"cloud\"\n", "\n", "# Configure NV-Ingest to use NVIDIA hosted cloud APIs\n", - "os.environ[\"OCR_HTTP_ENDPOINT\"] = \"https://ai.api.nvidia.com/v1/cv/nvidia/nemoretriever-ocr\"\n", + "os.environ[\"OCR_HTTP_ENDPOINT\"] = \"https://ai.api.nvidia.com/v1/cv/nvidia/nemotron-ocr-v1\"\n", "os.environ[\"OCR_INFER_PROTOCOL\"] = \"http\"\n", "os.environ[\"YOLOX_HTTP_ENDPOINT\"] = (\n", " \"https://ai.api.nvidia.com/v1/cv/nvidia/nemotron-page-elements-v3\"\n", @@ -401,7 +401,7 @@ "\n", "# Set the log level via environment variable before importing nvidia_rag\n", "# This ensures the package respects our log level setting\n", - "LOGLEVEL = logging.WARNING # Set to INFO, DEBUG, WARNING or ERROR\n", + "LOGLEVEL = logging.INFO # Set to INFO, DEBUG, WARNING or ERROR\n", "os.environ[\"LOGLEVEL\"] = logging.getLevelName(LOGLEVEL)\n", "\n", "# Configure logging\n", @@ -440,7 +440,7 @@ " config_ingestor.llm.server_url = \"\" # Empty uses NVIDIA API catalog\n", " config_ingestor.summarizer.server_url = \"\" # Empty uses NVIDIA API catalog\n", "else:\n", - " config_ingestor.embeddings.server_url = \"http://nemotron-embedding-ms:8000/v1\"\n", + " config_ingestor.embeddings.server_url = \"http://nemotron-vlm-embedding-ms:8000/v1\"\n", "ingestor = NvidiaRAGIngestor(config=config_ingestor)" ] }, @@ -460,7 +460,7 @@ "source": [ "response = ingestor.create_collection(\n", " collection_name=\"test_library\",\n", - " vdb_endpoint=\"http://localhost:19530\",\n", + " vdb_endpoint=\"http://localhost:9200\",\n", " # [Optional]: Create collection with metadata schema, uncomment to create collection with metadata schemas\n", " # metadata_schema = [\n", " # {\n", @@ -487,7 +487,7 @@ "metadata": {}, "outputs": [], "source": [ - "response = ingestor.get_collections(vdb_endpoint=\"http://localhost:19530\")\n", + "response = ingestor.get_collections(vdb_endpoint=\"http://localhost:9200\")\n", "print(response)" ] }, @@ -507,7 +507,7 @@ "source": [ "response = await ingestor.upload_documents(\n", " collection_name=\"test_library\",\n", - " vdb_endpoint=\"http://localhost:19530\",\n", + " vdb_endpoint=\"http://localhost:9200\",\n", " blocking=False,\n", " split_options={\"chunk_size\": 512, \"chunk_overlap\": 150},\n", " filepaths=[\n", @@ -565,7 +565,7 @@ "source": [ "response = await ingestor.update_documents(\n", " collection_name=\"test_library\",\n", - " vdb_endpoint=\"http://localhost:19530\",\n", + " vdb_endpoint=\"http://localhost:9200\",\n", " blocking=False,\n", " filepaths=[\"../data/multimodal/woods_frost.docx\"],\n", " generate_summary=False,\n", @@ -589,7 +589,7 @@ "source": [ "response = ingestor.get_documents(\n", " collection_name=\"test_library\",\n", - " vdb_endpoint=\"http://localhost:19530\",\n", + " vdb_endpoint=\"http://localhost:9200\",\n", ")\n", "print(response)" ] @@ -621,16 +621,16 @@ "# config_rag = NvidiaRAGConfig.from_dict(\n", "# {\n", "# \"llm\": {\n", - "# \"model_name\": \"nvidia/llama-3.3-nemotron-super-49b-v1.5\",\n", + "# \"model_name\": \"nvidia/nemotron-3-super-120b-a12b\",\n", "# \"server_url\": \"\",\n", "# },\n", "# \"embeddings\": {\n", - "# \"model_name\": \"nvidia/llama-nemotron-embed-1b-v2\",\n", + "# \"model_name\": \"nvidia/llama-nemotron-embed-vl-1b-v2\",\n", "# \"server_url\": \"https://integrate.api.nvidia.com/v1\",\n", "# },\n", "# \"ranking\": {\n", "# \"model_name\": \"nvidia/llama-nemotron-rerank-1b-v2\",\n", - "# \"server_url\": \"https://ai.api.nvidia.com/v1/retrieval/nvidia/llama-3_2-nv-rerankqa-1b-v2/reranking/v1\",\n", + "# \"server_url\": \"https://ai.api.nvidia.com/v1/retrieval/nvidia/llama-nemotron-rerank-1b-v2/reranking/v1\",\n", "# },\n", "# }\n", "# )\n", @@ -708,6 +708,7 @@ " # Extract the streaming generator from the response\n", " response_generator = rag_response.generator\n", " first_chunk_data = None\n", + " final_response = \"\"\n", " async for chunk in response_generator:\n", " if chunk.startswith(\"data: \"):\n", " chunk = chunk[len(\"data: \") :].strip()\n", @@ -721,6 +722,8 @@ " choices = data.get(\"choices\", [])\n", " if not choices:\n", " continue\n", + " if not choices[0].get(\"message\", {}).get(\"content\"):\n", + " continue\n", " # Save the first chunk with citations\n", " if first_chunk_data is None and data.get(\"citations\"):\n", " first_chunk_data = data\n", @@ -730,9 +733,14 @@ " if not text:\n", " message = choices[0].get(\"message\", {})\n", " text = message.get(\"content\", \"\")\n", - " print(text, end=\"\", flush=True)\n", + " final_response += text\n", " print() # Newline after streaming\n", "\n", + " print(\"-\" * 100)\n", + " print(\"Final response:\")\n", + " print(final_response)\n", + " print(\"-\" * 100)\n", + "\n", " # Display citations after streaming is done\n", " if first_chunk_data and first_chunk_data.get(\"citations\"):\n", " citations = first_chunk_data[\"citations\"]\n", @@ -772,9 +780,66 @@ }, { "cell_type": "markdown", + "id": "82a02d8c", + "metadata": {}, + "source": [ + "## 7. Agentic RAG Query\n", + "Uses a multi-step plan-and-execute agentic workflow to answer complex queries.\n", + "Unlike standard RAG, the agent performs scope discovery, targeted parallel retrieval,\n", + "synthesis, and an answer verification pass — all automatically.\n", + "Pass `agentic=True` to `generate()` to enable this mode.\n", + "\n", + "The `print_streaming_response_and_citations` helper defined in section 6 works for\n", + "agentic responses as well: intermediate pipeline events carry no visible `content`,\n", + "so only the final synthesized answer is displayed." + ] + }, + { + "cell_type": "code", + "execution_count": null, "metadata": {}, + "outputs": [], "source": [ - "## 7. Search for documents\n", + "# Update agentic LLM server URLs for cloud deployment if using Option 2.\n", + "# The agentic RAG flow reads config_rag.agentic_rag at request time, so these\n", + "# overrides take effect even though `rag` was already constructed above.\n", + "if DEPLOYMENT_MODE == \"cloud\":\n", + " config_rag.agentic_rag.planner_llm.server_url = \"https://integrate.api.nvidia.com/v1\"\n", + " config_rag.agentic_rag.task_llm.server_url = \"https://integrate.api.nvidia.com/v1\"\n", + " config_rag.agentic_rag.seed_gen_llm.server_url = \"https://integrate.api.nvidia.com/v1\"\n", + " config_rag.agentic_rag.synthesis_llm.server_url = \"https://integrate.api.nvidia.com/v1\"" + ] + }, + { + "cell_type": "markdown", + "id": "47d3dc1a", + "metadata": {}, + "source": [ + "### Call the API" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e88f2f6d", + "metadata": {}, + "outputs": [], + "source": [ + "await print_streaming_response_and_citations(\n", + " await rag.generate(\n", + " messages=[{\"role\": \"user\", \"content\": \"What is lion doing, what is giraffe doing?\"}],\n", + " use_knowledge_base=True,\n", + " collection_names=[\"test_library\"],\n", + " agentic=True,\n", + " )\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 8. Search for documents\n", "Performs a search in the vector database for relevant documents." ] }, @@ -834,7 +899,7 @@ " reranker_top_k=10,\n", " vdb_top_k=100,\n", " # [Optional]: Uncomment to filter the documents based on the metadata, ensure that the metadata schema is created with the same fields with create_collection\n", - " # filter_expr='content_metadata[\"meta_field_1\"] == \"multimodal document 1\"'\n", + " # filter_expr=[{\"term\": {\"metadata.content_metadata.meta_field_1.keyword\": \"multimodal document 1\"}}]\n", " )\n", ")" ] @@ -843,7 +908,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## 8. [Optional] Retrieve documents summary\n", + "## 9. [Optional] Retrieve documents summary\n", "You can execute this cell if summary generation was enabled during document upload using `generate_summary: bool` flag." ] }, @@ -866,7 +931,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## 9. Customize prompts" + "## 10. Customize prompts" ] }, { @@ -969,7 +1034,7 @@ "metadata": {}, "source": [ "Below APIs illustrate how to cleanup uploaded documents and collections once no more interaction is needed.\n", - "## 10. Delete documents from a collection\n", + "## 11. Delete documents from a collection\n", "Deletes documents from the specified collection." ] }, @@ -982,7 +1047,7 @@ "response = ingestor.delete_documents(\n", " collection_name=\"test_library\",\n", " document_names=[\"../data/multimodal/multimodal_test.pdf\"],\n", - " vdb_endpoint=\"http://localhost:19530\",\n", + " vdb_endpoint=\"http://localhost:9200\",\n", ")\n", "print(response)" ] @@ -991,7 +1056,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## 11. Delete collections\n", + "## 12. Delete collections\n", "Deletes the specified collection and all its documents from the vector database." ] }, @@ -1002,15 +1067,22 @@ "outputs": [], "source": [ "response = ingestor.delete_collections(\n", - " vdb_endpoint=\"http://localhost:19530\", collection_names=[\"test_library\"]\n", + " vdb_endpoint=\"http://localhost:9200\", collection_names=[\"test_library\"]\n", ")\n", "print(response)" ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] } ], "metadata": { "kernelspec": { - "display_name": "Python 3 (ipykernel)", + "display_name": ".venv", "language": "python", "name": "python3" }, diff --git a/notebooks/retriever_api_usage.ipynb b/notebooks/retriever_api_usage.ipynb index 9b52647ed..a9746d235 100644 --- a/notebooks/retriever_api_usage.ipynb +++ b/notebooks/retriever_api_usage.ipynb @@ -136,7 +136,7 @@ " \"max_tokens\": 1024,\n", " \"reranker_top_k\": 2,\n", " \"vdb_top_k\": 10,\n", - " \"vdb_endpoint\": \"http://milvus:19530\",\n", + " \"vdb_endpoint\": \"http://elasticsearch:9200\",\n", " \"collection_names\": [\"multimodal_data\"],\n", " \"enable_query_rewriting\": True,\n", " \"enable_reranker\": True,\n", @@ -144,9 +144,9 @@ " \"stop\": [],\n", " \"filter_expr\": \"\",\n", " # Override model endpoints and details if needed\n", - " #\"model\": \"nvidia/llama-3.3-nemotron-super-49b-v1.5\",\n", + " #\"model\": \"nvidia/nemotron-3-super-120b-a12b\",\n", " #\"reranker_model\": \"nvidia/llama-nemotron-rerank-1b-v2\",\n", - " #\"embedding_model\": \"nvidia/llama-nemotron-embed-1b-v2\",\n", + " #\"embedding_model\": \"nvidia/llama-nemotron-embed-vl-1b-v2\",\n", " #\"llm_endpoint\": \"\",\n", " #\"embedding_endpoint\": \"\",\n", " #\"reranker_endpoint\": \"\",\n", @@ -263,7 +263,7 @@ " \"query\": \"Tell me about robert frost's poems\",\n", " \"reranker_top_k\": 2,\n", " \"vdb_top_k\": 10,\n", - " \"vdb_endpoint\": \"http://milvus:19530\",\n", + " \"vdb_endpoint\": \"http://elasticsearch:9200\",\n", " \"collection_names\": [\n", " \"multimodal_data\"\n", " ], # Multiple collection retrieval can be used by passing multiple collection names\n", @@ -272,7 +272,7 @@ " \"enable_reranker\": True,\n", " # Override model endpoints and details if needed\n", " #\"reranker_model\": \"nvidia/llama-nemotron-rerank-1b-v2\",\n", - " #\"embedding_model\": \"nvidia/llama-nemotron-embed-1b-v2\",\n", + " #\"embedding_model\": \"nvidia/llama-nemotron-embed-vl-1b-v2\",\n", " #\"embedding_endpoint\": \"\",\n", " #\"reranker_endpoint\": \"\",\n", "}\n", @@ -290,16 +290,103 @@ "await document_seach(payload)" ] }, + { + "cell_type": "markdown", + "id": "c27da34d", + "metadata": {}, + "source": [ + "#### 7. Agentic RAG via Generate Endpoint\n", + "\n", + "**Purpose:**\n", + "This endpoint uses the same `/v1/generate` API but activates a multi-step\n", + "plan-and-execute agentic workflow by passing `\"agentic\": true`. The agent\n", + "automatically performs scope discovery, targeted parallel retrieval, synthesis,\n", + "and answer verification. Only the final synthesized answer is printed below." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "de9a7cba", + "metadata": {}, + "outputs": [], + "source": [ + "url = f\"{BASE_URL}/v1/generate\"\n", + "agentic_payload = {\n", + " \"messages\": [\n", + " {\n", + " \"role\": \"user\",\n", + " \"content\": \"How does the price of bluetooth speaker compare with hammer?\",\n", + " }\n", + " ],\n", + " \"use_knowledge_base\": True,\n", + " \"temperature\": 0.2,\n", + " \"top_p\": 0.7,\n", + " \"max_tokens\": 1024,\n", + " \"reranker_top_k\": 2,\n", + " \"vdb_top_k\": 10,\n", + " \"vdb_endpoint\": \"http://elasticsearch:9200\",\n", + " \"collection_names\": [\"multimodal_data\"],\n", + " \"enable_query_rewriting\": True,\n", + " \"enable_reranker\": True,\n", + " \"enable_citations\": True,\n", + " \"agentic\": True, # Enable agentic RAG workflow\n", + "}\n", + "\n", + "\n", + "async def parse_agentic_final_answer(response):\n", + " \"\"\"Parse SSE stream from the agentic pipeline and print only the final answer.\"\"\"\n", + " final_answer_parts = []\n", + " buffer = \"\"\n", + " async for chunk in response.content.iter_chunked(8192):\n", + " if chunk:\n", + " chunk_str = chunk.decode(\"utf-8\")\n", + " buffer += chunk_str\n", + " lines = buffer.split('\\n')\n", + " buffer = lines[-1]\n", + " for line in lines[:-1]:\n", + " line = line.strip()\n", + " if line.startswith(\"data: \"):\n", + " json_str = line[6:]\n", + " if json_str:\n", + " try:\n", + " data = json.loads(json_str)\n", + " event_type = data.get(\"event_type\", \"\")\n", + " # Accumulate only the final synthesized answer tokens\n", + " if event_type == \"final_answer\":\n", + " delta = data.get(\"choices\", [{}])[0].get(\"delta\", {}).get(\"content\", \"\")\n", + " if delta:\n", + " final_answer_parts.append(delta)\n", + " finish_reason = data.get(\"choices\", [{}])[0].get(\"finish_reason\")\n", + " if finish_reason == \"stop\":\n", + " break\n", + " except json.JSONDecodeError:\n", + " continue\n", + " print(\"\".join(final_answer_parts))\n", + "\n", + "\n", + "async def agentic_generate(payload):\n", + " async with aiohttp.ClientSession() as session:\n", + " try:\n", + " async with session.post(url=url, json=payload) as response:\n", + " await parse_agentic_final_answer(response)\n", + " except aiohttp.ClientError as e:\n", + " print(f\"Error: {e}\")\n", + "\n", + "\n", + "await agentic_generate(agentic_payload)" + ] + }, { "cell_type": "markdown", "id": "f79f4a3b-cd1d-4923-9d4c-a59460d12572", "metadata": {}, "source": [ - "#### 7. [Optional] Document Search Endpoint with metadata filtering\n", + "#### 8. [Optional] Document Search Endpoint with metadata filtering\n", "\n", "**Purpose:** Filtering can be performed with custom-metadata provided during ingestion. Similarly `filter_expr` field can be passed in `/generate` endpoint to filter the retrieved chunks from the RAG. \n", "\n", - "Before using custom-metadata filtering, kindly ensure the custom metadata is added at ingestion stage. The filtering can be performed using Milvus filtering expression (Reference: [Milvus Filtering](https://milvus.io/docs/boolean.md)). An example is shown below:" + "Before using custom-metadata filtering, kindly ensure the custom metadata is added at ingestion stage. The filtering can be performed using an Elasticsearch filter expression. An example is shown below:" ] }, { @@ -316,14 +403,14 @@ " \"query\": \"What is lion doing?\",\n", " \"reranker_top_k\": 10,\n", " \"vdb_top_k\": 100,\n", - " \"vdb_endpoint\": \"http://milvus:19530\",\n", + " \"vdb_endpoint\": \"http://elasticsearch:9200\",\n", " \"collection_names\": [\n", " \"multimodal_data\"\n", " ], # Multiple collection retrieval can be used by passing multiple collection names\n", " \"messages\": [],\n", " \"enable_query_rewriting\": False,\n", " \"enable_reranker\": True,\n", - " \"filter_expr\": 'content_metadata[\"meta_field_1\"] == \"multimodal document\"', # Following is an example filter expression\n", + " \"filter_expr\": [{\"term\": {\"metadata.content_metadata.meta_field_1.keyword\": \"multimodal document\"}}], # Elasticsearch filter expression example\n", "}\n", "\n", "\n", @@ -344,7 +431,7 @@ "id": "c09fcb7b-36c1-4f3b-9321-336b4d538f58", "metadata": {}, "source": [ - "#### 8. [Optional] Retrieve documents summary\n", + "#### 9. [Optional] Retrieve documents summary\n", "You can execute this cell if summary generation was enabled during document upload using `generate_summary: bool` flag." ] }, @@ -379,7 +466,7 @@ "id": "cb2a3254", "metadata": {}, "source": [ - "#### 9. [Optional] Vector Store Search using OpenAI SDK\n", + "#### 10. [Optional] Vector Store Search using OpenAI SDK\n", "\n", "**Purpose:**\n", "This endpoint demonstrates the OpenAI-compatible vector store search API. You can use the OpenAI Python SDK to interact with the NVIDIA RAG server's vector store search endpoint. This provides a familiar interface for developers already using OpenAI's API.\n" @@ -434,7 +521,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3 (ipykernel)", + "display_name": ".venv", "language": "python", "name": "python3" }, @@ -448,7 +535,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.9" + "version": "3.12.3" } }, "nbformat": 4, diff --git a/notebooks/summarization.ipynb b/notebooks/summarization.ipynb index d0c7ef285..e2533a343 100644 --- a/notebooks/summarization.ipynb +++ b/notebooks/summarization.ipynb @@ -1,1450 +1,1535 @@ { - "cells": [ - { - "cell_type": "markdown", - "id": "fbf6ca4e", - "metadata": {}, - "source": [ - "# Document Summarization Customization Guide\n", - "\n", - "This notebook demonstrates how to customize the document summarization feature in NVIDIA RAG.\n", - "\n", - "## Two Modes of Operation\n", - "\n", - "- **Library Mode**: Programmatic configuration changes in Python notebooks/scripts\n", - "- **Docker Mode**: Configuration via environment variables and config files" - ] - }, - { - "cell_type": "markdown", - "id": "b134dbf9", - "metadata": {}, - "source": [ - "## 📊 Summarization Pipeline Architecture\n", - "\n", - "The diagram below shows how document summarization integrates into the complete RAG pipeline:\n", - "\n", - "![Summarization Pipeline Architecture](https://github.com/NVIDIA-AI-Blueprints/rag/raw/main/docs/assets/summarization_flow_diagram.png)\n", - "\n", - "The summarization workflow that this notebook focuses on. You'll learn to customize:\n", - "\n", - "- **Page Filtering**: Select specific pages using ranges, negative indexing, or even/odd patterns\n", - "- **Shallow vs Full Extraction**: Fast text-only OR comprehensive multimodal processing\n", - "- **Summarization Strategy**: Choose between Single (fastest), Hierarchical (balanced), or Iterative (best quality - default)\n", - " - **Single**: Merge all content, chunk by configured size, and summarize only the first chunk (fastest, one LLM call)\n", - " - **Hierarchical**: Tree-based summarization - summarize all chunks, merge summaries until they fit chunk size, repeat recursively until reaching one final summary (balanced speed/quality)\n", - " - **Iterative (default)**: Process chunks sequentially with context refinement from previous summaries (best quality, N sequential LLM calls)\n", - "- **Token-based Chunking**: 9000 tokens per chunk with 400 token overlap\n", - "- **Real-time Status Tracking**: Monitor progress via Redis with chunk-level updates" - ] - }, - { - "cell_type": "markdown", - "id": "3a62b1a6", - "metadata": {}, - "source": [ - "---" - ] - }, - { - "cell_type": "markdown", - "id": "0dc56f4f", - "metadata": {}, - "source": [ - "## Part 1: Library Mode " - ] - }, - { - "cell_type": "markdown", - "id": "f389a897", - "metadata": {}, - "source": [ - "### 1. Setup before using library mode" - ] - }, - { - "cell_type": "markdown", - "id": "7adbc9f2", - "metadata": {}, - "source": [ - "#### 1.1. Installation guide for python package\n", - "\n", - "> **Note**: Python version **3.11 or higher** is required.\n", - "\n", - "##### 📝 **Development Mode Note:**\n", - "\n", - "- Installing with `uv pip install -e \"..[all]\"` allows you to make live edits to the `nvidia_rag` source code and have those changes reflected without reinstalling the package.\n", - "- After making changes to the source code, you need to:\n", - " - Restart the kernel of your notebook server\n", - " - Re-execute the cells under `Setting up the dependencies` and `Import the packages` sections" - ] - }, - { - "cell_type": "markdown", - "id": "d2bb524d", - "metadata": {}, - "source": [ - "#### Install uv (if not already installed)\n", - "\n", - "Run the cell below to check if `uv` is installed and install it if needed." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "f7fa66fc", - "metadata": {}, - "outputs": [], - "source": [ - "import subprocess\n", - "import shutil\n", - "\n", - "# Check if uv is installed\n", - "if shutil.which(\"uv\"):\n", - " result = subprocess.run([\"uv\", \"--version\"], capture_output=True, text=True)\n", - " print(f\"✅ uv is already installed: {result.stdout.strip()}\")\n", - "else:\n", - " print(\"⚠️ uv is not installed. Installing now...\")\n", - " # Install uv using the official installer\n", - " !curl -LsSf https://astral.sh/uv/install.sh | sh\n", - " print(\"\\n✅ uv installed! Please restart your terminal/kernel and re-run this notebook.\")" - ] - }, - { - "cell_type": "markdown", - "id": "c6ce9248", - "metadata": {}, - "source": [ - "#### Install the NVIDIA RAG Package\n", - "\n", - "Choose one of the installation options below:\n", - "- **Option A**: Install from PyPI (recommended for most users)\n", - "- **Option B**: Install from source in development mode (for contributors)\n", - "- **Option C**: Build and install from source wheel" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "e775b4ac", - "metadata": {}, - "outputs": [], - "source": [ - "# Option A: Install from PyPI (recommended)\n", - "# Uncomment the line below to install from PyPI\n", - "# !uv pip install nvidia-rag[all]\n", - "\n", - "# Option B: Install from source in development mode (for contributors)\n", - "# Note: \"..\" refers to the parent directory where pyproject.toml is located\n", - "!uv pip install -e \"..[all]\"\n", - "\n", - "# Option C: Build and install from source wheel\n", - "# Uncomment the lines below to build and install from source\n", - "# !cd .. && uv build\n", - "# !uv pip install ../dist/nvidia_rag-*-py3-none-any.whl[all]" - ] - }, - { - "cell_type": "markdown", - "id": "7cdd2bd9", - "metadata": {}, - "source": [ - "#### 1.2. Verify the installation\n", - "The location of the package shown in the output of this command should be inside the virtual environment.\n", - "\n", - "Location: `/rag/.venv/lib/python3.12/site-packages`" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "08c40dab", - "metadata": {}, - "outputs": [], - "source": [ - "!uv pip show nvidia_rag | grep Location" - ] - }, - { - "cell_type": "markdown", - "id": "ff09d666", - "metadata": {}, - "source": [ - "### 2. Setting up the dependencies" - ] - }, - { - "cell_type": "markdown", - "id": "efdd89dc", - "metadata": {}, - "source": [ - "After the environment for the python package is set up, launch all the dependent services and NIMs that the pipeline depends on.\n", - "\n", - "Fulfill the [prerequisites here](https://github.com/NVIDIA-AI-Blueprints/rag/blob/main/docs/deploy-docker-self-hosted.md) to set up docker on your system." - ] - }, - { - "cell_type": "markdown", - "id": "84bf4f5f", - "metadata": {}, - "source": [ - "#### 2.1. Setup the default configurations" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "1b5162b4", - "metadata": {}, - "outputs": [], - "source": [ - "!uv pip install python-dotenv\n", - "import os\n", - "from getpass import getpass\n", - "\n", - "from dotenv import load_dotenv" - ] - }, - { - "cell_type": "markdown", - "id": "ec509cc9", - "metadata": {}, - "source": [ - "Provide your NGC_API_KEY after executing the cell below. You can obtain a key by following steps [here](https://github.com/NVIDIA-AI-Blueprints/rag/blob/main/docs/api-key.md)." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "d60f81ff", - "metadata": {}, - "outputs": [], - "source": [ - "# del os.environ['NVIDIA_API_KEY'] ## delete key and reset if needed\n", - "if os.environ.get(\"NGC_API_KEY\", \"\").startswith(\"nvapi-\"):\n", - " print(\"Valid NGC_API_KEY already in environment. Delete to reset\")\n", - "else:\n", - " candidate_api_key = getpass(\"NVAPI Key (starts with nvapi-): \")\n", - " assert candidate_api_key.startswith(\"nvapi-\"), (\n", - " f\"{candidate_api_key[:5]}... is not a valid key\"\n", - " )\n", - " os.environ[\"NGC_API_KEY\"] = candidate_api_key" - ] - }, - { - "cell_type": "markdown", - "id": "70dec262", - "metadata": {}, - "source": [ - "Login to nvcr.io which is needed for pulling the containers of dependencies" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "7fec18c6", - "metadata": {}, - "outputs": [], - "source": [ - "!echo \"${NGC_API_KEY}\" | docker login nvcr.io -u '$oauthtoken' --password-stdin" - ] - }, - { - "cell_type": "markdown", - "id": "593ac9b0", - "metadata": {}, - "source": [ - "#### 2.2. Setup the Milvus vector DB services\n", - "By default milvus uses GPU Indexing. Ensure you have provided correct GPU ID.\n", - "Note: If you don't have a GPU available, you can switch to CPU-only Milvus by following the instructions in [milvus-configuration.md](https://github.com/NVIDIA-AI-Blueprints/rag/blob/main/docs/milvus-configuration.md)." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "b5fa2f5d", - "metadata": {}, - "outputs": [], - "source": [ - "os.environ[\"VECTORSTORE_GPU_DEVICE_ID\"] = \"0\"" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "51c7b137", - "metadata": {}, - "outputs": [], - "source": [ - "!docker compose -f ../deploy/compose/vectordb.yaml up -d" - ] - }, - { - "cell_type": "markdown", - "id": "3969aa2c", - "metadata": {}, - "source": [ - "#### 2.3. Setup the NIMs" - ] - }, - { - "cell_type": "markdown", - "id": "99abdce4", - "metadata": {}, - "source": [ - "#### Option 1: Deploy on-prem models" - ] - }, - { - "cell_type": "markdown", - "id": "4bb2ea98", - "metadata": {}, - "source": [ - "Move to Option 2 if you are interested in using cloud models.\n", - "\n", - "Ensure you meet [the hardware requirements](https://github.com/NVIDIA-AI-Blueprints/rag/blob/main/docs/support-matrix.md). By default the NIMs are configured to use 2xH100." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "816b14c7", - "metadata": {}, - "outputs": [], - "source": [ - "# Create the model cache directory\n", - "!mkdir -p ~/.cache/model-cache" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "50299ae0", - "metadata": {}, - "outputs": [], - "source": [ - "# Set the MODEL_DIRECTORY environment variable in the Python kernel\n", - "import os\n", - "\n", - "os.environ[\"MODEL_DIRECTORY\"] = os.path.expanduser(\"~/.cache/model-cache\")\n", - "print(\"MODEL_DIRECTORY set to:\", os.environ[\"MODEL_DIRECTORY\"])" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "f577f8ff", - "metadata": {}, - "outputs": [], - "source": [ - "# Configure GPU IDs for the various microservices if needed\n", - "os.environ[\"EMBEDDING_MS_GPU_ID\"] = \"0\"\n", - "os.environ[\"RANKING_MS_GPU_ID\"] = \"0\"\n", - "os.environ[\"YOLOX_MS_GPU_ID\"] = \"0\"\n", - "os.environ[\"YOLOX_GRAPHICS_MS_GPU_ID\"] = \"0\"\n", - "os.environ[\"YOLOX_TABLE_MS_GPU_ID\"] = \"0\"\n", - "os.environ[\"OCR_MS_GPU_ID\"] = \"0\"\n", - "os.environ[\"LLM_MS_GPU_ID\"] = \"1\"" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "1c621259", - "metadata": {}, - "outputs": [], - "source": [ - "# ⚠️ Deploying NIMs - This may take a while as models download. If kernel times out, just rerun this cell.\n", - "!USERID=$(id -u) docker compose -f ../deploy/compose/nims.yaml up -d" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "af5e0f6f", - "metadata": {}, - "outputs": [], - "source": [ - "# Watch the status of running containers (run this cell repeatedly or in a terminal)\n", - "!docker ps" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "2ba63d1d", - "metadata": {}, - "outputs": [], - "source": [ - "# Set deployment mode for on-prem NIMs\n", - "DEPLOYMENT_MODE = \"on_prem\"" - ] - }, - { - "cell_type": "markdown", - "id": "9fed48c0", - "metadata": {}, - "source": [ - "Ensure all the below are running and healthy before proceeding further\n", - "```output\n", - "NAMES STATUS\n", - "nemotron-ranking-ms Up ... (healthy)\n", - "compose-page-elements-1 Up ...\n", - "compose-nemoretriever-ocr-1 Up ...\n", - "compose-graphic-elements-1 Up ...\n", - "compose-table-structure-1 Up ...\n", - "nemotron-embedding-ms Up ... (healthy)\n", - "nim-llm-ms Up ... (healthy)\n", - "```" - ] - }, - { - "cell_type": "markdown", - "id": "dbac5b7a", - "metadata": {}, - "source": [ - "#### Option 2: Using Nvidia Hosted models" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "2d529315", - "metadata": {}, - "outputs": [], - "source": [ - "DEPLOYMENT_MODE = \"cloud\"\n", - "\n", - "# Set deployment mode for NVIDIA hosted cloud APIs\n", - "os.environ[\"OCR_HTTP_ENDPOINT\"] = \"https://ai.api.nvidia.com/v1/cv/nvidia/nemoretriever-ocr\"\n", - "os.environ[\"OCR_INFER_PROTOCOL\"] = \"http\"\n", - "os.environ[\"YOLOX_HTTP_ENDPOINT\"] = (\n", - " \"https://ai.api.nvidia.com/v1/cv/nvidia/nemotron-page-elements-v3\"\n", - ")\n", - "os.environ[\"YOLOX_INFER_PROTOCOL\"] = \"http\"\n", - "os.environ[\"YOLOX_GRAPHIC_ELEMENTS_HTTP_ENDPOINT\"] = (\n", - " \"https://ai.api.nvidia.com/v1/cv/nvidia/nemotron-graphic-elements-v1\"\n", - ")\n", - "os.environ[\"YOLOX_GRAPHIC_ELEMENTS_INFER_PROTOCOL\"] = \"http\"\n", - "os.environ[\"YOLOX_TABLE_STRUCTURE_HTTP_ENDPOINT\"] = (\n", - " \"https://ai.api.nvidia.com/v1/cv/nvidia/nemotron-table-structure-v1\"\n", - ")\n", - "os.environ[\"YOLOX_TABLE_STRUCTURE_INFER_PROTOCOL\"] = \"http\"" - ] - }, - { - "cell_type": "markdown", - "id": "d5b176dc", - "metadata": {}, - "source": [ - "#### 2.4. Setup the Nvidia Ingest runtime and redis service" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "be355c11", - "metadata": {}, - "outputs": [], - "source": [ - "!docker compose -f ../deploy/compose/docker-compose-ingestor-server.yaml up nv-ingest-ms-runtime redis -d" - ] - }, - { - "cell_type": "markdown", - "id": "cbc0ef32", - "metadata": {}, - "source": [ - "#### 2.5. Load optional profiles if needed" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "4c1fca90", - "metadata": {}, - "outputs": [], - "source": [ - "# Load accuracy profile\n", - "# load_dotenv(dotenv_path='../deploy/compose/accuracy_profile.env', override=True)\n", - "\n", - "# OR load perf profile\n", - "# load_dotenv(dotenv_path='../deploy/compose/perf_profile.env', override=True)" - ] - }, - { - "cell_type": "markdown", - "id": "12b33ced", - "metadata": {}, - "source": [ - "### 3. Import libraries and view defaults\n", - "\n", - "After setting up the python package and starting all dependent services, we can now import the libraries and view default configuration for summarization." - ] - }, - { - "cell_type": "markdown", - "id": "0f067c70", - "metadata": {}, - "source": [ - "#### 3.1. Set logging level\n", - "\n", - "First let's set the required logging level. Set to INFO for displaying basic important logs. Set to DEBUG for full verbosity." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "f15e1d4f", - "metadata": {}, - "outputs": [], - "source": [ - "import logging\n", - "import os\n", - "\n", - "# Set the log level via environment variable before importing nvidia_rag\n", - "# This ensures the package respects our log level setting\n", - "LOGLEVEL = logging.WARNING # Set to INFO, DEBUG, WARNING or ERROR\n", - "os.environ[\"LOGLEVEL\"] = logging.getLevelName(LOGLEVEL)\n", - "\n", - "# Configure logging\n", - "logging.basicConfig(level=LOGLEVEL, force=True)\n", - "\n", - "# Set log levels for specific loggers after package import\n", - "for name in logging.root.manager.loggerDict:\n", - " if name == \"nvidia_rag\" or name.startswith(\"nvidia_rag.\"):\n", - " logging.getLogger(name).setLevel(LOGLEVEL)\n", - " if name == \"nv_ingest_client\" or name.startswith(\"nv_ingest_client.\"):\n", - " logging.getLogger(name).setLevel(LOGLEVEL)" - ] - }, - { - "cell_type": "markdown", - "id": "5be07e0e", - "metadata": {}, - "source": [ - "#### 3.2. Import the packages and initialize configuration\n", - "You can import both or either one based on your requirements. `NvidiaRAG()` exposes APIs to interact with the uploaded documents or retrieve summaries and `NvidiaRAGIngestor()` exposes APIs for document upload, management and summary generation." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "47db7b83", - "metadata": {}, - "outputs": [], - "source": [ - "from nvidia_rag import NvidiaRAG, NvidiaRAGIngestor\n", - "from nvidia_rag.utils.configuration import NvidiaRAGConfig\n", - "from nvidia_rag.rag_server.response_generator import retrieve_summary\n", - "\n", - "# Get the configuration object\n", - "config = NvidiaRAGConfig.from_yaml(\"config.yaml\")\n", - "\n", - "# Update config for cloud deployment if using Option 2\n", - "if DEPLOYMENT_MODE == \"cloud\":\n", - " config.embeddings.server_url = \"https://integrate.api.nvidia.com/v1\"\n", - " config.llm.server_url = \"\" # Empty uses NVIDIA API catalog\n", - " config.ranking.server_url = \"https://ai.api.nvidia.com/v1/retrieval/nvidia/llama-3_2-nv-rerankqa-1b-v2/reranking/v1\"\n", - " config.summarizer.server_url = \"\" # Empty uses NVIDIA API catalog\n", - "else:\n", - " config.embeddings.server_url = \"nemotron-embedding-ms:8000/v1\"\n", - " config.ranking.server_url = \"nemotron-ranking-ms:8000\"\n", - " config.summarizer.server_url = \"nim-llm:8000\"\n", - " config.llm.server_url = \"nim-llm:8000\"\n", - "\n", - "# Initialize NvidiaRAG and NvidiaRAGIngestor with config\n", - "# For summarization customization, pass prompts to NvidiaRAGIngestor:\n", - "# - A path to a YAML/JSON file: prompts=\"custom_prompts.yaml\"\n", - "# - A dictionary: prompts={\"document_summary_prompt\": {...}}\n", - "rag = NvidiaRAG(config=config)\n", - "ingestor = NvidiaRAGIngestor(config=config)" - ] - }, - { - "cell_type": "markdown", - "id": "8fceebee", - "metadata": {}, - "source": [ - "#### 3.3. View Default Summarizer LLM Settings\n", - "\n", - "Let's see what LLM model and parameters are used by default for summarization." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "5b53e5bf", - "metadata": {}, - "outputs": [], - "source": [ - "print(\"=\" * 70)\n", - "print(\"DEFAULT SUMMARIZER LLM CONFIGURATION\")\n", - "print(\"=\" * 70)\n", - "print(f\"Model: {config.summarizer.model_name}\")\n", - "print(f\"Server URL: {config.summarizer.server_url}\")\n", - "print(f\"Temperature: {config.summarizer.temperature}\")\n", - "print(f\"Top P: {config.summarizer.top_p}\")\n", - "print(f\"Max Parallel: {config.summarizer.max_parallelization}\")\n", - "print(f\"Max Chunk Length: {config.summarizer.max_chunk_length}\")\n", - "print(f\"Chunk Overlap: {config.summarizer.chunk_overlap}\")\n", - "print(\"=\" * 70)" - ] - }, - { - "cell_type": "markdown", - "id": "d50f697c", - "metadata": {}, - "source": [ - "#### 3.4. View Default Summarization Prompts\n", - "\n", - "The prompt template controls how the LLM generates summaries. Let's see the default prompts." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "b6cbb7d0", - "metadata": {}, - "outputs": [], - "source": [ - "import json\n", - "\n", - "# Access prompts from the NvidiaRAGIngestor instance (initialized with defaults)\n", - "# Summarization is handled by NvidiaRAGIngestor, so view prompts from ingestor\n", - "print(\"=\" * 70)\n", - "print(\"DEFAULT DOCUMENT SUMMARY PROMPT\")\n", - "print(\"=\" * 70)\n", - "print(json.dumps(ingestor.prompts[\"document_summary_prompt\"], indent=2))\n", - "print(\"=\" * 70)\n", - "\n", - "print(\"\\n\" + \"=\" * 70)\n", - "print(\"DEFAULT ITERATIVE SUMMARY PROMPT\")\n", - "print(\"=\" * 70)\n", - "print(json.dumps(ingestor.prompts[\"iterative_summary_prompt\"], indent=2))\n", - "print(\"=\" * 70)" - ] - }, - { - "cell_type": "markdown", - "id": "fce20869", - "metadata": {}, - "source": [ - "This will display the default prompts used for:\n", - "- **document_summary_prompt**: Summarizing a single document or chunk (used for full multimodal extraction)\n", - "- **shallow_summary_prompt**: Summarizing with fast text-only extraction (used when `shallow_summary: true`)\n", - "- **iterative_summary_prompt**: Combining multiple summaries for large documents\n", - "\n", - "The system automatically selects the appropriate prompt based on extraction mode and document size." - ] - }, - { - "cell_type": "markdown", - "id": "3eefeb2b", - "metadata": {}, - "source": [ - "---" - ] - }, - { - "cell_type": "markdown", - "id": "27ec763e", - "metadata": {}, - "source": [ - "## Part 2: Library Mode - Change Configuration\n", - "Now let's see how to modify these settings programmatically in library mode.\n", - "\n", - "### 1. Change LLM Model and Parameters\n", - "\n", - "You can change the model and sampling parameters dynamically." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "6cc84ba0", - "metadata": {}, - "outputs": [], - "source": [ - "# Change to a different model (e.g., Llama 3.1 70B)\n", - "config.summarizer.model_name = \"meta/llama-3.1-70b-instruct\"\n", - "config.summarizer.server_url = \"\"\n", - "\n", - "# Lower temperature for more deterministic, focused summaries\n", - "config.summarizer.temperature = 0.2\n", - "\n", - "# Adjust top_p for nucleus sampling\n", - "config.summarizer.top_p = 0.7\n", - "\n", - "# Configure global rate limiting (max parallel summary tasks across all workers)\n", - "# Prevents overwhelming GPU/API with too many concurrent LLM calls\n", - "config.summarizer.max_parallelization = 10 # Default: 20\n", - "\n", - "print(\"✅ Updated Summarizer Configuration:\")\n", - "print(f\" Model: {config.summarizer.model_name}\")\n", - "print(f\" Server URL: {config.summarizer.server_url}\")\n", - "print(f\" Temperature: {config.summarizer.temperature}\")\n", - "print(f\" Top P: {config.summarizer.top_p}\")\n", - "print(f\" Max Parallel:{config.summarizer.max_parallelization}\")" - ] - }, - { - "cell_type": "markdown", - "id": "15dabf4c", - "metadata": {}, - "source": [ - "### 2. Customize Summarization Prompts\n", - "\n", - "Customize the prompt to change the style and focus of summaries by passing prompts during `NvidiaRAGIngestor` initialization.\n", - "\n", - "This is the **recommended approach** for library mode - pass prompts directly to the constructor for clean, instance-specific configuration.\n", - "\n", - "> **Note**: Summarization is handled by `NvidiaRAGIngestor`, so prompts for summarization should be passed to `NvidiaRAGIngestor`, not `NvidiaRAG`." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "05167610", - "metadata": {}, - "outputs": [], - "source": [ - "# Define custom prompts as a dictionary\n", - "custom_prompts = {\n", - " \"document_summary_prompt\": {\n", - " \"system\": \"/no_think\",\n", - " \"human\": \"\"\"You are a documentation specialist.\n", - "\n", - "Create a clear, summary that:\n", - "1. Identifies the main topic and purpose\n", - "2. Lists key concepts or features\n", - "3. Highlights important procedures or steps \n", - "4. Notes any warnings or critical information\n", - "\n", - "Keep the summary concise.\n", - "\n", - "Text to summarize:\n", - "{document_text}\n", - "\n", - "Summary:\"\"\"\n", - " }\n", - "}\n", - "\n", - "# Create NvidiaRAGIngestor instance with custom prompts (Recommended Approach)\n", - "# The prompts are merged with defaults - only specified keys are overridden\n", - "ingestor_custom = NvidiaRAGIngestor(config=config, prompts=custom_prompts)\n", - "\n", - "print(\"✅ NvidiaRAGIngestor initialized with custom prompts\")\n", - "print(\"\\nCustom prompt preview (first 200 chars):\")\n", - "print(ingestor_custom.prompts[\"document_summary_prompt\"][\"human\"][:200] + \"...\")" - ] - }, - { - "cell_type": "markdown", - "id": "a90fa503", - "metadata": {}, - "source": [ - "#### Alternative: Using a YAML File\n", - "\n", - "You can also pass a path to a YAML file containing your custom prompts:\n", - "\n", - "```python\n", - "# Using a YAML file path\n", - "ingestor_from_yaml = NvidiaRAGIngestor(config=config, prompts=\"custom_prompts.yaml\")\n", - "```\n", - "\n", - "The YAML file format should match the structure shown in the Docker Mode section below.\n" - ] - }, - { - "cell_type": "markdown", - "id": "c56d2be2", - "metadata": {}, - "source": [ - "### 3. Configure Summary Options" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "0fce6903", - "metadata": {}, - "outputs": [], - "source": [ - "summary_options = {\n", - " # Page filtering: [[1, 10]] (ranges), [[-5, -1]] (last N pages), \"even\"/\"odd\"\n", - " \"page_filter\": [[1, 10]], # Only pages 1-10\n", - " \n", - " # Fast mode: Text-only extraction first, summary in seconds\n", - " \"shallow_summary\": True, # Default: False\n", - " \n", - " # Strategy: None (iterative/best), \"single\" (fastest/truncates), \"hierarchical\" (parallel/faster than iterative)\n", - " \"summarization_strategy\": \"hierarchical\" # Default: None\n", - "}\n", - "\n", - "\n", - "print(f\" • Page Filter: {summary_options['page_filter']}\")\n", - "print(f\" • Shallow Summary: {summary_options['shallow_summary']}\")\n", - "print(f\" • Strategy: {summary_options['summarization_strategy']}\")" - ] - }, - { - "cell_type": "markdown", - "id": "ff3be036", - "metadata": {}, - "source": [ - "### 4. Complete Workflow Example\n", - "\n", - "This section demonstrates the end-to-end workflow: create collection → upload documents → check status → retrieve summary → cleanup." - ] - }, - { - "cell_type": "markdown", - "id": "c0c75f90", - "metadata": {}, - "source": [ - "#### 4.1. Create Collection" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "606e67a3", - "metadata": {}, - "outputs": [], - "source": [ - "# Create collection\n", - "collection_name = \"test_summary\"\n", - "response = ingestor.create_collection(\n", - " collection_name=collection_name,\n", - " vdb_endpoint=\"http://localhost:19530\"\n", - ")\n", - "print(f\"✅ Collection response: {response}\")" - ] - }, - { - "cell_type": "markdown", - "id": "1ac5baec", - "metadata": {}, - "source": [ - "#### 4.2. Upload Documents" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "cbf50562", - "metadata": {}, - "outputs": [], - "source": [ - "# Upload documents with summary options\n", - "result = await ingestor.upload_documents(\n", - " filepaths=[\"../data/multimodal/functional_validation.pdf\"],\n", - " collection_name=collection_name,\n", - " generate_summary=True,\n", - " summary_options=summary_options, # From previous cell\n", - " blocking=False # Don't wait, check status instead\n", - ")\n", - "print(f\"✅ Upload started: {result}\")" - ] - }, - { - "cell_type": "markdown", - "id": "02afe705", - "metadata": {}, - "source": [ - "#### 4.3. Check Status and Get Summary" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "94cebc0e", - "metadata": {}, - "outputs": [], - "source": [ - "# Check summary status\n", - "status = await retrieve_summary(\n", - " collection_name=collection_name,\n", - " file_name=\"functional_validation.pdf\",\n", - " wait=False # Just check, don't wait\n", - ")\n", - "print(f\"\\n📊 Status: {status.get('status')}\")\n", - "if status.get('status') == 'IN_PROGRESS':\n", - " progress = status.get('progress', {})\n", - " print(f\" Progress: Chunk {progress.get('current')}/{progress.get('total')}\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "301f8dfc", - "metadata": {}, - "outputs": [], - "source": [ - "# Get summary (blocking - waits until complete)\n", - "summary_result = await retrieve_summary(\n", - " collection_name=collection_name,\n", - " file_name=\"functional_validation.pdf\",\n", - " wait=True,\n", - " timeout=300\n", - ")\n", - "\n", - "if summary_result.get('status') == 'SUCCESS':\n", - " print(f\"\\n✅ Summary:\\n{summary_result.get('summary')}\")\n", - "else:\n", - " print(f\"\\n❌ {summary_result.get('status')}: {summary_result.get('message')}\")" - ] - }, - { - "cell_type": "markdown", - "id": "de34199d", - "metadata": {}, - "source": [ - "#### 4.4. Delete Collection" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "71fe09fe", - "metadata": {}, - "outputs": [], - "source": [ - "# Delete the test collection\n", - "response = ingestor.delete_collections(\n", - " collection_names=[collection_name],\n", - " vdb_endpoint=\"http://localhost:19530\"\n", - ")\n", - "print(f\"✅ Delete response: {response}\")" - ] - }, - { - "cell_type": "markdown", - "id": "caea11bf", - "metadata": {}, - "source": [ - "---" - ] - }, - { - "cell_type": "markdown", - "id": "a55c3b72", - "metadata": {}, - "source": [ - "## Part 3: Docker Mode - Change Configuration via Environment Variables\n", - "\n", - "When running in Docker mode, you configure the ingestor-server and rag-server containers via environment variables and REST APIs.\n", - "\n", - "**Prerequisites:**\n", - "- If you're starting fresh with Part 3, first complete section **\"2. Setting up the dependencies\"** from Part 1 above to start all required services (Milvus, NV-Ingest, Redis)\n", - "- If you completed Part 1, these services are already running" - ] - }, - { - "cell_type": "markdown", - "id": "f9e4a17e", - "metadata": {}, - "source": [ - "### 1. Configure via Environment Variables\n", - "\n", - "Configure the ingestor server by setting environment variables before startup. Adjust these values according to your requirements:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "4eae9bca", - "metadata": {}, - "outputs": [], - "source": [ - "# Set environment variables in Python based on mode\n", - "if DEPLOYMENT_MODE == \"cloud\":\n", - " os.environ[\"SUMMARY_LLM_SERVERURL\"] = \"\"\n", - " os.environ[\"LLM_SERVER_URL\"] = \"\"\n", - " os.environ[\"APP_EMBEDDINGS_SERVERURL\"] = \"https://integrate.api.nvidia.com/v1\"\n", - " print(\"✓ Configured for NVIDIA cloud APIs\")\n", - "else:\n", - " os.environ[\"SUMMARY_LLM_SERVERURL\"] = \"nim-llm:8000\"\n", - " os.environ[\"LLM_SERVER_URL\"] = \"nim-llm:8000\"\n", - " os.environ[\"APP_EMBEDDINGS_SERVERURL\"] = \"nemotron-embedding-ms:8000/v1\"\n", - " print(\"✓ Configured for on-prem NIMs\")\n", - "\n", - "os.environ[\"LOGLEVEL\"] = \"INFO\"\n", - "\n", - "print(\"Environment variables set for deployment mode\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "920a5ad0", - "metadata": {}, - "outputs": [], - "source": [ - "%%bash\n", - "# Custom Summarization configuration\n", - "export SUMMARY_LLM=\"meta/llama-3.1-70b-instruct\"\n", - "export SUMMARY_LLM_TEMPERATURE=0.2\n", - "export SUMMARY_LLM_TOP_P=0.7\n", - "export SUMMARY_LLM_MAX_CHUNK_LENGTH=9000\n", - "export SUMMARY_CHUNK_OVERLAP=400\n", - "export SUMMARY_MAX_PARALLELIZATION=20\n", - "\n", - "# start container\n", - "docker compose -f ../deploy/compose/docker-compose-ingestor-server.yaml up -d ingestor-server\n", - "docker compose -f ../deploy/compose/docker-compose-rag-server.yaml up -d rag-server\n", - "\n", - "echo \"Configure summarization parameters and start container\"" - ] - }, - { - "cell_type": "markdown", - "id": "7307d496", - "metadata": {}, - "source": [ - "### 2. Custom Prompts via YAML File\n", - "\n", - "To change prompts in Docker mode, create a custom `prompt.yaml` file and set the `PROMPT_CONFIG_FILE` environment variable.\n", - "\n", - "#### 2.1. Create Custom Prompt File\n", - "\n", - "Create your custom prompt file (e.g., `/home/user/my_custom_prompt.yaml`):" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "73da6ef7", - "metadata": {}, - "outputs": [], - "source": [ - "# Define custom prompt configuration\n", - "custom_prompt_content = \"\"\"document_summary_prompt:\n", - " system: |\n", - " /no_think\n", - " \n", - " human: |\n", - " You are a technical documentation specialist.\n", - " \n", - " Create a clear, technical summary that:\n", - " 1. Identifies the main topic and purpose\n", - " 2. Lists key technical concepts or features\n", - " 3. Highlights important procedures or steps\n", - " 4. Notes any warnings or critical information\n", - " \n", - " Keep the summary concise and technical.\n", - " \n", - " Text to summarize:\n", - " {document_text}\n", - " \n", - " Technical Summary:\n", - "\n", - "iterative_summary_prompt:\n", - " system: |\n", - " /no_think\n", - " \n", - " human: |\n", - " You are a technical documentation specialist combining summaries.\n", - " \n", - " Previous Summary:\n", - " {previous_summary}\n", - " \n", - " New chunk:\n", - " {new_chunk}\n", - " \n", - " Create an updated technical summary combining both.\n", - "\"\"\"\n", - "\n", - "# Write the custom prompt file\n", - "import os\n", - "custom_prompt_path = os.path.expanduser(\"~/my_custom_prompt.yaml\")\n", - "with open(custom_prompt_path, \"w\") as f:\n", - " f.write(custom_prompt_content)\n", - "\n", - "print(f\"Custom prompt file created at: {custom_prompt_path}\")" - ] - }, - { - "cell_type": "markdown", - "id": "beffd23e", - "metadata": {}, - "source": [ - "#### 2.2. Set Environment Variable and Restart\n", - "\n", - "Set the environment variable and restart the container:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "a33369d9", - "metadata": {}, - "outputs": [], - "source": [ - "%%bash\n", - "# Set path to custom prompt file\n", - "export PROMPT_CONFIG_FILE=~/my_custom_prompt.yaml\n", - "\n", - "# Restart the container (no rebuild needed)\n", - "# Note: This inherits NGC_API_KEY from the parent shell if it was set via os.environ earlier\n", - "docker compose -f ../deploy/compose/docker-compose-ingestor-server.yaml up -d ingestor-server\n", - "\n", - "echo \"Ingestor server restarted with custom prompts from: $PROMPT_CONFIG_FILE\"" - ] - }, - { - "cell_type": "markdown", - "id": "b25fe3db", - "metadata": {}, - "source": [ - "**Key Points:**\n", - "- The service will merge your custom prompts with the defaults\n", - "- Only the prompts you specify will be overridden - all others remain unchanged\n", - "- No container rebuild is required, just restart with the new environment variable!\n", - "\n", - "For more details, see the prompt customization documentation." - ] - }, - { - "cell_type": "markdown", - "id": "cb475d78", - "metadata": {}, - "source": [ - "### 3. Using Ingestor Server REST APIs\n", - "\n", - "When running in Docker mode, you interact with the ingestor server via REST APIs. Here's the complete workflow for document summarization using APIs.\n", - "\n", - "#### Prerequisites\n", - "- Ensure ingestor-server and rag-server containers are running\n", - "- Replace `localhost` with actual IP if hosted on another system" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "069af221", - "metadata": {}, - "outputs": [], - "source": [ - "# Install Dependencies\n", - "!uv pip install aiohttp" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "54c02b58", - "metadata": {}, - "outputs": [], - "source": [ - "import json\n", - "import os\n", - "import aiohttp\n", - "\n", - "# Setup base configuration\n", - "INGESTOR_BASE_URL = \"http://localhost:8082\"\n", - "RAG_BASE_URL = \"http://localhost:8081\"\n", - "\n", - "\n", - "async def print_response(response):\n", - " \"\"\"Helper to print API response.\"\"\"\n", - " try:\n", - " response_json = await response.json()\n", - " print(json.dumps(response_json, indent=2))\n", - " except aiohttp.ClientResponseError:\n", - " print(await response.text())" - ] - }, - { - "cell_type": "markdown", - "id": "ece3f9ed", - "metadata": {}, - "source": [ - "#### 3.1. Health Check" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "e2b9bd62", - "metadata": {}, - "outputs": [], - "source": [ - "async def check_health():\n", - " \"\"\"Check ingestor server health.\"\"\"\n", - " url = f\"{INGESTOR_BASE_URL}/v1/health\"\n", - " params = {\"check_dependencies\": \"True\"}\n", - " async with aiohttp.ClientSession() as session:\n", - " async with session.get(url, params=params) as response:\n", - " await print_response(response)\n", - "\n", - "await check_health()" - ] - }, - { - "cell_type": "markdown", - "id": "e56e52f7", - "metadata": {}, - "source": [ - "#### 3.2. Create Collection" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "2908a9b7", - "metadata": {}, - "outputs": [], - "source": [ - "async def create_collection(collection_name: str):\n", - " \"\"\"Create a collection for document storage.\"\"\"\n", - " data = {\n", - " \"collection_name\": collection_name,\n", - " \"metadata_schema\": []\n", - " }\n", - " \n", - " headers = {\"Content-Type\": \"application/json\"}\n", - " \n", - " async with aiohttp.ClientSession() as session:\n", - " try:\n", - " async with session.post(\n", - " f\"{INGESTOR_BASE_URL}/v1/collection\", \n", - " json=data, \n", - " headers=headers\n", - " ) as response:\n", - " await print_response(response)\n", - " except aiohttp.ClientError as e:\n", - " print(f\"Error: {e}\")\n", - "\n", - "# Create collection\n", - "await create_collection(collection_name=\"test_summary_api\")" - ] - }, - { - "cell_type": "markdown", - "id": "9492a594", - "metadata": {}, - "source": [ - "#### 3.3. Upload Documents with Summary Options" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "8bf7cada", - "metadata": {}, - "outputs": [], - "source": [ - "async def upload_with_summary(collection_name: str, filepaths: list):\n", - " \"\"\"Upload documents and generate summaries.\"\"\"\n", - " \n", - " # Configure summary options\n", - " data = {\n", - " \"collection_name\": collection_name,\n", - " \"blocking\": False, # Non-blocking upload\n", - " \"split_options\": {\"chunk_size\": 512, \"chunk_overlap\": 150},\n", - " \"generate_summary\": True, # Enable summary generation\n", - " \"summary_options\": {\n", - " \"page_filter\": [[1, 10], [-5, -1]], # First 10 and last 5 pages\n", - " \"shallow_summary\": True, # Fast text-only extraction\n", - " \"summarization_strategy\": \"single\" # fastest strategy other available: \"hierarchical\", None(iterative)\n", - " }\n", - " }\n", - " \n", - " form_data = aiohttp.FormData()\n", - " for file_path in filepaths:\n", - " form_data.add_field(\n", - " \"documents\",\n", - " open(file_path, \"rb\"),\n", - " filename=os.path.basename(file_path),\n", - " content_type=\"application/pdf\",\n", - " )\n", - " \n", - " form_data.add_field(\"data\", json.dumps(data), content_type=\"application/json\")\n", - " \n", - " async with aiohttp.ClientSession() as session:\n", - " try:\n", - " async with session.post(\n", - " f\"{INGESTOR_BASE_URL}/v1/documents\", \n", - " data=form_data\n", - " ) as response:\n", - " await print_response(response)\n", - " response_json = await response.json()\n", - " return response_json.get(\"task_id\")\n", - " except aiohttp.ClientError as e:\n", - " print(f\"Error: {e}\")\n", - " return None\n", - "\n", - "# Upload documents\n", - "task_id = await upload_with_summary(\n", - " collection_name=\"test_summary_api\",\n", - " filepaths=[\"../data/multimodal/functional_validation.pdf\"]\n", - ")\n", - "print(f\"\\n✅ Upload task_id: {task_id}\")" - ] - }, - { - "cell_type": "markdown", - "id": "1d4c4704", - "metadata": {}, - "source": [ - "#### 3.4. Check Upload Status (Ingestor Server)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "d1d17b62", - "metadata": {}, - "outputs": [], - "source": [ - "async def check_upload_status(task_id: str):\n", - " \"\"\"Check ingestion task status.\"\"\"\n", - " params = {\"task_id\": task_id}\n", - " headers = {\"Content-Type\": \"application/json\"}\n", - " \n", - " async with aiohttp.ClientSession() as session:\n", - " try:\n", - " async with session.get(\n", - " f\"{INGESTOR_BASE_URL}/v1/status\", \n", - " params=params, \n", - " headers=headers\n", - " ) as response:\n", - " await print_response(response)\n", - " except aiohttp.ClientError as e:\n", - " print(f\"Error: {e}\")\n", - "\n", - "# Check status\n", - "if task_id:\n", - " await check_upload_status(task_id=task_id)\n", - "else:\n", - " print(\"No task_id available\")" - ] - }, - { - "cell_type": "markdown", - "id": "9986e2f9", - "metadata": {}, - "source": [ - "#### 3.5. Check Summary Status (RAG Server)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "1b773bfa", - "metadata": {}, - "outputs": [], - "source": [ - "async def check_summary_status(collection_name: str, file_name: str):\n", - " \"\"\"Check summary generation status via RAG server.\"\"\"\n", - " params = {\n", - " \"collection_name\": collection_name,\n", - " \"file_name\": file_name,\n", - " \"blocking\": \"false\" # Just check status, don't wait\n", - " }\n", - " \n", - " url = f\"{RAG_BASE_URL}/v1/summary\"\n", - " \n", - " async with aiohttp.ClientSession() as session:\n", - " try:\n", - " async with session.get(url, params=params) as response:\n", - " await print_response(response)\n", - " except aiohttp.ClientError as e:\n", - " print(f\"Error: {e}\")\n", - "\n", - "# Check summary status\n", - "await check_summary_status(\n", - " collection_name=\"test_summary_api\",\n", - " file_name=\"functional_validation.pdf\"\n", - ")" - ] - }, - { - "cell_type": "markdown", - "id": "bcb0e82b", - "metadata": {}, - "source": [ - "#### 3.6. Delete Collection" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "fa7edca4", - "metadata": {}, - "outputs": [], - "source": [ - "async def delete_collections(collection_names: list[str]):\n", - " \"\"\"Delete collections from the vector store.\"\"\"\n", - " url = f\"{INGESTOR_BASE_URL}/v1/collections\"\n", - " \n", - " async with aiohttp.ClientSession() as session:\n", - " try:\n", - " async with session.delete(url, json=collection_names) as response:\n", - " await print_response(response)\n", - " except aiohttp.ClientError as e:\n", - " print(f\"Error: {e}\")\n", - "\n", - "# Delete the test collection\n", - "await delete_collections(collection_names=[\"test_summary_api\"])" - ] - }, - { - "cell_type": "markdown", - "id": "5e120e1c", - "metadata": {}, - "source": [ - "---" - ] - }, - { - "cell_type": "markdown", - "id": "baab41ef", - "metadata": {}, - "source": [ - "## Summary of Available Configuration Options\n", - "\n", - "### Summarizer Configuration Fields\n", - "\n", - "| Field | Environment Variable | Default Value | Description |\n", - "|-------|---------------------|---------------|-------------|\n", - "| `model_name` | `SUMMARY_LLM` | `nvidia/llama-3.3-nemotron-super-49b-v1.5` | The LLM model used for summarization |\n", - "| `server_url` | `SUMMARY_LLM_SERVERURL` | (empty) | Server URL for custom model hosting |\n", - "| `temperature` | `SUMMARY_LLM_TEMPERATURE` | `0.0` | Controls randomness (0.0-1.0) |\n", - "| `top_p` | `SUMMARY_LLM_TOP_P` | `1.0` | Nucleus sampling parameter (0.0-1.0) |\n", - "| `max_chunk_length` | `SUMMARY_LLM_MAX_CHUNK_LENGTH` | `9000` | Maximum chunk size in tokens |\n", - "| `chunk_overlap` | `SUMMARY_CHUNK_OVERLAP` | `400` | Overlap between chunks in tokens |\n", - "\n", - "### Prompt Template Variables\n", - "\n", - "- **document_summary_prompt**: Use `{document_text}` variable\n", - "- **iterative_summary_prompt**: Use `{previous_summary}` and `{new_chunk}` variable\n", - "\n", - "**Note:** Changes made in library mode take effect immediately without restarting any services. Changes in Docker mode require a container restart but no rebuild." - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": ".venv", - "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.10" - } - }, - "nbformat": 4, - "nbformat_minor": 5 + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Document Summarization Customization Guide\n", + "\n", + "This notebook demonstrates how to customize the document summarization feature in NVIDIA RAG.\n", + "\n", + "## Two Modes of Operation\n", + "\n", + "- **Library Mode**: Programmatic configuration changes in Python notebooks/scripts\n", + "- **Docker Mode**: Configuration via environment variables and config files" + ], + "id": "fbf6ca4e" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 📊 Summarization Pipeline Architecture\n", + "\n", + "The diagram below shows how document summarization integrates into the complete RAG pipeline:\n", + "\n", + "![Summarization Pipeline Architecture](https://github.com/NVIDIA-AI-Blueprints/rag/raw/main/docs/assets/summarization_flow_diagram.png)\n", + "\n", + "The summarization workflow that this notebook focuses on. You'll learn to customize:\n", + "\n", + "- **Page Filtering**: Select specific pages using ranges, negative indexing, or even/odd patterns\n", + "- **Shallow vs Full Extraction**: Fast text-only OR comprehensive multimodal processing\n", + "- **Summarization Strategy**: Choose between Single (fastest), Hierarchical (balanced), or Iterative (best quality - default)\n", + " - **Single**: Merge all content, chunk by configured size, and summarize only the first chunk (fastest, one LLM call)\n", + " - **Hierarchical**: Tree-based summarization - summarize all chunks, merge summaries until they fit chunk size, repeat recursively until reaching one final summary (balanced speed/quality)\n", + " - **Iterative (default)**: Process chunks sequentially with context refinement from previous summaries (best quality, N sequential LLM calls)\n", + "- **Token-based Chunking**: 9000 tokens per chunk with 400 token overlap\n", + "- **Real-time Status Tracking**: Monitor progress via Redis with chunk-level updates" + ], + "id": "b134dbf9" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---" + ], + "id": "3a62b1a6" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Part 1: Library Mode " + ], + "id": "0dc56f4f" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 1. Setup before using library mode" + ], + "id": "f389a897" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### 1.1. Installation guide for python package\n", + "\n", + "> **Note**: Python version **3.11 or higher** is required.\n", + "\n", + "##### 📝 **Development Mode Note:**\n", + "\n", + "- Installing with `uv pip install -e \"..[all]\"` allows you to make live edits to the `nvidia_rag` source code and have those changes reflected without reinstalling the package.\n", + "- After making changes to the source code, you need to:\n", + " - Restart the kernel of your notebook server\n", + " - Re-execute the cells under `Setting up the dependencies` and `Import the packages` sections" + ], + "id": "7adbc9f2" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Install uv (if not already installed)\n", + "\n", + "Run the cell below to check if `uv` is installed and install it if needed." + ], + "id": "d2bb524d" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "import subprocess\n", + "import shutil\n", + "\n", + "# Check if uv is installed\n", + "if shutil.which(\"uv\"):\n", + " result = subprocess.run([\"uv\", \"--version\"], capture_output=True, text=True)\n", + " print(f\"✅ uv is already installed: {result.stdout.strip()}\")\n", + "else:\n", + " print(\"⚠️ uv is not installed. Installing now...\")\n", + " # Install uv using the official installer\n", + " !curl -LsSf https://astral.sh/uv/install.sh | sh\n", + " print(\"\\n✅ uv installed! Please restart your terminal/kernel and re-run this notebook.\")" + ], + "execution_count": null, + "outputs": [], + "id": "f7fa66fc" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Install the NVIDIA RAG Package\n", + "\n", + "Choose one of the installation options below:\n", + "- **Option A**: Install from PyPI (recommended for most users)\n", + "- **Option B**: Install from source in development mode (for contributors)\n", + "- **Option C**: Build and install from source wheel" + ], + "id": "c6ce9248" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# Option A: Install from PyPI (recommended)\n", + "# Uncomment the line below to install from PyPI\n", + "# !uv pip install nvidia-rag[all]\n", + "\n", + "# Option B: Install from source in development mode (for contributors)\n", + "# Note: \"..\" refers to the parent directory where pyproject.toml is located\n", + "!uv pip install -e \"..[all]\"\n", + "\n", + "# Option C: Build and install from source wheel\n", + "# Uncomment the lines below to build and install from source\n", + "# !cd .. && uv build\n", + "# !uv pip install ../dist/nvidia_rag-*-py3-none-any.whl[all]" + ], + "execution_count": null, + "outputs": [], + "id": "e775b4ac" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### 1.2. Verify the installation\n", + "The location of the package shown in the output of this command should be inside the virtual environment.\n", + "\n", + "Location: `/rag/.venv/lib/python3.12/site-packages`" + ], + "id": "7cdd2bd9" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "!uv pip show nvidia_rag | grep Location" + ], + "execution_count": null, + "outputs": [], + "id": "08c40dab" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 2. Setting up the dependencies" + ], + "id": "ff09d666" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "After the environment for the python package is set up, launch all the dependent services and NIMs that the pipeline depends on.\n", + "\n", + "Fulfill the [prerequisites here](https://github.com/NVIDIA-AI-Blueprints/rag/blob/main/docs/deploy-docker-self-hosted.md) to set up docker on your system." + ], + "id": "efdd89dc" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### 2.1. Setup the default configurations" + ], + "id": "84bf4f5f" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "!uv pip install python-dotenv\n", + "import os\n", + "from getpass import getpass\n", + "\n", + "from dotenv import load_dotenv" + ], + "execution_count": null, + "outputs": [], + "id": "1b5162b4" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Provide your NGC_API_KEY after executing the cell below. You can obtain a key by following steps [here](https://github.com/NVIDIA-AI-Blueprints/rag/blob/main/docs/api-key.md)." + ], + "id": "ec509cc9" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# del os.environ['NGC_API_KEY'] ## delete key and reset if needed\n", + "if os.environ.get(\"NGC_API_KEY\", \"\").startswith(\"nvapi-\"):\n", + " print(\"Valid NGC_API_KEY already in environment. Delete to reset\")\n", + "else:\n", + " candidate_api_key = getpass(\"NVAPI Key (starts with nvapi-): \")\n", + " assert candidate_api_key.startswith(\"nvapi-\"), (\n", + " f\"{candidate_api_key[:5]}... is not a valid key\"\n", + " )\n", + " os.environ[\"NGC_API_KEY\"] = candidate_api_key" + ], + "execution_count": null, + "outputs": [], + "id": "d60f81ff" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Login to nvcr.io which is needed for pulling the containers of dependencies" + ], + "id": "70dec262" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "!echo \"${NGC_API_KEY}\" | docker login nvcr.io -u '$oauthtoken' --password-stdin" + ], + "execution_count": null, + "outputs": [], + "id": "7fec18c6" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### 2.2. Setup the Elasticsearch vector DB services\n", + "Elasticsearch is the default vector store.\n", + "See [change-vectordb.md](https://github.com/NVIDIA-AI-Blueprints/rag/blob/main/docs/change-vectordb.md) to switch to Milvus if needed." + ], + "id": "593ac9b0" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "os.environ[\"VECTORSTORE_GPU_DEVICE_ID\"] = \"0\"" + ], + "execution_count": null, + "outputs": [], + "id": "b5fa2f5d" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "!docker compose -f ../deploy/compose/vectordb.yaml up -d" + ], + "execution_count": null, + "outputs": [], + "id": "51c7b137" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### 2.3. Setup the NIMs" + ], + "id": "3969aa2c" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Option 1: Deploy on-prem models" + ], + "id": "99abdce4" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Move to Option 2 if you are interested in using cloud models.\n", + "\n", + "Ensure you meet [the hardware requirements](https://github.com/NVIDIA-AI-Blueprints/rag/blob/main/docs/support-matrix.md). By default the NIMs are configured to use 2xH100." + ], + "id": "4bb2ea98" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# Create the model cache directory\n", + "!mkdir -p ~/.cache/model-cache" + ], + "execution_count": null, + "outputs": [], + "id": "816b14c7" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# Set the MODEL_DIRECTORY environment variable in the Python kernel\n", + "import os\n", + "\n", + "os.environ[\"MODEL_DIRECTORY\"] = os.path.expanduser(\"~/.cache/model-cache\")\n", + "print(\"MODEL_DIRECTORY set to:\", os.environ[\"MODEL_DIRECTORY\"])" + ], + "execution_count": null, + "outputs": [], + "id": "50299ae0" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# Configure GPU IDs for the various microservices if needed\n", + "os.environ[\"EMBEDDING_MS_GPU_ID\"] = \"0\"\n", + "os.environ[\"RANKING_MS_GPU_ID\"] = \"0\"\n", + "os.environ[\"YOLOX_MS_GPU_ID\"] = \"0\"\n", + "os.environ[\"YOLOX_GRAPHICS_MS_GPU_ID\"] = \"0\"\n", + "os.environ[\"YOLOX_TABLE_MS_GPU_ID\"] = \"0\"\n", + "os.environ[\"OCR_MS_GPU_ID\"] = \"0\"\n", + "os.environ[\"LLM_MS_GPU_ID\"] = \"1\"" + ], + "execution_count": null, + "outputs": [], + "id": "f577f8ff" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# ⚠️ Deploying NIMs - This may take a while as models download. If kernel times out, just rerun this cell.\n", + "!USERID=$(id -u) docker compose -f ../deploy/compose/nims.yaml up -d" + ], + "execution_count": null, + "outputs": [], + "id": "1c621259" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# Watch the status of running containers (run this cell repeatedly or in a terminal)\n", + "!docker ps" + ], + "execution_count": null, + "outputs": [], + "id": "af5e0f6f" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# Set deployment mode for on-prem NIMs\n", + "DEPLOYMENT_MODE = \"on_prem\"" + ], + "execution_count": null, + "outputs": [], + "id": "2ba63d1d" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Ensure all the below are running and healthy before proceeding further\n", + "```output\n", + "NAMES STATUS\n", + "nemotron-ranking-ms Up ... (healthy)\n", + "compose-page-elements-1 Up ...\n", + "compose-nemotron-ocr-1 Up ...\n", + "compose-graphic-elements-1 Up ...\n", + "compose-table-structure-1 Up ...\n", + "nemotron-vlm-embedding-ms Up ... (healthy)\n", + "nim-llm-ms Up ... (healthy)\n", + "```" + ], + "id": "9fed48c0" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Option 2: Using Nvidia Hosted models" + ], + "id": "dbac5b7a" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "DEPLOYMENT_MODE = globals().get(\"DEPLOYMENT_MODE\", os.environ.get(\"RAG_DEPLOYMENT_MODE\", \"cloud\"))\n", + "\n", + "if DEPLOYMENT_MODE != \"cloud\":\n", + " print(f\"Skipping NVIDIA hosted model configuration for DEPLOYMENT_MODE={DEPLOYMENT_MODE!r}\")\n", + "else:\n", + "\n", + " # Set deployment mode for NVIDIA hosted cloud APIs\n", + " os.environ[\"OCR_HTTP_ENDPOINT\"] = \"https://ai.api.nvidia.com/v1/cv/nvidia/nemotron-ocr-v1\"\n", + " os.environ[\"OCR_INFER_PROTOCOL\"] = \"http\"\n", + " os.environ[\"YOLOX_HTTP_ENDPOINT\"] = (\n", + " \"https://ai.api.nvidia.com/v1/cv/nvidia/nemotron-page-elements-v3\"\n", + " )\n", + " os.environ[\"YOLOX_INFER_PROTOCOL\"] = \"http\"\n", + " os.environ[\"YOLOX_GRAPHIC_ELEMENTS_HTTP_ENDPOINT\"] = (\n", + " \"https://ai.api.nvidia.com/v1/cv/nvidia/nemotron-graphic-elements-v1\"\n", + " )\n", + " os.environ[\"YOLOX_GRAPHIC_ELEMENTS_INFER_PROTOCOL\"] = \"http\"\n", + " os.environ[\"YOLOX_TABLE_STRUCTURE_HTTP_ENDPOINT\"] = (\n", + " \"https://ai.api.nvidia.com/v1/cv/nvidia/nemotron-table-structure-v1\"\n", + " )\n", + " os.environ[\"YOLOX_TABLE_STRUCTURE_INFER_PROTOCOL\"] = \"http\"" + ], + "execution_count": null, + "outputs": [], + "id": "2d529315" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### 2.3.1. Configure embedding endpoints for NV-Ingest\n", + "\n", + "NV-Ingest reads `APP_EMBEDDINGS_*` at **container startup**. Set these before starting the runtime so ingestion and health checks use the VLM embedding NIM (`nemotron-vlm-embedding-ms`), not the legacy text-only `nemotron-embedding-ms` service." + ], + "id": "d7afa514" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# Override stale embedding env (e.g. from variables.env or notebooks/.env_library).\n", + "load_dotenv(dotenv_path=\"../deploy/compose/.env\", override=False)\n", + "\n", + "deployment_mode = globals().get(\"DEPLOYMENT_MODE\", os.environ.get(\"RAG_DEPLOYMENT_MODE\", \"cloud\"))\n", + "if deployment_mode == \"cloud\":\n", + " os.environ[\"APP_EMBEDDINGS_SERVERURL\"] = \"https://integrate.api.nvidia.com/v1\"\n", + " os.environ[\"APP_EMBEDDINGS_MODELNAME\"] = \"nvidia/llama-nemotron-embed-vl-1b-v2\"\n", + " print(\"✓ Embedding: NVIDIA API catalog\")\n", + "else:\n", + " # Include the scheme here so URL sanitization does not append a second /v1.\n", + " os.environ[\"APP_EMBEDDINGS_SERVERURL\"] = \"http://nemotron-vlm-embedding-ms:8000/v1\"\n", + " os.environ[\"APP_EMBEDDINGS_MODELNAME\"] = \"nvidia/llama-nemotron-embed-vl-1b-v2\"\n", + " print(\"✓ Embedding: on-prem VLM NIM (nemotron-vlm-embedding-ms)\")\n", + "\n", + "print(f\" APP_EMBEDDINGS_SERVERURL={os.environ['APP_EMBEDDINGS_SERVERURL']}\")\n", + "print(f\" APP_EMBEDDINGS_MODELNAME={os.environ['APP_EMBEDDINGS_MODELNAME']}\")" + ], + "execution_count": null, + "outputs": [], + "id": "2bca97f1" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### 2.4. Setup the Nvidia Ingest runtime and redis service" + ], + "id": "d5b176dc" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# --force-recreate ensures NV-Ingest picks up APP_EMBEDDINGS_* set above\n", + "!docker compose -f ../deploy/compose/docker-compose-ingestor-server.yaml up nv-ingest-ms-runtime redis -d --force-recreate" + ], + "execution_count": null, + "outputs": [], + "id": "be355c11" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### 2.5. Load optional profiles if needed" + ], + "id": "cbc0ef32" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# Load accuracy profile\n", + "# load_dotenv(dotenv_path='../deploy/compose/accuracy_profile.env', override=True)\n", + "\n", + "# OR load perf profile\n", + "# load_dotenv(dotenv_path='../deploy/compose/perf_profile.env', override=True)" + ], + "execution_count": null, + "outputs": [], + "id": "4c1fca90" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 3. Import libraries and view defaults\n", + "\n", + "After setting up the python package and starting all dependent services, we can now import the libraries and view default configuration for summarization." + ], + "id": "12b33ced" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### 3.1. Set logging level\n", + "\n", + "First let's set the required logging level. Set to INFO for displaying basic important logs. Set to DEBUG for full verbosity." + ], + "id": "0f067c70" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "import logging\n", + "import os\n", + "\n", + "# Set the log level via environment variable before importing nvidia_rag\n", + "# This ensures the package respects our log level setting\n", + "LOGLEVEL = logging.WARNING # Set to INFO, DEBUG, WARNING or ERROR\n", + "os.environ[\"LOGLEVEL\"] = logging.getLevelName(LOGLEVEL)\n", + "\n", + "# Configure logging\n", + "logging.basicConfig(level=LOGLEVEL, force=True)\n", + "\n", + "# Set log levels for specific loggers after package import\n", + "for name in logging.root.manager.loggerDict:\n", + " if name == \"nvidia_rag\" or name.startswith(\"nvidia_rag.\"):\n", + " logging.getLogger(name).setLevel(LOGLEVEL)\n", + " if name == \"nv_ingest_client\" or name.startswith(\"nv_ingest_client.\"):\n", + " logging.getLogger(name).setLevel(LOGLEVEL)" + ], + "execution_count": null, + "outputs": [], + "id": "f15e1d4f" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### 3.2. Import the packages and initialize configuration\n", + "You can import both or either one based on your requirements. `NvidiaRAG()` exposes APIs to interact with the uploaded documents or retrieve summaries and `NvidiaRAGIngestor()` exposes APIs for document upload, management and summary generation." + ], + "id": "5be07e0e" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "from nvidia_rag import NvidiaRAG, NvidiaRAGIngestor\n", + "from nvidia_rag.utils.configuration import NvidiaRAGConfig\n", + "from nvidia_rag.rag_server.response_generator import retrieve_summary\n", + "import socket\n", + "\n", + "# Get the configuration object\n", + "config = NvidiaRAGConfig.from_yaml(\"config.yaml\")\n", + "\n", + "\n", + "def can_resolve(hostname: str) -> bool:\n", + " try:\n", + " socket.gethostbyname(hostname)\n", + " return True\n", + " except OSError:\n", + " return False\n", + "\n", + "# Update config for cloud deployment if using Option 2\n", + "if DEPLOYMENT_MODE == \"cloud\":\n", + " config.embeddings.server_url = \"https://integrate.api.nvidia.com/v1\"\n", + " config.embeddings.model_name = \"nvidia/llama-nemotron-embed-vl-1b-v2\"\n", + " config.llm.server_url = \"\" # Empty uses NVIDIA API catalog\n", + " config.ranking.server_url = \"https://ai.api.nvidia.com/v1/retrieval/nvidia/llama-nemotron-rerank-1b-v2/reranking/v1\"\n", + " config.summarizer.server_url = \"\" # Empty uses NVIDIA API catalog\n", + "else:\n", + " # Prefer Docker service names when the notebook kernel can resolve them.\n", + " # Fall back to host ports only for host-side kernels outside the Compose network.\n", + " use_docker_dns = can_resolve(\"nim-llm\")\n", + " config.embeddings.server_url = (\n", + " \"http://nemotron-vlm-embedding-ms:8000/v1\"\n", + " if use_docker_dns\n", + " else \"http://localhost:9081/v1\"\n", + " )\n", + " config.embeddings.model_name = \"nvidia/llama-nemotron-embed-vl-1b-v2\"\n", + " config.ranking.server_url = (\n", + " \"http://nemotron-ranking-ms:8000\" if use_docker_dns else \"http://localhost:1976\"\n", + " )\n", + " config.summarizer.server_url = (\n", + " \"http://nim-llm:8000\" if use_docker_dns else \"http://localhost:8999\"\n", + " )\n", + " config.llm.server_url = (\n", + " \"http://nim-llm:8000\" if use_docker_dns else \"http://localhost:8999\"\n", + " )\n", + "\n", + "# Initialize NvidiaRAG and NvidiaRAGIngestor with config\n", + "# For summarization customization, pass prompts to NvidiaRAGIngestor:\n", + "# - A path to a YAML/JSON file: prompts=\"custom_prompts.yaml\"\n", + "# - A dictionary: prompts={\"document_summary_prompt\": {...}}\n", + "rag = NvidiaRAG(config=config)\n", + "ingestor = NvidiaRAGIngestor(config=config)" + ], + "execution_count": null, + "outputs": [], + "id": "47db7b83" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### 3.3. View Default Summarizer LLM Settings\n", + "\n", + "Let's see what LLM model and parameters are used by default for summarization." + ], + "id": "8fceebee" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "print(\"=\" * 70)\n", + "print(\"DEFAULT SUMMARIZER LLM CONFIGURATION\")\n", + "print(\"=\" * 70)\n", + "print(f\"Model: {config.summarizer.model_name}\")\n", + "print(f\"Server URL: {config.summarizer.server_url}\")\n", + "print(f\"Temperature: {config.summarizer.temperature}\")\n", + "print(f\"Top P: {config.summarizer.top_p}\")\n", + "print(f\"Max Parallel: {config.summarizer.max_parallelization}\")\n", + "print(f\"Max Chunk Length: {config.summarizer.max_chunk_length}\")\n", + "print(f\"Chunk Overlap: {config.summarizer.chunk_overlap}\")\n", + "print(\"=\" * 70)" + ], + "execution_count": null, + "outputs": [], + "id": "5b53e5bf" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### 3.4. View Default Summarization Prompts\n", + "\n", + "The prompt template controls how the LLM generates summaries. Let's see the default prompts." + ], + "id": "d50f697c" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "import json\n", + "\n", + "# Access prompts from the NvidiaRAGIngestor instance (initialized with defaults)\n", + "# Summarization is handled by NvidiaRAGIngestor, so view prompts from ingestor\n", + "print(\"=\" * 70)\n", + "print(\"DEFAULT DOCUMENT SUMMARY PROMPT\")\n", + "print(\"=\" * 70)\n", + "print(json.dumps(ingestor.prompts[\"document_summary_prompt\"], indent=2))\n", + "print(\"=\" * 70)\n", + "\n", + "print(\"\\n\" + \"=\" * 70)\n", + "print(\"DEFAULT ITERATIVE SUMMARY PROMPT\")\n", + "print(\"=\" * 70)\n", + "print(json.dumps(ingestor.prompts[\"iterative_summary_prompt\"], indent=2))\n", + "print(\"=\" * 70)" + ], + "execution_count": null, + "outputs": [], + "id": "b6cbb7d0" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "This will display the default prompts used for:\n", + "- **document_summary_prompt**: Summarizing a single document or chunk (used for full multimodal extraction)\n", + "- **shallow_summary_prompt**: Summarizing with fast text-only extraction (used when `shallow_summary: true`)\n", + "- **iterative_summary_prompt**: Combining multiple summaries for large documents\n", + "\n", + "The system automatically selects the appropriate prompt based on extraction mode and document size." + ], + "id": "fce20869" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---" + ], + "id": "3eefeb2b" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Part 2: Library Mode - Change Configuration\n", + "Now let's see how to modify these settings programmatically in library mode.\n", + "\n", + "### 1. Change LLM Model and Parameters\n", + "\n", + "You can change the model and sampling parameters dynamically." + ], + "id": "27ec763e" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# Change summarizer model/endpoint for the selected deployment.\n", + "if DEPLOYMENT_MODE == \"cloud\":\n", + " config.summarizer.model_name = \"nvidia/nemotron-3-nano-30b-a3b\"\n", + " config.summarizer.server_url = \"\" # Empty uses NVIDIA API catalog\n", + " config.summarizer.max_parallelization = 2\n", + "else:\n", + " # Must match the model served by the local nim-llm NIM.\n", + " config.summarizer.model_name = \"nvidia/nemotron-3-super-120b-a12b\"\n", + " use_docker_dns = globals().get(\"use_docker_dns\", False)\n", + " config.summarizer.server_url = (\n", + " \"http://nim-llm:8000\" if use_docker_dns else \"http://localhost:8999\"\n", + " )\n", + " config.summarizer.max_parallelization = 4\n", + "\n", + "# Lower temperature for more deterministic, focused summaries\n", + "config.summarizer.temperature = 0.2\n", + "\n", + "# Adjust top_p for nucleus sampling\n", + "config.summarizer.top_p = 0.7\n", + "\n", + "# Configure global rate limiting (max parallel summary tasks across all workers).\n", + "# Keep hosted API examples conservative to avoid 429 rate-limit errors.\n", + "\n", + "print(\"✅ Updated Summarizer Configuration:\")\n", + "print(f\" Model: {config.summarizer.model_name}\")\n", + "print(f\" Server URL: {config.summarizer.server_url}\")\n", + "print(f\" Temperature: {config.summarizer.temperature}\")\n", + "print(f\" Top P: {config.summarizer.top_p}\")\n", + "print(f\" Max Parallel:{config.summarizer.max_parallelization}\")" + ], + "execution_count": null, + "outputs": [], + "id": "6cc84ba0" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 2. Customize Summarization Prompts\n", + "\n", + "Customize the prompt to change the style and focus of summaries by passing prompts during `NvidiaRAGIngestor` initialization.\n", + "\n", + "This is the **recommended approach** for library mode - pass prompts directly to the constructor for clean, instance-specific configuration.\n", + "\n", + "> **Note**: Summarization is handled by `NvidiaRAGIngestor`, so prompts for summarization should be passed to `NvidiaRAGIngestor`, not `NvidiaRAG`." + ], + "id": "15dabf4c" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# Define custom prompts as a dictionary\n", + "custom_prompts = {\n", + " \"document_summary_prompt\": {\n", + " \"system\": \"/no_think\",\n", + " \"human\": \"\"\"You are a documentation specialist.\n", + "\n", + "Create a clear, summary that:\n", + "1. Identifies the main topic and purpose\n", + "2. Lists key concepts or features\n", + "3. Highlights important procedures or steps \n", + "4. Notes any warnings or critical information\n", + "\n", + "Keep the summary concise.\n", + "\n", + "Text to summarize:\n", + "{document_text}\n", + "\n", + "Summary:\"\"\"\n", + " }\n", + "}\n", + "\n", + "# Create NvidiaRAGIngestor instance with custom prompts (Recommended Approach)\n", + "# The prompts are merged with defaults - only specified keys are overridden\n", + "ingestor_custom = NvidiaRAGIngestor(config=config, prompts=custom_prompts)\n", + "\n", + "print(\"✅ NvidiaRAGIngestor initialized with custom prompts\")\n", + "print(\"\\nCustom prompt preview (first 200 chars):\")\n", + "print(ingestor_custom.prompts[\"document_summary_prompt\"][\"human\"][:200] + \"...\")" + ], + "execution_count": null, + "outputs": [], + "id": "05167610" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Alternative: Using a YAML File\n", + "\n", + "You can also pass a path to a YAML file containing your custom prompts:\n", + "\n", + "```python\n", + "# Using a YAML file path\n", + "ingestor_from_yaml = NvidiaRAGIngestor(config=config, prompts=\"custom_prompts.yaml\")\n", + "```\n", + "\n", + "The YAML file format should match the structure shown in the Docker Mode section below.\n" + ], + "id": "a90fa503" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 3. Configure Summary Options" + ], + "id": "c56d2be2" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "summary_options = {\n", + " # Page filtering: [[1, 10]] (ranges), [[-5, -1]] (last N pages), \"even\"/\"odd\"\n", + " \"page_filter\": [[1, 10]], # Only pages 1-10\n", + " \n", + " # Fast mode: Text-only extraction first, summary in seconds\n", + " \"shallow_summary\": True, # Default: False\n", + " \n", + " # Strategy: None (iterative/best), \"single\" (fewest LLM calls), \"hierarchical\" (parallel/faster than iterative)\n", + " \"summarization_strategy\": \"single\" # Use \"hierarchical\" when your quota/GPU capacity allows more parallel calls.\n", + "}\n", + "\n", + "\n", + "print(f\" • Page Filter: {summary_options['page_filter']}\")\n", + "print(f\" • Shallow Summary: {summary_options['shallow_summary']}\")\n", + "print(f\" • Strategy: {summary_options['summarization_strategy']}\")" + ], + "execution_count": null, + "outputs": [], + "id": "0fce6903" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 4. Complete Workflow Example\n", + "\n", + "This section demonstrates the end-to-end workflow: create collection → upload documents → check status → retrieve summary → cleanup." + ], + "id": "ff3be036" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### 4.1. Create Collection" + ], + "id": "c0c75f90" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# Create collection\n", + "collection_name = \"test_summary\"\n", + "response = ingestor.create_collection(\n", + " collection_name=collection_name,\n", + " vdb_endpoint=\"http://localhost:9200\"\n", + ")\n", + "print(f\"✅ Collection response: {response}\")" + ], + "execution_count": null, + "outputs": [], + "id": "606e67a3" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### 4.2. Upload Documents" + ], + "id": "1ac5baec" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# Upload documents with summary options\n", + "result = await ingestor.upload_documents(\n", + " filepaths=[\"../data/multimodal/functional_validation.pdf\"],\n", + " collection_name=collection_name,\n", + " generate_summary=True,\n", + " summary_options=summary_options, # From previous cell\n", + " blocking=False # Don't wait, check status instead\n", + ")\n", + "print(f\"✅ Upload started: {result}\")" + ], + "execution_count": null, + "outputs": [], + "id": "cbf50562" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### 4.3. Check Status and Get Summary" + ], + "id": "02afe705" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# Check summary status\n", + "status = await retrieve_summary(\n", + " collection_name=collection_name,\n", + " file_name=\"functional_validation.pdf\",\n", + " wait=False # Just check, don't wait\n", + ")\n", + "print(f\"\\n📊 Status: {status.get('status')}\")\n", + "if status.get('status') == 'IN_PROGRESS':\n", + " progress = status.get('progress', {})\n", + " print(f\" Progress: Chunk {progress.get('current')}/{progress.get('total')}\")" + ], + "execution_count": null, + "outputs": [], + "id": "94cebc0e" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# Get summary (blocking - waits until complete)\n", + "summary_result = await retrieve_summary(\n", + " collection_name=collection_name,\n", + " file_name=\"functional_validation.pdf\",\n", + " wait=True,\n", + " timeout=300\n", + ")\n", + "\n", + "if summary_result.get('status') == 'SUCCESS':\n", + " print(f\"\\n✅ Summary:\\n{summary_result.get('summary')}\")\n", + "else:\n", + " print(f\"\\n❌ {summary_result.get('status')}: {summary_result.get('message')}\")" + ], + "execution_count": null, + "outputs": [], + "id": "301f8dfc" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### 4.4. Delete Collection" + ], + "id": "de34199d" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# Delete the test collection\n", + "response = ingestor.delete_collections(\n", + " collection_names=[collection_name],\n", + " vdb_endpoint=\"http://localhost:9200\"\n", + ")\n", + "print(f\"✅ Delete response: {response}\")" + ], + "execution_count": null, + "outputs": [], + "id": "71fe09fe" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---" + ], + "id": "caea11bf" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Part 3: Docker Mode - Change Configuration via Environment Variables\n", + "\n", + "When running in Docker mode, you configure the ingestor-server and rag-server containers via environment variables and REST APIs.\n", + "\n", + "**Prerequisites:**\n", + "- If you're starting fresh with Part 3, first complete section **\"2. Setting up the dependencies\"** from Part 1 above to start all required services (Elasticsearch, NV-Ingest, Redis)\n", + "- If you completed Part 1, these services are already running" + ], + "id": "a55c3b72" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 1. Configure via Environment Variables\n", + "\n", + "Configure the ingestor server by setting environment variables before startup. Adjust these values according to your requirements:" + ], + "id": "f9e4a17e" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# Set environment variables in Python based on mode\n", + "if DEPLOYMENT_MODE == \"cloud\":\n", + " os.environ[\"SUMMARY_LLM\"] = \"nvidia/nemotron-3-nano-30b-a3b\"\n", + " os.environ[\"SUMMARY_LLM_SERVERURL\"] = \"\"\n", + " os.environ[\"APP_LLM_SERVERURL\"] = \"\"\n", + " os.environ[\"APP_EMBEDDINGS_SERVERURL\"] = \"https://integrate.api.nvidia.com/v1\"\n", + " os.environ[\"APP_EMBEDDINGS_MODELNAME\"] = \"nvidia/llama-nemotron-embed-vl-1b-v2\"\n", + " os.environ[\"SUMMARY_MAX_PARALLELIZATION\"] = \"2\"\n", + " print(\"✓ Configured for NVIDIA cloud APIs\")\n", + "else:\n", + " # Must match the model served by the local nim-llm NIM.\n", + " os.environ[\"SUMMARY_LLM\"] = \"nvidia/nemotron-3-super-120b-a12b\"\n", + " os.environ[\"SUMMARY_LLM_SERVERURL\"] = \"nim-llm:8000\"\n", + " os.environ[\"APP_LLM_SERVERURL\"] = \"nim-llm:8000\"\n", + " # Include the scheme here so URL sanitization does not append a second /v1.\n", + " os.environ[\"APP_EMBEDDINGS_SERVERURL\"] = \"http://nemotron-vlm-embedding-ms:8000/v1\"\n", + " os.environ[\"APP_EMBEDDINGS_MODELNAME\"] = \"nvidia/llama-nemotron-embed-vl-1b-v2\"\n", + " os.environ[\"SUMMARY_MAX_PARALLELIZATION\"] = \"4\"\n", + " print(\"✓ Configured for on-prem NIMs\")\n", + "\n", + "os.environ[\"LOGLEVEL\"] = \"INFO\"\n", + "\n", + "print(\"Environment variables set for deployment mode\")" + ], + "execution_count": null, + "outputs": [], + "id": "4eae9bca" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "%%bash\n", + "# Custom Summarization configuration\n", + "export SUMMARY_LLM=\"${SUMMARY_LLM:-nvidia/nemotron-3-super-120b-a12b}\"\n", + "export SUMMARY_LLM_TEMPERATURE=0.2\n", + "export SUMMARY_LLM_TOP_P=0.7\n", + "export SUMMARY_LLM_MAX_CHUNK_LENGTH=9000\n", + "export SUMMARY_CHUNK_OVERLAP=400\n", + "export SUMMARY_MAX_PARALLELIZATION=\"${SUMMARY_MAX_PARALLELIZATION:-2}\"\n", + "\n", + "# Embedding endpoint (inherited from previous cell; fallback to VLM NIM defaults)\n", + "export APP_EMBEDDINGS_SERVERURL=\"${APP_EMBEDDINGS_SERVERURL:-http://nemotron-vlm-embedding-ms:8000/v1}\"\n", + "export APP_EMBEDDINGS_MODELNAME=\"${APP_EMBEDDINGS_MODELNAME:-nvidia/llama-nemotron-embed-vl-1b-v2}\"\n", + "\n", + "# start container (--force-recreate picks up embedding env for health checks)\n", + "docker compose -f ../deploy/compose/docker-compose-ingestor-server.yaml up -d --force-recreate ingestor-server\n", + "docker compose -f ../deploy/compose/docker-compose-rag-server.yaml up -d --force-recreate rag-server\n", + "\n", + "echo \"Configure summarization parameters and start container\"" + ], + "execution_count": null, + "outputs": [], + "id": "920a5ad0" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 2. Custom Prompts via YAML File\n", + "\n", + "To change prompts in Docker mode, create a custom `prompt.yaml` file and set the `PROMPT_CONFIG_FILE` environment variable.\n", + "\n", + "#### 2.1. Create Custom Prompt File\n", + "\n", + "Create your custom prompt file (e.g., `/home/user/my_custom_prompt.yaml`):" + ], + "id": "7307d496" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# Define custom prompt configuration\n", + "custom_prompt_content = \"\"\"document_summary_prompt:\n", + " system: |\n", + " /no_think\n", + " \n", + " human: |\n", + " You are a technical documentation specialist.\n", + " \n", + " Create a clear, technical summary that:\n", + " 1. Identifies the main topic and purpose\n", + " 2. Lists key technical concepts or features\n", + " 3. Highlights important procedures or steps\n", + " 4. Notes any warnings or critical information\n", + " \n", + " Keep the summary concise and technical.\n", + " \n", + " Text to summarize:\n", + " {document_text}\n", + " \n", + " Technical Summary:\n", + "\n", + "iterative_summary_prompt:\n", + " system: |\n", + " /no_think\n", + " \n", + " human: |\n", + " You are a technical documentation specialist combining summaries.\n", + " \n", + " Previous Summary:\n", + " {previous_summary}\n", + " \n", + " New chunk:\n", + " {new_chunk}\n", + " \n", + " Create an updated technical summary combining both.\n", + "\"\"\"\n", + "\n", + "# Write the custom prompt file\n", + "import os\n", + "custom_prompt_path = os.path.expanduser(\"~/my_custom_prompt.yaml\")\n", + "with open(custom_prompt_path, \"w\") as f:\n", + " f.write(custom_prompt_content)\n", + "\n", + "print(f\"Custom prompt file created at: {custom_prompt_path}\")" + ], + "execution_count": null, + "outputs": [], + "id": "73da6ef7" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### 2.2. Set Environment Variable and Restart\n", + "\n", + "Set the environment variable and restart the container:" + ], + "id": "beffd23e" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "%%bash\n", + "# Set path to custom prompt file\n", + "export PROMPT_CONFIG_FILE=~/my_custom_prompt.yaml\n", + "\n", + "# Restart the container (no rebuild needed)\n", + "# Note: This inherits NGC_API_KEY from the parent shell if it was set via os.environ earlier\n", + "docker compose -f ../deploy/compose/docker-compose-ingestor-server.yaml up -d ingestor-server\n", + "\n", + "echo \"Ingestor server restarted with custom prompts from: $PROMPT_CONFIG_FILE\"" + ], + "execution_count": null, + "outputs": [], + "id": "a33369d9" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "**Key Points:**\n", + "- The service will merge your custom prompts with the defaults\n", + "- Only the prompts you specify will be overridden - all others remain unchanged\n", + "- No container rebuild is required, just restart with the new environment variable!\n", + "\n", + "For more details, see the prompt customization documentation." + ], + "id": "b25fe3db" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 3. Using Ingestor Server REST APIs\n", + "\n", + "When running in Docker mode, you interact with the ingestor server via REST APIs. Here's the complete workflow for document summarization using APIs.\n", + "\n", + "#### Prerequisites\n", + "- Ensure ingestor-server and rag-server containers are running\n", + "- Replace `localhost` with actual IP if hosted on another system" + ], + "id": "cb475d78" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# Install Dependencies\n", + "!uv pip install aiohttp" + ], + "execution_count": null, + "outputs": [], + "id": "069af221" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "import json\n", + "import os\n", + "import aiohttp\n", + "\n", + "# Setup base configuration\n", + "INGESTOR_BASE_URL = \"http://localhost:8082\"\n", + "RAG_BASE_URL = \"http://localhost:8081\"\n", + "\n", + "\n", + "async def print_response(response):\n", + " \"\"\"Helper to print API response.\"\"\"\n", + " try:\n", + " response_json = await response.json()\n", + " print(json.dumps(response_json, indent=2))\n", + " except aiohttp.ClientResponseError:\n", + " print(await response.text())" + ], + "execution_count": null, + "outputs": [], + "id": "54c02b58" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### 3.1. Health Check" + ], + "id": "ece3f9ed" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "async def check_health():\n", + " \"\"\"Check ingestor server health.\"\"\"\n", + " url = f\"{INGESTOR_BASE_URL}/v1/health\"\n", + " params = {\"check_dependencies\": \"True\"}\n", + " async with aiohttp.ClientSession() as session:\n", + " async with session.get(url, params=params) as response:\n", + " await print_response(response)\n", + "\n", + "await check_health()" + ], + "execution_count": null, + "outputs": [], + "id": "e2b9bd62" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### 3.2. Create Collection" + ], + "id": "e56e52f7" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "async def create_collection(collection_name: str):\n", + " \"\"\"Create a collection for document storage.\"\"\"\n", + " data = {\n", + " \"collection_name\": collection_name,\n", + " \"metadata_schema\": []\n", + " }\n", + " \n", + " headers = {\"Content-Type\": \"application/json\"}\n", + " \n", + " async with aiohttp.ClientSession() as session:\n", + " try:\n", + " async with session.post(\n", + " f\"{INGESTOR_BASE_URL}/v1/collection\", \n", + " json=data, \n", + " headers=headers\n", + " ) as response:\n", + " await print_response(response)\n", + " except aiohttp.ClientError as e:\n", + " print(f\"Error: {e}\")\n", + "\n", + "# Create collection\n", + "await create_collection(collection_name=\"test_summary_api\")" + ], + "execution_count": null, + "outputs": [], + "id": "2908a9b7" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### 3.3. Upload Documents with Summary Options" + ], + "id": "9492a594" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "async def upload_with_summary(collection_name: str, filepaths: list):\n", + " \"\"\"Upload documents and generate summaries.\"\"\"\n", + " \n", + " # Configure summary options\n", + " data = {\n", + " \"collection_name\": collection_name,\n", + " \"blocking\": False, # Non-blocking upload\n", + " \"split_options\": {\"chunk_size\": 512, \"chunk_overlap\": 150},\n", + " \"generate_summary\": True, # Enable summary generation\n", + " \"summary_options\": {\n", + " \"page_filter\": [[1, 10], [-5, -1]], # First 10 and last 5 pages\n", + " \"shallow_summary\": True, # Fast text-only extraction\n", + " \"summarization_strategy\": \"single\" # fastest strategy other available: \"hierarchical\", None(iterative)\n", + " }\n", + " }\n", + " \n", + " form_data = aiohttp.FormData()\n", + " for file_path in filepaths:\n", + " form_data.add_field(\n", + " \"documents\",\n", + " open(file_path, \"rb\"),\n", + " filename=os.path.basename(file_path),\n", + " content_type=\"application/pdf\",\n", + " )\n", + " \n", + " form_data.add_field(\"data\", json.dumps(data), content_type=\"application/json\")\n", + " \n", + " async with aiohttp.ClientSession() as session:\n", + " try:\n", + " async with session.post(\n", + " f\"{INGESTOR_BASE_URL}/v1/documents\", \n", + " data=form_data\n", + " ) as response:\n", + " await print_response(response)\n", + " response_json = await response.json()\n", + " return response_json.get(\"task_id\")\n", + " except aiohttp.ClientError as e:\n", + " print(f\"Error: {e}\")\n", + " return None\n", + "\n", + "# Upload documents\n", + "task_id = await upload_with_summary(\n", + " collection_name=\"test_summary_api\",\n", + " filepaths=[\"../data/multimodal/functional_validation.pdf\"]\n", + ")\n", + "print(f\"\\n✅ Upload task_id: {task_id}\")" + ], + "execution_count": null, + "outputs": [], + "id": "8bf7cada" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### 3.4. Check Upload Status (Ingestor Server)" + ], + "id": "1d4c4704" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "async def check_upload_status(task_id: str):\n", + " \"\"\"Check ingestion task status.\"\"\"\n", + " params = {\"task_id\": task_id}\n", + " headers = {\"Content-Type\": \"application/json\"}\n", + " \n", + " async with aiohttp.ClientSession() as session:\n", + " try:\n", + " async with session.get(\n", + " f\"{INGESTOR_BASE_URL}/v1/status\", \n", + " params=params, \n", + " headers=headers\n", + " ) as response:\n", + " await print_response(response)\n", + " except aiohttp.ClientError as e:\n", + " print(f\"Error: {e}\")\n", + "\n", + "# Check status\n", + "if task_id:\n", + " await check_upload_status(task_id=task_id)\n", + "else:\n", + " print(\"No task_id available\")" + ], + "execution_count": null, + "outputs": [], + "id": "d1d17b62" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### 3.5. Check Summary Status (RAG Server)" + ], + "id": "9986e2f9" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "async def check_summary_status(collection_name: str, file_name: str):\n", + " \"\"\"Check summary generation status via RAG server.\"\"\"\n", + " params = {\n", + " \"collection_name\": collection_name,\n", + " \"file_name\": file_name,\n", + " \"blocking\": \"false\" # Just check status, don't wait\n", + " }\n", + " \n", + " url = f\"{RAG_BASE_URL}/v1/summary\"\n", + " \n", + " async with aiohttp.ClientSession() as session:\n", + " try:\n", + " async with session.get(url, params=params) as response:\n", + " await print_response(response)\n", + " except aiohttp.ClientError as e:\n", + " print(f\"Error: {e}\")\n", + "\n", + "# Check summary status\n", + "await check_summary_status(\n", + " collection_name=\"test_summary_api\",\n", + " file_name=\"functional_validation.pdf\"\n", + ")" + ], + "execution_count": null, + "outputs": [], + "id": "1b773bfa" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### 3.6. Delete Collection" + ], + "id": "bcb0e82b" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "async def delete_collections(collection_names: list[str]):\n", + " \"\"\"Delete collections from the vector store.\"\"\"\n", + " url = f\"{INGESTOR_BASE_URL}/v1/collections\"\n", + " \n", + " async with aiohttp.ClientSession() as session:\n", + " try:\n", + " async with session.delete(url, json=collection_names) as response:\n", + " await print_response(response)\n", + " except aiohttp.ClientError as e:\n", + " print(f\"Error: {e}\")\n", + "\n", + "# Delete the test collection\n", + "await delete_collections(collection_names=[\"test_summary_api\"])" + ], + "execution_count": null, + "outputs": [], + "id": "fa7edca4" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---" + ], + "id": "5e120e1c" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Summary of Available Configuration Options\n", + "\n", + "### Summarizer Configuration Fields\n", + "\n", + "| Field | Environment Variable | Default Value | Description |\n", + "|-------|---------------------|---------------|-------------|\n", + "| `model_name` | `SUMMARY_LLM` | `nvidia/nemotron-3-super-120b-a12b` | The LLM model used for summarization |\n", + "| `server_url` | `SUMMARY_LLM_SERVERURL` | (empty) | Server URL for custom model hosting |\n", + "| `temperature` | `SUMMARY_LLM_TEMPERATURE` | `0.0` | Controls randomness (0.0-1.0) |\n", + "| `top_p` | `SUMMARY_LLM_TOP_P` | `1.0` | Nucleus sampling parameter (0.0-1.0) |\n", + "| `max_chunk_length` | `SUMMARY_LLM_MAX_CHUNK_LENGTH` | `9000` | Maximum chunk size in tokens |\n", + "| `chunk_overlap` | `SUMMARY_CHUNK_OVERLAP` | `400` | Overlap between chunks in tokens |\n", + "\n", + "### Prompt Template Variables\n", + "\n", + "- **document_summary_prompt**: Use `{document_text}` variable\n", + "- **iterative_summary_prompt**: Use `{previous_summary}` and `{new_chunk}` variable\n", + "\n", + "**Note:** Changes made in library mode take effect immediately without restarting any services. Changes in Docker mode require a container restart but no rebuild." + ], + "id": "baab41ef" + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "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.10" + } + }, + "nbformat": 4, + "nbformat_minor": 5 } diff --git a/pyproject.toml b/pyproject.toml index 32b7a7a6c..13abe8a9c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "nvidia_rag" -version = "2.5.1" +version = "2.6.0.rc1" description = "This blueprint serves as a reference solution for a foundational Retrieval Augmented Generation (RAG) pipeline." readme = "README.md" license = "Apache-2.0" @@ -20,19 +20,20 @@ dependencies = [ "anyio>=4.12.0", "httpx>=0.28.1", "httpx-sse>=0.4.3", - "langchain>=1.2.7", + "langchain>=1.3.1", "langchain-community>=0.4", + "langgraph>=1.2.1", "langchain-milvus>=0.3.0", - "langchain-nvidia-ai-endpoints>=1.2.0", + "langchain-nvidia-ai-endpoints>=1.4.0", "minio>=7.2,<8.0", "pdfplumber>=0.11.9", "pydantic>=2.11,<3.0", "pymilvus[milvus_lite]>=2.6.7,<3.0", "pymilvus-model>=0.3,<1.0", - "python-multipart>=0.0.22,<1.0", + "python-multipart>=0.0.27,<1.0", "pyyaml>=6.0,<7.0", "uvicorn[standard]>=0.32,<1.0", - "langchain-core>=1.2.22", + "langchain-core>=1.2.28", "redis>=4.3.4", "protobuf>=6.33.5", "lark>=1.2.2", @@ -41,7 +42,8 @@ dependencies = [ [project.optional-dependencies] rag = [ - "langchain-openai>=0.2", + "langchain-openai>=0.2,<1.1.9", + "openai>=1.0,<2.0", "opentelemetry-api>=1.29,<2.0", "opentelemetry-exporter-otlp>=1.29,<2.0", "opentelemetry-exporter-prometheus>=0.50b0,<1.0", @@ -55,14 +57,16 @@ rag = [ "azure-core>=1.35,<2.0", "azure-storage-blob>=12.26,<13.0", "pyarrow>=21.0,<22.0", + "tiktoken>=0.7", ] ingest = [ # nv-ingest dependencies (required for ingestion operations) - "nv-ingest-api==26.1.2", - "nv-ingest-client==26.1.2", + "nv-ingest-api==26.3.0", + "nv-ingest-client==26.3.0", "tritonclient==2.57.0", # Other ingest dependencies - "langchain-openai>=0.2", + "langchain-openai>=0.2,<1.1.9", + "openai>=1.0,<2.0", "overrides>=7.7,<8.0", "tqdm>=4.67,<5.0", "opentelemetry-api>=1.29,<2.0", @@ -80,11 +84,12 @@ ingest = [ ] all = [ # nv-ingest dependencies (required for ingestion operations) - "nv-ingest-api==26.1.2", - "nv-ingest-client==26.1.2", + "nv-ingest-api==26.3.0", + "nv-ingest-client==26.3.0", "tritonclient==2.57.0", # RAG + Ingest dependencies - "langchain-openai>=0.2", + "langchain-openai>=0.2,<1.1.9", + "openai>=1.0,<2.0", "overrides>=7.7,<8.0", "tqdm>=4.67,<5.0", "opentelemetry-api>=1.29,<2.0", @@ -109,11 +114,21 @@ elasticsearch = [ nvidia-rag = { workspace = true } [tool.uv] -# Pillow >=12.2.0 fixes GHSA-whj4-6x5x-4v2j (CVE-2026-40192 FITS GZIP decomp bomb); moviepy pins pillow<12 so override needed -# orjson >=3.11.6 fixes GHSA-hx9q-6w63-j58v (CVE-2025-67221 deep-recursion DoS) — transitive via langchain stack +# Pillow 12.x required for containers; moviepy pins pillow<12 so override needed for resolution override-dependencies = [ "pillow>=12.2.0", + "cryptography>=46.0.6", + "urllib3>=2.7.0", + "aiohttp>=3.13.4", "orjson>=3.11.6", + "langsmith>=0.8.0", + "langchain-classic>=1.0.7", + "langchain-text-splitters>=1.1.2", + "transformers>=5.1.0", + "idna>=3.15", + "pygments>=2.20.0", + "python-dotenv>=1.2.2", + "requests>=2.33.0", ] [tool.setuptools] diff --git a/scripts/README.md b/scripts/README.md index 006da81fd..99a7be56a 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -104,13 +104,13 @@ Pass a collection name with your query (overrides the default `multimodal_data`) ```bash # Generate python scripts/retriever_api_usage.py \ - --payload-json '{"collection_names":["my_collection"]}' \ + --collection-name my_collection \ "What is lion doing?" # Search python scripts/retriever_api_usage.py \ --mode search \ - --payload-json '{"collection_names":["my_collection"]}' \ + --collection-name my_collection \ "Tell me about Robert Frost's poems" ``` diff --git a/scripts/eval/README.md b/scripts/eval/README.md new file mode 100644 index 000000000..eba49b6e5 --- /dev/null +++ b/scripts/eval/README.md @@ -0,0 +1,178 @@ +# RAG evaluation scripts + +Scripts in this folder benchmark a deployed RAG stack. They load a local dataset into the ingestor, send each question to the RAG server’s generate API, and evaluate the responses using RAGAS with [NVIDIA metrics](https://docs.ragas.io/en/stable/concepts/metrics/available_metrics/nvidia_metrics/). + +## Prerequisites + +- The RAG server and ingestor server must be accessible on the network (for example, after completing the [Quickstart: self-hosted Docker](../../docs/deploy-docker-self-hosted.md)). +- Set `NVIDIA_API_KEY` in the environment; it is required for `langchain_nvidia_ai_endpoints` to run the RAGAS judge. +- `RAG_EVAL_JUDGE_MODEL` (optional) — LLM id passed to `ChatNVIDIA` for RAGAS scoring; defaults to `mistralai/mixtral-8x22b-instruct-v0.1` when unset or empty. + +## Install (this repository) + +From the repository root, sync and run with uv’s `--project` flag pointing at this folder: + +```bash +uv sync --project scripts/eval +uv run --project scripts/eval python scripts/eval/evaluate_rag.py --help +``` + +Or work inside `scripts/eval` (creates `.venv` next to `pyproject.toml`): + +```bash +cd scripts/eval +uv sync +uv run python evaluate_rag.py --help +``` + +Use the same `--project scripts/eval` / `cd scripts/eval` pattern for every command below when invoking `evaluate_rag.py`. + +## Accepted dataset format + +Use `evaluate_rag.py` as the driver. Invoke it with one or more dataset root directories via `--dataset-paths`. It does not support dataset names, only filesystem paths. + +`evaluate_rag.py` validates each dataset root with `validate_dataset_roots():` + +- The path must be a directory. +- A `corpus\` directory must exist (documents to ingest are discovered recursively under it). +- A `train.json` file must exist, be a regular file, and contain UTF-8 JSON (see shapes below). + +If ingestion is not skipped, every file under corpus/ that is not already marked as ingested for the target collection will be uploaded. Use the same collection name that the eval run will query (by default, the dataset directory’s basename, unless you pass `--collection`). + +### Converting external benchmarks into this layout + +When importing a dataset from elsewhere (hosted catalogs, JSONL, CSV, APIs, etc.), materialize `corpus/` as PDF whenever possible—export or print sources to PDF so the bundle matches common document RAG and the default `--file-type pdf` (including PDF page metrics during ingest). + +### Directory layout (summary) + +```text +my_dataset/ ← dataset root (pass this to --dataset-paths) + corpus/ ← required; source documents (nested dirs allowed) + doc_a.pdf + notes.pdf + train.json ← required; eval questions and answers +``` + +### `train.json` + +Use a JSON array of objects. The script reads these fields from each object: + +| Field | Required | Used by evaluator | +|--------------------|---------------------------|-------------------------------------------------------------------------| +| `question` | Yes | Sent to the RAG server as the user message for each item. | +| `answer` | Yes (for meaningful scores) | Used as the reference for RAGAS judge metrics (`evaluate_result`). | +| `id` or `query_id` | No | If present, stored on the saved eval row for traceability. | +| `contexts` | No | Optional; format below. | +| Other keys (for example, `is_impossible`) | No | Ignored by the current driver unless you extend it. | + +Context relevance and response groundedness compare the model answer to the contexts retrieved from the RAG server. E2E accuracy uses `question`, `answer`, and the model’s answer. + +Each item in `contexts` should include a `filename` and a `text` field: + +- `filename` — the same file name the document has under `corpus/` (its basename, not a subdirectory path), matching the file on disk exactly. +- `text` — the reference span (page, snippet, or chunk text) used for answering the query. + +```json +"contexts": [ + { + "filename": "COMPANY_2020_10K", + "text": "…" + } +] +``` + +Multiple objects are allowed when several files apply. A legacy shape of plain strings (`["…", "…"]`) is also valid for simple bundles where per-file tagging is not needed. + +Minimal example: + +```json +[ + { + "id": "q1", + "question": "What is the corporate tax rate in the United States?", + "answer": "21%" + } +] +``` + +Corpus files should be the documents you want indexed; prefer PDF when building the bundle from external data, especially when upstream references do not pin down a concrete on-disk name. Naming must stay consistent with how your ingestor stores `document_name` (citation parsing matches streamed citation results using basenames). + +### Ingestion: `--file-type` + +The default value is `pdf`. If the converted corpus is mostly PDFs (recommended when preparing benchmarks, including when you materialize sources from links that do not spell out a concrete file name), leave defaults or pass `--file-type pdf`; the substring `pdf` enables PDF page counts in ingestion metrics. For non-PDF corpora, use values such as `txt` or `txt,html` so they match what is under `corpus/`. + +### Checklist + +- The dataset path is a directory containing `corpus/` and `train.json`. +- `train.json` is valid JSON: array of objects (top-level dict / multi-turn bundles are rejected). +- Every turn / row has `question` and `answer` where you need judge scores. +- `corpus/` holds the files you intend to retrieve against. +- `NVIDIA_API_KEY` is set for cloud judge models. + + +## What gets measured + +- Ingestion: time, file counts, and (for PDFs) pages per second — appended to `rag_