diff --git a/docs/_static/llama-stack-spec.html b/docs/_static/llama-stack-spec.html index affc426d6..801e8dc33 100644 --- a/docs/_static/llama-stack-spec.html +++ b/docs/_static/llama-stack-spec.html @@ -11190,6 +11190,115 @@ ], "title": "InsertRequest" }, + "Chunk": { + "type": "object", + "properties": { + "content": { + "$ref": "#/components/schemas/InterleavedContent", + "description": "The content of the chunk, which can be interleaved text, images, or other types." + }, + "metadata": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + }, + { + "type": "array" + }, + { + "type": "object" + } + ] + }, + "description": "Metadata associated with the chunk that will be used in the model context during inference." + }, + "embedding": { + "type": "array", + "items": { + "type": "number" + }, + "description": "Optional embedding for the chunk. If not provided, it will be computed later." + }, + "stored_chunk_id": { + "type": "string", + "description": "The chunk ID that is stored in the vector database. Used for backend functionality." + }, + "chunk_metadata": { + "$ref": "#/components/schemas/ChunkMetadata", + "description": "Metadata for the chunk that will NOT be used in the context during inference. The `chunk_metadata` is required backend functionality." + } + }, + "additionalProperties": false, + "required": [ + "content", + "metadata" + ], + "title": "Chunk", + "description": "A chunk of content that can be inserted into a vector database." + }, + "ChunkMetadata": { + "type": "object", + "properties": { + "chunk_id": { + "type": "string", + "description": "The ID of the chunk. If not set, it will be generated based on the document ID and content." + }, + "document_id": { + "type": "string", + "description": "The ID of the document this chunk belongs to." + }, + "source": { + "type": "string", + "description": "The source of the content, such as a URL, file path, or other identifier." + }, + "created_timestamp": { + "type": "integer", + "description": "An optional timestamp indicating when the chunk was created." + }, + "updated_timestamp": { + "type": "integer", + "description": "An optional timestamp indicating when the chunk was last updated." + }, + "chunk_window": { + "type": "string", + "description": "The window of the chunk, which can be used to group related chunks together." + }, + "chunk_tokenizer": { + "type": "string", + "description": "The tokenizer used to create the chunk. Default is Tiktoken." + }, + "chunk_embedding_model": { + "type": "string", + "description": "The embedding model used to create the chunk's embedding." + }, + "chunk_embedding_dimension": { + "type": "integer", + "description": "The dimension of the embedding vector for the chunk." + }, + "content_token_count": { + "type": "integer", + "description": "The number of tokens in the content of the chunk." + }, + "metadata_token_count": { + "type": "integer", + "description": "The number of tokens in the metadata of the chunk." + } + }, + "additionalProperties": false, + "title": "ChunkMetadata", + "description": "`ChunkMetadata` is backend metadata for a `Chunk` that is used to store additional information about the chunk that will not be used in the context during inference, but is required for backend functionality. The `ChunkMetadata` is set during chunk creation in `MemoryToolRuntimeImpl().insert()`and is not expected to change after. Use `Chunk.metadata` for metadata that will be used in the context during inference." + }, "InsertChunksRequest": { "type": "object", "properties": { @@ -11200,53 +11309,7 @@ "chunks": { "type": "array", "items": { - "type": "object", - "properties": { - "content": { - "$ref": "#/components/schemas/InterleavedContent", - "description": "The content of the chunk, which can be interleaved text, images, or other types." - }, - "metadata": { - "type": "object", - "additionalProperties": { - "oneOf": [ - { - "type": "null" - }, - { - "type": "boolean" - }, - { - "type": "number" - }, - { - "type": "string" - }, - { - "type": "array" - }, - { - "type": "object" - } - ] - }, - "description": "Metadata associated with the chunk, such as document ID, source, or other relevant information." - }, - "embedding": { - "type": "array", - "items": { - "type": "number" - }, - "description": "Optional embedding for the chunk. If not provided, it will be computed later." - } - }, - "additionalProperties": false, - "required": [ - "content", - "metadata" - ], - "title": "Chunk", - "description": "A chunk of content that can be inserted into a vector database." + "$ref": "#/components/schemas/Chunk" }, "description": "The chunks to insert. Each `Chunk` should contain content which can be interleaved text, images, or other types. `metadata`: `dict[str, Any]` and `embedding`: `List[float]` are optional. If `metadata` is provided, you configure how Llama Stack formats the chunk during generation. If `embedding` is not provided, it will be computed later." }, @@ -14671,53 +14734,7 @@ "chunks": { "type": "array", "items": { - "type": "object", - "properties": { - "content": { - "$ref": "#/components/schemas/InterleavedContent", - "description": "The content of the chunk, which can be interleaved text, images, or other types." - }, - "metadata": { - "type": "object", - "additionalProperties": { - "oneOf": [ - { - "type": "null" - }, - { - "type": "boolean" - }, - { - "type": "number" - }, - { - "type": "string" - }, - { - "type": "array" - }, - { - "type": "object" - } - ] - }, - "description": "Metadata associated with the chunk, such as document ID, source, or other relevant information." - }, - "embedding": { - "type": "array", - "items": { - "type": "number" - }, - "description": "Optional embedding for the chunk. If not provided, it will be computed later." - } - }, - "additionalProperties": false, - "required": [ - "content", - "metadata" - ], - "title": "Chunk", - "description": "A chunk of content that can be inserted into a vector database." + "$ref": "#/components/schemas/Chunk" } }, "scores": { diff --git a/docs/_static/llama-stack-spec.yaml b/docs/_static/llama-stack-spec.yaml index 1e1293dc2..b736cd904 100644 --- a/docs/_static/llama-stack-spec.yaml +++ b/docs/_static/llama-stack-spec.yaml @@ -7867,6 +7867,107 @@ components: - vector_db_id - chunk_size_in_tokens title: InsertRequest + Chunk: + type: object + properties: + content: + $ref: '#/components/schemas/InterleavedContent' + description: >- + The content of the chunk, which can be interleaved text, images, or other + types. + metadata: + type: object + additionalProperties: + oneOf: + - type: 'null' + - type: boolean + - type: number + - type: string + - type: array + - type: object + description: >- + Metadata associated with the chunk that will be used in the model context + during inference. + embedding: + type: array + items: + type: number + description: >- + Optional embedding for the chunk. If not provided, it will be computed + later. + stored_chunk_id: + type: string + description: >- + The chunk ID that is stored in the vector database. Used for backend functionality. + chunk_metadata: + $ref: '#/components/schemas/ChunkMetadata' + description: >- + Metadata for the chunk that will NOT be used in the context during inference. + The `chunk_metadata` is required backend functionality. + additionalProperties: false + required: + - content + - metadata + title: Chunk + description: >- + A chunk of content that can be inserted into a vector database. + ChunkMetadata: + type: object + properties: + chunk_id: + type: string + description: >- + The ID of the chunk. If not set, it will be generated based on the document + ID and content. + document_id: + type: string + description: >- + The ID of the document this chunk belongs to. + source: + type: string + description: >- + The source of the content, such as a URL, file path, or other identifier. + created_timestamp: + type: integer + description: >- + An optional timestamp indicating when the chunk was created. + updated_timestamp: + type: integer + description: >- + An optional timestamp indicating when the chunk was last updated. + chunk_window: + type: string + description: >- + The window of the chunk, which can be used to group related chunks together. + chunk_tokenizer: + type: string + description: >- + The tokenizer used to create the chunk. Default is Tiktoken. + chunk_embedding_model: + type: string + description: >- + The embedding model used to create the chunk's embedding. + chunk_embedding_dimension: + type: integer + description: >- + The dimension of the embedding vector for the chunk. + content_token_count: + type: integer + description: >- + The number of tokens in the content of the chunk. + metadata_token_count: + type: integer + description: >- + The number of tokens in the metadata of the chunk. + additionalProperties: false + title: ChunkMetadata + description: >- + `ChunkMetadata` is backend metadata for a `Chunk` that is used to store additional + information about the chunk that will not be used in the context during + inference, but is required for backend functionality. The `ChunkMetadata` is + set during chunk creation in `MemoryToolRuntimeImpl().insert()`and is not + expected to change after. Use `Chunk.metadata` for metadata that will + be used in the context during inference. InsertChunksRequest: type: object properties: @@ -7877,40 +7978,7 @@ components: chunks: type: array items: - type: object - properties: - content: - $ref: '#/components/schemas/InterleavedContent' - description: >- - The content of the chunk, which can be interleaved text, images, - or other types. - metadata: - type: object - additionalProperties: - oneOf: - - type: 'null' - - type: boolean - - type: number - - type: string - - type: array - - type: object - description: >- - Metadata associated with the chunk, such as document ID, source, - or other relevant information. - embedding: - type: array - items: - type: number - description: >- - Optional embedding for the chunk. If not provided, it will be computed - later. - additionalProperties: false - required: - - content - - metadata - title: Chunk - description: >- - A chunk of content that can be inserted into a vector database. + $ref: '#/components/schemas/Chunk' description: >- The chunks to insert. Each `Chunk` should contain content which can be interleaved text, images, or other types. `metadata`: `dict[str, Any]` @@ -10231,40 +10299,7 @@ components: chunks: type: array items: - type: object - properties: - content: - $ref: '#/components/schemas/InterleavedContent' - description: >- - The content of the chunk, which can be interleaved text, images, - or other types. - metadata: - type: object - additionalProperties: - oneOf: - - type: 'null' - - type: boolean - - type: number - - type: string - - type: array - - type: object - description: >- - Metadata associated with the chunk, such as document ID, source, - or other relevant information. - embedding: - type: array - items: - type: number - description: >- - Optional embedding for the chunk. If not provided, it will be computed - later. - additionalProperties: false - required: - - content - - metadata - title: Chunk - description: >- - A chunk of content that can be inserted into a vector database. + $ref: '#/components/schemas/Chunk' scores: type: array items: diff --git a/llama_stack/apis/vector_io/vector_io.py b/llama_stack/apis/vector_io/vector_io.py index d6de0108c..2d4131315 100644 --- a/llama_stack/apis/vector_io/vector_io.py +++ b/llama_stack/apis/vector_io/vector_io.py @@ -8,6 +8,7 @@ # # This source code is licensed under the terms described in the LICENSE file in # the root directory of this source tree. +import uuid from typing import Annotated, Any, Literal, Protocol, runtime_checkable from pydantic import BaseModel, Field @@ -15,21 +16,80 @@ from pydantic import BaseModel, Field from llama_stack.apis.inference import InterleavedContent from llama_stack.apis.vector_dbs import VectorDB from llama_stack.providers.utils.telemetry.trace_protocol import trace_protocol +from llama_stack.providers.utils.vector_io.chunk_utils import generate_chunk_id from llama_stack.schema_utils import json_schema_type, webmethod from llama_stack.strong_typing.schema import register_schema +@json_schema_type +class ChunkMetadata(BaseModel): + """ + `ChunkMetadata` is backend metadata for a `Chunk` that is used to store additional information about the chunk that + will not be used in the context during inference, but is required for backend functionality. The `ChunkMetadata` + is set during chunk creation in `MemoryToolRuntimeImpl().insert()`and is not expected to change after. + Use `Chunk.metadata` for metadata that will be used in the context during inference. + :param chunk_id: The ID of the chunk. If not set, it will be generated based on the document ID and content. + :param document_id: The ID of the document this chunk belongs to. + :param source: The source of the content, such as a URL, file path, or other identifier. + :param created_timestamp: An optional timestamp indicating when the chunk was created. + :param updated_timestamp: An optional timestamp indicating when the chunk was last updated. + :param chunk_window: The window of the chunk, which can be used to group related chunks together. + :param chunk_tokenizer: The tokenizer used to create the chunk. Default is Tiktoken. + :param chunk_embedding_model: The embedding model used to create the chunk's embedding. + :param chunk_embedding_dimension: The dimension of the embedding vector for the chunk. + :param content_token_count: The number of tokens in the content of the chunk. + :param metadata_token_count: The number of tokens in the metadata of the chunk. + """ + + chunk_id: str | None = None + document_id: str | None = None + source: str | None = None + created_timestamp: int | None = None + updated_timestamp: int | None = None + chunk_window: str | None = None + chunk_tokenizer: str | None = None + chunk_embedding_model: str | None = None + chunk_embedding_dimension: int | None = None + content_token_count: int | None = None + metadata_token_count: int | None = None + + +@json_schema_type class Chunk(BaseModel): """ A chunk of content that can be inserted into a vector database. :param content: The content of the chunk, which can be interleaved text, images, or other types. :param embedding: Optional embedding for the chunk. If not provided, it will be computed later. - :param metadata: Metadata associated with the chunk, such as document ID, source, or other relevant information. + :param metadata: Metadata associated with the chunk that will be used in the model context during inference. + :param stored_chunk_id: The chunk ID that is stored in the vector database. Used for backend functionality. + :param chunk_metadata: Metadata for the chunk that will NOT be used in the context during inference. + The `chunk_metadata` is required backend functionality. """ content: InterleavedContent metadata: dict[str, Any] = Field(default_factory=dict) embedding: list[float] | None = None + # The alias parameter serializes the field as "chunk_id" in JSON but keeps the internal name as "stored_chunk_id" + stored_chunk_id: str | None = Field(default=None, alias="chunk_id") + chunk_metadata: ChunkMetadata | None = None + + model_config = {"populate_by_name": True} + + def model_post_init(self, __context): + # Extract chunk_id from metadata if present + if self.metadata and "chunk_id" in self.metadata: + self.stored_chunk_id = self.metadata.pop("chunk_id") + + @property + def chunk_id(self) -> str: + """Returns the chunk ID, which is either an input `chunk_id` or a generated one if not set.""" + if self.stored_chunk_id: + return self.stored_chunk_id + + if "document_id" in self.metadata: + return generate_chunk_id(self.metadata["document_id"], str(self.content)) + + return generate_chunk_id(str(uuid.uuid4()), str(self.content)) @json_schema_type diff --git a/llama_stack/providers/inline/tool_runtime/rag/memory.py b/llama_stack/providers/inline/tool_runtime/rag/memory.py index 7f4fe5dbd..6a7c7885c 100644 --- a/llama_stack/providers/inline/tool_runtime/rag/memory.py +++ b/llama_stack/providers/inline/tool_runtime/rag/memory.py @@ -81,6 +81,7 @@ class MemoryToolRuntimeImpl(ToolGroupsProtocolPrivate, ToolRuntime, RAGToolRunti chunks = [] for doc in documents: content = await content_from_doc(doc) + # TODO: we should add enrichment here as URLs won't be added to the metadata by default chunks.extend( make_overlapped_chunks( doc.document_id, @@ -157,8 +158,24 @@ class MemoryToolRuntimeImpl(ToolGroupsProtocolPrivate, ToolRuntime, RAGToolRunti ) break - metadata_subset = {k: v for k, v in metadata.items() if k not in ["token_count", "metadata_token_count"]} - text_content = query_config.chunk_template.format(index=i + 1, chunk=chunk, metadata=metadata_subset) + # Add useful keys from chunk_metadata to metadata and remove some from metadata + chunk_metadata_keys_to_include_from_context = [ + "chunk_id", + "document_id", + "source", + ] + metadata_keys_to_exclude_from_context = [ + "token_count", + "metadata_token_count", + ] + metadata_for_context = {} + for k in chunk_metadata_keys_to_include_from_context: + metadata_for_context[k] = getattr(chunk.chunk_metadata, k) + for k in metadata: + if k not in metadata_keys_to_exclude_from_context: + metadata_for_context[k] = metadata[k] + + text_content = query_config.chunk_template.format(index=i + 1, chunk=chunk, metadata=metadata_for_context) picked.append(TextContentItem(text=text_content)) picked.append(TextContentItem(text="END of knowledge_search tool results.\n")) diff --git a/llama_stack/providers/inline/vector_io/sqlite_vec/sqlite_vec.py b/llama_stack/providers/inline/vector_io/sqlite_vec/sqlite_vec.py index d832e56f5..3b3c5f486 100644 --- a/llama_stack/providers/inline/vector_io/sqlite_vec/sqlite_vec.py +++ b/llama_stack/providers/inline/vector_io/sqlite_vec/sqlite_vec.py @@ -5,12 +5,10 @@ # the root directory of this source tree. import asyncio -import hashlib import json import logging import sqlite3 import struct -import uuid from typing import Any import numpy as np @@ -201,10 +199,7 @@ class SQLiteVecIndex(EmbeddingIndex): batch_embeddings = embeddings[i : i + batch_size] # Insert metadata - metadata_data = [ - (generate_chunk_id(chunk.metadata["document_id"], chunk.content), chunk.model_dump_json()) - for chunk in batch_chunks - ] + metadata_data = [(chunk.chunk_id, chunk.model_dump_json()) for chunk in batch_chunks] cur.executemany( f""" INSERT INTO {self.metadata_table} (id, chunk) @@ -218,7 +213,7 @@ class SQLiteVecIndex(EmbeddingIndex): embedding_data = [ ( ( - generate_chunk_id(chunk.metadata["document_id"], chunk.content), + chunk.chunk_id, serialize_vector(emb.tolist()), ) ) @@ -230,10 +225,7 @@ class SQLiteVecIndex(EmbeddingIndex): ) # Insert FTS content - fts_data = [ - (generate_chunk_id(chunk.metadata["document_id"], chunk.content), chunk.content) - for chunk in batch_chunks - ] + fts_data = [(chunk.chunk_id, chunk.content) for chunk in batch_chunks] # DELETE existing entries with same IDs (FTS5 doesn't support ON CONFLICT) cur.executemany( f"DELETE FROM {self.fts_table} WHERE id = ?;", @@ -381,13 +373,12 @@ class SQLiteVecIndex(EmbeddingIndex): vector_response = await self.query_vector(embedding, k, score_threshold) keyword_response = await self.query_keyword(query_string, k, score_threshold) - # Convert responses to score dictionaries using generate_chunk_id + # Convert responses to score dictionaries using chunk_id vector_scores = { - generate_chunk_id(chunk.metadata["document_id"], str(chunk.content)): score - for chunk, score in zip(vector_response.chunks, vector_response.scores, strict=False) + chunk.chunk_id: score for chunk, score in zip(vector_response.chunks, vector_response.scores, strict=False) } keyword_scores = { - generate_chunk_id(chunk.metadata["document_id"], str(chunk.content)): score + chunk.chunk_id: score for chunk, score in zip(keyword_response.chunks, keyword_response.scores, strict=False) } @@ -408,13 +399,7 @@ class SQLiteVecIndex(EmbeddingIndex): filtered_items = [(doc_id, score) for doc_id, score in top_k_items if score >= score_threshold] # Create a map of chunk_id to chunk for both responses - chunk_map = {} - for c in vector_response.chunks: - chunk_id = generate_chunk_id(c.metadata["document_id"], str(c.content)) - chunk_map[chunk_id] = c - for c in keyword_response.chunks: - chunk_id = generate_chunk_id(c.metadata["document_id"], str(c.content)) - chunk_map[chunk_id] = c + chunk_map = {c.chunk_id: c for c in vector_response.chunks + keyword_response.chunks} # Use the map to look up chunks by their IDs chunks = [] @@ -757,9 +742,3 @@ class SQLiteVecVectorIOAdapter(OpenAIVectorStoreMixin, VectorIO, VectorDBsProtoc if vector_db_id not in self.cache: raise ValueError(f"Vector DB {vector_db_id} not found") return await self.cache[vector_db_id].query_chunks(query, params) - - -def generate_chunk_id(document_id: str, chunk_text: str) -> str: - """Generate a unique chunk ID using a hash of document ID and chunk text.""" - hash_input = f"{document_id}:{chunk_text}".encode() - return str(uuid.UUID(hashlib.md5(hash_input).hexdigest())) diff --git a/llama_stack/providers/remote/vector_io/qdrant/qdrant.py b/llama_stack/providers/remote/vector_io/qdrant/qdrant.py index e9d6eec22..09ea08fa0 100644 --- a/llama_stack/providers/remote/vector_io/qdrant/qdrant.py +++ b/llama_stack/providers/remote/vector_io/qdrant/qdrant.py @@ -70,8 +70,8 @@ class QdrantIndex(EmbeddingIndex): ) points = [] - for i, (chunk, embedding) in enumerate(zip(chunks, embeddings, strict=False)): - chunk_id = f"{chunk.metadata['document_id']}:chunk-{i}" + for _i, (chunk, embedding) in enumerate(zip(chunks, embeddings, strict=False)): + chunk_id = chunk.chunk_id points.append( PointStruct( id=convert_id(chunk_id), diff --git a/llama_stack/providers/utils/memory/vector_store.py b/llama_stack/providers/utils/memory/vector_store.py index a6e420feb..ab204a75a 100644 --- a/llama_stack/providers/utils/memory/vector_store.py +++ b/llama_stack/providers/utils/memory/vector_store.py @@ -7,6 +7,7 @@ import base64 import io import logging import re +import time from abc import ABC, abstractmethod from dataclasses import dataclass from typing import Any @@ -23,12 +24,13 @@ from llama_stack.apis.common.content_types import ( ) from llama_stack.apis.tools import RAGDocument from llama_stack.apis.vector_dbs import VectorDB -from llama_stack.apis.vector_io import Chunk, QueryChunksResponse +from llama_stack.apis.vector_io import Chunk, ChunkMetadata, QueryChunksResponse from llama_stack.models.llama.llama3.tokenizer import Tokenizer from llama_stack.providers.datatypes import Api from llama_stack.providers.utils.inference.prompt_adapter import ( interleaved_content_as_str, ) +from llama_stack.providers.utils.vector_io.chunk_utils import generate_chunk_id log = logging.getLogger(__name__) @@ -148,6 +150,7 @@ async def content_from_doc(doc: RAGDocument) -> str: def make_overlapped_chunks( document_id: str, text: str, window_len: int, overlap_len: int, metadata: dict[str, Any] ) -> list[Chunk]: + default_tokenizer = "DEFAULT_TIKTOKEN_TOKENIZER" tokenizer = Tokenizer.get_instance() tokens = tokenizer.encode(text, bos=False, eos=False) try: @@ -161,16 +164,32 @@ def make_overlapped_chunks( for i in range(0, len(tokens), window_len - overlap_len): toks = tokens[i : i + window_len] chunk = tokenizer.decode(toks) + chunk_id = generate_chunk_id(chunk, text) chunk_metadata = metadata.copy() + chunk_metadata["chunk_id"] = chunk_id chunk_metadata["document_id"] = document_id chunk_metadata["token_count"] = len(toks) chunk_metadata["metadata_token_count"] = len(metadata_tokens) + backend_chunk_metadata = ChunkMetadata( + chunk_id=chunk_id, + document_id=document_id, + source=metadata.get("source", None), + created_timestamp=metadata.get("created_timestamp", int(time.time())), + updated_timestamp=int(time.time()), + chunk_window=f"{i}-{i + len(toks)}", + chunk_tokenizer=default_tokenizer, + chunk_embedding_model=None, # This will be set in `VectorDBWithIndex.insert_chunks` + content_token_count=len(toks), + metadata_token_count=len(metadata_tokens), + ) + # chunk is a string chunks.append( Chunk( content=chunk, metadata=chunk_metadata, + chunk_metadata=backend_chunk_metadata, ) ) @@ -237,6 +256,9 @@ class VectorDBWithIndex: for i, c in enumerate(chunks): if c.embedding is None: chunks_to_embed.append(c) + if c.chunk_metadata: + c.chunk_metadata.chunk_embedding_model = self.vector_db.embedding_model + c.chunk_metadata.chunk_embedding_dimension = self.vector_db.embedding_dimension else: _validate_embedding(c.embedding, i, self.vector_db.embedding_dimension) diff --git a/llama_stack/providers/utils/vector_io/__init__.py b/llama_stack/providers/utils/vector_io/__init__.py new file mode 100644 index 000000000..756f351d8 --- /dev/null +++ b/llama_stack/providers/utils/vector_io/__init__.py @@ -0,0 +1,5 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the terms described in the LICENSE file in +# the root directory of this source tree. diff --git a/llama_stack/providers/utils/vector_io/chunk_utils.py b/llama_stack/providers/utils/vector_io/chunk_utils.py new file mode 100644 index 000000000..68cf11cad --- /dev/null +++ b/llama_stack/providers/utils/vector_io/chunk_utils.py @@ -0,0 +1,14 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the terms described in the LICENSE file in +# the root directory of this source tree. + +import hashlib +import uuid + + +def generate_chunk_id(document_id: str, chunk_text: str) -> str: + """Generate a unique chunk ID using a hash of document ID and chunk text.""" + hash_input = f"{document_id}:{chunk_text}".encode() + return str(uuid.UUID(hashlib.md5(hash_input).hexdigest())) diff --git a/tests/unit/providers/vector_io/conftest.py b/tests/unit/providers/vector_io/conftest.py index 3bcd0613f..5eaca8a25 100644 --- a/tests/unit/providers/vector_io/conftest.py +++ b/tests/unit/providers/vector_io/conftest.py @@ -9,7 +9,7 @@ import random import numpy as np import pytest -from llama_stack.apis.vector_io import Chunk +from llama_stack.apis.vector_io import Chunk, ChunkMetadata EMBEDDING_DIMENSION = 384 @@ -33,6 +33,20 @@ def sample_chunks(): for j in range(k) for i in range(n) ] + sample.extend( + [ + Chunk( + content=f"Sentence {i} from document {j + k}", + chunk_metadata=ChunkMetadata( + document_id=f"document-{j + k}", + chunk_id=f"document-{j}-chunk-{i}", + source=f"example source-{j + k}-{i}", + ), + ) + for j in range(k) + for i in range(n) + ] + ) return sample diff --git a/tests/unit/providers/vector_io/test_chunk_utils.py b/tests/unit/providers/vector_io/test_chunk_utils.py new file mode 100644 index 000000000..941928b6d --- /dev/null +++ b/tests/unit/providers/vector_io/test_chunk_utils.py @@ -0,0 +1,66 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the terms described in the LICENSE file in +# the root directory of this source tree. + +from llama_stack.apis.vector_io import Chunk, ChunkMetadata +from llama_stack.providers.utils.vector_io.chunk_utils import generate_chunk_id + +# This test is a unit test for the chunk_utils.py helpers. This should only contain +# tests which are specific to this file. More general (API-level) tests should be placed in +# tests/integration/vector_io/ +# +# How to run this test: +# +# pytest tests/unit/providers/vector_io/test_chunk_utils.py \ +# -v -s --tb=short --disable-warnings --asyncio-mode=auto + + +def test_generate_chunk_id(): + chunks = [ + Chunk(content="test", metadata={"document_id": "doc-1"}), + Chunk(content="test ", metadata={"document_id": "doc-1"}), + Chunk(content="test 3", metadata={"document_id": "doc-1"}), + ] + + chunk_ids = sorted([chunk.chunk_id for chunk in chunks]) + assert chunk_ids == [ + "177a1368-f6a8-0c50-6e92-18677f2c3de3", + "bc744db3-1b25-0a9c-cdff-b6ba3df73c36", + "f68df25d-d9aa-ab4d-5684-64a233add20d", + ] + + +def test_chunk_id(): + # Test with existing chunk ID + chunk_with_id = Chunk(content="test", metadata={"document_id": "existing-id"}) + assert chunk_with_id.chunk_id == "84ededcc-b80b-a83e-1a20-ca6515a11350" + + # Test with document ID in metadata + chunk_with_doc_id = Chunk(content="test", metadata={"document_id": "doc-1"}) + assert chunk_with_doc_id.chunk_id == generate_chunk_id("doc-1", "test") + + # Test chunks with ChunkMetadata + chunk_with_metadata = Chunk( + content="test", + metadata={"document_id": "existing-id", "chunk_id": "chunk-id-1"}, + chunk_metadata=ChunkMetadata(document_id="document_1"), + ) + assert chunk_with_metadata.chunk_id == "chunk-id-1" + + # Test with no ID or document ID + chunk_without_id = Chunk(content="test") + generated_id = chunk_without_id.chunk_id + assert isinstance(generated_id, str) and len(generated_id) == 36 # Should be a valid UUID + + +def test_stored_chunk_id_alias(): + # Test with existing chunk ID alias + chunk_with_alias = Chunk(content="test", metadata={"document_id": "existing-id", "chunk_id": "chunk-id-1"}) + assert chunk_with_alias.chunk_id == "chunk-id-1" + serialized_chunk = chunk_with_alias.model_dump() + assert serialized_chunk["stored_chunk_id"] == "chunk-id-1" + # showing chunk_id is not serialized (i.e., a computed field) + assert "chunk_id" not in serialized_chunk + assert chunk_with_alias.stored_chunk_id == "chunk-id-1" diff --git a/tests/unit/providers/vector_io/test_qdrant.py b/tests/unit/providers/vector_io/test_qdrant.py index 607eccb24..6902c8850 100644 --- a/tests/unit/providers/vector_io/test_qdrant.py +++ b/tests/unit/providers/vector_io/test_qdrant.py @@ -81,7 +81,7 @@ __QUERY = "Sample query" @pytest.mark.asyncio -@pytest.mark.parametrize("max_query_chunks, expected_chunks", [(2, 2), (100, 30)]) +@pytest.mark.parametrize("max_query_chunks, expected_chunks", [(2, 2), (100, 60)]) async def test_qdrant_adapter_returns_expected_chunks( qdrant_adapter: QdrantVectorIOAdapter, vector_db_id, diff --git a/tests/unit/providers/vector_io/test_sqlite_vec.py b/tests/unit/providers/vector_io/test_sqlite_vec.py index 6424b9e86..bbac717c7 100644 --- a/tests/unit/providers/vector_io/test_sqlite_vec.py +++ b/tests/unit/providers/vector_io/test_sqlite_vec.py @@ -15,7 +15,6 @@ from llama_stack.providers.inline.vector_io.sqlite_vec.sqlite_vec import ( SQLiteVecIndex, SQLiteVecVectorIOAdapter, _create_sqlite_connection, - generate_chunk_id, ) # This test is a unit test for the SQLiteVecVectorIOAdapter class. This should only contain @@ -65,6 +64,14 @@ async def test_query_chunks_vector(sqlite_vec_index, sample_chunks, sample_embed assert len(response.chunks) == 2 +@pytest.mark.xfail(reason="Chunk Metadata not yet supported for SQLite-vec", strict=True) +async def test_query_chunk_metadata(sqlite_vec_index, sample_chunks, sample_embeddings): + await sqlite_vec_index.add_chunks(sample_chunks, sample_embeddings) + query_embedding = sample_embeddings[0] + response = await sqlite_vec_index.query_vector(query_embedding, k=2, score_threshold=0.0) + assert response.chunks[-1].chunk_metadata == sample_chunks[-1].chunk_metadata + + @pytest.mark.asyncio async def test_query_chunks_full_text_search(sqlite_vec_index, sample_chunks, sample_embeddings): await sqlite_vec_index.add_chunks(sample_chunks, sample_embeddings) @@ -150,21 +157,6 @@ async def sqlite_vec_adapter(sqlite_connection): await adapter.shutdown() -def test_generate_chunk_id(): - chunks = [ - Chunk(content="test", metadata={"document_id": "doc-1"}), - Chunk(content="test ", metadata={"document_id": "doc-1"}), - Chunk(content="test 3", metadata={"document_id": "doc-1"}), - ] - - chunk_ids = sorted([generate_chunk_id(chunk.metadata["document_id"], chunk.content) for chunk in chunks]) - assert chunk_ids == [ - "177a1368-f6a8-0c50-6e92-18677f2c3de3", - "bc744db3-1b25-0a9c-cdff-b6ba3df73c36", - "f68df25d-d9aa-ab4d-5684-64a233add20d", - ] - - @pytest.mark.asyncio async def test_query_chunks_hybrid_no_keyword_matches(sqlite_vec_index, sample_chunks, sample_embeddings): """Test hybrid search when keyword search returns no matches - should still return vector results.""" @@ -339,7 +331,7 @@ async def test_query_chunks_hybrid_mixed_results(sqlite_vec_index, sample_chunks # Verify scores are in descending order assert all(response.scores[i] >= response.scores[i + 1] for i in range(len(response.scores) - 1)) # Verify we get results from both the vector-similar document and keyword-matched document - doc_ids = {chunk.metadata["document_id"] for chunk in response.chunks} + doc_ids = {chunk.metadata.get("document_id") or chunk.chunk_metadata.document_id for chunk in response.chunks} assert "document-0" in doc_ids # From vector search assert "document-2" in doc_ids # From keyword search @@ -364,7 +356,11 @@ async def test_query_chunks_hybrid_weighted_reranker_parametrization( reranker_params={"alpha": 1.0}, ) assert len(response.chunks) > 0 # Should get at least one result - assert any("document-0" in chunk.metadata["document_id"] for chunk in response.chunks) + assert any( + "document-0" + in (chunk.metadata.get("document_id") or (chunk.chunk_metadata.document_id if chunk.chunk_metadata else "")) + for chunk in response.chunks + ) # alpha=0.0 (should behave like pure vector) response = await sqlite_vec_index.query_hybrid( @@ -389,7 +385,11 @@ async def test_query_chunks_hybrid_weighted_reranker_parametrization( reranker_params={"alpha": 0.7}, ) assert len(response.chunks) > 0 # Should get at least one result - assert any("document-0" in chunk.metadata["document_id"] for chunk in response.chunks) + assert any( + "document-0" + in (chunk.metadata.get("document_id") or (chunk.chunk_metadata.document_id if chunk.chunk_metadata else "")) + for chunk in response.chunks + ) @pytest.mark.asyncio diff --git a/tests/unit/rag/test_rag_query.py b/tests/unit/rag/test_rag_query.py index b9fd8cca4..d2dd1783b 100644 --- a/tests/unit/rag/test_rag_query.py +++ b/tests/unit/rag/test_rag_query.py @@ -4,10 +4,15 @@ # This source code is licensed under the terms described in the LICENSE file in # the root directory of this source tree. -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock import pytest +from llama_stack.apis.vector_io import ( + Chunk, + ChunkMetadata, + QueryChunksResponse, +) from llama_stack.providers.inline.tool_runtime.rag.memory import MemoryToolRuntimeImpl @@ -17,3 +22,41 @@ class TestRagQuery: rag_tool = MemoryToolRuntimeImpl(config=MagicMock(), vector_io_api=MagicMock(), inference_api=MagicMock()) with pytest.raises(ValueError): await rag_tool.query(content=MagicMock(), vector_db_ids=[]) + + @pytest.mark.asyncio + async def test_query_chunk_metadata_handling(self): + rag_tool = MemoryToolRuntimeImpl(config=MagicMock(), vector_io_api=MagicMock(), inference_api=MagicMock()) + content = "test query content" + vector_db_ids = ["db1"] + + chunk_metadata = ChunkMetadata( + document_id="doc1", + chunk_id="chunk1", + source="test_source", + metadata_token_count=5, + ) + interleaved_content = MagicMock() + chunk = Chunk( + content=interleaved_content, + metadata={ + "key1": "value1", + "token_count": 10, + "metadata_token_count": 5, + # Note this is inserted into `metadata` during MemoryToolRuntimeImpl().insert() + "document_id": "doc1", + }, + stored_chunk_id="chunk1", + chunk_metadata=chunk_metadata, + ) + + query_response = QueryChunksResponse(chunks=[chunk], scores=[1.0]) + + rag_tool.vector_io_api.query_chunks = AsyncMock(return_value=query_response) + result = await rag_tool.query(content=content, vector_db_ids=vector_db_ids) + + assert result is not None + expected_metadata_string = ( + "Metadata: {'chunk_id': 'chunk1', 'document_id': 'doc1', 'source': 'test_source', 'key1': 'value1'}" + ) + assert expected_metadata_string in result.content[1].text + assert result.content is not None