Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Changelog

All notable changes to this project are documented here.

## Unreleased

### Added

- Meaning-unit embeddings chunking for `/v1/batch/embeddings`: header, paragraph, sentence, and `data:image` units keep source offsets so naruon can search SKU lines and senders without mixing them into a due-date vector. The naruon one-vector-per-input reduce is unchanged; read `meaning_units` for unit-level search.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This changelog tells a buyer to read meaning_units for SKU-level search and claims the naruon reduce is unchanged. The reduce shape is unchanged; the reduced vector is not. Default-path mail now averages header + paragraphs + raw base64 into the one slot naruon indexes.

Do not ship this wording. The buyer next action belongs on #652: send chunking_strategy=meaning_units, then search chunk_units. Omit/null keeps the current one-vector contract.

1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,7 @@ python tests/test_paper_contracts.py
python tests/test_admin_contract.py
python tests/test_conventions.py
python tests/test_api_contract.py
python tests/test_meaning_unit_chunking.py
python tests/test_security_hardening.py
python tests/test_repository_security_metadata.py
python tests/test_product_planning_contract.py
Expand Down
2 changes: 1 addition & 1 deletion contextual_orchestrator/api_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -416,7 +416,7 @@
"/v1/batch/embeddings": {
"post": {
"operationId": "create_batch_embeddings_job",
"summary": "Submit a bulk, latency-tolerant embeddings batch (token-split, routed via pg-llm-batch, cost-recorded)",
"summary": "Submit a bulk, latency-tolerant embeddings batch (meaning-unit split, routed via pg-llm-batch, cost-recorded)",
"security": [{"inference_bearer_auth": []}],
"requestBody": {
"required": True,
Expand Down
3 changes: 3 additions & 0 deletions contextual_orchestrator/batch_routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,9 @@ class EmbeddingBatchRequest:
part_index: int = 0
part_count: int = 1
token_count: int = 0
source_start: int = 0
source_end: int = 0
unit_kind: str = "paragraph_unit"

def to_jsonl_line(self, endpoint: str = "/v1/embeddings") -> Dict[str, Any]:
"""Render this request as an OpenAI Batch API embeddings JSONL line."""
Expand Down
116 changes: 32 additions & 84 deletions contextual_orchestrator/cost_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@

from __future__ import annotations

import re
from typing import Any, Dict, List, Optional

from .batch_routing import (
Expand All @@ -36,12 +35,12 @@
)
from .cost_ledger import CostLedger, PriceBook
from .kv_config import InMemoryConfigStore
from .meaning_unit_chunking import MeaningUnitChunk, split_meaning_units
from .token_counting import HeuristicTokenCounter, build_token_counter

_EMBEDDING_CONFIG_CATEGORY = "routing"
_DEFAULT_EMBEDDING_MAX_TOKENS_PER_REQUEST = 280_000
_DEFAULT_EMBEDDING_MAX_CHARS_PER_PART = 240_000
_EMBEDDING_UNIT_RE = re.compile(r"\S+\s*|\s+", re.UNICODE)


class CostRoutingCoordinator:
Expand Down Expand Up @@ -301,7 +300,7 @@ def _build_embedding_requests(
model: str,
attribution: Dict[str, Any],
) -> tuple[List[EmbeddingBatchRequest], List[int], Dict[str, int]]:
"""Map original embedding inputs into token-budgeted provider parts."""
"""Map original embedding inputs into meaning-unit provider parts."""
max_tokens, max_chars = self._embedding_request_limits()
requests: List[EmbeddingBatchRequest] = []
part_counts: List[int] = []
Expand All @@ -312,16 +311,19 @@ def _build_embedding_requests(
)
part_count = len(parts)
part_counts.append(part_count)
for part_index, (part_text, token_count) in enumerate(parts):
for part_index, chunk in enumerate(parts):
requests.append(
EmbeddingBatchRequest(
input_text=part_text,
input_text=chunk.chunk_text,
model=model,
attribution=dict(attribution),
source_index=source_index,
part_index=part_index,
part_count=part_count,
token_count=token_count,
token_count=chunk.token_count,
source_start=chunk.source_start,
source_end=chunk.source_end,
unit_kind=chunk.unit_kind,
)
)
return requests, part_counts, {
Expand Down Expand Up @@ -362,87 +364,14 @@ def _split_embedding_input(
model: str,
max_tokens: int,
max_chars: int,
) -> List[tuple[str, int]]:
"""Split one original embedding input into provider-safe map parts."""
if text == "":
return [("", 0)]
parts = self._force_token_safe_chunks(
text, model=model, max_tokens=max_tokens, max_chars=max_chars
)
return parts or [("", 0)]

def _force_token_safe_chunks(
self,
text: str,
*,
model: str,
max_tokens: int,
max_chars: int,
) -> List[tuple[str, int]]:
"""Recursively split text until each chunk fits token and char budgets."""
if text == "":
return [("", 0)]
if len(text) > max_chars:
chunks: List[tuple[str, int]] = []
for start in range(0, len(text), max_chars):
chunks.extend(
self._force_token_safe_chunks(
text[start : start + max_chars],
model=model,
max_tokens=max_tokens,
max_chars=max_chars,
)
)
return chunks

token_count = self._count_embedding_tokens(text, model)
if token_count <= max_tokens or len(text) <= 1:
return [(text, token_count)]

units = _EMBEDDING_UNIT_RE.findall(text)
if len(units) > 1:
chunks = []
current = ""
for unit in units:
candidate = f"{current}{unit}"
if current and (
len(candidate) > max_chars
or self._count_embedding_tokens(candidate, model) > max_tokens
):
chunks.extend(
self._force_token_safe_chunks(
current,
model=model,
max_tokens=max_tokens,
max_chars=max_chars,
)
)
current = unit
else:
current = candidate
if current:
chunks.extend(
self._force_token_safe_chunks(
current,
model=model,
max_tokens=max_tokens,
max_chars=max_chars,
)
)
if len(chunks) > 1 or (chunks and chunks[0][0] != text):
return chunks

midpoint = max(1, len(text) // 2)
return self._force_token_safe_chunks(
text[:midpoint],
model=model,
max_tokens=max_tokens,
max_chars=max_chars,
) + self._force_token_safe_chunks(
text[midpoint:],
) -> List[MeaningUnitChunk]:
"""Split one original embedding input into meaning-unit map parts."""
return split_meaning_units(
text,
model=model,
max_tokens=max_tokens,
max_chars=max_chars,
count_tokens=self._count_embedding_tokens,
)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Always-on: every /v1/batch/embeddings input now goes through split_meaning_units, and _force_token_safe_chunks is deleted.

Repro on this head: the fixture AP email under the default 280k/240k ceilings becomes 4 provider parts (header, invoice, SKU, data:image) instead of 1. Those four vectors are then token-weighted-averaged into embeddings[0]. The naruon contract test stays green because alpha body / beta attachment / gamma attachment have no headers, blank lines, or images.

Restore the token-budget splitter as the default. Gate meaning-unit expansion on an explicit request field (chunking_strategy=meaning_units), same as #652. Do not re-implement that opt-in on this branch — close this PR and land #652.


def _count_embedding_tokens(self, text: str, model: str) -> int:
Expand Down Expand Up @@ -501,10 +430,15 @@ def embeddings_batch_document(self, batch_id: str) -> Dict[str, Any]:
"prompt_tokens": max(0, prompt_tokens),
"model": item.model,
"attribution": dict(request.attribution) if request else {},
"chunk_text": request.input_text if request else "",
"source_start": request.source_start if request else 0,
"source_end": request.source_end if request else 0,
"unit_kind": request.unit_kind if request else "paragraph_unit",
}
)

embeddings: List[Dict[str, Any]] = []
meaning_units: List[Dict[str, Any]] = []
token_counts: List[int] = []
total_cost_amount = 0.0
currency_code = "USD"
Expand Down Expand Up @@ -542,6 +476,18 @@ def embeddings_batch_document(self, batch_id: str) -> Dict[str, Any]:
),
}
)
for part in parts:
meaning_units.append(
{
"source_index": source_index,
"part_index": part["part_index"],
"unit_kind": part["unit_kind"],
"source_start": part["source_start"],
"source_end": part["source_end"],
"chunk_text": part["chunk_text"],
"embedding": part["embedding"],
}
)

document = {
"batch_id": batch_id,
Expand All @@ -553,8 +499,10 @@ def embeddings_batch_document(self, batch_id: str) -> Dict[str, Any]:
"total_tokens": sum(token_counts),
"part_count": len(requests),
"input_part_counts": part_counts,
"meaning_units": meaning_units,
"map_reduce": {
"strategy": "token_budgeted_embedding_parts_weighted_average",
"meaning_unit_strategy": "header_paragraph_sentence_image",

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

embeddings[i] is still a weighted average of the new parts. After the always-on split, that average includes the header block and the raw base64 image. The 1×1 PNG fixture is 15 heuristic tokens vs 20 for the invoice line; a real attachment will dominate the document vector naruon indexes today.

meaning_units is a new, OpenAPI-undocumented side channel. Nothing in this repo or the naruon fixture reads it. Shape (len(embeddings)==len(inputs)) holds; the semantic contract does not.

Default path must keep embeddings[i] as the whole-document (token-budget-only) vector. Unit vectors belong on an opt-in field, and must not be averaged into the naruon slot.

**part_limits,
},
"cost_amount": round(total_cost_amount, 6),
Expand Down
Loading
Loading