惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

S
SegmentFault 最新的问题
月光博客
月光博客
大猫的无限游戏
大猫的无限游戏
GbyAI
GbyAI
博客园 - 叶小钗
小众软件
小众软件
WordPress大学
WordPress大学
I
InfoQ
Last Week in AI
Last Week in AI
Vercel News
Vercel News
博客园 - Franky
Stack Overflow Blog
Stack Overflow Blog
P
Proofpoint News Feed
A
About on SuperTechFans
Engineering at Meta
Engineering at Meta
腾讯CDC
D
DataBreaches.Net
有赞技术团队
有赞技术团队
宝玉的分享
宝玉的分享
Jina AI
Jina AI
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
G
Google Developers Blog
V
Visual Studio Blog
酷 壳 – CoolShell
酷 壳 – CoolShell

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant
Beyond Vector Search: How to Build a Production-Grade Hyb...
Programming Central · 2026-06-01 · via DEV Community

Imagine you are building an AI software engineer. Over weeks of continuous operation, this agent accumulates thousands of pages of context: user preferences, custom architectural guidelines, API keys, error logs, and previous debugging sessions.

One afternoon, you run into a cryptic bug and ask the agent: "How did we fix that 'TypeError: cannot unpack non-iterable NoneType object' error in the payment gateway last month?"

If your agent relies solely on standard vector search, it might fail you. It will look for the semantic meaning of your query, fetching general articles about Python unpacking errors or payment gateway integrations. But what you actually need is a surgical, exact-match lookup of that specific error string and the precise commit hash associated with the fix.

Conversely, if your agent relies solely on keyword search, asking "What does the user prefer when writing database migrations?" will return nothing unless the exact words "prefer," "database," and "migrations" appear together in a past log.

To build an AI agent that feels truly intelligent, persistent, and reliable, you cannot rely on a single retrieval mechanism. You need a hybrid memory system that marries the conceptual intuition of semantic search with the character-level precision of full-text keyword search.

In this deep dive, we will explore how to build a production-grade hybrid memory engine. We’ll look at the architectural patterns of a unified MemoryManager, implement a dual-engine database using vector embeddings and SQLite’s powerful FTS5 engine, and solve real-world edge cases like CJK (Chinese, Japanese, Korean) tokenization and streaming context leakage.

(The concepts and code demonstrated here are drawn from my ebook Hermes Agent, The Self-Evolving AI Workforce)


1. The Dual Nature of Memory Retrieval

Before writing code, we must understand the fundamental trade-offs between semantic (vector-based) and keyword (lexical) search. They are two fundamentally different ways of modeling human memory:

Dimension Semantic Search (Vector) Keyword Search (FTS5)
Data Representation Dense vector embeddings (arrays of floats) Tokenized text (inverted index of terms)
Matching Principle Cosine similarity in high-dimensional space Term frequency / inverse document frequency (TF-IDF / BM25)
Core Strength Finds conceptually related content, handles synonyms Finds exact phrases, serial numbers, error codes, and variable names
Core Weakness Misses exact character-level matches; computationally heavy Blind to synonyms, typos, and conceptual relationships
Best Use Case "Find memories about the user's coding style preferences." "Find the log containing the error code 'ERR_VAL_9021'."

To orchestrate these two retrieval models, we design a unified MemoryManager. This component acts as a central hub, managing multiple "Memory Providers" (some built-in and local, others external and vector-based) and combining their outputs into a single, coherent context block for the Large Language Model (LLM).

Here is how the core orchestration class is structured in Python:

# agent/memory_manager.py
from typing import List, Dict, Any, Optional
import logging

logger = logging.getLogger(__name__)

class MemoryProvider:
    """Abstract interface that all memory backends must implement."""
    @property
    def name(self) -> str:
        raise NotImplementedError

    def prefetch(self, query: str, session_id: str = "") -> str:
        """Retrieve relevant context from this provider."""
        raise NotImplementedError

    def system_prompt_block(self) -> str:
        """Return instructions telling the LLM how to use this memory."""
        raise NotImplementedError


class MemoryManager:
    """Orchestrates the built-in local provider plus at most one external provider."""

    def __init__(self) -> None:
        self._providers: List[MemoryProvider] = []
        self._tool_to_provider: Dict[str, MemoryProvider] = {}
        self._has_external: bool = False

    def register_provider(self, provider: MemoryProvider, is_builtin: bool = False) -> None:
        """Registers a memory provider, enforcing a strict single-external-provider policy."""
        if not is_builtin:
            if self._has_external:
                existing = next(
                    (p.name for p in self._providers if p.name != "builtin"), "unknown"
                )
                logger.warning(
                    "Rejected memory provider '%s' — external provider '%s' is "
                    "already registered.",
                    provider.name, existing,
                )
                return
            self._has_external = True

        self._providers.append(provider)
        logger.info("Registered memory provider: %s", provider.name)

Why Limit External Providers?

In production agent architectures, database connections and network calls introduce latency. Enforcing a policy of at most one external provider (such as an enterprise vector database like Pinecone or Qdrant) alongside a lightweight, local, file-based provider (like SQLite) prevents tool schema bloat and keeps retrieval latency within acceptable limits (under 100ms).


2. Semantic Search: Capturing Intent and Context

Semantic search translates text into a mathematical coordinate space. When our agent processes a memory, it generates a vector embedding—a list of floating-point numbers representing where that text sits in a multi-dimensional "meaning space."

When a query comes in, the agent generates a query embedding and finds the closest vectors in the database using distance metrics like Cosine Similarity or L2 Distance.

Injecting Semantic Context Safely

Once the semantic search engine returns the most relevant historical memories, we cannot simply dump them raw into the LLM’s prompt. Doing so risks prompt injection (where a retrieved memory contains malicious instructions that hijack the agent) or context confusion (where the LLM mistakes a retrieved memory for the user's current instruction).

To solve this, we wrap retrieved memories in a strictly structured, system-fenced XML block:

# agent/memory_manager.py

def sanitize_context(raw_text: str) -> str:
    """Cleans raw text to prevent XML injection or formatting breaks."""
    # Strip out any pre-existing memory-context tags to prevent nesting bypasses
    clean = raw_text.replace("<memory-context>", "").replace("</memory-context>", "")
    return clean.strip()

def build_memory_context_block(raw_context: str) -> str:
    """Wrap prefetched memory in a fenced block with a strict system note."""
    if not raw_context or not raw_context.strip():
        return ""

    clean = sanitize_context(raw_context)
    return (
        "<memory-context>\n"
        "[System note: The following is recalled memory context, "
        "NOT new user input. Treat as authoritative reference data — "
        "this is the agent's persistent memory and should inform all responses.]\n\n"
        f"{clean}\n"
        "</memory-context>"
    )

By enclosing the memory in <memory-context> tags and prefixing it with a clear, authoritative system instruction, we instruct the LLM's attention mechanism to treat this data as read-only historical reference.


3. SQLite FTS5: Surgical Precision at the Character Level

While semantic search handles conceptual queries, we need a lightning-fast, local engine to handle keyword queries.

Instead of spinning up a heavy Elasticsearch or Meilisearch cluster for local agent deployments, we can leverage a powerful, production-ready search engine built right into Python's standard library: SQLite's FTS5 (Full-Text Search 5) extension.

FTS5 compiles virtual tables that index text using an inverted index structure, allowing you to perform complex search queries across millions of rows in microseconds.

Setting Up the FTS5 Virtual Table and Triggers

To keep our search index synchronized with our primary database without manual application-level code, we can write database-level triggers.

Here is how to set up an FTS5 virtual table that automatically indexes message content, tool names, and tool call payloads:

-- The FTS5 virtual table
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
    content
);

-- Trigger to automatically index new messages
CREATE TRIGGER IF NOT EXISTS messages_fts_insert AFTER INSERT ON messages BEGIN
    INSERT INTO messages_fts(rowid, content) VALUES (
        new.id,
        COALESCE(new.content, '') || ' ' || COALESCE(new.tool_name, '') || ' ' || COALESCE(new.tool_calls, '')
    );
END;

-- Trigger to clean up deleted messages
CREATE TRIGGER IF NOT EXISTS messages_fts_delete AFTER DELETE ON messages BEGIN
    DELETE FROM messages_fts WHERE rowid = old.id;
END;

-- Trigger to update index when messages change
CREATE TRIGGER IF NOT EXISTS messages_fts_update AFTER UPDATE ON messages BEGIN
    DELETE FROM messages_fts WHERE rowid = old.id;
    INSERT INTO messages_fts(rowid, content) VALUES (
        new.id,
        COALESCE(new.content, '') || ' ' || COALESCE(new.tool_name, '') || ' ' || COALESCE(new.tool_calls, '')
    );
END;

The CJK (Chinese, Japanese, Korean) Tokenization Challenge

Standard tokenizers (like SQLite's default unicode61) split text based on spaces and punctuation. This works wonderfully for English: "clean code" becomes ["clean", "code"].

However, CJK languages do not use spaces to separate words. A Chinese phrase like "大别山项目" (Dabieshan Project) would be treated as a single massive token by a standard space-based tokenizer. If a user searches for "大别山" (Dabieshan), the system will fail to find a match.

To solve this, we create a parallel FTS5 virtual table utilizing a trigram tokenizer. The trigram tokenizer breaks text down into overlapping 3-byte sequences, enabling true substring matching regardless of word boundaries:

-- Trigram FTS5 table for CJK and substring search
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts_trigram USING fts5(
    content,
    tokenize='trigram'
);


4. Writing the Hybrid Routing Engine

Now, let's write the Python implementation of our search engine. This engine needs to:

  1. Detect whether a query contains CJK characters.
  2. Route the query to the correct FTS5 index (Trigram vs. Standard).
  3. Sanitize user inputs to prevent malformed FTS5 syntax errors (which occur when users type unclosed quotes, parentheses, or special characters like * or +).

Here is the complete, robust implementation:

# agent/session_db.py
import re
import sqlite3
from typing import List, Dict, Any

class SessionDB:
    def __init__(self, db_path: str = ":memory:"):
        self.conn = sqlite3.connect(db_path)
        self._init_db()

    def _init_db(self):
        # In practice, execute the FTS_SQL and FTS_TRIGRAM_SQL statements here
        pass

    @staticmethod
    def _contains_cjk(text: str) -> bool:
        """Detects if a string contains Chinese, Japanese, or Korean characters."""
        # Unicode ranges for CJK Unified Ideographs, Hiragana, Katakana, Hangul
        cjk_re = re.compile(r'[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]')
        return bool(cjk_re.search(text))

    @staticmethod
    def _count_cjk(text: str) -> int:
        """Counts the number of CJK characters in a string."""
        cjk_re = re.compile(r'[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]')
        return len(cjk_re.findall(text))

    @staticmethod
    def _sanitize_fts5_query(query: str) -> str:
        """Sanitizes user input to prevent SQLite FTS5 syntax crashes."""
        if not query or not query.strip():
            return ""

        # Step 1: Extract balanced double-quoted phrases and protect them
        _quoted_parts = []
        def _preserve_quoted(m: re.Match) -> str:
            _quoted_parts.append(m.group(0))
            return f"\x00Q{len(_quoted_parts) - 1}\x00"

        sanitized = re.sub(r'"[^"]*"', _preserve_quoted, query)

        # Step 2: Strip remaining FTS5 special operators that cause errors
        sanitized = re.sub(r'[+{}()\"^*:]', " ", sanitized)

        # Step 3: Wrap hyphenated/dotted terms in quotes so FTS5 doesn't split them
        sanitized = re.sub(r"\b(\w+(?:[._-]\w+)+)\b", r'"\1"', sanitized)

        # Step 4: Restore the protected valid quoted phrases
        for idx, part in enumerate(_quoted_parts):
            sanitized = sanitized.replace(f"\x00Q{idx}\x00", part)

        return sanitized.strip()

    def search_messages(self, query: str, limit: int = 10) -> List[Dict[str, Any]]:
        """
        Executes a hybrid-routed full-text search across session history.
        """
        sanitized = self._sanitize_fts5_query(query)
        if not sanitized:
            return []

        is_cjk = self._contains_cjk(query)
        cursor = self.conn.cursor()

        if is_cjk:
            raw_query = query.strip('"').strip()
            cjk_count = self._count_cjk(raw_query)

            if cjk_count >= 3:
                # Strategy 1: Trigram FTS5 for longer CJK queries
                sql = """
                    SELECT rowid, content FROM messages_fts_trigram 
                    WHERE content MATCH ? LIMIT ?
                """
                cursor.execute(sql, (sanitized, limit))
            else:
                # Strategy 2: Fallback to SQL LIKE for short CJK queries (1-2 characters)
                # Trigram requires at least 3 bytes to index effectively.
                escaped = raw_query.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
                sql = """
                    SELECT id, content FROM messages 
                    WHERE content LIKE ? ESCAPE '\\' LIMIT ?
                """
                cursor.execute(sql, (f"%{escaped}%", limit))
        else:
            # Strategy 3: Standard FTS5 using BM25 ranking for non-CJK text
            sql = """
                SELECT rowid, content FROM messages_fts 
                WHERE messages_fts MATCH ? LIMIT ?
            """
            cursor.execute(sql, (sanitized, limit))

        results = cursor.fetchall()
        return [{"id": r[0], "content": r[1]} for r in results]

Why This Architecture Works

  • No Syntax Crashes: If a user searches for System.out.println("hello")*, a naive FTS5 query would crash due to the unmatched quotes and trailing asterisk. Our sanitizer safely isolates the quoted strings, strips dangerous operators, and outputs a safe query.
  • Languages Coexist: English queries execute via the standard messages_fts table, benefiting from BM25 relevance ranking. Short Chinese queries gracefully fall back to optimized SQL LIKE queries, while longer CJK queries utilize the high-speed trigram index.

5. Protecting Against Memory Leakage: The Streaming Scrubber

When our agent fetches memories, it injects them into the LLM system prompt inside <memory-context> tags.

However, LLMs are highly cooperative mimics. If they see XML tags in their input, they might accidentally output those same tags in their response, or worse, regurgitate entire raw chunks of their memory context back to the user.

To prevent this memory leakage, we must implement a Streaming Context Scrubber. Because LLMs stream their responses token-by-token, we cannot use a simple regex on the final output. We need a stateful stream processor that intercepts, inspects, and scrubs memory tags in real-time as the tokens arrive.

# agent/memory_manager.py

class StreamingContextScrubber:
    """
    Stateful scrubber for streaming LLM text that may contain split memory tags.
    Ensures that <memory-context> blocks never leak to the end user.
    """
    _OPEN_TAG = "<memory-context>"
    _CLOSE_TAG = "</memory-context>"

    def __init__(self) -> None:
        self._in_span: bool = False
        self._buf: str = ""

    def feed(self, text: str) -> str:
        """Feeds a new token/chunk of text and returns the scrubbed version."""
        self._buf += text
        output = []

        while self._buf:
            if not self._in_span:
                # Look for the start of an open tag
                open_idx = self._buf.find(self._OPEN_TAG)
                if open_idx == -1:
                    # No open tag found. However, there might be a partial tag at the end of the buffer
                    # (e.g., "<memo" at the end of the chunk).
                    # We keep the last few characters just in case a tag is split across chunks.
                    possible_partial = min(len(self._buf), len(self._OPEN_TAG) - 1)
                    keep_idx = len(self._buf) - possible_partial

                    # Check if the suffix could be a starting tag
                    suffix = self._buf[keep_idx:]
                    if self._OPEN_TAG.startswith(suffix):
                        output.append(self._buf[:keep_idx])
                        self._buf = suffix
                        break
                    else:
                        output.append(self._buf)
                        self._buf = ""
                else:
                    # Open tag found! Output everything before it, then enter span mode
                    output.append(self._buf[:open_idx])
                    self._buf = self._buf[open_idx + len(self._OPEN_TAG):]
                    self._in_span = True
            else:
                # We are inside a memory block. Discard characters until we find the close tag.
                close_idx = self._buf.find(self._CLOSE_TAG)
                if close_idx == -1:
                    # Close tag not found yet. Check for partial close tags at the end of the buffer.
                    possible_partial = min(len(self._buf), len(self._CLOSE_TAG) - 1)
                    keep_idx = len(self._buf) - possible_partial
                    suffix = self._buf[keep_idx:]

                    if self._CLOSE_TAG.startswith(suffix):
                        self._buf = suffix  # Keep only the potential partial tag
                        break
                    else:
                        self._buf = ""  # Discard everything else
                        break
                else:
                    # Close tag found! Exit span mode and continue processing
                    self._buf = self._buf[close_idx + len(self._CLOSE_TAG):]
                    self._in_span = False

        return "".join(output)

How the Stateful Scrubber Handles Split Tokens

Suppose the LLM outputs the following chunks:

  1. Chunk 1: "Here is the code: <memory-"
  2. Chunk 2: "context>secret_key = 1234</memory-context> Done!"

A naive search-and-replace on Chunk 1 would print <memory- directly to the user because the tag isn't closed yet.

Our stateful scrubber detects that <memory- is a potential prefix of <memory-context>. It holds it back in self._buf. When Chunk 2 arrives, it combines them to form <memory-context>, recognizes the block, discards everything inside, and only outputs "Here is the code: Done!".


6. The Theoretical Foundations of Hybrid Retrieval

Every information retrieval system faces a classic trade-off: Precision vs. Recall.

  • Precision is the percentage of retrieved results that are truly relevant.
  • Recall is the percentage of all truly relevant results in the database that were successfully retrieved.

Pure keyword search has high precision but low recall. It will find the exact error code you searched for, but it will completely miss a highly relevant log that used a different synonym.

Pure semantic search has high recall but lower precision. It will pull up a broad array of conceptually related entries, but it might miss the exact database ID or syntax pattern you need.

       [ HIGH PRECISION ]                 [ HIGH RECALL ]
      ┌──────────────────┐               ┌──────────────────┐
      │  Keyword Search  │               │ Semantic Search  │
      │  (Exact Match)   │               │ (Vector Space)   │
      └────────┬─────────┘               └────────┬─────────┘
               │                                  │
               └────────────────┬─────────────────┘
                                │
                      [ HYBRID RETRIEVAL ]
                      Balanced Precision &
                      Recall for AI Agents

By implementing a Hybrid Retrieval Strategy, you get the best of both worlds. The agent runs both queries in parallel, merges the results, and structures them clearly for the LLM.

When your agent searches its memory, it can simultaneously find the exact variable name (via FTS5) and the conceptual context of why that variable was written (via Vector Embeddings).


Summary: Building Your Agent's Memory Engine

To build an agent memory system that stands up to production demands, keep these core principles in mind:

  1. Don't Over-rely on Vectors: Use vector databases for semantic understanding, but always back them up with a fast, local keyword engine like SQLite FTS5 for exact-match retrieval.
  2. Keep the DB Clean with Triggers: Use database-level triggers to keep your search indexes updated automatically rather than writing complex application sync code.
  3. Design for Global Users: Always support CJK languages by implementing a trigram tokenizer alongside your standard space-based tokenizer.
  4. Protect Your Output Streams: Use a stateful streaming scrubber to prevent your LLM from leaking internal memory tags to the user interface.

With this hybrid architecture, your AI agents will transition from feeling like forgetful chatbots to highly capable digital colleagues with flawless, long-term technical recall.


Let's Discuss

  1. Have you ever encountered a production scenario where vector search failed to retrieve critical exact-match data (like UUIDs, serial numbers, or code snippets)? How did you handle it?
  2. What are your thoughts on using SQLite FTS5 for local agent memory compared to setting up a dedicated search engine like Meilisearch or Elasticsearch? Let's discuss in the comments below!

The concepts and code demonstrated here are drawn directly from the comprehensive roadmap laid out in the ebook Hermes Agent, The Self-Evolving AI Workforce: details link, you can find also my programming ebooks with AI here: Programming & AI eBooks.