Skip to main contentLuca Imbalzano's logo

fin-qdrant-rag

Finance RAG chatbot — hybrid Redis/Qdrant memory, query amplification, and strategy-gated long-term recall over trading PDFs.

fin-qdrant-rag

Preface

fin-qdrant-rag is a Retrieval-Augmented Generation stack for finance and trading PDFs. You upload books and reports, they get chunked and embedded into Qdrant, and a FastAPI chat layer answers questions with retrieved document context plus conversation memory.

The interesting part is not “call an LLM with a vector search.” It is how several specialized roles cooperate on every turn: expand the query, retrieve in parallel, fall back when recall is empty, rerank, then decide what is worth keeping in long-term memory.

The problem

Plain chat models do not know your PDFs. A single embedding search also fails often:

  • Users ask the same idea with different wording
  • Session chatter pollutes the prompt if you dump the whole history
  • Important insights get lost when Redis TTL expires
  • Naïve “store everything in the vector DB” creates noise and cost

This project splits short-term speed, long-term semantics, and document knowledge into separate stores, then orchestrates them like a small crew of specialists on each message.

Product preview

Chat UI on the assistant surface — upload PDFs, ask finance questions, keep session context warm.

fin-qdrant-rag finance assistant cover
fin-qdrant-rag finance assistant cover
Finance assistant chat UI preview
Finance assistant chat UI preview

Multi-agent orchestration

I did not bolt on a heavy agent framework. Instead I shaped the request path as cooperating specialists with clear contracts — closer to a production multi-agent layout than a single monolith function.

User message


┌───────────────────┐
│  Chat / RAG hub   │  ChatService → RAGService
└─────────┬─────────┘


┌───────────────────┐
│ HybridMemory      │  Facade that dispatches to specialists
│ Manager           │
└─────────┬─────────┘

    ┌─────┼──────────────────────────────┐
    ▼     ▼                              ▼
 Redis   Strategy crew              Amplification crew
 short-  Conversation / Insight /   Sub-questions → parallel
 term    Risk scorers               search → keyword fallback
         (long-term gate)           → LLM rerank over PDFs

Amplification crew (document recall)

When a question hits the knowledge base, one role rewrites it into sub-questions, several roles search in parallel, a fallback role extracts keywords if vectors miss, and a final role reranks chunks with a score threshold before they enter the prompt.

Memory strategy crew (what to remember)

After the reply, Conversation / Insight / Risk strategies score the exchange. Only turns above an importance threshold are promoted from Redis into the Qdrant conversations collection — so long-term memory stays curated instead of becoming a dump.

Folder structure

Clear separation: src/core/ owns memory and clients, src/features/ owns HTTP and services, docker/ brings up Postgres, Redis, Qdrant, API, and the React UI.

src
main.py
core
hybrid_memory_manager.py
memory_strategy.py
qdrant_client.py
openai_client.py
redis_memory_manager.py
utils/
features
endpoints
chat.py
upload.py
services
rag_service.py
chat_service.py
models/
database/
tests/
frontend
src
components/
hooks/useChat
services/api.ts
public/
docker
docker-compose.yml
Dockerfile.api
Dockerfile.frontend
data/md/
Makefile
pyproject.toml

Architecture

End-to-end path: React hits /chat and /upload; the API fans out to Redis (short-term), Qdrant (conversations + pdf_documents), Postgres (persist), and OpenAI for embed / amplify / answer.

fin-qdrant-rag schema — upload pipeline, hybrid context, Redis, Qdrant, Postgres, and OpenAI
fin-qdrant-rag schema — upload pipeline, hybrid context, Redis, Qdrant, Postgres, and OpenAI
┌──────────────┐   HTTP    ┌─────────────────────┐
│  React chat  │ ────────► │  FastAPI · RAG hub  │
│  Vite · hooks│           │  Chat + upload APIs │
└──────────────┘           └──────────┬──────────┘

         ┌────────────────────────────┼────────────────────────────┐
         ▼                            ▼                            ▼
   ┌───────────┐               ┌────────────┐               ┌────────────┐
   │ Redis     │               │ PostgreSQL │               │ Qdrant     │
   │ short-term│               │ chat rows  │               │ pdf_docs + │
   │ TTL list  │               │            │               │ conversations│
   └───────────┘               └────────────┘               └──────┬─────┘

                                                            ┌──────▼─────┐
                                                            │  OpenAI    │
                                                            │ embed/chat │
                                                            │ sub-Q/rerank│
                                                            └────────────┘

Two Qdrant collections keep concerns clean: pdf_documents for ingested knowledge, conversations for curated long-term dialogue memory.

Hybrid memory

Every prompt can pull three layers:

LayerStoreJob
Short-termRedis list + TTLRecent turns — fast, session-scoped
Long-termQdrant conversationsOnly high-importance exchanges
KnowledgeQdrant pdf_documentsChunked PDFs via amplification crew

HybridMemoryManager is the facade: one call assembles short-term text, similar long-term memories, and amplified PDF chunks into a single context block for the chat model.

Under the hood

Three geeky pieces worth reading: parallel amplification, strategy-gated storage, and dual collection factories.

Query amplification with parallel retrieval

Sub-questions fan out with asyncio.gather, results are deduped, then an LLM reranker drops weak chunks below the threshold.

src/core/hybrid_memory_manager.py
async def amplify_pdf_context(
    self, user_message: str, pdf_limit: int = 5, rerank_threshold: float = 0.5
):
    sub_questions = await self.openai_client.generate_sub_questions(user_message, n=3)

    async def fetch_chunks(query):
        embedding = (await self.openai_client.get_embeddings([query]))[0]
        return await self.pdf_memory.search_similar_memories(
            query_embedding=embedding,
            user_id=None,
            limit=pdf_limit,
        )

    results = await asyncio.gather(*(fetch_chunks(q) for q in sub_questions))

    # dedupe → keyword fallback if empty → OpenAI rerank with threshold
    ...
    if all_chunks:
        all_chunks = await self.openai_client.rerank_chunks_with_threshold(
            user_message, all_chunks, threshold=rerank_threshold
        )
    return all_chunks

Strategy crew decides what becomes long-term memory

Each strategy votes with should_store + an importance score. The factory keeps the best score; only values above 0.5 promote a turn into Qdrant.

src/core/memory_strategy.py
@classmethod
def evaluate_content(cls, content: str, metadata: Dict[str, Any]) -> Dict[str, Any]:
    results = {}
    max_score = 0.0
    best_strategy = None

    for strategy in cls.get_all_strategies():
        strategy_name = strategy.get_memory_type()
        should_store = strategy.should_store(content, metadata)
        importance_score = strategy.get_importance_score(content, metadata)

        results[strategy_name] = {
            "should_store": should_store,
            "importance_score": importance_score,
            "memory_type": strategy.get_memory_type(),
        }

        if should_store and importance_score > max_score:
            max_score = importance_score
            best_strategy = strategy_name

    return {
        "strategies": results,
        "should_store_in_long_term": max_score > 0.5,
        "best_strategy": best_strategy,
        "overall_importance": max_score,
    }

Dual Qdrant collections from one client

Factory constructors keep PDF knowledge and conversation memory on separate collections without duplicating client logic.

src/core/qdrant_client.py
@classmethod
def for_pdfs(cls, qdrant_url: str = None):
    return cls(collection_name="pdf_documents", qdrant_url=qdrant_url)

@classmethod
def for_conversations(cls, qdrant_url: str = None):
    return cls(collection_name="conversations", qdrant_url=qdrant_url)

Features

Multi-role retrieval

Sub-questions, parallel search, keyword fallback, and LLM rerank on every document recall.

Hybrid memory

Redis for the live session, Qdrant for curated long-term turns and PDF knowledge.

Strategy-gated storage

Conversation / Insight / Risk scorers decide what survives beyond TTL.

Dual vector collections

pdf_documents and conversations stay isolated behind one Qdrant client API.

PDF ingest pipeline

Upload → extract → chunk → embed → index, ready for chat in the same stack.

Docker Compose stack

Postgres, Redis, Qdrant, FastAPI, and the React chat UI from one compose file.

Tech stack

Makefile targets, compose services, and the full memory design notes live in the repository.

If you care about retrieval that behaves like a small specialist crew — expand, search, fall back, rerank, then remember selectively — this is the project where hybrid memory stops being a buzzword and becomes the runtime path.