contextualize() and embed_chunks() utilities in embeddings

This commit is contained in:
Yiorgis Gozadinos 2025-12-05 11:58:50 +02:00
parent 4fbb4609b6
commit e299aed788
No known key found for this signature in database
3 changed files with 198 additions and 0 deletions

View file

@ -45,6 +45,9 @@
- Supports `file://` URIs - Supports `file://` URIs
- **New `chunk()` Method**: Chunk a DoclingDocument into Chunk objects - **New `chunk()` Method**: Chunk a DoclingDocument into Chunk objects
- `client.chunk(docling_doc)` - returns `list[Chunk]` without embeddings - `client.chunk(docling_doc)` - returns `list[Chunk]` without embeddings
- **New `contextualize()` and `embed_chunks()` Utilities**: Standalone embedding utilities in `haiku.rag.embeddings`
- `contextualize(chunks)` - prepend section headings to chunk content for better semantic search
- `embed_chunks(chunks)` - generate embeddings for chunks, returns new Chunk objects with embeddings set
### Changed ### Changed

View file

@ -1,7 +1,75 @@
from typing import TYPE_CHECKING
from haiku.rag.config import AppConfig, Config from haiku.rag.config import AppConfig, Config
from haiku.rag.embeddings.base import EmbedderBase from haiku.rag.embeddings.base import EmbedderBase
from haiku.rag.embeddings.ollama import Embedder as OllamaEmbedder from haiku.rag.embeddings.ollama import Embedder as OllamaEmbedder
if TYPE_CHECKING:
from haiku.rag.store.models.chunk import Chunk
def contextualize(chunks: list["Chunk"]) -> list[str]:
"""Prepare chunk content for embedding by adding context.
Prepends section headings to chunk content for better semantic search.
The embeddings will capture section context while stored content stays raw.
Args:
chunks: List of chunks to contextualize.
Returns:
List of contextualized text strings for embedding.
"""
texts = []
for chunk in chunks:
meta = chunk.get_chunk_metadata()
if meta.headings:
text = "\n".join(meta.headings) + "\n" + chunk.content
else:
text = chunk.content
texts.append(text)
return texts
async def embed_chunks(
chunks: list["Chunk"], config: AppConfig = Config
) -> list["Chunk"]:
"""Generate embeddings for chunks.
Contextualizes chunks (prepends headings) before embedding for better
semantic search. Returns new Chunk objects with embeddings set.
Args:
chunks: List of chunks to embed.
config: Configuration for embedder selection.
Returns:
New list of Chunk objects with embedding field populated.
"""
if not chunks:
return []
from haiku.rag.store.models.chunk import Chunk
embedder = get_embedder(config)
texts = contextualize(chunks)
embeddings = await embedder.embed(texts)
return [
Chunk(
id=chunk.id,
document_id=chunk.document_id,
content=chunk.content,
metadata=chunk.metadata,
order=chunk.order,
document_uri=chunk.document_uri,
document_title=chunk.document_title,
document_meta=chunk.document_meta,
embedding=embedding,
)
for chunk, embedding in zip(chunks, embeddings)
]
def get_embedder(config: AppConfig = Config) -> EmbedderBase: def get_embedder(config: AppConfig = Config) -> EmbedderBase:
""" """

View file

@ -4,9 +4,11 @@ import numpy as np
import pytest import pytest
from haiku.rag.config import Config from haiku.rag.config import Config
from haiku.rag.embeddings import contextualize, embed_chunks
from haiku.rag.embeddings.ollama import Embedder as OllamaEmbedder from haiku.rag.embeddings.ollama import Embedder as OllamaEmbedder
from haiku.rag.embeddings.openai import Embedder as OpenAIEmbedder from haiku.rag.embeddings.openai import Embedder as OpenAIEmbedder
from haiku.rag.embeddings.vllm import Embedder as VLLMEmbedder from haiku.rag.embeddings.vllm import Embedder as VLLMEmbedder
from haiku.rag.store.models.chunk import Chunk
OPENAI_AVAILABLE = bool(os.getenv("OPENAI_API_KEY")) OPENAI_AVAILABLE = bool(os.getenv("OPENAI_API_KEY"))
VOYAGEAI_AVAILABLE = bool(os.getenv("VOYAGE_API_KEY")) VOYAGEAI_AVAILABLE = bool(os.getenv("VOYAGE_API_KEY"))
@ -171,3 +173,128 @@ async def test_vllm_embedder():
sims = similarities(embeddings, test_embedding) sims = similarities(embeddings, test_embedding)
assert max(sims) == sims[1] assert max(sims) == sims[1]
def test_contextualize_with_headings():
"""Test that contextualize prepends headings to chunk content."""
chunks = [
Chunk(
content="This is the content.",
metadata={"headings": ["Chapter 1", "Section 1.1"]},
),
Chunk(
content="More content here.",
metadata={"headings": ["Chapter 2"]},
),
]
texts = contextualize(chunks)
assert len(texts) == 2
assert texts[0] == "Chapter 1\nSection 1.1\nThis is the content."
assert texts[1] == "Chapter 2\nMore content here."
def test_contextualize_without_headings():
"""Test that contextualize returns raw content when no headings."""
chunks = [
Chunk(content="Plain content."),
Chunk(content="Another chunk.", metadata={}),
Chunk(content="With empty headings.", metadata={"headings": None}),
]
texts = contextualize(chunks)
assert len(texts) == 3
assert texts[0] == "Plain content."
assert texts[1] == "Another chunk."
assert texts[2] == "With empty headings."
def test_contextualize_empty_list():
"""Test that contextualize handles empty list."""
texts = contextualize([])
assert texts == []
@pytest.mark.asyncio
async def test_embed_chunks_basic():
"""Test that embed_chunks generates embeddings for chunks."""
chunks = [
Chunk(
id="chunk1",
document_id="doc1",
content="I enjoy eating great food.",
metadata={"headings": ["Food"]},
order=0,
),
Chunk(
id="chunk2",
document_id="doc1",
content="Python is my favorite programming language.",
metadata={"headings": ["Programming"]},
order=1,
),
]
embedded_chunks = await embed_chunks(chunks)
assert len(embedded_chunks) == 2
# Check that all original fields are preserved
assert embedded_chunks[0].id == "chunk1"
assert embedded_chunks[0].document_id == "doc1"
assert embedded_chunks[0].content == "I enjoy eating great food."
assert embedded_chunks[0].metadata == {"headings": ["Food"]}
assert embedded_chunks[0].order == 0
# Check that embeddings are generated
assert embedded_chunks[0].embedding is not None
assert len(embedded_chunks[0].embedding) > 0
assert embedded_chunks[1].embedding is not None
@pytest.mark.asyncio
async def test_embed_chunks_returns_new_objects():
"""Test that embed_chunks returns new Chunk objects (immutable pattern)."""
original = Chunk(id="orig", content="Test content.")
embedded = await embed_chunks([original])
# Original should be unchanged
assert original.embedding is None
# New chunk should have embedding
assert embedded[0].embedding is not None
# They should be different objects
assert embedded[0] is not original
@pytest.mark.asyncio
async def test_embed_chunks_empty_list():
"""Test that embed_chunks handles empty list."""
result = await embed_chunks([])
assert result == []
@pytest.mark.asyncio
async def test_embed_chunks_preserves_all_fields():
"""Test that embed_chunks preserves all chunk fields."""
chunk = Chunk(
id="test-id",
document_id="doc-id",
content="Test content",
metadata={"key": "value", "headings": ["Heading"]},
order=5,
document_uri="https://example.com/doc",
document_title="Test Document",
document_meta={"author": "Test"},
)
embedded = await embed_chunks([chunk])
assert embedded[0].id == "test-id"
assert embedded[0].document_id == "doc-id"
assert embedded[0].content == "Test content"
assert embedded[0].metadata == {"key": "value", "headings": ["Heading"]}
assert embedded[0].order == 5
assert embedded[0].document_uri == "https://example.com/doc"
assert embedded[0].document_title == "Test Document"
assert embedded[0].document_meta == {"author": "Test"}
assert embedded[0].embedding is not None