I Tried Semantic Search Over 60,000 DevelopersIO Articles with NumPy and Bedrock, Without a Vector DB
This page has been translated by machine translation. View original
On February 13, 2026, the number of published blog articles on DevelopersIO finally surpassed the 60,000 milestone.

To make this body of 60,000 technical articles easier to use, we implemented semantic search using vector search, as well as related article search.

The distinguishing feature is that we implemented semantic search over 60,000 articles with 'NumPy' and 'Bedrock', without using a dedicated vector DB. In addition, we adopted a multi-model configuration that automatically switches between two embedding models, Bedrock Nova 2 and Titan V2, depending on the language of the query.
This article covers everything from the data generation pipeline to the search API implementation and the background of our technology choices.
Overall Architecture
The system is broadly divided into two parts.
The AWS services used are as follows.
| Component | Technology |
|---|---|
| Search API | ECS Fargate (FastAPI) |
| Vector data store | Amazon DSQL / Amazon S3 (NumPy format) |
| Article metadata store | Amazon DSQL / Amazon S3 (JSON format) |
| Search query vectorization | Bedrock Nova 2 Embeddings / Bedrock Titan V2 Embeddings |
| AI summary generation | Bedrock Claude Haiku 4.5 |
| CDN | CloudFront |
Data Generation Pipeline
Generating AI Summaries
First, AI generates Japanese and English summaries from the article text. These summaries are used as the input for vectorization.
| Item | Value |
|---|---|
| Model | Claude Haiku 4.5 (Bedrock) |
| Processing method | On-demand (incremental / regeneration) / Batch Inference (initial run) |
| Input | Contentful's content (body), excerpt, and title |
| Output | Japanese summary (summary), English summary (summary_en), English title (title_en) |
We processed approximately 60,000 articles, with a success rate of over 99.9%.
We used Bedrock Batch Inference for the initial bulk processing.
When selecting the summarization model, we ran a comparison against a higher-tier model.
| Model | Evaluation | Reason for adoption |
|---|---|---|
| Claude Sonnet 4.5 | Top-level summarization accuracy, grasp of complex context | Summary quality is top-level, but the difference from Haiku 4.5 was marginal for this summarization task |
| Claude Haiku 4.5 | Fast, inexpensive, sufficient quality | Comparison with Sonnet 4.5 showed that its technical term extraction and summarization accuracy are of sufficient quality to support the search foundation. Adopted |
Being able to achieve search accuracy equivalent to the higher-tier model with a faster and more economical model was a major cost benefit.
Selecting Embedding Models — A Multi-Model Configuration with Nova 2 and Titan V2
For vectorization, we adopted two Amazon Bedrock embedding models and use them selectively depending on the use case.
| Model | Model ID | Dimensions | Region | Use |
|---|---|---|---|---|
| Nova 2 Multimodal Embeddings | amazon.nova-2-multimodal-embeddings-v1:0 |
1024 | us-east-1 | Japanese search, related articles |
| Titan Text Embeddings V2 | amazon.titan-embed-text-v2:0 |
1024 | ap-northeast-1 | Short text / English search |
We have also compared the search accuracy of the embedding models.
There are three reasons we adopted Bedrock embedding models.
-
Quality: In the overall score, OSS models (such as multilingual-e5-large) ranked higher, but for our semantic search we prioritize the use case where users vaguely type in the "problem they're facing" in natural language, rather than keyword matching. After evaluating understanding of intent and robustness to variations in notation, we concluded that Nova 2 is the best fit.
-
Cost: The cost of Bedrock batch inference is on par with the actual cost of EC2 instances, and considering that no infrastructure management such as GPUs is required, we judged Bedrock to be advantageous.
-
Real-time vectorization at search time: Search terms need to be vectorized at request time, but hosting a model on EC2 or Lambda posed issues with model load time and cold starts. With the Bedrock API, this problem does not exist, and vectorization was possible with stable latency.
We adopted Titan V2 for English search because its embedding quality for English text is equal to or better than Nova 2, and since it is available in the Tokyo region, it is also advantageous in terms of latency.
※As of February 2026, Nova 2 Embeddings is offered in regions such as US East (N. Virginia), so for Japanese search we call the API cross-region. Titan V2 is available in the Tokyo region.
Automatic Model Selection by Query Language
The search API automatically switches the model and index it uses depending on the user's input query.
| Example query | Model | Index |
|---|---|---|
AWSのベストプラクティス (Japanese) |
Nova 2 | nova/ja-s |
AWS Lambda best practices (English, short text) |
Titan V2 | titan/en-s |
Queries containing Japanese text are processed with Nova 2, and queries consisting of English words or short phrases are processed with Titan V2.
At present we use just these two models, but going forward we plan to optimize search accuracy with a multi-model setup that includes Cohere Embed and others.
For related article search, regardless of language, we consistently use nova/en-s (the Nova index based on English summaries). Since every article has an English summary, similarity between articles can be calculated without language barriers.
The input text for vectorization is the title and summary combined (at index build time). At search time, the user's input query is vectorized as is.
Indexes are managed with a "model/summary type" key structure.
| Index key | Model | Input text | Use |
|---|---|---|---|
nova/ja-s |
Nova 2 | Japanese title + Japanese summary (short) | Japanese semantic search |
nova/en-s |
Nova 2 | English title + English summary | Related article search |
titan/en-s |
Titan V2 | English title + English summary | English semantic search |
# nova/ja-s (Japanese articles only, short summary)
text = f"{記事タイトル}\n{日本語要約(1文)}"
# nova/en-s, titan/en-s (English-translated summaries for all articles)
text = f"{英語タイトル}\n{英語要約}"
For nova/ja-s, used for Japanese search, the input is a short one-sentence summary (about 200 characters) rather than the three-line bulleted summary. This is because we observed that short summaries tend to be semantically closer to search queries and improve search accuracy.
Storing Vector Data — Three Divided Roles
Vectorized data is stored in three places depending on the purpose.
Bedrock (ベクトル化)
├── Amazon S3 Vectors (ベクトルDB、ネイティブベクトル検索用)
└── Amazon DSQL (blogpost_vectors テーブル、BYTEA形式)
↓ 定期エクスポート
S3 (NumPy形式: vectors.npy + index_map.json.gz)
| Storage | Role |
|---|---|
| Amazon S3 Vectors | For model evaluation, and also as a backup |
| Amazon DSQL | Source of truth for vector data (stored as BYTEA), diff management and multilingual management |
| Amazon S3 (NumPy) | High-speed read cache for API search (loaded into memory on ECS) |
Amazon S3 Vectors
Originally we planned to use S3 Vectors for semantic search as well. However, although it has filtering capabilities, the number of results retrievable per query is limited to a maximum of 100, which was insufficient for text search over an index of about 60,000 items.
Currently, we use S3 Vectors as an inexpensive environment for evaluating model performance and characteristics, and also as a backup of vector data.
Amazon DSQL
The source of truth (master) for vector data is stored in Amazon DSQL. It is responsible for diff management and for centrally managing indexes for multiple models and languages. DSQL's distributed write performance and strong multi-region consistency are highly reliable as a foundation for holding the source-of-truth data, making it ideal for this role.
Amazon DSQL is PostgreSQL-compatible, but currently does not support vector search extensions such as pgvector. Therefore, vector data is stored in binary form as the BYTEA type.
CREATE TABLE blogpost_vectors (
article_id VARCHAR(50) NOT NULL,
language VARCHAR(10) NOT NULL,
vector BYTEA NOT NULL, -- 1024 dimensions × float32 = 4,096 bytes
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
PRIMARY KEY (article_id, language)
);
A 1024-dimension float32 vector becomes 4,096 bytes of binary, which is far more storage-efficient than JSON format (numbers as text). Date management via updated_at enables differential export, and including the language column in the composite primary key allows indexes for multiple languages to be managed in the same table.
When exporting to NumPy, only the records whose updated_at has been updated since the previous export are fetched and diff-merged into the existing .npy matrix. There is no need to perform a full scan of about 60,000 records each time, and a diff of tens to hundreds of records completes in a few seconds.
The flow of the initial full export is as follows.
- Fetch all vectors per index from DSQL (batch size of 2,000 articles)
- Restore the BYTEA with
np.frombuffer(bytes, dtype=np.float32) - Build an (N, 1024) matrix with
np.stack() - Upload to S3 as
vectors.npy+index_map.json.gz
S3 NumPy Format
As the means to perform vector search and similarity calculation in Python, we adopted NumPy, Python's representative numerical computing library. We prepared an export process that converts the vector data stored in DSQL into NumPy arrays and saves them to S3 in .npy format.
my-vector-index/
├── nova/
│ ├── ja-s/
│ │ ├── vectors.npy # shape: (56910, 1024), float32, 223MB
│ │ └── index_map.json.gz # {"article_id": array_index, ...}
│ └── en-s/
│ ├── vectors.npy # shape: (60008, 1024), float32, 245MB
│ └── index_map.json.gz
└── titan/
└── en-s/
├── vectors.npy # shape: (60008, 1024), float32, 246MB
└── index_map.json.gz
On startup, the ECS task downloads the three indexes specified by the environment variable ACTIVE_INDEXES from S3, and makes them immediately searchable with np.load().
Implementing the Search API
Loading Data at Startup
When an ECS task starts, the vector data for the three indexes is preloaded from S3 into memory.
# ACTIVE_INDEXES = "nova/ja-s,nova/en-s,titan/en-s"
for index_key in active_indexes:
matrix = np.load(f'/tmp/vectors_{index_key}.npy', mmap_mode='r')
norms = np.linalg.norm(matrix, axis=1, keepdims=True)
normalized = (matrix / norms).astype(np.float32)
At startup, we compute the L2 norm of every vector and keep the normalized matrix. Because the inner product of normalized vectors equals cosine similarity, search needs only a simple matrix product (dot product).
| Index | Count | File size | Use |
|---|---|---|---|
| nova/ja-s | 56,910 | 223MB | Japanese search |
| nova/en-s | 60,008 | 245MB | Related articles |
| titan/en-s | 60,008 | 246MB | English search |
The three indexes together use about 750MB of memory.
Cosine Similarity Search
The search term is vectorized with the Bedrock model matching the query language (Nova 2 or Titan V2), and its similarity to all article vectors in the corresponding index is computed in one go using NumPy matrix operations.
q_norm = query_vector / np.linalg.norm(query_vector)
similarities = normalized @ q_norm # Compute similarity for all items with a single matrix product
distances = 1.0 - similarities # Convert to cosine distance (closer to 0 means more similar)
top_indices = np.argpartition(distances, top_k)[:top_k] # Quickly select the top K
top_indices = top_indices[np.argsort(distances[top_indices])] # Sort by distance
A matrix product of about 60,000 items × 1024 dimensions completes in a few milliseconds.
Compound Search — The Pre-Filter Approach
When specific conditions such as author or tag are specified, the target articles are narrowed down before the vector search, and similarity is computed only for that limited set of articles.
全記事ベクトル (60,000件)
↓ 著者・タグなどで事前フィルタ
対象記事ベクトル (例: 500件)
↓ 対象のみ抽出して行列積
類似度順にソート → 結果返却
In the approach common to typical vector DBs, where the top 100 results by similarity are retrieved and then filtered (post-filtering), the results narrowed by conditions can become extremely few (in the worst case, zero). By adopting our mechanism (pre-filtering), articles that would have been missed by post-filtering are reliably hit, and the amount of computation is also reduced in proportion to the number of targets.
Implementation sketch:
# 1. Extract article IDs matching the conditions from the metadata
candidate_ids = get_filtered_article_ids(author="<author_id>", tag="AWS")
# 2. Convert article IDs to row indexes of the NumPy array
indices = []
valid_ids = []
for cid in candidate_ids:
idx = id_to_idx.get(cid)
if idx is not None:
indices.append(idx)
valid_ids.append(cid)
# 3. Slice only the target rows and take the matrix product (avoids computing all items)
similarities = normalized[indices] @ q_norm
distances = 1.0 - similarities
# 4. Sort by distance and return results
order = np.argsort(distances)
results = [{'key': valid_ids[i], 'distance': float(distances[i])} for i in order]
Related Article Search
This is the search used to display "Related articles" on article detail pages. Instead of a search term, it uses the vector of the reference article.
| Search method | Description |
|---|---|
tags |
Similarity computed as AI tag overlap × relevance_score. With time decay |
vector |
Cosine similarity between the reference article vector and all article vectors |
hybrid |
Weighted average of the tag score (40%) + vector score (60%) |
author+vector |
Vector similarity computed only over articles by the same author |
Data Update Flow
Hourly Incremental Updates
Contentful (記事更新)
→ Webhook → Step Functions
→ AI要約生成 (Claude Haiku 4.5)
→ ベクトル化 (Nova 2 / Titan V2 Embeddings)
→ DSQL書き込み + S3 Vectors登録
→ NumPyエクスポート (S3)
Background Updates on ECS
Because ECS tasks may run for a long time, we built a mechanism to detect and reflect changes to S3 data in the background.
| Target | Check interval | Detection method |
|---|---|---|
| S3 cache (master-data) | 10 minutes | head_object LastModified |
| S3 cache (delta / incremental update data) | 10 minutes | head_object LastModified |
| Vector data (vectors.npy) | 1 hour | head_object LastModified |
Updates work by atomically swapping what the global variable references. Because the new data is fully built in the background before the reference is switched, requests in flight continue to safely use the old data, and from the next request onward the new data is referenced instantly.
Health Check
The health check (GET /health) verifies the load state of the cache and vector data.
// Normal (200)
{"status": "ok", "cache_ready": true, "vectors_ready": true}
// Not loaded (503)
{"status": "degraded", "cache_ready": false, "vectors_ready": false,
"missing_keys": ["meta_by_id", ...],
"missing_vectors": ["nova/ja-s", "nova/en-s", "titan/en-s"]}
On a 503 response, the ECS health check marks the task as unhealthy and it is removed from traffic.
Performance
| Metric | Value |
|---|---|
| Semantic search, Japanese (TTFB) | 380ms |
| Semantic search, English (TTFB) | 204ms |
| Related articles, vector search (TTFB) | 36ms |
| Memory usage | ~2,200MB (cache + vectors ×3) |
※Measured from CloudShell in the Tokyo region
Breaking down the Japanese search (380ms) shows that the cost of the NumPy computation itself is very small.
| Processing step | Time required (approx.) | Notes |
|---|---|---|
| Query vectorization (Bedrock API) | ~350ms | Includes RTT of the cross-region call |
| NumPy similarity calculation (60,000 items) | < 10ms | Strength of the in-memory matrix product |
| Metadata join and formatting | ~20ms | |
| Total (TTFB) | ~380ms |
English search (204ms) calls Titan V2 in the Tokyo region, so it has lower latency than Japanese search, which uses Nova 2 cross-region. Related article search (36ms) is fast because it consists solely of in-memory computation between already vectorized data. If Nova 2 becomes available in the Tokyo region, we can expect latency improvements for Japanese search as well.
Technical Points
Why We Don't Use a Dedicated Vector DB
DevelopersIO's search foundation has evolved step by step. In the past, we built and operated Amazon Elasticsearch Service (now OpenSearch), and there was also a period when we used a fully managed search SaaS.
Powerful search SaaS is very useful, but weighing our operational requirement of "frequent index rebuilds" against its "pricing based on record count", there was room to consider optimizing costs and simplifying operations.
Conversely, when designing this semantic search, we focused on the data scale: "about 60,000 articles (only a few hundred MB even at 1024 dimensions)". Rather than putting this into a dedicated vector DB or external SaaS, which tend to be costly, we judged that loading it as a NumPy array onto the memory of our existing Fargate was the simplest and most sensible choice.
The specific benefits of forgoing a dedicated DB are as follows.
- Index updates are simple and free: Simply download the new
.npyfrom S3 and swap a variable in Python to complete an atomic update (a switchover with no service downtime). There is no need to worry about extra costs from temporary record growth as with SaaS - Startup speed and scaling safety: Amazon Aurora DSQL is an innovative service in terms of highly scalable distributed writes and multi-region consistency, but for the use case of bulk-loading tens of thousands of records at ECS task startup, memory-mapping a single binary on S3 (
.npy) is faster with no protocol overhead. This prevents a full SELECT from concentrating on the database when tasks multiply through auto scaling, and lets DSQL focus on its true heavy responsibility of "holding source-of-truth data and managing diffs" - Sufficient performance: Even holding three indexes, it fits in memory (~750MB), and computing all 60,000 items with a NumPy matrix product completes in a few milliseconds. Doing matrix operations with the local CPU cache and NumPy cuts latency down to the limit compared to inserting a query (network RTT) to a dedicated DB
- Flexibility in model selection: Because indexes are managed with a "model/summary type" key structure, adding new embedding models or summary formats is easy. The indexes in use can be switched just by changing environment variables
- Minimal infrastructure operations: The only data stores are S3 and DSQL, which we already use. We avoided adding new operational components or points of failure for a dedicated vector DB
Even if the number of articles grows to hundreds of thousands in the future, we can handle it by boosting resources or splitting tasks per language, so future scalability is also sufficiently secured.
Reference: Estimated monthly cost
This is an estimate assuming 10,000 searches per day and 1,000 article updates per month.
| Component | Monthly |
|---|---|
| Fargate Spot, 2 tasks (1vCPU / 4GB) | ~$28 |
| Bedrock (search vectorization + AI summaries + article vectorization) | ~$6 |
| S3 / DSQL | ~$2 |
| Total | ~$36 |
Since it is assumed to piggyback on the existing frontend container, the additional cost is only about the memory increment, which works out to roughly $10 per month in practice.
Efficiency Gains from Pre-Filtering
Because S3 Vectors limits the number of retrieved results to a maximum of 100, we confirmed that filtering from 100 vector search results makes it hard to get the expected results when narrowing by author or tag.
We also considered splitting indexes by period or language, but while we could not expect a significant improvement in the performance ceiling, we were concerned it would complicate operations.
We therefore adopted an in-memory approach using NumPy on Fargate, using a pre-filter method that first narrows down target articles via metadata and then performs vector computation. Because matrix operations can be executed on all items, articles matching the filter conditions can be searched without omission, and the amount of computation is reduced in proportion to the number of candidates. The Fargate cost is also sufficiently inexpensive, making it a practical choice.
In-Memory Full-Text Search — Used Together with Semantic Search
After implementing semantic search, we received a request: "we want full-text search too." Because vector search is based on semantic similarity, searching for "Lambda", for example, does not necessarily bring articles with "Lambda" in the title to the top. In use cases where people search pinpoint for service or technology names, direct text matching is expected.
What came in handy here was the S3 cache data already loaded in memory. Article metadata (titles) and AI summaries (English titles, Japanese summaries, English summaries) are kept in memory for all articles for use as pre-filters in vector search. By repurposing this data as is for partial match search, we were able to achieve full-text search without additional infrastructure cost or DB queries.
Three-layer merge of search results:
When the search term is a single word, text match results are displayed above vector search results.
| Priority | Match target | Score | Sort |
|---|---|---|---|
| 1 | Title (Japanese / English) | 100 | Publication date, descending |
| 2 | Summary (Japanese / English) | 99 | Publication date, descending |
| 3 | Vector search (semantic) | Based on similarity | By similarity |
if _is_single_word(q):
title_ids, summary_ids = _text_match_ids(q, meta_by_id, summary_by_id, lang)
# Merge in the order: title matches → summary matches → vector search results
Partial match search over about 60,000 titles and summaries completes in roughly 5 to 15ms using Python's string in operator (implemented in C). No additional API calls to Bedrock are made either.
Reusing data already loaded in memory for another purpose is a simple approach, but being able to achieve "using semantic search and full-text search together" at no additional cost can be called a secondary benefit of this architecture.
Summary
This article introduced a configuration that achieves semantic search and related article search over about 60,000 articles without using a dedicated vector database.
We believe this architecture is a reasonable conclusion at this point in time, balancing "minimal management cost" and "execution performance" for the data scale. By combining the newly adopted DSQL with the existing S3 and ECS (Fargate), we achieved an additional monthly cost of around $36 and low latency of around 36ms for related article search.
With the multi-model configuration that automatically switches between Nova 2 and Titan V2 depending on the query language, we achieve optimal search accuracy for each language, while adding or switching indexes is completed just by changing environment variables.
For vectors of 1024 dimensions at the scale of hundreds of thousands of items, NumPy operations on Fargate alone scale sufficiently. Being able to build a practical search foundation by combining familiar AWS service building blocks, without adding new points of failure or fixed costs, was a major benefit.
We hope this helps when you build a practical search foundation.
On the other hand, at AWS re:Invent 2025 at the end of last year (DAT441, etc.), there were also mentions of native vector search and generative AI support as part of the future roadmap for Amazon Aurora DSQL.
We look forward to the evolution of DSQL, and once native vector search is released, we would like to try comparing it with this NumPy logic and checking the differences in cost and performance.
