Skip to content

Feat: Add Hybrid Search (Vector + FTS) - #1475

Open
Jeoungahn wants to merge 7 commits into
MemMachine:mainfrom
skhynix:feat-hybrid-search
Open

Feat: Add Hybrid Search (Vector + FTS)#1475
Jeoungahn wants to merge 7 commits into
MemMachine:mainfrom
skhynix:feat-hybrid-search

Conversation

@Jeoungahn

Copy link
Copy Markdown

Purpose of the change

This PR introduces Hybrid Search capability to MemMachine, combining vector similarity search with keyword-based Full-Text Search (FTS) to significantly improve retrieval accuracy. The implementation addresses the fundamental limitation of pure vector search where exact keyword matches (error messages, model names, technical terms, identifiers) are often missed due to semantic embedding dilution.

This PR enables clients to opt-in to hybrid retrieval via a simple use_fts flag without changing query patterns.

Description

1. API Extension - Added use_fts: bool parameter across the API stack to enable hybrid search mode. Default false maintains backward compatibility.

2. FTS Index Setup - FTS indexes are auto-created during ingest. The index name follows the pattern fts_{sanitized_derivative_collection}_content and is indexed on the Derivative node's content field. No manual index creation is required; the index is ready for use when hybrid search is enabled.

3. FTS Search Implementation - New _search_fts() method (packages/server/src/memmachine_server/episodic_memory/long_term_memory/long_term_memory.py:363-460) executes Neo4j Full-Text Search queries on Derivative nodes using db.index.fulltext.queryNodes(). It follows the DERIVED_FROM relation to retrieve Episode nodes, returning FTS scores and Episode objects with metadata extracted from Neo4j.

4. Hybrid Search Orchestration - New _search_scored_hybrid() method (packages/server/src/memmachine_server/episodic_memory/long_term_memory/long_term_memory.py:295-349) runs Vector Search and FTS in parallel via asyncio.gather() (no latency overhead). Results are merged with UID-based deduplication: Vector results (top-k, RRF-reranked) are preserved in order, and unique FTS results (top-10) are appended. Returns up to top-k + 10 episodes.

5. Query Escaping Utility - Static method _escape_lucene_query() (packages/server/src/memmachine_server/episodic_memory/long_term_memory/long_term_memory.py:351-361) escapes Lucene special characters (+ - = && || > < ! ( ) { } [ ] ^ " ~ * ? : \ /) to prevent query syntax errors on user input containing special characters.

Motivation: Vector search excels at semantic similarity but struggles with exact keyword matches (error messages like CUDA_ERROR_OUT_OF_MEMORY, model names like RTX-4090, acronyms like API/HTTP, function/class names). FTS complements vector search by providing precise term matching.

Dependencies: No new external dependencies. Uses existing Neo4j FTS indexes (auto-created on ingest), asyncio, and vector search infrastructure.

Summary of Changes

This PR adds Hybrid Search functionality by integrating Neo4j Full-Text Search (FTS) indexes with existing vector search infrastructure. The key changes include:

Fixes/Closes

N/A (New feature)

Type of change

[Please delete options that are not relevant.]

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Refactor (does not change functionality, e.g., code style improvements, linting)
  • Documentation update
  • Project Maintenance (updates to build scripts, CI, etc., that do not affect the main project)
  • Security (improves security without changing functionality)

How Has This Been Tested?

Test Environment:

  • Benchmark: LongMemEval_M (500 questions, 896 gold turns)
  • Dataset: longmemeval_m_cleaned.json
  • Configuration: Qwen3-Embedding-4B + Qwen3.5-397B-A17B-NVFP4

[Please delete options that are not relevant.]

  • Unit Test
  • Integration Test
  • End-to-end Test
  • Test Script (please provide)
  • Manual verification (list step-by-step instructions)

Usage Example:

When data is ingested, FTS indexes are automatically created. To enable hybrid search, pass use_fts=True in the QueryParam when calling the search function:

from memmachine_server.retrieval_agent.common.agent_api import QueryParam

# Hybrid search enabled
results = await do_query(
    QueryParam(
        query="CUDA_ERROR_OUT_OF_MEMORY",
        limit=50,
        memory=memory,
        use_fts=True,  # Enable Hybrid Search (Vector + FTS)
    )
)

Test Results: [Attach logs, screenshots, or relevant output]

Method top-k Overall Recall Improvement
Vector (baseline) 50 0.8811 -
Vector 60 0.9006 +1.95%
Vector 70 0.8945 +1.34%
Vector 80 0.9010 +1.99%
Hybrid (Vector+FTS) 50 0.9108 +2.97%

Key Findings:

  • Hybrid Search achieves +2.97% improvement over Vector-only (top-k=50)
  • Outperforms Vector top-k=80 (0.9010) while fetching fewer total results
  • Single-session-user queries: +7.8% improvement (largest gain)
  • Temporal-reasoning queries: +3.9% improvement

Checklist

[Please delete options that are not relevant.]

  • I have signed the commit(s) within this pull request
  • My code follows the style guidelines of this project (See STYLE_GUIDE.md)
  • I have performed a self-review of my own code
  • I have commented my code
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added unit tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • Any dependent changes have been merged and published in downstream modules
  • I have checked my code and corrected any misspellings

Maintainer Checklist

  • Confirmed all checks passed
  • Contributor has signed the commit(s)
  • Reviewed the code
  • Run, Tested, and Verified the change(s) work as expected

Screenshots/Gifs

N/A (Backend feature - no UI changes)

Further comments

Known Limitations:

  1. Hybrid search currently only supported on declarative backend (Neo4j - VectorGraphStore), not event backend (VectorStore)
  2. FTS returns limited metadata (timestamp/producer_id set to defaults)
  3. FTS uses BM25 scores while vector uses cosine/euclidean; threshold semantics differ

  ## Change
  - range_index_creation_threshold: 10,000 → 1,000
  - vector_index_creation_threshold: 10,000 → 1,000

  ## Reason
  Neo4j vector and range indexes are now created at 1,000 nodes instead of
  10,000, enabling index-based search for smaller collections. This is
  particularly beneficial for:

  1. Test environments with limited data (e.g., test datasets with ~5,000 nodes)
  2. Development environments where early index creation aids debugging
  3. Production workloads with many small sessions that benefit from
     indexed queries
  4. FTS (Full-Text Search) index creation, which is triggered alongside
     range index creation for Derivative collections

  ## Files Changed
  - packages/server/src/memmachine_server/common/vector_graph_store/neo4j_vector_graph_store.py
    - Neo4jVectorGraphStoreParams.range_index_creation_threshold
    - Neo4jVectorGraphStoreParams.vector_index_creation_threshold

Signed-off-by: Jeoungahn Park <arsd098@gmail.com>
  ## Change
  - Add `_create_fts_index_if_not_exists()` method for FTS index creation
  - FTS index is automatically created when collection exceeds threshold (1,000 nodes)
  - FTS index targets `content` property on all NODE collections
  - Index creation runs in background using `asyncio.create_task()`

  ## Reason
  Full-Text Search enables efficient text-based retrieval on node content.
  FTS indexes are created alongside range indexes for all NODE collections,
  allowing text search queries to scale with data growth. This is particularly
  beneficial for:

  1. Text-based retrieval queries that don't use vector embeddings
  2. Hybrid search scenarios combining FTS with vector similarity search
  3. Development and test environments where quick text search is needed

  ## Files Changed
  - packages/server/src/memmachine_server/common/vector_graph_store/neo4j_vector_graph_store.py
    - Added `_create_fts_index_if_not_exists()` method
    - Added FTS index creation call in `add_nodes()` method (EntityType.NODE)
    - FTS index naming: `fts_{sanitized_collection}_content`
    - FTS analyzer: `standard`

Signed-off-by: Jeoungahn Park <arsd098@gmail.com>
  ## Change
  - Add `use_fts` boolean parameter to `search_scored()` API endpoint
  - Implement `_search_scored_hybrid()` for Vector + FTS hybrid search
  - Add `_search_fts()` method for Neo4j Full-Text Search on Derivative nodes
  - Add `_escape_lucene_query()` static method for Lucene special character escaping
  - Hybrid search returns Vector results (top-k) + FTS results (up to 10, deduped)
  - FTS follows same path as Vector Search: Derivative → DERIVED_FROM → Episode

  ## Reason
  Hybrid Search combines vector similarity search with keyword-based full-text
  search to improve retrieval quality. The  API flag allows clients to
  opt-in to hybrid search without changing query patterns. This addresses:

  1. Keyword match queries where vector similarity fails (exact term matching)
  2. Hybrid retrieval scenarios benefiting from both semantic and lexical search
  3. Flexible API design allowing Vector-only or Hybrid mode via single flag

  FTS integration uses existing FTS indexes on Derivative collections, ensuring
  consistent behavior with vector search (same DERIVED_FROM relation traversal,
  same Episode.uid for dedup). Deduplication preserves Vector result order and
  appends unique FTS results, returning top-k + (0~10) episodes.

  ## Files Changed
  - packages/common/src/memmachine_common/api/spec.py
  - packages/server/src/memmachine_server/server/api_v2/service.py
  - packages/server/src/memmachine_server/main/memmachine.py
  - packages/server/src/memmachine_server/episodic_memory/episodic_memory.py
  - packages/server/src/memmachine_server/episodic_memory/long_term_memory/long_term_memory.py
    - Added `use_fts` parameter to `search_scored()` method (line 258)
    - Added `_search_scored_hybrid()` method for hybrid search orchestration
    - Added `_search_fts()` method for FTS-only search on Derivative nodes
    - Added `_escape_lucene_query()` static method for query escaping
    - FTS index name: `fts_{sanitized_derivative_collection}_content`
    - Dedup strategy: UID-based, preserves Vector result order

Signed-off-by: Jeoungahn Park <arsd098@gmail.com>
  ## Change
  - Add `use_fts: bool = False` to `QueryParam` in `agent_api.py`
  - Pass `use_fts=query.use_fts` to `query_memory()` in `memmachine_retriever.py`

  ## Reason
  Enables retrieval-agent to support hybrid search (Vector + FTS) via the
  existing `use_fts` flag. Agents can now opt-in to hybrid search without
  code changes — just pass `use_fts=True` in QueryParam.

  ## Files Changed
  - packages/server/src/memmachine_server/retrieval_agent/common/agent_api.py
  - packages/server/src/memmachine_server/retrieval_agent/agents/memmachine_retriever.py

Signed-off-by: Jeoungahn Park <arsd098@gmail.com>
@Jeoungahn Jeoungahn changed the title Feat hybrid search Feat: hybrid search (vector index search + keyword index search) Jul 13, 2026
@Jeoungahn Jeoungahn changed the title Feat: hybrid search (vector index search + keyword index search) Feat: Add Hybrid Search (Vector + FTS) Jul 13, 2026
@malatewang

Copy link
Copy Markdown
Contributor

We will retire the neo4j based episodic memory implementation. The new implementation is based on vector store and sql DB, the code is under https://github.com/MemMachine/MemMachine/tree/main/packages/server/src/memmachine_server/episodic_memory/event_memory. Can you also add the FTS to the new implementation?

@edwinyyyu

Copy link
Copy Markdown
Contributor

We will retire the neo4j based episodic memory implementation. The new implementation is based on vector store and sql DB, the code is under https://github.com/MemMachine/MemMachine/tree/main/packages/server/src/memmachine_server/episodic_memory/event_memory. Can you also add the FTS to the new implementation?

I think a text index with the new system would require a new ABC or interface. It would not be appropriate to add text search functionality to the VectorStore/Collection API.

@Jeoungahn

Copy link
Copy Markdown
Author

Thank you for the thoughtful feedback(@malatewang @edwinyyyu).
I agree that adding text search functionality directly to Event Memory (Vector Store + SQL DB) is not appropriate. While the existing Neo4j provided text search functionality that enabled integrated implementation with Vector Index, the new Event Memory architecture requires verification of equivalent text search support and may require significant architectural changes.
So, I would like to position the current Hybrid Search PR as a concept/reference contribution. I will leave the PR available for reference should you decide to pursue implementation in the future.

@Jeoungahn
Jeoungahn marked this pull request as draft July 27, 2026 06:02
RonNiles added a commit to RonNiles/MemMachine that referenced this pull request Jul 31, 2026
The embedder was hardcoded to OpenAI text-embedding-3-small at 1536
dims, so reproducing benchmarks run under a different embedder (e.g.
Qwen3-Embedding-4B at 2560 dims, per PR MemMachine#1475) was impossible. Add
--embedding-model / --embedding-dimensions / --embedding-base-url to
both ingest and search, and give the embedder its own OpenAI-compatible
client keyed by EMBEDDING_API_KEY (falling back to OPENAI_API_KEY), so
embeddings can come from a hosted Qwen endpoint while answer generation
and the judge keep using OpenAI. Defaults reproduce the previous
behavior exactly. The model+dimensions flow into the cache signature,
so switching embedders yields a fresh cache with no collisions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: RonNiles <ron.niles@memverge.com>
RonNiles added a commit to RonNiles/MemMachine that referenced this pull request Aug 3, 2026
To A/B our RRF fusion against PR MemMachine#1475's original method on identical
data, add a `fusion` option to LongTermMemory.search_scored (default
"rrf", unchanged behavior). "append" restores the pre-RRF logic —
extracted into _search_scored_hybrid_append — which keeps the vector
list and appends the FTS top-10 (dedup by uid), so FTS augments rather
than competes for slots and never displaces a vector hit. Expose it via
a --fusion flag on the search harness. This isolates the fusion-method
variable, which the harness makes material: it feeds all returned
episodes to the answer model, so append (100 vector + 10 FTS) adds
context whereas RRF (re-ranked, truncated to 100) can drop vector hits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: RonNiles <ron.niles@memverge.com>
Signed-off-by: RonNiles <28164584+RonNiles@users.noreply.github.com>
@RonNiles

RonNiles commented Aug 5, 2026

Copy link
Copy Markdown

First let's get CI to pass. Please review the attached gist, which covers most of the issues, and apply it if you find it satisfactory: https://gist.github.com/RonNiles/b299f1826d58a0f33ab91ca01a53b479

@Jeoungahn Jeoungahn closed this Aug 6, 2026
@Jeoungahn Jeoungahn reopened this Aug 6, 2026
@Jeoungahn
Jeoungahn marked this pull request as ready for review August 6, 2026 02:12
@Jeoungahn

Copy link
Copy Markdown
Author

First let's get CI to pass. Please review the attached gist, which covers most of the issues, and apply it if you find it satisfactory: https://gist.github.com/RonNiles/b299f1826d58a0f33ab91ca01a53b479

Thanks @RonNiles.
I've re-opened the PR and confirmed that your patch works as expected. I also rebased onto the latest main.
It seems we have two possible paths for the remaining issue:

  1. Limit the current hybrid search implementation to Neo4j only. This keeps the PR focused, but FTS will not be supported on the Nebula backend.
    → For example, we could work around this with an isinstance check and cast() to bypass the type checker."
  2. Build a backend-agnostic Hybrid search (FTS). This supports all backends consistently, but requires architectural refactoring beyond current scope.

For this PR, we could move forward with option 1 first, with option 2 as our final development target. Would you agree with this approach?

@RonNiles

RonNiles commented Aug 6, 2026

Copy link
Copy Markdown

Thanks @RonNiles. I've re-opened the PR and confirmed that your patch works as expected. I also rebased onto the latest main. It seems we have two possible paths for the remaining issue:

1. Limit the current hybrid search implementation to Neo4j only. This keeps the PR focused, but FTS will not be supported on the Nebula backend.
   → For example, we could work around this with an isinstance check and cast() to bypass the type checker."

2. Build a backend-agnostic Hybrid search (FTS). This supports all backends consistently, but requires architectural refactoring beyond current scope.

For this PR, we could move forward with option 1 first, with option 2 as our final development target. Would you agree with this approach?

I think #1 is the best way because it limits the scope of this PR and the refactoring and expanded backend support required by #2 is best done in a separate PR. Also, we will not retire neo4j right away, and legacy support of neo4j should be possible into the future because many users monitor the neo4j database directly for things like determining whether ingestion is complete before beginning search, so we don't want to break those use cases.

Here is my wish list for the rest of the PR:

  1. The API and python client search should expose use_fts. I think we should support the various flavors: append-n, with n default to 10 (best case as studies show but custom n can specified), and rrf for reciprocal rank fusion which is popular in the industry and works well with small k such as WikiHow benchmark.
  2. FTS requires adding a new index to neo4j and currently it will happen on ingestion. It can cause a long pause at that time which may be unexpected. Also the user may want to do a hybrid search without having to add episodes for the sole purpose of triggering the new FTS index. I feel it is best to build the index when MemMachine initializes, and via a new "EnableFtsIndex" API call in case the user doesn't want to restart. Perhaps a good way is to add a key to config.yml such as: episodic_memory:long_term_memory:fts:enabled and if this key is present, build the neo4j index at startup. This will also be a good place to put "fts_default: " flag so that if user has a client application that is unaware of the fts flag, and wants to run that client application with fts, the configured default will allow that enhancement without needing to modify the client calls.
  3. Add unit test coverage for the FTS cases.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants