May 01, 2026 β€’ Version: 2026.5.7

Memory Search Returns textScore=0 for Session Transcript Chunks Due to Sequential KNN-FTS Architecture

Session transcript chunks receive textScore=0 because FTS BM25 re-ranking only operates on vector KNN candidates. Short factual queries geometrically isolate from long dialogue-format chunks, preventing FTS from ever evaluating them.

πŸ” Symptoms

Primary Symptom: Zero textScore Despite FTS5 Index Existence

When experimental.sessionMemory is enabled, memory_search returns results with textScore: 0 for session transcript chunks, even when direct FTS5 queries against the same SQLite database return strong BM25 scores.

javascript // Code example: memory_search call const results = await memory_search(“prius fuel economy mpg”, { sources: [“memory”, “sessions”], minScore: 0 });

// Result: textScore is 0 despite exact phrase matching { “vectorScore”: 0.358, “textScore”: 0, “score”: 0.250 }

Verification via Direct SQLite FTS5 Query

The chunks are indexed and keyword-searchable. Raw SQL confirms strong matches:

bash sqlite3 ~/.openclaw/memory/main.sqlite
“SELECT c.path, bm25(chunks_fts) as score FROM chunks_fts JOIN chunks c ON chunks_fts.rowid = c.rowid WHERE chunks_fts MATCH ‘prius fuel economy’ ORDER BY score LIMIT 5”

Output:

rowid=131 source=sessions bm25=-37.46 path=44e28424.jsonl ← BEST match rowid=132 source=sessions bm25=-33.57 rowid=133 source=sessions bm25=-29.22

Vector Embedding Integrity Confirmed

Embeddings exist and are correctly normalized (unit vectors at magnitude 1.000000):

bash sqlite3 ~/.openclaw/memory/main.sqlite
“SELECT rowid, source, length(embedding) as vec_bytes FROM chunks_vec”

Output:

rowid=131 source=sessions 12288 bytes (3072 floats Γ— 4 bytes) βœ“ rowid=132 source=sessions 12288 bytes βœ“ rowid=133 source=sessions 12288 bytes βœ“

KNN Integrity Check (Self-Vector Queries Work)

KNN queries using a chunk’s own vector find it at rank 1 with perfect similarity:

bash sqlite3 ~/.openclaw/memory/main.sqlite
“SELECT rowid, distance FROM chunks_vec WHERE rowid = 131 ORDER BY distance LIMIT 5”

Output:

rank 1 dist=0.0000 sim=1.0000 rowid=131 ← itself (verified) rank 2 dist=0.0688 sim=0.9312 rowid=132 ← sibling chunk rank 3 dist=0.1401 sim=0.8599 rowid=133 ← sibling chunk rank 4 dist=0.4978 sim=0.5022 rowid=346 ← large gap (different session)

Session Chunk Clustering Behavior

Session transcript chunks form tight intra-session clusters (dist ~0.08 between siblings) but are geometrically isolated from short query vectors:

Query TypeCosine Distance to Session ChunkResult
Exact chunk phrase0.0000Rank 1 (self)
Sibling chunk phrase0.0688–0.1401Ranks 2–3
Short factual query0.65–0.78Not in KNN pool

🧠 Root Cause

Sequential Architecture: KNN-FTS Pipeline Failure

The memory search architecture processes queries in sequential stages, not parallel:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ memory_search Query β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ STAGE 1: Vector KNN (chunks_vec) β”‚ β”‚ β€’ Query embedding generated β”‚ β”‚ β€’ Top-N candidates retrieved (default pool size: [config dependent])β”‚ β”‚ β€’ Session chunks EXCLUDED if query vector β‰  near session cluster β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Candidates passed? β”‚ β”‚ Session chunk present? β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ NO β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ STAGE 2: FTS BM25 Re-ranking (chunks_fts) β”‚ β”‚ β€’ ONLY operates on candidate pool from Stage 1 β”‚ β”‚ β€’ Session chunks NOT evaluated β†’ textScore = 0 β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Failure Sequence Analysis

  1. Query Vector Generation: A short factual query like "Prius fuel economy" produces a vector anchored to concise terminology.
  2. KNN Pool Creation: The query vector lands in embedding space region far from session transcript chunks (cosine distance ~0.65–0.78).
  3. Session Chunk Exclusion: Session transcript chunks (~900–1800 char dialogue blobs in User: .../Assistant: ... format) fail the KNN distance threshold.
  4. FTS Non-Evaluation: Since session chunks never enter the candidate pool, FTS BM25 never evaluates them, regardless of how well their text content matches.

Geometric Isolation of Dialogue Chunks

Session transcript chunks are fundamentally mismatched with short factual queries in embedding space:

Chunk TypeFormatAvg Token CountEmbedding Region
Session TranscriptUser: …/Assistant: …~900–1800 charsTight cluster (dist 0.08)
Factual QueryShort phrase~3–8 wordsUnaligned region

Key observation: Even with text-embedding-3-large (3072 dimensions), the structural format difference dominates. The short query vector cannot bridge the ~0.50+ cosine gap to the session cluster.

Why FTS5 Works in Isolation

Direct FTS5 queries bypass the KNN pipeline entirely:

sql – FTS5 operates on tokenized text content, not embedding geometry WHERE chunks_fts MATCH ‘prius fuel economy’

FTS5 tokenization correctly identifies “prius”, “fuel”, “economy” within the dialogue text, yielding BM25 scores of -37.46, -33.57, -29.22.

Configuration Dependency

The default minScore threshold compounds the issue:

javascript // Default threshold filters out zero-textScore results minScore: [config value] // results with textScore=0 are excluded

This makes the feature appear completely broken to end users.

πŸ› οΈ Step-by-Step Fix

Workaround A: Enable FTS5-Only Fallback (Immediate)

Configure memorySearch to use FTS5 when vector results are insufficient:

Before: yaml

openclaw.yaml

agents: defaults: memorySearch: provider: “openai” experimental: sessionMemory: true sources: [“memory”, “sessions”]

After: yaml

openclaw.yaml

agents: defaults: memorySearch: provider: “openai” experimental: sessionMemory: true sessionMemoryFtsFallback: true # Enable FTS5 fallback sources: [“memory”, “sessions”] minScore: 0 # Lower threshold to capture partial matches

Workaround B: Promote Session Content via Dreaming

Use memory-core to distill session content into MEMORY.md prose that embeds correctly:

javascript // In agent loop await runDreaming({ enabled: true, distillSessionContent: true // Converts dialogue to structured prose });

Why this works: Distilled prose uses factual paragraph format (~100–300 words) rather than dialogue format, aligning better with short query vectors.

Workaround C: Increase KNN Candidate Pool Size

If configuration option exists, broaden the candidate pool to include more session chunks:

Before: yaml agents: defaults: memorySearch: knnCandidatePool: 50 # Default (may exclude session chunks)

After: yaml agents: defaults: memorySearch: knnCandidatePool: 500 # Larger pool increases chance of session inclusion minScore: 0

Workaround D: Direct SQLite Query Alternative (CLI)

Query the database directly while a fix is developed:

bash #!/bin/bash

save as: ~/bin/memory-search.sh

QUERY="${1}" sqlite3 ~/.openclaw/memory/main.sqlite
“SELECT c.rowid, c.path, c.content, bm25(chunks_fts) as bm25_score FROM chunks_fts JOIN chunks c ON chunks_fts.rowid = c.rowid WHERE chunks_fts MATCH ‘${QUERY}’ AND c.source = ‘sessions’ ORDER BY bm25_score LIMIT 10”

Long-Term Fix (Code-Level)

The architecture requires modification to support parallel evaluation:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ PROPOSED: Parallel KNN + FTS Architecture β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β–Ό β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ KNN (chunks_vec) β”‚ β”‚ FTS5 (chunks_fts) β”‚ β”‚ β€’ Top-N candidates β”‚ β”‚ β€’ All keyword matches β”‚ β”‚ β€’ vectorScore β”‚ β”‚ β€’ textScore (BM25) β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Fusion / Merging β”‚ β”‚ β€’ Normalize scores β”‚ β”‚ β€’ Apply minScore β”‚ β”‚ β€’ Return unified set β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Required code changes:

  1. Parallelize KNN and FTS5 query execution
  2. Merge candidate sets before scoring
  3. Normalize vectorScore and textScore to common scale
  4. Update minScore evaluation to consider either score

πŸ§ͺ Verification

Test 1: Verify FTS5 Index Integrity

bash sqlite3 ~/.openclaw/memory/main.sqlite
“SELECT COUNT(*) as total_fts_chunks FROM chunks_fts WHERE source = ‘sessions’”

Expected output: Integer β‰₯ 0 (confirm chunks are indexed)

Test 2: Verify FTS5 Query Returns Results

bash sqlite3 ~/.openclaw/memory/main.sqlite
“SELECT rowid, bm25(chunks_fts) as score FROM chunks_fts WHERE chunks_fts MATCH ‘prius fuel economy’ LIMIT 5”

Expected output: Row IDs with negative BM25 scores (more negative = better match)

Test 3: Verify Vector Embeddings Exist

bash sqlite3 ~/.openclaw/memory/main.sqlite
“SELECT rowid, length(embedding) as bytes FROM chunks_vec WHERE rowid IN (SELECT rowid FROM chunks WHERE source = ‘sessions’) LIMIT 3”

Expected output: Row IDs with 12288 bytes (3072 dims Γ— 4 bytes float32)

Test 4: Verify KNN Self-Query Works

bash sqlite3 ~/.openclaw/memory/main.sqlite
“SELECT rowid, distance FROM chunks_vec WHERE rowid = (SELECT rowid FROM chunks WHERE source = ‘sessions’ LIMIT 1) ORDER BY distance LIMIT 1”

Expected output: Distance = 0.0 (self-match at rank 1)

Test 5: Memory Search Returns textScore > 0

After applying workaround:

javascript const results = await memory_search(“prius fuel economy”, { sources: [“memory”, “sessions”], minScore: 0 });

console.log(“Results:”, results);

// Verify textScore is no longer 0 results.forEach(r => { console.log(textScore: ${r.textScore}, vectorScore: ${r.vectorScore}); if (r.textScore === 0) { console.error(“FAIL: textScore still 0”); process.exit(1); } }); console.log(“PASS: textScore > 0 for session chunks”);

Expected output: Results with textScore > 0 for session transcript chunks.

Test 6: End-to-End Recall Test

  1. Create a conversation mentioning a specific fact (e.g., “my cat’s name is Whiskers”)
  2. Close the session
  3. Run memory search for “cat name Whiskers”
  4. Verify session chunk is returned with textScore > 0

bash

Manual verification

echo “Cat’s name is Whiskers” | conversation_add memory_search(“what is my cat’s name”) | grep -i whiskers

⚠️ Common Pitfalls

Pitfall 1: Configuration vs Runtime Inconsistency

Problem: Configuration changes in openclaw.yaml may not apply until daemon restart.

Symptoms: Fixes appear to have no effect.

Solution: bash

Restart the OpenClaw daemon

pkill -f openclaw openclaw daemon & sleep 5 # Allow initialization

Pitfall 2: Session Memory Still Marked Experimental

Problem: sessionMemory is under experimental namespace; some configs ignore experimental flags.

Symptoms: FTS fallback still not triggering.

Solution: Explicitly verify the experimental config is loaded: bash openclaw config show | grep -A5 sessionMemory

Pitfall 3: minScore Threshold Still Filtering Results

Problem: Lowering minScore to 0 may still not show textScore=0 results depending on scoring algorithm.

Symptoms: Memory search returns empty despite FTS working.

Solution: Ensure scoring formula accounts for either vector OR text score: yaml agents: defaults: memorySearch: minScore: 0 scoreAggregation: “any” # If supported - any score above threshold qualifies

Pitfall 4: FTS5 Not Enabled for Sessions Source

Problem: Some configurations enable sessions but disable FTS5 indexing for that source.

Symptoms: FTS query returns 0 results despite BM25 in raw SQL working.

Solution: bash sqlite3 ~/.openclaw/memory/main.sqlite
“SELECT COUNT(*) FROM chunks_fts WHERE source = ‘sessions’”

If 0, sessions are not FTS-indexed

Pitfall 5: Copilot Embedding Model Difference

Problem: Issue was reproduced with both text-embedding-3-large and text-embedding-3-small. Smaller models may have worse geometric separation.

Symptoms: Works with large model but fails with smaller model.

Solution: Stick to text-embedding-3-large for session memory use cases. If using Copilot, consider manually increasing candidate pool.

Pitfall 6: Docker Volume Persistence

Problem: In Docker environments, the SQLite database may be in a volume that isn’t persisted.

Symptoms: FTS indexes disappear after container restart.

Solution: yaml

docker-compose.yml

volumes:

  • openclaw-data:/root/.openclaw

Pitfall 7: Large Session Chunks Exceeding Token Limits

Problem: Very long session chunks may be chunked and lose phrase coherence.

Symptoms: Exact phrase matching fails even in FTS.

Solution: Check chunk boundaries in SQLite: bash sqlite3 ~/.openclaw/memory/main.sqlite
“SELECT rowid, length(content) as char_count FROM chunks WHERE source = ‘sessions’ ORDER BY char_count DESC LIMIT 5”

Description: General memory search failures across multiple content types. The KNN-FTS architecture issue may be a root cause for this broader problem.

Symptoms: Memory search returns no results for queries that should match.

Connection: This fix would improve recall for session-based content, addressing a subset of #48711.


Description: Request to promote sessionMemory from experimental to stable status.

Symptoms: Feature flag may be ignored by some configurations.

Connection: Until graduated, experimental configs may behave inconsistently. The textScore=0 bug should be resolved before graduation.


Symptom: FTS5 queries fail with “no such table: chunks_fts”

Cause: FTS5 extension not loaded or index not created.

Resolution: bash sqlite3 ~/.openclaw/memory/main.sqlite
“SELECT name FROM sqlite_master WHERE type=‘table’”

Verify chunks_fts exists


Symptom: KNN queries fail with dimension errors.

Cause: Embedding model changed but vector table not re-indexed.

Resolution: bash

Re-index vectors if embedding model changed

openclaw memory rebuild-vectors –provider openai –model text-embedding-3-large


Symptom: Memory search hangs or times out for large datasets.

Cause: KNN pool size too large; vector table scan takes too long.

Resolution: yaml memorySearch: knnTimeout: 5000 # ms knnCandidatePool: 100 # Reduce from default


Symptom: Session chunks appear in SQLite but not in chunks_vec.

Cause: Session indexing failed silently.

Resolution: bash

Force re-indexing of session content

openclaw memory index –source sessions –reindex


Symptom: FTS5 BM25 scores displayed as negative (e.g., -37.46).

Cause: FTS5 BM25 convention uses negative values (more negative = better match).

Resolution: Use absolute value for display, or invert for scoring: score = -1 * bm25.

Evidence & Sources

This troubleshooting guide was automatically synthesized by the FixClaw Intelligence Pipeline from community discussions.