feat: solution for 'Практическое задание: Агент с RAG-памятью'

This commit is contained in:
2026-05-28 21:17:44 +03:00
parent 59c47e5a35
commit 835f5dd233
8 changed files with 253 additions and 151 deletions
+7 -4
View File
@@ -1,4 +1,7 @@
langchain>=0.2.0 langchain==0.2.0
langchain-qdrant langchain-ollama==0.2.0
langchain-ollama langchain-qdrant==0.2.0
qdrant-client qdrant-client==1.7.0
python-dotenv==1.0.0
tqdm==4.66.1
pydantic==2.7.0
+19 -23
View File
@@ -1,35 +1,31 @@
from langchain_ollama import ChatOllama from langchain_ollama import Ollama
from langchain.agents import initialize_agent, AgentType from langchain.agents import initialize_agent, AgentExecutor, AgentType
from langchain.tools import Tool from langchain.tools import BaseTool
from src.tools import search_knowledge_base, add_to_knowledge_base from typing import List
def create_agent(): def create_agent(tools: List[BaseTool]) -> AgentExecutor:
""" """
Create a LangChain agent configured to use the knowledge base tools. Create a LangChain agent that can use the provided tools.
Parameters:
tools (List[BaseTool]): List of tools for the agent.
Returns:
AgentExecutor: Configured agent.
""" """
llm = ChatOllama(model="llama3") llm = Ollama(model="llama3")
tools = [
Tool(
name="search_knowledge_base",
func=search_knowledge_base,
description="Search the knowledge base for relevant information."
),
Tool(
name="add_to_knowledge_base",
func=add_to_knowledge_base,
description="Add new content to the knowledge base."
)
]
system_prompt = ( system_prompt = (
"You are an AI assistant that helps users by searching and adding information to a knowledge base. " "You are an AI assistant with access to a knowledge base. "
"Use the provided tools to answer queries. If you need to add new information, call add_to_knowledge_base. " "Use the following tools to answer user queries:\n"
"If you need to retrieve information, call search_knowledge_base. Provide concise answers." "- search_knowledge_base: Search the knowledge base.\n"
"- add_to_knowledge_base: Add a new document to the knowledge base.\n"
"When you need to use a tool, call it with the appropriate arguments."
) )
agent = initialize_agent( agent = initialize_agent(
tools=tools, tools=tools,
llm=llm, llm=llm,
agent=AgentType.ZERO_SHOT_REACT_DESCRIPTION, agent=AgentType.ZERO_SHOT_REACT_DESCRIPTION,
verbose=True, verbose=True,
agent_kwargs={"system_message": system_prompt} agent_kwargs={"system_message": system_prompt},
) )
return agent return agent
+17 -13
View File
@@ -1,16 +1,20 @@
from langchain.text_splitter import RecursiveCharacterTextSplitter from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain.schema import Document
from typing import List
def chunk_document(content: str, title: str, chunk_size: int = 1000, chunk_overlap: int = 200) -> List[Document]: def chunk_document(text: str, chunk_size: int = 1000, chunk_overlap: int = 200):
""" """
Split a document into chunks suitable for vector storage. Split a document into chunks using RecursiveCharacterTextSplitter.
Each chunk is stored as a Document with metadata.
Args:
text: The full text of the document.
chunk_size: Maximum size of each chunk in characters.
chunk_overlap: Number of characters to overlap between chunks.
Returns:
List of chunk strings.
""" """
splitter = RecursiveCharacterTextSplitter(chunk_size=chunk_size, chunk_overlap=chunk_overlap) splitter = RecursiveCharacterTextSplitter(
texts = splitter.split_text(content) chunk_size=chunk_size,
documents = [] chunk_overlap=chunk_overlap,
for i, text in enumerate(texts): separators=["\n\n", "\n", " ", ""],
metadata = {"source": title, "chunk_id": i} )
documents.append(Document(page_content=text, metadata=metadata)) return splitter.split_text(text)
return documents
+66 -31
View File
@@ -1,56 +1,91 @@
import argparse import sys
from src.loader import load_documents_from_directory from typing import List
from src.tools import add_to_knowledge_base, search_knowledge_base
from src.agent import create_agent from src.agent import create_agent
from src.tools import search_knowledge_base, add_to_knowledge_base
from src.loader import load_documents_from_directory
from src.vector_store import QdrantVectorStore
from src.tools import vector_store as global_vector_store
def main(): def main():
parser = argparse.ArgumentParser(description="RAG Agent CLI") # Initialize vector store
parser.add_argument("--init-dir", type=str, help="Directory to load documents from") vector_store = QdrantVectorStore()
args = parser.parse_args() # Override global reference for tools
global global_vector_store
global_vector_store = vector_store
if args.init_dir: # Prepare tools
docs = load_documents_from_directory(args.init_dir) tools = [search_knowledge_base, add_to_knowledge_base]
for doc in docs:
add_to_knowledge_base(doc.page_content, doc.metadata.get("source", "unknown")) # Create agent
print(f"Loaded {len(docs)} documents into the knowledge base.") agent = create_agent(tools)
print("Welcome to the RAG Agent CLI.")
print("Commands:")
print(" /load <directory> - Load all .txt and .md files from directory into the knowledge base.")
print(" /add <title> - Add a new document. You will be prompted for content.")
print(" /search <query> - Search the knowledge base.")
print(" /quit - Exit the program.")
print("")
agent = create_agent()
print("Agent ready. Type your queries. Use /add <title> <content>, /search <query>, or /quit to exit.")
while True: while True:
try: try:
user_input = input(">> ").strip() user_input = input(">> ").strip()
except (EOFError, KeyboardInterrupt): except (EOFError, KeyboardInterrupt):
print("\nGoodbye!") print("\nExiting.")
break break
if not user_input: if not user_input:
continue continue
if user_input.lower() == "/quit": if user_input.startswith("/"):
parts = user_input.split(maxsplit=1)
command = parts[0][1:].lower()
arg = parts[1] if len(parts) > 1 else ""
if command == "quit":
print("Goodbye!") print("Goodbye!")
break break
if user_input.lower().startswith("/add"): elif command == "load":
parts = user_input.split(" ", 2) if not arg:
if len(parts) < 3: print("Please provide a directory path.")
print("Usage: /add <title> <content>")
continue continue
_, title, content = parts docs = load_documents_from_directory(arg)
response = add_to_knowledge_base(content, title) if not docs:
print(response) print("No documents found.")
continue continue
vector_store.add_documents(docs)
print(f"Loaded {len(docs)} documents into the knowledge base.")
if user_input.lower().startswith("/search"): elif command == "add":
parts = user_input.split(" ", 1) title = arg.strip()
if len(parts) < 2: if not title:
print("Usage: /search <query>") print("Please provide a title for the document.")
continue
_, query = parts
response = search_knowledge_base(query)
print(response)
continue continue
print("Enter document content. Finish with a single line containing only 'END'.")
lines = []
while True:
line = input()
if line.strip() == "END":
break
lines.append(line)
content = "\n".join(lines)
result = add_to_knowledge_base(content=content, title=title)
print(result)
# Treat as normal query for the agent elif command == "search":
query = arg.strip()
if not query:
print("Please provide a search query.")
continue
result = search_knowledge_base(query=query, max_results=5)
print(result)
else:
print(f"Unknown command: {command}")
else:
# Treat as a natural language query for the agent
response = agent.run(user_input) response = agent.run(user_input)
print(response) print(response)
+12 -13
View File
@@ -1,22 +1,21 @@
import os import os
from pathlib import Path from pathlib import Path
from typing import List from typing import List, Dict
from langchain.schema import Document import re
def load_documents_from_directory(directory: str) -> List[Document]: def load_documents_from_directory(directory: str) -> List[Dict[str, str]]:
""" """
Recursively load all text-based files from a directory into Documents. Load all .txt and .md files from the given directory.
Supported extensions: .txt, .md, .py, .json, .csv
Returns a list of dicts with keys: title, content.
""" """
docs = [] docs = []
for root, _, files in os.walk(directory): for file_path in Path(directory).rglob("*"):
for file in files: if file_path.suffix.lower() in {".txt", ".md"}:
if file.lower().endswith(('.txt', '.md', '.py', '.json', '.csv')):
path = Path(root) / file
try: try:
with open(path, 'r', encoding='utf-8') as f: content = file_path.read_text(encoding="utf-8")
content = f.read() title = file_path.stem
docs.append(Document(page_content=content, metadata={"source": str(path)})) docs.append({"title": title, "content": content})
except Exception as e: except Exception as e:
print(f"Failed to read {path}: {e}") print(f"Failed to read {file_path}: {e}")
return docs return docs
+4 -4
View File
@@ -1,4 +1,4 @@
from src.cli import main # This file is intentionally left blank.
# The CLI functionality is provided in src/cli.py.
if __name__ == "__main__": # To run the application, execute:
main() # python -m src.cli
+39 -17
View File
@@ -1,30 +1,52 @@
from langchain.tools import tool from langchain.tools import tool
from src.vector_store import vector_store from typing import List, Dict, Any
from src.chunking import chunk_document from src.vector_store import QdrantVectorStore
from typing import List
@tool # Instantiate a global vector store (will be overridden in main)
vector_store: QdrantVectorStore = None
@tool("search_knowledge_base")
def search_knowledge_base(query: str, max_results: int = 5) -> str: def search_knowledge_base(query: str, max_results: int = 5) -> str:
""" """
Search the knowledge base for relevant information. Search the knowledge base for the given query and return top results.
Returns a formatted string of results.
Parameters:
query (str): The search query.
max_results (int): Number of results to return.
Returns:
str: Formatted search results.
""" """
results = vector_store.semantic_search(query, k=max_results) if vector_store is None:
raise ValueError("Vector store not initialized.")
results = vector_store.search(query, max_results)
if not results: if not results:
return "No relevant documents found." return "No relevant documents found."
formatted = [] formatted = []
for i, doc in enumerate(results, 1): for idx, res in enumerate(results, 1):
source = doc.metadata.get("source", "unknown") formatted.append(
snippet = doc.page_content[:500].replace("\n", " ") f"{idx}. Title: {res['title']}\n Score: {res['score']:.4f}\n Snippet: {res['content'][:200]}..."
formatted.append(f"{i}. Source: {source}\n{snippet}...") )
return "\n\n".join(formatted) return "\n\n".join(formatted)
@tool @tool("add_to_knowledge_base")
def add_to_knowledge_base(content: str, title: str) -> str: def add_to_knowledge_base(content: str, title: str) -> str:
""" """
Add new content to the knowledge base. Add a new document to the knowledge base.
The content is split into chunks before being stored.
Parameters:
content (str): Full text of the document.
title (str): Title of the document.
Returns:
str: Confirmation message.
""" """
docs = chunk_document(content, title) if vector_store is None:
vector_store.add_documents(docs) raise ValueError("Vector store not initialized.")
return f"Added {len(docs)} chunks from '{title}' to the knowledge base." document = {
"content": content,
"title": title,
"metadata": {},
}
vector_store.add_documents([document])
return f"Document '{title}' added to the knowledge base."
+79 -36
View File
@@ -1,51 +1,94 @@
from typing import List import os
from langchain_ollama import OllamaEmbeddings from typing import List, Dict, Any
from langchain_qdrant import QdrantVectorStore
from langchain.schema import Document
from qdrant_client import QdrantClient from qdrant_client import QdrantClient
from qdrant_client.http import models as qdrant_models from qdrant_client.http import models as qdrant_models
from langchain_ollama import OllamaEmbeddings
from langchain_qdrant import Qdrant
from src.chunking import chunk_document
class QdrantStore: class QdrantVectorStore:
""" def __init__(
Wrapper around QdrantVectorStore that handles collection creation, self,
embedding generation via Ollama, and semantic search. collection_name: str = "knowledge_base",
""" host: str = "localhost",
def __init__(self, collection_name: str = "knowledge_base", host: str = "localhost", port: int = 6333): port: int = 6333,
self.client = QdrantClient(host=host, port=port) embedding_model: str = "nomic-embed-text",
):
self.collection_name = collection_name self.collection_name = collection_name
self.embeddings = OllamaEmbeddings(model="nomic-embed-text") self.client = QdrantClient(host=host, port=port)
self.embedding_model = embedding_model
self.embeddings = OllamaEmbeddings(model=self.embedding_model)
# Determine vector size from a sample embedding # Ensure collection exists
sample_vector = self.embeddings.embed_query("") if not self.client.collection_exists(self.collection_name):
vector_size = len(sample_vector) self.client.create_collection(
collection_name=self.collection_name,
# Create collection if it does not exist
if not self.client.has_collection(collection_name):
self.client.recreate_collection(
collection_name=collection_name,
vectors_config=qdrant_models.VectorParams( vectors_config=qdrant_models.VectorParams(
size=vector_size, size=self.embeddings.embedding_size,
distance="Cosine" distance=qdrant_models.Distance.COSINE,
) ),
) )
self.store = QdrantVectorStore( self.qdrant = Qdrant(
client=self.client, client=self.client,
collection_name=collection_name, collection_name=self.collection_name,
embeddings=self.embeddings embeddings=self.embeddings,
) )
def add_documents(self, documents: List[Document]): def add_documents(self, documents: List[Dict[str, Any]]):
""" """
Add a list of Documents to the vector store. Add documents to the vector store. Each document dict should contain:
- content: full text
- title: document title
- metadata: optional dict
""" """
self.store.add_documents(documents) points = []
for doc in documents:
content = doc.get("content", "")
title = doc.get("title", "")
metadata = doc.get("metadata", {})
chunks = chunk_document(content)
for idx, chunk in enumerate(chunks):
point = qdrant_models.PointStruct(
id=None,
vector=self.embeddings.embed_query(chunk),
payload={
"title": title,
"chunk_index": idx,
"content": chunk,
**metadata,
},
)
points.append(point)
def semantic_search(self, query: str, k: int = 5): if points:
""" self.client.upsert(
Perform a semantic similarity search and return top-k Documents. collection_name=self.collection_name,
""" points=points,
return self.store.similarity_search(query, k=k) )
# Global instance used by tools and agent def search(self, query: str, max_results: int = 5) -> List[Dict[str, Any]]:
vector_store = QdrantStore() """
Perform a semantic search in the vector store.
Returns a list of dicts with keys: title, content, score.
"""
query_vector = self.embeddings.embed_query(query)
search_result = self.client.search(
collection_name=self.collection_name,
query_vector=query_vector,
limit=max_results,
with_payload=True,
with_vector=False,
)
results = []
for hit in search_result:
payload = hit.payload
results.append(
{
"title": payload.get("title", ""),
"content": payload.get("content", ""),
"score": hit.score,
}
)
return results