While integrating RAGFlow into our agent memory pipeline, we hit a wall at around 50 concurrent document insertions per second. Query latency spiked from 80ms to 4+ seconds, and recall rates dropped by 30%. The culprit? HNSW index locks and improper embedding normalization.
RAGFlow is a solid open-source RAG engine that fuses retrieval with agent capabilities, but like any vector search system, it exposes you to PostgreSQL's pgvector quirks under load. Here's what broke, and the exact fixes that stabilized our production deployment.
Pgvector's HNSW index is not optimized for high-concurrency writes. During bulk document ingestion, we saw INSERT queries blocking each other, creating a convoy effect.
The Fix: Separate Write and Read Indexes
We created a dual-index schema with IVFFlat for writes and HNSW for reads:
-- Schema for agent memory chunks with dual indexing
CREATE TABLE agent_memory_chunks (
id BIGSERIAL PRIMARY KEY,
agent_id VARCHAR(64) NOT NULL,
chunk_text TEXT NOT NULL,
embedding vector(1536) NOT NULL,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMP DEFAULT NOW()
);
-- Fast writes: IVFFlat with low list count
CREATE INDEX idx_memory_ivfflat ON agent_memory_chunks
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);
-- Fast reads: HNSW with tuned parameters
CREATE INDEX idx_memory_hnsw ON agent_memory_chunks
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
-- Filtered search with agent_id partition
CREATE INDEX idx_agent_embedding ON agent_memory_chunks (agent_id, ((embedding <=> '[...]'::vector)));
The IVFFlat index handles inserts without blocking, while HNSW serves reads. Rebuild HNSW asynchronously during low-traffic windows:
-- Rebuild HNSW without blocking writes
REINDEX INDEX CONCURRENTLY idx_memory_hnsw;
Query with agent-specific filtering:
SELECT id, chunk_text, metadata,
1 - (embedding <=> $1::vector) AS similarity
FROM agent_memory_chunks
WHERE agent_id = $2
AND metadata @> $3::jsonb -- JSON filter for tags/context
ORDER BY embedding <=> $1::vector
LIMIT 20;
We initially stored raw embeddings from OpenAI's API without normalization. Cosine similarity in pgvector expects unit vectors, but our embeddings had varying magnitudes, causing semantically similar chunks to rank poorly.
The Fix: Normalize at Write Time
import numpy as np
from typing import List
def normalize_embedding(embedding: List[float]) -> List[float]:
"""L2 normalization for cosine similarity."""
vec = np.array(embedding)
norm = np.linalg.norm(vec)
if norm == 0:
return embedding
return (vec / norm).tolist()
After normalization, recall@10 jumped from 0.68 to 0.91 in our test set.
Vector search alone misses exact keyword matches. We implemented hybrid search combining pgvector with PostgreSQL's full-text search, fused via RRF:
import psycopg2
from typing import List, Tuple
def hybrid_search_rrf(
conn,
query_embedding: List[float],
query_text: str,
agent_id: str,
k: int = 20,
rrf_k: int = 60
) -> List[Tuple[int, str, float]]:
"""
Hybrid search with reciprocal rank fusion.
Args:
rrf_k: RRF constant (default 60 per academic papers)
Returns:
List of (id, chunk_text, fused_score)
"""
cursor = conn.cursor()
cursor.execute("""
SELECT id, chunk_text,
ROW_NUMBER() OVER (ORDER BY embedding <=> %s::vector) as rank
FROM agent_memory_chunks
WHERE agent_id = %s
ORDER BY embedding <=> %s::vector
LIMIT %s
""", (query_embedding, agent_id, query_embedding, k))
vector_ranks = {row[0]: (row[1], row[2]) for row in cursor.fetchall()}
cursor.execute("""
SELECT id, chunk_text,
ROW_NUMBER() OVER (ORDER BY ts_rank(to_tsvector('english', chunk_text),
plainto_tsquery('english', %s)) DESC) as rank
FROM agent_memory_chunks
WHERE agent_id = %s
AND to_tsvector('english', chunk_text) @@ plainto_tsquery('english', %s)
LIMIT %s
""", (query_text, agent_id, query_text, k))
fts_ranks = {row[0]: (row[1], row[2]) for row in cursor.fetchall()}
fused_scores = {}
all_ids = set(vector_ranks.keys()) | set(fts_ranks.keys())
for doc_id in all_ids:
vector_rank = vector_ranks.get(doc_id, (None, float('inf')))[1]
fts_rank = fts_ranks.get(doc_id, (None, float('inf')))[1]
rrf_score = (1.0 / (rrf_k + vector_rank)) + (1.0 / (rrf_k + fts_rank))
text = vector_ranks.get(doc_id, fts_ranks.get(doc_id))[0]
fused_scores[doc_id] = (text, rrf_score)
results = [(doc_id, text, score)
for doc_id, (text, score) in fused_scores.items()]
results.sort(key=lambda x: x[2], reverse=True)
return results[:k]
After these changes:
When evaluating RAGFlow or any vector-backed RAG system, stress-test your memory layer early. Index strategy and embedding hygiene matter more than model selection.
This article reflects genuine production debugging. No affiliate links, no sponsored tools. Just PostgreSQL, Python, and the scars from a latency crisis.