# Vector Store The VectorStore provides document storage and retrieval using PostgreSQL with pgvector for semantic search. ## Overview The VectorStore supports: - **Semantic Search**: Vector similarity using L2 distance - **Full-Text Search**: PostgreSQL tsvector keyword matching - **Hybrid Search**: Reciprocal Rank Fusion (RRF) combining both - **Text Chunking**: Intelligent text splitting with overlap ## Database Setup ### Migration ```elixir defmodule MyApp.Repo.Migrations.CreateRagChunks do use Ecto.Migration def up do execute "CREATE EXTENSION IF NOT EXISTS vector" create table(:rag_chunks) do add :content, :text, null: false add :source, :string add :embedding, :vector, size: 768 add :metadata, :map, default: %{} timestamps() end # Vector index for semantic search execute """ CREATE INDEX rag_chunks_embedding_idx ON rag_chunks USING ivfflat (embedding vector_l2_ops) WITH (lists = 100) """ # Full-text search index execute """ CREATE INDEX rag_chunks_content_search_idx ON rag_chunks USING gin (to_tsvector('english', content)) """ end def down do drop table(:rag_chunks) end end ``` ## Core API ### Building Chunks ```elixir alias Rag.VectorStore alias Rag.VectorStore.Chunk # Build single chunk chunk = VectorStore.build_chunk(%{ content: "Elixir is functional", source: "intro.md", metadata: %{category: "language"} }) # Build multiple chunks documents = [ %{content: "First document", source: "doc1.md"}, %{content: "Second document", source: "doc2.md"} ] chunks = VectorStore.build_chunks(documents) ``` ### Adding Embeddings ```elixir alias Rag.Router {:ok, router} = Router.new(providers: [:gemini]) # Generate embeddings contents = Enum.map(chunks, & &1.content) {:ok, embeddings, router} = Router.execute(router, :embeddings, contents, []) # Attach to chunks chunks_with_embeddings = VectorStore.add_embeddings(chunks, embeddings) ``` ### Storing Chunks ```elixir # Prepare for database insert prepared = Enum.map(chunks_with_embeddings, &VectorStore.prepare_for_insert/1) # Insert using your Repo {count, _} = Repo.insert_all(Chunk, prepared) ``` ### Using the Store Behaviour ```elixir alias Rag.VectorStore.Pgvector # Create store store = Pgvector.new(repo: MyApp.Repo) # Insert documents {:ok, count} = Pgvector.insert(store, [ %{content: "text", embedding: [0.1, 0.2, ...], source: "doc.md"} ]) # Search {:ok, results} = Pgvector.search(store, query_embedding, limit: 5) # results: [%{id: 1, content: "...", score: 0.99, source: "...", metadata: %{}}, ...] # Delete {:ok, count} = Pgvector.delete(store, [1, 2, 3]) # Get by IDs {:ok, documents} = Pgvector.get(store, [1, 2]) ``` ## Search Methods ### Semantic Search Vector similarity using L2 distance: ```elixir # Build query query = VectorStore.semantic_search_query(query_embedding, limit: 5) # Execute with your Repo results = Repo.all(query) # Returns: [%{id, content, source, metadata, distance}, ...] ``` **Scoring:** - Score = 1.0 - distance - Range: 0.0 (dissimilar) to 1.0 (identical) ### Full-Text Search PostgreSQL tsvector keyword matching: ```elixir # Build query query = VectorStore.fulltext_search_query("GenServer state", limit: 5) # Execute results = Repo.all(query) # Returns: [%{id, content, source, metadata, rank}, ...] ``` **Features:** - Multiple search terms combined with AND - English text search configuration - Results ordered by ts_rank ### Hybrid Search with RRF Combines semantic and full-text using Reciprocal Rank Fusion: ```elixir # Perform both searches semantic = Repo.all(VectorStore.semantic_search_query(embedding, limit: 20)) fulltext = Repo.all(VectorStore.fulltext_search_query(text, limit: 20)) # Combine with RRF hybrid_results = VectorStore.calculate_rrf_score(semantic, fulltext) # Returns: [%{rrf_score: 0.035, id, content, ...}, ...] ``` **RRF Formula:** ``` RRF(d) = Σ 1 / (k + rank(d)) where k = 60 ``` Documents appearing in both result sets get combined scores. ## Text Chunking Split large documents into smaller chunks with byte positions: ```elixir alias Rag.Chunker alias Rag.Chunker.Character long_text = File.read!("large_document.md") chunker = %Character{max_chars: 500, overlap: 50} chunks = Chunker.chunk(chunker, long_text) vector_chunks = VectorStore.from_chunker_chunks(chunks, "large_document.md") ``` This preserves `start_byte` and `end_byte` in metadata for source highlighting. If you only need raw strings, `VectorStore.chunk_text/2` remains available: ```elixir VectorStore.chunk_text(long_text, max_chars: 500, overlap: 50) ``` For more advanced chunking strategies, see the [Chunking Guide](chunking.md). ## Chunk Schema The `Rag.VectorStore.Chunk` Ecto schema: ```elixir schema "rag_chunks" do field :content, :string field :source, :string field :embedding, Pgvector.Ecto.Vector field :metadata, :map, default: %{} timestamps() end ``` ### API ```elixir # Create chunk struct chunk = Chunk.new(%{content: "text", source: "file.md"}) # Changeset for insert changeset = Chunk.changeset(chunk, %{content: "updated"}) # Embedding-only changeset changeset = Chunk.embedding_changeset(chunk, %{embedding: [0.1, 0.2, ...]}) # Convert to map map = Chunk.to_map(chunk) ``` ## Complete Workflow ```elixir alias Rag.Router alias Rag.VectorStore alias Rag.VectorStore.{Chunk, Pgvector} # 1. Initialize {:ok, router} = Router.new(providers: [:gemini]) store = Pgvector.new(repo: MyApp.Repo) # 2. Prepare documents documents = [ %{content: "Elixir is functional", source: "intro.md"}, %{content: "GenServer handles state", source: "otp.md"} ] # 3. Build chunks chunks = VectorStore.build_chunks(documents) # 4. Generate embeddings contents = Enum.map(chunks, & &1.content) {:ok, embeddings, router} = Router.execute(router, :embeddings, contents, []) # 5. Add embeddings to chunks chunks_with_embeddings = VectorStore.add_embeddings(chunks, embeddings) # 6. Store in database prepared = Enum.map(chunks_with_embeddings, &VectorStore.prepare_for_insert/1) Repo.insert_all(Chunk, prepared) # 7. Search query = "How do I manage state?" {:ok, [query_embedding], _} = Router.execute(router, :embeddings, [query], []) # Semantic search semantic_results = Repo.all( VectorStore.semantic_search_query(query_embedding, limit: 5) ) # Full-text search fulltext_results = Repo.all( VectorStore.fulltext_search_query(query, limit: 5) ) # Hybrid search hybrid_results = VectorStore.calculate_rrf_score(semantic_results, fulltext_results) ``` ## Best Practices 1. **Use appropriate chunk sizes** - 500-1000 chars works well for most use cases 2. **Add overlap** - 50-100 chars helps maintain context across chunks 3. **Include source metadata** - Helps with result attribution 4. **Create proper indexes** - IVFFlat for vectors, GIN for full-text 5. **Use hybrid search** - Combines semantic understanding with keyword precision ## Next Steps - [Embeddings](embeddings.md) - Learn about the embedding service - [Retrievers](retrievers.md) - Higher-level retrieval abstractions - [Chunking](chunking.md) - Advanced chunking strategies