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 Type | Cosine Distance to Session Chunk | Result |
|---|---|---|
| Exact chunk phrase | 0.0000 | Rank 1 (self) |
| Sibling chunk phrase | 0.0688β0.1401 | Ranks 2β3 |
| Short factual query | 0.65β0.78 | Not 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
- Query Vector Generation: A short factual query like "Prius fuel economy" produces a vector anchored to concise terminology.
- KNN Pool Creation: The query vector lands in embedding space region far from session transcript chunks (cosine distance ~0.65β0.78).
- Session Chunk Exclusion: Session transcript chunks (~900β1800 char dialogue blobs in
User: .../Assistant: ...format) fail the KNN distance threshold. - 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 Type | Format | Avg Token Count | Embedding Region |
|---|---|---|---|
| Session Transcript | User: …/Assistant: … | ~900β1800 chars | Tight cluster (dist 0.08) |
| Factual Query | Short phrase | ~3β8 words | Unaligned 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:
- Parallelize KNN and FTS5 query execution
- Merge candidate sets before scoring
- Normalize vectorScore and textScore to common scale
- Update
minScoreevaluation 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
- Create a conversation mentioning a specific fact (e.g., “my cat’s name is Whiskers”)
- Close the session
- Run memory search for “cat name Whiskers”
- 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”
π Related Errors
Related Issue: #48711 β Broad Memory Recall Reliability
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.
Related Issue: #51386 β Graduate sessionMemory from Experimental
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.
Related Error: E_FTS_INDEX_NOT_FOUND
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
Related Error: E_VECTOR_DIMENSION_MISMATCH
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
Related Error: E_KNN_TIMEOUT
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
Related Warning: W_SESSION_CHUNK_NOT_EMBEDDED
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
Related Warning: W_BM25_NEGATIVE_SCALE
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.