rag-visualizer / backend /engines /chunking.py
Vasanth6's picture
Restore Phase 5 advanced RAG changes and clean schemas.py
f35d149
Raw
History Blame Contribute Delete
7.92 kB
import math
import tiktoken
from typing import List
from nltk.tokenize import sent_tokenize
import numpy as np
from backend.engines.embedding import EmbeddingEngine
from langchain_text_splitters import (
RecursiveCharacterTextSplitter,
NLTKTextSplitter,
CharacterTextSplitter,
)
from backend.models.schemas import ChunkConfig, ChunkNode
def count_token(text: str, tokenizer) -> int:
_encoder = tiktoken.get_encoding(tokenizer)
return len(_encoder.encode(text))
def fixed_size_strategy(text, config: ChunkConfig) -> List[ChunkNode]:
splitter = CharacterTextSplitter.from_tiktoken_encoder(
encoding_name=config.tokenizer,
chunk_size=config.chunk_size,
chunk_overlap=config.chunk_overlap,
)
result = splitter.split_text(text)
result = construct_chunk_node(text, result, config.tokenizer)
return result
def sentence_strategy(text, config: ChunkConfig) -> List[ChunkNode]:
text_splitter = NLTKTextSplitter.from_tiktoken_encoder(
encoding_name=config.tokenizer,
chunk_size=config.chunk_size,
chunk_overlap=config.chunk_overlap,
)
result = text_splitter.split_text(text)
result = construct_chunk_node(text, result, config.tokenizer)
return result
def recursive_strategy(text, config: ChunkConfig) -> List[ChunkNode]:
splitter = RecursiveCharacterTextSplitter.from_tiktoken_encoder(
encoding_name=config.tokenizer,
chunk_size=config.chunk_size,
chunk_overlap=config.chunk_overlap,
separators=config.separators,
)
result = splitter.split_text(text)
result = construct_chunk_node(text, result, config.tokenizer)
return result
def parent_child_strategy(text, config) -> List[ChunkNode]:
parent_splitter = RecursiveCharacterTextSplitter.from_tiktoken_encoder(
encoding_name=config.tokenizer,
chunk_size=config.parent_chunk_size,
chunk_overlap=config.parent_chunk_overlap,
separators=config.separators,
)
parent_chunks = parent_splitter.split_text(text)
result = construct_parent_child_nodes(text, parent_chunks, config)
return result
def construct_chunk_node(text, chunks, tokenizer):
nodes = []
current_position = 0
for i, chunk in enumerate(chunks):
# 1. Try exact find first
start = text.find(chunk, current_position)
# 2. If exact find fails, try a clean stripped version
if start == -1:
clean_anchor = chunk.strip()[:40]
if clean_anchor:
start = text.find(clean_anchor, current_position)
# 3. If it still fails, park it at current_position
if start == -1:
start = current_position
end = start + len(chunk)
node = ChunkNode(
id=f"chunk_{i}",
order=i,
text=chunk,
token_count=count_token(chunk, tokenizer),
start_char=start,
end_char=end,
)
nodes.append(node)
# Safely advance position but allow overlaps
current_position = max(current_position, start + 1)
return nodes
def construct_parent_child_nodes(text, parent_chunks, config):
all_nodes = []
current_position = 0
for parent_index, parent_chunk in enumerate(parent_chunks):
parent_start = text.find(parent_chunk, current_position)
parent_end = parent_start + len(parent_chunk)
parent_id = f"parent_{parent_index}"
parent_node = ChunkNode(
id=parent_id,
order=parent_index,
text=parent_chunk,
token_count=count_token(parent_chunk, config.tokenizer),
start_char=parent_start,
end_char=parent_end,
level=0,
child_ids=[],
)
all_nodes.append(parent_node)
child_splitter = RecursiveCharacterTextSplitter.from_tiktoken_encoder(
encoding_name=config.tokenizer,
chunk_size=config.child_chunk_size,
chunk_overlap=config.child_chunk_overlap,
separators=config.separators,
)
child_chunks = child_splitter.split_text(parent_chunk)
child_position = parent_start
for child_index, child_chunk in enumerate(child_chunks):
child_start = text.find(child_chunk, child_position)
child_end = child_start + len(child_chunk)
child_id = f"{parent_id}_child_{child_index}"
child_node = ChunkNode(
id=child_id,
order=child_index,
text=child_chunk,
token_count=count_token(child_chunk, config.tokenizer),
start_char=child_start,
end_char=child_end,
level=1,
parent_id=parent_id,
)
parent_node.child_ids.append(child_id)
all_nodes.append(child_node)
child_position = child_start + 1
current_position = parent_start + 1
return all_nodes
def cosine_similarity(v1, v2):
dot_product = sum(x * y for x, y in zip(v1, v2))
norm_v1 = math.sqrt(sum(x * x for x in v1))
norm_v2 = math.sqrt(sum(x * x for x in v2))
if not norm_v1 or not norm_v2:
return 0.0
return dot_product / (norm_v1 * norm_v2)
async def semantic_strategy(text, config: ChunkConfig, embedding_model):
sentences = sent_tokenize(text)
embedding_engine = EmbeddingEngine(embedding_model)
if not sentences:
return []
temp_nodes = [
ChunkNode(
id=f"temp_{i}", order=i, text=s, token_count=0, start_char=0, end_char=0
)
for i, s in enumerate(sentences)
]
sentence_embeddings = await embedding_engine.generate_embeddings(temp_nodes)
similarities = []
for i in range(len(sentence_embeddings) - 1):
sim = cosine_similarity(sentence_embeddings[i], sentence_embeddings[i + 1])
similarities.append(sim)
distances = [1 - s for s in similarities]
if not distances:
return construct_chunk_node(text, sentences, config.tokenizer)
mean_distance = np.mean(distances)
std_deviation = np.std(distances)
z_score_multiplier = 2.5 - (config.semantic_threshold * 3.0)
dynamic_threshold = mean_distance + (z_score_multiplier * std_deviation)
chunks = []
current_chunks = [sentences[0]]
for i in range(len(distances)):
current_dist = distances[i]
if current_dist > dynamic_threshold:
is_greater_than_prev = (i == 0) or (current_dist > distances[i - 1])
is_greater_than_or_equal_next = (i == len(distances) - 1) or (
current_dist >= distances[i + 1]
)
if is_greater_than_prev and is_greater_than_or_equal_next:
chunks.append(" ".join(current_chunks))
current_chunks = [sentences[i + 1]]
else:
current_chunks.append(sentences[i + 1])
else:
current_chunks.append(sentences[i + 1])
if current_chunks:
chunks.append(" ".join(current_chunks))
return construct_chunk_node(text, chunks, config.tokenizer)
class ChunkingEngine:
async def chunk(self, text, strategy, config, embedding_model) -> List[ChunkNode]:
strategy_func = self.available_strategy(strategy)
if not strategy_func:
raise ValueError(f"Unknown strategy: {strategy}")
if strategy == "semantic":
return await strategy_func(text, config, embedding_model)
return strategy_func(text, config)
def available_strategy(self, strategy):
STRATEGY = {
"fixed_size": fixed_size_strategy,
"sentence": sentence_strategy,
"recursive": recursive_strategy,
"parent_child": parent_child_strategy,
"semantic": semantic_strategy,
}
return STRATEGY.get(strategy)