Studio RAG: remove late chunking and the chunking_strategy field/selector (single fixed chunker)
This commit is contained in:
parent
f955738075
commit
a41fae78eb
16 changed files with 69 additions and 847 deletions
|
|
@ -246,209 +246,3 @@ def token_counter(model_name: str | None = None):
|
|||
return max(1, len(text) // 4)
|
||||
|
||||
return _count
|
||||
|
||||
|
||||
# --- Late chunking (Jina technique) ---
|
||||
|
||||
_LATE_WINDOW_OVERLAP_TOKENS = 512
|
||||
|
||||
|
||||
def late_chunk_encode(
|
||||
doc_text: str,
|
||||
char_spans: list[tuple[int, int]],
|
||||
*,
|
||||
model_name: str | None = None,
|
||||
normalize: bool = True,
|
||||
):
|
||||
"""Single forward pass over the doc, mean-pool token embeddings per chunk span."""
|
||||
import numpy as np
|
||||
|
||||
if not char_spans:
|
||||
return []
|
||||
model = get_embedder(model_name)
|
||||
tokenizer = model.tokenizer
|
||||
max_length = int(getattr(model, "max_seq_length", None) or 8192)
|
||||
|
||||
encoded = tokenizer(
|
||||
doc_text,
|
||||
return_tensors = "pt",
|
||||
return_offsets_mapping = True,
|
||||
add_special_tokens = True,
|
||||
truncation = False,
|
||||
)
|
||||
offsets = encoded.pop("offset_mapping")[0].tolist()
|
||||
n_tokens = int(encoded["input_ids"].shape[1])
|
||||
|
||||
if n_tokens <= max_length:
|
||||
token_embeddings = _encode_tokens(model, encoded)
|
||||
return _pool_spans(
|
||||
token_embeddings,
|
||||
offsets,
|
||||
char_spans,
|
||||
normalize = normalize,
|
||||
np_module = np,
|
||||
model = model,
|
||||
doc_text = doc_text,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Late chunking: doc has %d tokens > model max %d; using windowed pass",
|
||||
n_tokens,
|
||||
max_length,
|
||||
)
|
||||
return _windowed_late_chunk_encode(
|
||||
doc_text = doc_text,
|
||||
char_spans = char_spans,
|
||||
model = model,
|
||||
max_length = max_length,
|
||||
normalize = normalize,
|
||||
np_module = np,
|
||||
)
|
||||
|
||||
|
||||
def _encode_tokens(model, encoded):
|
||||
import torch
|
||||
|
||||
transformer = model[0].auto_model
|
||||
device = next(transformer.parameters()).device
|
||||
inputs_on_device = {k: v.to(device) for k, v in encoded.items()}
|
||||
with torch.no_grad():
|
||||
outputs = transformer(**inputs_on_device)
|
||||
return outputs.last_hidden_state[0].detach().cpu().numpy()
|
||||
|
||||
|
||||
def _pool_spans(
|
||||
token_embeddings,
|
||||
offsets,
|
||||
char_spans,
|
||||
*,
|
||||
normalize: bool,
|
||||
np_module,
|
||||
model,
|
||||
doc_text: str,
|
||||
token_index_offset: int = 0,
|
||||
):
|
||||
"""Mean-pool token embeddings per (char_start, char_end) span."""
|
||||
vectors = []
|
||||
n_rows = token_embeddings.shape[0]
|
||||
for char_start, char_end in char_spans:
|
||||
# Skip special tokens whose offsets are (0, 0).
|
||||
indices = [
|
||||
i - token_index_offset
|
||||
for i, (ts, te) in enumerate(offsets)
|
||||
if te > ts and te > char_start and ts < char_end
|
||||
]
|
||||
indices = [i for i in indices if 0 <= i < n_rows]
|
||||
if not indices:
|
||||
vec = model.encode(
|
||||
doc_text[char_start:char_end],
|
||||
normalize_embeddings = normalize,
|
||||
convert_to_numpy = True,
|
||||
show_progress_bar = False,
|
||||
)
|
||||
vectors.append(vec)
|
||||
continue
|
||||
pooled = token_embeddings[indices].mean(axis = 0)
|
||||
if normalize:
|
||||
denom = float(np_module.linalg.norm(pooled))
|
||||
if denom > 0:
|
||||
pooled = pooled / denom
|
||||
vectors.append(pooled)
|
||||
return vectors
|
||||
|
||||
|
||||
def _windowed_late_chunk_encode(
|
||||
*,
|
||||
doc_text: str,
|
||||
char_spans: list[tuple[int, int]],
|
||||
model,
|
||||
max_length: int,
|
||||
normalize: bool,
|
||||
np_module,
|
||||
):
|
||||
"""Doc > ctx window: pool each chunk against the window containing most of its tokens."""
|
||||
import torch
|
||||
|
||||
tokenizer = model.tokenizer
|
||||
transformer = model[0].auto_model
|
||||
device = next(transformer.parameters()).device
|
||||
|
||||
full = tokenizer(
|
||||
doc_text,
|
||||
return_tensors = "pt",
|
||||
return_offsets_mapping = True,
|
||||
add_special_tokens = False,
|
||||
truncation = False,
|
||||
)
|
||||
all_input_ids = full["input_ids"][0]
|
||||
all_offsets = full["offset_mapping"][0].tolist()
|
||||
n_tokens = int(all_input_ids.shape[0])
|
||||
stride = max(1, max_length - _LATE_WINDOW_OVERLAP_TOKENS)
|
||||
|
||||
windows: list[tuple[int, int]] = []
|
||||
pos = 0
|
||||
while pos < n_tokens:
|
||||
end = min(pos + max_length, n_tokens)
|
||||
windows.append((pos, end))
|
||||
if end >= n_tokens:
|
||||
break
|
||||
pos += stride
|
||||
|
||||
window_embeddings: dict[int, "np_module.ndarray"] = {}
|
||||
|
||||
def _window_embeddings(window_index: int):
|
||||
if window_index in window_embeddings:
|
||||
return window_embeddings[window_index]
|
||||
ws, we = windows[window_index]
|
||||
win_ids = all_input_ids[ws:we].unsqueeze(0).to(device)
|
||||
win_attn = torch.ones_like(win_ids)
|
||||
with torch.no_grad():
|
||||
outputs = transformer(input_ids = win_ids, attention_mask = win_attn)
|
||||
emb = outputs.last_hidden_state[0].detach().cpu().numpy()
|
||||
window_embeddings[window_index] = emb
|
||||
return emb
|
||||
|
||||
vectors = []
|
||||
for char_start, char_end in char_spans:
|
||||
chunk_token_indices = [
|
||||
i
|
||||
for i, (ts, te) in enumerate(all_offsets)
|
||||
if te > ts and te > char_start and ts < char_end
|
||||
]
|
||||
if not chunk_token_indices:
|
||||
vec = model.encode(
|
||||
doc_text[char_start:char_end],
|
||||
normalize_embeddings = normalize,
|
||||
convert_to_numpy = True,
|
||||
show_progress_bar = False,
|
||||
)
|
||||
vectors.append(vec)
|
||||
continue
|
||||
best_window = 0
|
||||
best_overlap = 0
|
||||
for wi, (ws, we) in enumerate(windows):
|
||||
overlap = sum(1 for ti in chunk_token_indices if ws <= ti < we)
|
||||
if overlap > best_overlap:
|
||||
best_overlap = overlap
|
||||
best_window = wi
|
||||
ws, _we = windows[best_window]
|
||||
emb = _window_embeddings(best_window)
|
||||
local_indices = [
|
||||
ti - ws for ti in chunk_token_indices if ws <= ti < ws + emb.shape[0]
|
||||
]
|
||||
if not local_indices:
|
||||
vec = model.encode(
|
||||
doc_text[char_start:char_end],
|
||||
normalize_embeddings = normalize,
|
||||
convert_to_numpy = True,
|
||||
show_progress_bar = False,
|
||||
)
|
||||
vectors.append(vec)
|
||||
continue
|
||||
pooled = emb[local_indices].mean(axis = 0)
|
||||
if normalize:
|
||||
denom = float(np_module.linalg.norm(pooled))
|
||||
if denom > 0:
|
||||
pooled = pooled / denom
|
||||
vectors.append(pooled)
|
||||
return vectors
|
||||
|
|
|
|||
|
|
@ -58,7 +58,6 @@ def _subprocess_worker(
|
|||
overlap: int,
|
||||
batch_size: int,
|
||||
out_queue: Any,
|
||||
chunking_strategy: str = "standard",
|
||||
mode: str = "text",
|
||||
document_id: str = "",
|
||||
vlm_url: str | None = None,
|
||||
|
|
@ -128,7 +127,6 @@ def _subprocess_worker(
|
|||
out_queue.put({"type": "progress", "stage": "load_model", "progress": 0.1})
|
||||
from core.rag.embeddings import (
|
||||
get_embedder,
|
||||
late_chunk_encode,
|
||||
token_counter,
|
||||
)
|
||||
|
||||
|
|
@ -137,18 +135,6 @@ def _subprocess_worker(
|
|||
dim = int(model.get_sentence_embedding_dimension())
|
||||
out_queue.put({"type": "dim", "dim": dim})
|
||||
|
||||
if chunking_strategy == "late":
|
||||
_run_late_chunking(
|
||||
pages = pages,
|
||||
stored_path = Path(stored_path),
|
||||
chunk_size = chunk_size,
|
||||
overlap = overlap,
|
||||
counter = counter,
|
||||
model_name = model_name,
|
||||
late_chunk_encode = late_chunk_encode,
|
||||
out_queue = out_queue,
|
||||
)
|
||||
return
|
||||
|
||||
text_count = _run_standard_chunking(
|
||||
pages = pages,
|
||||
|
|
@ -352,67 +338,6 @@ def _stream_image_chunks(
|
|||
return len(out_chunks)
|
||||
|
||||
|
||||
def _run_late_chunking(
|
||||
*,
|
||||
pages,
|
||||
stored_path,
|
||||
chunk_size,
|
||||
overlap,
|
||||
counter,
|
||||
model_name,
|
||||
late_chunk_encode,
|
||||
out_queue,
|
||||
) -> None:
|
||||
"""Chunk once, embed in one pass, ship all chunks in one chunks_batch."""
|
||||
from core.rag.chunking import chunk_pages_with_spans
|
||||
from core.rag.locators import pdf_regions_for_chunks
|
||||
|
||||
out_queue.put({"type": "progress", "stage": "chunk", "progress": 0.2})
|
||||
full_doc, chunks, char_spans = chunk_pages_with_spans(
|
||||
pages,
|
||||
max_tokens = chunk_size,
|
||||
overlap_tokens = overlap,
|
||||
token_counter = counter,
|
||||
)
|
||||
if not chunks:
|
||||
out_queue.put({"type": "error", "error": "chunker produced no chunks"})
|
||||
return
|
||||
|
||||
out_queue.put({"type": "progress", "stage": "embed", "progress": 0.4})
|
||||
vectors = late_chunk_encode(
|
||||
full_doc,
|
||||
char_spans,
|
||||
model_name = model_name,
|
||||
normalize = True,
|
||||
)
|
||||
pdf_regions = pdf_regions_for_chunks(stored_path, pages, chunks)
|
||||
|
||||
out_queue.put({"type": "progress", "stage": "embed", "progress": 0.9})
|
||||
out_queue.put(
|
||||
{
|
||||
"type": "chunks_batch",
|
||||
"first_index": 0,
|
||||
"chunks": [
|
||||
{
|
||||
"text": c.text,
|
||||
"token_count": c.token_count,
|
||||
"page_number": c.page_number,
|
||||
"source_page_index": c.source_page_index,
|
||||
"page_char_start": c.page_char_start,
|
||||
"page_char_end": c.page_char_end,
|
||||
"line_start": c.line_start,
|
||||
"line_end": c.line_end,
|
||||
"pdf_regions": pdf_regions[index],
|
||||
"kind": "text",
|
||||
}
|
||||
for index, c in enumerate(chunks)
|
||||
],
|
||||
"vectors": [v.tolist() for v in vectors],
|
||||
}
|
||||
)
|
||||
out_queue.put({"type": "complete", "num_chunks": len(chunks)})
|
||||
|
||||
|
||||
# --- Job manager (parent side) ---
|
||||
|
||||
|
||||
|
|
@ -830,7 +755,6 @@ def enqueue_ingestion(
|
|||
kb_id: str | None = None,
|
||||
thread_id: str | None = None,
|
||||
embedding_model: str | None = None,
|
||||
chunking_strategy: str = "standard",
|
||||
mode: str = "text",
|
||||
enable_captions: bool = True,
|
||||
) -> str:
|
||||
|
|
@ -840,7 +764,7 @@ def enqueue_ingestion(
|
|||
scope = _scope_for(kb_id, thread_id)
|
||||
model_name = (
|
||||
embedding_model
|
||||
or resolve_embedder(mode, chunking_strategy)
|
||||
or resolve_embedder(mode)
|
||||
or RAG_EMBEDDING_MODEL
|
||||
)
|
||||
# Probe the loaded chat backend so the subprocess can caption figures with the
|
||||
|
|
@ -892,7 +816,6 @@ def enqueue_ingestion(
|
|||
RAG_CHUNK_OVERLAP,
|
||||
RAG_EMBED_BATCH_SIZE,
|
||||
out_queue,
|
||||
chunking_strategy,
|
||||
mode,
|
||||
document_id,
|
||||
vlm_url,
|
||||
|
|
|
|||
|
|
@ -39,11 +39,6 @@ def resolve_scope_embedder(scope: str) -> str | None:
|
|||
if explicit:
|
||||
return explicit
|
||||
mode = per_thread.get("mode") or defaults.get("mode") or "text"
|
||||
chunking_strategy = (
|
||||
per_thread.get("chunking_strategy")
|
||||
or defaults.get("chunking_strategy")
|
||||
or "standard"
|
||||
)
|
||||
return resolve_embedder(mode, chunking_strategy)
|
||||
return resolve_embedder(mode)
|
||||
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -63,7 +63,6 @@ logger = get_logger(__name__)
|
|||
|
||||
# --- Pydantic schemas ---
|
||||
|
||||
ChunkingStrategy = Literal["standard", "late"]
|
||||
KBMode = Literal["text", "multimodal"]
|
||||
|
||||
|
||||
|
|
@ -71,7 +70,6 @@ class CreateKBRequest(BaseModel):
|
|||
name: str = Field(min_length = 1, max_length = 200)
|
||||
description: str | None = None
|
||||
embedding_model: str | None = None
|
||||
chunking_strategy: ChunkingStrategy = "standard"
|
||||
mode: KBMode = "text"
|
||||
|
||||
|
||||
|
|
@ -80,7 +78,6 @@ class KBResponse(BaseModel):
|
|||
name: str
|
||||
description: str | None
|
||||
embedding_model: str
|
||||
chunking_strategy: ChunkingStrategy
|
||||
mode: KBMode
|
||||
created_at: int
|
||||
|
||||
|
|
@ -173,34 +170,17 @@ from core.rag.scope import resolve_scope_embedder as _resolve_scope_embedder #
|
|||
|
||||
def _row_to_kb(row: Any) -> KBResponse:
|
||||
keys = row.keys() if hasattr(row, "keys") else ()
|
||||
chunking_strategy = (
|
||||
row["chunking_strategy"] if "chunking_strategy" in keys else "standard"
|
||||
)
|
||||
mode = row["mode"] if "mode" in keys else "text"
|
||||
return KBResponse(
|
||||
id = row["id"],
|
||||
name = row["name"],
|
||||
description = row["description"],
|
||||
embedding_model = row["embedding_model"],
|
||||
chunking_strategy = chunking_strategy,
|
||||
mode = mode,
|
||||
created_at = row["created_at"],
|
||||
)
|
||||
|
||||
|
||||
def _validate_mode_combo(mode: KBMode, chunking_strategy: ChunkingStrategy) -> None:
|
||||
"""Reject (multimodal, late) — no embedder supports both at once."""
|
||||
if mode == "multimodal" and chunking_strategy == "late":
|
||||
raise HTTPException(
|
||||
status_code = 400,
|
||||
detail = (
|
||||
"Late chunking is not supported in multimodal mode — "
|
||||
"the multimodal embedder does not expose per-token "
|
||||
"embeddings. Pick 'standard' chunking or 'text' mode."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _row_to_document(row: Any) -> DocumentResponse:
|
||||
return DocumentResponse(
|
||||
id = row["id"],
|
||||
|
|
@ -300,7 +280,6 @@ def _start_ingestion(
|
|||
kb_id: str | None,
|
||||
thread_id: str | None,
|
||||
embedding_model: str,
|
||||
chunking_strategy: str = "standard",
|
||||
mode: str = "text",
|
||||
caption_images: bool = True,
|
||||
content_hash: str | None = None,
|
||||
|
|
@ -360,7 +339,6 @@ def _start_ingestion(
|
|||
kb_id = kb_id,
|
||||
thread_id = thread_id,
|
||||
embedding_model = embedding_model,
|
||||
chunking_strategy = chunking_strategy,
|
||||
mode = mode,
|
||||
enable_captions = caption_images,
|
||||
)
|
||||
|
|
@ -387,13 +365,9 @@ def create_knowledge_base(
|
|||
) -> KBResponse:
|
||||
from utils.rag.config import resolve_embedder
|
||||
|
||||
_validate_mode_combo(payload.mode, payload.chunking_strategy)
|
||||
|
||||
kb_id = str(uuid4())
|
||||
# No override: resolve from (mode, strategy) matrix.
|
||||
embedding_model = payload.embedding_model or resolve_embedder(
|
||||
payload.mode, payload.chunking_strategy
|
||||
)
|
||||
# No override: resolve the embedder from the KB mode.
|
||||
embedding_model = payload.embedding_model or resolve_embedder(payload.mode)
|
||||
created_at = _now_ms()
|
||||
with closing_connection() as conn:
|
||||
try:
|
||||
|
|
@ -401,8 +375,8 @@ def create_knowledge_base(
|
|||
"""
|
||||
INSERT INTO rag_knowledge_bases
|
||||
(id, name, description, owner_user_id, embedding_model,
|
||||
chunking_strategy, mode, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
mode, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
kb_id,
|
||||
|
|
@ -410,7 +384,6 @@ def create_knowledge_base(
|
|||
payload.description,
|
||||
current_subject,
|
||||
embedding_model,
|
||||
payload.chunking_strategy,
|
||||
payload.mode,
|
||||
created_at,
|
||||
),
|
||||
|
|
@ -426,7 +399,6 @@ def create_knowledge_base(
|
|||
name = payload.name,
|
||||
description = payload.description,
|
||||
embedding_model = embedding_model,
|
||||
chunking_strategy = payload.chunking_strategy,
|
||||
mode = payload.mode,
|
||||
created_at = created_at,
|
||||
)
|
||||
|
|
@ -444,7 +416,6 @@ def list_knowledge_bases(
|
|||
|
||||
|
||||
class RagDefaults(BaseModel):
|
||||
chunking_strategy: ChunkingStrategy = "standard"
|
||||
mode: KBMode = "text"
|
||||
embedding_model: str | None = None
|
||||
|
||||
|
|
@ -452,7 +423,6 @@ class RagDefaults(BaseModel):
|
|||
class UpdateRagDefaultsRequest(BaseModel):
|
||||
"""Patch shape — only fields present overwrite stored values."""
|
||||
|
||||
chunking_strategy: ChunkingStrategy | None = None
|
||||
mode: KBMode | None = None
|
||||
embedding_model: str | None = None
|
||||
|
||||
|
|
@ -466,7 +436,6 @@ def _load_rag_defaults() -> RagDefaults:
|
|||
if not isinstance(raw, dict):
|
||||
raw = {}
|
||||
return RagDefaults(
|
||||
chunking_strategy = raw.get("chunking_strategy") or "standard",
|
||||
mode = raw.get("mode") or "text",
|
||||
embedding_model = raw.get("embedding_model"),
|
||||
)
|
||||
|
|
@ -492,10 +461,7 @@ def warmup_rag_embedder(
|
|||
from utils.rag.config import resolve_embedder
|
||||
|
||||
defaults = _load_rag_defaults()
|
||||
model_name = defaults.embedding_model or resolve_embedder(
|
||||
defaults.mode,
|
||||
defaults.chunking_strategy,
|
||||
)
|
||||
model_name = defaults.embedding_model or resolve_embedder(defaults.mode)
|
||||
try:
|
||||
embeddings.get_embedder(model_name)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
|
|
@ -511,7 +477,6 @@ def set_rag_defaults(
|
|||
current_subject: str = Depends(get_current_subject),
|
||||
) -> RagDefaults:
|
||||
current = _load_rag_defaults()
|
||||
new_strategy = payload.chunking_strategy or current.chunking_strategy
|
||||
new_mode = payload.mode or current.mode
|
||||
# PATCH-style: empty string clears, null/missing keeps current.
|
||||
if payload.embedding_model is None:
|
||||
|
|
@ -520,32 +485,27 @@ def set_rag_defaults(
|
|||
new_embedder = None
|
||||
else:
|
||||
new_embedder = payload.embedding_model.strip()
|
||||
_validate_mode_combo(new_mode, new_strategy)
|
||||
|
||||
upsert_chat_settings_merge(
|
||||
{
|
||||
_DEFAULTS_KEY: {
|
||||
"chunking_strategy": new_strategy,
|
||||
"mode": new_mode,
|
||||
"embedding_model": new_embedder,
|
||||
}
|
||||
}
|
||||
)
|
||||
return RagDefaults(
|
||||
chunking_strategy = new_strategy,
|
||||
mode = new_mode,
|
||||
embedding_model = new_embedder,
|
||||
)
|
||||
|
||||
|
||||
class ThreadRagSettings(BaseModel):
|
||||
chunking_strategy: ChunkingStrategy = "standard"
|
||||
mode: KBMode = "text"
|
||||
embedding_model: str | None = None
|
||||
|
||||
|
||||
class UpdateThreadRagSettingsRequest(BaseModel):
|
||||
chunking_strategy: ChunkingStrategy | None = None
|
||||
mode: KBMode | None = None
|
||||
embedding_model: str | None = None
|
||||
# Reingest-only (not persisted); omit or None keeps captioning on.
|
||||
|
|
@ -564,7 +524,6 @@ def _load_thread_settings(thread_id: str) -> ThreadRagSettings:
|
|||
raw = {}
|
||||
fallback = _load_rag_defaults()
|
||||
return ThreadRagSettings(
|
||||
chunking_strategy = (raw.get("chunking_strategy") or fallback.chunking_strategy),
|
||||
mode = raw.get("mode") or fallback.mode,
|
||||
embedding_model = raw.get("embedding_model") or fallback.embedding_model,
|
||||
)
|
||||
|
|
@ -591,7 +550,6 @@ def set_thread_rag_settings(
|
|||
current_subject: str = Depends(get_current_subject),
|
||||
) -> ThreadRagSettings:
|
||||
current = _load_thread_settings(thread_id)
|
||||
new_strategy = payload.chunking_strategy or current.chunking_strategy
|
||||
new_mode = payload.mode or current.mode
|
||||
if payload.embedding_model is None:
|
||||
new_embedder = current.embedding_model
|
||||
|
|
@ -599,19 +557,16 @@ def set_thread_rag_settings(
|
|||
new_embedder = None
|
||||
else:
|
||||
new_embedder = payload.embedding_model.strip()
|
||||
_validate_mode_combo(new_mode, new_strategy)
|
||||
|
||||
upsert_chat_settings_merge(
|
||||
{
|
||||
_thread_settings_key(thread_id): {
|
||||
"chunking_strategy": new_strategy,
|
||||
"mode": new_mode,
|
||||
"embedding_model": new_embedder,
|
||||
}
|
||||
}
|
||||
)
|
||||
return ThreadRagSettings(
|
||||
chunking_strategy = new_strategy,
|
||||
mode = new_mode,
|
||||
embedding_model = new_embedder,
|
||||
)
|
||||
|
|
@ -620,7 +575,6 @@ def set_thread_rag_settings(
|
|||
class ReingestKBRequest(BaseModel):
|
||||
"""All fields optional — omitting one keeps the KB's current value."""
|
||||
|
||||
chunking_strategy: ChunkingStrategy | None = None
|
||||
mode: KBMode | None = None
|
||||
embedding_model: str | None = None
|
||||
# Not persisted on the KB; omit or None keeps captioning on for the rebuild.
|
||||
|
|
@ -636,7 +590,6 @@ def _reingest_scope(
|
|||
*,
|
||||
kb_id: str | None,
|
||||
thread_id: str | None,
|
||||
chunking_strategy: str,
|
||||
mode: str,
|
||||
embedding_model: str,
|
||||
caption_images: bool = True,
|
||||
|
|
@ -685,7 +638,6 @@ def _reingest_scope(
|
|||
kb_id = kb_id,
|
||||
thread_id = thread_id,
|
||||
embedding_model = embedding_model,
|
||||
chunking_strategy = chunking_strategy,
|
||||
mode = mode,
|
||||
caption_images = caption_images,
|
||||
)
|
||||
|
|
@ -707,37 +659,31 @@ def reingest_knowledge_base(
|
|||
|
||||
kb_row = _kb_or_404(kb_id)
|
||||
keys = kb_row.keys() if hasattr(kb_row, "keys") else ()
|
||||
current_strategy = (
|
||||
kb_row["chunking_strategy"] if "chunking_strategy" in keys else "standard"
|
||||
)
|
||||
current_mode = kb_row["mode"] if "mode" in keys else "text"
|
||||
current_embedder = kb_row["embedding_model"]
|
||||
|
||||
new_strategy = payload.chunking_strategy or current_strategy
|
||||
new_mode = payload.mode or current_mode
|
||||
_validate_mode_combo(new_mode, new_strategy)
|
||||
|
||||
new_embedder = payload.embedding_model or (
|
||||
current_embedder
|
||||
if (new_strategy == current_strategy and new_mode == current_mode)
|
||||
else resolve_embedder(new_mode, new_strategy)
|
||||
if new_mode == current_mode
|
||||
else resolve_embedder(new_mode)
|
||||
)
|
||||
|
||||
with closing_connection() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE rag_knowledge_bases
|
||||
SET chunking_strategy = ?, mode = ?, embedding_model = ?
|
||||
SET mode = ?, embedding_model = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(new_strategy, new_mode, new_embedder, kb_id),
|
||||
(new_mode, new_embedder, kb_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return _reingest_scope(
|
||||
kb_id = kb_id,
|
||||
thread_id = None,
|
||||
chunking_strategy = new_strategy,
|
||||
mode = new_mode,
|
||||
embedding_model = new_embedder,
|
||||
caption_images = payload.caption_images is not False,
|
||||
|
|
@ -758,11 +704,7 @@ def reingest_thread_documents(
|
|||
|
||||
if payload is None:
|
||||
payload = UpdateThreadRagSettingsRequest()
|
||||
if (
|
||||
payload.chunking_strategy is not None
|
||||
or payload.mode is not None
|
||||
or payload.embedding_model is not None
|
||||
):
|
||||
if payload.mode is not None or payload.embedding_model is not None:
|
||||
settings = set_thread_rag_settings(
|
||||
thread_id,
|
||||
payload,
|
||||
|
|
@ -771,14 +713,10 @@ def reingest_thread_documents(
|
|||
else:
|
||||
settings = _load_thread_settings(thread_id)
|
||||
|
||||
embedder = settings.embedding_model or resolve_embedder(
|
||||
settings.mode,
|
||||
settings.chunking_strategy,
|
||||
)
|
||||
embedder = settings.embedding_model or resolve_embedder(settings.mode)
|
||||
return _reingest_scope(
|
||||
kb_id = None,
|
||||
thread_id = thread_id,
|
||||
chunking_strategy = settings.chunking_strategy,
|
||||
mode = settings.mode,
|
||||
embedding_model = embedder,
|
||||
caption_images = payload.caption_images is not False,
|
||||
|
|
@ -816,11 +754,8 @@ async def upload_kb_document(
|
|||
) -> UploadResponse:
|
||||
kb_row = _kb_or_404(kb_id)
|
||||
stored_path, filename, byte_size, content_hash = await _save_upload(file)
|
||||
# Tolerate pre-Phase-3 rows missing chunking_strategy/mode.
|
||||
# Tolerate pre-Phase-3 rows missing mode.
|
||||
kb_keys = kb_row.keys() if hasattr(kb_row, "keys") else ()
|
||||
chunking_strategy = (
|
||||
kb_row["chunking_strategy"] if "chunking_strategy" in kb_keys else "standard"
|
||||
)
|
||||
mode = kb_row["mode"] if "mode" in kb_keys else "text"
|
||||
return _start_ingestion(
|
||||
filename = filename,
|
||||
|
|
@ -830,7 +765,6 @@ async def upload_kb_document(
|
|||
kb_id = kb_id,
|
||||
thread_id = None,
|
||||
embedding_model = kb_row["embedding_model"],
|
||||
chunking_strategy = chunking_strategy,
|
||||
mode = mode,
|
||||
caption_images = caption_images,
|
||||
content_hash = content_hash,
|
||||
|
|
@ -849,10 +783,7 @@ async def upload_thread_document(
|
|||
# No chat_threads check — fresh threads aren't persisted until first run.
|
||||
stored_path, filename, byte_size, content_hash = await _save_upload(file)
|
||||
settings = _load_thread_settings(thread_id)
|
||||
embedder = settings.embedding_model or resolve_embedder(
|
||||
settings.mode,
|
||||
settings.chunking_strategy,
|
||||
)
|
||||
embedder = settings.embedding_model or resolve_embedder(settings.mode)
|
||||
return _start_ingestion(
|
||||
filename = filename,
|
||||
stored_path = stored_path,
|
||||
|
|
@ -861,7 +792,6 @@ async def upload_thread_document(
|
|||
kb_id = None,
|
||||
thread_id = thread_id,
|
||||
embedding_model = embedder,
|
||||
chunking_strategy = settings.chunking_strategy,
|
||||
mode = settings.mode,
|
||||
caption_images = caption_images,
|
||||
content_hash = content_hash,
|
||||
|
|
|
|||
|
|
@ -31,8 +31,7 @@ RAG_EMBEDDING_MODEL: str = (
|
|||
or "BAAI/bge-small-en-v1.5"
|
||||
)
|
||||
|
||||
# Default embedder per (mode, chunking). (multimodal, late) is rejected at
|
||||
# KB-create time in routes/rag.py.
|
||||
# Default embedder per mode.
|
||||
#
|
||||
# Text mode is the default: PDF figures are captioned at ingest (chat VLM or
|
||||
# helper gemma-3n fallback) and spliced into the page markdown before chunking,
|
||||
|
|
@ -43,19 +42,15 @@ RAG_EMBEDDING_MODEL: str = (
|
|||
# Alternative multimodal embedders kept for manual override:
|
||||
# - "BAAI/BGE-VL-large" — smaller (~400 M / 768-d) but CLIP-family with a
|
||||
# 77-token text cap; routed via `_BGEVLAdapter` in core/rag/embeddings.py.
|
||||
RAG_EMBEDDER_MATRIX: dict[tuple[str, str], str] = {
|
||||
("text", "standard"): "BAAI/bge-small-en-v1.5",
|
||||
("text", "late"): "nomic-ai/nomic-embed-text-v1.5",
|
||||
("multimodal", "standard"): "Qwen/Qwen3-VL-Embedding-2B",
|
||||
RAG_EMBEDDER_MATRIX: dict[str, str] = {
|
||||
"text": "BAAI/bge-small-en-v1.5",
|
||||
"multimodal": "Qwen/Qwen3-VL-Embedding-2B",
|
||||
}
|
||||
|
||||
|
||||
def resolve_embedder(mode: str, chunking_strategy: str) -> str:
|
||||
"""Embedder for (mode, chunking); unknown combos fall back to RAG_EMBEDDING_MODEL."""
|
||||
return RAG_EMBEDDER_MATRIX.get(
|
||||
(mode, chunking_strategy),
|
||||
RAG_EMBEDDING_MODEL,
|
||||
)
|
||||
def resolve_embedder(mode: str) -> str:
|
||||
"""Embedder for the given mode; unknown modes fall back to RAG_EMBEDDING_MODEL."""
|
||||
return RAG_EMBEDDER_MATRIX.get(mode, RAG_EMBEDDING_MODEL)
|
||||
|
||||
|
||||
RAG_CHUNK_SIZE: int = _env_int("UNSLOTH_RAG_CHUNK_SIZE", 512)
|
||||
|
|
|
|||
|
|
@ -45,10 +45,7 @@ import {
|
|||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import {
|
||||
type KBMode,
|
||||
type ChunkingStrategy as RagChunkingStrategy,
|
||||
} from "@/features/rag/api/rag-api";
|
||||
import { type KBMode } from "@/features/rag/api/rag-api";
|
||||
import { DocumentRow } from "@/features/rag/components/document-row";
|
||||
import { KBCreateDialog } from "@/features/rag/components/kb-create-dialog";
|
||||
import { PreviewPanel } from "@/features/rag/components/preview-panel";
|
||||
|
|
@ -562,10 +559,6 @@ export function ChatSettingsPanel({
|
|||
}
|
||||
}, [ragSource.kind, activeThreadId, loadThreadSettings]);
|
||||
|
||||
const effectiveThreadChunking: RagChunkingStrategy =
|
||||
threadSettings?.chunking_strategy ??
|
||||
ragDefaults?.chunking_strategy ??
|
||||
"standard";
|
||||
const effectiveThreadMode: KBMode =
|
||||
threadSettings?.mode ?? ragDefaults?.mode ?? "text";
|
||||
|
||||
|
|
@ -592,7 +585,6 @@ export function ChatSettingsPanel({
|
|||
};
|
||||
|
||||
const applyThreadSettingChange = (patch: {
|
||||
chunking_strategy?: RagChunkingStrategy;
|
||||
mode?: KBMode;
|
||||
}) => {
|
||||
void (async () => {
|
||||
|
|
@ -1422,7 +1414,6 @@ export function ChatSettingsPanel({
|
|||
) : null}
|
||||
{knowledgeBases.map((kb) => {
|
||||
const isActive = kb.id === activeKbId;
|
||||
const isLate = kb.chunking_strategy === "late";
|
||||
const isMultimodal = kb.mode === "multimodal";
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
|
|
@ -1437,14 +1428,6 @@ export function ChatSettingsPanel({
|
|||
>
|
||||
<span className="flex min-w-0 items-center gap-1.5 truncate">
|
||||
<span className="truncate">{kb.name}</span>
|
||||
{isLate ? (
|
||||
<span
|
||||
className="rounded-sm bg-amber-500/15 px-1 text-[10px] font-medium text-amber-700 dark:text-amber-300"
|
||||
title="Late chunking enabled"
|
||||
>
|
||||
⚡ Late
|
||||
</span>
|
||||
) : null}
|
||||
{isMultimodal ? (
|
||||
<span
|
||||
className="rounded-sm bg-violet-500/15 px-1 text-[10px] font-medium text-violet-700 dark:text-violet-300"
|
||||
|
|
@ -1547,75 +1530,30 @@ export function ChatSettingsPanel({
|
|||
/>
|
||||
{ragSource.kind === "thread" ? (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[11px] font-medium text-muted-foreground">
|
||||
Mode
|
||||
</label>
|
||||
<Select
|
||||
value={effectiveThreadMode}
|
||||
onValueChange={(v) => {
|
||||
const next = v as KBMode;
|
||||
if (next === effectiveThreadMode) return;
|
||||
applyThreadSettingChange({ mode: next });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="text">Text only</SelectItem>
|
||||
<SelectItem
|
||||
value="multimodal"
|
||||
disabled={effectiveThreadChunking === "late"}
|
||||
title={
|
||||
effectiveThreadChunking === "late"
|
||||
? "Multimodal cannot be combined with late chunking"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
Multimodal
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[11px] font-medium text-muted-foreground">
|
||||
Chunking
|
||||
</label>
|
||||
<Select
|
||||
value={effectiveThreadChunking}
|
||||
onValueChange={(v) => {
|
||||
const next = v as RagChunkingStrategy;
|
||||
if (next === effectiveThreadChunking) return;
|
||||
applyThreadSettingChange({
|
||||
chunking_strategy: next,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="standard">Standard</SelectItem>
|
||||
<SelectItem
|
||||
value="late"
|
||||
disabled={effectiveThreadMode === "multimodal"}
|
||||
title={
|
||||
effectiveThreadMode === "multimodal"
|
||||
? "Late chunking cannot be combined with multimodal mode"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
Late
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[11px] font-medium text-muted-foreground">
|
||||
Mode
|
||||
</label>
|
||||
<Select
|
||||
value={effectiveThreadMode}
|
||||
onValueChange={(v) => {
|
||||
const next = v as KBMode;
|
||||
if (next === effectiveThreadMode) return;
|
||||
applyThreadSettingChange({ mode: next });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="text">Text only</SelectItem>
|
||||
<SelectItem value="multimodal">Multimodal</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Changing either setting will re-index this thread's
|
||||
existing documents.
|
||||
Changing the mode will re-index this thread's existing
|
||||
documents.
|
||||
</p>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[12px] font-medium text-muted-foreground">
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import { apiUrl } from "@/lib/api-base";
|
|||
import { formatFastApiDetail } from "@/lib/format-fastapi-error";
|
||||
import { EventSourcePolyfill } from "event-source-polyfill";
|
||||
|
||||
export type ChunkingStrategy = "standard" | "late";
|
||||
export type KBMode = "text" | "multimodal";
|
||||
|
||||
export interface KnowledgeBase {
|
||||
|
|
@ -14,7 +13,6 @@ export interface KnowledgeBase {
|
|||
name: string;
|
||||
description: string | null;
|
||||
embedding_model: string;
|
||||
chunking_strategy: ChunkingStrategy;
|
||||
mode: KBMode;
|
||||
created_at: number;
|
||||
}
|
||||
|
|
@ -183,7 +181,6 @@ export interface CreateKnowledgeBaseRequest {
|
|||
name: string;
|
||||
description?: string;
|
||||
embedding_model?: string;
|
||||
chunking_strategy?: ChunkingStrategy;
|
||||
mode?: KBMode;
|
||||
}
|
||||
|
||||
|
|
@ -291,7 +288,6 @@ export interface ReingestResponse {
|
|||
}
|
||||
|
||||
export interface ReingestKBOptions {
|
||||
chunking_strategy?: ChunkingStrategy;
|
||||
mode?: KBMode;
|
||||
embedding_model?: string;
|
||||
caption_images?: boolean;
|
||||
|
|
@ -313,13 +309,11 @@ export async function reingestKnowledgeBase(
|
|||
}
|
||||
|
||||
export interface ThreadRagSettings {
|
||||
chunking_strategy: ChunkingStrategy;
|
||||
mode: KBMode;
|
||||
embedding_model: string | null;
|
||||
}
|
||||
|
||||
export interface UpdateThreadRagSettingsRequest {
|
||||
chunking_strategy?: ChunkingStrategy;
|
||||
mode?: KBMode;
|
||||
embedding_model?: string | null;
|
||||
// Only consulted by reingest (not persisted as a thread setting).
|
||||
|
|
@ -366,7 +360,6 @@ export async function reingestThreadDocuments(
|
|||
}
|
||||
|
||||
export interface RagDefaults {
|
||||
chunking_strategy: ChunkingStrategy;
|
||||
mode: KBMode;
|
||||
embedding_model: string | null;
|
||||
}
|
||||
|
|
@ -377,7 +370,6 @@ export async function getRagDefaults(): Promise<RagDefaults> {
|
|||
}
|
||||
|
||||
export interface UpdateRagDefaultsRequest {
|
||||
chunking_strategy?: ChunkingStrategy;
|
||||
mode?: KBMode;
|
||||
embedding_model?: string | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,11 +20,7 @@ import {
|
|||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useEffect, useState } from "react";
|
||||
import type {
|
||||
ChunkingStrategy,
|
||||
KBMode,
|
||||
KnowledgeBase,
|
||||
} from "../api/rag-api";
|
||||
import type { KBMode, KnowledgeBase } from "../api/rag-api";
|
||||
import { useKnowledgeBases } from "../hooks/use-knowledge-bases";
|
||||
import { useRagStore } from "../stores/rag-store";
|
||||
|
||||
|
|
@ -41,16 +37,12 @@ export function KBCreateDialog({
|
|||
const defaults = useRagStore((s) => s.defaults);
|
||||
const loadDefaults = useRagStore((s) => s.loadDefaults);
|
||||
|
||||
const initialStrategy: ChunkingStrategy =
|
||||
defaults?.chunking_strategy ?? "standard";
|
||||
const initialMode: KBMode = defaults?.mode ?? "text";
|
||||
const initialEmbedder = defaults?.embedding_model ?? "";
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [embeddingModel, setEmbeddingModel] = useState(initialEmbedder);
|
||||
const [chunkingStrategy, setChunkingStrategy] =
|
||||
useState<ChunkingStrategy>(initialStrategy);
|
||||
const [mode, setMode] = useState<KBMode>(initialMode);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
|
@ -63,7 +55,6 @@ export function KBCreateDialog({
|
|||
|
||||
useEffect(() => {
|
||||
if (open && defaults) {
|
||||
setChunkingStrategy(defaults.chunking_strategy);
|
||||
setMode(defaults.mode);
|
||||
setEmbeddingModel(defaults.embedding_model ?? "");
|
||||
}
|
||||
|
|
@ -75,22 +66,15 @@ export function KBCreateDialog({
|
|||
setName("");
|
||||
setDescription("");
|
||||
setEmbeddingModel(defaults?.embedding_model ?? "");
|
||||
setChunkingStrategy(defaults?.chunking_strategy ?? "standard");
|
||||
setMode(defaults?.mode ?? "text");
|
||||
setError(null);
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
// Forbid (multimodal, late): disable each side when the other is picked.
|
||||
const lateDisabled = mode === "multimodal";
|
||||
const multimodalDisabled = chunkingStrategy === "late";
|
||||
|
||||
const placeholderEmbedder =
|
||||
mode === "multimodal"
|
||||
? "Defaults to BAAI/BGE-VL-base"
|
||||
: chunkingStrategy === "late"
|
||||
? "Defaults to nomic-ai/nomic-embed-text-v1.5"
|
||||
: "Defaults to BAAI/bge-small-en-v1.5";
|
||||
: "Defaults to BAAI/bge-small-en-v1.5";
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -102,7 +86,6 @@ export function KBCreateDialog({
|
|||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
embedding_model: embeddingModel.trim() || undefined,
|
||||
chunking_strategy: chunkingStrategy,
|
||||
mode,
|
||||
});
|
||||
onCreated?.(kb);
|
||||
|
|
@ -165,15 +148,7 @@ export function KBCreateDialog({
|
|||
<SelectItem value="text">
|
||||
Text only — embed text chunks
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="multimodal"
|
||||
disabled={multimodalDisabled}
|
||||
title={
|
||||
multimodalDisabled
|
||||
? "Multimodal cannot be combined with late chunking"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<SelectItem value="multimodal">
|
||||
Multimodal — also embed images alongside text
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
|
|
@ -182,42 +157,7 @@ export function KBCreateDialog({
|
|||
Multimodal mode extracts figures from your documents and
|
||||
embeds them in a shared text + image vector space (BGE-VL),
|
||||
so retrieval can match visual content. Larger embedder
|
||||
(~1.5 GB VRAM) and cannot be combined with late
|
||||
chunking.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="kb-strategy">Chunking strategy</Label>
|
||||
<Select
|
||||
value={chunkingStrategy}
|
||||
onValueChange={(v) => setChunkingStrategy(v as ChunkingStrategy)}
|
||||
>
|
||||
<SelectTrigger id="kb-strategy">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="standard">
|
||||
Standard — heading-aware recursive splitter
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="late"
|
||||
disabled={lateDisabled}
|
||||
title={
|
||||
lateDisabled
|
||||
? "Late chunking cannot be combined with multimodal mode"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
Late chunking — single-pass embedder, slower ingest
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Late chunking embeds the whole document in one pass, so each
|
||||
chunk vector carries full-document context. Slower to ingest
|
||||
(one forward pass per doc) but improves retrieval on long,
|
||||
cross-referenced text. Cannot be combined with multimodal
|
||||
mode.
|
||||
(~1.5 GB VRAM).
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
|
|
|
|||
|
|
@ -134,7 +134,6 @@ export function KBDetailPanel({
|
|||
wrapping next to the action buttons. */}
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{kb.mode === "multimodal" ? "🖼️ Multimodal · " : ""}
|
||||
{kb.chunking_strategy === "late" ? "⚡ Late · " : ""}
|
||||
Embedder: <code>{kb.embedding_model}</code>
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -50,14 +50,6 @@ export function KBList({
|
|||
<div className="flex min-w-0 flex-col">
|
||||
<span className="flex min-w-0 items-center gap-1.5 truncate text-sm font-medium">
|
||||
<span className="truncate">{kb.name}</span>
|
||||
{kb.chunking_strategy === "late" ? (
|
||||
<span
|
||||
className="shrink-0 rounded-sm bg-amber-500/15 px-1 text-[10px] font-medium text-amber-700 dark:text-amber-300"
|
||||
title="Late chunking enabled"
|
||||
>
|
||||
⚡ Late
|
||||
</span>
|
||||
) : null}
|
||||
{kb.mode === "multimodal" ? (
|
||||
<span
|
||||
className="shrink-0 rounded-sm bg-violet-500/15 px-1 text-[10px] font-medium text-violet-700 dark:text-violet-300"
|
||||
|
|
|
|||
|
|
@ -20,11 +20,7 @@ import {
|
|||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useEffect, useState } from "react";
|
||||
import type {
|
||||
ChunkingStrategy,
|
||||
KBMode,
|
||||
KnowledgeBase,
|
||||
} from "../api/rag-api";
|
||||
import type { KBMode, KnowledgeBase } from "../api/rag-api";
|
||||
import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store";
|
||||
import { useRagStore } from "../stores/rag-store";
|
||||
|
||||
|
|
@ -40,9 +36,6 @@ export function KBReconfigureDialog({
|
|||
documentCount: number;
|
||||
}) {
|
||||
const reingestKB = useRagStore((s) => s.reingestKB);
|
||||
const [chunkingStrategy, setChunkingStrategy] = useState<ChunkingStrategy>(
|
||||
kb.chunking_strategy,
|
||||
);
|
||||
const [mode, setMode] = useState<KBMode>(kb.mode);
|
||||
const [embeddingModel, setEmbeddingModel] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
|
@ -51,28 +44,17 @@ export function KBReconfigureDialog({
|
|||
// Re-sync when the dialog opens against a different KB.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setChunkingStrategy(kb.chunking_strategy);
|
||||
setMode(kb.mode);
|
||||
setEmbeddingModel("");
|
||||
setError(null);
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [open, kb.id, kb.chunking_strategy, kb.mode]);
|
||||
}, [open, kb.id, kb.mode]);
|
||||
|
||||
const lateDisabled = mode === "multimodal";
|
||||
const multimodalDisabled = chunkingStrategy === "late";
|
||||
|
||||
const placeholderEmbedder =
|
||||
mode === "multimodal"
|
||||
? `Current: ${kb.embedding_model}`
|
||||
: chunkingStrategy === "late"
|
||||
? `Current: ${kb.embedding_model}`
|
||||
: `Current: ${kb.embedding_model}`;
|
||||
const placeholderEmbedder = `Current: ${kb.embedding_model}`;
|
||||
|
||||
const changedSettings =
|
||||
chunkingStrategy !== kb.chunking_strategy ||
|
||||
mode !== kb.mode ||
|
||||
embeddingModel.trim() !== "";
|
||||
mode !== kb.mode || embeddingModel.trim() !== "";
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -91,7 +73,6 @@ export function KBReconfigureDialog({
|
|||
setError(null);
|
||||
try {
|
||||
await reingestKB(kb.id, {
|
||||
chunking_strategy: chunkingStrategy,
|
||||
mode,
|
||||
embedding_model: embeddingModel.trim() || undefined,
|
||||
caption_images: useChatRuntimeStore.getState().ragCaptionImages,
|
||||
|
|
@ -110,7 +91,7 @@ export function KBReconfigureDialog({
|
|||
<DialogHeader>
|
||||
<DialogTitle>Reconfigure “{kb.name}”</DialogTitle>
|
||||
<DialogDescription>
|
||||
Change the chunking strategy, mode, or embedder for this KB.
|
||||
Change the mode or embedder for this KB.
|
||||
All {documentCount} document{documentCount === 1 ? "" : "s"}{" "}
|
||||
will be re-ingested from the originals on disk.
|
||||
</DialogDescription>
|
||||
|
|
@ -127,47 +108,12 @@ export function KBReconfigureDialog({
|
|||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="text">Text only</SelectItem>
|
||||
<SelectItem
|
||||
value="multimodal"
|
||||
disabled={multimodalDisabled}
|
||||
title={
|
||||
multimodalDisabled
|
||||
? "Multimodal cannot be combined with late chunking"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<SelectItem value="multimodal">
|
||||
Multimodal — text + images
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="reconf-strategy">Chunking strategy</Label>
|
||||
<Select
|
||||
value={chunkingStrategy}
|
||||
onValueChange={(v) => setChunkingStrategy(v as ChunkingStrategy)}
|
||||
>
|
||||
<SelectTrigger id="reconf-strategy">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="standard">
|
||||
Standard — heading-aware recursive splitter
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="late"
|
||||
disabled={lateDisabled}
|
||||
title={
|
||||
lateDisabled
|
||||
? "Late chunking cannot be combined with multimodal mode"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
Late chunking — single-pass embedder
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="reconf-model">Embedding model (optional)</Label>
|
||||
<Input
|
||||
|
|
@ -177,8 +123,8 @@ export function KBReconfigureDialog({
|
|||
placeholder={placeholderEmbedder}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Leave blank to keep the current model (or pick the matrix
|
||||
default when mode/strategy changes).
|
||||
Leave blank to keep the current model (or pick the default
|
||||
when the mode changes).
|
||||
</p>
|
||||
</div>
|
||||
{error ? (
|
||||
|
|
|
|||
|
|
@ -10,17 +10,15 @@ import {
|
|||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ChunkingStrategy, KBMode } from "../api/rag-api";
|
||||
import type { KBMode } from "../api/rag-api";
|
||||
import { useRagStore } from "../stores/rag-store";
|
||||
|
||||
/** Defaults pre-fill the KB create dialog. Same (multimodal, late) rejection as create. */
|
||||
/** Defaults pre-fill the KB create dialog. */
|
||||
export function RagDefaultsSection() {
|
||||
const defaults = useRagStore((s) => s.defaults);
|
||||
const loadDefaults = useRagStore((s) => s.loadDefaults);
|
||||
const updateDefaults = useRagStore((s) => s.updateDefaults);
|
||||
|
||||
const [chunkingStrategy, setChunkingStrategy] =
|
||||
useState<ChunkingStrategy>("standard");
|
||||
const [mode, setMode] = useState<KBMode>("text");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
|
|
@ -30,16 +28,11 @@ export function RagDefaultsSection() {
|
|||
|
||||
useEffect(() => {
|
||||
if (defaults) {
|
||||
setChunkingStrategy(defaults.chunking_strategy);
|
||||
setMode(defaults.mode);
|
||||
}
|
||||
}, [defaults]);
|
||||
|
||||
const lateDisabled = mode === "multimodal";
|
||||
const multimodalDisabled = chunkingStrategy === "late";
|
||||
|
||||
const persist = (patch: {
|
||||
chunking_strategy?: ChunkingStrategy;
|
||||
mode?: KBMode;
|
||||
embedding_model?: string | null;
|
||||
}) => {
|
||||
|
|
@ -74,46 +67,7 @@ export function RagDefaultsSection() {
|
|||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="text">Text only</SelectItem>
|
||||
<SelectItem
|
||||
value="multimodal"
|
||||
disabled={multimodalDisabled}
|
||||
title={
|
||||
multimodalDisabled
|
||||
? "Multimodal cannot be combined with late chunking"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
Multimodal
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="defaults-strategy">Chunking strategy</Label>
|
||||
<Select
|
||||
value={chunkingStrategy}
|
||||
onValueChange={(v) => {
|
||||
const next = v as ChunkingStrategy;
|
||||
setChunkingStrategy(next);
|
||||
persist({ chunking_strategy: next });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="defaults-strategy">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="standard">Standard</SelectItem>
|
||||
<SelectItem
|
||||
value="late"
|
||||
disabled={lateDisabled}
|
||||
title={
|
||||
lateDisabled
|
||||
? "Late chunking cannot be combined with multimodal mode"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
Late chunking
|
||||
</SelectItem>
|
||||
<SelectItem value="multimodal">Multimodal</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,113 +0,0 @@
|
|||
"""Late chunking tests (Phase 3B-late).
|
||||
|
||||
Pure-python coverage of `chunk_pages_with_spans` runs always. The
|
||||
encoder test loads a small SentenceTransformer and is gated behind the
|
||||
existing `server` marker so default `pytest` runs skip it.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
STUDIO_BACKEND = REPO_ROOT / "studio" / "backend"
|
||||
if str(STUDIO_BACKEND) not in sys.path:
|
||||
sys.path.insert(0, str(STUDIO_BACKEND))
|
||||
|
||||
from core.rag.chunking import chunk_pages_with_spans
|
||||
from core.rag.parsers import ParsedPage
|
||||
|
||||
|
||||
def _wc_counter(text: str) -> int:
|
||||
return max(1, len(text.split()))
|
||||
|
||||
|
||||
def test_spans_index_back_to_full_doc_text():
|
||||
pages = [
|
||||
ParsedPage(text = "# Section A\n\n" + ("alpha " * 20), page_number = 1),
|
||||
ParsedPage(text = "# Section B\n\n" + ("beta " * 20), page_number = 2),
|
||||
]
|
||||
full_doc, chunks, char_spans = chunk_pages_with_spans(
|
||||
pages,
|
||||
max_tokens = 12,
|
||||
overlap_tokens = 0,
|
||||
token_counter = _wc_counter,
|
||||
)
|
||||
assert chunks
|
||||
assert len(chunks) == len(char_spans)
|
||||
for chunk, (start, end) in zip(chunks, char_spans):
|
||||
# Chunk text must equal the full_doc slice it claims.
|
||||
assert full_doc[start:end] == chunk.text
|
||||
|
||||
|
||||
def test_chunks_inherit_page_number_by_overlap():
|
||||
pages = [
|
||||
ParsedPage(text = "page-one text here", page_number = 1),
|
||||
ParsedPage(text = "page-two text here", page_number = 2),
|
||||
]
|
||||
_full_doc, chunks, _spans = chunk_pages_with_spans(
|
||||
pages,
|
||||
max_tokens = 4,
|
||||
overlap_tokens = 0,
|
||||
token_counter = _wc_counter,
|
||||
)
|
||||
pages_seen = {c.page_number for c in chunks}
|
||||
assert pages_seen <= {1, 2}
|
||||
# Each page contributes at least one chunk.
|
||||
assert 1 in pages_seen
|
||||
assert 2 in pages_seen
|
||||
|
||||
|
||||
def test_full_doc_joins_pages_with_blank_line_separator():
|
||||
pages = [
|
||||
ParsedPage(text = "first", page_number = 1),
|
||||
ParsedPage(text = "second", page_number = 2),
|
||||
]
|
||||
full_doc, _chunks, _spans = chunk_pages_with_spans(
|
||||
pages,
|
||||
max_tokens = 5,
|
||||
overlap_tokens = 0,
|
||||
token_counter = _wc_counter,
|
||||
)
|
||||
assert "first" in full_doc
|
||||
assert "second" in full_doc
|
||||
# Pages separated by exactly one blank line.
|
||||
assert "first\n\nsecond" in full_doc
|
||||
|
||||
|
||||
@pytest.mark.server
|
||||
def test_late_chunk_encode_returns_one_vector_per_span():
|
||||
pytest.importorskip("sentence_transformers")
|
||||
pytest.importorskip("torch")
|
||||
# all-MiniLM-L6-v2: ~80MB, 384 dims.
|
||||
import os
|
||||
|
||||
os.environ.setdefault(
|
||||
"UNSLOTH_RAG_EMBEDDING_MODEL",
|
||||
"sentence-transformers/all-MiniLM-L6-v2",
|
||||
)
|
||||
from core.rag import embeddings as embeddings_module
|
||||
|
||||
embeddings_module._model = None # force re-load
|
||||
embeddings_module._model_name = None
|
||||
|
||||
doc_text = (
|
||||
"# Intro\n\n"
|
||||
"The quick brown fox jumps over the lazy dog.\n\n"
|
||||
"# Methods\n\n"
|
||||
"We trained the model on a corpus of 100M tokens.\n\n"
|
||||
"# Results\n\n"
|
||||
"Accuracy improved by 12% over the baseline."
|
||||
)
|
||||
# char_spans: one per section, picked manually.
|
||||
char_spans = [
|
||||
(doc_text.index("The quick"), doc_text.index("\n\n# Methods")),
|
||||
(doc_text.index("We trained"), doc_text.index("\n\n# Results")),
|
||||
(doc_text.index("Accuracy"), len(doc_text)),
|
||||
]
|
||||
vectors = embeddings_module.late_chunk_encode(doc_text, char_spans)
|
||||
assert len(vectors) == len(char_spans)
|
||||
dim = vectors[0].shape[0]
|
||||
for v in vectors:
|
||||
assert v.shape == (dim,)
|
||||
|
|
@ -7,7 +7,6 @@ returns images when asked, route accepts the mode field, constraint
|
|||
validator rejects illegal combos) run in every test invocation.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
|
@ -19,27 +18,6 @@ if str(STUDIO_BACKEND) not in sys.path:
|
|||
sys.path.insert(0, str(STUDIO_BACKEND))
|
||||
|
||||
|
||||
def _rag_route():
|
||||
"""Load ``routes/rag.py`` directly, bypassing the ``routes`` package.
|
||||
|
||||
``from routes.rag import X`` first runs ``routes/__init__.py``, which eagerly
|
||||
imports every router — including the datasets router, whose chain does
|
||||
``from datasets import IterableDataset`` at import time. On a GPU-less CI
|
||||
runner the unsloth bootstrap can leave ``datasets`` half-initialized, so that
|
||||
eager import raises. These tests only need pure helpers from rag.py, so load
|
||||
the file on its own (it has no intra-``routes`` imports).
|
||||
"""
|
||||
mod = sys.modules.get("_rag_route_under_test")
|
||||
if mod is None:
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"_rag_route_under_test", STUDIO_BACKEND / "routes" / "rag.py"
|
||||
)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
sys.modules["_rag_route_under_test"] = mod
|
||||
return mod
|
||||
|
||||
|
||||
def test_html_parser_returns_images_when_requested(tmp_path):
|
||||
pytest.importorskip("bs4")
|
||||
pytest.importorskip("lxml")
|
||||
|
|
@ -74,32 +52,14 @@ def test_html_parser_returns_images_when_requested(tmp_path):
|
|||
assert img.nearest_caption == "A tiny figure"
|
||||
|
||||
|
||||
def test_multimodal_late_combo_validator():
|
||||
from fastapi import HTTPException
|
||||
|
||||
_validate_mode_combo = _rag_route()._validate_mode_combo
|
||||
|
||||
# Allowed combos → None.
|
||||
assert _validate_mode_combo("text", "standard") is None
|
||||
assert _validate_mode_combo("text", "late") is None
|
||||
assert _validate_mode_combo("multimodal", "standard") is None
|
||||
|
||||
# Forbidden combo → 400.
|
||||
with pytest.raises(HTTPException) as excinfo:
|
||||
_validate_mode_combo("multimodal", "late")
|
||||
assert excinfo.value.status_code == 400
|
||||
|
||||
|
||||
def test_rag_embedder_matrix_excludes_multimodal_late():
|
||||
def test_rag_embedder_matrix_is_keyed_by_mode():
|
||||
from utils.rag.config import RAG_EMBEDDER_MATRIX, resolve_embedder
|
||||
|
||||
assert ("multimodal", "late") not in RAG_EMBEDDER_MATRIX
|
||||
assert ("text", "standard") in RAG_EMBEDDER_MATRIX
|
||||
assert ("text", "late") in RAG_EMBEDDER_MATRIX
|
||||
assert ("multimodal", "standard") in RAG_EMBEDDER_MATRIX
|
||||
assert "text" in RAG_EMBEDDER_MATRIX
|
||||
assert "multimodal" in RAG_EMBEDDER_MATRIX
|
||||
|
||||
# Unknown combos fall back to the legacy default, not KeyError.
|
||||
fallback = resolve_embedder("multimodal", "late")
|
||||
# Unknown modes fall back to the default, not KeyError.
|
||||
fallback = resolve_embedder("unknown-mode")
|
||||
assert isinstance(fallback, str) and fallback
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -93,7 +93,6 @@ def test_multimodal_subprocess_emits_image_and_caption_chunks(
|
|||
overlap = 20,
|
||||
batch_size = 4,
|
||||
out_queue = out_queue,
|
||||
chunking_strategy = "standard",
|
||||
mode = "multimodal",
|
||||
document_id = "test-doc-1",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,8 +2,7 @@
|
|||
|
||||
Full end-to-end reingest needs a running studio + a real embedder; that's
|
||||
covered manually via the curl smoke flow in the plan. Here we cover the
|
||||
parts that are testable without external models: payload validation and
|
||||
the (multimodal, late) constraint propagation.
|
||||
parts that are testable without external models: payload validation.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
|
|
@ -43,22 +42,12 @@ def test_reingest_request_accepts_all_optional_fields():
|
|||
ReingestKBRequest = _rag_route().ReingestKBRequest
|
||||
|
||||
empty = ReingestKBRequest()
|
||||
assert empty.chunking_strategy is None
|
||||
assert empty.mode is None
|
||||
assert empty.embedding_model is None
|
||||
|
||||
partial = ReingestKBRequest(chunking_strategy = "late")
|
||||
assert partial.chunking_strategy == "late"
|
||||
assert partial.mode is None
|
||||
|
||||
|
||||
def test_reingest_request_rejects_unknown_strategy():
|
||||
from pydantic import ValidationError
|
||||
|
||||
ReingestKBRequest = _rag_route().ReingestKBRequest
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
ReingestKBRequest(chunking_strategy = "telekinetic")
|
||||
partial = ReingestKBRequest(mode = "multimodal")
|
||||
assert partial.mode == "multimodal"
|
||||
assert partial.embedding_model is None
|
||||
|
||||
|
||||
def test_reingest_request_rejects_unknown_mode():
|
||||
|
|
@ -68,14 +57,3 @@ def test_reingest_request_rejects_unknown_mode():
|
|||
|
||||
with pytest.raises(ValidationError):
|
||||
ReingestKBRequest(mode = "augmented")
|
||||
|
||||
|
||||
def test_constraint_still_enforced_for_reingest_combos():
|
||||
"""The combination guard is shared with create — verify it still bites."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
_validate_mode_combo = _rag_route()._validate_mode_combo
|
||||
|
||||
with pytest.raises(HTTPException) as excinfo:
|
||||
_validate_mode_combo("multimodal", "late")
|
||||
assert excinfo.value.status_code == 400
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue