forked from SpeedyFoxAi/jarvis-memory
243 lines
7.9 KiB
Python
Executable File
243 lines
7.9 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Search memory: First Redis (exact), then Qdrant (semantic).
|
|
|
|
Usage: python3 search_mem.py "your search query" [--limit 10] [--user-id rob]
|
|
|
|
Searches:
|
|
1. Redis (mem:{user_id}) - exact text match in recent buffer
|
|
2. Qdrant (kimi_memories) - semantic similarity search
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import json
|
|
import redis
|
|
import argparse
|
|
from pathlib import Path
|
|
from datetime import datetime
|
|
|
|
# Config
|
|
REDIS_HOST = os.getenv("REDIS_HOST", "10.0.0.36")
|
|
REDIS_PORT = int(os.getenv("REDIS_PORT", "6379"))
|
|
USER_ID = os.getenv("USER_ID", "yourname")
|
|
|
|
QDRANT_URL = os.getenv("QDRANT_URL", "http://10.0.0.40:6333")
|
|
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://10.0.0.10:11434/v1")
|
|
|
|
def search_redis(query, user_id, limit=20):
|
|
"""Search Redis buffer for exact text matches."""
|
|
try:
|
|
r = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, decode_responses=True)
|
|
key = f"mem:{user_id}"
|
|
|
|
# Get all items from list
|
|
items = r.lrange(key, 0, -1)
|
|
if not items:
|
|
return []
|
|
|
|
query_lower = query.lower()
|
|
matches = []
|
|
|
|
for item in items:
|
|
try:
|
|
turn = json.loads(item)
|
|
content = turn.get('content', '').lower()
|
|
if query_lower in content:
|
|
matches.append({
|
|
'source': 'redis',
|
|
'turn': turn.get('turn'),
|
|
'role': turn.get('role'),
|
|
'content': turn.get('content'),
|
|
'timestamp': turn.get('timestamp'),
|
|
'score': 'exact'
|
|
})
|
|
except json.JSONDecodeError:
|
|
continue
|
|
|
|
# Sort by turn number descending (newest first)
|
|
matches.sort(key=lambda x: x.get('turn', 0), reverse=True)
|
|
return matches[:limit]
|
|
except Exception as e:
|
|
print(f"Redis search error: {e}", file=sys.stderr)
|
|
return []
|
|
|
|
def get_embedding(text):
|
|
"""Get embedding from Ollama."""
|
|
import urllib.request
|
|
|
|
payload = json.dumps({
|
|
"model": "snowflake-arctic-embed2",
|
|
"input": text
|
|
}).encode()
|
|
|
|
req = urllib.request.Request(
|
|
f"{OLLAMA_URL}/embeddings",
|
|
data=payload,
|
|
headers={"Content-Type": "application/json"},
|
|
method="POST"
|
|
)
|
|
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
result = json.loads(resp.read().decode())
|
|
return result.get('data', [{}])[0].get('embedding')
|
|
except Exception as e:
|
|
print(f"Embedding error: {e}", file=sys.stderr)
|
|
return None
|
|
|
|
def search_qdrant(query, user_id, limit=10):
|
|
"""Search Qdrant for semantic similarity."""
|
|
import urllib.request
|
|
|
|
embedding = get_embedding(query)
|
|
if not embedding:
|
|
return []
|
|
|
|
payload = json.dumps({
|
|
"vector": embedding,
|
|
"limit": limit,
|
|
"with_payload": True,
|
|
"filter": {
|
|
"must": [
|
|
{"key": "user_id", "match": {"value": user_id}}
|
|
]
|
|
}
|
|
}).encode()
|
|
|
|
req = urllib.request.Request(
|
|
f"{QDRANT_URL}/collections/kimi_memories/points/search",
|
|
data=payload,
|
|
headers={"Content-Type": "application/json"},
|
|
method="POST"
|
|
)
|
|
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
result = json.loads(resp.read().decode())
|
|
points = result.get('result', [])
|
|
|
|
matches = []
|
|
for point in points:
|
|
payload = point.get('payload', {})
|
|
matches.append({
|
|
'source': 'qdrant',
|
|
'score': round(point.get('score', 0), 3),
|
|
'turn': payload.get('turn_number'),
|
|
'role': payload.get('role'),
|
|
'content': payload.get('user_message') or payload.get('content', ''),
|
|
'ai_response': payload.get('ai_response', ''),
|
|
'timestamp': payload.get('timestamp'),
|
|
'conversation_id': payload.get('conversation_id')
|
|
})
|
|
return matches
|
|
except Exception as e:
|
|
print(f"Qdrant search error: {e}", file=sys.stderr)
|
|
return []
|
|
|
|
def format_result(result, index):
|
|
"""Format a single search result."""
|
|
source = result.get('source', 'unknown')
|
|
role = result.get('role', 'unknown')
|
|
turn = result.get('turn', '?')
|
|
score = result.get('score', '?')
|
|
|
|
content = result.get('content', '')
|
|
if len(content) > 200:
|
|
content = content[:200] + "..."
|
|
|
|
# Role emoji
|
|
role_emoji = "👤" if role == "user" else "🤖"
|
|
|
|
# Source indicator
|
|
source_icon = "🔴" if source == "redis" else "🔵"
|
|
|
|
lines = [
|
|
f"{source_icon} [{index}] Turn {turn} ({role}):",
|
|
f" {role_emoji} {content}"
|
|
]
|
|
|
|
if source == "qdrant" and result.get('ai_response'):
|
|
ai_resp = result['ai_response'][:150]
|
|
if len(result['ai_response']) > 150:
|
|
ai_resp += "..."
|
|
lines.append(f" 💬 AI: {ai_resp}")
|
|
|
|
if score != 'exact':
|
|
lines.append(f" 📊 Score: {score}")
|
|
else:
|
|
lines.append(f" 📊 Match: exact (Redis)")
|
|
|
|
return "\n".join(lines)
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Search memory: Redis first, then Qdrant")
|
|
parser.add_argument("query", help="Search query")
|
|
parser.add_argument("--limit", type=int, default=10, help="Results per source (default: 10)")
|
|
parser.add_argument("--user-id", default=USER_ID, help="User ID")
|
|
parser.add_argument("--redis-only", action="store_true", help="Only search Redis")
|
|
parser.add_argument("--qdrant-only", action="store_true", help="Only search Qdrant")
|
|
args = parser.parse_args()
|
|
|
|
print(f"🔍 Searching for: \"{args.query}\"\n")
|
|
|
|
all_results = []
|
|
|
|
# Search Redis first (unless qdrant-only)
|
|
if not args.qdrant_only:
|
|
print("📍 Searching Redis (exact match)...")
|
|
redis_results = search_redis(args.query, args.user_id, limit=args.limit)
|
|
if redis_results:
|
|
print(f"✅ Found {len(redis_results)} matches in Redis\n")
|
|
all_results.extend(redis_results)
|
|
else:
|
|
print("❌ No exact matches in Redis\n")
|
|
|
|
# Search Qdrant (unless redis-only)
|
|
if not args.redis_only:
|
|
print("🧠 Searching Qdrant (semantic similarity)...")
|
|
qdrant_results = search_qdrant(args.query, args.user_id, limit=args.limit)
|
|
if qdrant_results:
|
|
print(f"✅ Found {len(qdrant_results)} matches in Qdrant\n")
|
|
all_results.extend(qdrant_results)
|
|
else:
|
|
print("❌ No semantic matches in Qdrant\n")
|
|
|
|
# Display results
|
|
if not all_results:
|
|
print("No results found in either Redis or Qdrant.")
|
|
sys.exit(0)
|
|
|
|
print(f"=== Search Results ({len(all_results)} total) ===\n")
|
|
|
|
# Sort: Redis first (chronological), then Qdrant (by score)
|
|
redis_sorted = [r for r in all_results if r['source'] == 'redis']
|
|
qdrant_sorted = sorted(
|
|
[r for r in all_results if r['source'] == 'qdrant'],
|
|
key=lambda x: x.get('score', 0),
|
|
reverse=True
|
|
)
|
|
|
|
# Display Redis results first
|
|
if redis_sorted:
|
|
print("🔴 FROM REDIS (Recent Buffer):\n")
|
|
for i, result in enumerate(redis_sorted, 1):
|
|
print(format_result(result, i))
|
|
print()
|
|
|
|
# Then Qdrant results
|
|
if qdrant_sorted:
|
|
print("🔵 FROM QDRANT (Long-term Memory):\n")
|
|
for i, result in enumerate(qdrant_sorted, len(redis_sorted) + 1):
|
|
print(format_result(result, i))
|
|
print()
|
|
|
|
print(f"=== {len(all_results)} results ===")
|
|
if redis_sorted:
|
|
print(f" 🔴 Redis: {len(redis_sorted)} (exact, recent)")
|
|
if qdrant_sorted:
|
|
print(f" 🔵 Qdrant: {len(qdrant_sorted)} (semantic, long-term)")
|
|
|
|
if __name__ == "__main__":
|
|
main()
|