Extending rag-titan
Every part works the same way:
- create a class that inherits from the part's base class
- write the methods the base class asks for
- (optional) add one
if name == ...line infactory.pyso it can be chosen from settings
Example: a new chunker
from rag_titan.chunking import BaseChunker
class LineChunker(BaseChunker):
"""Splits text into groups of lines (for logs, CSV-like text, poems)."""
def __init__(self, lines_per_chunk=20):
self.lines_per_chunk = lines_per_chunk
def split_text(self, text):
"""Return pieces of `lines_per_chunk` non-empty lines each."""
lines = [line for line in text.splitlines() if line.strip()]
pieces = []
for start in range(0, len(lines), self.lines_per_chunk):
group = lines[start:start + self.lines_per_chunk]
pieces.append("\n".join(group))
return pieces
Then register it in factory.py:
def build_chunker(settings):
name = settings["chunker"]
if name == "word":
return WordChunker(settings["chunk_size"], settings["chunk_overlap"])
if name == "line":
return LineChunker()
...
and set CHUNKER=line in .env.
Where chunkers live
Chunkers are grouped by family, one sub-folder each, following the chunking handbook. A new family gets its folder when its first chunker is added.
chunking/
├── base_chunker.py # BaseChunker, shared by every family
├── packing.py # pack(): whole pieces up to a size, whole-piece overlap
├── fixed_size/ # characters, words, tokens, recursive separators
│ ├── fixed_windows.py # fixed_windows(), count_windows()
│ ├── word_chunker.py
│ ├── character_chunker.py
│ ├── token_chunker.py
│ └── recursive_chunker.py
└── structure_aware/ # sentences, paragraphs, heading sections, pages
├── base.py # PieceChunker: cut into pieces, then pack()
├── sentences.py # split_sentences(), split_paragraphs()
├── sentence_chunker.py
├── paragraph_chunker.py
├── heading_chunker.py
└── page_chunker.py
A chunker that keeps whole units can inherit PieceChunker and only write
pieces(text); size checks, packing and overlap come for free. The line chunker
above is a structure-aware idea, so it would go in chunking/structure_aware/.
Export it from that folder's __init__.py and from chunking/__init__.py, so
users can write from rag_titan.chunking import LineChunker.
Your chunker gets start_offset / end_offset (and page_numbers,
section_path) for free: BaseChunker searches for each chunk in the text, in
order. If your chunker knows its exact positions, override
split_spans(text) to return (chunk_text, start, end) for each chunk; that
is exact even in very repetitive text.
What each base class needs
| Base class | Methods to write |
|---|---|
BaseLLM |
chat(messages, tools), tool_result_message() |
BaseLoader |
extensions list and extract_text(path) |
BaseChunker |
split_text(text) |
BaseEmbedder |
embed_many(texts) |
BaseStore |
add(), search(), clear(), count() |
BaseRetriever |
name, retrieve(query, top_k), has_documents() |
BaseReranker |
score(query, match) |
BaseGenerator |
generate(query, chunks) |
BaseRAG |
FLOW, run(query) (answer() comes for free) |
Example: any function as a model
FunctionLLM and FunctionEmbedder turn a plain function into a model, so any
API works without writing a class. The RAG playground uses them to talk to your
local Ollama from the browser.
from rag_titan.embeddings import FunctionEmbedder
from rag_titan.llm import FunctionLLM
def reply(messages):
"""messages: [{"role": ..., "content": ...}, ...] -> the reply text"""
return my_api.chat(messages)
def embed(texts):
"""texts: list of strings -> one list of numbers per text"""
return my_api.embed(texts)
llm = FunctionLLM(reply, model="my-api")
embedder = FunctionEmbedder(embed, model="my-api-embed")
Example: a new retriever
A retriever returns chunk dicts with a score, best first. Call
self._check_top_k(top_k) first and pass the results through
self._with_trace(), which adds "retriever" and "score_trace":
from rag_titan.retrieval import BaseRetriever
class TitleRetriever(BaseRetriever):
"""Finds chunks whose file name contains a word of the question."""
name = "title"
def __init__(self, chunks):
self.chunks = chunks
def has_documents(self):
return len(self.chunks) > 0
def retrieve(self, query, top_k=5):
self._check_top_k(top_k)
words = query.lower().split()
matches = []
for chunk in self.chunks:
score = sum(word in chunk["source"].lower() for word in words)
if score:
matches.append({**chunk, "score": float(score)})
matches.sort(key=lambda match: match["score"], reverse=True)
return self._with_trace(matches[:top_k])
Any BaseRetriever works in every RAG type, and you can measure it against the
others with compare_retrievers().
Adding a new RAG type
- create
src/rag_titan/rag_types/my_rag.pywith a class that inheritsBaseRAG - describe its steps in
FLOW: one node per step (id,label, andkind:search,llmoroutput) and an edge for every arrow, loops included - write
run(query): record every step withtrace.record("retrieve", ...)and end withtrace.result(answer, sources);answer()comes for free - add it to
RAG_TYPESandbuild_rag()infactory.py - export it in
rag_types/__init__.py - add tests in
tests/(useFakeLLMandFakeEmbedderfromconftest.py).tests/test_traces.pyalready checks every type inRAG_TYPES: it fails whenFLOWis missing or a run takes a step that has no arrow - measure it against the other RAG types with evaluation
- document it in
docs/rag-types.md -
record the RAG playground examples again, so your type shows up there with its diagram (no JavaScript to write):
python tools/playground/record_traces.py # needs Ollama, about a minutetests/test_playground.pyreplays the recording and fails until you do this. Do the same after changing a prompt, a chunker or a RAG type.
from rag_titan.rag_types import BaseRAG, Trace
class OneShotRAG(BaseRAG):
"""Search once and answer with the best chunk only."""
FLOW = {
"nodes": [{"id": "retrieve", "label": "Retrieve", "kind": "search"},
{"id": "generate", "label": "Generate answer", "kind": "output"}],
"edges": [["retrieve", "generate"]],
}
def __init__(self, retriever, generator):
self.retriever = retriever
self.generator = generator
def run(self, query):
trace = Trace()
chunks = trace.record("retrieve", self.retriever.retrieve, query=query, top_k=1)
answer = trace.record("generate", self.generator.generate, query=query, chunks=chunks)
return trace.result(answer, chunks)
Code style
- keep functions short (under about 50 lines) and files under about 200 lines
- no type hints, plain dicts and lists
- a docstring for every class and public method
- comments explain why, not what