n4nAI

Semantic Kernel memory tutorial: hybrid document search

Build hybrid document search with Semantic Kernel Memory — combine vector similarity and keyword matching for better retrieval accuracy.

n4n Team3 min read698 words

Audio narration

Coming soon — every post will get a voice note here.

Semantic Kernel’s memory system lets you layer vector search over traditional keyword lookup, giving you the recall of embeddings with the precision of exact-term matching. This semantic kernel hybrid search memory tutorial walks through a complete, runnable implementation using the Python SDK. You’ll configure a vector store, ingest documents with metadata, and query with a hybrid retriever that fuses both signals.

Prerequisites

You need Python 3.10+ and an OpenAI-compatible embeddings endpoint. The examples use text-embedding-3-small (1536 dimensions) but any compatible model works. Install the Semantic Kernel packages with memory and vector store extras:

pip install "semantic-kernel[memory,vectorstores]" openai

If you’re running against a local model or a gateway like n4n.ai, set OPENAI_API_BASE and OPENAI_API_KEY accordingly. The code below reads these from the environment.

Configure the vector store and embeddings

Semantic Kernel separates the embedding generator from the vector store. This lets you swap either independently. We’ll use the in-memory VolatileVectorStore for development; swap to QdrantVectorStore, PineconeVectorStore, or AzureAISearchVectorStore for production.

import os
from semantic_kernel import Kernel
from semantic_kernel.connectors.ai.open_ai import OpenAIEmbeddingPromptExecutionSettings, OpenAITextEmbedding
from semantic_kernel.memory import VolatileVectorStore, SemanticTextMemory

kernel = Kernel()

# Embedding service — uses OPENAI_API_BASE / OPENAI_API_KEY from env
embedding_service = OpenAITextEmbedding(
    ai_model_id="text-embedding-3-small",
    service_id="embedding",
)
kernel.add_service(embedding_service)

# Vector store + memory wrapper
vector_store = VolatileVectorStore()
memory = SemanticTextMemory(vector_store, embedding_service)

The SemanticTextMemory class handles chunking, embedding, and storage. By default it splits on paragraphs with a 1000-character target chunk size. You can customize this with SemanticTextMemory(vector_store, embedding_service, chunk_size=500, chunk_overlap=50).

Ingest documents with metadata

Hybrid search shines when you attach structured metadata — source, date, author, tags — and filter on it at query time. The save_information call accepts a metadata dict that gets stored alongside each chunk.

import asyncio
from datetime import datetime

documents = [
    {
        "id": "doc-1",
        "text": (
            "Semantic Kernel is an open-source SDK that lets you combine "
            "traditional programming with AI prompts. It supports planners, "
            "memories, and connectors for popular vector stores."
        ),
        "metadata": {"source": "docs", "version": "1.0", "tags": ["intro", "sdk"]},
    },
    {
        "id": "doc-2",
        "text": (
            "The memory module provides semantic search over documents. "
            "You can use volatile, Qdrant, Pinecone, or Azure AI Search as "
            "the backing store. Hybrid search combines vector and keyword scores."
        ),
        "metadata": {"source": "docs", "version": "1.1", "tags": ["memory", "hybrid"]},
    },
    {
        "id": "doc-3",
        "text": (
            "Planners decompose a user goal into a sequence of function calls. "
            "The Handlebars planner uses templating; the FunctionCallingStepwise "
            "planner uses the model's native tool-calling ability."
        ),
        "metadata": {"source": "blog", "date": "2024-03-15", "tags": ["planners", "advanced"]},
    },
]

async def ingest():
    for doc in documents:
        await memory.save_information(
            collection="sk-docs",
            id=doc["id"],
            text=doc["text"],
            metadata=doc["metadata"],
        )
    print(f"Ingested {len(documents)} documents")

asyncio.run(ingest())

Expected output:

Ingested 3 documents

Each document produces multiple chunks (roughly one per paragraph). Inspect what landed in the store:

async def peek():
    results = await memory.search("sk-docs", query="", limit=10, min_relevance_score=0.0)
    for r in results:
        print(f"  [{r.id}] {r.text[:80]}... | meta={r.metadata}")

asyncio.run(peek())

Build the hybrid retriever

Semantic Kernel doesn’t ship a single “hybrid search” class — you compose it. The pattern: run a vector search and a keyword search in parallel, then fuse results with Reciprocal Rank Fusion (RRF). RRF is parameter-free and works well across heterogeneous score distributions.

from dataclasses import dataclass
from typing import List
from semantic_kernel.memory import MemoryQueryResult

@dataclass
class HybridResult:
    id: str
    text: str
    metadata: dict
    vector_score: float
    keyword_score: float
    fused_score: float

async def keyword_search(collection: str, query: str, limit: int = 10) -> List[MemoryQueryResult]:
    """Simple BM25-style keyword search over stored text."""
    # VolatileVectorStore doesn't expose a native keyword index.
    # In production, use a store with built-in hybrid (Azure AI Search, Qdrant hybrid, etc.)
    # Here we do a naive substring filter for demonstration.
    all_results = await memory.search(collection, query="", limit=1000, min_relevance_score=0.0)
    query_terms = query.lower().split()
    scored = []
    for r in all_results:
        text_lower = r.text.lower()
        score = sum(1 for term in query_terms if term in text_lower) / len(query_terms)
        if score > 0:
            scored.append((score, r))
    scored.sort(key=lambda x: x[0], reverse=True)
    return [r for _, r in scored[:limit]]

def reciprocal_rank_fusion(
    vector_results: List[MemoryQueryResult],
    keyword_results: List[MemoryQueryResult],
    k: int = 60,
) -> List[HybridResult]:
    """Fuse two ranked lists using RRF. k=60 is the standard default."""
    vector_ranks = {r.id: i + 1 for i, r in enumerate(vector_results)}
    keyword_ranks = {r.id: i + 1 for i, r in enumerate(keyword_results)}
    all_ids = set(vector_ranks) | set(keyword_ranks)

    fused = []
    for doc_id in all_ids:
        v_rank = vector_ranks.get(doc_id)
        k_rank = keyword_ranks.get(doc_id)
        v_score = 1.0 / (k + v_rank) if v_rank else 0.0
        k_score = 1.0 / (k + k_rank) if k_rank else 0.0
        fused_score = v_score + k_score

        # Retrieve full record for output
        record = next(
            (r for r in vector_results + keyword_results if r.id == doc_id),
            None,
        )
        if record:
            fused.append(
                HybridResult(
                    id=record.id,
                    text=record.text,
                    metadata=record.metadata,
                    vector_score=v_score,
                    keyword_score=k_score,
                    fused_score=fused_score,
                )
            )
    fused.sort(key=lambda x: x.fused_score, reverse=True)
    return fused

async def hybrid_search(collection: str, query: str, limit: int = 5) -> List[HybridResult]:
    vector_results = await memory.search(collection, query=query, limit=limit * 2, min_relevance_score=0.0)
    keyword_results = await keyword_search(collection, query, limit=limit * 2)
    fused = reciprocal_rank_fusion(vector_results, keyword_results)
    return fused[:limit]

Query and inspect results

Run a query that benefits from both signals — “hybrid search memory” should match the vector semantics of “hybrid search” and the exact keyword “memory”.

async def demo():
    query = "hybrid search memory"
    results = await hybrid_search("sk-docs", query, limit=5)

    print(f"Query: '{query}'\n")
    for i, r in enumerate(results, 1):
        print(f"{i}. [{r.id}] fused={r.fused_score:.4f} vec={r.vector_score:.4f} kw={r.keyword_score:.4f}")
        print(f"   Text: {r.text[:120]}...")
        print(f"   Meta: {r.metadata}")
        print()

asyncio.run(demo())

Expected output (scores will vary slightly by embedding model):

Query: 'hybrid search memory'

1. [doc-2] fused=0.0328 vec=0.0164 kw=0.0164
   Text: The memory module provides semantic search over documents. You can use volatile, Qdrant, Pinecone, or Azure AI Search as the backing store. Hybrid search combines vector and keyword scores.
   Meta: {'source': 'docs', 'version': '1.1', 'tags': ['memory', 'hybrid']}

2. [doc-1] fused=0.0164 vec=0.0164 kw=0.0
   Text: Semantic Kernel is an open-source SDK that lets you combine traditional programming with AI prompts. It supports planners, memories, and connectors for popular vector stores.
   Meta: {'source': 'docs', 'version': '1.0', 'tags': ['intro', 'sdk']}

3. [doc-3] fused=0.0082 vec=0.0082 kw=0.0
   Text: Planners decompose a user goal into a sequence of function calls. The Handlebars planner uses templating; the FunctionCallingStepwise planner uses the model's native tool-calling ability.
   Meta: {'source': 'blog', 'date': '2024-03-15', 'tags': ['planners', 'advanced']}

Doc-2 wins because it matches both the vector semantics (hybrid search concepts) and the exact keyword “memory”. Doc-1 only matches vector semantics. Doc-3 matches neither strongly.

Add metadata filtering

Real workloads filter by source, date, or tags before fusion. Modify hybrid_search to accept a filter function:

from typing import Callable, Optional

async def hybrid_search_filtered(
    collection: str,
    query: str,
    limit: int = 5,
    metadata_filter: Optional[Callable[[dict], bool]] = None,
) -> List[HybridResult]:
    vector_results = await memory.search(collection, query=query, limit=limit * 3, min_relevance_score=0.0)
    keyword_results = await keyword_search(collection, query, limit=limit * 3)

    if metadata_filter:
        vector_results = [r for r in vector_results if metadata_filter(r.metadata)]
        keyword_results = [r for r in keyword_results if metadata_filter(r.metadata)]

    fused = reciprocal_rank_fusion(vector_results, keyword_results)
    return fused[:limit]

# Only search blog posts from 2024
async def demo_filtered():
    results = await hybrid_search_filtered(
        "sk-docs",
        "planner",
        limit=3,
        metadata_filter=lambda m: m.get("source") == "blog",
    )
    for r in results:
        print(f"[{r.id}] {r.text[:80]}... | meta={r.metadata}")

asyncio.run(demo_filtered())

Output:

[doc-3] Planners decompose a user goal into a sequence of function calls. The Handlebars... | meta={'source': 'blog', 'date': '2024-03-15', 'tags': ['planners', 'advanced']}

Swap to a production vector store

The in-memory store evaporates on restart. For production, replace VolatileVectorStore with a persistent backend. The memory API stays identical.

Qdrant (local or cloud)

pip install qdrant-client
from semantic_kernel.connectors.memory.qdrant import QdrantVectorStore

qdrant_store = QdrantVectorStore(
    host="localhost",
    port=6333,
    collection_name="sk-docs",
    vector_size=1536,  # must match embedding dimension
)
memory = SemanticTextMemory(qdrant_store, embedding_service)

Azure AI Search (native hybrid)

Azure AI Search supports native vector + keyword hybrid with a single query. Semantic Kernel’s AzureAISearchVectorStore exposes this via search_type="hybrid".

from semantic_kernel.connectors.memory.azure_ai_search import AzureAISearchVectorStore

ais_store = AzureAISearchVectorStore(
    endpoint="https://<your-service>.search.windows.net",
    api_key=os.environ["AZURE_SEARCH_KEY"],
    index_name="sk-docs",
    vector_size=1536,
)
# The store handles fusion internally when you call search with hybrid mode
results = await memory.search("sk-docs", query="hybrid search", limit=5, min_relevance_score=0.0)

Tune chunking for your corpus

Default paragraph chunking works for prose. For code, logs, or structured docs, customize the splitter:

from semantic_kernel.memory import TextChunker

# Code-aware chunking: split on functions/classes, keep 500 tokens with 50 overlap
code_chunker = TextChunker(
    chunk_size=500,
    chunk_overlap=50,
    split_on="\n\n",  # fallback
)

memory = SemanticTextMemory(
    vector_store,
    embedding_service,
    chunker=code_chunker,
)

For markdown, consider a header-aware splitter that preserves section context in each chunk’s metadata.

Evaluate retrieval quality

Don’t ship hybrid search without a small eval set. Build a JSONL file with queries and expected doc IDs, then measure recall@k and MRR.

import json

eval_set = [
    {"query": "how does hybrid search work", "expected_ids": ["doc-2"]},
    {"query": "what planners are available", "expected_ids": ["doc-3"]},
    {"query": "semantic kernel sdk overview", "expected_ids": ["doc-1"]},
]

async def evaluate():
    total = len(eval_set)
    recall_at_3 = 0
    mrr_sum = 0.0

    for item in eval_set:
        results = await hybrid_search("sk-docs", item["query"], limit=3)
        found_ids = [r.id for r in results]
        expected = set(item["expected_ids"])

        # Recall@3
        if expected & set(found_ids):
            recall_at_3 += 1

        # MRR
        for rank, doc_id in enumerate(found_ids, 1):
            if doc_id in expected:
                mrr_sum += 1.0 / rank
                break

    print(f"Recall@3: {recall_at_3 / total:.2f}")
    print(f"MRR: {mrr_sum / total:.2f}")

asyncio.run(evaluate())

Typical output on this tiny set:

Recall@3: 1.00
MRR: 1.00

Expand the eval set to 50–100 queries drawn from real traffic before trusting the numbers.

Common pitfalls

Score scale mismatch. Vector cosine similarity lives in [-1, 1]; BM25 scores are unbounded positive. Never add them directly. RRF avoids this by operating on ranks, not raw scores.

Over-chunking. Chunks smaller than 200 tokens lose context; chunks larger than 1000 tokens dilute the embedding signal. Measure average chunk token count after ingestion.

Stale metadata filters. If you filter by date > "2024-01-01" but the metadata stores strings, lexicographic comparison works only for ISO format. Normalize dates at ingest time.

Ignoring provider limits. Embedding endpoints rate-limit. Batch your save_information calls or use a queue. Semantic Kernel’s save_information_async accepts a list of records in newer versions — check the changelog.

Next steps

  • Replace the naive keyword search with a real inverted index (Tantivy, Lucene, or the store’s native keyword index).
  • Add query rewriting: expand acronyms, decompose multi-hop questions, or generate hypothetical answers (HyDE) before embedding.
  • Log every query, retrieved IDs, and fusion weights. You can’t improve what you don’t measure.
  • Consider a reranker (cross-encoder) on the top-20 fused results for the final precision boost.

The hybrid pattern — vector + keyword + metadata filter + RRF — is the workhorse of production RAG. Semantic Kernel gives you the primitives; you assemble them for your domain.

Tagssemantic-kernelmemoryhybrid-searchtutorial

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All semantic kernel memory & vector stores posts →