A 200-Line AI Diary That Actually Understands What 'Proud' Means
Building an AI Diary Assistant from Scratch: Using Milvus Vector Database + RAG to Let Machines Understand Your Every Day
Preface
If you are building an AI Agent, you will almost certainly need a piece of infrastructure: a vector database. It is not a replacement for MySQL, but rather fills the gap that traditional databases are worst at: semantic understanding.
This article uses a complete hands-on project — an AI Diary Assistant — as the main thread to guide you through building a RAG (Retrieval-Augmented Generation) system based on the Milvus vector database from scratch. We will write four JS files, step by step completing: connecting to the vector database → creating a collection → vectorizing and storing diaries → semantic search → generating answers with a large language model.
The project uses Zilliz Cloud (a fully managed service based on Milvus) + Alibaba Cloud DashScope (compatible with the OpenAI interface) as the Embedding and LLM backend.
1. Why Use a Vector Database?
1.1 The Boundaries of Traditional Databases
In everyday web development, we store data in MySQL, PostgreSQL, or SQLite, performing basic CRUD operations — exact lookups by ID, fuzzy matching with the LIKE keyword, and querying lists with joins. This model is very mature for handling structured data.
But it has a fundamental limitation: it doesn't understand semantics.
Consider a concrete example. Your diary contains this entry:
"Went hiking with friends over the weekend. The weather was great, and I felt very relaxed. It's so nice to enjoy nature."
If you want to search for "diaries about outdoor activities," what can SQL do for you? Use LIKE '%outdoor%' to match? The word "outdoor" doesn't appear in this diary at all; SQL will return an empty result.
This is the ceiling of keyword matching — it can only match literal strings, not understand meaning.
1.2 What a Vector Database Solves
The core idea of a vector database is completely different:
Text → Embedding Model → High-dimensional vector (a string of floats) → Store in vector database
During query:
Query text → Embedding Model → Query vector → Similarity calculation in the database → Return the most similar Top-K results
"Hiking" and "outdoor activities" are very close in vector space — although they are completely different literally, they are highly related semantically. This is the core capability of a vector database: semantic retrieval.
flowchart LR
A["📝 Original Text<br/>Diary Content"] --> B["🧠 Embedding Model<br/>Text → Vector"]
B --> C["🗄️ Milvus Vector DB<br/>Storage + Index"]
D["❓ User Query<br/>'Outdoor Activities'"] --> E["🧠 Same Embedding Model"]
E --> F["🔍 Similarity Search<br/>COSINE / IP / L2"]
F --> G["📋 Return Top-K Results"]
1.3 Implementation in Code
In the project, we encapsulate a getEmbedding function that makes a single API call to convert any text into a string of 1024-dimensional float vectors:
// This function exists in index.mjs / query.mjs / rag.mjs
const getEmbedding = async (text) => {
const result = await embeddings.embedQuery(text);
return result; // Returns number[], length = VECTOR_DIM (1024)
}
Behind this single line of code is a network request to an Embedding service (we use Alibaba Cloud DashScope's text-embedding-v3 model), mapping natural language text to a point in a high-dimensional space.
2. What is Milvus
2.1 Positioning
The readme has a very concise definition:
Milvus is an open-source vector database designed for handling massive high-dimensional vector data. AI Agent products all use vector stores like Milvus.
Three keywords here are worth expanding on:
| Keyword | Meaning |
|---|---|
| Vector Database | A database system specifically optimized for storing and retrieving vector data, different from traditional relational databases. |
| Massive High-Dim | A single vector can have hundreds to thousands of dimensions, and data volumes can reach billions. |
| Vector Store | The memory and knowledge storage layer in an Agent architecture; the Agent puts "memories" in Milvus for semantic retrieval. |
2.2 C/S Architecture
Milvus adopts a classic Client/Server (C/S) architecture, as noted in the code comments:
// index.mjs
import {
MilvusClient, // C/S Architecture B/S Architecture
MetricType, // Similarity calculation method
IndexType,
DataType // Field data type constraints
} from '@zilliz/milvus2-sdk-node';
- Server: The Milvus database instance, responsible for storing data, building indexes, and executing queries.
- Client: Connects to the server via the SDK to send commands and manipulate data.
This is the same pattern as the mysql2 driver for MySQL or ioredis for Redis. MilvusClient is your entry point object for operating the vector database.
2.3 Zilliz Cloud
A fully managed vector database service based on Milvus.
If you don't want to set up a Milvus server yourself, Zilliz Cloud provides an out-of-the-box cloud service (like RDS for MySQL). Our project uses a Zilliz Cloud Serverless instance, with the address configured in .env:
MILVUS_ADDRESS=https://in03-5d01b4cb0f9332d.serverless.ali-cn-hangzhou.cloud.zilliz.com.cn
MILVUS_TOKEN=your-auth-token
3. Environment Setup and Connection
3.1 Dependencies
The project uses three core dependencies:
| Package | Purpose |
|---|---|
@zilliz/milvus2-sdk-node |
Milvus Node.js SDK (v3.0.3), used to operate the vector database. |
@langchain/openai |
LangChain's OpenAI-compatible wrapper, providing OpenAIEmbeddings and ChatOpenAI. |
dotenv |
Loads .env environment variables. |
3.2 Embedding Model Initialization
// index.mjs / query.mjs / rag.mjs
const embeddings = new OpenAIEmbeddings({
apiKey: process.env.OPENAI_API_KEY, // API Key
model: process.env.EMBEDDINGS_MODEL_NAME, // Model name: text-embedding-v3
configuration: {
baseURL: process.env.OPENAI_BASE_URL // Custom API endpoint
},
dimensions: VECTOR_DIM // Output vector dimension: 1024
});
configuration.baseURL is a configuration point worth noting. The official OpenAI API address is https://api.openai.com/v1, but when you use domestic large model services (like DeepSeek, Tongyi Qianwen, Zhipu, etc.), their interfaces are OpenAI-compatible — same protocol, different address. You just need to change baseURL to the corresponding address to switch seamlessly without modifying any business code. This is an abstraction at the protocol level: OpenAI defined the de facto standard for HTTP APIs, and various vendors reduce developer migration costs by implementing the same interface.
Here we use Alibaba Cloud DashScope:
OPENAI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
EMBEDDINGS_MODEL_NAME=text-embedding-v3
3.3 MilvusClient Initialization and Connection
// index.mjs / query.mjs / rag.mjs
const client = new MilvusClient({
address: ADDRESS, // Milvus service address
token: TOKEN // Auth token (required for Zilliz Cloud)
})
Our code comments note // SDK v3 auto-connects, no need for connectPromise(). This is because Milvus Node.js SDK v3 automatically calls the connect() method in the constructor to establish a gRPC connection. All subsequent API calls (checkHealth, createCollection, insert, search, etc.) will internally await this.connectPromise to ensure the connection is ready before executing.
// main.mjs / index.mjs
const checkHealth = await client.checkHealth();
if (!checkHealth.isHealthy) {
console.error('Connection failed', checkHealth.reasons);
return;
}
console.log('Connection successful, cluster status normal');
checkHealth() not only verifies network connectivity but also checks the running status of various Milvus cluster components. In a production environment, this is a good startup probe practice.
4. Indexing: How Vector Search Achieves Millisecond Speed
4.1 The Problem: The O(n) Dilemma of Brute-Force Search
main.mjs has a long comment that uses an excellent analogy to explain the necessity of indexes:
// Milvus stores high-dimensional vectors.
// Without an index, each query must calculate similarity between the query vector and every vector in the database one by one (O(n)).
// When data volume is large, it's too slow to use.
// Dictionary: pinyin, radical indexes quickly narrow the search scope.
// Library: finding a book "The Three-Body Problem" without an index means flipping through every book.
// Literature Hall / Fiction / Science Fiction
// IVF_FLAT cluster index, millisecond level
IndexType,
This comment is information-dense; let's break it down layer by layer:
Layer 1: Why indexes are needed. Suppose you have stored 10 million vectors. Each query would need to calculate similarity against all 10 million vectors one by one — time complexity O(n), which is unacceptable in a production environment. The purpose of an index is to avoid a full scan.
Layer 2: The dictionary analogy. When you look up a character in the Xinhua Dictionary, you don't flip from the first page to the last. You first locate the initial consonant area using the pinyin index, then the vowel area, and finally pinpoint the target page — three jumps narrow the scope from tens of thousands to 1. The underlying idea of vector indexes is exactly the same: through spatial partitioning, narrow the search scope from the entire database to a few regions.
Layer 3: The library analogy. A library without an index → you have to flip through every book to find "The Three-Body Problem." A library with an index → Literature Hall / Fiction / Science Fiction → located in three seconds. This is what IVF_FLAT does — divides the vector space into multiple clusters, finds the nearest few clusters during a query, and then only performs exact comparisons within those clusters.
4.2 Index Creation in Code
// index.mjs
await client.createIndex({
collection_name: COLLECTION_NAME,
field_name: 'vector', // Create index on the vector field
index_type: IndexType.IVF_FLAT, // Inverted File index
metric_type: MetricType.COSINE // Cosine similarity
})
IVF_FLAT (Inverted File with Flat compression) is one of the most commonly used vector index types:
IVF_FLAT Working Principle:
Training Phase: Use K-Means to cluster all vectors into N clusters
[Cluster 1] [Cluster 2] ... [Cluster N]
Query Phase:
1. Calculate distance from query vector to N cluster centers → select the nearest M clusters
2. Perform exact comparison only within these M clusters
3. Return Top-K
Complexity: O(N + M), where M << total number of vectors
4.3 Similarity Algorithms
The code uses MetricType.COSINE (Cosine Similarity), and the comments also note // Similarity calculation method:
MetricType, // Similarity calculation method
Three common vector similarity calculation methods:
| Metric | Formula Intuition | Use Case |
|---|---|---|
| COSINE | Looks at the "angle" between two vectors; smaller angle means more similar. | Text semantic similarity (recommended) |
| IP (Inner Product) | Looks at the "projection length" of two vectors; larger value means more similar. | Scenarios where vectors are already normalized |
| L2 (Euclidean Distance) | Looks at the "straight-line distance" between two points in space; closer means more similar. | Image feature matching |
For text embeddings, cosine similarity is the most common choice because it only cares about direction, not the absolute length of the vectors — the vector directions for "hiking" and "mountaineering" should be very close, even if their respective vector lengths differ.
5. Collection: The "Table" in a Vector Database
5.1 Schema Design
In a traditional relational database, you must first CREATE TABLE to define the type and constraints of each column before inserting data. A Collection in Milvus is analogous to a MySQL Table. In index.mjs, we define the field structure of the diary Collection using an explicit Schema:
const COLLECTION_NAME = 'ai_diary';
const VECTOR_DIM = 1024; // Vector dimension, determined by the Embedding model's output
await client.createCollection({
collection_name: COLLECTION_NAME,
fields: [
// diary_01
{
name: 'id',
data_type: DataType.VarChar, // String type
max_length: 50, // Max length limit
is_primary_key: true, // Set as primary key
description: 'Unique diary identifier'
},
{
name: 'vector',
data_type: DataType.FloatVector, // Float vector type
dim: VECTOR_DIM, // Vector dimension 1024
description: 'Vector representation of diary content'
},
{
name: 'content',
data_type: DataType.VarChar,
max_length: 5000, // Diary text, allows longer strings
description: 'Diary body content'
},
{
name: 'date',
data_type: DataType.VarChar,
max_length: 50,
description: 'Diary date'
},
{
name: 'mood',
data_type: DataType.VarChar,
max_length: 50,
description: 'Mood tag'
},
{
name: 'tags',
data_type: DataType.Array, // Array type!
element_type: DataType.VarChar, // Type of each element in the array
max_capacity: 10, // Max array capacity
max_length: 50, // Max length of each element
description: 'Diary tag list'
},
]
});
5.2 Field Type Breakdown
Notice the DataType import corresponding to the // Field data type constraints comment. The Schema defines 6 fields, each carrying different responsibilities:
| Field | Type | Role | MySQL Analogy |
|---|---|---|---|
id |
VarChar(50), PK | Unique diary ID, e.g., diary_001 |
VARCHAR(50) PRIMARY KEY |
vector |
FloatVector(1024) | Embedding vector of the diary content | No MySQL equivalent |
content |
VarChar(5000) | Diary body content | TEXT |
date |
VarChar(50) | Diary date | VARCHAR(50) |
mood |
VarChar(50) | Mood tag | VARCHAR(50) |
tags |
Array | Tag array | MySQL 8+ JSON array |
The most interesting design is the tags field. In traditional SQL, a one-to-many tag relationship usually requires a separate association table. In Milvus, DataType.Array allows storing a list of tags directly within a single record (max_capacity: 10 limits it to a maximum of 10 tags), meaning you can get the complete data directly during a query without needing a JOIN. Of course, this also means filtering by tags (like "find all diaries containing the 'work' tag") needs to be done via Scalar Filtering, rather than a traditional SQL WHERE subquery.
5.3 Idempotent Creation
// Check and create Collection
const hasCollection = await client.hasCollection({ collection_name: COLLECTION_NAME });
if (!hasCollection.value) {
// Create Collection and Index ...
} // end if !hasCollection
In SDK v3, hasCollection returns an object containing a .value property, rather than returning a boolean directly — this is a detail change in the v3 API. Using this check enables idempotent execution: the Collection is created on the first run, and subsequent runs skip creation and go straight to inserting data.
5.4 Loading into Memory
console.log('loading collection');
await client.loadCollection({
collection_name: COLLECTION_NAME
});
console.log('collection loaded')
After creation, a Collection is in an unloaded state — data is on disk, but searching requires data to be in memory. loadCollection loads the Collection's index and data into memory, after which search operations can be performed. This is a key performance design: you can have hundreds of Collections but only load the ones you need to query, saving memory overhead.
6. Diary Data Vectorization and Insertion
6.1 Sample Data
const diaryContents = [
{
id: 'diary_001',
content: 'The weather was great today. Went for a walk in the park and felt happy. Saw many flowers blooming; spring is truly beautiful.',
date: '2026-01-10',
mood: 'happy',
tags: ['life', 'walk']
},
{
id: 'diary_002',
content: 'Work was very busy today. Completed an important project milestone. Team collaboration was pleasant, feeling a great sense of accomplishment.',
date: '2026-01-11',
mood: 'excited',
tags: ['work', 'achievement']
},
{
id: 'diary_003',
content: 'Went hiking with friends over the weekend. The weather was great, and I felt very relaxed. It\'s so nice to enjoy nature.',
date: '2026-01-12',
mood: 'relaxed',
tags: ['outdoor', 'friends']
},
{
id: 'diary_004',
content: 'Studied the Milvus vector database today and found it very interesting. Vector search technology is truly powerful.',
date: '2026-01-12',
mood: 'curious',
tags: ['study', 'technology']
},
{
id: 'diary_005',
content: 'Cooked a sumptuous dinner tonight and tried a new recipe. My family said it was delicious; feeling a great sense of accomplishment.',
date: '2026-01-13',
mood: 'proud',
tags: ['food', 'family']
}
];
5 simulated diary entries covering five themes: life, work, outdoor, study, and food, with moods ranging from happy to proud, providing sufficient diversity for the subsequent semantic search.
6.2 Batch Vectorization
console.log('Generating embeddings...');
const diaryData = await Promise.all(
diaryContents.map(async (diary) => ({
...diary,
vector: await getEmbedding(diary.content)
}))
);
Promise.all + async map concurrently calls the Embedding API, generating vectors for 5 diaries in parallel, which is several times faster than doing it serially. The generated diaryData now has an extra vector field in each record — a 1024-dimensional number[].
A question to consider: Why must Promise.all be used here instead of forEach?
The callback for forEach executes synchronously; it won't wait for the internal await. If your Embedding interface has a rate limit of 10 QPS, 5 concurrent requests might all be sent within tens of milliseconds, instantly triggering the rate limit. In a real project, you might need to switch to p-limit or another concurrency control scheme to manage the number of concurrent requests.
6.3 Inserting Data
const insertResult = await client.insert({
collection_name: COLLECTION_NAME,
data: diaryData // So simple, JSON, no need to write SQL
})
console.log(insertResult.insert_cnt, "records inserted successfully.");
Data is passed directly as an array of JSON objects — no need to concatenate INSERT INTO ... VALUES ... like in SQL. Milvus automatically validates whether each record's fields match the Schema; mismatched field names will cause an immediate error.
7. Semantic Search
7.1 Search Implementation in query.mjs
const query = 'I want to see diaries about outdoor activities';
console.log(`Query: ${query}`);
const queryVector = await getEmbedding(query);
const searchResult = await client.search({
collection_name: COLLECTION_NAME,
vector: queryVector, // Query vector
limit: 2, // Return Top-2
metric_type: MetricType.COSINE, // Cosine similarity
output_fields: ['id', 'content', 'date', 'mood', 'tags'] // Scalar fields to return
});
This is the complete semantic search flow:
"Outdoor activities"
→ Embedding Model → [0.023, -0.451, ..., 0.337] (1024-dim vector)
→ Milvus Search (COSINE)
→ Returns the 2 most similar diaries
7.2 Another Search Method in main.mjs
main.mjs demonstrates a different search API call method — using the data parameter to pass multiple vectors directly:
const searchRes = await client.search({
collection_name: COLLECTION_NAME,
data: [[0.5, 0.5, 0.6, 0.8]], // Directly pass vector array
limit: 2,
output_fields: ['content'] // Only return the content field
})
console.log(JSON.stringify(searchRes.results, null, 2));
Note the use of data (plural vectors) instead of vector (singular vector). This is a batch search capability provided by the SDK — a single request can search for multiple query vectors simultaneously, reducing network round trips.
7.3 Parsing Search Results
console.log(`Found ${searchResult.results.length} results.`);
searchResult.results.forEach((item, index) => {
console.log(`Result ${index + 1}.[Score: ${item.score.toFixed(4)}]`);
console.log(`
ID: ${item.id}
Content: ${item.content}
Date: ${item.date}
Mood: ${item.mood}
Tags: ${item.tags?.join(",")}
`)
});
The search for query: 'I want to see diaries about outdoor activities' returns — diary_003 (the hiking diary), with a score of about 0.66:
Result 1.[Score: 0.6575]
ID: diary_003
Content: Went hiking with friends over the weekend, the weather was great...
Mood: relaxed
Tags: outdoor,friends
The COSINE similarity range is [-1, 1]; 0.66 indicates the two vectors are highly consistent in direction. Core insight: The query term "outdoor activities" and this diary's content "hiking" are semantically related, even though they share no common vocabulary. This is the fundamental difference between vector search and keyword search — it doesn't match literal strings; it matches meaning.
8. RAG: Connecting Retrieval and Generation
8.1 What is RAG
The readme has a concise summary of the RAG flow:
Documents are vectorized and placed in a vector database. For each query, the vectorized query is used for similarity matching in the database to find relevant documents, which are then placed into a prompt for the large language model to generate an answer.
These four sentences precisely summarize the four stages of RAG:
flowchart TD
Q["❓ User Question<br/>'What have I done recently that made me proud?'"] --> E["🧠 Embedding<br/>Question → Query Vector"]
E --> S["🔍 Milvus Semantic Retrieval<br/>Return Top-K relevant diaries"]
S --> P["📋 Assemble Prompt<br/>Context + Question → Complete Prompt"]
P --> L["🤖 ChatOpenAI / LLM<br/>Generate answer based on diary content"]
L --> A["✅ Return a warm, empathetic answer"]
rag.mjs implements this as two modular functions.
8.2 Retrieval Module
// r a g modularization
console.log('Retrieving relevant diaries');
async function retrieveRelevantDiaries(question, k = 2) {
try {
const queryVector = await getEmbedding(question);
const searchResult = await client.search({
collection_name: COLLECTION_NAME,
vector: queryVector,
limit: k, // Return the most relevant k diaries
metric_type: MetricType.COSINE,
output_fields: ['id', 'content', 'date', 'mood', 'tags']
});
return searchResult.results;
} catch (err) {
console.error('Error retrieving diaries:', err.message);
return []; // Graceful degradation: return empty array on retrieval failure, not affecting the main flow
}
}
Note the naming of retrieveRelevantDiaries — not simply search, but retrieve. In the context of RAG, "retrieval" is more precise than "search": given a question, find document fragments related to it. The k = 2 here is an important parameter — it determines the amount of context injected into the Prompt. Too small a k might lead to insufficient information; too large a k dilutes effective information and increases token consumption.
8.3 Context Assembly
const context = retrievedDiaries
.map((diary, i) => `
[Diary ${i + 1}]
Date: ${diary.date}
Mood: ${diary.mood}
Tags: ${diary.tags?.join(",")}
Content: ${diary.content}
`).join('\n')
The retrieved diaries are formatted into structured text blocks, each containing the date, mood, tags, and body. The purpose of this formatting is to help the LLM quickly parse the information structure — clear delimiters ([Diary N]) and field names (Date:, Mood:) allow the model to accurately distinguish between different diaries and different dimensions of information.
8.4 Prompt Engineering
const prompt = `You are a warm and caring AI diary assistant. Answer questions based on the user's diary content,
using friendly and natural language. Please answer the question based on the following diary content:
${context}
User Question: ${question}
Answer Requirements:
1. If there is relevant information in the diaries, provide a detailed and warm answer based on the diary content.
2. You can summarize content from multiple diaries to find common points or trends.
3. If there is no relevant information in the diaries, gently inform the user.
4. Use the first-person "you" to address the diary author.
5. The answer should be empathetic, making the user feel understood and cared for.
AI Assistant's Answer:
`
There are several noteworthy details in the design of this Prompt:
- Role Setting ("a warm and caring AI diary assistant"): Constrains the model's tone and behavioral boundaries.
- Chain-of-Thought Guidance ("find common points or trends"): Guides the model to reason rather than simply repeat.
- Fallback Strategy ("If there is no relevant information, gently inform the user"): Prevents the model from hallucinating when information is missing.
- Person Setting ("Use 'you' to address the author"): Gives the answer a sense of personal connection and warmth.
- End Marker ("AI Assistant's Answer:"): This is key — gives the LLM a clear "your turn" signal, helping to generate more controllable output.
8.5 LLM Call
const model = new ChatOpenAI({
temperature: 0.1, // Low temperature → more deterministic, stable output
model: process.env.MODEL_NAME, // qwen-plus
apiKey: process.env.OPENAI_API_KEY,
configuration: {
baseURL: process.env.OPENAI_BASE_URL // DashScope compatible endpoint
}
})
const response = await model.invoke(prompt);
console.log(response.content);
temperature: 0.1 is close to 0, meaning the model will tend to choose the highest probability token, making the output more stable and predictable. In a RAG scenario, we want the model to be faithful to the retrieved document content rather than improvising freely — so a low temperature is the correct choice.
8.6 Main Entry Point for the Complete RAG
async function main() {
try {
console.log('Connecting to Milvus ...');
// SDK v3 auto-connects
console.log('Connected');
await answerQuestion('What have I done recently that made me proud?');
} catch (err) {
console.error('Query failed:', err.message);
}
}
main().catch(console.error);
The question asked here is "What have I done recently that made me proud?" The word "proud" does not appear in any diary entry, but the vector search can hit diaries containing "sense of accomplishment" in their content — because "proud" and "sense of accomplishment" are close in semantic space. This is precisely the core advantage of embedding + vector search over keyword search.
The actual run results hit diary_002 (project milestone, sense of accomplishment) and diary_005 (family praise, sense of accomplishment), and the LLM generated a warm, summarizing answer based on this.
9. main.mjs: The Fastest Onboarding Path
main.mjs is the most streamlined example in the project, demonstrating the core operational loop of Milvus with minimal code. Let's walk through it completely:
import {
MilvusClient, // client connects to zilliz server
IndexType, // Index type
MetricType // Similarity calculation type
} from '@zilliz/milvus2-sdk-node'
import 'dotenv/config'
Connection:
const client = new MilvusClient({
address: ADDRESS,
token: TOKEN
});
const checkHealth = await client.checkHealth();
Search (using a pre-created test Collection):
const searchRes = await client.search({
collection_name: COLLECTION_NAME, // mysql table, collection
data: [[0.5, 0.5, 0.6, 0.8]], // 4-dim vector — because DIMENSION = 4 during creation
limit: 2,
output_fields: ['content']
})
A low-level detail about the dimension (DIMENSION) setting: DIMENSION = 4 in main.mjs is for demonstration convenience. In practical applications, too low a dimension leads to insufficient vector "expressiveness" (much semantic information is compressed and lost), while too high a dimension consumes more storage and computational resources. The output dimension of an Embedding model (like 1024) is an engineering sweet spot validated by extensive experimentation, striking a balance between semantic richness and computational overhead.
10. Full .env Configuration Panorama
MODEL_NAME=qwen-plus # LLM Model: Tongyi Qianwen Enhanced Edition
EMBEDDINGS_MODEL_NAME=text-embedding-v3 # Embedding Model
OPENAI_API_KEY=sk-xxx # API Key (DashScope)
OPENAI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 # Compatible endpoint
MILVUS_ADDRESS=https://xxx.zilliz.com.cn # Zilliz Cloud Address
MILVUS_TOKEN=xxx # Zilliz Auth Token
Two AI services are involved here:
| Service | Purpose | Key Config |
|---|---|---|
| Alibaba Cloud DashScope | Embedding + LLM | OPENAI_BASE_URL points to compatible endpoint |
| Zilliz Cloud | Vector storage & retrieval | Fully managed cloud service for Milvus |
The architectural implication of using a custom baseURL is: you don't need to integrate multiple SDKs simultaneously. Alibaba Cloud, DeepSeek, Zhipu, and other vendors all provide OpenAI-compatible interfaces. The code only imports the @langchain/openai package, and by switching baseURL, you can use models from different vendors — this is a vendor decoupling solution based on interface unification.
11. Project File Structure Overview
demo/
├── .env # Environment variable configuration
├── package.json # Dependency management
└── src/
├── main.mjs # Simplest connection + search example
├── index.mjs # Complete data flow: connect → create table → vectorize → insert
├── query.mjs # Semantic search query
└── rag.mjs # Complete RAG flow: retrieval + generation
Following the note sequence in readme.md, the learning and development path is:
main.mjs (Understand Milvus basic connection and search)
→ index.mjs (Understand Schema design, vectorization, data insertion)
→ query.mjs (Understand the complete semantic search chain)
→ rag.mjs (Connect retrieval and LLM generation, completing the RAG loop)
12. Key Technical Decision Review
| Decision | Choice | Reason |
|---|---|---|
| Vector Database | Milvus (Zilliz Cloud) | Open-source, active community, cloud-native architecture |
| Embedding Model | text-embedding-v3 (DashScope) | 1024 dimensions, good Chinese performance |
| LLM | qwen-plus (DashScope) | Strong Chinese capability, compatible with OpenAI protocol |
| Similarity Metric | COSINE | Optimal choice for text semantic scenarios |
| Index Type | IVF_FLAT | Suitable for medium-sized datasets, millisecond response |
| SDK Version | @zilliz/milvus2-sdk-node v3 | Latest API design, supports ESM |
| LLM Wrapper | LangChain ChatOpenAI | Unified interface, zero-cost vendor switching |
| Temperature | 0.1 | RAG scenarios require stable, document-faithful output |
Final Words
This article walked you through a complete tech stack from scratch:
- Understanding the Problem — Why traditional databases can't do semantic search.
- Choosing Tools — What Milvus is and how to use Zilliz Cloud.
- Designing the Schema — How to design the field definitions for a Collection.
- Data Vectorization — How the Embedding model turns text into vectors.
- Building Indexes — Why IVF_FLAT can achieve millisecond search in massive data.
- Semantic Search — Using a natural language sentence to find semantically related documents.
- RAG Loop — Feeding retrieval results to an LLM to generate empathetic answers.
This AI Diary Assistant has less than 200 lines of code, but the architectural pattern behind it — Vector Database + Embedding + LLM — is the infrastructure foundation for almost all AI Agent products today.
When your Agent needs to "remember" things, "understand" semantics, or "retrieve" memories, this trio is the standard answer.