Basic store for chunks and documents
This commit is contained in:
parent
4219cddea4
commit
5d1a854934
6 changed files with 324 additions and 0 deletions
4
src/haiku/rag/store/__init__.py
Normal file
4
src/haiku/rag/store/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
from .engine import Store
|
||||||
|
from .models import Chunk, Document
|
||||||
|
|
||||||
|
__all__ = ["Store", "Chunk", "Document"]
|
||||||
70
src/haiku/rag/store/engine.py
Normal file
70
src/haiku/rag/store/engine.py
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import sqlite3
|
||||||
|
import struct
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
import sqlite_vec
|
||||||
|
|
||||||
|
from haiku.rag.embeddings.ollama import Embedder
|
||||||
|
|
||||||
|
|
||||||
|
class Store:
|
||||||
|
def __init__(self, db_path: Path | Literal[":memory:"]):
|
||||||
|
self.db_path: Path | Literal[":memory:"] = db_path
|
||||||
|
self._connection = self.create_db()
|
||||||
|
|
||||||
|
def create_db(self) -> sqlite3.Connection:
|
||||||
|
"""Create the database and tables with sqlite-vec support for embeddings."""
|
||||||
|
db = sqlite3.connect(self.db_path)
|
||||||
|
db.enable_load_extension(True)
|
||||||
|
sqlite_vec.load(db)
|
||||||
|
|
||||||
|
# Create documents table
|
||||||
|
db.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS documents (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
metadata TEXT DEFAULT '{}',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Create chunks table
|
||||||
|
db.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS chunks (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
document_id INTEGER NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
metadata TEXT DEFAULT '{}',
|
||||||
|
FOREIGN KEY (document_id) REFERENCES documents (id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Create vector table for chunk embeddings
|
||||||
|
embedder = Embedder()
|
||||||
|
db.execute(f"""
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS chunk_embeddings USING vec0(
|
||||||
|
chunk_id INTEGER PRIMARY KEY,
|
||||||
|
embedding FLOAT[{embedder._vector_dim}]
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Create indexes for better performance
|
||||||
|
db.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_chunks_document_id ON chunks(document_id)"
|
||||||
|
)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return db
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def serialize_embedding(embedding: list[float]) -> bytes:
|
||||||
|
"""Serialize a list of floats to bytes for sqlite-vec storage."""
|
||||||
|
return struct.pack(f"{len(embedding)}f", *embedding)
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
"""Close the database connection if it's an in-memory database."""
|
||||||
|
if self._connection is not None:
|
||||||
|
self._connection.close()
|
||||||
|
self._connection = None
|
||||||
4
src/haiku/rag/store/models/__init__.py
Normal file
4
src/haiku/rag/store/models/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
from .chunk import Chunk
|
||||||
|
from .document import Document
|
||||||
|
|
||||||
|
__all__ = ["Chunk", "Document"]
|
||||||
12
src/haiku/rag/store/models/chunk.py
Normal file
12
src/haiku/rag/store/models/chunk.py
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class Chunk(BaseModel):
|
||||||
|
"""
|
||||||
|
Represents a document with an ID, content, and metadata.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: int | None = None
|
||||||
|
document_id: int
|
||||||
|
content: str
|
||||||
|
metadata: dict = {}
|
||||||
145
src/haiku/rag/store/models/document.py
Normal file
145
src/haiku/rag/store/models/document.py
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from haiku.rag.chunker import chunker
|
||||||
|
from haiku.rag.embeddings.ollama import Embedder
|
||||||
|
from haiku.rag.store.models.chunk import Chunk
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from haiku.rag.store.engine import Store
|
||||||
|
|
||||||
|
|
||||||
|
class Document(BaseModel):
|
||||||
|
"""
|
||||||
|
Represents a document with an ID, content, and metadata.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: int | None = None
|
||||||
|
content: str
|
||||||
|
metadata: dict = {}
|
||||||
|
created_at: datetime = Field(default_factory=datetime.now)
|
||||||
|
updated_at: datetime = Field(default_factory=datetime.now)
|
||||||
|
|
||||||
|
async def create_with_chunks(self, store: "Store") -> "Document":
|
||||||
|
"""
|
||||||
|
Create a document in the database along with its chunks and embeddings.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
store: The Store instance to use for database operations
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Document: The created document with updated id
|
||||||
|
"""
|
||||||
|
if store._connection is None:
|
||||||
|
raise ValueError("Store connection is not available")
|
||||||
|
|
||||||
|
cursor = store._connection.cursor()
|
||||||
|
embedder = Embedder()
|
||||||
|
|
||||||
|
# Insert the document
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO documents (content, metadata, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
self.content,
|
||||||
|
json.dumps(self.metadata),
|
||||||
|
self.created_at,
|
||||||
|
self.updated_at,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
document_id = cursor.lastrowid
|
||||||
|
assert document_id is not None, "Failed to create document in database"
|
||||||
|
self.id = document_id
|
||||||
|
|
||||||
|
# Chunk the document content
|
||||||
|
chunk_texts = await chunker.chunk(self.content)
|
||||||
|
|
||||||
|
# Create chunks with embeddings
|
||||||
|
for order, chunk_text in enumerate(chunk_texts):
|
||||||
|
# Create chunk with order in metadata
|
||||||
|
chunk = Chunk(
|
||||||
|
document_id=document_id, content=chunk_text, metadata={"order": order}
|
||||||
|
)
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO chunks (document_id, content, metadata)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
""",
|
||||||
|
(chunk.document_id, chunk.content, json.dumps(chunk.metadata)),
|
||||||
|
)
|
||||||
|
chunk_id = cursor.lastrowid
|
||||||
|
|
||||||
|
# Generate and store embedding
|
||||||
|
embedding = await embedder.embed(chunk_text)
|
||||||
|
serialized_embedding = store.serialize_embedding(embedding)
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO chunk_embeddings (chunk_id, embedding)
|
||||||
|
VALUES (?, ?)
|
||||||
|
""",
|
||||||
|
(chunk_id, serialized_embedding),
|
||||||
|
)
|
||||||
|
|
||||||
|
store._connection.commit()
|
||||||
|
return self
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def search_chunks(
|
||||||
|
cls, store: "Store", query: str, limit: int = 5
|
||||||
|
) -> list[Chunk]:
|
||||||
|
"""
|
||||||
|
Search for relevant chunks using vector similarity with sqlite-vec.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
store: The Store instance to use for database operations
|
||||||
|
query: The text query to search for
|
||||||
|
limit: Maximum number of chunks to return
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of relevant Chunk objects ordered by similarity
|
||||||
|
"""
|
||||||
|
if store._connection is None:
|
||||||
|
raise ValueError("Store connection is not available")
|
||||||
|
|
||||||
|
embedder = Embedder()
|
||||||
|
cursor = store._connection.cursor()
|
||||||
|
|
||||||
|
# Generate embedding for the query
|
||||||
|
query_embedding = await embedder.embed(query)
|
||||||
|
serialized_query_embedding = store.serialize_embedding(query_embedding)
|
||||||
|
|
||||||
|
# Search for similar chunks using sqlite-vec
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT c.id, c.document_id, c.content, c.metadata, distance
|
||||||
|
FROM chunk_embeddings
|
||||||
|
JOIN chunks c ON c.id = chunk_embeddings.chunk_id
|
||||||
|
WHERE embedding MATCH ? AND k = ?
|
||||||
|
ORDER BY distance
|
||||||
|
""",
|
||||||
|
(serialized_query_embedding, limit),
|
||||||
|
)
|
||||||
|
|
||||||
|
results = cursor.fetchall()
|
||||||
|
chunks = []
|
||||||
|
|
||||||
|
for row in results:
|
||||||
|
chunk_id, document_id, content, metadata_json, distance = row
|
||||||
|
metadata = json.loads(metadata_json) if metadata_json else {}
|
||||||
|
chunks.append(
|
||||||
|
Chunk(
|
||||||
|
id=chunk_id,
|
||||||
|
document_id=document_id,
|
||||||
|
content=content,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return chunks
|
||||||
89
tests/test_document.py
Normal file
89
tests/test_document.py
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
import pytest
|
||||||
|
from datasets import Dataset
|
||||||
|
|
||||||
|
from haiku.rag.store.engine import Store
|
||||||
|
from haiku.rag.store.models.document import Document
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_document_with_chunks(qa_corpus: Dataset):
|
||||||
|
"""Test creating a document with chunks from the qa_corpus."""
|
||||||
|
# Create an in-memory store
|
||||||
|
store = Store(":memory:")
|
||||||
|
|
||||||
|
# Get the first document from the corpus
|
||||||
|
first_doc = qa_corpus[0]
|
||||||
|
document_text = first_doc["document_extracted"]
|
||||||
|
|
||||||
|
# Create a Document instance
|
||||||
|
document = Document(
|
||||||
|
content=document_text,
|
||||||
|
metadata={"source": "qa_corpus", "topic": first_doc.get("document_topic", "")}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create the document with chunks in the database
|
||||||
|
created_document = await document.create_with_chunks(store)
|
||||||
|
|
||||||
|
# Verify the document was created
|
||||||
|
assert created_document.id is not None
|
||||||
|
assert created_document.content == document_text
|
||||||
|
|
||||||
|
# Check that chunks were created in the database
|
||||||
|
if store._connection is not None:
|
||||||
|
cursor = store._connection.cursor()
|
||||||
|
cursor.execute("SELECT COUNT(*) FROM chunks WHERE document_id = ?", (created_document.id,))
|
||||||
|
chunk_count = cursor.fetchone()[0]
|
||||||
|
|
||||||
|
assert chunk_count > 0
|
||||||
|
|
||||||
|
# Check that embeddings were created
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT COUNT(*) FROM chunk_embeddings ce
|
||||||
|
JOIN chunks c ON c.id = ce.chunk_id
|
||||||
|
WHERE c.document_id = ?
|
||||||
|
""", (created_document.id,))
|
||||||
|
embedding_count = cursor.fetchone()[0]
|
||||||
|
|
||||||
|
assert embedding_count == chunk_count
|
||||||
|
|
||||||
|
# Verify chunk metadata contains order information
|
||||||
|
cursor.execute("SELECT metadata FROM chunks WHERE document_id = ? ORDER BY id", (created_document.id,))
|
||||||
|
chunk_metadata = cursor.fetchall()
|
||||||
|
|
||||||
|
for i, (metadata_json,) in enumerate(chunk_metadata):
|
||||||
|
import json
|
||||||
|
metadata = json.loads(metadata_json)
|
||||||
|
assert "order" in metadata
|
||||||
|
assert metadata["order"] == i
|
||||||
|
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_chunks(qa_corpus: Dataset):
|
||||||
|
"""Test vector search functionality."""
|
||||||
|
# Create an in-memory store
|
||||||
|
store = Store(":memory:")
|
||||||
|
|
||||||
|
# Get the first document from the corpus
|
||||||
|
first_doc = qa_corpus[0]
|
||||||
|
document_text = first_doc["document_extracted"]
|
||||||
|
|
||||||
|
# Create and store a document
|
||||||
|
document = Document(
|
||||||
|
content=document_text,
|
||||||
|
metadata={"source": "qa_corpus"}
|
||||||
|
)
|
||||||
|
await document.create_with_chunks(store)
|
||||||
|
|
||||||
|
# Perform a search
|
||||||
|
search_query = "news" # Simple query
|
||||||
|
results = await Document.search_chunks(store, search_query, limit=3)
|
||||||
|
|
||||||
|
# Verify search results
|
||||||
|
assert len(results) <= 3
|
||||||
|
assert all(hasattr(chunk, "content") for chunk in results)
|
||||||
|
assert all(hasattr(chunk, "document_id") for chunk in results)
|
||||||
|
assert all(chunk.document_id == document.id for chunk in results)
|
||||||
|
|
||||||
|
store.close()
|
||||||
Loading…
Reference in a new issue