Add keyword/substring search mode alongside semantic search#62
Merged
Conversation
Semantic search ranks by meaning and can miss exact terms (names, IDs, URLs,
rare tokens). Add an opt-in mode="keyword" to search (REST + MCP) that does a
case-insensitive substring match over memory text, newest matches first.
Default stays "semantic", so existing behavior is unchanged. Adapts the OB1
keyword-search idea.
Reuses the same vector_store.list() payload path already validated against live
Qdrant for the dedup feature — no new Qdrant capability or index required. The
match runs in Python over the user's memories (scoped by user_id), bounded by a
generous scan limit and fail-open (a store error returns no results).
- app/memory.py: keyword_search() + _point_to_result() shaping.
- app/rest.py: SearchRequest.mode ("semantic"|"keyword"); route keyword path.
- app/mcp_server.py: search_memories gains a mode arg (docstring guides the model
to use keyword for exact terms).
- tests: keyword_search unit cases (substring/case, recency-first+limit, field
shaping, no-match, fail-open) + REST and MCP integration + invalid-mode 422.
- docs: USER_GUIDE search section (keyword mode + scan-limit caveat),
DEVELOPER_GUIDE memory.py description.
Closes #54.
https://claude.ai/code/session_017835DVrvURaYnbQiPQwzue
There was a problem hiding this comment.
Pull request overview
Adds an opt-in keyword/substring search path alongside existing semantic search so callers can find memories by exact/literal terms (names, IDs, URLs) that embeddings can miss, without requiring Qdrant schema/index changes.
Changes:
- Added
mode: "semantic" | "keyword"to REST search and routed keyword mode to a new in-process substring scan overvector_store.list(). - Exposed keyword mode via the MCP
search_memoriestool. - Added unit + integration tests and documented the new mode and its scan-limit tradeoffs.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| app/memory.py | Implements keyword_search() and point-to-result shaping for keyword mode. |
| app/rest.py | Adds mode to the REST search request model and routes keyword searches. |
| app/mcp_server.py | Exposes mode on the MCP search_memories tool and routes keyword searches. |
| tests/test_memory.py | Unit tests for keyword_search() behavior (matching, ordering, shaping, fail-open). |
| tests/test_rest.py | REST integration tests for keyword mode + invalid mode validation. |
| tests/test_mcp.py | MCP integration tests ensuring keyword mode uses listing and exposes mode. |
| docs/USER_GUIDE.md | Documents keyword mode usage and caveats. |
| docs/DEVELOPER_GUIDE.md | Updates internal architecture notes to include keyword search. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…obust ordering, validate MCP mode - app/memory.py: _point_to_result now also strips the internal `content_fp` dedup fingerprint from results. keyword_search trims the query and returns no results for an empty/whitespace query (instead of matching everything). Ordering now parses timestamps and prefers updated_at over created_at (via ranking._parse_timestamp), so it's correct regardless of tz representation and consistent with the recency-boost ranking. - app/mcp_server.py: search_memories validates `mode` and raises on unknown values, matching the REST API's strict 422 behavior. - tests: content_fp stripped, empty-query short-circuit (no store scan), updated_at-preferred ordering (incl. mixed Z/offset forms), MCP unknown mode raises ToolError. https://claude.ai/code/session_017835DVrvURaYnbQiPQwzue
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds an opt-in keyword/substring search mode alongside the existing semantic search (backlog issue #54). Semantic search ranks by meaning and can miss an exact term — a name, identifier, URL, or rare token — so
mode: "keyword"does a case-insensitive substring match over memory text, returning the most recent matches first. The default stays"semantic", so nothing changes unless you ask for it.Design / why no new Qdrant validation was needed
This reuses the exact
vector_store.list()+ payload path already validated against the live Qdrant for the dedup feature (#48) — thedatafield anduser_idfiltering are confirmed to work. The substring match runs in Python over the user's memories, so it needs no full-text index, no schema change, no migration on the live collection. It's:{"results": []}.user_id— spans the whole user store, consistent with the shared-pool model.Surface
POST /api/v1/memories/search: newmode: "semantic" | "keyword"(validated; an unknown value → 422).recency_weightapplies to semantic mode only.search_memories: newmodearg, with a docstring that tells the model to usekeywordfor exact terms.Files
app/memory.py—keyword_search()+_point_to_result()(shapes a Qdrant point into a result, dropping internaltext_lemmatized/dataplumbing).app/rest.py,app/mcp_server.py— route the keyword path.Tests
tests/test_memory.py—keyword_searchunit cases: case-insensitive substring +user_idscoping, newest-first ordering +limit, result field shaping (keepsagent_id/created_at, drops internals), no-match, and fail-open on store error.tests/test_rest.py/tests/test_mcp.py— integration: keyword mode bypasses vector search and uses the listing; invalid mode → 422; MCP exposes themodearg.ruffclean.Docs
memory.pydescription.Optional post-deploy check
After deploy:
-d '{"query":"Philips Hue","mode":"keyword"}'against/api/v1/memories/searchshould return memories literally containing that phrase, even ones semantic search ranks low.Closes #54.
https://claude.ai/code/session_017835DVrvURaYnbQiPQwzue
Generated by Claude Code