sharmaaryan's picture
Embedded Qdrant vector store (no external cluster)
a8bc5d0
Raw
History Blame Contribute Delete
10.7 kB
"""Embed parsed chunks with Cohere embed-v3 and upsert into Qdrant.
Pipeline:
data/processed/*.jsonl (Chunk objects)
──▢ Cohere embed-v3 (input_type=search_document)
──▢ Qdrant upsert into `finrag_chunks` collection
Idempotent: re-running overwrites existing points by ID (deterministic hash).
"""
from __future__ import annotations
import json
import sys
import time
from collections.abc import Iterable, Iterator
from pathlib import Path
from typing import TypeVar
import cohere
from cohere.errors import TooManyRequestsError
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, PointStruct, VectorParams
from finrag.config import REPO_ROOT, settings
from finrag.ingestion.parse import PROCESSED_DIR, Chunk
# ── Constants ─────────────────────────────────────────────────────────────
# Cohere v3 is the asymmetric retrieval model. 1024-dim output, English.
# Use `embed-multilingual-v3.0` if you want cross-language support; same dim.
COHERE_MODEL = "embed-english-v3.0"
EMBED_DIM = 1024
COLLECTION_NAME = "finrag_chunks"
# Cohere caps `texts=[...]` at 96 per request. Larger requests get 400'd.
COHERE_BATCH_SIZE = 96
# Qdrant upsert is fine with much larger batches; 256 keeps memory bounded
# while amortizing the HTTP overhead.
QDRANT_BATCH_SIZE = 256
# Pacing for Cohere calls. Trial keys have two limits:
# - 100 calls/min (call-based)
# - 100k tokens/min (token-based) ← this is the binding constraint at our chunk size
# At ~300 tokens/chunk Γ— 96 chunks/batch β‰ˆ 29k tokens/batch. Steady-state, that
# means we can do roughly 3 batches per rolling minute. 20s base sleep gives us
# margin; bursts above that get caught by the retry handler below.
COHERE_SLEEP_SECONDS = 20.0
COHERE_RETRY_INITIAL_BACKOFF_SECONDS = 30.0
COHERE_MAX_RETRIES = 5
# ── Clients ───────────────────────────────────────────────────────────────
def make_qdrant_client() -> QdrantClient:
"""Build the Qdrant client for both ingestion and retrieval.
Embedded mode (settings.qdrant_path set): an in-process, on-disk Qdrant β€” no
server, nothing external to wipe. This is what the public image ships, with
the store baked in next to DuckDB/BM25. Otherwise: a remote client at
settings.qdrant_url (dev docker-compose or a managed cluster).
Note on embedded mode: the on-disk store is locked to a single client per
process, so the lru_cached retrieval client (one per FastAPI worker) is the
intended access pattern; don't open a second concurrent client on the path.
"""
if settings.qdrant_path:
path = Path(settings.qdrant_path)
if not path.is_absolute():
path = REPO_ROOT / path
path.mkdir(parents=True, exist_ok=True)
return QdrantClient(path=str(path))
return QdrantClient(url=settings.qdrant_url, api_key=settings.qdrant_api_key)
# ── Helpers ───────────────────────────────────────────────────────────────
T = TypeVar("T")
def _force_utf8_stdout() -> None:
"""Make progress output (which uses ↳/⚠ glyphs) safe on any console.
Windows consoles default to cp1252, which can't encode those characters and
raises UnicodeEncodeError mid-run β€” crashing the upload after the collection
is created but before any points land (a silent empty-collection state). The
Docker image avoids this via PYTHONIOENCODING=utf-8; this makes a bare local
`python -m finrag.ingestion.embed` match it without needing the env var.
"""
for stream in (sys.stdout, sys.stderr):
reconfigure = getattr(stream, "reconfigure", None)
if reconfigure is not None:
try:
reconfigure(encoding="utf-8")
except (ValueError, OSError): # redirected/non-reconfigurable stream
pass
def _batched(seq: Iterable[T], n: int) -> Iterator[list[T]]:
"""Yield lists of size `n` from `seq`. Last batch may be shorter."""
buf: list[T] = []
for x in seq:
buf.append(x)
if len(buf) == n:
yield buf
buf = []
if buf:
yield buf
def _read_all_chunks(processed_dir: Path) -> list[Chunk]:
chunks: list[Chunk] = []
for jsonl in sorted(processed_dir.glob("*.jsonl")):
for line in jsonl.read_text(encoding="utf-8").splitlines():
if line.strip():
chunks.append(Chunk.model_validate_json(line))
return chunks
def _ensure_collection(qdrant: QdrantClient, name: str, dim: int) -> None:
"""Create the collection if it doesn't exist. No-op otherwise.
Note: we *don't* recreate the collection on schema mismatch β€” that would
destroy data. If you change EMBED_DIM, delete the collection manually
via the dashboard or `qdrant.delete_collection(name)`.
"""
existing = {c.name for c in qdrant.get_collections().collections}
if name in existing:
return
print(f"Creating Qdrant collection '{name}' (dim={dim}, distance=cosine)")
qdrant.create_collection(
collection_name=name,
vectors_config=VectorParams(size=dim, distance=Distance.COSINE),
)
def _embed_batch(co: cohere.ClientV2, texts: list[str]) -> list[list[float]]:
"""Embed a batch of texts with the document-side encoder.
The `input_type="search_document"` here is the asymmetric-retrieval flag.
The matching `search_query` lives in the /query endpoint (Decision 7).
Mixing the two destroys retrieval quality silently.
"""
response = co.embed(
texts=texts,
model=COHERE_MODEL,
input_type="search_document",
embedding_types=["float"],
)
# V2 response shape: response.embeddings.float_ is list[list[float]]
return response.embeddings.float_
def _embed_batch_with_retry(
co: cohere.ClientV2, texts: list[str]
) -> list[list[float]]:
"""Wrap _embed_batch with exponential backoff on 429 rate-limit errors.
Trial keys can hit either the call limit or the token limit; both surface
as TooManyRequestsError. We don't bother distinguishing β€” the right
response is the same: wait, then retry.
"""
backoff = COHERE_RETRY_INITIAL_BACKOFF_SECONDS
for attempt in range(1, COHERE_MAX_RETRIES + 1):
try:
return _embed_batch(co, texts)
except TooManyRequestsError:
if attempt == COHERE_MAX_RETRIES:
raise
print(
f" ⚠ rate-limited; sleeping {backoff:.0f}s "
f"(retry {attempt}/{COHERE_MAX_RETRIES - 1})"
)
time.sleep(backoff)
backoff *= 2
# Unreachable β€” loop either returns or raises.
raise RuntimeError("retry loop exited without resolving")
def _chunk_to_point(chunk: Chunk, vector: list[float]) -> PointStruct:
"""Convert a Chunk + its vector into a Qdrant point.
Point ID: convert our 16-hex chunk_id to uint64. Qdrant only accepts
UUID or unsigned int IDs, not arbitrary strings. The hex→int conversion
preserves determinism (same chunk β†’ same ID across runs).
"""
point_id = int(chunk.chunk_id, 16)
return PointStruct(
id=point_id,
vector=vector,
payload=chunk.model_dump(),
)
# ── Core ──────────────────────────────────────────────────────────────────
def embed_and_upsert(chunks: list[Chunk], qdrant: QdrantClient | None = None) -> int:
"""Embed all chunks and upsert into Qdrant. Returns count of points written.
Accepts an optional client so the CLI can build one and reuse it for the
post-run count β€” embedded (on-disk) mode locks the store to a single client,
so opening a second one would fail.
"""
co = cohere.ClientV2(api_key=settings.cohere_api_key)
qdrant = qdrant or make_qdrant_client()
_ensure_collection(qdrant, COLLECTION_NAME, EMBED_DIM)
qdrant_buf: list[PointStruct] = []
total_written = 0
for batch_idx, batch in enumerate(_batched(chunks, COHERE_BATCH_SIZE), start=1):
texts = [c.text for c in batch]
print(f" ↳ embed batch {batch_idx} ({len(batch)} chunks)")
vectors = _embed_batch_with_retry(co, texts)
if len(vectors) != len(batch):
# Cohere should always return one vector per input; fail loud if not.
raise RuntimeError(
f"Cohere returned {len(vectors)} vectors for {len(batch)} inputs"
)
for chunk, vector in zip(batch, vectors):
qdrant_buf.append(_chunk_to_point(chunk, vector))
if len(qdrant_buf) >= QDRANT_BATCH_SIZE:
qdrant.upsert(collection_name=COLLECTION_NAME, points=qdrant_buf)
total_written += len(qdrant_buf)
print(f" ↳ upsert {len(qdrant_buf):4d} points (total {total_written})")
qdrant_buf = []
time.sleep(COHERE_SLEEP_SECONDS)
# Flush remaining points (final partial batch)
if qdrant_buf:
qdrant.upsert(collection_name=COLLECTION_NAME, points=qdrant_buf)
total_written += len(qdrant_buf)
print(f" ↳ upsert {len(qdrant_buf):4d} points (total {total_written}, tail)")
return total_written
# ── CLI ───────────────────────────────────────────────────────────────────
def main() -> None:
_force_utf8_stdout()
chunks = _read_all_chunks(PROCESSED_DIR)
print(f"Loaded {len(chunks)} chunks from {PROCESSED_DIR}\n")
if not chunks:
print("No chunks found. Run `finrag.ingestion.parse` first.")
return
qdrant = make_qdrant_client()
written = embed_and_upsert(chunks, qdrant=qdrant)
# Verify final state via Qdrant's own count (reusing the same client, so
# embedded-mode's single-client lock isn't violated).
info = qdrant.get_collection(COLLECTION_NAME)
target = "embedded " + str(settings.qdrant_path) if settings.qdrant_path else settings.qdrant_url
print(
f"\nDone. {written} points written this run. "
f"Collection '{COLLECTION_NAME}' contains {info.points_count} total "
f"({target})."
)
if __name__ == "__main__":
main()