A hands-on learning repo for LangChain — from chat models and LCEL chains to retrieval-augmented generation (RAG) and tool-calling agents, one concept per script.
Tip
Perfect for: developers learning LangChain step by step — each script is a standalone reference for exactly one concept.
📋 Table of Contents
- Overview
- Requirements
- Quick Start
- Project Structure
- Example Breakdown
main.py– Foundationsrag-tooling.py– RAG shape, no external callsreal_rag.py– Production RAGingestion.py– Persistent RAG ingestion pipelinetool_calling.py– Agent with a strict system prompttool_calling_with_pydantic_schema.py– Structured outputtool_calling_manual.py–create_agent, unwrappedteach_tool_calling.py– TheToolCallshape, isolatedtool_calling_manual_pydantic.py– Schema-only binding, no@toolteach_react_agent.py– ReAct, minimalagent_loop_with_react_prompt.py– ReAct, real tool choicebackend/core.py– Retrieval tool, the read side ofingestion.py
- Suggested Learning Path
- Learnings
- Notes for Tomorrow
- Troubleshooting
- Resources
- License
Twelve standalone, runnable scripts, each isolating one concept:
| Script | Concept |
|---|---|
| main.py | Chat models, prompt templates, multi-turn conversation, first LCEL chain |
| rag-tooling.py | RAG chain shape using a fake in-memory retriever (no API calls for retrieval) |
| real_rag.py | Real RAG: text splitting, OpenAI embeddings, InMemoryVectorStore |
| ingestion.py | Persistent RAG ingestion pipeline: crawl real docs with Tavily, chunk, embed, and upsert into a Pinecone index |
| tool_calling.py | Tool-calling agent (create_agent) with a strict system prompt and free-form output |
| tool_calling_with_pydantic_schema.py | Same agent pattern, but with structured Pydantic output (response_format) |
| tool_calling_manual.py | The same job-search agent with create_agent removed — the tool-call loop written by hand |
| teach_tool_calling.py | Minimal, single-tool version of the manual loop — isolates what a ToolCall dict is for and why BaseTool.invoke() needs the whole thing, not just args |
| tool_calling_manual_pydantic.py | Same manual loop, but the tool is a bare Pydantic model, not @tool — isolates schema-only binding vs. execution |
| teach_react_agent.py | Minimal single-tool ReAct loop — Thought/Action/Action Input/Observation as a text format the model follows, no bind_tools involved |
| agent_loop_with_react_prompt.py | ReAct loop extended to a real choice between two tools — the prompt lists tool descriptions, and parsing has to recover both the chosen action and its input |
| backend/core.py | The read side of RAG: a retrieve_context tool that queries langchain-doc-index (built by ingestion.py) and returns both a serialized string and the raw Documents |
- Python 3.10+
- uv – fast Python package manager (replaces pip + venv)
- OpenAI API key – for chat models and embeddings
- Tavily API key – for web search in the agent examples, and web crawling in
ingestion.py - Pinecone API key – for the persistent vector index used by
ingestion.py
Dependency management and locking are handled via uv (see pyproject.toml / uv.lock).
git clone https://github.com/officialbidisha/Langchain-In-Depth.git
cd Langchain-In-Depthuv syncCreate a .env file in the project root:
OPENAI_API_KEY=sk-your-openai-key-here
TAVILY_API_KEY=your-tavily-api-key-here
PINECONE_API_KEY=your-pinecone-api-key-hereWarning
Never commit .env — it's already in .gitignore.
uv run python main.py # chat models, prompt templates, and LCEL basics
uv run python rag-tooling.py # RAG pipeline shape using a mocked retriever
uv run python real_rag.py # real RAG: text splitting, embeddings, vector search
uv run python ingestion.py # crawl docs.langchain.com, chunk, embed, concurrently upsert into Pinecone (async)
uv run python tool_calling.py # tool-calling job-search agent (Tavily search + extract)
uv run python tool_calling_with_pydantic_schema.py # same idea, with structured Pydantic output
uv run python tool_calling_manual.py # the create_agent loop, written by hand
uv run python teach_tool_calling.py # minimal manual loop: ToolCall shape, tool_call_id matching
uv run python tool_calling_manual_pydantic.py # same loop, tool defined via bare Pydantic model
uv run python teach_react_agent.py # minimal single-tool ReAct loop (Thought/Action/Observation)
uv run python agent_loop_with_react_prompt.py # ReAct loop choosing between two tools.
├── main.py # Chat models, prompts, multi-turn messages, LCEL chains
├── rag-tooling.py # RAG chain shape with a fake in-memory retriever
├── real_rag.py # RAG with real embeddings and vector store retrieval
├── ingestion.py # Crawl, chunk, embed, and upsert docs into a Pinecone index
├── tool_calling.py # Tool-calling agent: searches + verifies job postings
├── tool_calling_with_pydantic_schema.py # Tool-calling agent with structured (Pydantic) output
├── tool_calling_manual.py # Same agent, with create_agent's loop written by hand
├── teach_tool_calling.py # Minimal manual loop: ToolCall shape, tool_call_id matching
├── tool_calling_manual_pydantic.py # Same loop, tool schema as a bare Pydantic model (no @tool)
├── teach_react_agent.py # Minimal single-tool ReAct loop (Thought/Action/Observation)
├── agent_loop_with_react_prompt.py # ReAct loop with a real choice between two tools
├── backend/
│ ├── __init__.py
│ └── core.py # retrieve_context tool: queries langchain-doc-index (read side of ingestion.py)
├── pyproject.toml # Project metadata and dependencies
├── uv.lock # Locked dependency versions
└── .env # Local environment variables (not committed)
Each script below is collapsed by default — click a summary line to expand its walkthrough.
Caution
One script below has an open bug, expanded by default: ingestion.py (duplicate Pinecone upserts). See Notes for Tomorrow.
main.py – Foundations
- Initialize a chat model (
ChatOpenAI) - Send
SystemMessage/HumanMessage/AIMessagefor multi-turn conversation - Build a first LCEL chain:
prompt | model | parser
rag-tooling.py – RAG shape, no external calls
- A
FakeRetrieverstands in for a real vector store, so the chain's shape can be studied without hitting an API RunnableParallelruns two branches on the same input: one formats retrieved docs into context, the other passes the question through untouched- The prompt's
{context}/{question}placeholders must match theRunnableParalleldict keys exactly, or the chain raisesKeyErrorat runtime
real_rag.py – Production RAG
RecursiveCharacterTextSplitterchunks a raw text blobOpenAIEmbeddingsembeds each chunk intoInMemoryVectorStorevectorstore.as_retriever()performs real cosine-similarity search- Same
RunnableParallel → prompt → model → parsershape asrag-tooling.py, now backed by real retrieval
ingestion.py – Persistent RAG ingestion pipeline
- Five-stage pipeline: Crawl → Extract → Chunk → Embed → Upsert — a one-time/periodic batch job, entirely separate from any script that later queries the index
- The whole script is async now:
main()andindex_documents_async()are bothasync def, driven by a singleasyncio.run(main())at the bottom TavilyCrawl().ainvoke({...})does a BFS-style crawl from a root URL (max_depth= link-hops from root) and returns extracted page content per URL — no separate scraping code neededTavilyCrawlhashandle_tool_error=Trueby default: on an internal failure it returns the error as a plain string instead of raising, sores["results"]must be guarded withisinstance(res, dict)or a transient failure surfaces as a confusingTypeError: string indices must be integersinstead of the real error message- Not every crawled URL yields
raw_content(redirects, non-HTML assets, failed extraction can returnNone) —Document(page_content=None)raises a pydanticValidationError, so results are filtered withif doc.get("raw_content")before buildingDocuments RecursiveCharacterTextSplitter(chunk_size=4000, chunk_overlap=200)splits each page;split_documents(as opposed tosplit_text) also propagates each chunk'smetadata={"source": url}forward, which is what lets a later retriever cite which page an answer came fromOpenAIEmbeddings(model="text-embedding-3-large", dimensions=1024)—text-embedding-3-largenatively outputs 3072-dim vectors, but OpenAI v3 embedding models support Matryoshka-style truncation viadimensions=. It's pinned to 1024 here because Pinecone indexes have a fixed dimension set at creation time, andlangchain-doc-indexwas created as a Pinecone-integrated-inference index (bound tollama-text-embed-v2, 1024-dim) — the embedding model has to match the index, not the other way aroundembeddings' ownchunk_size=50is unrelated to the text splitter'schunk_size=4000— it's how many texts get batched into one OpenAI embeddings API call (throughput vs. rate-limit tradeoff), not a text length. Two different "chunk sizes" a few lines apart, easy to conflateindex_documents_async()splitssplit_docsintoUPSERT_BATCH_SIZE-sized groups, then fires off onevectorstore.aadd_documents(batch)task per group throughasyncio.gather(*tasks)— batches upsert concurrently instead of one at a time- An
asyncio.Semaphore(MAX_CONCURRENT_UPSERTS)wraps eachupsert_batchcoroutine, so all batches are created up front but onlyMAX_CONCURRENT_UPSERTS(3) run against Pinecone at once — the rest wait on the semaphore before starting - Each batch's
aadd_documentscall is wrapped in its owntry/except, so one failed batch is logged and skipped instead ofasyncio.gatheraborting every other in-flight batch ⚠️ Known bug: the batch-building loop isfor i in range(0, len(documents)): batches.append(documents[i:i+UPSERT_BATCH_SIZE])— missing theUPSERT_BATCH_SIZEstep argument that the pre-async version had (range(0, len(split_docs), UPSERT_BATCH_SIZE)). With the default step of 1,iadvances one document at a time, so batches are a 100-wide sliding window instead of 100 disjoint groups — every chunk gets upserted up to 100 times. See Notes for Tomorrow.- No explicit
idsare passed toaadd_documentseither, so re-running the script against the same URLs inserts duplicate vectors rather than upserting-in-place — not yet idempotent (see Notes for Tomorrow)
flowchart TD
A["TavilyCrawl().ainvoke(url, max_depth=2)"] --> B["Filter: keep docs\nwith raw_content"]
B --> C["RecursiveCharacterTextSplitter\nchunk_size=4000, overlap=200"]
C --> D["index_documents_async(split_docs)"]
D --> E["Group into UPSERT_BATCH_SIZE\nbatches ⚠️ see known bug above"]
E --> F["asyncio.Semaphore(MAX_CONCURRENT_UPSERTS=3)"]
F --> G1["vectorstore.aadd_documents(batch 1)"]
F --> G2["vectorstore.aadd_documents(batch 2)"]
F --> G3["vectorstore.aadd_documents(batch N)"]
G1 --> H["asyncio.gather(*tasks)"]
G2 --> H
G3 --> H
H --> I["Finished ingesting\ndocuments into Pinecone"]
tool_calling.py – Agent with a strict system prompt
- Two tools:
get_jobs(Tavily search) andget_job_details(Tavilyextract, to pull full posting content) create_agent(model, tools=[...], system_prompt=...)builds the agent- The system prompt encodes hard verification rules (explicit LangChain/LangGraph/LangSmith mention, explicit remote status, explicit India eligibility) so the agent can't hedge its way to a target count
- Output is the raw agent message trace, printed via
message.pretty_print()
tool_calling_with_pydantic_schema.py – Structured output
- Same job-search idea, single
get_new_jobs(query: str)tool with a free-form query response_format=AgentResponse(a Pydantic model) makescreate_agentreturnresult["structured_response"]as a typedAgentResponseinstead of free textJob/AgentResponsePydantic models define the exact shape (title, company, location, url) the agent must fill in
tool_calling_manual.py – create_agent, unwrapped
- Same two tools and equivalent rules as
tool_calling.py, but nocreate_agent—model.bind_tools(TOOLS)plus a hand-written loop - The loop: call the model → if
response.tool_callsis non-empty, run each tool and append aToolMessage(matched back viatool_call_id) → call the model again → repeat until no tool calls remain - A
MAX_STEPScap guards against the loop never terminating — the same kind of recursion limitcreate_agent/LangGraph applies internally - Shows exactly what
create_agentbuys you: this version has no built-inresponse_formatcoercion, streaming, or checkpointing
teach_tool_calling.py – The ToolCall shape, isolated
- One tool (
get_new_jobs), no system prompt engineering — strips away agent behavior to focus purely on the request/execute/respond mechanics - A single
HumanMessageproduced oneAIMessagewith 4tool_calls(Meta, Google, Salesforce, Uber) — the model batches independent lookups into one turn instead of asking one at a time BaseTool.invoke()branches on its input's shape (see_prep_run_argsinlangchain_core/tools/base.py): pass justtool_call["args"]and you get the tool's raw return value; pass the wholetool_calldict ({name, args, id, type}) and it unwrapsargsto run the function, then wraps the output in aToolMessagetagged withtool_call_id- That
tool_call_idtag is the only thing letting 4 parallel requests in one AI turn get matched back to their 4 correct answers once the message list is sent back to the model - The
for tool_call in result.tool_calls:loop that runs the tools is sequential in your code (each Tavily call blocks before the next starts) even though the model's request for them was parallel — "parallel in the API's eyes" and "concurrent in your code" are different things
tool_calling_manual_pydantic.py – Schema-only binding, no @tool
GetNewJobs(BaseModel)defines only the input schema (fields + docstring) — no function body, no execution logic attachedbind_tools([GetNewJobs])accepts the Pydantic class directly; the resultingresult.tool_callshas the identical{name, args, id, type}shape asteach_tool_calling.py's@tool-based version —bind_toolsdoesn't care what shape the schema came from- Because there's no
BaseTool, there's no.invoke()and no automaticToolMessagewrapping — both are written by hand: routetool_call["name"]to the realget_new_jobs()function, call it with**tool_call["args"], then manually buildToolMessage(content=..., tool_call_id=tool_call["id"]) get_new_jobs(**tool_args)(unpack into keyword args) vs.BaseTool.invoke(tool_call)(pass the whole dict) look similar but solve opposite problems — a plain function needs args spread across its named parameters;BaseTool.invoke()wants one object it can pattern-match on. Sametool_call/tool_args, opposite calling convention- Hit the same structural rule OpenAI's API enforces on every manual loop: a
ToolMessagemust directly follow an assistant message containing the matchingtool_callsid, or the API 400s with"messages with role 'tool' must be a response to a preceeding message with 'tool_calls'"— forgettingmessages.append(result)before appendingToolMessages breaks this
teach_react_agent.py – ReAct, minimal
- No
bind_tools, nocreate_agent— the model never sees a tool schema at all. Instead,PROMPTspells out a text format (Thought/Action/Action Input/Observation/Final Answer) and the model is expected to follow it literally model.invoke(prompt, stop=["\nObservation:"])cuts generation off right where the model would otherwise hallucinate its own search result — the real observation has to come from actually running Python code, not from the model's imagination- The loop: format
PROMPTwith the runningscratchpad→ invoke → if"Final Answer:"is in the reply, done → otherwise pullAction Inputout of the text, callsearch_jobsdirectly, and appendreply + Observationontoscratchpadso the next prompt includes the full history - Only one tool exists, so nothing is actually chosen yet — parsing only has to recover the input, never the action name
flowchart TD
A["Format PROMPT\n(question + scratchpad)"] --> B["model.invoke(prompt, stop=['\\nObservation:'])"]
B --> C{"'Final Answer:' in reply?"}
C -->|yes| D["Print final answer, stop"]
C -->|no| E["Parse Action + Action Input\nfrom reply text"]
E --> F["Look up Action in TOOLS_BY_NAME\nand call it with Action Input"]
F --> G["scratchpad += reply + Observation"]
G --> A
agent_loop_with_react_prompt.py – ReAct, real tool choice
- Two tools this time (
JobSearchTool,CompanyInfoTool), each a PydanticBaseModelused purely to hold a name + description — never bound viabind_tools, just read back into the prompt text so the model has something real to choose between TOOLS_BY_NAME(name → callable, for dispatch) andTOOLS_DESCRIPTIONS(name → description, for the prompt) are kept as two separate dicts — dispatch and "what the model gets told" are different concerns and don't belong in the same structuretool_names/toolsare built once via", ".join(...)/"\n".join(...)overTOOLS_DESCRIPTIONSand injected intoPROMPT's{tool_names}/{tools}placeholders — the model can only pick a tool it's actually been told about- Parsing got harder:
Action Inputis still the last field before the stop sequence, soreply.split("Action Input:")[-1].strip()works unchanged.Actionis not last —Action Input:follows it on the next line — so recovering just the action name needsreply.splitlines()+line.startswith("Action:")to isolate the right line first, then the same split/strip - Confirmed the model genuinely chooses: given "find a job at Salesforce, then tell me about Salesforce's culture," it called
JobSearchTool, judged the result insufficient, and switched toCompanyInfoToolon its own — driven only by the descriptions in the prompt - Hand-rolled ReAct has no built-in repetition guard the way
create_agent's LangGraph loop does — without one, the model sometimes retried an identicalAction/Action Inputpair for several steps, apologizing each time, and ran out of its step budget before reachingFinal Answer. Fixed with two independent changes: an explicit prompt line ("do not repeat an Action with the same Action Input you've already tried") and raising the step budget from 6 to 10
backend/core.py – Retrieval tool + RAG agent, the read side of ingestion.py
- The counterpart every prior script was missing:
ingestion.pyonly writes intolangchain-doc-index;backend/core.pyis the first script that reads from it - Same
OpenAIEmbeddings(model="text-embedding-3-large", dimensions=1024)config asingestion.py— has to match exactly, since a query vector built with different dimensions than the stored vectors can't be compared against them retrieve_contextis a@tool(response_format="content_and_artifact")— that flag makes the tool return a(content, artifact)tuple:contentis the plain string the model sees,artifactis the rawDocumentlist other code can use directly without re-parsing it back out of textrun_llm(query)builds the agent that actually uses the tool:create_agent(model, tools=[retrieve_context], system_prompt=...)— the tool is passed as the plain@tool-decorated function itself, not wrapped in anything;create_agentreads its schema straight off itcreate_agent(...).invoke({"messages": [...]})returns a state dict ({"messages": [...]}), not a{"content": ...}shape — the final answer isresponse["messages"][-1].content- Recovering the context, not just the answer:
response_format="content_and_artifact"means theDocuments retrieved mid-run don't just vanish once the model reads them — they're attached as.artifacton theToolMessagethe agent produces when it callsretrieve_context.run_llmwalksresponse["messages"], finds thatToolMessage, and reshapes its.artifactinto{"source": ..., "content": ...}dicts, sorun_llmreturns{"answer": ..., "context": [...]}instead of just the answer — the standard pattern for showing citations in a RAG app without a second, redundant retrieval call - Fixed bugs from the first draft:
docs.metadata→doc.metadata(was reading the whole list instead of the loop variable,AttributeErroron first call);"Cpntent:"→"Content:"typo;vectorstore.as_retriever(search_kwargs={"k": 4})(was passingkto.invoke(), config now lives with construction); a strayfrom langchain_core import systemimport (that attribute doesn't exist —ImportErroron load) and thesystem.prompt = ...assignment it enabled, replaced with a localsystem_promptstring;tools=[ToolMessage(tool=retrieve_context)]→tools=[retrieve_context](ToolMessageis a tool-result message type, not a tool-registration wrapper); droppedverbose=True, not a validcreate_agentkwarg if __name__ == "__main__":now actually callsrun_llmand prints both the answer and the list of source URLs — confirmed working end to end against the reallangchain-doc-indexindex
main.py– chat models, messages, first LCEL chainrag-tooling.py– learn the RAG chain shape with no API costreal_rag.py– swap the fake retriever for real embeddings + vector searchingestion.py– move from an in-memory RAG demo to a real, persistent pipeline: crawl real docs, chunk, embed, and upsert into a Pinecone index built to survive past one script runtool_calling.py– build an agent, see how much a system prompt has to constrain ittool_calling_with_pydantic_schema.py– same agent, structured output instead of free texttool_calling_manual.py– strip awaycreate_agentand write the tool-call loop yourself, to see what it was doingteach_tool_calling.py– same loop, minimal single-tool version — the one to reread when theToolCalldict /tool_call_idmechanics get fuzzytool_calling_manual_pydantic.py– same loop again, but the tool is a bare Pydantic model instead of@tool— see what binding buys you (a schema) vs. what it doesn't (execution)teach_react_agent.py– switch tracks entirely: nobind_tools, a text format the model follows instead — see the ReAct loop mechanics with just one toolagent_loop_with_react_prompt.py– same ReAct loop with two tools — see the model actually choose, and see what breaks (and how to fix it) once there's a real choice to parsebackend/core.py– close the loop oningestion.py: write the retrieval side of RAG as an actual tool an agent could call, and see whyresponse_format="content_and_artifact"needs a two-value return
Notes from building the tool-calling agents — things that weren't obvious going in.
Click to expand all 30 learnings
- Check the real SDK before wiring a tool to it.
tavily-python'sTavilyClientonly exposessearch,extract,crawl,map, etc. — there's noget_job_detailsmethod, so a tool calling it would fail at runtime the moment the agent tried to use it. Usetavily.extract(url)to pull full content from a specific posting instead. create_agent's real kwargs: it'sresponse_format(notresponse_schema) for structured output, and.invoke()expects{"messages": [...]}, not a bare string.- Agents under-explore by default. Given 10+ search results and a budget of 5 verified jobs,
gpt-4o-miniwould check just the first candidate, get one hit, and stop — instead of working through the list. The system prompt has to explicitly say "keep going through remaining candidates" and "search again with a different query if you're short," or the agent quits early. - Agents will rationalize instead of exclude. Once told to return "up to five," the model padded to five by hedging: labeling an on-site job "remote (but listed on-site)," or justifying weak evidence with "may include LangChain." Prompts need to state exclusion as the default and explicitly ban hedging language, or the LLM will bend the rules to hit the target count.
- Model choice affects rule-following, not just quality. Swapping
gpt-4o-mini→gpt-4omeasurably improved compliance (it correctly dropped an on-site job the mini model kept) — worth testing on a stricter model before assuming a prompt is broken. - Give tools a query the agent can actually use. A tool with a single
location: strparameter can't express "software engineer roles at Meta, Google, Salesforce, Uber" — the agent ended up calling it 4 times with the exact same input, unable to encode what it actually wanted. A free-formquery: strparameter let it compose the real intent in one call. - VS Code's Python interpreter is separate from the project's
.venv. Imports that work fine viauv runcan still fail in the IDE ifpython.defaultInterpreterPathisn't pointed at.venv/bin/python(see.vscode/settings.json). create_agent's tool loop, unwrapped, is just: bind tools → invoke → ifresponse.tool_calls, run each and append aToolMessagekeyed bytool_call_id→ invoke again → repeat until no tool calls remain (seetool_calling_manual.py). What it hides isresponse_formatcoercion, streaming, and the LangGraph state graph/checkpointing underneath.BaseTool.invoke()reads its input's shape to decide what to do. Pass a plain dict of args ({'query': ..., 'search_depth': ...}) and it runs the tool, returning the raw output. Pass a fullToolCalldict ({'name', 'args', 'id', 'type': 'tool_call'}) and it detects that shape, usesargsto run the function, but wraps the return value in aToolMessagecarryingtool_call_id. There's no separate "tool call mode" flag — it's purely inferred from the keys present in the input (teach_tool_calling.py)..invoke(**kwargs)isn't a thing forBaseTool. Its signature takes one positionalinput(str, dict, orToolCall) — unpacking args as.invoke(**tool_args)throwsTypeError: missing 1 required positional argument: 'input'. Pass the dict itself, not its unpacked keys.bind_toolsonly cares about producing validtool_calls— not what defined the schema. A bare Pydantic class (no@tool) binds and producestool_callswith the exact same{name, args, id, type}shape as a decorated function. Execution is always on you;@tooljust also hands you a convenient.invoke()to do it with (tool_calling_manual_pydantic.py).- Plain functions and
BaseToolwant opposite calling conventions for the same data. A raw Python function needs its args dict spread:get_new_jobs(**tool_args).BaseTool.invoke()wants the wholetool_calldict as one object so it can pattern-match on it internally. Passing a spread dict toinvoke(), or an unspread dict to a plain function, both fail — just with different errors (TypeErrorvs. a 422 from the API receiving a dict where a string was expected). - OpenAI's "tool must follow tool_calls" rule is a bracket-matching check. An assistant message with
tool_callsis the opening bracket for each call id; aToolMessageis the closing bracket. Every manual loop needsmessages.append(result)(the AIMessage) before appending anyToolMessages, or the API 400s on the first tool message it can't match to a preceding open. - ReAct doesn't need
bind_toolsat all. A plain text format (Thought/Action/Action Input/Observation) plus astopsequence plus string parsing reproduces the same request → execute → respond loop as the tool-calling scripts — just driven by string ops on raw text instead of a structuredtool_callslist. - The
stopsequence is what keeps a ReAct loop honest.model.invoke(prompt, stop=["\nObservation:"])cuts generation off before the model can write its own fake result. Skip it, and the model happily hallucinates a plausible-lookingObservation:instead of waiting for the real one. - Pydantic v2 model fields don't exist at the class level.
MyModel.some_fieldraisesAttributeErroreven whensome_fieldhas adefault=— fields are per-instance data, full stop. To read a field's default (or itsdescription=, type, etc.) without instantiating, useMyModel.model_fields["some_field"].default—model_fieldsis a dict ofFieldInfoobjects, always available on the class itself. ClassVaris the escape hatch for a genuinely class-level attribute on aBaseModel.description: ClassVar[str] = "..."tells Pydantic "don't manage this as a field" — it becomes a normal Python class attribute, readable directly off the class (MyModel.description), no instance ormodel_fieldslookup needed.- Where a field sits in the text format changes how you parse it. The last field before a
stopsequence (Action Input) can be extracted withreply.split(label)[-1].strip()since nothing trails it. A field with something after it on the next line (Action, followed byAction Input) needs isolating to its own line first (reply.splitlines()+line.startswith(label)) before the same split/strip — grabbing everything after the label directly would swallow the next field too. - Hand-rolled ReAct loops have no built-in repetition guard. Unlike
create_agent's LangGraph state machine, nothing stops the model from retrying an identicalAction/Action Inputpair forever if a search comes back thin — it'll apologize and retry until the step budget runs out. Needs to be handled explicitly: an instruction in the prompt against repeating a tried action, and/or a larger step budget as a backstop. - A vector index's dimension is fixed at creation — the embedding model has to match it, not the reverse.
langchain-doc-indexis a Pinecone integrated-inference index bound tollama-text-embed-v2(1024-dim). Upserting 3072-dim vectors fromtext-embedding-3-largefailed withPineconeApiException: Vector dimension 3072 does not match the dimension of the index 1024. Fixed non-destructively viaOpenAIEmbeddings(dimensions=1024)— OpenAI's v3 embedding models support Matryoshka-style truncation, so the model can be told to output a shorter vector instead of recreating the index. - Two unrelated things are both called "chunk size" a few lines apart in
ingestion.py.RecursiveCharacterTextSplitter(chunk_size=4000)is a character length per text chunk.OpenAIEmbeddings(chunk_size=50)is how many texts get batched into a single embeddings API call. Same word, orthogonal concerns — worth reading the surrounding code, not just the parameter name. - Tools with
handle_tool_error=True(the default on many built-in LangChain tools, includingTavilyCrawl) don't raise on failure — they return the error as a string. Indexing into that string like it's still the expected dict (res["results"]) produces a misleadingTypeError: string indices must be integersthat hides the real underlying error. Always safe to checkisinstance(res, dict)before trusting a tool's return shape. - A web crawl's results aren't uniformly usable. Some crawled URLs return
raw_content: None(redirects, non-HTML assets, failed extraction).Document(page_content=None)raises a pydanticValidationErrorsincepage_contentis a required string — filter withif doc.get("raw_content")before constructingDocuments. - macOS +
uv/non-system Python builds can fail HTTPS calls withCERTIFICATE_VERIFY_FAILEDbecause the interpreter'ssslmodule doesn't always pick up the OS's trusted CA bundle. Pointing bothSSL_CERT_FILEandREQUESTS_CA_BUNDLEatcertifi.where()(before any HTTPS-calling library is used) fixes it for both the stdlibsslmodule andrequests-based clients. - A
Semaphorelimits concurrency without limiting how many tasks get created.index_documents_asyncstill builds all batch coroutines and hands them toasyncio.gatherat once — theasyncio.Semaphore(MAX_CONCURRENT_UPSERTS)acquired inside each task is what actually throttles how many run against Pinecone simultaneously, by making the rest await entry into theasync with semaphore:block until a slot frees up. vectorstore.aadd_documents/TavilyCrawl.ainvokeare async twins of the sync methods used elsewhere in the repo (add_documentsinindex_documents_async's earlier sync version,.invoke()inreal_rag.py) — same behavior, just awaitable, which is what lets multiple batches actually overlap inasyncio.gatherinstead of blocking one at a time.- Wrapping each task's body in its own
try/exceptis what keepsasyncio.gatherresilient.asyncio.gatherby default re-raises the first exception it sees and cancels the rest of the group. Since eachupsert_batchalready catches and logs its own errors, no exception ever reachesgather, so one bad batch can't take down the others. range(start, stop)silently defaults to step1, not "reasonable." Rewriting a syncfor i in range(0, len(split_docs), UPSERT_BATCH_SIZE)loop into an async batch-builder and dropping the third argument (range(0, len(documents))) doesn't error — it just produces a sliding window of overlapping batches instead of disjoint ones, since nothing aboutrange's signature hints that the step was meaningful. Worth double-checking every argument survived a refactor, not just that the code runs (see the known bug iningestion.py's breakdown above, and Notes for Tomorrow).- A retriever's
kcan be set per-call, not just at construction.vectorstore.as_retriever(search_kwargs={"k": 4})is the pattern most examples show, butvectorstore.as_retriever().invoke(query, k=4)also works:BaseRetriever.invoke's source shows it forwards any kwargs it doesn't recognize (likek) into_get_relevant_documents, which merges them intosearch_kwargsbefore callingvectorstore.similarity_search(query, **kwargs). Confirmed by readinglangchain_core's actual source rather than assuming — it looked like it might be silently ignored, but it isn't (backend/core.py). @tool(response_format="content_and_artifact")changes what a tool is allowed to return. A normal@toolreturns one value, which becomes theToolMessage.content. Withresponse_format="content_and_artifact", the function must return a(content, artifact)tuple instead —contentis the string the model reads,artifactis the raw data (e.g. the actualDocumentlist) that other code can use directly without re-parsing it back out of the model-facing text (backend/core.py'sretrieve_context).create_agent(...).invoke(...)returns a state dict, not a chat response. The final answer isresponse["messages"][-1].content, notresponse["content"]orresponse["answer"]—create_agentbuilds a LangGraph state graph under the hood, so.invoke()returns whatever's in that graph's state, andmessagesis the key that holds the running conversation.- A tool's
artifact(fromresponse_format="content_and_artifact") survives the whole agent run — it rides along on theToolMessage. To recover it after.invoke(), walkresponse["messages"], filter forToolMessageinstances matching the tool'sname, and read.artifactoff each — this is howbackend/core.py'srun_llmreturns the actual retrievedDocuments (for citations) alongside the model's answer, without querying the vector store a second time.
A living list, not a daily log — check items off or remove them as they're done instead of re-queuing under a new date.
Important
Top priority: fix the ingestion.py batching bug before running it against the real Pinecone index again.
Foundations — do this first, it explains the "why" behind everything below
- Read the LangGraph basics —
create_agentis a thin wrapper around a LangGraphStateGraph. Understanding nodes/edges/state/checkpointing directly will explain why the loop, message list, stopping condition, and cross-run memory look the way they do.
ReAct track (agent_loop_with_react_prompt.py) — harden, then test, then compare
- Add a code-level repetition guard, not just a prompt instruction — track seen
(action, action_input)pairs and short-circuit or force a different approach if the model repeats one, instead of relying on it to follow the "don't repeat" instruction on its own. - Try a third tool — only two have ever been tested; confirm
TOOLS_BY_NAME/TOOLS_DESCRIPTIONS/PROMPTactually generalize past two, or find what breaks. - Compare the ReAct loop against the
bind_toolsloop head-to-head — same job-search task through bothagent_loop_with_react_prompt.pyandtool_calling_manual.py, and note the real tradeoffs (structuredtool_callsvs. fragile text parsing; prompt-format compliance vs. schema validation).
Tool-calling track
- Add structured output by hand to
tool_calling_manual.pyandtool_calling_manual_pydantic.py— once each loop ends, pass the final answer throughmodel.with_structured_output(AgentResponse)(or a second call) and compare to whatresponse_format=does automatically intool_calling_with_pydantic_schema.py.
Ingestion / RAG-on-real-docs track (ingestion.py)
- Fix the batch-building bug in
index_documents_async—for i in range(0, len(documents)):is missing theUPSERT_BATCH_SIZEstep (should berange(0, len(documents), UPSERT_BATCH_SIZE)), so batches are currently a 100-wide sliding window instead of disjoint groups, and every chunk gets upserted up to 100 times. Fix before running this against the real index again. - Make ingestion idempotent — pass explicit deterministic
ids(e.g. a hash of the URL, or URL + chunk index) toaadd_documentsso re-running the script upserts-in-place instead of inserting duplicate vectors for the same pages. This would also make the sliding-window bug above harmless (duplicate upserts of the same id just overwrite), but the loop should still be fixed since it's currently doing ~100x more work than intended. - Decide client-side vs. Pinecone-integrated embedding on purpose. Right now
ingestion.pycomputes embeddings client-side via OpenAI and just happens to match the index's dimension — worth deliberately comparing against using Pinecone's own hostedllama-text-embed-v2model directly (no OpenAI embedding call at all) to see the tradeoffs. - Tune
MAX_CONCURRENT_UPSERTS(currently 3) andUPSERT_BATCH_SIZE(currently 100) against real Pinecone/OpenAI rate limits once the batching bug above is fixed — these were picked as reasonable defaults, not measured.
Retrieval track (backend/core.py)
- Expose
run_llmover an API — it's a plain function right now; the natural next step is a thin FastAPI/Flask endpoint so the RAG agent can be called over HTTP instead of only from__main__. - Deduplicate
contextin the response — a singleretrieve_contextcall currently returns up to 4 chunks that can share the samesourceURL (see the smoke-test output: 4 sources, all the same page); worth grouping by source or deduping before returningcontextto a caller. - Handle multi-call queries — if the agent calls
retrieve_contextmore than once in a single run (e.g. it reformulates the query),run_llm's context-extraction loop already accumulates artifacts across every matchingToolMessage, but this hasn't been tested against a query that actually triggers a second call.
-
backend/core.py'sretrieve_contextbugs fixed and committed —doc.metadata(wasdocs.metadata), the"Cpntent:"typo, the bogusfrom langchain_core import systemimport,tools=[retrieve_context](was wrapped inToolMessage(...), which is a result type, not a registration wrapper), and the removedverbose=Truekwarg. - Built the RAG agent —
run_llm(query)now wiresretrieve_contextintocreate_agentand calls it fromif __name__ == "__main__":, closing the read side of the RAG loop end to end against the reallangchain-doc-indexindex. - Context/citations in the response —
run_llmpulls theDocumentartifact off the agent'sToolMessageand returns{"answer": ..., "context": [{"source", "content"}, ...]}instead of just the answer text. - Parallel tool calls — confirmed in
teach_tool_calling.py: oneHumanMessageproduced a singleAIMessagewith 4tool_calls(Meta/Google/Salesforce/Uber), and the manual loop resolved all 4 correctly, matched back viatool_call_id. - Stage 1 of
tool_calling_manual_pydantic.py— same manual bind-tools loop asteach_tool_calling.py, tool schema defined as a bare Pydantic model instead of via@tool; confirmedbind_toolsproduces an identicaltool_callsshape either way, and that execution/ToolMessage-wrapping has to be written by hand without aBaseTool. -
teach_react_agent.py— minimal single-tool ReAct loop built and confirmed working: text-format prompting +stop=["\nObservation:"]+ string parsing, nobind_toolsinvolved. -
agent_loop_with_react_prompt.py— extended ReAct to a real two-tool choice: dynamic{tools}/{tool_names}prompt building, two-stage parsing (action name + action input), Pydantic class-vs-instance field access (model_fields), and a repetition guard (prompt instruction + larger step budget) after the model got stuck re-trying the same search.
| Issue | Solution |
|---|---|
ModuleNotFoundError |
Run uv sync to ensure all dependencies are installed |
API key not found |
Check .env exists in the project root with OPENAI_API_KEY and TAVILY_API_KEY |
KeyError in a RAG chain |
Make sure the prompt's placeholders match the RunnableParallel dict keys exactly |
| Agent returns too few / hedged results | Tighten the system prompt's exclusion rules, or try a stronger model (see Learnings above) |
PineconeApiException: Vector dimension X does not match the dimension of the index Y |
Set dimensions=Y on OpenAIEmbeddings to match the target Pinecone index's fixed dimension (see ingestion.py) |
TypeError: string indices must be integers from a Tavily tool's result |
The tool caught an internal error and returned it as a string instead of raising (handle_tool_error=True) — check isinstance(res, dict) and print res to see the real error |
CERTIFICATE_VERIFY_FAILED on macOS |
Set SSL_CERT_FILE / REQUESTS_CA_BUNDLE to certifi.where() before making any HTTPS calls (see top of ingestion.py) |
Distributed under the terms of the LICENSE file included in this repository.