Studio: add RAG with hybrid search, reranker, chat integration
Backend (studio/backend/): - core/rag/: parsers (PDF/TXT/MD/DOCX/HTML via pypdf/python-docx/bs4), recursive token-aware chunker, embeddings singleton via FastSentenceTransformer.from_pretrained(for_inference=True), Qdrant local vector store, bm25s lexical index, RRF hybrid retrieval, spawn-subprocess ingestion job with SSE progress, optional CrossEncoder reranker (off-by-default). - routes/rag.py: KB CRUD, doc upload (KB + per-thread), doc list/delete, ingestion SSE, hybrid+rerank search, thread-index list/clear. - routes/chat_history.py: purge thread RAG artifacts on thread delete and clear-all (rag_documents has no FK cascade to chat_threads so uploads work on un-persisted threads). - studio.db gains 4 RAG tables; storage_roots gains rag_*() helpers. - auth/authentication.py: get_current_subject_sse accepts ?token=... so EventSource can stream ingestion progress. Frontend (studio/frontend/): - features/rag/: api client, Zustand store, hooks, dropzone, KB list, doc rows, ingestion-progress, thread-index list components. - Settings dialog gains a Knowledge Bases tab (master/detail + thread documents list); /knowledge-bases deep-links to it. - features/chat/: per-thread ragSource/enableRerank/ragTopK state in chat-runtime-store; Retrieval section in chat-settings-sheet with KB DropdownMenu (active highlight + per-row trash), thread doc list with Clear-thread-index button, RAG Top K slider, reranker toggle; chat-adapter retrieves before /v1/chat/completions and injects hits as a system block; shared-composer + button routes documents into pendingDocs (auto-uploads, send blocked while indexing).
This commit is contained in:
parent
83b20976f7
commit
92994e8b83
51 changed files with 4527 additions and 6 deletions
84
tests/python/test_rag_chunking.py
Normal file
84
tests/python/test_rag_chunking.py
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
"""Unit tests for RAG chunking — pure-python, no external deps."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
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, chunk_pages
|
||||
from core.rag.parsers import ParsedPage
|
||||
|
||||
|
||||
def _wc_counter(text: str) -> int:
|
||||
return max(1, len(text.split()))
|
||||
|
||||
|
||||
def test_chunk_pages_splits_long_text():
|
||||
text = "Lorem ipsum dolor sit amet. " * 200
|
||||
chunks = chunk_pages(
|
||||
[ParsedPage(text = text)],
|
||||
max_tokens = 50,
|
||||
overlap_tokens = 5,
|
||||
token_counter = _wc_counter,
|
||||
)
|
||||
assert len(chunks) > 1
|
||||
for chunk in chunks:
|
||||
assert _wc_counter(chunk.text) <= 55 # max + small slack from atomic split granularity
|
||||
|
||||
|
||||
def test_chunk_pages_short_text_is_one_chunk():
|
||||
text = "Just a short sentence."
|
||||
chunks = chunk_pages(
|
||||
[ParsedPage(text = text)],
|
||||
max_tokens = 50,
|
||||
overlap_tokens = 5,
|
||||
token_counter = _wc_counter,
|
||||
)
|
||||
assert len(chunks) == 1
|
||||
assert chunks[0].text == text
|
||||
|
||||
|
||||
def test_chunk_pages_preserves_page_numbers():
|
||||
chunks = chunk_pages(
|
||||
[
|
||||
ParsedPage(text = "Page one content here.", page_number = 1),
|
||||
ParsedPage(text = "Page two content here.", page_number = 2),
|
||||
],
|
||||
max_tokens = 50,
|
||||
overlap_tokens = 0,
|
||||
token_counter = _wc_counter,
|
||||
)
|
||||
page_numbers = {c.page_number for c in chunks}
|
||||
assert page_numbers == {1, 2}
|
||||
|
||||
|
||||
def test_chunk_pages_no_empty_chunks():
|
||||
text = "\n\n\n\n\nReal content\n\n\n\n\n"
|
||||
chunks = chunk_pages(
|
||||
[ParsedPage(text = text)],
|
||||
max_tokens = 50,
|
||||
overlap_tokens = 0,
|
||||
token_counter = _wc_counter,
|
||||
)
|
||||
for chunk in chunks:
|
||||
assert chunk.text.strip()
|
||||
|
||||
|
||||
def test_chunk_pages_overlap_produces_repeated_tokens():
|
||||
# Build a list of unique numbered sentences so we can detect overlap.
|
||||
sentences = [f"sentence-{i}" for i in range(40)]
|
||||
text = " ".join(sentences)
|
||||
chunks = chunk_pages(
|
||||
[ParsedPage(text = text)],
|
||||
max_tokens = 10,
|
||||
overlap_tokens = 4,
|
||||
token_counter = _wc_counter,
|
||||
)
|
||||
if len(chunks) >= 2:
|
||||
first_tail_words = set(chunks[0].text.split()[-4:])
|
||||
second_head_words = set(chunks[1].text.split()[:4])
|
||||
# At least one word should appear in both
|
||||
assert first_tail_words & second_head_words
|
||||
Loading…
Add table
Add a link
Reference in a new issue