Skip to content

Commit 156e124

Browse files
authored
feat: add Chainlit frontend (#33)
1 parent 76f2f1b commit 156e124

File tree

15 files changed

+1142
-111
lines changed

15 files changed

+1142
-111
lines changed

.cruft.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"cookiecutter": {
77
"project_type": "package",
88
"project_name": "RAGLite",
9-
"project_description": "A Python package for Retrieval-Augmented Generation (RAG) with SQLite or PostgreSQL.",
9+
"project_description": "A Python toolkit for Retrieval-Augmented Generation (RAG) with SQLite or PostgreSQL.",
1010
"project_url": "https://github.com/superlinear-ai/raglite",
1111
"author_name": "Laurent Sorber",
1212
"author_email": "laurent@superlinear.eu",
@@ -26,4 +26,4 @@
2626
}
2727
},
2828
"directory": null
29-
}
29+
}

.gitignore

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
# Chainlit
2+
.chainlit/
3+
.files/
4+
chainlit.md
5+
16
# Coverage.py
27
htmlcov/
38
reports/
@@ -19,7 +24,7 @@ data/
1924
# dotenv
2025
.env
2126

22-
# Rerankers
27+
# rerankers
2328
.*_cache/
2429

2530
# Hypothesis
@@ -57,6 +62,10 @@ dist/
5762
__pycache__/
5863
*.py[cdo]
5964

65+
# RAGLite
66+
*.db
67+
*.sqlite
68+
6069
# Ruff
6170
.ruff_cache/
6271

README.md

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
# 🥤 RAGLite
44

5-
RAGLite is a Python package for Retrieval-Augmented Generation (RAG) with PostgreSQL or SQLite.
5+
RAGLite is a Python toolkit for Retrieval-Augmented Generation (RAG) with PostgreSQL or SQLite.
66

77
## Features
88

@@ -27,6 +27,7 @@ RAGLite is a Python package for Retrieval-Augmented Generation (RAG) with Postgr
2727

2828
##### Extensible
2929

30+
- 💬 Optional customizable ChatGPT-like frontend for [web](https://docs.chainlit.io/deploy/copilot), [Slack](https://docs.chainlit.io/deploy/slack), and [Teams](https://docs.chainlit.io/deploy/teams) with [Chainlit](https://github.com/Chainlit/chainlit)
3031
- ✍️ Optional conversion of any input document to Markdown with [Pandoc](https://github.com/jgm/pandoc)
3132
- ✅ Optional evaluation of retrieval and generation performance with [Ragas](https://github.com/explodinggradients/ragas)
3233

@@ -60,6 +61,12 @@ Finally, install RAGLite with:
6061
pip install raglite
6162
```
6263

64+
To add support for a customizable ChatGPT-like frontend, use the `chainlit` extra:
65+
66+
```sh
67+
pip install raglite[chainlit]
68+
```
69+
6370
To add support for filetypes other than PDF, use the `pandoc` extra:
6471

6572
```sh
@@ -81,6 +88,7 @@ pip install raglite[ragas]
8188
3. [Searching and Retrieval-Augmented Generation (RAG)](#3-searching-and-retrieval-augmented-generation-rag)
8289
4. [Computing and using an optimal query adapter](#4-computing-and-using-an-optimal-query-adapter)
8390
5. [Evaluation of retrieval and generation](#5-evaluation-of-retrieval-and-generation)
91+
6. [Serving a customizable ChatGPT-like frontend](#6-serving-a-customizable-chatgpt-like-frontend)
8492

8593
### 1. Configuring RAGLite
8694

@@ -166,9 +174,9 @@ from raglite import retrieve_chunks
166174
chunks_hybrid = retrieve_chunks(chunk_ids_hybrid, config=my_config)
167175

168176
# Rerank chunks:
169-
from raglite import rerank
177+
from raglite import rerank_chunks
170178

171-
chunks_reranked = rerank(prompt, chunks_hybrid, config=my_config)
179+
chunks_reranked = rerank_chunks(prompt, chunks_hybrid, config=my_config)
172180

173181
# Answer questions with RAG:
174182
from raglite import rag
@@ -208,6 +216,33 @@ answered_evals_df = answer_evals(num_evals=10, config=my_config)
208216
evaluation_df = evaluate(answered_evals_df, config=my_config)
209217
```
210218

219+
### 6. Serving a customizable ChatGPT-like frontend
220+
221+
If you installed the `chainlit` extra, you can serve a customizable ChatGPT-like frontend with:
222+
223+
```sh
224+
raglite chainlit
225+
```
226+
227+
The application is also deployable to [web](https://docs.chainlit.io/deploy/copilot), [Slack](https://docs.chainlit.io/deploy/slack), and [Teams](https://docs.chainlit.io/deploy/teams).
228+
229+
You can specify the database URL, LLM, and embedder directly in the Chainlit frontend, or with the CLI as follows:
230+
231+
```sh
232+
raglite chainlit \
233+
--db_url sqlite:///raglite.sqlite \
234+
--llm llama-cpp-python/bartowski/Llama-3.2-3B-Instruct-GGUF/*Q4_K_M.gguf@4096 \
235+
--embedder llama-cpp-python/lm-kit/bge-m3-gguf/*F16.gguf
236+
```
237+
238+
To use an API-based LLM, make sure to include your credentials in a `.env` file or supply them inline:
239+
240+
```sh
241+
OPENAI_API_KEY=sk-... raglite chainlit --llm gpt-4o-mini --embedder text-embedding-3-large
242+
```
243+
244+
<div align="center"><video src="https://github.com/user-attachments/assets/01cf98d3-6ddd-45bb-8617-cf290c09f187" /></div>
245+
211246
## Contributing
212247

213248
<details>

poetry.lock

Lines changed: 713 additions & 44 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ build-backend = "poetry.core.masonry.api"
55
[tool.poetry] # https://python-poetry.org/docs/pyproject/
66
name = "raglite"
77
version = "0.1.4"
8-
description = "A Python package for Retrieval-Augmented Generation (RAG) with SQLite or PostgreSQL."
8+
description = "A Python toolkit for Retrieval-Augmented Generation (RAG) with SQLite or PostgreSQL."
99
authors = ["Laurent Sorber <laurent@superlinear.eu>"]
1010
readme = "README.md"
1111
repository = "https://github.com/superlinear-ai/raglite"
@@ -37,7 +37,7 @@ llama-cpp-python = ">=0.2.88"
3737
pydantic = ">=2.7.0"
3838
# Approximate Nearest Neighbors:
3939
pynndescent = ">=0.5.12"
40-
# Reranking
40+
# Reranking:
4141
langdetect = ">=1.0.9"
4242
rerankers = { extras = ["flashrank"], version = ">=0.5.3" }
4343
# Storage:
@@ -48,8 +48,13 @@ tqdm = ">=4.66.0"
4848
# Evaluation:
4949
pandas = ">=2.1.0"
5050
ragas = { version = ">=0.1.12", optional = true }
51+
# CLI:
52+
typer = ">=0.12.5"
53+
# Frontend:
54+
chainlit = { version = ">=1.2.0", optional = true }
5155

5256
[tool.poetry.extras] # https://python-poetry.org/docs/pyproject/#extras
57+
chainlit = ["chainlit"]
5358
pandoc = ["pypandoc-binary"]
5459
ragas = ["ragas"]
5560

@@ -76,6 +81,9 @@ matplotlib = ">=3.9.0"
7681
memory-profiler = ">=0.61.0"
7782
pdoc = ">=14.4.0"
7883

84+
[tool.poetry.scripts] # https://python-poetry.org/docs/pyproject/#scripts
85+
raglite = "raglite:cli"
86+
7987
[tool.coverage.report] # https://coverage.readthedocs.io/en/latest/config.html#report
8088
fail_under = 50
8189
precision = 1
@@ -104,7 +112,7 @@ show_error_context = true
104112
warn_unreachable = true
105113

106114
[tool.pytest.ini_options] # https://docs.pytest.org/en/latest/reference/reference.html#ini-options-ref
107-
addopts = "--color=yes --doctest-modules --exitfirst --failed-first --strict-config --strict-markers --verbosity=2 --junitxml=reports/pytest.xml"
115+
addopts = "--color=yes --exitfirst --failed-first --strict-config --strict-markers --verbosity=2 --junitxml=reports/pytest.xml"
108116
filterwarnings = ["error", "ignore::DeprecationWarning", "ignore::pytest.PytestUnraisableExceptionWarning"]
109117
testpaths = ["src", "tests"]
110118
xfail_strict = true

src/raglite/__init__.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
"""RAGLite."""
22

3+
from raglite._cli import cli
34
from raglite._config import RAGLiteConfig
45
from raglite._eval import answer_evals, evaluate, insert_evals
56
from raglite._insert import insert_document
67
from raglite._query_adapter import update_query_adapter
7-
from raglite._rag import rag
8+
from raglite._rag import async_rag, rag
89
from raglite._search import (
910
hybrid_search,
1011
keyword_search,
11-
rerank,
12+
rerank_chunks,
1213
retrieve_chunks,
1314
retrieve_segments,
1415
vector_search,
@@ -25,13 +26,16 @@
2526
"vector_search",
2627
"retrieve_chunks",
2728
"retrieve_segments",
28-
"rerank",
29+
"rerank_chunks",
2930
# RAG
31+
"async_rag",
3032
"rag",
3133
# Query adapter
3234
"update_query_adapter",
3335
# Evaluate
3436
"insert_evals",
3537
"answer_evals",
3638
"evaluate",
39+
# CLI
40+
"cli",
3741
]

src/raglite/_chainlit.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
"""Chainlit frontend for RAGLite."""
2+
3+
import os
4+
from pathlib import Path
5+
6+
import chainlit as cl
7+
from chainlit.input_widget import Switch, TextInput
8+
9+
from raglite import (
10+
RAGLiteConfig,
11+
async_rag,
12+
hybrid_search,
13+
insert_document,
14+
rerank_chunks,
15+
retrieve_chunks,
16+
)
17+
from raglite._markdown import document_to_markdown
18+
19+
async_insert_document = cl.make_async(insert_document)
20+
async_hybrid_search = cl.make_async(hybrid_search)
21+
async_retrieve_chunks = cl.make_async(retrieve_chunks)
22+
async_rerank_chunks = cl.make_async(rerank_chunks)
23+
24+
25+
@cl.on_chat_start
26+
async def start_chat() -> None:
27+
"""Initialize the chat."""
28+
# Disable tokenizes parallelism to avoid the deadlock warning.
29+
os.environ["TOKENIZERS_PARALLELISM"] = "false"
30+
# Add Chainlit settings with which the user can configure the RAGLite config.
31+
default_config = RAGLiteConfig()
32+
config = RAGLiteConfig(
33+
db_url=os.environ.get("RAGLITE_DB_URL", default_config.db_url),
34+
llm=os.environ.get("RAGLITE_LLM", default_config.llm),
35+
embedder=os.environ.get("RAGLITE_EMBEDDER", default_config.embedder),
36+
)
37+
settings = await cl.ChatSettings( # type: ignore[no-untyped-call]
38+
[
39+
TextInput(id="db_url", label="Database URL", initial=str(config.db_url)),
40+
TextInput(id="llm", label="LLM", initial=config.llm),
41+
TextInput(id="embedder", label="Embedder", initial=config.embedder),
42+
Switch(id="vector_search_query_adapter", label="Query adapter", initial=True),
43+
]
44+
).send()
45+
await update_config(settings)
46+
47+
48+
@cl.on_settings_update # type: ignore[arg-type]
49+
async def update_config(settings: cl.ChatSettings) -> None:
50+
"""Update the RAGLite config."""
51+
# Update the RAGLite config given the Chainlit settings.
52+
config = RAGLiteConfig(
53+
db_url=settings["db_url"], # type: ignore[index]
54+
llm=settings["llm"], # type: ignore[index]
55+
embedder=settings["embedder"], # type: ignore[index]
56+
vector_search_query_adapter=settings["vector_search_query_adapter"], # type: ignore[index]
57+
)
58+
cl.user_session.set("config", config) # type: ignore[no-untyped-call]
59+
# Run a search to prime the pipeline if it's a local pipeline.
60+
# TODO: Don't do this for SQLite once we switch from PyNNDescent to sqlite-vec.
61+
if str(config.db_url).startswith("sqlite") or config.embedder.startswith("llama-cpp-python"):
62+
# async with cl.Step(name="initialize", type="retrieval"):
63+
query = "Hello world"
64+
chunk_ids, _ = await async_hybrid_search(query=query, config=config)
65+
_ = await async_rerank_chunks(query=query, chunk_ids=chunk_ids, config=config)
66+
67+
68+
@cl.on_message
69+
async def handle_message(user_message: cl.Message) -> None:
70+
"""Respond to a user message."""
71+
# Get the config and message history from the user session.
72+
config: RAGLiteConfig = cl.user_session.get("config") # type: ignore[no-untyped-call]
73+
# Determine what to do with the attachments.
74+
inline_attachments = []
75+
for file in user_message.elements:
76+
if file.path:
77+
doc_md = document_to_markdown(Path(file.path))
78+
if len(doc_md) // 3 <= 5 * (config.chunk_max_size // 3):
79+
# Document is small enough to attach to the context.
80+
inline_attachments.append(f"{Path(file.path).name}:\n\n{doc_md}")
81+
else:
82+
# Document is too large and must be inserted into the database.
83+
async with cl.Step(name="insert", type="run") as step:
84+
step.input = Path(file.path).name
85+
await async_insert_document(Path(file.path), config=config)
86+
# Append any inline attachments to the user prompt.
87+
user_prompt = f"{user_message.content}\n\n" + "\n\n".join(
88+
f'<attachment index="{i}">\n{attachment.strip()}\n</attachment>'
89+
for i, attachment in enumerate(inline_attachments)
90+
)
91+
# Search for relevant contexts for RAG.
92+
async with cl.Step(name="search", type="retrieval") as step:
93+
step.input = user_message.content
94+
chunk_ids, _ = await async_hybrid_search(query=user_prompt, num_results=10, config=config)
95+
chunks = await async_retrieve_chunks(chunk_ids=chunk_ids, config=config)
96+
step.output = chunks
97+
step.elements = [ # Show the top 3 chunks inline.
98+
cl.Text(content=str(chunk), display="inline") for chunk in chunks[:3]
99+
]
100+
# Rerank the chunks.
101+
async with cl.Step(name="rerank", type="rerank") as step:
102+
step.input = chunks
103+
chunks = await async_rerank_chunks(query=user_prompt, chunk_ids=chunks, config=config)
104+
step.output = chunks
105+
step.elements = [ # Show the top 3 chunks inline.
106+
cl.Text(content=str(chunk), display="inline") for chunk in chunks[:3]
107+
]
108+
# Stream the LLM response.
109+
assistant_message = cl.Message(content="")
110+
async for token in async_rag(
111+
prompt=user_prompt,
112+
search=chunks,
113+
messages=cl.chat_context.to_openai()[-5:], # type: ignore[no-untyped-call]
114+
config=config,
115+
):
116+
await assistant_message.stream_token(token)
117+
await assistant_message.update() # type: ignore[no-untyped-call]

src/raglite/_cli.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""RAGLite CLI."""
2+
3+
import os
4+
5+
import typer
6+
7+
from raglite._config import RAGLiteConfig
8+
9+
cli = typer.Typer()
10+
11+
12+
@cli.callback()
13+
def main() -> None:
14+
"""RAGLite CLI."""
15+
16+
17+
@cli.command()
18+
def chainlit(
19+
db_url: str = typer.Option(RAGLiteConfig().db_url, help="Database URL"),
20+
llm: str = typer.Option(RAGLiteConfig().llm, help="LiteLLM LLM"),
21+
embedder: str = typer.Option(RAGLiteConfig().embedder, help="LiteLLM embedder"),
22+
) -> None:
23+
"""Serve a Chainlit frontend."""
24+
# Set the environment variables for the Chainlit frontend.
25+
os.environ["RAGLITE_DB_URL"] = os.environ.get("RAGLITE_DB_URL", db_url)
26+
os.environ["RAGLITE_LLM"] = os.environ.get("RAGLITE_LLM", llm)
27+
os.environ["RAGLITE_EMBEDDER"] = os.environ.get("RAGLITE_EMBEDDER", embedder)
28+
# Import Chainlit here as it's an optional dependency.
29+
try:
30+
from chainlit.cli import run_chainlit
31+
except ImportError as error:
32+
error_message = "To serve a Chainlit frontend, please install the `chainlit` extra."
33+
raise ImportError(error_message) from error
34+
# Serve the frontend.
35+
run_chainlit(__file__.replace("_cli.py", "_chainlit.py"))
36+
37+
38+
if __name__ == "__main__":
39+
cli()

src/raglite/_config.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@ class RAGLiteConfig:
5151
default_factory=lambda: (
5252
("en", FlashRankRanker("ms-marco-MiniLM-L-12-v2", verbose=0)),
5353
("other", FlashRankRanker("ms-marco-MultiBERT-L-12", verbose=0)),
54-
)
54+
),
55+
compare=False, # Exclude the reranker from comparison to avoid lru_cache misses.
5556
)
5657

5758
def __post_init__(self) -> None:

0 commit comments

Comments
 (0)