mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-07-28 13:26:48 +00:00
356 lines
12 KiB
Markdown
356 lines
12 KiB
Markdown
# Memory Primitive
|
|
|
|
The Memory primitive provides **persistent, searchable storage** for documents and knowledge. It enables context injection -- retrieving relevant information from indexed documents and prepending it to prompts so the LLM can answer questions grounded in specific content.
|
|
|
|
---
|
|
|
|
## MemoryBackend ABC
|
|
|
|
All memory backends implement the `MemoryBackend` abstract base class:
|
|
|
|
```python
|
|
class MemoryBackend(ABC):
|
|
backend_id: str
|
|
|
|
@abstractmethod
|
|
def store(
|
|
self,
|
|
content: str,
|
|
*,
|
|
source: str = "",
|
|
metadata: Optional[Dict[str, Any]] = None,
|
|
) -> str:
|
|
"""Persist *content* and return a unique document id."""
|
|
|
|
@abstractmethod
|
|
def retrieve(
|
|
self,
|
|
query: str,
|
|
*,
|
|
top_k: int = 5,
|
|
**kwargs: Any,
|
|
) -> List[RetrievalResult]:
|
|
"""Search for *query* and return the top-k results."""
|
|
|
|
@abstractmethod
|
|
def delete(self, doc_id: str) -> bool:
|
|
"""Delete a document by id. Return True if it existed."""
|
|
|
|
@abstractmethod
|
|
def clear(self) -> None:
|
|
"""Remove all stored documents."""
|
|
```
|
|
|
|
### RetrievalResult
|
|
|
|
Search results are returned as `RetrievalResult` objects:
|
|
|
|
```python
|
|
@dataclass(slots=True)
|
|
class RetrievalResult:
|
|
content: str # The document text
|
|
score: float = 0.0 # Relevance score (higher is better)
|
|
source: str = "" # Originating file path or identifier
|
|
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
```
|
|
|
|
---
|
|
|
|
## Backend Comparison
|
|
|
|
| Backend | Registry Key | Index Type | Extra Dependencies | GPU Required | Quality | Speed | Persistence |
|
|
|---------|-------------|-----------|-------------------|-------------|---------|-------|-------------|
|
|
| **SQLite/FTS5** | `sqlite` | Full-text (BM25) | None | No | Good | Fast | Disk (SQLite) |
|
|
| **FAISS** | `faiss` | Dense vector | `faiss-cpu`, `sentence-transformers` | Optional | Very Good | Fast | In-memory |
|
|
| **ColBERTv2** | `colbert` | Late interaction | `colbert-ai`, `torch` | Optional | Excellent | Slower | In-memory |
|
|
| **BM25** | `bm25` | Term-frequency | `rank-bm25` | No | Good | Fast | In-memory |
|
|
| **Hybrid** | `hybrid` | RRF fusion | Depends on sub-backends | Depends | Best | Moderate | Depends |
|
|
|
|
### SQLite/FTS5 (Default)
|
|
|
|
The zero-dependency default backend. Uses SQLite's built-in FTS5 extension for full-text search with BM25 ranking.
|
|
|
|
- **Storage:** Documents stored in a `documents` table with automatic FTS5 indexing via triggers
|
|
- **Search:** FTS5 `MATCH` queries with BM25 ranking (more negative rank = better match, converted to positive scores)
|
|
- **Query escaping:** Each word is quoted to avoid FTS5 syntax errors
|
|
- **Persistence:** Data persists across restarts in `~/.openjarvis/memory.db`
|
|
|
|
### FAISS
|
|
|
|
Dense retrieval using Facebook AI Similarity Search. Documents are embedded into vector space and searched via cosine similarity.
|
|
|
|
- **Index type:** `IndexFlatIP` (inner-product, equivalent to cosine similarity when vectors are L2-normalized)
|
|
- **Embedding model:** `all-MiniLM-L6-v2` by default (384-dim, ~22 MB)
|
|
- **Deletion:** Soft-delete (documents are marked as deleted but remain in the index)
|
|
- **Persistence:** In-memory only -- data is lost on restart
|
|
|
|
### ColBERTv2
|
|
|
|
Late interaction retrieval using token-level embeddings with MaxSim scoring. Provides the highest retrieval quality at the cost of higher latency.
|
|
|
|
- **Scoring:** For each query token, finds the maximum cosine similarity across all document tokens, then sums across query tokens
|
|
- **Checkpoint:** `colbert-ir/colbertv2.0` (lazily loaded on first use)
|
|
- **Persistence:** In-memory only
|
|
|
|
!!! warning "Heavy dependencies"
|
|
ColBERTv2 requires `colbert-ai` and `torch`, which are large packages. Install with:
|
|
`uv sync --extra memory-colbert`
|
|
|
|
### BM25
|
|
|
|
Classic Okapi BM25 probabilistic ranking function using the `rank_bm25` library.
|
|
|
|
- **Tokenization:** Lowercase whitespace split
|
|
- **Index:** Rebuilt on every `store()` and `delete()` operation
|
|
- **Filtering:** Results are filtered to require at least one shared token with the query (handles edge cases where BM25 assigns IDF=0)
|
|
- **Persistence:** In-memory only
|
|
|
|
### Hybrid (RRF Fusion)
|
|
|
|
Combines a sparse retriever and a dense retriever using Reciprocal Rank Fusion:
|
|
|
|
$$\text{RRF}(d) = \sum_{i} \frac{w_i}{k + \text{rank}_i(d)}$$
|
|
|
|
- **Sub-backends:** Any two `MemoryBackend` implementations (e.g., SQLite + FAISS)
|
|
- **Over-fetch:** Retrieves `top_k * 3` results from each sub-backend for better fusion
|
|
- **Configurable:** RRF constant `k` (default 60) and per-backend weights
|
|
|
|
```python
|
|
from openjarvis.tools.storage.sqlite import SQLiteMemory
|
|
from openjarvis.tools.storage.faiss_backend import FAISSMemory
|
|
from openjarvis.tools.storage.hybrid import HybridMemory
|
|
|
|
hybrid = HybridMemory(
|
|
sparse=SQLiteMemory(db_path="memory.db"),
|
|
dense=FAISSMemory(),
|
|
sparse_weight=1.0,
|
|
dense_weight=1.5, # Weight dense retrieval more heavily
|
|
)
|
|
```
|
|
|
|
!!! note "Backward compatibility"
|
|
The old imports (e.g., `from openjarvis.memory.sqlite import SQLiteMemory`) still work via backward-compatibility shims in the `memory/` package, but the canonical location is now `openjarvis.tools.storage.*`.
|
|
|
|
---
|
|
|
|
## Chunking Pipeline
|
|
|
|
Large documents are split into manageable chunks before storage. The chunking pipeline is defined in `tools/storage/chunking.py` (previously `memory/chunking.py`).
|
|
|
|
### ChunkConfig
|
|
|
|
```python
|
|
@dataclass(slots=True)
|
|
class ChunkConfig:
|
|
chunk_size: int = 512 # Maximum tokens per chunk (whitespace-split)
|
|
chunk_overlap: int = 64 # Tokens to overlap between consecutive chunks
|
|
min_chunk_size: int = 50 # Minimum tokens for a chunk to be kept
|
|
```
|
|
|
|
### Chunk
|
|
|
|
```python
|
|
@dataclass(slots=True)
|
|
class Chunk:
|
|
content: str # The chunk text
|
|
source: str = "" # Originating file path
|
|
offset: int = 0 # Token offset within the original document
|
|
index: int = 0 # Chunk index (0, 1, 2, ...)
|
|
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
```
|
|
|
|
### Chunking Algorithm
|
|
|
|
The `chunk_text()` function splits text using paragraph boundaries:
|
|
|
|
1. Split the document on double newlines (`\n\n`) into paragraphs
|
|
2. Accumulate paragraphs into the current chunk until `chunk_size` is exceeded
|
|
3. When a chunk is full, flush it and keep the last `chunk_overlap` tokens as overlap for the next chunk
|
|
4. If a single paragraph exceeds `chunk_size`, split it into fixed-size windows with overlap
|
|
5. Discard chunks smaller than `min_chunk_size`
|
|
|
|
```python
|
|
from openjarvis.tools.storage.chunking import chunk_text, ChunkConfig
|
|
|
|
config = ChunkConfig(chunk_size=256, chunk_overlap=32)
|
|
chunks = chunk_text(document_text, source="docs/guide.md", config=config)
|
|
```
|
|
|
|
---
|
|
|
|
## Document Ingestion
|
|
|
|
The `tools/storage/ingest.py` module (previously `memory/ingest.py`) handles reading files and directories into chunks.
|
|
|
|
### File Type Detection
|
|
|
|
| Extension | Detected Type |
|
|
|-----------|--------------|
|
|
| `.md`, `.markdown`, `.mdx` | `markdown` |
|
|
| `.pdf` | `pdf` |
|
|
| `.py`, `.js`, `.ts`, `.rs`, `.go`, `.java`, `.c`, `.cpp`, `.yaml`, `.json`, `.html`, `.css`, ... | `code` |
|
|
| Everything else | `text` |
|
|
|
|
### `ingest_path(path, config=None)`
|
|
|
|
Ingests a file or directory into chunks:
|
|
|
|
- **Single file:** Reads the file, detects its type, and chunks the content
|
|
- **Directory:** Recursively walks the tree, skipping:
|
|
- Hidden directories (starting with `.`)
|
|
- Common non-content directories (`__pycache__`, `node_modules`, `.git`, `.venv`, etc.)
|
|
- Binary files (images, audio, video, archives, compiled files)
|
|
- Hidden files (starting with `.`)
|
|
|
|
```python
|
|
from pathlib import Path
|
|
from openjarvis.tools.storage.ingest import ingest_path
|
|
|
|
# Ingest a single file
|
|
chunks = ingest_path(Path("docs/guide.md"))
|
|
|
|
# Ingest an entire directory
|
|
chunks = ingest_path(Path("./docs/"))
|
|
```
|
|
|
|
### PDF Support
|
|
|
|
PDF files are read using `pdfplumber`, extracting text from each page and joining with double newlines. This requires the optional `pdfplumber` dependency:
|
|
|
|
```bash
|
|
uv sync --extra memory-pdf
|
|
```
|
|
|
|
---
|
|
|
|
## Embeddings
|
|
|
|
Dense retrieval backends (FAISS, ColBERT) require text embeddings. The `tools/storage/embeddings.py` module (previously `memory/embeddings.py`) provides the `Embedder` ABC and a default implementation.
|
|
|
|
### Embedder ABC
|
|
|
|
```python
|
|
class Embedder(ABC):
|
|
@abstractmethod
|
|
def embed(self, texts: list[str]) -> Any:
|
|
"""Embed texts and return a numpy array of shape (n, dim)."""
|
|
|
|
@abstractmethod
|
|
def dim(self) -> int:
|
|
"""Return the dimensionality of the embedding vectors."""
|
|
```
|
|
|
|
### SentenceTransformerEmbedder
|
|
|
|
The default embedder wraps the `sentence-transformers` library:
|
|
|
|
- **Default model:** `all-MiniLM-L6-v2` (384 dimensions, ~22 MB)
|
|
- **Output:** NumPy arrays of shape `(n, dim)`
|
|
|
|
```python
|
|
from openjarvis.tools.storage.embeddings import SentenceTransformerEmbedder
|
|
|
|
embedder = SentenceTransformerEmbedder(model_name="all-MiniLM-L6-v2")
|
|
vectors = embedder.embed(["Hello world", "How are you?"])
|
|
# Shape: (2, 384)
|
|
```
|
|
|
|
---
|
|
|
|
## Context Injection
|
|
|
|
The context injection pipeline retrieves relevant documents and prepends them to the prompt with source attribution. This is defined in `tools/storage/context.py` (previously `memory/context.py`).
|
|
|
|
### ContextConfig
|
|
|
|
```python
|
|
@dataclass(slots=True)
|
|
class ContextConfig:
|
|
enabled: bool = True # Whether context injection is active
|
|
top_k: int = 5 # Maximum results to retrieve
|
|
min_score: float = 0.1 # Minimum relevance score threshold
|
|
max_context_tokens: int = 2048 # Maximum tokens of context to inject
|
|
```
|
|
|
|
### `inject_context()`
|
|
|
|
The main function for context injection:
|
|
|
|
```python
|
|
def inject_context(
|
|
query: str,
|
|
messages: List[Message],
|
|
backend: MemoryBackend,
|
|
*,
|
|
config: Optional[ContextConfig] = None,
|
|
) -> List[Message]:
|
|
```
|
|
|
|
How it works:
|
|
|
|
1. Retrieves results from the memory backend using the query
|
|
2. Filters results below `min_score`
|
|
3. Truncates to `max_context_tokens` (approximate token count via whitespace split)
|
|
4. Formats results with source attribution tags: `[Source: docs/guide.md] The content...`
|
|
5. Creates a system message with the formatted context
|
|
6. Returns a **new** message list with the context message prepended
|
|
|
|
```python
|
|
from openjarvis.tools.storage.context import inject_context, ContextConfig
|
|
|
|
config = ContextConfig(top_k=3, min_score=0.2)
|
|
messages = inject_context("What is the API?", messages, backend, config=config)
|
|
```
|
|
|
|
### Source Attribution
|
|
|
|
Context is injected as a system message with clear source tags:
|
|
|
|
```
|
|
The following context was retrieved from the knowledge base. Use it to
|
|
inform your response, citing sources where applicable:
|
|
|
|
[Source: docs/api.md] The API exposes a /v1/chat/completions endpoint...
|
|
|
|
[Source: docs/setup.md] To configure the API server, edit config.toml...
|
|
```
|
|
|
|
---
|
|
|
|
## Backend Registration
|
|
|
|
Memory backends are registered via the `@MemoryRegistry.register("name")` decorator:
|
|
|
|
```python
|
|
from openjarvis.core.registry import MemoryRegistry
|
|
from openjarvis.tools.storage._stubs import MemoryBackend
|
|
|
|
@MemoryRegistry.register("my-backend")
|
|
class MyMemoryBackend(MemoryBackend):
|
|
backend_id = "my-backend"
|
|
|
|
def store(self, content, *, source="", metadata=None) -> str: ...
|
|
def retrieve(self, query, *, top_k=5, **kwargs) -> list: ...
|
|
def delete(self, doc_id) -> bool: ...
|
|
def clear(self) -> None: ...
|
|
```
|
|
|
|
The default backend is configured in `~/.openjarvis/config.toml`. Storage settings live under `[tools.storage]`, and context injection is controlled by `agent.context_from_memory`:
|
|
|
|
```toml
|
|
[agent]
|
|
context_from_memory = true
|
|
|
|
[tools.storage]
|
|
default_backend = "sqlite"
|
|
db_path = "~/.openjarvis/memory.db"
|
|
context_top_k = 5
|
|
context_min_score = 0.1
|
|
context_max_tokens = 2048
|
|
chunk_size = 512
|
|
chunk_overlap = 64
|
|
```
|
|
|
|
!!! note "Backward compatibility"
|
|
The `[memory]` TOML section is still accepted as a backward-compatible alias for `[tools.storage]`. The old `context_injection` field is automatically migrated to `agent.context_from_memory` at load time.
|