You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
A unified HTTP interface for financial news ingestion and search, combining REST endpoints for pipeline management with SSE endpoints for MCP protocol communication.
Unified HTTP API: REST endpoints for pipelines, SSE for MCP protocol
Async job management: Redis-backed job tracking with progress updates
Cloud-ready: Designed for Railway deployment with Qdrant Cloud
Quick Start
# Start all services (Qdrant, Redis, MCP Server)
docker-compose up -d
# Check services are running
docker-compose ps
# API docs: http://localhost:8000/docs# Health check: http://localhost:8000/health
The Docker Compose automatically starts:
Qdrant - Vector database for embeddings
Redis - Job management and caching
MCP Server - Unified HTTP API (REST + SSE)
API Overview
Pipeline Management
# RSS pipeline
POST /api/pipelines/rss/run
{
"regions": ["usa", "eu"],
"stocks": ["AAPL", "MSFT"],
"cleanup_days": 30
}
# API pipeline
POST /api/pipelines/api/run
{
"tickers": ["AAPL", "GOOGL"],
"cleanup_days": 30
}
# Job status
GET /api/jobs/{job_id}
fromsharedimportRSSPipeline, APIPipeline, SearchClient# Run RSS pipeline - vocabularies are automatically saved to the storeRSSPipeline(regions=["usa", "spain"]).run()
# Run API pipeline - vocabularies are automatically saved to the storeAPIPipeline(tickers=["AAPL", "NVDA", "MSFT"]).run()
# Search - vocabularies are automatically loaded from the storeclient=SearchClient()
# Hybrid search with keyword boosts and date filterresults=client.search(
query="NVIDIA earnings report",
mode="hybrid",
keywords=["NVIDIA", "earnings"],
keyword_boosts={"NVIDIA": 2.0},
published_after="2025-01-01", # Filter by date
)
Auto-loading Vocabularies: Vocabularies are automatically loaded from the store per collection
BM25 Scoring: Uses rank_bm25 library for proper Okapi BM25 term weighting
Date Filtering: Filter results by published_after date
Keyword Match Requirement: Returns empty results when explicit keywords don't match any documents
Search Example
fromsharedimportSearchClient# Vocabularies are automatically loaded from the store - no manual loading needed!client=SearchClient()
# Simple semantic searchresults=client.search("tech company earnings", mode="simple")
# Hybrid search - vocabulary is auto-loaded for the target collectionresults=client.search(
query="NVIDIA GPU demand",
mode="hybrid",
keywords=["NVIDIA", "GPU", "demand"], # Optional: explicit keywordskeyword_boosts={"NVIDIA": 2.0}, # Optional: boost specific termspublished_after="2025-01-01", # Optional: filter by date
)
# Search specific collection (vocabulary auto-loaded per collection)results=client.search("Federal Reserve", collection="financial_rss_news", mode="hybrid")
results=client.search("Apple iPhone", collection="financial_api_news", mode="hybrid")
# Search with date filterfromdatetimeimportdatetime, timedeltaone_week_ago= (datetime.now() -timedelta(days=7)).strftime("%Y-%m-%d")
results=client.search("market news", published_after=one_week_ago, mode="hybrid")
# Force reload vocabulary from store (e.g., after pipeline update)client.clear_vocabulary_cache()
MCP Server
The system includes an MCP (Model Context Protocol) server for AI agent integration, plus REST API endpoints.
Running the Server
# SSE transport (recommended for web clients)
python -m mcp_server.sse_server --host 0.0.0.0 --port 8000
# STDIO transport (for local MCP clients like Claude Desktop)
python -m mcp_server.server
Available Tools
Tool
Description
Use Case
get_portfolio_news
Strict ticker matching - only returns articles explicitly mentioning tickers
Monitoring specific holdings
get_market_insights
Semantic search with keyword hints - finds related news even without exact matches
Discovering sector trends, risks, opportunities
REST API Endpoints
When running the SSE server:
POST /api/portfolio-news - Get news for portfolio tickers
POST /api/market-insights - Get broader market insights
Larger chunks (800-1000): Better context, fewer chunks, may miss specific details
Smaller chunks (256-400): More precise retrieval, more storage, potential context loss
More overlap (100-150): Better continuity, more redundancy
Less overlap (20-30): Less redundancy, potential boundary issues
Qdrant Configuration
Variable
Default
Description
QDRANT_HOST
localhost
Qdrant server hostname
QDRANT_PORT
6333
Qdrant server port
QDRANT_API_KEY
None
API key for Qdrant Cloud
QDRANT_DISTANCE_METRIC
cosine
Distance metric (cosine, dot, euclid)
Distance Metrics:
Metric
Formula
Best For
cosine
1 - cos(a,b)
Recommended - normalized embeddings
dot
-a·b
When magnitude matters
euclid
||a-b||
Absolute distances
Search Configuration
Variable
Default
Description
QDRANT_SEARCH_HNSW_EF
128
HNSW search expansion factor
QDRANT_SEARCH_EXACT
false
Use exact search (slower, more accurate)
QDRANT_RESCORING_ENABLED
true
Enable quantization rescoring
QDRANT_RESCORING_OVERSAMPLING
2.0
Oversampling factor for rescoring
SEARCH_LIMIT_DEFAULT
10
Default number of results
SEARCH_DEDUPLICATE
true
Enable result deduplication
SEARCH_SIMILARITY_THRESHOLD
0.85
Text similarity threshold for dedup
┌──────────────────────────────────────────────────────────────────────────┐
│ HNSW SEARCH PARAMETERS │
└──────────────────────────────────────────────────────────────────────────┘
HNSW_EF (Expansion Factor):
Lower (64) ──────────────────────────────────────▶ Higher (256)
├── Faster search ├── Slower search
├── Less accurate ├── More accurate
└── Lower recall └── Higher recall
Recommended:
• Development: 64-128
• Production: 128-256
• High accuracy needs: 256-512
Hybrid Search Configuration
Variable
Default
Description
QDRANT_USE_HYBRID
true
Enable hybrid search capability
QDRANT_HYBRID_ALPHA
0.5
Balance between dense and sparse
LEXICAL_K1
1.5
BM25 term frequency saturation
LEXICAL_B
0.75
BM25 length normalization
VOCABULARY_STORE_BACKEND
redis
Storage backend (redis or json)
VOCABULARY_STORE_PATH
artifacts/vocabularies
Directory for JSON vocabulary storage
Redis Configuration
Variable
Default
Description
REDIS_HOST
localhost
Redis server hostname
REDIS_PORT
6379
Redis server port
REDIS_DB
0
Redis database number
REDIS_PASSWORD
None
Redis password (optional)
REDIS_VOCAB_KEY_PREFIX
vocab:
Key prefix for vocabulary data
┌──────────────────────────────────────────────────────────────────────────┐
│ REDIS VOCABULARY STORAGE │
└──────────────────────────────────────────────────────────────────────────┘
Each collection uses 3 Redis Hash keys:
┌─────────────────────────────────┬─────────────────────────────────────┐
│ Key │ Purpose │
├─────────────────────────────────┼─────────────────────────────────────┤
│ vocab:{collection}:meta │ BM25 metadata │
│ │ Fields: doc_count, avg_doc_len, │
│ │ lowercase, k1, b │
├─────────────────────────────────┼─────────────────────────────────────┤
│ vocab:{collection}:tokens │ Token → ID mapping │
│ │ Each token is a field, │
│ │ value is its integer ID │
├─────────────────────────────────┼─────────────────────────────────────┤
│ vocab:{collection}:idf │ Token → IDF score │
│ │ Each token is a field, │
│ │ value is its float IDF score │
└─────────────────────────────────┴─────────────────────────────────────┘
Example for collection "financial_api_news":
vocab:financial_api_news:meta → {"doc_count": "1000", "avg_doc_len": "45.2", ...}
vocab:financial_api_news:tokens → {"nvidia": "0", "earnings": "1", "apple": "2", ...}
vocab:financial_api_news:idf → {"nvidia": "3.45", "earnings": "1.23", "apple": "2.89", ...}
Benefits:
• O(1) single token lookups via HGET (no full vocab load)
• Incremental updates possible
• Shared across distributed services
• Memory-efficient for large vocabularies
┌──────────────────────────────────────────────────────────────────────────┐
│ HYBRID ALPHA TUNING │
└──────────────────────────────────────────────────────────────────────────┘
alpha = 0.0 alpha = 0.5 alpha = 1.0
┌─────────┐ ┌─────────┐ ┌─────────┐
│ 100% │ │ 50% │ │ 0% │
│ Sparse │ │ Dense + │ │ Dense │
│ (BM25) │ │ 50% │ │ only │
│ │ │ Sparse │ │ │
└─────────┘ └─────────┘ └─────────┘
│ │ │
▼ ▼ ▼
Best for exact Best for balanced Best for semantic
keyword matching retrieval (default) similarity only
BM25 Parameters:
Parameter
Effect of Increasing
Typical Range
k1
More weight to term frequency
1.2 - 2.0
b
More penalty for long documents
0.5 - 1.0
Pipeline Configuration
Variable
Default
Description
PIPELINE_BATCH_SIZE
100
Batch size for Qdrant upserts
PIPELINE_CLEANUP_DAYS
90
Delete documents older than N days
RSS_COLLECTION
financial_rss_news
RSS collection name
API_COLLECTION
financial_api_news
API collection name
Logging Configuration
Variable
Default
Description
LOG_LEVEL
INFO
Logging level (DEBUG, INFO, WARNING, ERROR)
LOG_FORMAT
json
Log format (json, text)
Module Reference
Pipelines
fromsharedimportRSSPipeline, APIPipeline# RSS Pipelinerss=RSSPipeline(
# Fetch filtersregions=["usa", "spain"], # Filter by regions (None = all)tiers=["tier1", "tier2"], # Filter by source tiers (None = all)tickers=["AAPL", "NVDA"], # Generate stock-specific feedscompany_names=["Apple", "NVIDIA"], # Generate company-specific feedsmax_age_hours=24, # Max article age# Base pipeline optionscollection="my_rss_collection", # Override collection namebatch_size=200, # Override batch sizecleanup_days=30, # Override cleanup periodrecreate_collection=False, # Recreate if schema changes
)
result=rss.run(cleanup=True) # run() only accepts cleanup flag# API Pipelineapi=APIPipeline(
# Fetch filterstickers=["AAPL", "NVDA", "TSLA"], # Tickers to fetchproviders=["yahoo"], # Providers to usedays_back=7, # Days of historymax_articles_per_ticker=10, # Limit per ticker# Base pipeline optionscollection="my_api_collection", # Override collection name
)
result=api.run() # cleanup=True by default# Check available providersprint(APIPipeline.get_available_providers()) # {"yahoo": "yahoo_finance"}
Search Client
fromsharedimportSearchClient# Vocabularies are automatically loaded from the storeclient=SearchClient()
# Basic search - vocabulary auto-loaded for the collectionresults=client.search(
query="NVIDIA earnings",
collection="financial_api_news", # Target collectionmode="hybrid", # "simple" or "hybrid"limit=20, # Max resultsdeduplicate=True, # Remove duplicates
)
# With keyword boostingresults=client.search(
query="tech earnings report",
mode="hybrid",
keywords=["NVIDIA", "earnings"], # Explicit keywordskeyword_boosts={"NVIDIA": 2.0}, # Boost NVIDIA matches
)
# With filtersresults=client.search(
query="market analysis",
filters={"source_tier": "tier1"}, # Qdrant payload filters
)
# Clear vocabulary cache to force reload from storeclient.clear_vocabulary_cache()
client.clear_vocabulary_cache("financial_api_news") # Clear specific collection
Lexical Vocabulary
fromsharedimportLexicalVocabulary, get_vocabulary_store# Build from documentsdocuments= ["Article 1 text...", "Article 2 text..."]
vocab=LexicalVocabulary.build(
documents=documents,
lowercase=True,
min_freq=2, # Min document frequencyk1=1.5, # BM25 k1b=0.75, # BM25 b
)
# Save/Load via VocabularyStore (recommended)store=get_vocabulary_store()
store.save_vocabulary("my_collection", vocab)
vocab=store.get_vocabulary("my_collection")
# Check if vocabulary existsifstore.exists("my_collection"):
vocab=store.get_vocabulary("my_collection")
# Generate sparse vectorsparse=vocab.to_sparse_vector(
keywords=["nvidia", "earnings"],
boosts={"nvidia": 2.0},
l2_normalize=True,
)
Vocabulary Store
fromsharedimportget_vocabulary_store, set_vocabulary_storefromsharedimportJSONVocabularyStore, RedisVocabularyStore# Get the default store (uses VOCABULARY_STORE_BACKEND config, defaults to Redis)store=get_vocabulary_store()
# Explicitly request a specific backendstore=get_vocabulary_store(backend="redis") # Redis backendstore=get_vocabulary_store(backend="json") # JSON file backend# Store operations (same interface for both backends)store.save_vocabulary("collection_name", vocab) # Savevocab=store.get_vocabulary("collection_name") # Loadexists=store.exists("collection_name") # Check existencestore.delete("collection_name") # Delete# Redis-specific: efficient partial lookups (no full vocab load)ifisinstance(store, RedisVocabularyStore):
token_id=store.get_token_id("collection_name", "nvidia")
token_idf=store.get_token_idf("collection_name", "nvidia")
# Batch lookup for multiple tokensbatch=store.get_tokens_batch("collection_name", ["nvidia", "earnings"])
# List all collectionscollections=store.list_collections()
# Use a custom Redis connectioncustom_store=RedisVocabularyStore(
host="redis.example.com",
port=6379,
password="secret",
key_prefix="myapp:vocab:",
)
set_vocabulary_store(custom_store)
# Use JSON file backend with custom pathjson_store=JSONVocabularyStore(base_dir="/path/to/vocabs")
set_vocabulary_store(json_store)
Configuration Access
fromsharedimportConfig# Read current valuesprint(Config.EMBEDDING_MODEL)
print(Config.QDRANT_HOST)
# Reload from environmentConfig.reload()
# Get all as dictprint(Config.as_dict())
Adding New API Providers
The system uses a ProviderRegistry pattern for extensibility:
# 1. Create a new provider in financial_news_api_feed/providers/fromfinancial_news_api_feed.base_providerimportBaseNewsProviderfromfinancial_news_api_feed.modelsimportNewsArticleclassAlphaVantageProvider(BaseNewsProvider):
defget_provider_name(self) ->str:
return"alpha_vantage"deffetch_ticker_news(self, ticker: str, max_results=None, days_back=7):
# Implementation herepassdeffetch_multiple_tickers(self, tickers, max_results=None, days_back=7):
# Implementation herepass# 2. Register in financial_news_api_feed/providers/__init__.pyfrom ..provider_registryimportProviderRegistryProviderRegistry.register('alpha_vantage', AlphaVantageProvider)
# 3. Add mapping in shared/pipelines/api.py _get_provider()provider_map= {
"yahoo": "yahoo_finance",
"alphavantage": "alpha_vantage", # Add this
}
# 4. Use in pipelineAPIPipeline(providers=["yahoo", "alphavantage"]).run()
Example: Complete Workflow
importosfromsharedimportRSSPipeline, APIPipeline, SearchClient, Config# 1. Configure via environment (optional)os.environ["EMBEDDING_MODEL"] ="sentence-transformers/all-mpnet-base-v2"os.environ["QDRANT_HOST"] ="localhost"Config.reload()
# 2. Run RSS pipeline - vocabulary automatically saved to storeprint("Running RSS pipeline...")
rss_result=RSSPipeline(
regions=["usa"],
tiers=["tier1", "tier2"],
max_age_hours=24,
).run()
print(f"RSS: {rss_result['articles_fetched']} articles, {rss_result['chunks_stored']} chunks")
# 3. Run API pipeline - vocabulary automatically saved to storeprint("Running API pipeline...")
api_result=APIPipeline(
tickers=["AAPL", "NVDA", "MSFT", "GOOGL"],
days_back=7,
).run()
print(f"API: {api_result['articles_fetched']} articles, {api_result['chunks_stored']} chunks")
# 4. Search - vocabulary automatically loaded from storeprint("Searching...")
client=SearchClient()
results=client.search(
query="NVIDIA AI chip demand",
mode="hybrid",
keywords=["NVIDIA", "AI"],
keyword_boosts={"NVIDIA": 2.0},
limit=5,
)
forrinresults:
print(f" [{r['score']:.3f}] {r['metadata'].get('article_title', 'N/A')[:60]}...")
Troubleshooting
Common Issues
Issue
Cause
Solution
ModuleNotFoundError
Missing dependency
pip install sentence-transformers qdrant-client
Connection refused
Qdrant not running
Start Qdrant: docker run -p 6333:6333 qdrant/qdrant
Dimension mismatch
Changed embedding model
Set recreate_collection=True or delete collection
No results
Empty collection
Run pipeline first
Hybrid search fallback
No lexical vocab
Run pipeline first to generate vocabulary
Redis connection refused
Redis not running
Start Redis: docker run -p 6379:6379 redis
redis package not found
Missing dependency
pip install redis
Performance Tips
Use GPU for embeddings: Set EMBEDDING_DEVICE=cuda
Batch size tuning: Increase PIPELINE_BATCH_SIZE for faster ingestion
HNSW tuning: Lower QDRANT_SEARCH_HNSW_EF for faster (less accurate) search
Disable rescoring: Set QDRANT_RESCORING_ENABLED=false for speed
License
MIT License
About
Financial data feed RAG system with MCP server for streameable HTTP