Post 3 of 6 on Cosmos DB agent memory search, because storing memory well doesn’t guarantee you’ll retrieve the right piece.
Post 2 ended with the schema settled and retrieval still open. This post closes that gap: the practical mechanics of Cosmos DB agent memory search, one container, four query patterns. Run the same question four different ways against that container, and it comes back with four different answers, because “find the right memory” isn’t one query pattern; it’s at least three, and knowing which one to reach for is most of the job.
Vector Indexing for Cosmos DB Agent Memory Search
Cosmos DB supports two vector index types, and the right one depends almost entirely on how many vectors you’re searching, not on anything specific to agents.

quantizedFlat compresses each vector and scans the compressed space exactly. It suits smaller workloads (tens of thousands of vectors) and trades a small amount of accuracy for lower RU cost and faster scans. For a single tenant’s short-term memory, this is often enough on its own.
DiskANN, on the other hand, indexes vectors for approximate nearest-neighbor search and scales to hundreds of thousands or billions of embeddings, with dynamic updates and strong recall even at that size. Post 1 already leaned on DiskANN as part of the case for Cosmos DB as a unified store; this is the mechanism behind that claim.
Sharding the Vector Index for Multitenant Isolation
DiskANN doesn’t have to search across every vector in the container. A vectorIndexShardKey partitions the index itself by a property you choose: session, user, or tenant, so a query only searches candidates within that shard instead of the whole container.
That maps directly onto the partition key work from post 2: set the vectorIndexShardKey to tenantId, or to the same [tenantId, threadId] pair you already use as the partition key, and semantic search for one tenant never touches another tenant’s vectors. A global, unsharded index still works and makes searching everything at once simpler, but it’sonly appropriate for a single-tenant app or a genuinely shared knowledge base where cross-tenant recall is the point rather than a leak.
Full-Text Search: When Precision Beats Semantics
Vector search finds what’s semantically similar. Sometimes semantically similar isn’t what you want — a customer asking about “the refund policy” needs the actual refund policy language, not five conceptually related passages about returns in general.
Full-text search on Cosmos DB handles that case through BM25, a statistical ranking function that scores by term frequency and document length. Cosmos DB applies linguistic processing automatically: tokenization, stemming, case normalization, so “running” still matches “run” or “ran.” It’s the right tool whenever exact terms or phrases carry meaning that a vector embedding would blur.
Hybrid Search: Combining Both with RRF
Most agent memory queries don’t need to choose between semantic and lexical relevance; they need a blend of both. That’s what Reciprocal Rank Fusion (RRF) does: it takes the vector-similarity ranking and the BM25 ranking for the same result set and merges them into one combined rank, instead of forcing a pick between the two.

In practice, this shows up as a single ORDER BY RANK RRF(...) clause, which the next section demonstrates directly.
Four Ways to Ask the Same Question
Take the turn-based schema from post 2 — tenantId, threadId, turnIndex, messages, embedding, content and run the same underlying question against it four ways. (content is a flat, denormalized copy of the turn’s text, added specifically because Cosmos DB doesn’t support wildcard array paths like /messages/*/content in a full-text policy or index the full-text and hybrid queries below point at c.content rather than c.messages for exactly that reason.)
Most recent, by recency:
SELECT TOP 5 c.messages, c.turnIndexFROM cWHERE c.tenantId = @tenantId AND c.threadId = @threadIdORDER BY c.turnIndex DESC
Semantic, by vector similarity:
SELECT TOP 5 c.messages, VectorDistance(c.embedding, @queryVector) AS scoreFROM cWHERE c.tenantId = @tenantId AND c.threadId = @threadIdORDER BY VectorDistance(c.embedding, @queryVector)
Hybrid, blending both with RRF:
SELECT TOP 5 c.messages, VectorDistance(c.embedding, @queryVector) AS scoreFROM cWHERE c.tenantId = @tenantId AND c.threadId = @threadIdORDER BY VectorDistance(c.embedding, @queryVector)
Keyword, by exact phrase:
SELECT TOP 5 c.messages, c.turnIndexFROM cWHERE c.tenantId = @tenantId AND c.threadId = @threadId AND FULLTEXTCONTAINS(c.content, @phrase)ORDER BY c.turnIndex DESC
Run all four against a thread where a customer asked about refunds three times, in different words, across twenty turns, and the differences stop being theoretical fast: recency surfaces whichever turn happened most recently, even if it’s off-topic; semantic search pulls in every conceptually related turn, including the ones that used different words entirely; hybrid balances the two; keyword search returns only the turns that used the customer’s actual phrase, and ranks them by recency underneath that filter.
Running These Queries in Data Explorer
The four queries above use parameterized SQL, the same form search.py, from the companion repo behind this series, sends through the Python SDK, which binds @tenantId, @queryVector, and @phrase properly before the query runs. Paste them as-is into the Azure Portal’s Data Explorer query pane instead, and two things break, neither of which is a schema or code bug:
Data Explorer’s query box doesn’t bind named parameters. A query that leaves @tenantId unresolved either matches nothing and returns “No results” silently, or for VectorDistance() inside ORDER BY and FullTextScore() fails to compile outright, because both functions require their arguments to resolve to literal values at query-compile time rather than at execution time.
Swap every @parameter for a literal value and all four run cleanly. Against the seeded sample data (tenantId = "contoso", threadId = "thread-1234", searching for "refund"):
Recency, with literals:
SELECT TOP 5 c.messages, c.turnIndex FROM c WHERE c.tenantId = "contoso" AND c.threadId = "thread-1234" ORDER BY c.turnIndex DESC

Semantic, with literals:
SELECT TOP 5 c.messages, VectorDistance(c.embedding, [0.8196, 0.6392, -0.2471, 0.1608, -0.8667, 0.4902, -0.2549, 0.2235]) AS scoreFROM cWHERE c.tenantId = "contoso" AND c.threadId = "thread-1234"ORDER BY VectorDistance(c.embedding, [0.8196, 0.6392, -0.2471, 0.1608, -0.8667, 0.4902, -0.2549, 0.2235])

Hybrid, with literals:
SELECT TOP 5 c.messages, c.turnIndexFROM cWHERE c.tenantId = "contoso" AND c.threadId = "thread-1234"ORDER BY RANK RRF( VectorDistance(c.embedding, [0.8196, 0.6392, -0.2471, 0.1608, -0.8667, 0.4902, -0.2549, 0.2235]), FullTextScore(c.content, "refund"))

Keyword, with literals:
SELECT TOP 5 c.messages, c.turnIndexFROM cWHERE c.tenantId = "contoso" AND c.threadId = "thread-1234" AND FULLTEXTCONTAINS(c.content, "refund")ORDER BY c.turnIndex DESC

Pitfalls
Reaching for DiskANN on a small dataset. DiskANN’s approximate search and sharding options solve a scale problem. Below roughly ten thousand vectors, quantizedFlat gets equivalent recall for less operational complexity and lower RU cost. Default to DiskANN because it sounds like the “serious” choice, and you’ve added index-shard decisions to a workload that never needed them.
A global vector index in a multitenant app. Skip the vectorIndexShardKey, and a semantic query searches every candidate in the entire container, tenant boundaries or not. Nothing stops the query from surfacing another tenant’s conceptually similar memory in the result set unless a WHERE clause happens to filter it back out after the fact, and relying on a filter to catch what the index itself should have scoped is the kind of gap that shows up in an audit, not in testing.
Forgetting WHERE filters still apply. Vector and hybrid queries look like they replace normal filtering, but ORDER BY VectorDistance(...) or ORDER BY RANK RRF(...) still runs inside a WHERE-scoped query, same as any other. Leave the WHERE c.tenantId = @tenantId AND c.threadId = @threadId clause off a semantic query, and it searches everything the container holds, not just the thread the agent is currently in.
Next: Coordinating Multiple Agents
That settles Cosmos DB agent memory search for a single agent working alone. Coordinating what several agents know about the same conversation is a different problem, and it’s where change feed, a mechanism post 1 already covered as a callback to the 2023 retail monitoring work, comes back to tie multi-agent state together. That’s post 4.
Sources
- Microsoft Learn — Agent memories in Azure Cosmos DB for NoSQL
- Microsoft Learn — Vector search in Azure Cosmos DB
- Microsoft Learn — Sharded DiskANN (vectorIndexShardKey)
- Microsoft Learn — Hybrid search in Azure Cosmos DB
- GitHub — steefjan1/cosmos-agent-memory-lab — the runnable sample behind these four queries, and behind posts 2 and 4











